
📌 들어가며
오늘의 학습 목표: Spring Boot에서 체계적인 예외 처리 전략을 세우고, 일관된 에러 응답 설계하기
안녕하세요! 오늘은 예외 처리 전략에 대해 공부했습니다.
솔직히 말하면, 예전에는 예외 처리를 대충 했어요.
try {
// 뭔가 위험한 코드
} catch (Exception e) {
e.printStackTrace(); // 이게 최선이었음...
}
그러다가 팀 프로젝트에서 이런 일이 있었어요.
프론트엔드 개발자: "API 호출했는데 500 에러 떠요"
나: "어... 뭐가 문제지? 로그 봐야겠다"
(로그 확인)
나: "NullPointerException이네... 근데 어디서 난 거지?"
(한참 디버깅)
나: "아 여기서 null 체크 안 했구나..."
→ 원인 파악에만 30분 소요 😭
거기다 프론트엔드 입장에서는...
// 프론트가 받은 응답
{
"timestamp": "2024-01-15T10:30:00",
"status": 500,
"error": "Internal Server Error",
"path": "/api/orders"
}
프론트: "그래서 뭐가 잘못됐다는 거야?
사용자한테 뭐라고 보여줘?"
이런 경험들이 쌓이면서 **"예외 처리를 제대로 해야겠다"**고 느꼈습니다.
🎯 Today I Learned
✅ 예외 처리를 왜 제대로 해야 하는가
✅ Java 예외 기초 (Checked vs Unchecked)
✅ Spring의 예외 처리 방식들
✅ @ControllerAdvice로 전역 예외 처리
✅ 커스텀 예외 설계
✅ 일관된 에러 응답 포맷 만들기
✅ 예외와 로깅 전략
✅ 실무에서 자주 하는 실수들
🤔 예외 처리, 왜 제대로 해야 할까?
나쁜 예외 처리의 문제점
처음에는 "에러 나면 500 띄우면 되는 거 아냐?"라고 생각했어요. 근데 실제로 겪어보니까 문제가 많더라고요.
🔥 문제 1: 사용자가 뭘 해야 할지 모름
화면에 "Internal Server Error"만 딱 뜨면?
→ 사용자: "내가 뭘 잘못한 거야? 다시 해도 돼?"
→ 그냥 이탈함
🔥 문제 2: 프론트엔드가 처리할 수가 없음
에러 응답이 일관성 없으면?
→ 프론트: "어떤 땐 message고, 어떤 땐 error고... 뭘 보여줘야 해?"
🔥 문제 3: 디버깅이 어려움
로그에 "NullPointerException"만 찍히면?
→ 개발자: "어디서? 왜? 뭘 넣어서?"
→ 삽질 시작...
🔥 문제 4: 보안 취약점
스택 트레이스가 그대로 노출되면?
→ 해커: "오, 어떤 라이브러리 쓰는지, 내부 구조가 어떤지 다 보이네?"
좋은 예외 처리의 목표
✅ 사용자에게: 무엇이 잘못됐고, 어떻게 해야 하는지 알려주기
"입력하신 이메일 형식이 올바르지 않습니다"
✅ 프론트엔드에게: 일관된 에러 응답 포맷 제공
항상 같은 구조로 응답 → 처리 로직 통일 가능
✅ 개발자에게: 디버깅에 필요한 정보 로깅
언제, 어디서, 왜, 어떤 값 때문에 에러났는지
✅ 보안: 내부 정보는 숨기기
스택 트레이스, DB 구조 등 노출 금지
📚 Java 예외 기초 복습
Checked vs Unchecked 예외

