문제 상황
현재 포트폴리오 API는 얼추 완성하고 회원 가입과 로그인, 로그아웃 기능을 개발하고 있다.
그런데 회원 가입 중 문제가 발생했어서 글을 남기고자 한다.

먼저 회원의 에러 코드는 이렇게 작성해놓았다. 회원 가입할 때 몇 가지 조건을 달아놓았는데,
1. 아이디와 비밀번호는 8자 이상이어야 한다.
2. 비밀번호는 문자와 숫자를 모두 포함해야 한다.
3. 아이디와 닉네임은 중복되어서는 안된다.
@Transactional
public Member signup(MemberSignupRequest request) {
// ID와 비밀번호는 8자 이상이여야 한다.
if (request.getUserName().length() < 8 || request.getPassword().length() < 8) {
throw new BusinessException(MemberErrorCode.INVALID_CREDENTIALS_LENGTH);
}
// 비밀번호는 문자와 숫자를 모두 포함해야 한다.
String password = request.getPassword();
if (!password.matches(".*[a-zA-Z].*") || !password.matches(".*\\d.*")) {
throw new BusinessException(MemberErrorCode.INVALID_PASSWORD_POLICY);
}
// 아이디는 중복되면 409 에러
memberRepository.findByUserName(request.getUserName())
.ifPresent(member -> {
throw new BusinessException(MemberErrorCode.DUPLICATE_USERNAME); });
// 닉네임이 중복되면 409 에러
memberRepository.findByDisplayName(request.getDisplayName())
.ifPresent(member -> {
throw new BusinessException(MemberErrorCode.DUPLICATE_DISPLAY_NAME); });
Member newMember = Member.builder()
.userName(request.getUserName())
.displayName(request.getDisplayName())
.password(passwordEncoder.encode(request.getPassword()))
.build();
return memberRepository.save(newMember);
맨 처음 MemberService는 위와 같이 작성했었다. 만약 아이디나 닉네임이 중복되면 내가 만든 BusinessException으로 예외처리를 해서 DUPLICATE_USERNAME이나, DUPLICATE_DISPLAY_NAME를 발생시켜서 409(충돌)이 응답되어야 하는데, DataIntegrityViolationException으로 인해 500(서버 내부 오류)가 발생했었다.
문제가 발생한 원인
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.UUID;
@Entity
@Table(name = "member")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID memberId;
@Column(nullable = false, unique = true)
private String displayName;
@Column(nullable = false, unique = true)
private String userName;
@Column(nullable = false)
private String password;
public Member(String userName, String displayName, String password) {
this.userName = userName;
this.displayName = displayName;
this.password = password;
}
}
초기 Member 엔티티 코드
처음에는 Member 엔티티를 위와 같이 구성했다. displayName(닉네임)과 userName(아이디)를 unique = true로 설정해놓아서 데이터베이스에 UNIQUE 제약 조건을 걸어서 중복을 막으려고 했었다.
이러면 중복된 userName이나 displayName을 저장하려고 하면 SQLIntegrityConstraintViolationException이 발생하고, 스프링은 이걸 DataIntegrityViolationException으로 감싸서 던져 버린다.
그런데 난 이걸 BusinessException으로 잡으려고 했다. (머쓱;;)
@Slf4j
@RestControllerAdvice
@Component
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ResponseErrorEntity> handleBusinessException(BusinessException e) {
log.error("BusinessException occurred: {}", e.getMessage(), e);
return ResponseErrorEntity.toResponseEntity(e.getErrorCode());
}
}
초기 GlobalExceptionHandler
물론 GlobalExceptionHandler에는 DataIntegrityViolationException를 처리하지 않았기 때문에 DB 제약 오류가 그대로 노출되어 500이 반환되었다.
나의 해결 방안
package com.AISA.AISA.member.adapter.in;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.UUID;
@Entity
@Table(
name = "member",
uniqueConstraints = {
@UniqueConstraint(name = "uk_member_user_name", columnNames = "user_name"),
@UniqueConstraint(name = "uk_member_display_name", columnNames = "display_name")
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID memberId;
@Column(name = "display_name", nullable = false)
private String displayName;
@Column(name = "user_name", nullable = false)
private String userName;
@Column(nullable = false)
private String password;
@Builder
public Member(String userName, String displayName, String password) {
this.userName = userName;
this.displayName = displayName;
this.password = password;
}
}
수정한 Member 엔티티
먼저 Member 엔티티를 위와 같이 수정했다. Table 어노테이션에 UniqueConstraint를 걸어서 각 제약조건에 직접 이름을 붙여주었다.
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ResponseErrorEntity> handleDataIntegrityViolation(DataIntegrityViolationException e) {
log.error("DataIntegrityViolationException occurred: {}", e.getMessage(), e);
String rootCauseMessage = e.getMostSpecificCause().getMessage();
// user_name이 중복이면 DUPLICATE_USERNAME, display_name이 중복이면 DUPLICATE_DISPLAY_NAME 처리
if (rootCauseMessage.contains("uk_member_user_name")) {
return ResponseErrorEntity.toResponseEntity(MemberErrorCode.DUPLICATE_USERNAME);
} else if (rootCauseMessage.contains("uk_member_display_name")) {
return ResponseErrorEntity.toResponseEntity(MemberErrorCode.DUPLICATE_DISPLAY_NAME);
}
// 그 외의 데이터 무결성 오류는 일반적인 서버 오류로 처리
return ResponseErrorEntity.toResponseEntity(CommonErrorCode.INTERNAL_SERVER_ERROR);
}
그 다음, GlobalExceptionHandler에 DataIntegrityViolationException 핸들러를 추가하고, DB 제약 이름(uk_member_user_name, uk_member_display_name)을 통해 어떤 필드가 중복됐는지를 식별해서 명확한 커스텀 에러( 409 DUPLICATE_USERNAME 등)를 반환하도록 만들었다.
@Transactional
public Member signup(MemberSignupRequest request) {
// ID와 비밀번호는 8자 이상이여야 한다.
if (request.getUserName().length() < 8 || request.getPassword().length() < 8) {
throw new BusinessException(MemberErrorCode.INVALID_CREDENTIALS_LENGTH);
}
// 비밀번호는 문자와 숫자를 모두 포함해야 한다.
String password = request.getPassword();
if (!password.matches(".*[a-zA-Z].*") || !password.matches(".*\\d.*")) {
throw new BusinessException(MemberErrorCode.INVALID_PASSWORD_POLICY);
}
Member newMember = Member.builder()
.userName(request.getUserName())
.displayName(request.getDisplayName())
.password(passwordEncoder.encode(request.getPassword()))
.build();
return memberRepository.save(newMember);
}
그리고 이제 userName과 displayName 중복 문제는 GlobalExceptionHandler 에서 처리하므로 MemberService에서는 제거한다.
최종 결과
1. 애플리케이션 레벨(비즈니스 로직) 검증
조건
- ID와 비밀번호가 8자 미만일 때
- 비밀번호에 문자나 숫자가 포함되지 않았을 때
처리 방식
- 데이터베이스에 접근하기 전에, 서비스 계층에서 직접 조건을 확인한다.
- 조건을 만족하지 않으면 BusinessException을 던진다.
2. 데이터베이스 레벨(무결성 제약) 검증
조건
- userName이 기존 회원과 중복된 경우
- displayName이 기존 회원과 중복된 경우
처리 방식
- DB의 UNIQUE 제약 조건이 위반되면 JPA가 내부적으로 DataIntegrityViolationException을 던진다.
- 즉, 스프링이 DataIntegrityViolationException으로 감싸서 던지고, 이를 GlobalExceptionHandler에서 잡아서 적절한 MemberErrorCode로 변환한다.
- 내가 직접 조건을 검사한 결과 위반 -> BusinessException
- DB가 제약 위반으로 판단한 결과 -> DataIntegrityViolationException
소감

예외 처리는 공부를 해도 어렵다... 그래도 한 단계 발전했다는 데에 의의를 둬본다.
'개발 일지' 카테고리의 다른 글
| 주식과 지수 정보 조회 기능 (1) | 2025.11.23 |
|---|---|
| 한국투자증권 API 주식 조회 기능 (0) | 2025.11.21 |
| 증권사 API 토큰 발급과 WebClient (0) | 2025.11.13 |
| 2025-11-03 근황 (0) | 2025.11.03 |