본문 바로가기
Back-end & 알고리즘

자바 예외처리, 당신의 서비스 레이어가 맨날 스파게티 코드가 되는 이유

by CodeByJin 2026. 6. 2.
반응형

Service와 Controller 예외 처리, 왜 맨날 도마 위에 오를까

새 프로젝트에 투입되어 남이 짜놓은 코드를 열었을 때 가장 먼저 스트레스를 유발하는 구간이 바로 try-catch 블록입니다. 비즈니스 로직이 흘러가야 할 서비스 레이어 코드 절반이 예외 처리와 로그 찍기로 도배되어 있거나, 반대로 컨트롤러가 어떤 에러를 뱉을지 몰라 프론트엔드 개발자가 매번 "이거 에러 포맷이 왜 이래요?"라고 따지는 상황, 개발해 본 사람이라면 누구나 겪어봤을 겁니다.

 

이 문제가 반복되는 이유는 명확합니다. 레이어별 '책임'의 경계를 모호하게 잡았기 때문입니다. 예외 처리를 어디서, 어떻게 하느냐에 따라 시스템의 모니터링 난이도와 코드의 유지보수 피로도가 완전히 달라집니다. 단순히 "에러가 안 나게 막는다"는 관점을 넘어, "장애가 났을 때 얼마나 빨리 원인을 파악하고 대응할 수 있는가"의 관점으로 이 분리 기준을 다시 정립해야 합니다.

상황별 분기로 보는 레이어별 예외 처리의 정석

가장 널리 쓰이면서도 실무에서 사고가 안 나는 구조는 서비스 레이어에서 비즈니스 의미를 담은 커스텀 예외를 던지고, 컨트롤러(또는 Global Exception Handler)에서 이를 취합해 HTTP 상태 코드로 변환하는 방식입니다. 운영 관점에서 몇 가지 전형적인 상황을 기준으로 흐름을 쪼개보겠습니다.

1. 비즈니스 규칙 위반 (예: 잔액 부족, 중복 가입)

이 영역은 철저히 서비스 레이어의 무대입니다. 데이터베이스를 조회했더니 잔액이 부족하다는 것은 기술적 오류가 아닌 비즈니스 흐름의 일부입니다. 여기서 서비스 레이어는 절대로 400이나 409 같은 HTTP 스펙을 알면 안 됩니다. 인프라 환경이 웹이 아닐 수도 있기 때문입니다.

// 서비스 레이어의 역할: 비즈니스 관점의 명확한 예외 발생
public void withdraw(Long accountId, BigDecimal amount) {
    Account account = accountRepository.findById(accountId)
    .orElseThrow(() -> new EntityNotFoundException("계좌를 찾을 수 없습니다."));
    if (account.isLessThan(amount)) {
        throw new InsufficientBalanceException("잔액이 부족합니다. 현재 잔액: " + account.getBalance());
    }
    account.deduct(amount);
}

2. 외부 API 연동 실패 (예: 결제 게이트웨이 타임아웃)

외부 연동은 예측이 불가능합니다. 2026년 현재 대다수 마이크로서비스 아키텍처(MSA) 환경에서는 서킷 브레이커를 걸어두지만, 애플리케이션 레벨에서도 재시도(Retry)를 할지, 바로 상위로 에러를 던질지 서비스 레이어가 결정해야 합니다. 복구 불가능한 타임아웃이라면 시스템 인프라 에러로 정의하고 Custom 시스템 예외로 감싸서(Wrapping) 던집니다.

자바 예외처리 서버
자바 예외처리 서버

3. 컨트롤러 레이어의 최종 수문장 역할

컨트롤러는 서비스 레이어가 던진 예외를 받아서 클라이언트가 이해할 수 있는 언어(HTTP Status Code, 표준 JSON Response)로 통역하는 역할을 합니다. 스프링 부트 환경이라면 @RestControllerAdvice를 활용해 컨트롤러 진입 전후의 예외를 한 곳에서 처리하는 것이 운영상 훨씬 유리합니다.

