Notice
Recent Posts
Recent Comments
Link
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
Archives
Today
Total
관리 메뉴

co-cherry

예외 처리와 Validation 본문

Springboot

예외 처리와 Validation

co-cherry 2026. 3. 13. 19:10

공통 응답 형식 구조

ApiResponse<T>          ← 실제 응답 wrapper 클래스
    ├── BaseSuccessCode  ← 성공 코드 인터페이스
    │       └── GeneralSuccessCode (enum)
    └── BaseErrorCode    ← 에러 코드 인터페이스
            └── GeneralErrorCode (enum)

 

ApiResponse<T>

실제 응답을 감싸는 공통 응답 *래퍼(wrapper) 클래스

*Java의 기본형을 객체로 감싸는 클래스 

public class ApiResponse<T> {
    private final Boolean isSuccess;
    private final String code;
    private final String message;
    private T result;
}

 

즉, 모든 API 응답이 동일한 JSON 구조로 반환되도록 하는 중심 클래스이다.

 

  • isSuccess : 성공/실패 여부
  • code : 내부 응답 코드
  • message : 설명 메세지
  • result : 실제 응답 데이터 

Q. <T>를 사용하는 이유는 무엇일까? 

ApiResponse<T> 에서 T는 제네릭(Generic)을 의미한다.

이는 result 안에 들어갈 데이터 타입이 상황마다 달라도 되게 하기 위함이다. 

  • 문자열 결과면 ApiResponse<String>
  • 회원 정보면 ApiResponse<MemberDto>
  • 게시글 리스트면 ApiResponse<List<PostDto>>

이처럼 공통 껍데기를 하나로 두되, 안에 들어가는 실제 데이터 타입은 유연하게 바꿀 수 있도록 만들기 위함이다. 

 

BaseSuccessCode

성공 코드를 만들 때 반드시 지켜야 하는 공통 형식

  • HttpStatus : HTTP 상태 코드 (200, 201 ... )
  • code : 커스텀 코드 문자열 ("COMMON200")
  • message : 사람이 읽을 수 있는 설명 메시지
public interface BaseSuccessCode {
    HttpStatus getStatus();
    String getCode();
    String getMessage();
}

BaseErrorCode

에러 코드를 만들 때 반드시 지켜야 하는 공통 형식

위 BaseSuccessCode와 인터페이스 구조가 동일

public interface BaseSuccessCode {
    HttpStatus getStatus();
    String getCode();
    String getMessage();
}

 

Q. 구조가 똑같은데 왜 두 개로 나눴을까?

성공 코드와 에러 코드를 혼동하여 아래와 같은 실수가 발생할 수 있기 때문이다. 

ApiResponse.onSuccess(GeneralErrorCode.NOT_FOUND, data) // 성공인데 에러코드 사용
ApiResponse.onFailure(GeneralSuccessCode.OK, data)      // 실패인데 성공코드 사용

 

이를 서로 다른 타입으로 분리해두면 컴파일 단계에서 오류가 발생하므로 이러한 실수를 사전에 방지할 수 있다. 

public static <T> ApiResponse<T> onSuccess(BaseSuccessCode code, T result) { ... }
//                                          ↑ 여기에 BaseErrorCode 넣으면 컴파일 에러
public static <T> ApiResponse<T> onFailure(BaseErrorCode code, T result) { ... }
//                                          ↑ 여기에 BaseSuccessCode 넣으면 컴파일 에러

GeneralSuccessCode

공통 성공 코드 enum

public enum GeneralSuccessCode implements BaseSuccessCode {
    OK(HttpStatus.OK, "COMMON200_1", "요청이 성공적으로 처리되었습니다."),
    CREATED(HttpStatus.CREATED, "COMMON201_1", "리소스가 성공적으로 생성되었습니다."),
    ACCEPTED(HttpStatus.ACCEPTED, "COMMON202_1", "요청이 접수되었습니다."),
    NO_CONTENT(HttpStatus.NO_CONTENT, "COMMON204_1", "응답할 내용이 없습니다."),
}

 

예를 들어,

  • 단순 조회 성공이면 OK
  • 회원가입, 게시글 생성이면 CREATED
  • 바로 처리 완료는 아니지만 접수되었으면 ACCEPTED
  • 바디 없이 끝내고 싶으면 NO_CONTENT

이런 식으로 상황에 맞춰 골라 쓰는 구조이다. 

GeneralErrorCode

공통 에러 코드 enum

public enum GeneralErrorCode implements BaseErrorCode {
    BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400_1", "잘못된 요청입니다."),
    UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "AUTH401_1", "인증이 필요합니다."),
    FORBIDDEN(HttpStatus.FORBIDDEN, "AUTH403_1", "요청이 거부되었습니다."),
    NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404_1", "요청한 리소스를 찾을 수 없습니다."),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500_1", "예기치 않은 서버 에러가 발생했습니다."),
}

 

