예외 계층, try/catch/throw 규칙, Spring/MyBatis 예외 처리, 스택트레이스 읽기, IntelliJ 디버거,
jstack/jmap/jcmd같은 JDK 진단 도구를 한 페이지에 모았습니다.Ctrl+F로 예외 이름(예:ConcurrentModificationException,jstack,Evaluate Expression)을 검색하면 됩니다. 원리는 중급 03 예외 처리 레슨에서 다루므로 여기서는 바로 찾아 쓰는 표와 코드만 담습니다.
| 항목 | 설명 | 예제 | 결과 |
|---|---|---|---|
throw e; |
원래 예외 재던지기 | catch (IOException e) { throw e; } |
스택트레이스 보존 |
new X("msg", e) |
원인 보존 래핑 | new IllegalStateException("실패", e) |
getCause()로 원인 조회 |
Objects.requireNonNull(x, msg) |
null 검사 | requireNonNull(id, "id null") |
NullPointerException: id null |
Optional.orElseThrow(...) |
없으면 예외 | opt.orElseThrow(() -> new NotFound()) |
커스텀 예외 던짐 |
try (AutoCloseable r = ...) |
자원 자동 해제 | try (var c = ds.getConnection()) |
finally 없이 close |
getSuppressed() |
숨겨진 예외 조회 | e.getSuppressed() |
close 중 예외 배열 |
Throwable.getCause() |
원인 예외 | e.getCause() |
최초 원인 반환 |
e.getMessage() |
메시지만 | e.getMessage() |
"id null" |
e.toString() |
클래스+메시지 | e.toString() |
"java.lang.NullPointerException: id null" |
Thread.currentThread().getStackTrace() |
현재 스택 | [0]은 이 메서드 자신 |
StackTraceElement[] |
assertThrows(X.class, () -> ...) |
예외 발생 검증 | JUnit 테스트 | 통과/실패 |
@RestControllerAdvice |
전역 예외 처리 | 컨트롤러 밖에서 잡기 | 응답 규격 통일 |
@Transactional(rollbackFor = X.class) |
checked도 롤백 | 기본은 RuntimeException만 | 커밋/롤백 결정 |
jstack <pid> |
스레드 덤프 | 데드락 감지 | Found one Java-level deadlock |
jmap -histo:live <pid> |
객체 수 상위 | 메모리 누수 의심 시 | 클래스별 인스턴스 수 |
-XX:+HeapDumpOnOutOfMemoryError |
OOM 시 덤프 | JVM 옵션 | .hprof 파일 생성 |
| 조건부 브레이크포인트 | 특정 값에서만 정지 | id == 42 |
그 값일 때만 멈춤 |
| 로그 브레이크포인트 | 멈추지 않고 출력 | 콘솔에 값 출력 | 실행 흐름 유지 |
| Evaluate Expression | 정지 상태에서 식 실행 | list.size() |
즉시 값 확인 |
-agentlib:jdwp=... |
원격 디버그 포트 | address=*:5005 |
운영에서는 금지 |
Throwable
├─ Error (잡지 않음, 복구 불가)
│ ├─ OutOfMemoryError
│ └─ StackOverflowError
└─ Exception
├─ checked (컴파일러가 처리 강제)
│ ├─ IOException
│ └─ SQLException
└─ RuntimeException (unchecked)
├─ NullPointerException
├─ IllegalArgumentException
├─ IllegalStateException
├─ IndexOutOfBoundsException
├─ ClassCastException
├─ NumberFormatException (IllegalArgumentException 하위)
├─ UnsupportedOperationException
├─ ConcurrentModificationException
└─ ArithmeticException각 예외가 언제 나는지와 재현 코드입니다.
| 예외 | 언제 발생 | 재현 코드 한 줄 |
|---|---|---|
NullPointerException |
null 참조 호출 | String s = null; s.length(); |
IllegalArgumentException |
잘못된 인자값 | LocalDate.of(2024, 13, 1) |
IllegalStateException |
객체 상태가 부적절 | stream.forEach(x->{}); stream.count(); |
IndexOutOfBoundsException |
범위 밖 인덱스 | List.of(1,2).get(5) |
ClassCastException |
잘못된 형변환 | (String)(Object)(Integer)1 |
NumberFormatException |
숫자 파싱 실패 | Integer.parseInt("12a") |
UnsupportedOperationException |
불변 컬렉션 수정 | List.of(1).add(2) |
ConcurrentModificationException |
반복 중 컬렉션 수정 | for(var x:list) list.remove(x); |
ArithmeticException |
산술 오류 | 1 / 0 |
OutOfMemoryError |
힙/Metaspace 고갈 | 무한 리스트 누적 |
StackOverflowError |
재귀 무한 호출 | void f(){f();} |
Spring 은 데이터 접근 예외를 DataAccessException(unchecked) 계층으로 감쌉니다.
SQLException(checked)을 그대로 던지지 않고 이 계층으로 번역합니다.
MyBatis 는 내부 오류를 PersistenceException으로 감싸고, Spring 연동 시 다시 DataAccessException으로 변환됩니다.
catch 순서는 구체적 예외를 먼저, 일반 예외를 나중에 씁니다.
try {
process();
} catch (IllegalArgumentException e) { // 구체적
log.warn("잘못된 인자", e);
} catch (RuntimeException e) { // 일반적, 뒤에
log.error("처리 실패", e);
}
// → 순서 반대면 컴파일 에러(이미 잡힌 catch 이후 도달 불가)multi-catch는 관련 없는 예외를 한 줄로 처리할 때 씁니다.
try {
convert();
} catch (NumberFormatException | DateTimeParseException e) {
log.warn("입력 형식 오류: {}", e.getMessage());
}finally 에서 return 하면 try/catch 의 반환값을 덮어써서 버그가 됩니다.
static int bad() {
try {
return 1;
} finally {
return 2; // → 항상 2 반환, try의 1은 버려짐. 절대 금지
}
}try-with-resources 는 여러 자원을 선언 역순으로 닫습니다.
try (var a = openA(); var b = openB()) {
use(a, b);
}
// → 닫는 순서: b.close() 먼저, a.close() 나중
// b.close()에서 예외가 나면 a.close()의 예외는 suppressed로 붙는다e.getSuppressed()로 숨겨진 예외를 확인합니다.
try {
/* 위 코드에서 예외 발생 시 */
} catch (Exception e) {
for (var s : e.getSuppressed()) log.warn("suppressed: {}", s.toString());
}catch 블록의 변수는 그 블록 안에서만 유효합니다. 같은 이름을 여러 catch에서 재사용해도 서로 다른 변수입니다.
throw는 실제로 던지는 문장, throws는 메서드 선언에 붙이는 표시입니다.
void save(String id) throws SQLException { // 선언: 호출자에게 알림
if (id == null) throw new IllegalArgumentException("id null"); // 실행
}checked 예외를 unchecked로 래핑할 때는 원인을 반드시 함께 넘깁니다.
try {
repo.save(entity);
} catch (SQLException e) {
throw new IllegalStateException("저장 실패: id=" + entity.getId(), e);
// → 두 번째 인자 e가 원인(cause) 보존. 빠뜨리면 원인 추적 불가
}계층 경계(컨트롤러/서비스/레포지토리)에서는 하위 계층 예외를 도메인 예외로 번역합니다.
try {
return mapper.selectUser(id);
} catch (PersistenceException e) {
throw new UserNotFoundException("user id=" + id, e); // 예외 번역
}그대로 다시 던질 때는 throw e;로 스택트레이스를 보존합니다.
새 예외로 다시 만들면 원래 발생 위치 정보가 사라집니다.
예외 메시지에는 반드시 어떤 id·값에서 났는지 컨텍스트를 넣습니다.
throw new IllegalArgumentException("orderId=" + orderId + " status=" + status + " 는 취소 불가");비즈니스 예외는 보통 하나로 두고, 에러 코드로 종류를 구분합니다.
public enum ErrorCode {
USER_NOT_FOUND(404, "사용자를 찾을 수 없습니다"),
DUPLICATE_EMAIL(409, "이미 등록된 이메일입니다"),
INVALID_STATE(400, "잘못된 상태입니다");
final int httpStatus;
final String message;
ErrorCode(int httpStatus, String message) {
this.httpStatus = httpStatus;
this.message = message;
}
}생성자는 보통 4종을 둡니다: 코드만, 코드+상세, 코드+원인, 코드+상세+원인.
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
private final String detail;
public BusinessException(ErrorCode c) { this(c, null, null); }
public BusinessException(ErrorCode c, String detail) { this(c, detail, null); }
public BusinessException(ErrorCode c, Throwable cause) { this(c, null, cause); }
public BusinessException(ErrorCode c, String detail, Throwable cause) {
super(c.message + (detail != null ? " (" + detail + ")" : ""), cause);
this.errorCode = c;
this.detail = detail;
}
public ErrorCode getErrorCode() { return errorCode; }
}
// → throw new BusinessException(ErrorCode.USER_NOT_FOUND, "id=" + id);checked 커스텀 예외는 거의 만들지 않습니다. 호출자에게 강제로 처리시켜야 할 뚜렷한 이유(재시도 필수 등)가 없으면 unchecked로 둡니다. 예외 계층은 2단(최상위 하나 + 세부 하나) 이내로 유지합니다.
java.lang.IllegalStateException: 주문 처리 실패: orderId=1001
at com.company.order.OrderService.process(OrderService.java:42)
at com.company.order.OrderController.create(OrderController.java:20)
at jdk.internal.reflect.GeneratedMethodAccessor12.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
Caused by: java.sql.SQLException: ORA-00001: unique constraint violated
at com.company.order.OrderMapper.insert(OrderMapper.java:15)
at com.company.order.OrderService.process(OrderService.java:40)
... 23 moreCaused by가 진짜 원인입니다. 여기서는 ORA-00001 유니크 제약 위반이 근본 원인입니다.Caused by 블록에서 내 패키지(com.company...)가 등장하는 첫 줄이 실제 문제 위치입니다.... 23 more는 위 스택과 겹치는 하위 프레임 23개를 생략했다는 뜻입니다.jdk.internal.reflect..., Method.invoke 같은 리플렉션/프록시 프레임은 대부분 건너뛰어도 됩니다.$SpringCGLIB, ReflectiveMethodInvocation)도 마찬가지로 건너뛰고 그 아래 실제 호출을 봅니다.e.printStackTrace()는 표준출력으로 나가 로그 시스템에 안 남으므로 대신 로거를 씁니다.
log.error("주문 처리 실패: orderId={}", orderId, e); // e를 마지막 인자로getStackTrace()[0]은 예외가 던져진 최초 위치입니다.
toString()은 클래스명: 메시지, getMessage()는 메시지만 반환합니다.
| 예외 | 흔한 원인 | 해결 |
|---|---|---|
NullPointerException |
메시지의 "because ... is null" 부분 읽기 | Helpful NPE로 어떤 변수인지 확인 |
NumberFormatException |
입력에 공백·콤마 포함 | s.trim().replace(",", "") 후 파싱 |
IndexOutOfBoundsException |
size와 마지막 인덱스 혼동 | 반복 전 size() > 0 확인 |
ClassCastException |
제네릭 소거, JSON 역직렬화 타입 불일치 | 대상 타입을 명시적으로 지정 |
ConcurrentModificationException |
반복 중 컬렉션 삭제 | list.removeIf(cond) 또는 Iterator.remove() |
ArithmeticException |
정수 나눗셈 0, BigDecimal 무한소수 | 0 검사, divide(x, scale, mode) |
DateTimeParseException |
포맷 문자열이 입력과 불일치 | 실제 값 형식에 맞는 패턴 확인 |
UnsupportedOperationException |
List.of() 등 불변 리스트 수정 |
new ArrayList<>(List.of(...)) |
IllegalStateException |
스트림을 두 번 소비 | 매번 새 스트림 생성 |
OutOfMemoryError |
힙 고갈 vs Metaspace 고갈 | 메시지의 "Java heap space" vs "Metaspace" 구분 |
StackOverflowError |
무한 재귀, toString() 순환 참조 |
종료 조건 확인, toString에서 순환 필드 제외 |
Helpful NPE 메시지 예시입니다.
Cannot invoke "String.length()" because "s" is nullHelpful NullPointerException은 JDK 15+ 기본 활성화이며 어떤 변수가 null인지 알려줍니다.
BigDecimal 무한소수 예시입니다.
new BigDecimal("10").divide(new BigDecimal("3"));
// → ArithmeticException: Non-terminating decimal expansion
new BigDecimal("10").divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP);
// → 3.33자주 만나는 ORA 코드입니다.
| ORA 코드 | 의미 |
|---|---|
ORA-00001 |
유니크 제약 위반 |
ORA-01400 |
NOT NULL 컬럼에 null 삽입 |
ORA-01722 |
문자를 숫자로 변환 실패 |
ORA-00942 |
테이블 또는 뷰가 없음 |
ORA-01000 |
오픈 커서 초과 |
ORA-00060 |
데드락 감지 |
전역 예외 처리 골격입니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handle(BusinessException e) {
var code = e.getErrorCode();
return ResponseEntity.status(code.httpStatus)
.body(new ErrorResponse(code.name(), e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handle(MethodArgumentNotValidException e) {
var msg = e.getBindingResult().getFieldErrors().get(0).getDefaultMessage();
return ResponseEntity.badRequest().body(new ErrorResponse("VALIDATION", msg));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handle(Exception e) {
String traceId = MDC.get("traceId");
log.error("처리되지 않은 예외 traceId={}", traceId, e);
return ResponseEntity.internalServerError()
.body(new ErrorResponse("INTERNAL", "서버 오류, traceId=" + traceId));
}
}@Transactional은 기본적으로 RuntimeException(및 Error)만 롤백합니다.
checked 예외까지 롤백하려면 rollbackFor를 명시합니다.
@Transactional(rollbackFor = Exception.class)
void transfer(...) throws Exception { ... }트랜잭션 안에서 예외를 catch 후 삼키면 트랜잭션이 정상 커밋됩니다.
@Transactional
void bad() {
try {
repo.save(entity);
} catch (Exception e) {
log.warn("저장 실패", e); // → 삼켜짐, 트랜잭션은 그대로 커밋된다
}
}
// 반드시 다시 던지거나 setRollbackOnly() 호출MyBatis 예외는 Spring 연동 시 PersistenceExceptionTranslator가 DataAccessException으로 자동 변환합니다.
호출 코드에서는 SQLException이 아니라 DataAccessException 하위 타입을 잡습니다.
try {
mapper.insert(user);
} catch (DuplicateKeyException e) { // ORA-00001이 여기로 매핑
throw new BusinessException(ErrorCode.DUPLICATE_EMAIL, e);
}Objects.requireNonNull(id, "id must not be null");
// → null이면 NullPointerException: id must not be null
String name = Objects.requireNonNullElse(input, "default");
// → input이 null이면 "default"
Optional<User> opt = repo.findById(id);
opt.orElse(guestUser); // 기본값(즉시 계산됨)
opt.orElseGet(() -> buildGuestUser()); // 기본값(지연 계산)
opt.orElseThrow(() -> new UserNotFoundException(id));// 없으면 예외방어적 null 체크는 메서드 경계(공개 API 진입점)에서만 하고 내부에서는 반복하지 않습니다.
@Nullable/@NonNull 주석은 관례상 파라미터·반환값이 null 가능한지 표시합니다.
NPE 방지 체크리스트입니다.
Map.get() 결과는 곧바로 쓰지 말고 존재 확인equals()는 Objects.equals(a, b)로 null-safe 비교| 기능 | IntelliJ | VSCode |
|---|---|---|
| 브레이크포인트 | 라인 번호 옆 클릭 | 라인 번호 옆 클릭 |
| 조건부 브레이크포인트 | 우클릭 → Condition에 id == 42 |
우클릭 → Edit Breakpoint → Condition |
| 로그 브레이크포인트 | Condition 옆 "Evaluate and log" | Edit Breakpoint → Log Message |
| 예외 브레이크포인트 | Run → View Breakpoints → Java Exception Breakpoints | Java: Add Exception Breakpoint |
| Step Over/Into/Out | F8/F7/Shift+F8 | F10/F11/Shift+F11 |
| Run to Cursor | Alt+F9 | Ctrl+F10 |
| Evaluate Expression | Alt+F8 | 디버그 콘솔에 직접 입력 |
| 변수 값 바꾸기 | Variables 창에서 값 더블클릭(Set Value) | Variables에서 값 편집 |
| Drop Frame | Debug 창 툴바 | 별도 지원 약함, 재시작 권장 |
예외 브레이크포인트는 특정 예외(예: NPE)가 던져지는 즉시 멈춥니다. 어디서 catch 되든 상관없이 최초 발생 지점에서 멈추므로 원인 추적에 유용합니다.
Evaluate Expression은 중단 상태에서 임의의 식을 즉시 실행해 값을 확인합니다.
Evaluate: list.stream().filter(x -> x.getId() == 42).count()Drop Frame은 현재 스택 프레임을 버리고 호출 이전 상태로 되돌려 재실행합니다. 호출 부작용(DB 저장 등)이 이미 일어났다면 중복 실행 위험이 있어 주의합니다.
원격 디버그는 JVM에 다음 옵션을 붙여 포트를 엽니다.
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jarsuspend=n은 디버거 연결 전에도 앱이 먼저 실행됨을 의미합니다.
이 포트는 인증 없이 코드 실행이 가능하므로 운영 환경에는 절대 열지 않습니다.
로그는 입력값, 분기 결과, 외부 호출(DB/API) 전후에 남깁니다.
log.debug("조회 시작 userId={}", userId);
var user = repo.findById(userId).orElseThrow();
log.debug("조회 완료 status={}", user.getStatus());레벨은 ERROR(장애), WARN(비정상이지만 계속 가능), INFO(주요 흐름), DEBUG(상세 값) 순으로 선택합니다.
파라미터는 문자열 연결 대신 자리표시자({})를 씁니다.
log.info("주문 생성 orderId={} amount={}", orderId, amount); // 문자열 연결 금지MDC로 요청 단위 traceId를 남기면 로그 여러 줄을 하나의 요청으로 묶을 수 있습니다.
MDC.put("traceId", UUID.randomUUID().toString());
// ... 요청 처리 ...
MDC.clear(); // 스레드 재사용 대비 필수조건부 DEBUG는 if (log.isDebugEnabled())로 비용이 큰 로그 생성을 막습니다.
운영 로그에서 문제를 재현하는 절차입니다.
traceId 확인| 명령 | 용도 |
|---|---|
jps |
실행 중인 JVM 프로세스 목록과 PID |
jstack <pid> |
스레드 덤프, 데드락 감지 |
jmap -histo:live <pid> |
클래스별 살아있는 객체 수 상위 목록 |
jmap -dump:live,format=b,file=heap.hprof <pid> |
힙 덤프 파일 생성 |
jcmd <pid> GC.heap_info |
힙 영역별 사용량 |
jcmd <pid> Thread.print |
jstack과 동일한 스레드 덤프 |
jcmd <pid> VM.flags |
적용된 JVM 옵션 확인 |
jcmd <pid> GC.run |
Full GC 강제 실행 |
jstat -gcutil <pid> 1000 |
1초 간격 GC 영역별 사용률 |
jconsole / jvisualvm |
GUI 모니터링, 폐쇄망 반입 가능 |
C:\project\jdk-21.0.8\bin\jstack 12345 > thread_dump.txt데드락이 있으면 다음과 같이 표시됩니다.
Found one Java-level deadlock:
"Thread-1": waiting to lock monitor 0x000... (a java.lang.Object),
which is held by "Thread-0"스레드 덤프에서 확인할 항목입니다.
BLOCKED 상태 스레드: 락을 기다리는 중, waiting to lock 뒤의 대상 확인RUNNABLE이 수백 개면 스택 상단 메서드로 무슨 작업(파싱, 정렬 등) 중인지 추정VisualVM은 JDK 기본 제공이 아니라 별도 다운로드가 필요하며, 폐쇄망은 사전 반입이 필요합니다. 힙 덤프(.hprof) 상세 분석은 MAT(Eclipse Memory Analyzer)를 별도 설치해 사용합니다.
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=C:\dumps
# → OOM 발생 시 자동으로 힙 덤프 생성
-Xlog:gc*:file=gc.log
# → GC 로그를 파일로 기록
-XX:+ExitOnOutOfMemoryError
# → OOM 시 프로세스 즉시 종료(재기동 스크립트와 병행)
-Dfile.encoding=UTF-8
# → 파일/콘솔 인코딩 고정, 폐쇄망 한글 깨짐 방지
-ea
# → assert 문 활성화(기본은 비활성)
-verbose:class
# → 클래스 로딩 과정 출력, 클래스 충돌 진단
-XX:+PrintFlagsFinal
# → 전체 JVM 옵션 값 출력, grep으로 필터링C:\project\jdk-21.0.8\bin\java -XX:+PrintFlagsFinal -version | grep -i heapsize버그를 고치기 전에 재현 테스트를 먼저 작성합니다.
@Test
void 존재하지않는사용자조회시예외() {
assertThrows(UserNotFoundException.class, () -> service.getUser(-1L));
}경계값(0, 음수, 최대값, 빈 문자열, null)을 먼저 테스트합니다. 스택트레이스의 파라미터 값(로그에 남은 id, 입력값)을 그대로 테스트 입력으로 씁니다.
@ParameterizedTest
@ValueSource(strings = {"", " ", "abc"})
void 잘못된형식이면예외(String input) {
assertThrows(NumberFormatException.class, () -> Integer.parseInt(input.trim()));
}수정 후에는 같은 테스트가 통과하는지 재실행해 회귀를 막습니다. 자동화된 회귀 테스트 구성은 실무 확장 12 레슨에서 다룹니다.
버그를 만나면 다음 순서로 좁혀갑니다.
git log, 배포 이력)Caused by에서 진짜 원인 찾기"내 컴퓨터에서는 되는데 운영에서는 다르다" 계열은 환경 차이를 먼저 확인합니다.
| 차이 항목 | 확인 방법 |
|---|---|
| 인코딩 | file.encoding 값, DB 커넥션 문자셋 |
| 시간대 | user.timezone, DB 서버 시간대 |
| 로케일 | Locale.getDefault(), 숫자/날짜 포맷 영향 |
| JDK 버전 | java -version, 배포 서버와 개발 환경 일치 여부 |
| 활성 프로파일 | spring.profiles.active 설정값 |
이 표의 항목이 개발 환경과 운영 환경에서 다르면 같은 코드가 다르게 동작할 수 있습니다.