뉴스피드 프로젝트
@ValidPassword 오작동 문제
문제 상황
@Constraint(validatedBy = PasswordValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidPassword {
String message() default "비밀번호는 8~20자이며, 대소문자/숫자/특수문자를 최소 1글자씩 포함해야 합니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
- 비밀번호 유효성 검증을 위한 커스텀 어노테이션 @ValidPassword와 검증을 수행할 PasswordValidator 클래스 정의
- 아래와 같이 레코드 기반 요청 DTO에 어노테이션을 적용
public record SignupRequest(
...
@NotBlank(message = "비밀번호는 필수 입력사항입니다.")
@ValidPassword
String password
) {}
- 하지만 실제 요청 시 @ValidPassword가 동작하지 않는 문제 발생
원인 분석
- 레코드 클래스는 모든 필드를 private final로 선언하고, 컴파일러가 자동으로 getter 메서드 생성
- Bean Validation은 객체의 값을 검증할 때 어디에 어노테이션이 붙었는지에 따라 접근 방식을 결정
| 접근 방식 | 설명 |
| 프로퍼티 접근 | getter 메서드에 붙은 어노테이션을 기준으로 검증 수행 (getPassword() 등) |
| 필드 접근 | 필드 자체에 붙은 어노테이션을 기준으로 검증 수행 (private String password) |
- 레코드 클래스는 기본적으로 프로퍼티 접근 방식이 우선됨
- → 즉, 클래스에 getter가 존재하면 Bean Validation은 그 메서드에 어노테이션이 붙었는지를 먼저 확인
public record SignupRequest(String password) {
// 아래와 같은 구조를 자동 생성
public String password() { return password; }
}
- 필드에 어노테이션을 붙여줬지만 Bean Validation은 기본적으로 getter 메서드에 붙은 어노테이션을 우선적으로 인식하기 때문에, 우리가 FIELD에만 어노테이션을 붙이면 일부 구현체에서는 이를 무시할 수 있음
- 반면,@NotBlank, @Email 같은 기본 어노테이션은 FIELD, METHOD, PARAMETER 등 다양한 위치에 적용 가능하도록 설정되어 있어 레코드에서도 정상 작동함을 확인
해결 방법
@Constraint(validatedBy = PasswordValidator.class)
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidPassword {
String message() default "비밀번호는 8~20자이며, 대소문자/숫자/특수문자를 최소 1글자씩 포함해야 합니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
- @Target에 FIELD, METHOD, PARAMETER를 모두 지정함으로써 다양한 위치에 어노테이션을 적용할 수 있게 되어 호환성과 유연성이 향상됨
JPA에서 객체 vs ID 기반 조회
객체 기반 조회의 문제점
- 영속성 컨텍스트에 의존: 넘기는 객체가 영속 상태가 아닐 경우, 예기치 않은 동작이나 오류 발생 가능
- 불필요한 내부 처리: JPA가 객체를 분석하고 ID를 추출하는 추가 작업이 들어감
- 도메인 결합도 증가: Repository가 특정 도메인 객체에 의존하게 되어 유지보수 어려움
ID 기반 조회의 장점
- 성능: JPA가 바로 쿼리 실행 가능, 내부 처리 최소화
- 안정성: 영속 상태 여부와 무관하게 일관된 동작 보장
- 결합도: 도메인 간 의존성 낮아짐, 테스트 및 유지보수 용이
- 명확성: 파라미터가 단순하고 의도가 분명해짐
리팩토링 예시
public interface FollowRepository extends JpaRepository<Follow, Long> {
Optional<Follow> findByUserAndFollowingUser(User user, User followingUser);
List<Follow> findAllByUser(User user);
List<Follow> findAllByFollowingUser(User followingUser);
}
- 변경 전: User 객체 직접 넘김
public interface FollowRepository extends JpaRepository<Follow, Long> {
boolean existsByFollowingUserIdAndUserId(Long followingUserId, Long userId);
void deleteByFollowingUserIdAndUserId(Long followingUserId, Long userId);
List<Follow> findAllByUserId(Long userId);
List<Follow> findAllByFollowingUserId(Long followingUserId);
}
- 변경 후: ID 기반으로 조회
- 영속성 컨텍스트 의존 X / 쿼리 성능 향상 / 테스트 코드 작성 간편
※ 존재 여부 확인엔 existsBy...가 가장 효율적!
- Optional<>로 조회 후 isPresent() 체크하는 것보다 가볍고 빠름
마치며
오늘은 대부분의 시간을 오류를 수정하고, 더 나은 방향에 대해 논의하며 리팩토링하는 데 집중했다. 하나의 프로젝트를 여러 팀원들과 분담해 진행하다 보니, 각자가 충분한 테스트 없이 코드를 작성한 부분이 문제로 이어졌다. 전체적인 구조나 흐름에는 큰 문제가 없었지만, 세부적으로는 각 API가 명세서에 맞게 응답하고 있는지, 실제 계획과 목적대로 작동하고 있는지 등 다양한 부분에서 예상치 못한 오류들이 발생했다. 이 과정을 통해 느낀 점은, 개발을 시작하기 전에 보다 체계적인 절차와 명확한 기준을 세우는 것이 얼마나 중요한지 다시금 깨달았다는 것이다. 단순히 기능을 구현하는 것에 그치지 않고, 명세서에 기반한 테스트와 검증, 그리고 팀원 간의 긴밀한 커뮤니케이션이 프로젝트의 완성도를 좌우한다는 것을 실감했다. 오늘 겪은 시행착오를 통해 배운 것들이 참 많다. 이 경험들을 통해 더 성장해야할 것이다.