에러가 발생했을 때, 단순히 에러가 발생했습니다 라는 식으로 보내는 게 아니라,

HTTP 상태 + 내부 에러 코드 + 메세지를 묶어서 관리한다. 

 

예를 들어,

  • 잘못된 요청 → BAD_REQUEST
  • 로그인 안 한 사용자 → UNAUTHORIZED
  • 권한 없음 → FORBIDDEN
  • 요청한 데이터 없음 → NOT_FOUND
  • 서버 내부 문제 → INTERNAL_SERVER_ERROR

이런 식으로 상황에 맞춰 골라 쓰는 구조이다. 

 

Q. Enum을 사용한 이유가 뭘까?

Enum을 사용하면 오타나 중복 코드를 방지할 수 있으며, 코드 자동 완성을 통해 편리하게 사용 가능하기 때문이다.

 

실제 Controller 사용 예시

@GetMapping("/members/{id}")
public ResponseEntity<ApiResponse<MemberDto>> getMember(@PathVariable Long id) {

    MemberDto member = memberService.findById(id);

    // 성공
    return ResponseEntity
        .status(HttpStatus.OK)
        .body(ApiResponse.onSuccess(GeneralSuccessCode.OK, member));
}

 

전역 예외 처리 적용

 

API를 만들다 보면, 공통적으로 아래와 같은 상황이 발생할 수 있다.

  • 존재하지 않는 데이터 조회
  • 잘못된 요청 값
  • 인증 실패
  • 서버 내부 오류

만약, 전역 예외 처리가 없다면 컨트롤러마다 try-catch 문을 통해 반복적으로 예외 처리를 해야 하는 경우가 발생한다.

@GetMapping("/member/{id}")
public ApiResponse<MemberDto> getMember(@PathVariable Long id) {
	try {
		Member member = memberService.findMember(id);
		return ApiResponse.onSuccess(GeneralSuccessCode.OK, member);
	} catch (Exception e) {
		return ApiResponse.onFailure(GeneralErrorCode.INTERNAL_SERVER_ERROR, null);
	}
}

 

이처럼 전역 예외 처리가 없다면,

  • 컨트롤러 마다 try-catch문 필요
  • 불필요한 코드 중복
  • 유지보수 어려움
  • 에러 응답 형식이 깨질 수 있음

이를 위해 예외 처리 한 곳에서 관리하는 구조를 만드는데 이것이 바로 전역 예외 처리(Global Exception Handler)이다.

전역 예외 처리 구현 방법

1. 전역 예외 처리 클래스 생성

2. @RestControllerAdvice 적용

3. @ExceptionHandler로 처리할 예외 지정

4. 공통 응답 형식(ApiResponse) 반환

 

전역 예외 처리 코드

해당 코드에서는 

  1. 커스텀 예외에 해당한다면 그에 맞게 먼저 처리하고
  2. 커스텀 예외를 제외한 모든 예외를 INTERNAL_SERVER_ERROR로 처리하는 방식이다. 
@RestControllerAdvice
public class GeneralExceptionAdvice {

    // 애플리케이션에서 발생하는 커스텀 예외를 처리
    @ExceptionHandler(GeneralException.class)
    public ResponseEntity<ApiResponse<Void>> handleException(
            GeneralException ex
    ) {

        return ResponseEntity.status(ex.getCode().getStatus())
                .body(ApiResponse.onFailure(
                                ex.getCode(),
                                null
                        )
                );
    }

    // 그 외의 정의되지 않은 모든 예외 처리
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<String>> handleException(
            Exception ex
    ) {

        BaseErrorCode code = GeneralErrorCode.INTERNAL_SERVER_ERROR;
        return ResponseEntity.status(code.getStatus())
                .body(ApiResponse.onFailure(
                                code,
                                ex.getMessage()
                        )
                );
    }
}

 

Q. 왜 그 외 예외를 전부 INTERNAL_SERVER_ERROR로 처리할까?

그 외 예외를 INTERAL_SERVER_ERROR로 감싸서 내려주면 서버의 내부 정보를 노출하지 않으면서 서버에 문제가 났다는 것을 알려 줄 수 있기 때문이다. 

 

이를 통해 전역 예외 처리는 이와 같은 흐름으로 동작한다. 

Controller
   ↓
Service
   ↓
Exception 발생
   ↓
@RestControllerAdvice
   ↓
@ExceptionHandler 실행
   ↓
ApiResponse.onFailure 반환
   ↓
JSON 응답 전달

핵심 어노테이션

@RestControllerAdvice 

애플리케이션 전역의 컨트롤러에서 발생하는 예외를 처리하는 클래스임을 의미 

 

@ExceptionHandler

특정 예외가 발생했을 때 실행될 메서드를 지정

커스텀 예외 생성

