
회원가입 API를 만들면서도 생각보다 고민할 지점이 많았다. 오늘은 그 과정에서 직접 물어보고 정리한 내용들(코드리뷰 포함)을 한 글로 묶어둔다.
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를 정상 흐름으로 쓰면 디버깅/의도 파악이 어려워짐.
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 파싱/조립은 서비스가.
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)는 “내부에서도 안전” 방어(선택이지만 추천)
null: 값 자체 없음
"": empty(길이 0)
" ": blank(공백만, trim 하면 empty)

@Valid(jakarta): 표준 Bean Validation 트리거(요청 DTO 검증에 흔함)
@Validated(Spring):
서비스 메서드 파라미터에 @NotBlank 같은 걸 걸고 자동 검증하려면
클래스에 @Validated가 필요할 때가 많다.
UNIQUE
NOT NULL
FK
CHECK
org.hibernate.exception.ConstraintViolationException
DB constraint 위반(UNIQUE 포함)을 의미
getConstraintName()으로 깨진 제약 이름을 얻을 수 있을 때가 있음
jakarta.validation.ConstraintViolationException(검증 실패)와 완전히 다름회원가입 중복(이메일/닉네임/전화번호) 상황에서는 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()이 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 환경 변화가 있어도 서비스가 예외 분기 로직 때문에 깨지지 않고, 최소한 “중복으로 실패했다”는 공통 응답은 안정적으로 제공할 수 있다.
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로 보내지 않을 수 있다.save()는 영속성 컨텍스트에만 등록(예외 안 터짐)
메서드는 return으로 정상 종료
스프링 트랜잭션 AOP가 메서드 종료 후 commit
commit 과정에서 flush → 실제 INSERT 실행
그때 UNIQUE 위반 예외 발생
즉, 예외가 “try 블록 안”이 아니라 “메서드 종료 후 commit”에서 터지면
서비스의 try-catch는 못 잡을 수 있다.
saveAndFlush()로 예외를 save 시점에 확정시키거나
더 실무적으로: ControllerAdvice에서 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 유지)