[JAVA] 예외 처리4 -실무

2025. 7. 12. 19:05·자바

이번 장에서는 실무에서 예외 처리를 어떻게 하면 좋을지 알아볼 것이다.

 

자바는 예외를 크게 체크 예외와 언체크 예외로 나눈다. 이 중 체크 예외는 컴파일 타임에 반드시 throws 선언이나 try-catch로 처리해야 하도록 강제된다. 처음에는 안정성과 신뢰성 측면에서 좋은 설계처럼 보이지만, 실무 환경에서는 오히려 과도한 예외 처리 부담과 코드 오염의 원인이 되기도 한다.

 

1. 예외를 잡아도 복구할 수 없는 상황 

가장 대표적인 예는 외부 시스템과 연동할 때 발생한다. 예를 들어, 네트워크 서버에 메시지를 전송하거나 데이터베이스에 연결하는 과정에서 서버가 꺼져 있거나 응답이 지연되는 문제가 발생할 수 있다.

 

이런 경우 애플리케이션 내부에서 해당 예외를 잡아도 복구 방법이 없다. 연결을 재시도하더라도 같은 문제가 반복되고, 결국 사용자에게는 “시스템 오류”라는 메시지를 보여주는 것 외에는 별다른 대응이 불가능하다.

 

하지만 체크 예외로 설계되어 있다면, 이 예외는 반드시 throws로 선언하거나 try-catch로 감싸야 한다. 결과적으로 모든 계층에서 쓸모없는 예외 처리 코드가 반복되며, 코드가 복잡해지고, 실제 로직보다 예외 처리 코드가 더 많아지는 상황이 벌어진다.

이 상황이 바로 다음 사진과 같다.

 


→ 복구할 수 없는 예외를 무조건적으로 명시하고 전파해야 하기 때문에, 잡아도 쓸모없는 코드가 프로젝트 전체로 퍼지는 문제가 생긴다.

 

2. 예외 전파의 확산

예외가 한 번 발생하면, 그것을 던진 하위 계층만이 아니라 그 모든 상위 계층에서 throws 선언을 반복해야 한다. 예를 들어, NetworkClient가 SendException을 던지면 이를 호출하는 Service, Facade, Controller, Main까지 모두 해당 예외를 던져야 한다.

이 중간 계층들은 실제로 예외를 복구할 수 없지만, 단지 하위 계층에서 던졌기 때문에 억지로 예외를 떠안아야 한다. 이렇게 되면 예외가 코드의 흐름을 지저분하게 만들고, 계층 분리 원칙을 훼손한다.

이것이 바로 예외 전파로 코드 오염이다.

 

이 그림은 복구할 수 없는 체크 예외가 애플리케이션 계층 전체로 어떻게 전파되는지를 보여준다. NetworkClient, DatabaseClient, XxxClient는 각각 외부 시스템과 통신하는 하위 모듈로, 네트워크 연결 실패, 데이터베이스 접속 오류, 기타 시스템 예외 등 다양한 체크 예외를 던진다. 문제는 이러한 예외를 실제로 복구할 수 있는 위치가 애플리케이션 내부에는 없다는 점이다. 그럼에도 불구하고 자바의 체크 예외 규칙에 따라, 이 예외들을 모든 계층(Main, Facade, Service 등)이 throws로 선언하거나 catch 후 다시 던져야 한다.

 

이로 인해 Service 계층은 네트워크, DB, 기타 예외를 모두 떠안게 되고, 다시 Facade로 넘기며, Main에서는 결국 모든 종류의 예외를 처리하는 구조가 만들어진다. 각 계층은 본래의 역할과 책임(비즈니스 로직 처리, 사용자 요청 위임 등)에 집중하기보다는, 예외를 전달하고 선언하는 데 상당한 코드를 소모하게 된다. 이러한 구조는 코드의 복잡도를 높이고, 실제 로직과 무관한 throws 선언과 catch 블록이 중복되어 코드 가독성과 유지보수성을 떨어뜨린다.

 

특히 Facade나 Main처럼 사용자 요청을 단순히 위임하는 역할만 하는 계층에서도 예외를 처리할 수 없는데, 예외를 전파하기 위한 throws 선언만 억지로 반복해야 한다. 이는 예외에 대한 책임이 명확히 분리되지 못한 구조를 만들고, 결과적으로 애플리케이션 전체가 예외 전파의 부담에 종속되는 상황을 초래한다.
이러한 구조를 "예외 처리 지옥"이라고 부르는 이유다.

 

예외 처리 지옥 예시 코드:

 

3. throws Exception의 유혹과 그 부작용

많은 개발자가 이 문제를 해결하기 위해 결국 모든 예외를 throws Exception으로 한 줄 처리하려 한다.

 

