Spring Boot 회원가입 구현 삽질 정리: Validation, Enum 파싱, DTO↔Entity 책임, DataIntegrityViolationException, 전역 예외 처리까지

오병택·2026년 1월 28일
post-thumbnail

회원가입 API를 만들면서도 생각보다 고민할 지점이 많았다. 오늘은 그 과정에서 직접 물어보고 정리한 내용들(코드리뷰 포함)을 한 글로 묶어둔다.

1) CodeRabbit 리뷰 해석: “User.of() 역할”과 “UserRole.of(String) null 처리”

1-1. UserRole.of(String)에서 try-catch로 NPE 잡지 말라는 의미

기존 구현:

public static UserRole of(String role) {
  try {
    return UserRole.valueOf(role.trim().toUpperCase());
  } catch (IllegalArgumentException | NullPointerException e) {
    throw new BaseException(ErrorCode.USER_ROLE_BAD_REQUEST);
  }
}

리뷰 핵심은 “NullPointerException을 catch로 처리하지 말고, 입력 정규화(trim/upper) 전에 명시적으로 null 체크를 하자”였다.

권장 형태(의도 명확):

if (role == null) throw ...
String normalized = role.trim().toUpperCase();
if (normalized.isEmpty()) throw ...
try { return UserRole.valueOf(normalized); }
catch (IllegalArgumentException e) { throw ...; }

포인트: NPE를 정상 흐름으로 쓰면 디버깅/의도 파악이 어려워짐.

1-2. User.of() 관련: “SignupRequest에서 직접 User를 생성하는 로직이 서비스에 위임”

내 코드에서 서비스는:

String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
User user = User.of(signupRequest, encodedPassword);

User.of(SignupRequest, ...) 처럼 엔티티가 DTO를 직접 받으면,
도메인이 요청 DTO에 의존하게 된다(레이어 결합).

그래서 리뷰어 의도는 보통 이런 방향:

  • 엔티티 팩토리는 DTO 대신 필요 값만 받게

  • 서비스는 값을 꺼내 “조립”만

User user = User.of(email, encodedPw, nickname, phone, role);

“생성 책임을 명확히” = 엔티티가 지켜야 할 규칙(기본값/불변조건)은 엔티티가, DTO 파싱/조립은 서비스가.

2) “@NotBlank 붙였는데도 null/empty 체크를 또 해야 하나?”

DTO에:

@NotBlank private String role;

컨트롤러에:

public ResponseEntity<?> signup(@Valid @RequestBody SignupRequest req)

이 조합이면 HTTP 요청 경로에서는 role이 null/blank로 서비스까지 들어오지 않는다(검증 실패로 400).

그럼에도 UserRole.of()에서 null/blank 방어를 남기는 이유:

  • 서비스가 컨트롤러를 통하지 않고 호출될 수 있음(테스트/다른 내부 호출/미래 기능)

  • 도메인 메서드는 더 범용적으로 안전한 편이 좋음

정리:

  • API 경계(@Valid)는 “사용자 입력” 방어

  • 도메인(UserRole.of)는 “내부에서도 안전” 방어(선택이지만 추천)

3) empty / blank / null 차이 + NotNull/NotEmpty/NotBlank 정리

3-1. 문자열의 차이

null: 값 자체 없음

"": empty(길이 0)

" ": blank(공백만, trim 하면 empty)

3-2. 어노테이션 차이

4) @Valid vs @Validated 차이(“컨트롤러/서비스 위치”가 핵심이 아님)

  • @Valid(jakarta): 표준 Bean Validation 트리거(요청 DTO 검증에 흔함)

  • @Validated(Spring):

    • 메서드 파라미터 검증(Method Validation) 활성화에 자주 사용
    • Validation Groups 지원

서비스 메서드 파라미터에 @NotBlank 같은 걸 걸고 자동 검증하려면
클래스에 @Validated가 필요할 때가 많다.

5) DataIntegrityViolationException & ConstraintViolationException 정확히 뭐야?