Java에서 제공하는 기본 예외(Exception, RuntimeException 등)를 그대로 사용하는 것이 아닌, 비즈니스 상황에 맞게 개발자가 직접 정의한 예외 클래스

 

Q. 왜 필요할까? 

하나의 API를 실행할 때 NOT_FOUND 오류가 발생한다면, 그것이 회원을 못 찾은 건지, 게시글을 못 찾은 건지, 주문을 못 찾은 건지 어디서 발생한 에러인지 구체적으로 알 수 없기 때문이다. 

 

커스텀 예외 처리 구현 방법

1. 커스텀 예외 클래스를 생성하기 위해서는 먼저 공통 예외 클래스가 필요하다. 

@Getter
@AllArgsConstructor
public class GeneralException extends RuntimeException {

    private final BaseErrorCode code;
	// BaseErrorCode 타입이라 어떤 도메인 에러코드든 담을 수 있음
}

*RuntimeException 실행 중(Runtime)에 발생하는 예외를 의미한다. 

*BaseErrorCode 에러 코드 인터페이스, 이 인터페이스를 통해 HTTP 상태 코드, 에러 코드 문자열, 에러 메세지를 관리할 수 있다. 

 

2. 각 도메인에서 사용할 예외 클래스를 정의한다. 

public class MemberException extends GeneralException {
    public MemberException(BaseErrorCode code) {
        super(code);
    }
}

 

MemberException은 Member 도메인에서 발생하는 예외를 표현하기 위한 클래스이다. 

이 클래스는 GeneralException을 상속받기 때문에 다음 기능을 그대로 사용할 수 있다.

  • 에러 코드 저장
  • 전역 예외 처리와 연동
  • 공통 에러 응답 형식 반환 

3. 도메인 별 에러 코드를 정의한다. 

@Getter
@AllArgsConstructor
public enum MemberErrorCode implements BaseErrorCode {
    MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND,   "MEMBER404_1", "존재하지 않는 회원입니다."),
    DUPLICATE_EMAIL(HttpStatus.CONFLICT,     "MEMBER409_1", "이미 사용 중인 이메일입니다."),
    INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "MEMBER400_1", "비밀번호가 올바르지 않습니다.");

    private final HttpStatus status;
    private final String code;
    private final String message;
}

 

4. Service에서 에러를 던진다. 

@Service
public class MemberService {

    public MemberDto findById(Long id) {
        return memberRepository.findById(id)
                .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
                //                   ↑ "회원을 못 찾았다"는 구체적인 의미를 담아 던짐
    }

    public void join(JoinRequest request) {
        if (memberRepository.existsByEmail(request.getEmail())) {
            throw new MemberException(MemberErrorCode.DUPLICATE_EMAIL);
            // ↑ "이메일 중복이다"는 구체적인 의미를 담아 던짐
        }
    }
}

 

그 외, 어느 도메인에서나 공통으로 사용할 수 있는 범용 에러 코드 목록을 통해 중복되는 에러 코드는 따로 빼두는 것 또한 좋다.

@Getter
@AllArgsConstructor
public enum GeneralErrorCode implements BaseErrorCode{

    BAD_REQUEST(HttpStatus.BAD_REQUEST,
            "COMMON400_1",
            "잘못된 요청입니다."),
    UNAUTHORIZED(HttpStatus.UNAUTHORIZED,
            "AUTH401_1",
            "인증이 필요합니다."),
    FORBIDDEN(HttpStatus.FORBIDDEN,
            "AUTH403_1",
            "요청이 거부되었습니다."),
    NOT_FOUND(HttpStatus.NOT_FOUND,
            "COMMON404_1",
            "요청한 리소스를 찾을 수 없습니다."),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,
            "COMMON500_1",
            "예기치 않은 서버 에러가 발생했습니다."),
    ;

    private final HttpStatus status;
    private final String code;
    private final String message;
}

 

전역 예외 처리와 커스텀 예외 처리의 관계

전역 예외 처리가 큰 틀이고 그 안에서 커스텀 예외를 처리하는 방식과 그 외 예외를 처리하는 방식으로 나뉘는 것.

전역 예외 처리
   │
   ├─ 커스텀 예외 처리
   │     (GeneralException, MemberException 등)
   │
   └─ 일반 예외 처리
         (Exception, IllegalArgumentException 등)

 

 

프로젝트 할 때 썼던 예외 처리 구조 (참고)

@Valid

객체에 정의된 검증 규칙을 실행하여 데이터의 유효성 검사를 자동으로 수행하는 어노테이션

 

앞서, @Valid를 사용하려면 Bulid.Gradle에 아래 라이브러리를 추가해야 한다. 

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-validation'
}

 

그러나, @Valid는 단순히 검증을 실행하는 역할일 뿐, 어떤 조건을 검증할지는 각 객체 필드에 정의된 제약 조건을 통해 결정된다. 