겉보기에 코드는 깔끔해 보이지만, 이는 치명적인 함정을 포함한다.

  • Exception은 모든 체크 예외의 최상위 타입이므로, 컴파일러가 예외의 종류를 식별하지 못하게 된다.
  • 이후에 예외가 변경되거나 새로운 예외가 추가되어도, 코드는 문법적으로 문제없다고 판단되므로 오히려 예외를 놓치게 된다.
  • 특히, 중요한 예외를 catch하지 않고 무시해버리는 위험성이 생긴다.

결국, throws Exception은 체크 예외의 장점인 정적 분석 기능을 무력화시키는 위험한 편법이 된다.

 

 

4. 실무에서는 언체크 예외(Unchecked Exception)로 전환하는 것이 유리

실제로 실무에서는 이러한 문제를 피하기 위해 복구할 수 없는 예외를 런타임 예외(RuntimeException)로 전환하는 전략을 쓴다. 런타임 예외는 throws를 명시하지 않아도 자동으로 전파되므로, 중간 계층들이 불필요하게 예외를 처리하거나 던질 필요가 없다.

이런 방식은 아래 사진과 같이 예외를 공통 처리기로 위임하는 구조에서 특히 강력한 효과를 발휘한다.

  • 서비스나 클라이언트에서 예외가 발생하면 곧바로 위로 전파되고,
  • 최종 진입점(Main, Controller 등)에서 공통 처리기를 통해 사용자 메시지 출력, 로그 남기기 등의 처리를 일괄적으로 수행한다.
  • 개발자는 복구 가능한 예외만 골라서 처리하고, 나머지는 신경 쓰지 않아도 된다.

예시 코드:

            try {
                networkService.sendMessage(input);
            }catch (Exception e){
                exceptionHandler(e);

            }

            networkService.sendMessage(input);
            System.out.println();

        }
        System.out.println("프로그램을 정상 종료합니다.");
    }


    //곹통 예외 처리
    private static void exceptionHandler(Exception e) {
        System.out.println("사용자 메시지: 죄송합니다 알 수 없는 메시지가 발생하였습니다.");
        System.out.println("==개발자용 디버깅 메시지==");
        e.printStackTrace(System.out);

        //필요하면 별도로 예외 추가
        if(e instanceof SendExceptionV4 sendEx){
            System.out.println("[전송 오류] 전송 데이터: "+sendEx.getSendData());
        }
    }

 

위 코드는 실무에서 예외를 처리할 때 자주 쓰는 방식이다. 여기서 중요한 건 모든 예외를 똑같이 처리하지 않는다는 점이다.

 

실제로는 예외도 성격이 다르기 때문에, 복구가 가능한 예외와 복구할 수 없는 예외를 나눠서 처리해야 한다.

 

예를 들어, 사용자가 보낸 데이터가 잘못되었거나, 어떤 값을 잘못 입력해서 생긴 문제는 개발자가 의도한 범위 안에서 예측 가능한 예외다. 이런 건 직접 잡아서 구체적인 처리를 해주는 게 맞다. 그래서 이런 예외는 SendExceptionV4 같은 세세한 예외 클래스로 나눠서 직접 catch 한다.

 

반면, 서버가 꺼졌거나 네트워크가 끊긴 경우처럼 시스템적으로 발생하는 문제는 개발자가 그 자리에서 해결할 수 없다. 이런 건 어차피 잡아도 처리할 방법이 없기 때문에, 굳이 복잡하게 따로 catch하지 않고 그냥 Exception으로 퉁쳐서 공통 처리기로 넘긴다.

 

이런 구조를 쓰면 다음과 같은 장점이 있다:

  • 복구 가능한 예외만 세밀하게 신경 쓰고, 복구 불가능한 예외는 일괄 처리로 넘기기 때문에 코드가 깔끔해진다.
  • 예외를 다룰 때 중요한 건 세세하게, 안 되는 건 한 번에 모아서 처리하므로 예외 흐름이 명확해진다.
  • 공통 처리기에서는 사용자에게는 "시스템에 문제가 있습니다" 같은 메시지를 보여주고, 개발자에게는 스택 트레이스나 추가 로그를 남겨 빠르게 원인을 찾을 수 있다.

정리하자면, 실무에서는 예외를 무조건 다 잡지 않는다. 꼭 처리해야 하는 예외만 직접 잡고, 나머지는 예외 타입을 통일해서 공통 처리기에서 일괄 관리하는 메커니즘을 쓴다. 그래야 예외 처리 코드가 지저분해지지 않고, 유지보수도 편해진다.

 

 

5. 외부 자원 해제는 try-with-resources로 안전하게

실무에서 예외 처리와 함께 항상 따라오는 또 다른 과제는 외부 자원의 해제 문제이다. 예외가 발생하더라도 파일, 네트워크 연결, 데이터베이스 커넥션 등 외부 자원을 반드시 반납해야 한다.

 

이 작업을 실수 없이 처리하기 위해 과거에는 다음과 같은 try-finally 패턴을 반복적으로 사용했다.

NetworkClient client = new NetworkClient();
try {
    client.connect();
    client.send(data);
} catch (Exception e) {
    // 예외 처리
} finally {
    client.disconnect(); // 반드시 호출되어야 함
}

