Photo by Daniil Komov on Pexels
사수나 팀장에게 코드를 검수받는 코드 리뷰(Code Review) 과정에서 신입 개발자가 가장 많이 지적받는 영역 중 하나가 바로 '예외 처리(Exception Handling)'와 '로깅(Logging)'입니다. 입사 초기에는 요구사항에 맞는 기능이 일단 동작하게 만드는 데에만 집중하다 보니, 예외 상황이나 시스템 오류를 제대로 고려하지 않은 채 코드를 작성하기 쉽습니다. 그러나 실제 운영 환경에서는 DB 연결 끊김, 외부 API 타임아웃, 잘못된 클라이언트 입력 등 수많은 변수가 발생합니다.
예외 처리와 로깅이 허술하게 작성되어 있으면 장애가 발생했을 때 원인을 찾기 위해 며칠 동안 수동으로 데이터를 뒤져야 하거나, 심각한 데이터 불일치 및 개인정보 유출 사고로 이어질 수 있습니다. 본 글에서는 업무를 막 맡게 된 신입 백엔드 개발자가 현장에서 흔히 범하는 대표적인 예외 처리 및 로깅 실수 5가지를 살펴보고, 이를 즉시 바로잡을 수 있는 실용적인 해결법을 상세히 다룹니다.
실수 1: Catch 블록에서 예외를 무시하거나 printStackTrace()만 남기기
신입 개발자의 코드에서 가장 흔하게 발견되는 패턴은 예외가 발생했을 때 비어 있는 catch 블록을 두어 예외를 무시하거나, System.out.println() 혹은 e.printStackTrace() 메서드만 호출하고 넘어가는 형태입니다.
왜 문제가 되는가?
catch 블록 내부를 비워두는 '예외 먹어버리기(Catch and Swallowing)'는 시스템 내부에서 심각한 오류가 발생했음에도 불구하고 로직이 성공한 것처럼 다음 단계로 진행되게 만듭니다. 그 결과 데이터가 오염되거나, 정작 원인이 발생한 지점이 아닌 전혀 다른 코드 라인에서 NullPointerException이 터지는 등 버그 추적이 불가능에 가까워집니다.
또한 e.printStackTrace()는 표준 에러 출력(System.err) 스트림으로 로그를 내보냅니다. 이는 서버의 로그 수집기(Datadog, ELK, CloudWatch 등)에서 로그 발생 시간, 로그 레벨(INFO, ERROR), 스레드 이름, 요청 ID 등의 메타데이터를 올바르게 파싱하지 못하게 만듭니다. 심지어 콘솔 I/O 병목 현상을 유발하여 높은 트래픽 상황에서 서버 전체의 성능을 저하시키는 주범이 됩니다.
올바른 해결 방법
모든 예외는 검증된 로깅 프레임워크(예: SLF4J, Logback, Log4j2)를 사용하여 적절한 로그 레벨로 기록해야 합니다. 예외를 단순히 콘솔에 찍고 끝내는 것이 아니라, 상위 레이어로 던지거나 비즈니스 예외(Custom Business Exception)로 재정의하여 처리해야 합니다.
잘못된 구현 예시:
try { userService.processPayment(userId, amount); } catch (Exception e) { e.printStackTrace(); }
올바른 구현 예시:
try { userService.processPayment(userId, amount); } catch (PaymentGatewayException e) { log.error("결제 PG사 연동 실패 - 사용자ID: {}, 결제금액: {}", userId, amount, e); throw new BusinessException(ErrorCode.PAYMENT_PROCESSING_FAILED); }
로거에 예외 객체(e)를 마지막 인수로 넘겨주면, 로깅 프레임워크가 스택 트레이스(Stack Trace) 전체를 정돈된 형태로 로그 파일에 기록해 주므로 장애 추적이 훨씬 쉬워집니다.
실수 2: 클라이언트에게 DB 에러 메시지나 Stack Trace를 그대로 노출하기
API 실행 중 예상치 못한 예외가 발생했을 때, 컨트롤러 계층에서 이를 잡아서 정제하지 않고 그대로 밖으로 던져버리면 클라이언트에게 HTTP status 500과 함께 내부 스택 트레이스나 SQL 쿼리 에러 메시지가 담긴 JSON/HTML 응답이 전달됩니다.
왜 문제가 되는가?
이는 정보 유출(Information Disclosure) 관점에서 매우 치명적인 보안 취약점입니다. 시스템 내부 스택 트레이스나 DB 에러 메시지에는 사용하는 프레임워크의 버전, 클래스 파일 구조, 데이터베이스 테이블명 및 컬럼명 등이 고스란히 담겨 있습니다. 해커는 이러한 정보를 바탕으로 시스템의 약점을 파악하고 SQL Injection이나 알려진 라이브러리 취약점 공격을 시도할 수 있습니다.
가령 A사의 신입 개발자가 구현한 회원가입 API에서 Unique Key 제약조건 위반 시 "Duplicate entry 'admin@test.com' for key 'users.uk_email'"이라는 에러 문구가 사용자 화면에 그대로 노출되어 내부 테이블 구조와 중복 여부 판단 로직이 유출된 사례가 대표적입니다.
올바른 해결 방법
스프링 기반 환경이라면 @RestControllerAdvice 또는 @ExceptionHandler를 사용하는 전역 예외 처리기(Global Exception Handler)를 구축해야 합니다. 백엔드 내부의 구체적인 구현 예외는 내부에 에러 로그로만 기록하고, 클라이언트에게는 미리 약속된 일관된 에러 응답 객체(ErrorResponse)만 전달합니다.
클라이언트에게 반환하는 표준 에러 응답 예시:
{ "code": "INVALID_INPUT_VALUE", "message": "입력한 이메일이 이미 존재하거나 올바르지 않은 형식입니다." }
실수 3: 민감 정보(비밀번호, 토큰, 개인정보)를 로그에 평문으로 남기기
개발 과정에서 요청 데이터를 빠르게 확인하려는 목적으로 컨트롤러 진입부에 log.info("회원가입 요청 데이터: {}", requestDto.toString()); 와 같은 로깅 코드를 남겨두는 경우가 자주 있습니다.
왜 문제가 되는가?
Request DTO 객체 내부에 사용자의 비밀번호, 계좌번호, 주민등록번호, OAuth Access Token, JWT 등이 들어있다면, 이 민감한 정보들이 평문(Plain Text) 그대로 로그 파일과 로그 수집 서버에 저장됩니다. 이는 개인정보 보호법 및 관련 법령 위반으로 이어지며, 만약 로그 수집 시스템의 접근 권한이 탈취될 경우 대규모 2차 피해를 유발할 수 있습니다.
올바른 해결 방법
민감 정보를 담고 있는 DTO의 toString() 메서드를 재정의하여 마스킹(Masking) 처리하거나, Lombok을 사용하는 경우 @ToString.Exclude 어노테이션을 사용하여 해당 필드가 문자열 출력 시 포함되지 않도록 설정해야 합니다.
또한, 개발 환경(Local, Dev)에서 출력하는 디버그용 로그와 운영 환경(Production)에서 출력하는 로그의 레벨 및 대상을 확실하게 구분해야 합니다. 운영 환경에서는 logback-spring.xml 등의 설정 파일을 통해 INFO 레벨 이상만 출력되도록 제한하고, 개인정보가 출력될 가능성이 있는 지점에는 반드시 별도의 마스킹 유틸리티를 거치도록 전처리 로직을 추가해야 합니다.
실수 4: HTTP 상태 코드는 무조건 200 OK로 반환하고 바디에만 에러 표기하기
비즈니스 로직 처리 과정에서 에러가 발생했음에도 불구하고, HTTP 응답 상태 코드는 무조건 200 OK로 설정한 채 Response Body에만 에러 상태를 넣어 반환하는 방식입니다.
왜 문제가 되는가?
HTTP 프로토콜은 상태 코드(Status Code)를 통해 통신의 성공과 실패 여부를 1차적으로 나타내도록 설계되어 있습니다. 에러가 발생했음에도 200 OK를 반환하게 되면 Nginx, API Gateway, Load Balancer, 브라우저, Axios/Fetch 같은 HTTP 클라이언트 라이브러리가 해당 요청을 '성공'으로 오인하게 됩니다.
이로 인해 API 모니터링 도구(APM)에서 실제 에러율 집계가 정확히 이루어지지 않아 장애 감지 시간이 지연되며, 프론트엔드 개발자 입장에서도 매번 Response Body 내부의 특정 필드를 파싱해야만 에러를 인지할 수 있어 코드의 복잡성이 크게 증가합니다.
올바른 해결 방법
상황에 맞는 표준 HTTP 상태 코드를 엄격하게 준수하여 응답해야 합니다.
- 400 Bad Request: 클라이언트의 요청 파라미터가 누락되었거나 데이터 유효성 검증(Validation)에 실패한 경우
- 401 Unauthorized: 인증 자격 증명이 없거나 토큰이 만료된 상태에서 보호된 자원에 접근할 때
- 403 Forbidden: 인증은 완료되었으나 해당 리소스에 접근할 수 있는 권한(Role)이 부족할 때
- 404 Not Found: 요청한 ID에 해당하는 DB 데이터나 API 엔드포인트를 찾을 수 없을 때
- 409 Conflict: 이미 존재하는 리소스와 충돌이 발생했을 때 (예: 이메일 중복 등록)
- 500 Internal Server Error: 백엔드 내부의 예상치 못한 버그, DB 접속 장애, 시스템 오류가 발생했을 때
실수 5: 트랜잭션 범위 안에서 예외를 세밀하게 제어하지 않아 발생하는 데이터 불일치
스프링(Spring) 프레임워크 환경에서 @Transactional 어노테이션이 붙은 서비스 메서드 내부 예외를 처리할 때, 트랜잭션 롤백(Rollback) 메커니즘을 정확히 이해하지 못해 DB 데이터가 오염되는 현상이 빈번하게 발생합니다.
왜 문제가 되는가?
기본적으로 선언적 트랜잭션(@Transactional)은 언체크 예외(Unchecked Exception: RuntimeException 및 그 하위 예외)가 발생했을 때만 자동으로 Rollback을 진행합니다. 만약 Checked Exception(예: Exception, IOException, SQLException 등)이 발생했는데 별도의 rollbackFor 설정을 하지 않은 경우, 메서드 내부에서 예외가 터져서 비정상 종료되었음에도 불구하고 이전까지 수행된 DB CUD(등록, 수정, 삭제) 작업이 그대로 커밋(Commit)되어 버립니다.
반대로 내부 서비스 메서드에서 예외를 catch하여 비즈니스적으로 복구(Recover)하려고 했지만, 이미 트랜잭션에 롤백 전용(Rollback-only) 표시가 남아서 전체 트랜잭션이 UnexpectedRollbackException을 발생시키며 꼬이는 상황도 신입 개발자들이 자주 겪는 함정입니다.
올바른 해결 방법
Checked Exception이 발생할 수 있는 비즈니스 로직에 트랜잭션을 걸 때는 항상 @Transactional(rollbackFor = Exception.class) 속성을 지정해 주는 것이 안전합니다.
또한, 외부 API 호출이나 이메일 발송, 파일 업로드와 같은 외부 I/O 작업은 DB 트랜잭션 범위 밖으로 분리해야 합니다. 외부 시스템의 응답 지연으로 인해 DB 커넥션을 오랫동안 점유하거나, 외부 서비스 실패로 인해 정상적으로 완료된 DB 작업까지 불필요하게 롤백되는 부작용을 방지하기 위함입니다.
실무에 바로 적용하는 API 예외 처리 및 로깅 패턴 비교표와 체크리스트
아래 표는 흔히 범하는 안 좋은 패턴과 이를 개선한 올바른 실무 패턴을 비교한 정형화된 자료입니다.
| 구분 | 안 좋은 예외 처리 패턴 (Anti-Pattern) | 올바른 예외 처리 패턴 (Best Practice) |
|---|---|---|
| 예외 포착 및 기록 | catch 블록을 비워두거나 e.printStackTrace() 사용 | Logger를 사용해 파라미터화 로깅 및 예외 객체 전달 |
| 클라이언트 응답 | 500 Internal Server Error와 함께 스택트레이스 노출 | @RestControllerAdvice 기반 표준 ErrorResponse 반환 |
| 민감정보 처리 | DTO 전체를 toString()하여 비밀번호/토큰 평문 저장 | @ToString.Exclude 사용 및 마스킹 처리 유틸 적용 |
| HTTP 상태 코드 | 오류 상황에도 무조건 200 OK로 반환 후 바디에만 표시 | 400, 401, 404, 500 등 의미에 맞는 HTTP Status 응답 |
| 트랜잭션 연동 | Checked Exception 발생 시 DB 미롤백 방치 | rollbackFor = Exception.class 명시 및 외부 I/O 분리 |
배포 전 반드시 확인해야 할 예외 처리 및 로깅 체크리스트
작성한 코드를 실운영 환경이나 테스트 서버에 배포하기 전에 아래 5가지 체크리스트를 순서대로 점검해 보세요.
- System.out 및 e.printStackTrace 제거 여부: 프로젝트 전체 검색 기능을 이용하여 System.out.println 및 e.printStackTrace() 호출 코드가 완전히 제거되었는지 확인했는가?
- 전역 예외 처리기 작동 검증: 존재하지 않는 API 경로 요청이나 유효하지 않은 데이터 전달 시, 내부 스택 트레이스 대신 규격화된 JSON 에러 응답이 반환되는가?
- 민감 정보 마스킹 검증: 로그 파일 및 수집 서버에 비밀번호, 계좌번호, Access Token 등의 민감 데이터가 평문으로 출력되지 않는가?
- 적절한 HTTP 상태 코드 매핑: 입력값 오류에는 400, 권한 부족에는 403, 데이터 부재에는 404 등 적절한 상태 코드가 응답 헤더에 설정되어 있는가?
- 트랜잭션 롤백 정책 설정: DB 변경 작업이 일어나는 서비스 메서드에 @Transactional(rollbackFor = Exception.class)가 올바르게 적용되어 있는가?
신입 개발자를 위한 핵심 실행 요약
오늘 다룬 내용 중 지금 당장 여러분의 코드베이스에 적용해야 할 핵심 실행 수칙 4가지는 다음과 같습니다.
- 첫째, 콘솔 출력 전면 금지 및 로거 일원화: e.printStackTrace()와 System.out을 모두 지우고, SLF4J의 log.error("메시지", e) 구문을 사용하여 스택 트레이스를 안전하게 기록하세요.
- 둘째, 전역 예외 처리기(@RestControllerAdvice) 구축: 발생 가능한 모든 예외를 중앙에서 흡수하고, 사용자에게는 정제된 에러 코드와 메시지만 포함된 정형화된 JSON 객체를 반환하세요.
- 셋째, 올바른 HTTP 상태 코드 사용: 비즈니스 오류나 검증 실패 시 200 OK 대신 4xx 계열의 상태 코드를 명확히 지정하여 클라이언트 및 모니터링 시스템과의 규약을 지키세요.
- 넷째, 트랜잭션 롤백 범위 명시: DB 작업을 수행하는 서비스 계층에는 rollbackFor 옵션을 명시하고, 외부 API 통신과 같은 I/O 로직은 트랜잭션 범위 밖으로 분리하여 실행하세요.
위 수칙들만 잘 지켜도 코드 리뷰에서 지적받는 횟수가 크게 줄어들 뿐만 아니라, 장애 발생 시 원인을 수분 내에 찾아 해결할 수 있는 신뢰받는 개발자로 성장할 수 있을 것입니다.