처음에 이 구분이 헷갈렸어요.
Java 예외 계층:
Throwable
│
┌─────┴─────┐
│ │
Error Exception
(심각) │
┌──┴───┐
│ │
Checked Unchecked
Exception (RuntimeException)
📌 Checked Exception (컴파일러가 체크)
- 반드시 try-catch 하거나 throws 해야 함
- 안 하면 컴파일 에러!
- 예: IOException, SQLException
try {
Files.readString(Path.of("file.txt"));
} catch (IOException e) {
// 반드시 처리해야 컴파일됨
}
📌 Unchecked Exception (런타임에 발생)
- 처리 안 해도 컴파일됨
- 실행 중에 터짐
- 예: NullPointerException, IllegalArgumentException
String name = null;
name.length(); // 컴파일은 됨, 실행하면 NPE!
내가 헷갈렸던 부분
의문: "둘 다 예외인데 왜 구분해?"
내가 이해한 방식:
Checked Exception = "예상 가능한 외부 문제"
- 파일이 없을 수도 있지 (IOException)
- DB 연결이 끊길 수도 있지 (SQLException)
- 이건 프로그램 잘못이 아니라 환경 문제!
- 그래서 "대비책을 마련해!" 하고 강제하는 것
Unchecked Exception = "프로그래밍 실수"
- null 체크 안 한 건 개발자 잘못
- 배열 범위 벗어난 것도 개발자 잘못
- 이건 코드를 고쳐야 하는 것!
- 매번 try-catch 강제하면 코드가 지저분해짐
🎯 Spring의 예외 처리 방식들
방법 1: 각 Controller에서 처리
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public ResponseEntity<?> getUser(@PathVariable Long id) {
try {
User user = userService.findById(id);
return ResponseEntity.ok(user);
} catch (UserNotFoundException e) {
return ResponseEntity.status(404)
.body(Map.of("message", "사용자를 찾을 수 없습니다"));
} catch (Exception e) {
return ResponseEntity.status(500)
.body(Map.of("message", "서버 오류가 발생했습니다"));
}
}
}
처음엔 이렇게 했는데...
문제점:
❌ 모든 Controller에 비슷한 try-catch 반복
❌ 에러 응답 형식이 제각각
❌ 새 예외 추가될 때마다 모든 곳 수정
❌ 코드가 지저분해짐
방법 2: @ExceptionHandler (Controller 단위)
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userService.findById(id); // 예외 발생하면?
}
// 이 Controller에서 발생한 예외만 처리
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<?> handleUserNotFound(UserNotFoundException e) {
return ResponseEntity.status(404)
.body(Map.of("message", e.getMessage()));
}
}
좀 나아졌는데...
여전한 문제:
❌ 각 Controller마다 @ExceptionHandler 작성
❌ 같은 예외 처리 로직이 여러 곳에 중복
❌ 공통 예외 처리가 어려움
방법 3: @ControllerAdvice (전역 처리) ⭐

