회원 가입 중복 방지 중 발생한 DataIntegrityViolationException 예외 처리

728x90
반응형

 

문제 상황

 

현재 포트폴리오 API는 얼추 완성하고 회원 가입과 로그인, 로그아웃 기능을 개발하고 있다.

 

그런데 회원 가입 중 문제가 발생했어서 글을 남기고자 한다.

 

MemberErrorCode enum

 

먼저 회원의 에러 코드는 이렇게 작성해놓았다. 회원 가입할 때 몇 가지 조건을 달아놓았는데,

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

 

소감

 

 

예외 처리는 공부를 해도 어렵다... 그래도 한 단계 발전했다는 데에 의의를 둬본다.

728x90
반응형

'개발 일지' 카테고리의 다른 글

주식과 지수 정보 조회 기능  (1) 2025.11.23
한국투자증권 API 주식 조회 기능  (0) 2025.11.21
증권사 API 토큰 발급과 WebClient  (0) 2025.11.13
2025-11-03 근황  (0) 2025.11.03