5-1. DataIntegrityViolationException

  • Spring이 DB/ORM/JDBC 예외를 “무결성 위반” 범주로 감싸서 던진 예외

보통 이런 제약 위반에서 발생:

  • UNIQUE

  • NOT NULL

  • FK

  • CHECK

5-2. Hibernate의 ConstraintViolationException

org.hibernate.exception.ConstraintViolationException

  • DB constraint 위반(UNIQUE 포함)을 의미

  • getConstraintName()으로 깨진 제약 이름을 얻을 수 있을 때가 있음

주의:

  • jakarta.validation.ConstraintViolationException(검증 실패)와 완전히 다름

6) Fallback이 “DB마다 깨지기 쉽다”는 말의 의미

상황 설명

회원가입 중복(이메일/닉네임/전화번호) 상황에서는 DB의 UNIQUE 제약이 깨지면서 예외가 발생한다.
이때 어떤 제약이 깨졌는지 알 수 있다면, 아래처럼 제약 이름(constraintName) 을 기준으로 에러 코드를 구분해 사용자에게 더 정확한 메시지를 내려줄 수 있다.

if (constraintName.contains("uk_users_email")) {
    return conflict(ErrorCode.CONFLICT_EMAIL);
}

if (constraintName.contains("uk_users_nickname")) {
    return conflict(ErrorCode.CONFLICT_NICKNAME);
}

if (constraintName.contains("uk_users_phone")) {
    return conflict(ErrorCode.CONFLICT_PHONENUMBER);
}

getConstraintName()이 성공하면 구조화된 값(제약 이름) 을 얻는 것이기 때문에 분기 처리가 비교적 안정적이다.

문제: getConstraintName()이 항상 값을 주는 것은 아니다

하지만 환경에 따라 getConstraintName()이 null을 반환하는 경우가 있다. 대표적으로 아래 같은 상황에서 발생할 수 있다.

  • DB 드라이버가 제약 이름을 명확히 제공하지 않는 경우

  • Hibernate가 예외에서 제약 이름을 추출하지 못한 경우

  • 원인 예외 체인이 Hibernate의 ConstraintViolationException 형태로 전달되지 않은 경우

  • DB가 제약명을 자동 생성했고, Hibernate가 해당 이름을 정확히 잡지 못한 경우

즉, “제약 위반” 자체는 발생했지만 제약 이름을 코드에서 안정적으로 얻지 못하는 케이스가 존재한다.

그래서 나온 시도: 가장 안쪽 예외 메시지에서 힌트를 얻기

이럴 때 흔히 시도하는 fallback이 e.getMostSpecificCause().getMessage()다.
이 메서드는 예외 체인에서 가장 안쪽(대개 JDBC 드라이버 예외) 의 메시지를 가져오기 때문에, 메시지에 제약명 또는 힌트가 포함되어 있다면 이를 활용해 분기 처리를 할 수 있다.

String message = e.getMostSpecificCause().getMessage();
if (message.contains("uk_users_email")) {
    ...
}

그런데 이 방식이 “깨지기 쉬운” 이유

문제는 message가 구조화된 데이터가 아니라 단순 문자열이라는 점이다.

  • DB(MySQL/Postgres/H2)마다 에러 메시지 포맷이 다름

  • 같은 DB라도 드라이버/버전 업으로 메시지 문구가 변경될 수 있음

  • 메시지에 제약명이 아예 포함되지 않을 수도 있음

즉, 오늘은 contains("uk_users_email")가 동작해도, DB 환경이 바뀌면 갑자기 분기 로직이 실패할 수 있다.

결론: 가장 안정적인 권장 방향

그래서 보통은 아래 전략을 권장한다.

  • 가능하면 constraintName처럼 구조화된 정보로 먼저 분기한다.

  • constraintName을 얻지 못하는 경우에는 세부 분기를 포기하고 공통 에러(CONFLICT_USER_DATA)로 fallback 한다.

이렇게 하면 DB 환경 변화가 있어도 서비스가 예외 분기 로직 때문에 깨지지 않고, 최소한 “중복으로 실패했다”는 공통 응답은 안정적으로 제공할 수 있다.

