신입 개발자 디버깅 가이드를 한 줄로 요약하면 “값의 흐름을 추적하는 능력” 입니다. 신입·후배에게 가장 자주 강조하는 것 한 가지를 꼽으라면 단연 디버깅입니다. 디버깅이 약하면 같은 버그를 잡는 데 다른 사람보다 몇 배의 시간이 들고, 야근의 절반은 사실 디버깅 방법을 몰라서 발생합니다.
이 글에서는 신입이 가장 자주 놓치는 5가지 디버깅 포인트를 시니어 관점에서 정리합니다. 안티 패턴(log 100번 찍기)부터 IntelliJ 디버거 핵심 사용법, Exception 로그 분석법, NullPointerException 추적의 정석, 그리고 실전 디버깅 워크플로우까지 함께 살펴보시기 바랍니다.
디버깅의 본질 값의 흐름을 추적하는 일
신입에게 가장 먼저 알려주는 한 문장이 있습니다. “디버깅은 어떤 값이 들어왔고, 어떤 값이 나갔으며, 그 사이 어느 구간에서 원치 않는 방향으로 어긋났는지를 추적하는 일이다.”
이 한 문장에 디버깅의 모든 것이 들어 있습니다. 코드는 결국 입력 → 가공 → 출력의 연속이고, 버그는 그 연결 어딘가에서 기대값과 실제값이 어긋나면서 발생합니다. 따라서 디버깅의 핵심은 기대값과 실제값을 비교 가능한 형태로 만드는 일입니다.
좋은 디버거는 다음 세 가지를 먼저 정확히 정의합니다.
| 단계 | 질문 |
|---|---|
| 1. 입력 | 어떤 값이 들어왔는가? |
| 2. 기대 출력 | 어떤 값이 나와야 했는가? |
| 3. 실제 출력 | 실제로는 어떤 값이 나왔는가? |
이 세 가지가 명확해야 그다음에 “어디서 어긋났는가”를 추적할 수 있습니다. 거꾸로, 이 셋 중 하나라도 모호하면 그 어떤 디버깅 기법도 효과가 없습니다.
안티 패턴 1: log를 100번 찍어서 추측한다
가장 자주 보이는 신입 디버깅 풍경은 다음과 같습니다.
public Order placeOrder(OrderRequest request) {
log.info("1번 도달");
Customer customer = customerService.find(request.getCustomerId());
log.info("2번 customer = {}", customer);
log.info("3번 도달");
Product product = productService.find(request.getProductId());
log.info("4번 product = {}", product);
log.info("5번 도달");
// ...
}
코드 곳곳에 “여기 도달”, “값 출력” 같은 log.info()가 도배되어 있습니다. 한 번 돌려 보고, 안 되면 또 다른 위치에 log를 추가하고, 다시 빌드·재시작·재현하는 사이클이 반복됩니다. 버그 하나 잡는 데 1시간이 넘어가는 가장 흔한 이유가 바로 이 안티 패턴입니다.
왜 이게 비효율인가
세 가지 비용이 누적됩니다.
| 비용 | 설명 |
|---|---|
| 코드 변경 비용 | 디버그용 log가 PR에 섞여 들어가 코드 리뷰 시간 증가 |
| 빌드·재시작 비용 | log 한 줄 추가할 때마다 서버 재시작 후 확인 하는 누적 시간 |
| 추측 비용 | 어디에 찍어야 할지 추측에 의존, 잘못 찍으면 처음부터 다시 |
디버거를 사용하면 같은 작업을 한 번의 실행으로 끝낼 수 있습니다. Breakpoint 하나만 걸면 그 시점의 모든 변수 상태를 한 화면에서 확인할 수 있고, Step 실행으로 한 줄씩 따라가며 값의 변화를 실시간으로 볼 수 있습니다.
IntelliJ 디버거 5분 핵심 사용법
신입에게 디버거를 알려줄 때 다음 다섯 가지만 정확히 가르치면 충분합니다. 나머지는 자연스럽게 따라옵니다.
1. Breakpoint 설정
코드 라인 번호 옆 여백을 클릭하면 빨간 점이 찍히고, 그 라인 진입 시 실행이 멈춥니다. 디버그 모드(▶ 옆 벌레 아이콘)로 실행해야 멈춥니다.
2. Step Over / Step Into / Step Out
| 단축키 | 동작 | 사용 시점 |
|---|---|---|
| F8 (Step Over) | 다음 줄로 이동 | 일반적인 라인 단위 이동 |
| F7 (Step Into) | 메서드 내부로 진입 | 특정 메서드 내부 동작 확인 |
| Shift+F8 (Step Out) | 현재 메서드 빠져나옴 | 잘못 진입했을 때 |
| F9 (Resume) | 다음 Breakpoint까지 실행 | 관심 구간 건너뛸 때 |
3. Evaluate Expression (Alt+F8)
현재 멈춘 시점에서 임의의 표현식을 실행할 수 있습니다. 예를 들어 customer.getOrders().stream().filter(o -> o.getStatus() == PAID).count() 같은 식을 즉석에서 평가해 결과를 확인할 수 있습니다. log를 따로 찍을 필요가 없습니다.
4. Watches
자주 확인하고 싶은 변수나 표현식을 등록해 두면, 매 Step마다 자동으로 값이 갱신되어 표시됩니다. “이 변수가 언제 바뀌는지” 추적하기에 가장 효과적입니다.
5. Conditional Breakpoint
Breakpoint를 우클릭하고 조건식을 입력하면, 그 조건이 참일 때만 멈춥니다. 예를 들어 1만 개의 리스트를 순회하다가 특정 ID에서만 NPE가 나는 경우 order.getId().equals("ORD-12345") 같은 조건을 걸면 정확히 그 시점에만 멈춥니다.
// Conditional Breakpoint 예시
order.getId().equals("ORD-12345")
order.getAmount() > 1000000
customer == null
이 다섯 가지만 익히면 log 100번 찍기는 자연스럽게 사라집니다.
안티 패턴 2: Exception 로그를 끝까지 읽지 않는다
두 번째로 자주 보이는 신입의 모습은 이렇습니다. 에러가 나면 화면 위쪽 한두 줄만 보고 “에러 났네, 다시 돌려볼까” 하고 재실행을 누릅니다. Exception 로그는 디버깅 단서가 가장 풍부하게 모여 있는 곳인데도 그냥 닫아 버립니다.
다음과 같은 스택트레이스가 있다고 가정합니다.
java.lang.NullPointerException:
Cannot invoke "Customer.getEmail()" because the return value of
"CustomerRepository.findById(String)" is null
at com.example.order.OrderService.notify(OrderService.java:87)
at com.example.order.OrderService.placeOrder(OrderService.java:52)
at com.example.order.OrderController.create(OrderController.java:38)
...
Caused by: org.springframework.dao.DataAccessException:
Connection is closed
at com.example.config.DataSourceConfig.lambda$0(DataSourceConfig.java:24)
...
여기서 신입이 자주 놓치는 정보가 세 가지 있습니다.
첫 번째: 예외 메시지의 첫 줄에 답이 있다
Cannot invoke "Customer.getEmail()" because the return value of "CustomerRepository.findById(String)" is null — 이 한 줄이 버그의 원인을 이미 정확히 말하고 있습니다. “findById가 null을 반환했는데 getEmail()을 호출하려 했다”는 뜻입니다. Java 14+의 Helpful NullPointerExceptions 기능 덕분에 어느 객체가 null인지까지 알려 줍니다.
두 번째: 스택트레이스의 첫 줄이 발생 위치다
at com.example.order.OrderService.notify(OrderService.java:87) — 정확히 87번 라인에서 발생했다는 의미입니다. 그 라인을 보면 거의 항상 답이 있습니다.
세 번째: “Caused by”는 진짜 원인이다
표면 예외 아래의 Caused by:가 실제 근본 원인일 때가 많습니다. 위 예시에서는 “Connection is closed”가 진짜 원인이고, NPE는 그 결과입니다. 스택트레이스는 반드시 마지막 Caused by까지 읽어야 합니다.
신입이 자주 만나는 5가지 예외와 빠른 진단
| 예외 | 가장 흔한 원인 | 빠른 점검 |
|---|---|---|
NullPointerException | 메서드 결과나 외부 입력이 null인데 검증 안 함 | “어떤 객체가 null인가” 메시지 확인 |
ClassCastException | 잘못된 타입 캐스팅 | 실제 객체 타입 vs 캐스팅 대상 비교 |
IndexOutOfBoundsException | 리스트·배열 인덱스 초과 | size 체크 누락 |
ConcurrentModificationException | 순회 중 컬렉션 변경 | Iterator.remove() 또는 복사본 사용 |
StackOverflowError | 무한 재귀 | 종료 조건 누락 |
NullPointerException 추적의 정석
신입이 가장 자주 마주치는 예외는 단연 NullPointerException입니다. 다음 세 가지 원칙을 기억하면 NPE의 80%는 사전에 막을 수 있습니다.
1. 메서드 결과는 항상 null 가능성을 의심한다
// 위험한 코드
Customer customer = customerRepository.findById(id);
String email = customer.getEmail(); // findById가 null 반환하면 NPE
// 안전한 코드: Optional 활용
Customer customer = customerRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.CUSTOMER_NOT_FOUND));
String email = customer.getEmail();
findById처럼 결과가 없을 수 있는 메서드는 반환 타입을 Optional로 두는 것이 표준입니다. 신입이 작성한 메서드도 마찬가지로 설계해야 합니다.
2. 외부 입력은 무조건 null 검증한다
외부에서 들어오는 값(API 요청, DB 조회 결과, 설정 파일)은 항상 null일 수 있다고 가정해야 합니다. Spring의 @Valid와 @NotNull 어노테이션을 사용하면 컨트롤러 단에서 자동 검증할 수 있습니다.
public class OrderRequest {
@NotNull(message = "고객 ID는 필수입니다")
private String customerId;
@NotNull
@Min(value = 1, message = "수량은 1 이상이어야 합니다")
private Integer quantity;
}
3. Map.get() 결과는 반드시 체크한다
// 위험: 키가 없으면 null 반환
String value = map.get("KEY");
value.toUpperCase(); // NPE 가능
// 안전: getOrDefault 사용
String value = map.getOrDefault("KEY", "");
이 패턴은 이전 글에서 다룬 JSON 키 대소문자 미스매치 사고와도 직접 연결됩니다. Map의 키 대소문자가 다르면 get()이 조용히 null을 반환해 NPE로 이어지므로, DTO와 @JsonAlias로 받는 것이 근본적인 해결책입니다.
신입에게 강조하는 디버깅 워크플로우 6단계
마지막으로, 같은 버그라도 6단계 워크플로우대로 진행하면 추적 시간이 절반 이하로 줄어듭니다.
1단계. 증상을 정확한 문장으로 정의한다. “안 돼요”는 증상이 아닙니다. “주문 등록 API에 customerId=1234로 호출했을 때 500 에러가 발생한다”가 증상입니다.
2단계. 재현 가능한 최소 케이스를 만든다. 운영에서 발생한 버그라면 같은 입력값으로 로컬에서 재현할 수 있는 환경을 먼저 만듭니다. 재현되지 않는 버그는 추적이 거의 불가능합니다.
3단계. 입력값과 기대 출력을 명확히 적는다. 종이에 한 줄씩이라도 적어 두면 추적 중 길을 잃지 않습니다.
4단계. 첫 Breakpoint는 가장 의심되는 위치 한 곳에 건다. 처음부터 여러 곳에 걸지 않습니다. 한 곳에서 멈춰 변수를 확인하고, 그다음을 결정합니다.
5단계. Step Over로 한 줄씩 따라가며 값의 변화를 관찰한다. 이때 Watches에 핵심 변수를 등록해 두면 변화 시점을 놓치지 않습니다.
6단계. 원인을 찾으면 수정 → 재현 케이스 다시 돌려 본다. 수정했다고 끝이 아니라, 재현 케이스가 정말 통과하는지 확인하고 마무리합니다.
자주 묻는 질문 (FAQ)
Q1. log와 디버거 중 어느 게 더 좋나요?
개발·로컬 단계에서는 디버거가 압도적으로 효율적입니다. 한 번의 실행으로 모든 변수 상태를 확인할 수 있기 때문입니다. 반면 운영 환경에서는 log가 유일한 단서이므로, 운영용 로그 정책(traceId 포함, 입출력 본문 절단 저장 등)을 별도로 잘 설계해야 합니다. 둘은 대체재가 아니라 보완재입니다.
Q2. 운영 환경에서도 디버거를 붙일 수 있나요?
기술적으로는 가능합니다. JVM에 -agentlib:jdwp 옵션을 주면 Remote Debug가 가능합니다. 다만 운영 트래픽이 멈출 수 있어 매우 신중해야 하며, 보통은 로그 보강·Heap Dump·Thread Dump·APM 도구로 대신합니다. Remote Debug는 카나리 인스턴스 한 대에 한정해 짧게만 사용하시기 바랍니다.
Q3. 멀티스레드 버그는 어떻게 디버깅하나요?
일반 Breakpoint로는 잘 잡히지 않는 경우가 많습니다. IntelliJ의 Thread Filter Breakpoint를 사용해 특정 스레드에서만 멈추도록 하거나, Thread Dump(jstack 또는 IntelliJ의 “Dump Threads”)를 떠서 데드락·블로킹 상태를 분석합니다.
Q4. 비동기·Reactive 코드는 디버깅하기 어렵지 않나요?
맞습니다. ThreadLocal 컨텍스트가 끊겨 일반 Breakpoint가 의도대로 동작하지 않습니다. Reactor의 경우 .log() 연산자나 Reactor Tools(BlockHound·Reactor Debug Agent) 를 활용하면 콜백 흐름을 따라갈 수 있습니다.
Q5. NullPointerException을 근본적으로 줄이려면 어떻게 해야 하나요?
세 가지를 습관화하시기 바랍니다. 반환 타입은 가능하면 Optional로, 외부 입력은 @Valid로 자동 검증, Lombok의 @NonNull 또는 Objects.requireNonNull() 로 메서드 진입 시 검증. Java 14+ 환경이라면 NPE 메시지가 어느 객체가 null인지 알려 주므로, 그 정보를 적극 활용하시기 바랍니다.
마무리
신입 개발자 디버깅 가이드의 본질은 도구 사용법이 아니라 값의 흐름을 추적하는 사고방식입니다. log를 100번 찍어 추측에 의존하는 대신, 디버거 한 번으로 모든 변수 상태를 확인하고, 스택트레이스는 마지막 Caused by까지 읽고, NullPointerException은 메시지에서 어느 객체가 null인지를 먼저 확인하시기 바랍니다.
핵심을 다시 정리하면 다음과 같습니다. 디버깅은 입력·기대 출력·실제 출력 세 가지 비교에서 시작하고, IntelliJ 디버거의 다섯 가지 기본기(Breakpoint·Step·Evaluate·Watches·Conditional)만 익혀도 추적 시간이 절반 이하로 줄어듭니다. Exception 로그는 첫 줄과 마지막 Caused by가 답이며, NPE는 메서드 결과·외부 입력·Map.get() 세 곳을 의심합니다. 마지막으로 6단계 워크플로우(증상 정의 → 재현 케이스 → 입력·기대 정리 → 첫 Breakpoint → Step 추적 → 수정 후 재검증)를 습관화하면, 같은 사람이 같은 시간 안에 잡을 수 있는 버그 수가 몇 배로 늘어납니다.
핵심 요약
- 디버깅의 본질: 어떤 값이 들어왔고, 어떤 값이 나갔으며, 어느 구간에서 어긋났는지를 추적하는 일입니다.
- 안티 패턴: log를 100번 찍어 추측하기, Exception 로그를 끝까지 안 읽기.
- IntelliJ 디버거 5가지: Breakpoint · Step Over/Into/Out · Evaluate Expression · Watches · Conditional Breakpoint.
- Exception 분석 3원칙: 첫 줄에 답이 있다 / 스택트레이스 첫 줄이 발생 위치 / 마지막
Caused by가 진짜 원인.- NPE 예방 3가지: 메서드 결과는
Optional, 외부 입력은@Valid,Map.get()은getOrDefault.- 6단계 워크플로우: 증상 정의 → 재현 케이스 → 입력·기대 정리 → 첫 Breakpoint → Step 추적 → 수정 후 재검증.