Java 예외 처리 완벽 가이드: 예외 계층부터 Spring 전역 처리, 실무 전략까지
Java 예외 처리 완벽 가이드: 예외 계층부터 Spring 전역 처리, 실무 전략까지
면접에서 “Checked Exception과 Unchecked Exception의 차이를 설명해주세요”는 Java 면접의 단골 질문이다. 단순히 “컴파일 에러냐 런타임 에러냐”로 답하면 후속 질문에 막힌다. “그러면 왜 Unchecked를 더 많이 쓰나요?”, “커스텀 예외는 어떻게 설계하나요?”, “Spring에서 예외 처리 어떻게 하세요?” — 이 글은 예외의 내부 구조부터 실무 전략까지 모두 다룬다.
1. Java 예외 계층 구조
1.1 전체 계층도
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
┌─────────────────────────────────────────────────────────────────────┐
│ Java 예외 계층 구조 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Object │
│ │ │
│ Throwable │
│ ┌────┴────┐ │
│ Error Exception │
│ │ ┌───┴────────────┐ │
│ ┌─────┴─────┐ │ RuntimeException │
│ │ │ │ │ │
│ OutOfMemory StackOver │ ┌─────┼──────────┐ │
│ Error flowError │ │ │ │ │
│ │ NullPointer │ IllegalArgument│
│ ┌─────┴─────┐ Exception│ Exception │
│ │ │ │ │
│ IOException SQL IndexOutOf │
│ │ Exception BoundsException │
│ FileNot │
│ FoundException │
│ │
│ ◀── Checked ──▶ ◀── Checked ──▶ ◀── Unchecked ──▶ │
│ (Error: 잡으면 안 됨) (반드시 처리) (선택적 처리) │
│ │
└─────────────────────────────────────────────────────────────────────┘
1.2 세 가지 분류
| 분류 | 상위 클래스 | 컴파일러 강제 | 예시 |
|---|---|---|---|
| Error | Error |
X | OutOfMemoryError, StackOverflowError |
| Checked Exception | Exception (RuntimeException 제외) |
O | IOException, SQLException, FileNotFoundException |
| Unchecked Exception | RuntimeException |
X | NullPointerException, IllegalArgumentException |
Error는 JVM 수준의 심각한 오류로, 애플리케이션에서 잡으면 안 된다. 메모리 부족(OOM)이나 스택 오버플로우는 catch로 해결할 수 있는 문제가 아니기 때문이다.
2. Checked vs Unchecked Exception
2.1 핵심 차이
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌─────────────────────────────────────────────────────────────────────┐
│ Checked vs Unchecked Exception │
├──────────────────────┬──────────────────────────────────────────────┤
│ │ │
│ Checked Exception │ Unchecked Exception (RuntimeException) │
│ │ │
│ ● 컴파일러가 처리를 │ ● 컴파일러가 강제하지 않음 │
│ 강제함 │ │
│ ● try-catch 또는 │ ● try-catch 없어도 컴파일 됨 │
│ throws 필수 │ │
│ ● "예측 가능한 외부 │ ● "프로그래밍 실수"로 발생 │
│ 문제"에 사용 │ │
│ ● 파일 I/O, 네트워크│ ● null 참조, 잘못된 인자, │
│ DB 연결 등 │ 배열 범위 초과 등 │
│ │ │
│ IOException │ NullPointerException │
│ SQLException │ IllegalArgumentException │
│ FileNotFoundException│ IllegalStateException │
│ ClassNotFoundException│ IndexOutOfBoundsException │
│ │ ClassCastException │
│ │ │
└──────────────────────┴──────────────────────────────────────────────┘
2.2 Checked Exception 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 컴파일 에러! — IOException을 반드시 처리해야 함
public String readFile(String path) {
BufferedReader reader = new BufferedReader(new FileReader(path)); // 컴파일 에러
return reader.readLine();
}
// 방법 1: try-catch로 직접 처리
public String readFile(String path) {
try {
BufferedReader reader = new BufferedReader(new FileReader(path));
return reader.readLine();
} catch (IOException e) {
throw new RuntimeException("파일 읽기 실패: " + path, e);
}
}
// 방법 2: throws로 호출자에게 위임
public String readFile(String path) throws IOException {
BufferedReader reader = new BufferedReader(new FileReader(path));
return reader.readLine();
}
2.3 Unchecked Exception 예시
1
2
3
4
5
6
7
8
9
10
11
12
// 컴파일은 되지만 실행 시 예외 발생
public void process(String name) {
System.out.println(name.length()); // name이 null이면 NullPointerException
}
// 방어적 코드로 예방
public void process(String name) {
if (name == null) {
throw new IllegalArgumentException("name은 null일 수 없습니다");
}
System.out.println(name.length());
}
2.4 왜 최근에는 Unchecked를 선호하는가?
Checked Exception은 좋은 의도(예외 처리를 강제)로 만들어졌지만, 실무에서 여러 문제가 생겼다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 문제 1: 의미 없는 catch — 잡아놓고 아무것도 안 함
try {
doSomething();
} catch (SomeCheckedException e) {
// 뭘 해야 할지 모르겠어서 그냥 무시...
}
// 문제 2: throws 전파 지옥 — 메서드 시그니처 오염
public void a() throws IOException, SQLException { b(); }
public void b() throws IOException, SQLException { c(); }
public void c() throws IOException, SQLException { /* 실제 발생 */ }
// → 모든 호출 체인에 throws가 전파됨
// 문제 3: 캡슐화 위반 — 하위 구현의 예외가 상위로 노출
public interface UserRepository {
User findById(Long id) throws SQLException; // 구현 기술이 인터페이스에 노출!
}
Spring, JPA 등 모던 프레임워크는 대부분 Unchecked Exception을 사용한다.
1
2
3
// Spring의 DataAccessException → RuntimeException의 하위 클래스
// JPA의 PersistenceException → RuntimeException의 하위 클래스
// → 개발자가 "필요한 곳에서만" 선택적으로 catch
3. 예외 처리 문법
3.1 try-catch-finally
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void processFile(String path) {
FileInputStream fis = null;
try {
fis = new FileInputStream(path);
// 파일 처리 로직
int data = fis.read();
} catch (FileNotFoundException e) {
// 파일이 없을 때 처리
log.error("파일을 찾을 수 없습니다: {}", path, e);
} catch (IOException e) {
// I/O 에러 처리 (더 넓은 범위)
log.error("파일 읽기 실패", e);
} finally {
// 예외 발생 여부와 무관하게 항상 실행
if (fis != null) {
try {
fis.close();
} catch (IOException e) {
log.error("파일 닫기 실패", e);
}
}
}
}
catch 순서 주의: 하위 예외 → 상위 예외 순서로 작성해야 한다.
1
2
3
4
5
6
7
8
9
// 컴파일 에러! IOException이 FileNotFoundException보다 넓으므로 아래에 와야 함
try { ... }
catch (IOException e) { ... } // 넓은 범위가 먼저 오면
catch (FileNotFoundException e) { ... } // 여기에 절대 도달 못함 (dead code)
// 올바른 순서
try { ... }
catch (FileNotFoundException e) { ... } // 좁은 범위 먼저
catch (IOException e) { ... } // 넓은 범위 나중에
3.2 try-with-resources (Java 7+)
AutoCloseable을 구현한 리소스를 자동으로 닫아주는 문법이다. finally에서 close()를 직접 호출할 필요가 없다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// before: try-finally (장황하고, close()에서도 예외 처리 필요)
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(path));
return reader.readLine();
} finally {
if (reader != null) {
try { reader.close(); } catch (IOException e) { /* ... */ }
}
}
// after: try-with-resources (깔끔!)
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
return reader.readLine();
} // reader.close()가 자동 호출됨
1
2
3
4
5
6
7
8
9
10
// 여러 리소스도 가능 (세미콜론으로 구분)
try (
Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()
) {
while (rs.next()) {
// 결과 처리
}
} // rs → stmt → conn 순서로 자동 close (선언 역순)
커스텀 리소스에도 적용:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyResource implements AutoCloseable {
public void doWork() {
System.out.println("작업 수행");
}
@Override
public void close() {
System.out.println("리소스 정리");
}
}
try (MyResource resource = new MyResource()) {
resource.doWork();
} // "리소스 정리" 자동 호출
3.3 멀티 catch (Java 7+)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// before: 같은 처리를 하는 catch가 여러 개
try {
doSomething();
} catch (IllegalArgumentException e) {
log.error("에러 발생", e);
throw e;
} catch (IllegalStateException e) {
log.error("에러 발생", e);
throw e;
}
// after: 파이프(|)로 묶기
try {
doSomething();
} catch (IllegalArgumentException | IllegalStateException e) {
log.error("에러 발생", e);
throw e;
}
3.4 throw vs throws
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// throw: 예외를 직접 던짐 (메서드 내부)
public void withdraw(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("출금액은 0보다 커야 합니다");
}
if (amount > this.balance) {
throw new InsufficientBalanceException("잔액 부족");
}
this.balance -= amount;
}
// throws: 이 메서드가 해당 예외를 던질 수 있음을 선언 (메서드 시그니처)
public byte[] readFile(String path) throws IOException {
return Files.readAllBytes(Path.of(path));
}
4. 예외 계층 설계 (커스텀 예외)
4.1 왜 커스텀 예외를 만드는가?
1
2
3
4
5
6
7
8
9
10
11
// 나쁜 예: 범용 예외로 모든 상황을 처리
throw new RuntimeException("사용자를 찾을 수 없습니다");
throw new RuntimeException("잔액이 부족합니다");
throw new RuntimeException("이미 처리된 주문입니다");
// → catch에서 메시지 문자열로 구분해야 함 (취약하고 유지보수 어려움)
// 좋은 예: 상황별 커스텀 예외
throw new UserNotFoundException(userId);
throw new InsufficientBalanceException(balance, amount);
throw new OrderAlreadyProcessedException(orderId);
// → 타입으로 구분 가능, 각 예외에 맞는 정보를 담을 수 있음
4.2 실무 예외 계층 설계
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌─────────────────────────────────────────────────────────────────────┐
│ 실무 예외 계층 설계 예시 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ RuntimeException │
│ │ │
│ BusinessException (추상) │
│ │ - ErrorCode code │
│ │ - String message │
│ │ │
│ ┌─────────┼──────────┬──────────────┐ │
│ │ │ │ │ │
│ EntityNot InvalidInput Duplicate Unauthorized │
│ FoundExc. Exception Exception Exception │
│ │ │
│ ┌─────┴──────┐ │
│ │ │ │
│ UserNot OrderNot │
│ FoundExc. FoundExc. │
│ │
└─────────────────────────────────────────────────────────────────────┘
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
// 1. ErrorCode 정의
@Getter
@AllArgsConstructor
public enum ErrorCode {
// 400 Bad Request
INVALID_INPUT(HttpStatus.BAD_REQUEST, "잘못된 입력값입니다"),
DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "이미 사용 중인 이메일입니다"),
// 401 Unauthorized
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다"),
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다"),
// 403 Forbidden
ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근 권한이 없습니다"),
// 404 Not Found
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다"),
ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "주문을 찾을 수 없습니다"),
// 409 Conflict
ORDER_ALREADY_PROCESSED(HttpStatus.CONFLICT, "이미 처리된 주문입니다"),
// 500 Internal Server Error
INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다");
private final HttpStatus status;
private final String message;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 2. 공통 비즈니스 예외 (최상위)
@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public BusinessException(ErrorCode errorCode, String detailMessage) {
super(detailMessage);
this.errorCode = errorCode;
}
}
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
32
// 3. 구체적인 예외 클래스
public class EntityNotFoundException extends BusinessException {
public EntityNotFoundException(ErrorCode errorCode) {
super(errorCode);
}
}
public class UserNotFoundException extends EntityNotFoundException {
public UserNotFoundException(Long userId) {
super(ErrorCode.USER_NOT_FOUND);
}
}
public class DuplicateException extends BusinessException {
public DuplicateException(ErrorCode errorCode) {
super(errorCode);
}
}
public class InvalidInputException extends BusinessException {
public InvalidInputException(ErrorCode errorCode) {
super(errorCode);
}
public InvalidInputException(ErrorCode errorCode, String detail) {
super(errorCode, detail);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 4. 서비스에서 사용
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
public User register(RegisterRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateException(ErrorCode.DUPLICATE_EMAIL);
}
return userRepository.save(request.toEntity());
}
}
5. Spring 예외 처리
5.1 @ExceptionHandler (컨트롤러 단위)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id) {
return userService.findById(id);
}
// 이 컨트롤러 내에서 발생하는 UserNotFoundException만 처리
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
ErrorResponse response = new ErrorResponse(e.getErrorCode());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
}
문제점: 컨트롤러마다 @ExceptionHandler를 작성해야 한다 → 중복 코드 폭발.
5.2 @ControllerAdvice (전역 처리)
모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리한다.
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 1. 비즈니스 예외 처리 (우리가 정의한 예외)
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
log.warn("Business exception: {}", e.getMessage());
ErrorCode code = e.getErrorCode();
return ResponseEntity
.status(code.getStatus())
.body(new ErrorResponse(code.name(), e.getMessage()));
}
// 2. 요청 데이터 검증 실패 (@Valid)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("INVALID_INPUT", message));
}
// 3. 잘못된 HTTP 메서드
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleMethodNotSupported(
HttpRequestMethodNotSupportedException e) {
return ResponseEntity
.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(new ErrorResponse("METHOD_NOT_ALLOWED", e.getMessage()));
}
// 4. 타입 미스매치 (PathVariable, RequestParam)
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleTypeMismatch(
MethodArgumentTypeMismatchException e) {
String message = e.getName() + "의 타입이 올바르지 않습니다";
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("TYPE_MISMATCH", message));
}
// 5. 최후의 방어선 — 예상하지 못한 예외
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
log.error("Unexpected exception", e); // 스택 트레이스 전체 로깅
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "서버 내부 오류가 발생했습니다"));
// 클라이언트에게는 내부 정보 노출하지 않음!
}
}
1
2
3
4
5
6
7
8
9
10
11
12
// ErrorResponse DTO
@Getter
@AllArgsConstructor
public class ErrorResponse {
private String code;
private String message;
public ErrorResponse(ErrorCode errorCode) {
this.code = errorCode.name();
this.message = errorCode.getMessage();
}
}
5.3 예외 처리 흐름 전체도
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
32
┌─────────────────────────────────────────────────────────────────────┐
│ Spring 예외 처리 흐름 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Client Request │
│ │ │
│ ▼ │
│ DispatcherServlet │
│ │ │
│ ▼ │
│ Controller → Service → Repository │
│ │ │ │
│ │ 예외 발생! │
│ │ │ │
│ │ throw BusinessException │
│ │ │ │
│ ◀────────────────────┘ (예외가 Controller까지 전파) │
│ │ │
│ ▼ │
│ ① @ExceptionHandler (해당 Controller 내) │
│ │ 없으면 ↓ │
│ ② @ControllerAdvice (전역) │
│ │ 없으면 ↓ │
│ ③ Spring 기본 에러 처리 (/error) │
│ │ │
│ ▼ │
│ ErrorResponse (JSON) → Client │
│ │
│ 우선순위: ① > ② > ③ │
│ 컨트롤러 내 @ExceptionHandler가 @ControllerAdvice보다 우선 │
│ │
└─────────────────────────────────────────────────────────────────────┘
5.4 응답 형식 통일
성공과 실패 모두 일관된 형식으로 응답하면 프론트엔드에서 처리하기 편하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 성공 응답
{
"code": "SUCCESS",
"message": "요청이 성공했습니다",
"data": {
"id": 1,
"name": "홍길동",
"email": "hong@example.com"
}
}
// 에러 응답
{
"code": "USER_NOT_FOUND",
"message": "사용자를 찾을 수 없습니다"
}
// 검증 에러 응답
{
"code": "INVALID_INPUT",
"message": "email: 이메일 형식이 올바르지 않습니다, name: 이름은 필수입니다"
}
6. 예외 처리 실무 전략
6.1 예외 처리 원칙
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
┌─────────────────────────────────────────────────────────────────────┐
│ 예외 처리 5대 원칙 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 예외를 무시하지 마라 │
│ catch (Exception e) { } ← 절대 금지! │
│ 최소한 로그라도 남겨야 한다 │
│ │
│ 2. 범용 예외를 잡지 마라 │
│ catch (Exception e) ← 가능한 구체적인 예외를 잡아라 │
│ 어떤 예외인지 모르면 적절한 대응이 불가능하다 │
│ │
│ 3. 예외를 제어 흐름에 사용하지 마라 │
│ try { int i = Integer.parseInt(s); } │
│ catch (NumberFormatException e) { /* 숫자가 아닌 경우 */ } │
│ → if로 먼저 검사하는 게 올바른 방법 │
│ │
│ 4. 가능한 일찍 던지고, 가능한 늦게 잡아라 │
│ Throw early: 잘못된 입력은 즉시 검증 │
│ Catch late: 처리할 수 있는 계층에서 잡아라 │
│ │
│ 5. 원인 예외(cause)를 보존하라 │
│ throw new CustomException("메시지", e); ← e를 반드시 전달 │
│ 원인을 누락하면 디버깅이 불가능해진다 │
│ │
└─────────────────────────────────────────────────────────────────────┘
6.2 계층별 예외 처리 전략
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌─────────────────────────────────────────────────────────────────────┐
│ 계층별 예외 처리 전략 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Controller (표현 계층) │
│ ├── 예외를 직접 처리하지 않음 │
│ ├── @ControllerAdvice에 위임 │
│ └── 입력 검증(@Valid)만 담당 │
│ │
│ Service (비즈니스 계층) │
│ ├── 비즈니스 규칙 위반 시 커스텀 예외 throw │
│ ├── Repository의 예외를 비즈니스 예외로 변환 │
│ └── 핵심 로직에 대한 예외 판단 │
│ │
│ Repository (데이터 접근 계층) │
│ ├── Spring이 자동으로 DataAccessException으로 변환 │
│ ├── SQL 에러 → 추상화된 예외 (Unchecked) │
│ └── 직접 예외를 던질 필요가 거의 없음 │
│ │
└─────────────────────────────────────────────────────────────────────┘
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
// Service 계층 예시 — 예외 변환
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final UserRepository userRepository;
@Transactional
public OrderResponse createOrder(Long userId, OrderCreateRequest request) {
// 1. 존재하지 않는 사용자 → 비즈니스 예외
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
// 2. 비즈니스 규칙 검증 → 비즈니스 예외
if (request.getAmount() <= 0) {
throw new InvalidInputException(
ErrorCode.INVALID_INPUT, "주문 금액은 0보다 커야 합니다");
}
// 3. 중복 주문 검증
if (orderRepository.existsByUserIdAndProductId(userId, request.getProductId())) {
throw new DuplicateException(ErrorCode.ORDER_ALREADY_PROCESSED);
}
Order order = Order.create(user, request);
return OrderResponse.from(orderRepository.save(order));
}
}
6.3 예외 로깅 전략
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 비즈니스 예외: WARN 레벨 (예상된 상황)
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
log.warn("[{}] {}", e.getErrorCode(), e.getMessage());
// 스택 트레이스 불필요 — 예상된 예외이므로
return createResponse(e.getErrorCode());
}
// 시스템 예외: ERROR 레벨 + 스택 트레이스
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
log.error("Unexpected error occurred", e);
// 스택 트레이스 포함 — 디버깅에 필수
return createResponse(ErrorCode.INTERNAL_ERROR);
}
}
| 예외 유형 | 로그 레벨 | 스택 트레이스 | 이유 |
|---|---|---|---|
| 비즈니스 예외 | WARN |
X | 예상된 상황, 메시지만으로 충분 |
| 검증 실패 | WARN |
X | 클라이언트 잘못, 서버 문제 아님 |
| 시스템 예외 | ERROR |
O | 예상치 못한 버그, 원인 추적 필수 |
6.4 안티패턴 모음
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
32
33
34
35
36
37
38
39
40
41
42
43
44
// ❌ 안티패턴 1: 예외 삼키기 (swallowing)
try {
riskyOperation();
} catch (Exception e) {
// 아무것도 안 함 → 장애 원인을 절대 찾을 수 없음
}
// ❌ 안티패턴 2: 원인 예외 누락
try {
parseData(input);
} catch (ParseException e) {
throw new BusinessException(ErrorCode.INVALID_INPUT);
// e를 전달하지 않음 → 원래 어디서 터졌는지 알 수 없음
}
// ✅ 올바른 방법
catch (ParseException e) {
throw new BusinessException(ErrorCode.INVALID_INPUT, e); // cause 전달
}
// ❌ 안티패턴 3: catch 후 무조건 printStackTrace()
catch (Exception e) {
e.printStackTrace(); // 표준 에러 출력 → 로그 시스템 우회, 운영에서 추적 불가
}
// ✅ 올바른 방법
catch (Exception e) {
log.error("처리 실패", e); // SLF4J 로거 사용
}
// ❌ 안티패턴 4: 무의미한 re-throw
try {
doSomething();
} catch (IOException e) {
throw e; // 잡아놓고 다시 던지기만 함 → try-catch가 의미 없음
}
// ❌ 안티패턴 5: 예외로 비즈니스 로직 제어
public boolean isValidEmail(String email) {
try {
new InternetAddress(email).validate();
return true;
} catch (AddressException e) {
return false; // 예외를 if-else처럼 사용 → 성능 저하, 의도 불명확
}
}
7. Spring의 예외 추상화
7.1 DataAccessException 계층
Spring은 JDBC, JPA, MyBatis 등 다양한 데이터 접근 기술의 예외를 통일된 계층으로 추상화한다.
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
┌─────────────────────────────────────────────────────────────────────┐
│ Spring DataAccessException 계층 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ DataAccessException (Unchecked) │
│ │ │
│ ┌───────────────┼───────────────────┐ │
│ │ │ │ │
│ NonTransient Transient Recoverable │
│ DataAccess DataAccess DataAccess │
│ Exception Exception Exception │
│ │ │ │
│ ┌─────┴─────┐ QueryTimeout │
│ │ │ Exception │
│ Duplicate DataIntegrity │
│ Key Violation │
│ Exception Exception │
│ │
│ JDBC: SQLException ──→ Spring이 자동 변환 ──→ DataAccessException │
│ JPA: PersistenceException ──→ Spring이 자동 변환 │
│ │
│ 장점: │
│ ● DB 기술을 바꿔도 예외 처리 코드 변경 불필요 │
│ ● Checked → Unchecked로 변환 (try-catch 강제 없음) │
│ ● 구체적인 예외 타입으로 세분화 │
│ │
└─────────────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
// JDBC: SQLException(Checked)을 직접 처리해야 함
try {
jdbcTemplate.update(sql);
} catch (SQLException e) {
// DB마다 에러 코드가 다름 (MySQL: 1062, PostgreSQL: 23505)
}
// Spring: DataAccessException(Unchecked)으로 추상화
try {
jdbcTemplate.update(sql);
} catch (DuplicateKeyException e) {
// DB가 뭐든 동일한 예외 타입
}
면접 예상 질문 & 답변
Q1. Checked Exception과 Unchecked Exception의 차이를 설명해주세요.
Checked Exception은
Exception의 하위 클래스 중RuntimeException을 제외한 것으로, 컴파일러가 반드시 처리를 강제합니다. try-catch로 잡거나 throws로 선언해야 컴파일됩니다.IOException,SQLException등이 해당합니다.Unchecked Exception은
RuntimeException의 하위 클래스로, 컴파일러가 처리를 강제하지 않습니다.NullPointerException,IllegalArgumentException등이 대표적입니다.Checked는 외부 환경의 예측 가능한 문제(파일, 네트워크, DB)에, Unchecked는 프로그래밍 실수에 사용합니다. 최근에는 Spring, JPA 등 프레임워크 대부분이 Unchecked를 사용하는데, Checked가 메서드 시그니처를 오염시키고 캡슐화를 위반하는 문제가 있기 때문입니다.
Q2. try-with-resources란 무엇인가요?
Java 7에서 도입된 문법으로,
AutoCloseable인터페이스를 구현한 리소스를 try 블록이 끝나면 자동으로 닫아주는 기능입니다.기존에는 finally에서 직접 close()를 호출해야 했고, close()에서도 예외가 발생할 수 있어서 코드가 복잡했습니다. try-with-resources를 쓰면
try (InputStream is = ...)형태로 선언하면 되고, 여러 리소스는 세미콜론으로 구분하며 선언 역순으로 자동 close됩니다.DB Connection, InputStream, BufferedReader 등 닫아야 하는 리소스에는 항상 try-with-resources를 사용해야 합니다.
Q3. @ControllerAdvice는 무엇이고 왜 사용하나요?
@ControllerAdvice는 모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리할 수 있게 해주는 Spring의 전역 예외 처리 어노테이션입니다.각 컨트롤러마다 @ExceptionHandler를 작성하면 중복 코드가 발생합니다. @ControllerAdvice에 @ExceptionHandler를 모아두면 예외 처리 로직을 중앙 집중화할 수 있습니다.
@RestControllerAdvice를 쓰면 @ResponseBody가 자동 적용되어 JSON으로 응답합니다.보통 BusinessException, MethodArgumentNotValidException 등 예외 유형별로 처리 메서드를 분리하고, 마지막에
Exception.class를 잡는 최후의 방어선을 두는 구조로 설계합니다.
Q4. 예외 처리 시 주의할 점은?
첫째, 예외를 삼키면 안 됩니다.
catch (Exception e) { }처럼 아무것도 안 하면 장애 원인을 찾을 수 없습니다. 최소한 로그를 남겨야 합니다.둘째, 원인 예외(cause)를 보존해야 합니다.
throw new CustomException("메시지", e)처럼 원래 예외를 cause로 전달해야 디버깅이 가능합니다.셋째, e.printStackTrace() 대신 로거를 사용해야 합니다. printStackTrace()는 표준 에러로 출력되어 운영 환경에서 로그 관리가 안 됩니다.
넷째, 가능한 구체적인 예외를 잡아야 합니다.
catch (Exception e)로 모든 예외를 뭉뚱그리면 적절한 대응이 불가능합니다.
Q5. Error와 Exception의 차이는?
Error는
OutOfMemoryError,StackOverflowError처럼 JVM 수준의 심각한 오류입니다. 애플리케이션에서 복구할 수 없으므로 catch하면 안 됩니다.Exception은 애플리케이션 레벨에서 예측하고 대응할 수 있는 예외입니다. Checked와 Unchecked로 나뉘며, 적절한 처리가 가능합니다.
둘 다
Throwable의 하위 클래스이지만, Error는 “시스템 문제”, Exception은 “프로그램 문제”로 구분할 수 있습니다.
Q6. Spring에서 예외가 전파되는 순서를 설명해주세요.
Service나 Repository에서 예외가 발생하면 Controller까지 전파됩니다. 그 후 Spring은 다음 순서로 처리할 핸들러를 찾습니다.
- 해당 Controller의 @ExceptionHandler — 컨트롤러 내에서 정의한 핸들러
- @ControllerAdvice의 @ExceptionHandler — 전역 핸들러
- Spring 기본 에러 처리 (
/error경로)컨트롤러 레벨이 전역보다 우선순위가 높고, 같은 레벨에서는 구체적인 예외 타입이 우선합니다. 예를 들어
UserNotFoundException용 핸들러가BusinessException용 핸들러보다 먼저 매칭됩니다.
Q7. 커스텀 예외를 왜 만드나요? RuntimeException만 써도 되지 않나요?
throw new RuntimeException("사용자 없음")으로도 동작은 하지만, catch에서 문자열로 예외를 구분해야 하는 문제가 생깁니다. 메시지가 바뀌면 catch 로직이 깨집니다.커스텀 예외를 만들면 타입으로 구분할 수 있고(
catch (UserNotFoundException e)), 예외에 필요한 정보(userId, errorCode 등)를 담을 수 있습니다. 또한 @ControllerAdvice에서 예외 유형별로 HTTP 상태코드와 응답을 매핑하기 편해집니다.보통
BusinessException같은 공통 상위 예외를 만들고, 그 아래에EntityNotFoundException,DuplicateException등을 두는 계층 구조를 설계합니다.
마무리
예외 처리는 “에러가 나면 try-catch로 잡는 것”이 아니라, 시스템의 안정성을 설계하는 행위다. 이 글에서 다룬 내용을 정리하면:
- 예외 계층: Error(시스템) / Checked(외부 환경) / Unchecked(프로그래밍 실수)
- 핵심 문법: try-catch-finally, try-with-resources, throw/throws, 멀티 catch
- 커스텀 예외: ErrorCode 기반 계층 설계, BusinessException 패턴
- Spring 처리: @ExceptionHandler → @ControllerAdvice → 전역 처리
- 실무 전략: 계층별 역할 분리, 로깅 전략, 안티패턴 회피
예외 처리를 잘하는 개발자는 장애가 터져도 로그만 보고 원인을 찾을 수 있다. 예외를 잘 설계하면 코드를 읽는 것만으로 “이 시스템이 어떤 실패 상황을 예상하고 있는가”를 알 수 있다.