@RestControllerAdvice
public class GlobalExceptionHandler {
// 모든 Controller에서 발생하는 예외를 여기서 처리!
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<?> handleUserNotFound(UserNotFoundException e) {
return ResponseEntity.status(404)
.body(Map.of("message", e.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<?> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity.status(400)
.body(Map.of("message", e.getMessage()));
}
// 예상 못한 예외도 여기서 처리
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleException(Exception e) {
return ResponseEntity.status(500)
.body(Map.of("message", "서버 오류가 발생했습니다"));
}
}
이게 정답이었어요!
장점:
✅ 예외 처리 로직이 한 곳에!
✅ 모든 Controller에 자동 적용
✅ 에러 응답 형식 통일 가능
✅ 새 예외 추가도 한 곳에서
🏗️ 커스텀 예외 설계
왜 커스텀 예외가 필요할까?
처음에는 기본 예외만 썼어요.
// 예전 코드
if (user == null) {
throw new RuntimeException("사용자를 찾을 수 없습니다");
}
if (password.length() < 8) {
throw new IllegalArgumentException("비밀번호는 8자 이상이어야 합니다");
}
문제점:
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<?> handleRuntimeException(RuntimeException e) {
// 🤔 이게 "사용자 없음"인지 "상품 없음"인지 어떻게 알아?
// 🤔 404를 줘야 해? 500을 줘야 해?
}
커스텀 예외 만들기
내가 설계한 구조:

// 1. 비즈니스 예외의 최상위 클래스
public abstract class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
// 2. 에러 코드 정의 (핵심!)
public enum ErrorCode {
// 400 Bad Request
INVALID_INPUT_VALUE(400, "C001", "잘못된 입력값입니다"),
INVALID_EMAIL_FORMAT(400, "C002", "이메일 형식이 올바르지 않습니다"),
// 401 Unauthorized
INVALID_TOKEN(401, "A001", "유효하지 않은 토큰입니다"),
EXPIRED_TOKEN(401, "A002", "만료된 토큰입니다"),
// 403 Forbidden
ACCESS_DENIED(403, "A003", "접근 권한이 없습니다"),
// 404 Not Found
USER_NOT_FOUND(404, "U001", "사용자를 찾을 수 없습니다"),
POST_NOT_FOUND(404, "P001", "게시글을 찾을 수 없습니다"),
// 409 Conflict
DUPLICATE_EMAIL(409, "U002", "이미 사용 중인 이메일입니다"),
// 500 Internal Server Error
INTERNAL_SERVER_ERROR(500, "S001", "서버 오류가 발생했습니다");
private final int status;
private final String code;
private final String message;
// 생성자, getter...
}
// 3. 구체적인 예외 클래스들
public class UserNotFoundException extends BusinessException {
public UserNotFoundException() {
super(ErrorCode.USER_NOT_FOUND);
}
}
public class DuplicateEmailException extends BusinessException {
public DuplicateEmailException() {
super(ErrorCode.DUPLICATE_EMAIL);
}
}
public class InvalidTokenException extends BusinessException {
public InvalidTokenException() {
super(ErrorCode.INVALID_TOKEN);
}
}
이렇게 하니까 뭐가 좋았나
1️⃣ 예외만 봐도 뭔지 알 수 있음
throw new UserNotFoundException();
→ "아, 사용자 못 찾았구나"
throw new RuntimeException("사용자 없음");
→ "음... 이게 뭔 예외지?"
2️⃣ HTTP 상태 코드가 자동으로 결정됨
UserNotFoundException → 404
DuplicateEmailException → 409
InvalidTokenException → 401
예외 클래스가 상태 코드를 알고 있으니까!
3️⃣ 에러 코드로 구분 가능
"U001" → 사용자 관련 첫 번째 에러 (User Not Found)
"P001" → 게시글 관련 첫 번째 에러 (Post Not Found)
프론트에서 코드로 분기 처리 가능!
📦 일관된 에러 응답 포맷
내가 겪은 문제
// 어떤 API는 이렇게
{ "message": "에러입니다" }
// 어떤 API는 이렇게
{ "error": "에러", "code": 500 }
// 또 어떤 API는 이렇게
{ "success": false, "data": null, "msg": "에러!" }
프론트엔드 입장에서 이거 처리하려면...
// 프론트 코드가 이 지경이 됨...
if (response.message) {
showError(response.message);
} else if (response.error) {
showError(response.error);
} else if (response.msg) {
showError(response.msg);
}
// 😱
통일된 에러 응답 설계

// 에러 응답 DTO
@Getter
public class ErrorResponse {
private final LocalDateTime timestamp;
private final int status;
private final String code;
private final String message;
private final List<FieldError> errors; // 검증 에러용
@Getter
public static class FieldError {
private final String field;
private final String value;
private final String reason;
}
// 기본 에러
public static ErrorResponse of(ErrorCode errorCode) {
return new ErrorResponse(
LocalDateTime.now(),
errorCode.getStatus(),
errorCode.getCode(),
errorCode.getMessage(),
new ArrayList<>()
);
}
// 검증 에러 (필드별 에러 포함)
public static ErrorResponse of(ErrorCode errorCode, BindingResult bindingResult) {
List<FieldError> fieldErrors = bindingResult.getFieldErrors().stream()
.map(error -> new FieldError(
error.getField(),
error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(),
error.getDefaultMessage()
))
.collect(Collectors.toList());
return new ErrorResponse(
LocalDateTime.now(),
errorCode.getStatus(),
errorCode.getCode(),
errorCode.getMessage(),
fieldErrors
);
}
}
응답 예시
// 일반 에러 (404 Not Found)
{
"timestamp": "2024-01-15T10:30:00",
"status": 404,
"code": "U001",
"message": "사용자를 찾을 수 없습니다",
"errors": []
}
// 검증 에러 (400 Bad Request)
{
"timestamp": "2024-01-15T10:30:00",
"status": 400,
"code": "C001",
"message": "잘못된 입력값입니다",
"errors": [
{
"field": "email",
"value": "invalid-email",
"reason": "이메일 형식이 올바르지 않습니다"
},
{
"field": "password",
"value": "",
"reason": "비밀번호는 필수입니다"
}
]
}
이제 프론트는 항상 같은 구조로 처리 가능!
// 프론트 에러 처리 (통일됨!)
const handleError = (error) => {
const { code, message, errors } = error.response.data;
if (errors.length > 0) {
// 필드별 에러 표시
errors.forEach(e => showFieldError(e.field, e.reason));
} else {
// 일반 에러 표시
showToast(message);
}
};
🎨 GlobalExceptionHandler 완성본
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// 1. 비즈니스 예외 처리
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
log.warn("BusinessException: {}", e.getMessage());
ErrorCode errorCode = e.getErrorCode();
ErrorResponse response = ErrorResponse.of(errorCode);
return ResponseEntity.status(errorCode.getStatus()).body(response);
}
// 2. 검증 예외 처리 (@Valid 실패)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
MethodArgumentNotValidException e) {
log.warn("Validation failed: {}", e.getMessage());
ErrorResponse response = ErrorResponse.of(
ErrorCode.INVALID_INPUT_VALUE,
e.getBindingResult()
);
return ResponseEntity.status(400).body(response);
}
// 3. 잘못된 HTTP 메서드 (GET인데 POST로 요청 등)
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleMethodNotSupported(
HttpRequestMethodNotSupportedException e) {
log.warn("Method not supported: {}", e.getMethod());
ErrorResponse response = ErrorResponse.of(ErrorCode.METHOD_NOT_ALLOWED);
return ResponseEntity.status(405).body(response);
}
// 4. 예상 못한 예외 (최후의 방어선)
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
// 이건 심각한 거니까 error 레벨로!
log.error("Unexpected error occurred", e);
ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
return ResponseEntity.status(500).body(response);
}
}
예외 처리 우선순위
@ExceptionHandler 우선순위:
1. 가장 구체적인 예외가 먼저 매칭
2. 부모 예외는 나중에 매칭
예:
UserNotFoundException (구체적) → 먼저 처리
BusinessException (부모) → 그 다음
Exception (최상위) → 마지막 (다 못 잡으면)
📝 예외와 로깅
어떤 레벨로 로깅해야 할까?
이것도 처음엔 헷갈렸어요.
내가 정한 기준:
📌 log.error() - 즉시 대응 필요!
- 예상 못한 Exception (NPE, DB 연결 실패 등)
- 시스템이 정상 작동 못하는 상황
- 알림 발송 대상
📌 log.warn() - 주의 필요
- 비즈니스 예외 (리소스 없음, 권한 없음 등)
- 잘못된 요청이지만 시스템은 정상
- 너무 많이 발생하면 확인 필요
📌 log.info() - 정상 흐름
- 중요 비즈니스 로직 수행 (주문 완료, 결제 성공 등)
📌 log.debug() - 개발/디버깅용
- 상세 실행 흐름
- 운영에서는 보통 OFF
로깅할 때 뭘 남겨야 할까?
// ❌ 안 좋은 로깅
log.error("에러 발생");
log.error(e.getMessage());
// 😐 그나마 나은 로깅
log.error("사용자 조회 실패: {}", e.getMessage());
// ✅ 좋은 로깅
log.error("사용자 조회 실패 - userId: {}, 원인: {}", userId, e.getMessage(), e);
로깅에 포함할 정보:
✅ 무슨 작업 중이었는지 (사용자 조회, 주문 생성 등)
✅ 관련 ID 값 (userId, orderId 등)
✅ 에러 메시지
✅ 스택 트레이스 (error 레벨일 때)
✅ 요청 정보 (필요시)
🛠️ 실무에서 자주 하는 실수들