// 컨트롤러 및 글로벌 핸들러의 역할: 통역 및 최종 응답 규격화
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(InsufficientBalanceException.class)
    public ResponseEntity handleBusinessException(InsufficientBalanceException e) {
        // 비즈니스 예외는 대개 Warn 로그로 처리하며, 400 Bad Request 등으로 대응
        log.warn("Business rule violation: {}", e.getMessage());
        return ResponseEntity.status(HttpStatus.BAD_MANDATORY)
        .body(new ErrorResponse("INSUFFICIENT_BALANCE", e.getMessage()));
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity handleUnhandledException(Exception e) {
        // 예측하지 못한 시스템 에러는 Error 로그를 찍고 500 내부 에러로 은폐
        log.error("Unhandled Internal Server Error: ", e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
        .body(new ErrorResponse("SERVER_ERROR", "시스템에 일시적인 오류가 발생했습니다."));
    }
}

실무 질문: 언체크 예외(RuntimeException)만 쓰면 정말 문제가 없을까?

최근 몇 년간 자바 진영의 트렌드는 체크 예외(Checked Exception)를 지양하고 모든 예외를 RuntimeException을 상속받은 언체크 예외로 처리하는 추세입니다. 코드가 깔끔해지고, 메서드 선언부마다 throws를 지저분하게 붙이지 않아도 되니 생산성이 올라가는 건 사실입니다. 하지만 운영 관점에서 명확한 가이드라인 없이 언체크 예외만 남발하면 트랜잭션 롤백 전략이 꼬이기 시작합니다.

 

스프링의 @Transactional은 기본적으로 RuntimeExceptionError가 발생했을 때만 롤백을 수행합니다. 만약 레거시 라이브러리나 외부 모듈에서 Checked Exception을 던지는데 이를 서비스 레이어에서 제대로 감싸지 않고 방치하면, 데이터베이스에 데이터는 욱여넣어졌는데 상위 로직은 실패 처리되는 데이터 정합성 지옥을 맛보게 됩니다. 무조건 RuntimeException만 쓸 게 아니라, 내가 던진 예외가 현재 진행 중인 데이터 변경 건을 백아웃(Rollback)해야 하는 사안인지 아닌지 판단하는 기준이 선행되어야 합니다.

실무에서 안 하면 후회하는 예외 처리 안티 패턴 3가지

많은 개발팀이 코드 리뷰 때 로직 자체는 유심히 보면서도, 예외 처리 구문은 대충 넘어가곤 합니다. 대규모 트래픽이 몰리거나 장애가 터졌을 때 시스템을 완전히 가라앉히는 주범들은 보통 아래의 세 가지 패턴에서 나옵니다.

1. 무의미한 e.printStackTrace()와 로그 이중 기록

서비스 레이어에서 try-catch를 잡고 e.printStackTrace()를 남긴 뒤 다시 예외를 던지는 짓은 하지 말아야 합니다. 톰캣 같은 WAS 콘솔에 표준 출력으로 쌓이는 스택 트레이스는 파일 로그로 수집하기 까다로울뿐더러, 성능상 엄청난 IO 병목을 유발합니다. 또한 서비스에서 로그를 찍고, 컨트롤러에서 또 찍으면 로그 파일 용량만 기하급수적으로 늘어나 장애 추적 시 노이즈만 가중됩니다. 로그는 최초로 예외를 상황 지어 처리하는 '가장 상위 레이어'에서 한 번만 기록하는 게 원칙입니다.

2. 예외 삼키기 (Empty Catch Block)

에러가 나도 프로세스가 죽지 않게 하겠다는 일념으로 catch 블록을 비워두거나 무조건 정상 리턴(null 또는 빈 객체)으로 무마하는 코드가 있습니다. 이건 폭탄 돌리기나 다름없습니다. 정작 진짜 버그가 발생했을 때 데이터는 이미 오염되었는데 에러 로그는 한 줄도 안 찍혀서, 디버깅을 위해 로컬 환경에서 수시간을 허비하게 만듭니다.

3. 내부 구현 기술을 노출하는 에러 메시지

컨트롤러가 BadSqlGrammarException이나 NullPointerException의 스택 트레이스를 그대로 클라이언트에게 응답으로 내리는 경우가 있습니다. 이는 심각한 보안 결함입니다. 외부 공격자에게 우리 시스템이 어떤 DB를 쓰는지, 테이블 구조가 어떤지 친절하게 알려주는 꼴입니다. 사용자에게는 "시스템 오류가 발생했습니다"라는 정제된 메시지만 보여주고, 상세한 에러 정보는 UUID나 Trace ID로 매핑하여 내부 로그 시스템(ELK, Grafana 등)에만 보관해야 합니다.

서비스와 컨트롤러 예외 분리 체크리스트

우리 팀의 코드가 계층별 역할을 잘 지키고 있는지 확신이 서지 않는다면, 다음 항목을 기준으로 코드를 점검해 보시기 바랍니다.

점검 항목 이상적인 상태 (Best Practice)
HTTP 스펙 의존성 Service 레이어 코드에 HttpServletRequest, HttpStatus 같은 웹 기술 관련 임포트가 전혀 없다.
트랜잭션 롤백 비즈니스 실패 시 데이터가 롤백되어야 하는 경우 RuntimeException(언체크 예외)을 명확히 활용한다.
예외 캡슐화 하위 인프라(JPA, MyBatis, Redis)의 로우레벨 예외를 그대로 상위로 올리지 않고, 도메인 친화적 예외로 감싸서 던진다.
로그의 집중화 개별 Controller나 Service 안에서 log.error()를 개별적으로 남기지 않고, @Advice 클래스에서 전담한다.

결국 어떤 선택을 해야 하는가?

팀의 규모와 도메인의 복잡도에 따라 정답은 조금씩 달라질 수 있습니다. 하지만 변하지 않는 대원칙은 하나입니다. "서비스 레이어는 비즈니스 로직의 완결성에만 집중하고, 컨트롤러 레이어는 진입점 관리와 프리젠테이션 변환에만 집중한다"는 것입니다.

 

만약 현재 진행 중인 프로젝트가 소규모이고 빠른 생산성이 중요하다면, 커스텀 예외 클래스를 너무 잘게 쪼개기보다는 공통 비즈니스 예외(예: BusinessException) 하나를 두고 내부에 에러 코드(ErrorCode Enum)를 심어서 컨트롤러로 넘기는 방식이 효율적입니다. 반면 시스템이 비대해지고 도메인이 복잡해진다면, 각 도메인 영역마다 명확한 맥락을 가진 구체적인 예외들을 정의해야 흐름을 제어하기 쉬워집니다.

 

완벽한 아키텍처는 없습니다. 지금 작성하고 있는 catch 블록이 6개월 뒤 갑작스러운 장애 상황에서 나에게 명확한 단서를 줄 수 있을지, 아니면 디버깅을 방해하는 덫이 될지 한 번 더 고민해 보는 것만으로도 코드의 질은 눈에 띄게 좋아질 것입니다.

 

 

우선순위 큐 코딩테스트: 정렬 함수만 쓰다가 효율성 터지는 이유

코딩테스트에서 굳이 우선순위 큐를 꺼내야 하는 시점정렬 문제인 줄 알고 무심코 정렬 함수를 호출했다가 효율성 테스트에서 빨간 불을 본 경험은 누구나 있을 겁니다. 매번 데이터를 집어넣

byteandbit.tistory.com

 

반응형