JAVA

Java 예외 처리 제대로 하기: Checked vs Unchecked부터 실무 전략까지

IT Lab 2026. 2. 17. 10:00

Java 17 기준으로 Checked/Unchecked 예외 선택 기준, try-with-resources, 커스텀 예외 설계와 실무 예외 처리 전략을 한 번에 정리합니다.

 

운영 장애를 분석하다 보면 “로그에는 예외가 있는데 사용자는 그냥 실패했다는 메시지만 본다” 같은 상황을 자주 마주하게 됩니다. 혹은 반대로, 메서드마다 throws Exception이 붙어서 호출부가 도미노처럼 지저분해진 경험도 있으실 거예요. Java 예외 처리는 “잡기만 하면 끝”이 아니라, 설계와 전략이 함께 가야 유지보수가 쉬워집니다.

핵심 개념 (Java 예외 처리 전략과 선택 기준)

Java 예외 처리에서 내부 계층과 경계 계층의 책임 분리 다이어그램

예외 처리는 크게 “어떤 예외를 던질지(Checked vs Unchecked)”와 “어디서 잡을지(경계에서 처리)”를 결정하는 문제입니다. 이 두 가지가 흔들리면 코드가 금방 복잡해지고, 장애 시 원인 추적도 어려워져요.

Checked vs Unchecked: 무엇이 더 좋은가가 아니라, 무엇을 표현하나

  • **Checked Exception(검사 예외)**는 컴파일러가 처리를 강제합니다. “호출자가 의미 있게 복구할 수 있는 상황”을 표현할 때 가치가 있어요. 예를 들어 “파일이 없으니 다른 경로를 시도한다”, “재시도 정책을 적용한다”처럼 호출자가 선택지를 가질 때입니다.
  • **Unchecked Exception(런타임 예외)**는 보통 “프로그래밍 오류” 또는 “복구 불가능/복구 책임이 호출자에게 없는 상황”에 씁니다. 예: NullPointerException, IllegalArgumentException, 도메인 규칙 위반을 런타임으로 표현하는 경우 등.

중요한 포인트는 “예외는 제어 흐름을 위한 if 대체재가 아니라”, 실패의 맥락을 담아 경계를 넘어 전달하는 메커니즘이라는 점입니다. 비유하자면, 예외는 ‘상태 코드’가 아니라 ‘사고 보고서’에 가까워요. 누가(어떤 계층) 보고서를 읽고(처리) 어떤 조치를 할지(복구/전환/알림)까지 설계해야 합니다.

try-with-resources: 리소스 누수는 실무에서 가장 비싼 버그 중 하나

DB 커넥션, 파일 핸들, 스트림 같은 리소스는 “반드시 닫혀야” 합니다. try-with-resourcesAutoCloseable을 자동으로 닫아주고, 예외가 발생해도 close가 보장됩니다. 특히 close 중 발생한 예외는 suppressed exception으로 원래 예외에 딸려가므로(유실 방지) 디버깅에도 유리합니다.

커스텀 예외: 메시지보다 “타입”이 의사결정에 도움 됩니다

실무에서는 문자열 메시지로 분기하기보다, 예외 타입으로 의미를 전달하는 편이 안정적입니다. 예를 들어 UserNotFoundException, DuplicateEmailException처럼 도메인 실패를 명확히 표현하면, 상위 계층(예: API 레이어)에서 HTTP 상태 코드나 에러 응답을 일관되게 매핑하기 쉬워집니다.

실무 예외 전략: “경계에서 잡고, 내부에서는 의미 있게 던진다”

  • 내부(도메인/서비스): 원인(cause)을 보존하면서 의미 있는 예외로 변환(랩핑)합니다.
  • 경계(API 컨트롤러, 메시지 컨슈머, 배치 잡 엔트리포인트): 예외를 한 곳에서 로깅/응답 변환/재시도 정책 적용 등으로 마무리합니다.
  • 절대 피하고 싶은 패턴: catch (Exception e) {} (삼키기), throws Exception 남발, 로그를 여러 계층에서 중복으로 찍기.

아래 표는 “언제 Checked/Unchecked를 선택할지” 빠르게 판단할 수 있게 정리한 가이드입니다.

구분 Checked Exception Unchecked Exception
목적 호출자가 복구/대체 경로를 선택하도록 강제 복구 불가 또는 프로그래밍/도메인 규칙 위반 표현
호출부 영향 try/catch 또는 throws 전파가 필수 선택적으로 처리, 보통 경계에서 일괄 처리
적합한 예 “다른 파일 경로로 재시도”, “대체 서버로 페일오버” “잘못된 인자”, “상태 불변식 위반”, “존재하지 않는 리소스”
남용 시 문제 throws 전파 도미노, 시그니처 오염 런타임에만 드러나 호출부 책임이 모호해짐

코드 예제 (Checked/Unchecked, try-with-resources, 커스텀 예외, 경계 처리까지)