실수 1: 예외 삼키기
// ❌ 최악의 패턴
try {
riskyOperation();
} catch (Exception e) {
// 아무것도 안 함... 예외가 사라짐!
}
// ❌ 이것도 별로
try {
riskyOperation();
} catch (Exception e) {
e.printStackTrace(); // 콘솔에만 찍히고 끝
}
// ✅ 제대로
try {
riskyOperation();
} catch (Exception e) {
log.error("작업 실패: {}", e.getMessage(), e);
throw new BusinessException(ErrorCode.OPERATION_FAILED);
}
실수 2: 너무 넓은 catch
// ❌ 모든 예외를 뭉뚱그려서
try {
// 여러 작업...
} catch (Exception e) {
// NPE도, IOException도, 내 예외도 다 여기로...
// 뭐가 뭔지 모름
}
// ✅ 구체적으로 분리
try {
// 여러 작업...
} catch (UserNotFoundException e) {
// 사용자 없음 처리
} catch (InsufficientBalanceException e) {
// 잔액 부족 처리
} catch (Exception e) {
// 예상 못한 에러 (로깅 후 재throw)
log.error("예상치 못한 에러", e);
throw e;
}
실수 3: 검증을 예외로 처리
// ❌ 예외를 흐름 제어로 사용
public boolean isEmailExists(String email) {
try {
userRepository.findByEmail(email);
return true;
} catch (UserNotFoundException e) {
return false;
}
}
// ✅ 정상적인 흐름 제어
public boolean isEmailExists(String email) {
return userRepository.existsByEmail(email);
}
예외는 "예외적인 상황"에만! 정상적인 비즈니스 흐름에 예외를 쓰면 안 돼요.
실수 4: 민감 정보 노출
// ❌ 위험!
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleException(Exception e) {
return ResponseEntity.status(500)
.body(Map.of(
"message", e.getMessage(),
"stackTrace", Arrays.toString(e.getStackTrace()) // 😱
));
}
// ✅ 안전
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleException(Exception e) {
log.error("Unexpected error", e); // 로그에만 남기고
return ResponseEntity.status(500)
.body(Map.of("message", "서버 오류가 발생했습니다")); // 사용자에겐 간단히
}
💡 오늘 배운 핵심 정리