이 방식은 자원 해제를 보장하긴 하지만, 매번 finally를 적어야 하고, 가독성이 떨어지며, 실수로 disconnect()를 빼먹으면 자원 누수가 발생한다.

 

try-with-resources의 도입

자바 7부터 등장한 try-with-resources는 자원을 안전하게 해제하면서도 코드의 가독성과 유지보수성을 높여주는 문법이다. 핵심 아이디어는 다음과 같다.

  • try 괄호 안에 자원을 생성하면,
  • try 블록이 종료되는 시점에 자동으로 close() 메서드가 호출되어 자원이 해제된다.

이때 자원 클래스는 반드시 AutoCloseable 인터페이스를 구현해야 하며, close() 메서드를 정의하면 된다.

public class NetworkClientV5 implements AutoCloseable
public class NetworkServiceV5 {
    public void sendMessage(String data) {
        String address = "https://example.com";
        try (NetworkClientV5 client = new NetworkClientV5(address)) {
            client.initError(data);
            client.connect();
            client.send(data);
        } catch (Exception e) {
            System.out.println("[예외 확인]: " + e.getMessage());
            throw e;
        }
    }
}
//자동으로 호출
	@Override
    public void close() {
        System.out.println("NetworkClientV5.close");
        disconnect();
    }

 

이 방식은 특히 실무에서 다음과 같은 강점을 가진다.

  1. 자원 누수 방지
    try-with-resources는 자원 해제를 자동으로 처리하므로, 개발자가 해제를 빼먹는 실수를 방지할 수 있다.
  2. 코드 간결성
    finally 블록이 없어도 되기 때문에, 전체 예외 처리 구조가 훨씬 간단해진다.
  3. 범위 한정 (스코프)
    자원 객체가 try 블록 내부에서만 유효하므로, 불필요하게 외부로 노출되지 않아 관리가 쉬워진다.
  4. 예외 발생 시에도 안전하게 close
    try 블록에서 예외가 발생하더라도 close()는 반드시 실행되므로, 예외 상황에서도 안전하게 자원이 반납된다.

 

마무리하며:

과거에는 컴파일 타임에 오류를 강제로 잡아주는 체크 예외가 안정적인 코드 작성에 도움이 된다고 여겨졌다. 하지만 시스템이 복잡해지고, 우리가 직접 해결할 수 없는 외부 환경의 예외가 많아지면서 모든 예외를 일일이 처리하는 구조는 오히려 개발 생산성과 코드 품질을 떨어뜨리는 원인이 되었다.

 

이제는 복구 가능한 예외는 세분화된 예외 클래스를 통해 명확하게 분리해서 처리하고,
복구할 수 없는 예외는 언체크 예외로 전환한 뒤 공통 처리 로직에서 한 번에 처리하는 방식이 더 실용적이다.

 

이러한 구조는 예외 흐름을 단순화시키고, 개발자는 자신이 통제할 수 있는 영역에만 집중할 수 있으며,
사용자에게는 안정적인 메시지를, 개발자에게는 빠른 디버깅 정보를 제공하는 방향으로 진화하고 있다.

 

감사합니다.

'자바' 카테고리의 다른 글

[JAVA] 자바 제네릭 메서드와 와일드카드, 그리고 타입 이레이저  (1) 2025.07.16
[JAVA] 제네릭이 필요한 이유  (1) 2025.07.14
[JAVA] 예외 처리3 -실습  (1) 2025.07.11
[JAVA] 예외 처리 2 -이론  (5) 2025.07.10
[JAVA] 예외 처리1 -이론  (0) 2025.07.10
'자바' 카테고리의 다른 글
  • [JAVA] 자바 제네릭 메서드와 와일드카드, 그리고 타입 이레이저
  • [JAVA] 제네릭이 필요한 이유
  • [JAVA] 예외 처리3 -실습
  • [JAVA] 예외 처리 2 -이론
0kingki_
0kingki_
자바 + 스프링 웹 개발
  • 0kingki_
    0kingki_
    0kingki_
  • 전체
    오늘
    어제
    • 분류 전체보기 (134)
      • 코딩 테스트 (54)
      • 자바 (21)
      • 스프링 (27)
      • 타임리프 (16)
      • 스프링 데이터 JPA (8)
      • 최적화 (2)
      • QueryDSL (4)
      • AWS (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    쿼리dsl
    코딩 테스트
    spring
    스프링 데이터 JPA
    예외 처리
    객체지향
    Java
    코딩테스트
    dfs
    fetch join
    예외처리
    SOLID
    컬렉션
    자바
    스프링 컨테이너
    QueryDSL
    다형성
    thymeleaf
    SpringDataJpa
    타임리프
    백준
    LocalDateTime
    스프링
    mvc
    재귀
    쿼리
    불변객체
    JPA
    BFS
    최적화
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
0kingki_
[JAVA] 예외 처리4 -실무
상단으로

티스토리툴바