이러한 제약 조건을 정의하는 어노테이션을 사용하면 요청 데이터가 특정 규칙을 만족하는지 자동으로 검증할 수 있다. 

 

제약 조건

문자열 검증

어노테이션 설명
@NotNull null 허용하지 않음
@NotEmpty null + 빈 문자열 허용 안 함 
@NotBlank 공백 문자열 허용 안 함 
@Size(min, max) 문자열 길이 제한

숫자 검증

어노테이션 설명
@Min 최소값
@Max 최대값
@Positive 양수
@Negative 음수

형식 검증

어노테이션 설명
@Email 이메일 형식 검증
@Pattern 정규식 기반 검증

 

 

이러한 제약 조건과 @Valid는 주로 Controller에서 요청 DTO를 받을 때 사용된다. 

 

Controller

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/members")
public class MemberController {

	@PostMapping
	public String join(@Valid @RequestBody MemberJoinRequest request) {
		return "회원가입 성공";
	}
}

 

DTO

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;

@Getter
public class MemberJoinRequest {

	@NotBlank(message = "이름은 필수입니다.")
	@Size(max = 20, message = "이름은 20자 이하여야 합니다.")
	private String name;

	@NotBlank(message = "이메일은 필수입니다.")
	@Email(message = "올바른 이메일 형식이 아닙니다.")
	private String email;

	@NotBlank(message = "비밀번호는 필수입니다.")
	@Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.")
	private String password;
}

 

게시글 생성 API 작성 

위 내용을 바탕으로 게시글 생성 API를 만들어보자. 

 

1. Entity 설계

각 id, title, content를 갖는 post entity를 설계한다. (자세한 설명은 나중에)

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Table(name = "post")
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String content;
}

 

2. 게시글 요청/응답 DTO 생성

@Getter
public class PostRequestDto {

    @NotBlank(message = "제목은 필수입니다.")
    @Size(max = 50, message = "제목은 50자 이하여야 합니다.")
    private String title;

    @NotBlank(message = "내용은 필수입니다.")
    @Size(max = 1000, message = "내용은 1000자 이하여야 합니다.")
    private String content;
}
@Getter
@AllArgsConstructor
public class PostResponseDto {
    private Long postId;
    private String title;
    private String content;
}

 

 

3. Repository 생성

public interface PostRepository extends JpaRepository<Post, Long> {
}

 

4. service 생성

RequestDto에서 받은 값들을 통해 새 ResponseDto를 반환시킨다. 

@Service
@RequiredArgsConstructor
public class PostService {
    private final PostRepository postRepository;

    public PostResponseDto createPost(PostRequestDto request) {
        Post post = Post.builder()
                .title(request.getTitle())
                .content(request.getContent())
                .build();

        Post savedPost = postRepository.save(post);

        return new PostResponseDto(
                savedPost.getId(),
                savedPost.getTitle(),
                savedPost.getContent()
        );
    }
}

 

5. controller 생성 

/post 경로를 통해 API를 생성하였으며 @Valid 어노테이션을 통해 유효성 검사를 수행하도록 했다. 

성공 시에는 GeneralSuccessCode.CREATED 코드와 함께 새 ResponseDto 값이 반환된다. 

@RestController
@RequiredArgsConstructor
@RequestMapping("/posts")
public class PostController {
    private final PostService postService;

    @PostMapping
    public ApiResponse<PostResponseDto> createPost(@Valid @RequestBody PostRequestDto request) {
        return ApiResponse.onSuccess(GeneralSuccessCode.CREATED, postService.createPost(request));
    }
}

 

 

 

그럼 Swagger를 통해 생성한 API를 테스트 해보자!

 

먼저, title과 content를 비워둔 채 실행하면, 

 

이와 같은 500 에러가 발생한다. (@Valid가 제대로 작동함을 알 수 있다)

400 에러가 아닌 500 에러인 이유는 위에서 INTERNAL_SERVER_ERROR로 포괄적으로 처리했기 때문이다. 

 

그럼 제대로 제목과 내용을 작성한다면?

 

이처럼 제대로 생성된 모습을 확인할 수 있다.

도메인 별 성공 코드를 작성하지 않아서 COMMON code가 나온 것 또한 볼 수 있다. 

더 정리되고 예쁜 코드를 받아보기 위해서는 도메인 별로 성공 코드 또한 정의해주는 것이 좋다. 

 

 

'Springboot' 카테고리의 다른 글

게시글 목록 API 구현하기 (페이징 · 검색, N+1)  (0) 2026.05.07
회원가입 및 로그인 구현  (0) 2026.04.28
게시판 CRUD  (0) 2026.03.30
ERD + DB + JPA 시작  (0) 2026.03.24
Spring Boot 시작하기 & REST API 기초  (0) 2026.03.09