co-cherry
예외 처리와 Validation 본문
공통 응답 형식 구조
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) 반환
전역 예외 처리 코드
해당 코드에서는
- 커스텀 예외에 해당한다면 그에 맞게 먼저 처리하고
- 커스텀 예외를 제외한 모든 예외를 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 | 음수 |
형식 검증
| 어노테이션 | 설명 |
| 이메일 형식 검증 | |
| @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 |