1️⃣ @ControllerAdvice로 전역 처리
- 예외 처리 로직을 한 곳에!
- 모든 Controller에 자동 적용
2️⃣ 커스텀 예외 + ErrorCode 조합
- 예외만 봐도 뭔지 알 수 있게
- HTTP 상태 코드 자동 결정
- 에러 코드로 프론트 분기 처리
3️⃣ 일관된 에러 응답 포맷
- 항상 같은 구조로 응답
- 프론트 처리 로직 단순화
- timestamp, status, code, message, errors
4️⃣ 로깅 레벨 구분
- error: 즉시 대응 필요
- warn: 비즈니스 예외
- 스택 트레이스는 error에만
5️⃣ 사용자에겐 친절하게, 내부엔 상세하게
- 사용자: "이메일 형식이 올바르지 않습니다"
- 로그: 어떤 값이, 어디서, 왜 실패했는지
😊 좋았던 점 & 😅 아쉬웠던 점
좋았던 점
👍 디버깅 시간 단축
예전엔 에러 발생하면 "어디서 났지?" 찾느라 시간 썼는데, 이제 로그만 보면 바로 원인 파악!
👍 프론트엔드 협업 개선
"에러 응답 형식이 뭐예요?" → "ErrorResponse 클래스 보세요!" 한 방에 해결
👍 코드가 깔끔해짐
Controller에서 try-catch 제거하니까 비즈니스 로직만 남아서 읽기 좋아짐
아쉬웠던 점
😢 처음 설계할 때 고민 많이 필요
ErrorCode를 어떻게 분류할지, 예외 계층을 어떻게 나눌지 처음에 결정하기 어려웠어요
😢 팀원들과 규칙 공유 필요
혼자만 알면 안 되고, 팀 전체가 같은 방식으로 예외 처리해야 의미가 있어요
🚀 다음 학습 목표
📌 단기
✓ 프로젝트에 GlobalExceptionHandler 적용
✓ ErrorCode enum 정의
📌 중기
✓ 로깅 + 모니터링 연동 (에러 알림)
✓ API 문서에 에러 응답 명세 추가
📌 장기
✓ 분산 환경에서 에러 추적 (Trace ID)
✓ 에러 대시보드 구축
🎬 마치며
예외 처리를 공부하면서 가장 크게 느낀 건, **"에러 메시지는 사용자와의 대화"**라는 거예요.
나쁜 대화:
사용자: (로그인 시도)
서버: "Internal Server Error"
사용자: "...뭐?"
좋은 대화:
사용자: (로그인 시도)
서버: "비밀번호가 일치하지 않습니다. 다시 확인해주세요."
사용자: "아, 비밀번호 틀렸구나!"
에러가 발생했을 때 사용자가 다음에 뭘 해야 할지 알려주는 게 좋은 예외 처리인 것 같아요.
그리고 개발자를 위한 로깅도 중요해요. 새벽에 장애 알림 받았을 때, 로그만 보고 원인 파악할 수 있으면 1시간 절약이니까요! 😴
예외 처리, 귀찮아도 처음에 제대로 설계해두면 나중에 진짜 편합니다! 💪