아래 코드는 Java 17에서 그대로 실행 가능한 단일 파일 예제입니다.

  • 파일 읽기(IO)는 Checked로 다루고
  • 도메인 검증 실패는 Unchecked 커스텀 예외로 표현하며
  • 엔트리포인트에서 예외를 한 번에 처리합니다.
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class ExceptionHandlingDemo {

    // ---- Custom Exceptions (Domain-oriented) ----
    static class BusinessException extends RuntimeException {
        private final String errorCode;

        BusinessException(String errorCode, String message) {
            super(message);
            this.errorCode = errorCode;
        }

        BusinessException(String errorCode, String message, Throwable cause) {
            super(message, cause);
            this.errorCode = errorCode;
        }

        public String getErrorCode() {
            return errorCode;
        }
    }

    static class UserNotFoundException extends BusinessException {
        UserNotFoundException(String userId) {
            super("USER_NOT_FOUND", "User not found. userId=" + userId);
        }
    }

    static class InvalidEmailException extends BusinessException {
        InvalidEmailException(String email) {
            super("INVALID_EMAIL", "Invalid email format. email=" + email);
        }
    }

    // ---- Domain model ----
    record User(String id, String email) {}

    // ---- Infrastructure (Checked exception example) ----
    static class UserFileRepository {
        private final Path filePath;

        UserFileRepository(Path filePath) {
            this.filePath = filePath;
        }

        /**
         * 파일 접근은 호출자가 복구 전략(다른 파일, 재시도 등)을 가질 수 있으므로
         * IOException(Checked)을 그대로 노출하는 편이 합리적일 때가 많습니다.
         */
        User findById(String userId) throws IOException {
            try (BufferedReader br = Files.newBufferedReader(filePath)) { // try-with-resources
                String line;
                while ((line = br.readLine()) != null) {
                    // format: id,email
                    String[] parts = line.split(",", -1);
                    if (parts.length >= 2 && parts[0].equals(userId)) {
                        return new User(parts[0], parts[1]);
                    }
                }
                return null;
            }
        }
    }

    // ---- Service (Unchecked exception example) ----
    static class UserService {
        private final UserFileRepository repository;

        UserService(UserFileRepository repository) {
            this.repository = repository;
        }

        /**
         * - IO 문제는 상위에서 복구 가능성이 있으니 IOException을 전파(Checked)
         * - 도메인 실패는 커스텀 RuntimeException으로 의미를 전달(Unchecked)
         */
        String getVerifiedEmail(String userId) throws IOException {
            User user = repository.findById(userId);
            if (user == null) {
                throw new UserNotFoundException(userId);
            }
            if (!looksLikeEmail(user.email())) {
                throw new InvalidEmailException(user.email());
            }
            return user.email();
        }

        private boolean looksLikeEmail(String email) {
            // 단순 예시: 실무에서는 더 엄격한 검증 또는 검증 책임의 위치를 재검토하세요.
            return email != null && email.contains("@") && email.indexOf('@') > 0 && email.indexOf('@') < email.length() - 1;
        }
    }

    // ---- Boundary (e.g., Controller/CLI/Batch entrypoint) ----
    public static void main(String[] args) {
        // args[0] = filePath, args[1] = userId
        Path filePath = args.length >= 1 ? Path.of(args[0]) : Path.of("users.csv");
        String userId = args.length >= 2 ? args[1] : "u-1";

        UserService service = new UserService(new UserFileRepository(filePath));

        try {
            String email = service.getVerifiedEmail(userId);
            System.out.println("OK. verifiedEmail=" + email);
        } catch (BusinessException be) {
            // 비즈니스 예외는 타입/코드 기반으로 응답 매핑이 쉬워집니다.
            System.err.println("BUSINESS_ERROR code=" + be.getErrorCode() + " message=" + be.getMessage());
        } catch (IOException ioe) {
            // 인프라 예외는 재시도/대체 경로/알림 등 운영 전략과 연결되는 경우가 많습니다.
            System.err.println("IO_ERROR message=" + ioe.getMessage());
        }
    }
}
flowchart TD
  A[""Entry Point (Controller/CLI)"""] --> B[""Service"""]
  B --> C[""Repository/IO"""]
  C -->|""IOException (Checked)""| A
  B -->|""BusinessException (Unchecked)""| A

예외는 내부에서 “의미 있게 던지고”, 경계(엔트리포인트)에서 “일관되게 처리”하는 흐름이 핵심입니다.

 

실무 팁

💡 실무에서는
예외를 “여러 번 로깅”하지 않는 규칙을 먼저 정해두는 게 효과가 큽니다. 보통 경계 레이어에서만 에러 로그를 남기고, 내부에서는 원인(cause)만 잘 보존해 던지면 로그 중복이 줄고, 장애 분석 시 신호 대 잡음비가 좋아집니다.

💡 실무에서는
catch (Exception e)로 뭉뚱그려 처리해야 한다면, 최소한 다시 던질지(전파), 감쌀지(랩핑), 복구할지(대체/재시도) 중 하나를 명확히 하세요. “일단 잡고 넘어가기”는 가장 위험한 선택입니다. 특히 배치나 메시지 컨슈머에서는 삼켜진 예외가 데이터 불일치로 이어지기 쉽습니다.

핵심 요약

Checked는 “호출자가 복구할 수 있는 실패”, Unchecked는 “복구 불가/도메인 실패”에 주로 씁니다.
try-with-resources로 리소스 누수를 원천 차단하고 suppressed 예외도 놓치지 마세요.
예외는 내부에서 의미 있게 만들고, 경계에서 일관되게 처리하는 전략이 유지보수성을 올립니다.

다음 글: #11 String 완전 정복