7) “save() try-catch로 잡으면 되잖아?” → commit/flush 시점 함정

상황 설명

public SignupResponse signup(SignupRequest signupRequest) {

		if (userRepository.existsByEmail(signupRequest.getEmail())) {
			throw new BaseException(ErrorCode.CONFLICT_EMAIL);
		}

		if (userRepository.existsByNickname(signupRequest.getNickname())) {
			throw new BaseException(ErrorCode.CONFLICT_NICKNAME);
		}

		if (userRepository.existsByPhoneNumber(signupRequest.getPhoneNumber())) {
			throw new BaseException(ErrorCode.CONFLICT_PHONENUMBER);
		}

		String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());

		User user = User.create(signupRequest.getEmail(), encodedPassword, signupRequest.getNickname()
		, signupRequest.getPhoneNumber(), signupRequest.getRole());


		User savedUser = userRepository.save(user);

		return SignupResponse.from(savedUser);
	}

회원가입 서비스 로직에서 위의 사전 중복 예외 검증을 통과한 후 db에 save()가 될 때 동시에 save()가 되어 DataIntegrityViolationException가 발생하여 서버에러 500이 나오게 될 수 있다. 그리하여 save()try-catch로 잡는 상황이 생기면서 나온 내용이다.

핵심: @Transactional에서 save()가 즉시 INSERT를 DB로 보내지 않을 수 있다.

가능한 흐름:

  1. save()는 영속성 컨텍스트에만 등록(예외 안 터짐)

  2. 메서드는 return으로 정상 종료

  3. 스프링 트랜잭션 AOP가 메서드 종료 후 commit

  4. commit 과정에서 flush → 실제 INSERT 실행

  5. 그때 UNIQUE 위반 예외 발생

즉, 예외가 “try 블록 안”이 아니라 “메서드 종료 후 commit”에서 터지면
서비스의 try-catch는 못 잡을 수 있다.

해결:

  • saveAndFlush()로 예외를 save 시점에 확정시키거나

  • 더 실무적으로: ControllerAdvice에서 DataIntegrityViolationException을 전역 처리(커밋 시점 예외도 잡힘) 이 방법 채택했습니다.

8) 전역 예외 처리: DataIntegrityViolationException 분기 처리 구현

@ExceptionHandler(DataIntegrityViolationException.class)
	public ResponseEntity<ErrorResponse> handleDataIntegrityViolationException(DataIntegrityViolationException e) {

		ConstraintViolationException cve = findHibernateConstraintViolation(e);
		if (cve != null) {
			String constraintName = cve.getConstraintName();
			if (constraintName != null) {
				if (constraintName.contains("uk_users_email")) {
					return conflict(ErrorCode.CONFLICT_EMAIL);
				}

				if (constraintName.contains("uk_users_nickname")) {
					return conflict(ErrorCode.CONFLICT_NICKNAME);
				}

				if (constraintName.contains("uk_users_phone")) {
					return conflict(ErrorCode.CONFLICT_PHONENUMBER);
				}
			}
		}

		return conflict(ErrorCode.CONFLICT_USER_DATA);
	}

	private ResponseEntity<ErrorResponse> conflict(ErrorCode errorCode) {
		return ResponseEntity.status(HttpStatus.CONFLICT).body(ErrorResponse.from(errorCode));
	}

	private ConstraintViolationException findHibernateConstraintViolation(Throwable t) {
		while (t != null) {
			if (t instanceof ConstraintViolationException cve) return cve;
			t = t.getCause();
		}
		return null;
	}

내가 만든 형태:

  • 원인 체인을 타고 ConstraintViolationException 찾아서

  • constraintName에 따라 이메일/닉네임/폰 중복 분기

  • 분기 실패 시 CONFLICT_USER_DATA

추가 개선 포인트로는:

  • constraintName 대소문자 변동 대비(lowercase)

  • constraintName이 안 나오는 환경 대비(공통 fallback 유지)

profile
걱정하지 말고 일단 해봐!

0개의 댓글