제11장 동시성: 안전하고 효율적인 다중 스레드 프로그래밍
아이템 78. 공유 중인 가변 데이터는 동기화해 사용하라
- 여러 스레드가 동시에 한 데이터를 고치려 하면 데이터가 엉망이 됩니다. 하지만 동기화는 단순히 "한 번에 한 명만 고치기" 이상의 의미가 있습니다.
1. 동기화는 '줄 세우기' 그 이상이다
보통 synchronized 키워드를 보면 "한 번에 한 스레드만 실행하게 막는 것(배타적 실행)"이라고만 생각합니다. 하지만 동기화에는 아주 중요한 기능이 하나 더 있습니다.
- 가시성(Visibility) 보장: 한 스레드가 만든 변화가 다른 스레드에게 언제 어떻게 보이는지를 결정합니다.
- 동기화가 없으면, 한 스레드가 수정한 값이 메인 메모리에 즉시 반영되지 않거나, 다른 스레드가 자신의 CPU 캐시에서 옛날 데이터를 계속 읽어오는 문제가 발생합니다.
2. "내 컴퓨터에선 무한 루프가 돌아요!" (가시성 문제)
책에서는 StopThread라는 예시를 듭니다. 메인 스레드가 stopRequested라는 변수를 true로 바꿔도, 다른 스레드는 이 변화를 눈치채지 못하고 무한 루프를 돌 수 있습니다.
- 원인: JVM의 최적화 때문입니다. JVM은 코드를 실행할 때 성능을 높이기 위해 "이 변수는 안 변하겠네?"라고 판단하면 끌어올리기(Hoisting)라는 작업을 수행하여 루프 밖으로 빼버립니다.
- 결과: 동기화를 하지 않으면 다른 스레드가 변수 값을 바꿨음에도 불구하고, 내 스레드는 계속 옛날 값을 보고 "아직
false구나!"라며 작업을 멈추지 않는 응답 불가(Liveness failure) 상태가 됩니다.
JVM 최적화
- JVM이 보는 시각: "이 스레드에서 stopRequested를 안 바꾸네? → 값이 안 바뀐다고 가정"
- 최적화 동작: 루프 시작 전 한 번만 읽고, 그 값을 계속 재사용
- 결과: 다른 스레드가 바꿔도 이 스레드는 옛날 값만 봄
- 냉장고에 우유가 있는지 매번 확인(느림) vs 아침에 한 번 확인하고 기억(빠름)
- JVM은 성능을 위해 후자를 선택 → volatile로 "매번 확인해!"라고 강제
3. volatile과 Atomic 클래스 활용하기
매번 synchronized를 쓰기엔 성능이 걱정된다면, 상황에 맞는 대안을 쓸 수 있습니다.
volatile한정자: 이 키워드를 붙이면 변수를 읽고 쓸 때 무조건 메인 메모리를 향하게 합니다. 덕분에 '가시성'은 해결되지만, 주의할 점이 있습니다.- 주의:
count++같은 작업은 원자적(Atomic)이지 않습니다. 읽고, 더하고, 저장하는 세 단계로 나뉘기 때문에volatile만으로는 여러 스레드가 동시에 올릴 때 값이 씹힐 수 있습니다.
- 주의:
AtomicLong,AtomicInteger: 이런 연산까지 안전하게 하고 싶다면java.util.concurrent.atomic패키지의 클래스들을 사용하세요. 성능도 좋고 원자성까지 보장합니다.
4. 가장 좋은 방법은 '공유하지 않는 것'
저자는 가장 좋은 대책으로 "가변 데이터는 단일 스레드에서만 쓰자"고 조언합니다. 데이터를 여러 스레드가 나눠 갖지 않으면 동기화할 이유도, 사고가 날 이유도 없기 때문입니다. 만약 꼭 공유해야 한다면 그 객체를 불변(Immutable)으로 만들거나, 수정이 끝난 뒤에만 동기화를 통해 안전하게 넘겨주어야 합니다.
요약
- 동기화는 '순서 지키기'뿐만 아니라 '수정된 값 제대로 보여주기'를 위해 필수입니다.
- 간단한 가시성 문제라면
volatile로 해결 가능하지만,++연산 같은 작업은Atomic클래스나synchronized를 써야 합니다. - 애초에 가변 데이터를 스레드 간에 공유하지 않는 것이 최선입니다.
비유로 이해하기:
이 상황은 '공용 화이트보드'에 비유할 수 있습니다.
여러 사람이 같은 화이트보드에 숫자를 적고 있는데, 각자 자기만의 안경(CPU 캐시)을 쓰고 있습니다. 어떤 안경은 화이트보드에 새로 적힌 글씨를 바로 보여주지 않고 옛날 이미지를 보여줍니다 (가시성 문제). 이때 synchronized나 volatile을 쓰는 것은 "모두 안경을 벗고 맨눈으로 직접 칠판(메인 메모리)을 확인하라"고 규칙을 정하는 것과 같습니다. 그래야만 누군가 숫자를 지우고 새로 썼을 때 모두가 똑같은 최신 정보를 볼 수 있게 됩니다.
스트림 병렬화 시 성능 개선이 어려운 두 가지 주요 조건은?
가변 객체를 다른 스레드로 건네는 안전 발행 방법들을 서술하라.
지연 초기화용 이중검사 관용구에서 필드를 volatile로 선언하는 이유는?
아이템 79. 과도한 동기화는 피하라
1. 외계인 메서드(Alien Method)를 조심하세요
동기화된 블록 안에서 사용자가 재정의한 메서드를 호출하거나, 사용자가 건네준 함수 객체를 실행하는 것을 '외계인 메서드 호출'이라고 합니다. 이 메서드는 외부에서 왔기 때문에 무슨 일을 할지 알 수 없으며, 다음과 같은 치명적인 문제를 일으킬 수 있습니다.
- 예외 발생 (응답 불가): 동기화 블록이 리스트를 순회하는 도중, 외계인 메서드가 그 리스트의 원소를 수정(삭제 등)하려 하면
ConcurrentModificationException이 발생할 수 있습니다. - 교착상태 (Deadlock): 만약 외계인 메서드가 별도의 스레드를 생성해 같은 자원의 락(lock)을 얻으려 한다면, 메인 스레드는 락을 쥔 채 메서드가 끝나기를 기다리고 백그라운드 스레드는 메인 스레드가 락을 놓기를 기다리는 상황이 되어 프로그램이 멈춰버립니다.
2. 해결책: 열린 호출(Open Call)
외계인 메서드 호출을 동기화 블록 바깥으로 옮기면 문제를 해결할 수 있습니다. 이를 열린 호출이라 합니다.
- 방법: 동기화 블록 안에서는 공유 데이터의 '스냅샷(복사본)'만 안전하게 만들고, 실제 데이터 가공이나 외계인 메서드 호출은 블록 밖에서 수행합니다.
- 더 좋은 대안: 자바의 동시성 컬렉션인
CopyOnWriteArrayList를 사용하면 내부를 변경할 때마다 복사본을 만들기 때문에, 별도의 락 없이도 안전하게 순회하며 외부 메서드를 호출할 수 있습니다.
3. 동기화의 성능 비용
오늘날의 멀티코어 환경에서 동기화의 진짜 비용은 락을 거는 시간 자체가 아닙니다. 진짜 문제는 병렬로 실행될 기회를 잃는 것과 CPU가 메모리를 일관되게 유지하기 위해 지연되는 시간입니다. 따라서 동기화 영역에서의 작업은 최소한으로 줄여야 합니다.
4. 클래스 설계 가이드라인
가변 클래스를 설계할 때는 다음 두 가지 중 하나를 선택해야 합니다:
- 동기화를 전혀 하지 말고, 사용자가 외부에서 알아서 동기화하게 하라 (예:
ArrayList,HashMap). - 내부에서 동기화하여 스레드 안전한 클래스로 만들어라 (예:
ConcurrentHashMap). 단, 이는 내부 동기화가 병렬 효율을 획기적으로 높일 수 있을 때만 선택합니다.
=> 동기화 영역 안에서는 가능한 한 일을 적게 하고, 절대로 외부 메서드를 호출하지 마세요. 과도한 동기화는 성능을 떨어뜨릴 뿐 아니라, 찾기 힘든 버그와 교착상태의 원인이 됩니다.
아이템 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라
직접 Thread를 만들어서 관리하는 시대는 지났습니다. 자바가 제공하는 **실행자 프레임워크(ExecutorService)**를 사용하세요.
1. 실행자 프레임워크란 무엇인가?
과거에는 단순한 작업 큐(Work Queue)를 만들기 위해 수많은 코드를 직접 짜야 했고, 그 과정에서 많은 버그가 발생했습니다. 자바 2판부터 도입된 java.util.concurrent 패키지에는 실행자 프레임워크라는 인터페이스 기반의 유연한 태스크 실행 기능이 담겨 있습니다. 이제는 단 한 줄의 코드로 작업 큐를 생성할 수 있게 되었습니다.
2. 핵심 개념: '작업(Task)'과 '실행 메커니즘'의 분리
이 아이템의 가장 큰 교훈은 작업 단위인 태스크와 그것을 실행하는 메커니즘을 분리하라는 것입니다.
- 태스크(Task): 수행해야 할 작업 그 자체를 의미하며,
Runnable과Callable이 있습니다. (Callable은 작업을 마치고 값을 반환하거나 예외를 던질 수 있어 더 유용합니다). - 실행자 서비스(Executor Service): 태스크를 실행하는 역할을 맡으며, 우리가 스레드를 직접 다루지 않아도 되게 해줍니다.
3. 상황에 맞는 스레드 풀(Thread Pool) 선택하기
실 실행자 서비스는 Executors의 정적 팩터리 메서드들을 통해 다양한 형태의 스레드 풀을 제공합니다.
newCachedThreadPool:- 작은 프로그램이나 가벼운 서버에 적합합니다.
- 요청받은 작업을 즉시 스레드에 위임하며, 가용 스레드가 없으면 새로 생성합니다.
newFixedThreadPool:- 부하가 심한 운영 서버에 적합합니다.
- 스레드 개수를 고정하여 시스템 자원을 안정적으로 관리할 수 있습니다.
ScheduledThreadPoolExecutor:- 특정 시간에 작업을 실행하거나 주기적으로 반복해야 할 때 사용하며, 과거의
Timer를 대체합니다.
- 특정 시간에 작업을 실행하거나 주기적으로 반복해야 할 때 사용하며, 과거의
4. 실행자 서비스를 쓰면 얻는 이점
실행자 서비스는 단순히 작업을 실행하는 것 이상의 강력한 기능들을 제공합니다.
- 우아한 종료:
shutdown()메서드를 통해 실행자가 작업을 안전하게 마무리하고 종료되게 할 수 있습니다. - 결과 대기: 특정 태스크가 완료되기를 기다리거나, 여러 태스크 중 하나라도 완료되기를 기다리는 기능(
invokeAny,invokeAll)이 있습니다. - 유연한 정책: 스레드 풀의 크기를 동적으로 조절하거나 스레드 생성 방식을 설정할 수 있습니다.
5. 포크-조인(Fork-Join)과 스트림
자바 7부터는 큰 작업을 작은 하위 작업으로 나누고 이를 병렬로 처리하는 포크-조인 프레임워크가 추가되었습니다. 병렬 스트림(Item 48)은 내부적으로 이 포크-조인 풀을 사용하여 적은 노력으로 높은 성능을 낼 수 있게 도와줍니다.
저자의 말:
*"이제 작업 큐를 손수 만드는 일은 삼가야 하고, 스레드를 직접 다루는 것도 일반적으로 삼가야 한다."*
비유 (Analogy):
이 방식은 '개인 기사'를 고용하는 것과 '대형 물류 센터' 시스템을 이용하는 것의 차이와 같습니다.
- 스레드를 직접 만드는 것: 매번 배달할 때마다 기사를 새로 뽑고, 월급을 주고, 사고가 나면 직접 책임지는 방식입니다. 관리가 너무 힘들고 위험합니다.
- 실행자 프레임워크를 쓰는 것: 이미 잘 갖춰진 물류 센터(스레드 풀)에 택배 물건(태스크)만 접수하는 방식입니다. 물류 센터가 알아서 빈 트럭(스레드)에 짐을 싣고 배송하며, 사고 관리나 효율적인 배차까지 모두 자동으로 처리해 줍니다. [Item 80]
아이템 81. wait와 notify보다는 동시성 유틸리티를 애용하라
1. 왜 wait와 notify를 쓰지 말아야 할까요?
자바의 초기 버전부터 있었던 wait와 notify는 동시성 프로그래밍의 '어셈블리 언어'와 같습니다. 매우 세밀한 제어가 가능하지만, 코드가 복잡해지고 한 줄만 실수해도 교착상태(Deadlock)나 안전 실패(Safety failure) 같은 치명적인 버그가 발생하기 때문입니다.
현재는 자바 5에서 도입된 고수준 동시성 유틸리티들이 이 어려운 일들을 대신 처리해주므로, 이를 사용하는 것이 훨씬 현명합니다.
2. 꼭 알아두어야 할 고수준 유틸리티
동시성 유틸리티는 크게 세 범주로 나뉩니다: 실행자 프레임워크(아이템 80), 동시성 컬렉션, 그리고 동기화 장치입니다.
① 동시성 컬렉션 (Concurrent Collections)
List, Queue, Map 같은 표준 컬렉션에 동기화 기능을 추가한 것입니다.
ConcurrentHashMap:Collections.synchronizedMap보다 훨씬 빠르고 효율적입니다. 이제 동기화된 맵이 필요하다면 고민하지 말고ConcurrentHashMap을 선택하세요.BlockingQueue: 데이터가 생길 때까지 기다리는 기능을 가진 큐입니다. 주로 '생산자-소비자(Producer-Consumer)' 작업에서 일감을 주고받는 용도로 쓰이며, 스레드 풀에서도 핵심적으로 사용됩니다.
② 동기화 장치 (Synchronizer)
스레드가 다른 스레드를 기다릴 수 있게 해주는 장치입니다.
CountDownLatch: 하나 이상의 스레드가 또 다른 스레드들의 작업이 끝날 때까지 기다리게 합니다. 예를 들어, "5명의 스레드가 모두 준비를 마칠 때까지 기다렸다가 한꺼번에 시작!" 하는 상황에 딱 맞습니다.- 기타:
Semaphore(자원 개수 제한),CyclicBarrier,Phaser등이 있습니다.
3. 만약 어쩔 수 없이 wait를 써야 한다면? (표준 관용구)
오래된 레거시 코드를 유지보수하다 보면 wait를 마주칠 수 있습니다. 이때 반드시 지켜야 할 규칙이 있습니다.
wait메서드는 반드시 반복문(while) 안에서 호출해야 합니다.
synchronized (obj) {
while (조건이 충족되지 않았는가) {
obj.wait(); // 락을 놓고 대기하다가 깨어나면 다시 조건을 확인한다.
}
// 조건 충족 후 로직 수행
}
- 이유: 스레드가 깨어났더라도 조건이 여전히 충족되지 않았을 수 있습니다(허위 각성 등). 따라서
while문으로 다시 한번 확인하는 과정이 필수입니다.
4. notify 보다는 notifyAll을 사용하세요
스레드를 깨울 때는 notify보다 notifyAll을 쓰는 것이 안전합니다.
notify는 무작위로 한 스레드만 깨우기 때문에, 정작 깨어나야 할 스레드가 계속 잠들어 있는 대참사가 발생할 수 있습니다.notifyAll을 쓰면 모든 스레드가 깨어나서 조건을 다시 확인하므로, 프로그램의 정확성을 보장하기 훨씬 쉽습니다.
요약 및 저자의 조언
- 새로 작성하는 코드라면
wait와notify를 쓸 이유가 전혀 없습니다. - 동시성 유틸리티를 공부하고 적재적소에 활용하는 것이 실력을 높이는 지름길입니다.
아이템 82. 스레드 안전성 수준을 문서화하라
- 핵심: 내가 만든 클래스가 여러 스레드에서 동시에 써도 안전한지 동료들에게 명확히 알려주어야 합니다.
- 상세 설명: 단순히 메서드에 synchronized가 붙었다고 해서 스레드 안전하다고 믿으면 안 됩니다. 불변(Immutable), 무조건적 스레드 안전, 조건부 스레드 안전 등 그 수준을 정확히 문서로 남겨야 사용자가 오해하지 않습니다.
- 저자의 말: "모든 클래스가 자신의 스레드 안전성 정보를 명확히 문서화해야 한다."
아이템 83. 지연 초기화는 신중히 사용하라
1. 지연 초기화(Lazy Initialization)란?
필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법입니다. 값이 전혀 쓰이지 않으면 초기화도 결코 일어나지 않습니다. 이는 주로 성능 최적화 용도로 쓰이지만, 때로는 클래스와 인스턴스 생성 시 발생하는 위험한 순환 참조(initialization circularity)를 해결하는 용도로도 사용됩니다.
2. 저자의 핵심 권고: "웬만하면 하지 마라"
조슈아 블로크는 이 기법에 대해 매우 신중한 태도를 보입니다.
- 양날의 검: 지연 초기화는 클래스 생성 비용은 줄여주지만, 해당 필드에 접근하는 비용은 키웁니다. 초기화가 필요한 비율, 실제 초기화에 드는 비용, 필드 호출 빈도에 따라 오히려 전체 성능은 느려질 수 있습니다.
- 저자의 말: *"다른 모든 최적화와 마찬가지로 지연 초기화에 대해 해줄 최선의 조언은 '필요할 때까지는 하지 말라'다."*
- 기본 원칙: 대부분의 경우에는 지연 초기화보다 일반적인 초기화가 더 낫습니다.
3. 멀티스레드 환경에서의 위험성
지연 초기화하는 필드를 둘 이상의 스레드가 공유한다면 반드시 어떤 형태로든 동기화해야 합니다. 동기화하지 않으면 심각한 버그(한 필드를 여러 번 초기화하거나, 초기화가 덜 된 값을 읽는 등)로 이어질 수 있기 때문입니다.
4. 상황별 올바른 구현 방법 (관용구)
① 가장 좋은 방법: 일반적인 초기화
대부분의 필드는 다음과 같이 그냥 선언과 동시에 초기화하는 것이 가장 깔끔하고 안전합니다.
private final FieldType field = computeFieldValue(); //
② 성능 때문에 꼭 지연 초기화가 필요하다면?
정적 필드용: 지연 초기화 홀더 클래스(Lazy Initialization Holder Class) 관용구
클래스가 처음 쓰일 때 JVM이 초기화해준다는 특성을 이용한 가장 우아한 방식입니다. 별도의 동기화 코드가 없어도 JVM이 알아서 스레드 안전하게 처리해줍니다.private static class FieldHolder { static final FieldType field = computeFieldValue(); } private static FieldType getField() { return FieldHolder.field; } //인스턴스 필드용: 이중검사(Double-Check) 관용구
필드에 접근할 때마다 발생하는 락(Lock) 비용을 없애기 위해 사용합니다. 필드가 초기화된 후에는 동기화 없이 바로 값을 반환하도록 설계되었습니다.- 주의: 필드는 반드시
volatile로 선언해야 가시성 문제가 생기지 않습니다. - 최적화 팁: 필드 값을 한 번만 읽도록 지역 변수(
result)를 사용하는 것이 성능상 유리합니다.private volatile FieldType field; private FieldType getField() { FieldType result = field; if (result == null) { // 첫 번째 검사 (락 없이) synchronized(this) { if (field == null) // 두 번째 검사 (락 쥐고) field = computeFieldValue(); result = field; } } return result; } //
- 주의: 필드는 반드시
5. 핵심 요약 및 조언
- 성능 최적화는 측정이 우선입니다. 지연 초기화를 적용하기 전후의 성능을 반드시 측정해보고 이득이 있을 때만 사용하세요.
- 스태틱 필드에는 홀더 클래스 방식을, 인스턴스 필드에는 이중검사 방식을 쓰면 안전합니다.
- 하지만 최고의 방법은 여전히 "그냥 미리 만들어 두는 것"임을 잊지 마세요.
비유 (Analogy):
이 원칙은 '여분의 전구를 미리 사두는 것'과 같습니다.
- 일반적인 초기화: 전구가 나갈 때를 대비해 미리 사서 창고에 넣어둡니다 (성능 확실, 관리 편함).
- 지연 초기화: 전구가 나간 그 순간에 마트에 가서 사 옵니다. 미리 돈을 안 써서 좋지만(메모리 절약), 불이 꺼진 순간 마트에 가야 하므로 당장 어둠 속에서 기다려야 하는 비용(접근 비용 증가)이 발생합니다. 만약 식구가 여럿인데 동시에 마트로 달려간다면 서로 부딪히는 혼란(멀티스레드 문제)이 생기므로, "누가 갈지" 정하는 규칙(동기화)이 반드시 필요한 것과 같습니다. [Item 83]
아이템 84. 프로그램의 동작을 스레드 스케줄러에 기대지 말라
- 핵심: 운영체제의 스레드 관리 방식에 의존하는 코드를 짜면, 다른 컴퓨터에서는 다르게 동작할 위험이 큽니다.
- 상세 설명: Thread.yield를 호출하거나 스레드 우선순위를 조절해서 문제를 해결하려 하지 마세요. 가장 좋은 방법은 실행 가능한 스레드의 개수를 CPU 코어 수와 비슷하게 유지하여 스케줄러가 할 고민을 덜어주는 것입니다.
- 저자의 말: "정확성이나 성능이 스레드 스케줄러에 따라 달라지는 프로그램이라면 다른 플랫폼에 이식하기 어렵다."
제12장 직렬화 (Items 85~90)
객체 직렬화(Object Serialization)란 자바가 객체를 바이트 스트림으로 인코딩(직렬화)하고, 그 바이트 스트림으로부터 다시 객체를 재구성(역직렬화)하는 메커니즘을 말합니다.
이렇게 만들어진 바이트 스트림은 다른 가상 머신(VM)으로 전송하거나 디스크에 저장했다가 나중에 다시 꺼내어 객체로 복원할 수 있습니다. 어떤 클래스의 인스턴스를 직렬화할 수 있게 하려면 클래스 선언에 implements Serializable만 덧붙이면 되기 때문에 사용하기 매우 쉬워 보입니다.
하지만 소스(이펙티브 자바)의 저자 조슈아 블로크는 직렬화가 심각한 보안 문제와 유지보수 비용을 초래한다고 경고하며 다음의 주의사항을 강조합니다.
- 유지보수의 어려움: 클래스가
Serializable을 구현하면 직렬화된 형태도 하나의 공개 API가 되어버립니다. 즉, 나중에 클래스 내부 구현을 바꾸면 기존의 직렬화된 데이터와 호환되지 않을 수 있어 수정이 매우 어려워집니다. - 보안 위험: 역직렬화는 생성자를 사용하지 않고 객체를 만드는 '숨은 생성자'와 같습니다. 공격자는 이 경로를 통해 정상적인 생성자로는 만들 수 없는 괴상한 객체를 만들어 내거나, 시스템을 마비시키는 '직렬화 폭탄' 공격을 가할 수 있습니다.
- 대안 권장: 저자는 자바 직렬화의 위험을 피하는 가장 좋은 방법으로 직렬화를 아예 하지 않는 것을 꼽습니다. 대신 JSON이나 프로토콜 버퍼(Protobuf) 같은 현대적인 데이터 형식(크로스-플랫폼 구조화된 데이터 표현)을 사용하는 것이 훨씬 안전하고 효율적입니다.
비유 (Analogy):
직렬화는 완성된 레고 성을 분해해서 상자에 담아 택배로 보내는 것과 같습니다. 받는 사람은 상자 안의 부품들을 보고 다시 원래의 성으로 조립(역직렬화)할 수 있습니다. 하지만 이 과정에서 누군가 부품을 살짝 바꿔치기해서 조립했을 때 성이 무너지게 만들 수도 있고(보안 위협), 한 번 이 규격으로 상자를 만들기 시작하면 나중에 성의 모양을 바꾸고 싶어도 상자 크기 때문에 바꾸지 못하게 되는(유지보수 문제) 것과 같습니다. [Item 85, 86]
아이템 85. 자바 직렬화의 대안을 찾으라
자바 직렬화는 보안에 취약하고 속도가 느립니다. 대신 JSON이나 프로토콜 버퍼(Protobuf) 같은 현대적인 데이터 형식(크로스-플랫폼 구조화된 데이터 표현)을 사용하세요.
자바 직렬화는 매우 위험하며, 현대적인 프로그래밍에서는 이를 대체할 안전한 방법을 사용해야 합니다.
1. 자바 직렬화의 위험성
자바 직렬화는 도입된 이후 수십 년간 원격 코드 실행(RCE)이나 서비스 거부(DoS)와 같은 치명적인 보안 취약점의 통로가 되었습니다. 역직렬화 과정에서 호출되는 readObject 메서드는 사실상 클래스패스 내의 거의 모든 타입의 객체를 만들어낼 수 있는 '숨은 생성자'와 같기 때문입니다.
2. 주요 공격 수단: 가젯과 직렬화 폭탄
- 가젯(gadget): 공격자는 역직렬화 과정에서 실행되는 위험한 메서드들을 사슬처럼 연결하여 자바 런타임 자체를 장악할 수 있습니다.
- 직렬화 폭탄: 아주 짧은 바이트 스트림만으로도 역직렬화 시에 객체 그래프를 탐색하는 데 수천 년이 걸리게 만들어 서버를 마비시킬 수 있습니다.
3. 근본적인 해결책: 직렬화 대안 찾기
직렬화 위험을 피하는 가장 좋은 방법은 신뢰할 수 없는 데이터를 절대로 역직렬화하지 않는 것입니다. 저자 조슈아 블로크는 "승리하는 유일한 길은 전쟁하지 않는 것"이라는 영화 대사를 인용하며, 직렬화 자체를 피할 것을 권고합니다. 대신 다음과 같은 크로스-플랫폼 구조화된 데이터 표현을 사용하세요.
- JSON: 텍스트 기반으로 사람이 읽기 쉽고 브라우저와 통신하기에 최적입니다.
- 프로토콜 버퍼(Protobuf): 구글이 만든 이진 표현 방식으로, 성능이 뛰어나고 스키마를 통해 데이터 타입을 엄격히 관리합니다.
4. 어쩔 수 없이 사용해야 한다면: 필터링 도입
레거시 시스템 유지보수 등으로 인해 자바 직렬화를 배제할 수 없다면, 자바 9에 도입된 객체 역직렬화 필터링(java.io.ObjectInputFilter)을 반드시 사용해야 합니다. 이때 특정 클래스만 거부하는 블랙리스트 방식보다는, 안전하다고 알려진 클래스만 허용하는 화이트리스트 방식을 사용해야 시스템을 더 안전하게 보호할 수 있습니다.
비유 (Analogy):
자바 직렬화는 "상대방이 보낸 설계도를 묻지도 따지지도 않고 그대로 조립해주는 마법의 3D 프린터"와 같습니다. 만약 나쁜 마음을 먹은 사람이 '폭발물' 설계도를 보내면 프린터는 충실하게 폭탄을 만들어 서버를 터뜨려버릴 것입니다. 반면 JSON이나 프로토콜 버퍼는 "정해진 규격의 레고 블록만 담긴 상자"와 같습니다. 상자를 열어 내용물을 확인하고 정해진 규칙대로만 조립하기 때문에, 갑자기 예상치 못한 위험한 물건이 튀어나올 걱정이 훨씬 적습니다.
아이템 86. Serializable을 구현할지는 신중히 결정하라
클래스 선언에 implements Serializable만 덧붙이면 되는 직렬화는 겉보기엔 매우 쉬워 보이지만, 사실은 매우 신중하게 결정해야 하는 위험한 작업입니다. 조슈아 블로크가 강조하는 직렬화 구현의 위험성과 주의사항을 정리해 드립니다.
1. 유지보수성이 급격히 떨어집니다 (공개 API 문제)
클래스가 Serializable을 구현하는 순간, 그 클래스의 내부 필드 구조가 하나의 공개 API가 되어버립니다.
- 구현의 종속: 나중에 성능을 개선하거나 버그를 고치기 위해 클래스 내부 구현을 바꾸면, 기존에 저장해두었던 직렬화 데이터와의 호환성이 깨지게 됩니다.
- 캡슐화 파괴: 원래는
private필드여서 감춰져야 할 내부 데이터들까지 직렬화 형태에 포함되어 외부로 노출되는 꼴이 됩니다.
2. 보안 구멍과 버그가 생길 위험이 커집니다
역직렬화는 일반적인 생성자를 사용하지 않고 객체를 생성하는 '숨은 생성자'와 같습니다.
- 불변식 파괴: 정상적인 생성자라면 허용하지 않았을 '잘못된 데이터'를 가진 객체도 역직렬화 과정을 통해서는 만들어질 수 있습니다.
- 공격 노출: 공격자가 고의로 조작된 바이트 스트림을 보내면, 내부 검사를 우회하여 시스템을 망가뜨릴 수 있는 객체를 생성해낼 수 있습니다.
3. 테스트 부담이 어마어마하게 늘어납니다
직렬화 가능한 클래스를 수정할 때는 '양방향 호환성'을 모두 테스트해야 합니다.
- 구버전에서 직렬화한 객체가 신버전에서 잘 읽히는지, 반대로 신버전에서 직렬화한 객체가 구버전에서 잘 읽히는지 매번 확인해야 합니다.
- 클래스 버전이 올라갈수록 이 테스트의 양은 기하급수적으로 늘어나며, 이를 소홀히 하면 사용자의 데이터가 날아가는 참사가 발생합니다.
4. 설계 시 주의사항
- 상속용 클래스는 직렬화를 지양하라: 상속용으로 설계된 클래스나 인터페이스가
Serializable을 구현하면, 이를 상속받는 모든 하위 클래스에 직렬화 부담을 강제로 지우게 됩니다. - 내부 클래스는 구현하지 마라: 내부 클래스(Inner Class)는 바깥 인스턴스의 참조 등을 저장하기 위한 정보들이 복잡하게 얽혀 있어 직렬화 형태가 불분명하므로 절대로 구현해서는 안 됩니다. (단, 정적 멤버 클래스는 괜찮습니다)
- 값 클래스 위주로 고려하라:
BigInteger나Instant같은 값 클래스나 컬렉션 클래스들은 직렬화를 구현해도 괜찮지만, 스레드 풀처럼 '동작'을 나타내는 클래스들은 직렬화해서는 안 됩니다.
요약하자면:Serializable은 한 번 구현하면 평생 책임져야 하는 무거운 짐과 같습니다. 따라서 정말 꼭 필요한 경우가 아니라면 피하는 것이 좋으며, 구현하기로 했다면 나중에 내부 구조를 바꿔도 호환성이 유지되도록 커스텀 직렬화 형태를 직접 설계하는 등 엄청난 주의를 기울여야 합니다.
비유 (Analogy):Serializable을 구현하는 것은 "오늘 입고 있는 옷(내부 구조) 그대로 박제되어 영원히 사진(API)에 남는 것"과 같습니다. 나중에 몸이 커져서 옷을 갈아입고 싶어도, 세상 사람들은 사진 속의 그 옷차림만 기억하고 기대하기 때문에 마음대로 옷을 갈아입지 못하게 되는 답답한 상황에 처하게 되는 것입니다. [Item 86]
아이템 87. 커스텀 직렬화 형태를 고려해보라
- 기본 직렬화 방식에만 의존하면 내부 구현이 외부로 노출되어 버립니다.
- 객체의 물리적 표현이 아닌 **논리적인 상태**만을 담도록 직렬화 형태를 직접 설계하는 것이 좋습니다.1. 왜 기본 직렬화 형태를 그대로 쓰면 안 될까요?
클래스에 단순히 implements Serializable을 추가하고 아무 조치를 취하지 않으면, 자바는 그 클래스의 물리적인 내부 구현을 그대로 바이트 스트림으로 기록합니다. 이는 다음과 같은 심각한 문제를 일으킵니다.
- 공개 API가 내부 구현에 묶입니다: 나중에 성능을 높이기 위해 내부 구조를 바꾸더라도, 옛날 방식으로 직렬화된 데이터를 읽어오기 위해 예전 코드를 버리지 못하고 영원히 유지해야 합니다.
- 너무 많은 공간을 차지합니다: 객체 내부의 복잡한 연결 관계(예: 연결 리스트의 노드 정보 등)까지 모두 기록하느라 직렬화 데이터가 불필요하게 커집니다.
- 시간이 너무 많이 걸립니다: 직렬화 로직이 객체 그래프의 위상에 관한 정보가 없으므로 직접 그래프를 순회하며 확인하느라 실행 시간이 길어집니다.
- 스택 오버플로를 일으킬 수 있습니다: 기본 직렬화는 객체 그래프를 재귀적으로 순회하는데, 리스트의 원소가 많으면(예: 1,000개 이상) 스택이 견디지 못하고 터질 수 있습니다.
2. 핵심 원칙: "물리적 표현"이 아닌 "논리적 내용"을 담으라
저자는 다음과 같이 조언합니다.
"객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다."
하지만 대부분의 복잡한 클래스는 물리적 구현(코드)과 논리적 상태(데이터의 의미)가 다릅니다. 예를 들어, '문자열 리스트' 클래스의 논리적 상태는 단순히 '일련의 문자열'일 뿐입니다. 이를 직렬화할 때는 내부의 연결 리스트 노드 구조를 다 기록할 필요 없이, 원소의 개수와 실제 문자열들만 기록하는 것이 훨씬 효율적입니다.
3. 커스텀 직렬화 구현 시 주의할 점
transient한정자 활용: 논리적 상태와 무관한 필드(캐시된 값, 내부 구현용 필드 등)는 반드시transient키워드를 붙여 직렬화 대상에서 제외해야 합니다.defaultWriteObject호출: 커스텀 직렬화를 구현할 때(writeObject,readObject메서드 사용) 필드 모두가transient라도 가장 먼저defaultWriteObject를 호출해주는 것이 좋습니다. 그래야 나중에transient가 아닌 필드가 추가되어도 상호 호환성이 유지됩니다.- 동기화 보장: 만약 객체 전체의 상태를 읽는 메서드에
synchronized를 적용했다면, 직렬화 메서드(writeObject) 안에서도 똑같이 동기화 처리를 해주어야 데이터가 깨지지 않습니다. - 직렬 버전 UID 명시: 클래스 안에
serialVersionUID를 직접 적어주세요. 그래야 클래스 코드가 조금 변해도 기존 데이터를 문제없이 읽어올 수 있으며, 런타임에 이 값을 계산하느라 시간이 낭비되는 것도 막을 수 있습니다.
비유 (Analogy):
이 상황은 '일기장을 친구에게 복사해주는 상황'에 비유할 수 있습니다.
- 기본 직렬화: 일기장 내용뿐만 아니라 종이의 재질, 잉크의 성분, 글씨를 쓸 때의 필압, 종이 사이의 먼지까지 원자 단위로 똑같이 복제해서 전달하려는 것과 같습니다. 전달하기도 너무 힘들고, 나중에 일기장 양식을 바꾸고 싶어도 바꿀 수 없습니다.
- 커스텀 직렬화: 종이나 펜이 무엇이든 상관없이 '일기장에 적힌 텍스트 내용'만 타이핑해서 파일로 보내주는 것과 같습니다. 친구는 내용만 알면 되고, 나중에 내가 일기장을 디지털로 바꾸든 다른 공책에 쓰든 친구가 내용을 읽는 데는 아무런 지장이 없는 것과 같습니다. [Item 87]
아이템 88. readObject 메서드는 방어적으로 작성하라
- 데이터를 다시 객체로 만드는 `readObject`는 '실질적인 생성자'와 같습니다.
- 들어오는 데이터가 유효한지 검사하고, 가변 필드는 반드시 방어적으로 복사해서 사용해야 합니다.이펙티브 자바 아이템 88. readObject 메서드는 방어적으로 작성하라는 객체를 역직렬화할 때 발생할 수 있는 보안 위협과 데이터 무결성 파괴를 막기 위한 지침을 담고 있습니다.
0년차 주니어 개발자분들이 이해하기 쉽게, readObject가 사실상 '생성자'와 같다는 점을 중심으로 설명해 드리겠습니다.
1. readObject는 '바이트 스트림'을 매개변수로 받는 생성자입니다
보통 생성자는 우리가 코드로 직접 호출하지만, readObject는 역직렬화 과정에서 자동으로 호출됩니다. 문제는 이 메서드가 매개변수로 받는 '바이트 스트림'이 언제나 정상적인 객체를 직렬화한 것이라고 믿어서는 안 된다는 점입니다.
공격자는 인위적으로 조작된 바이트 스트림을 보내서, 정상적인 생성자로는 절대 만들 수 없는 '불변식이 깨진 괴물 객체'를 만들어낼 수 있습니다.
2. 첫 번째 방어: 유효성 검사 (Invariants Check)
예를 들어, 시작 시간이 종료 시간보다 앞서야 하는 Period 클래스가 있다고 해봅시다.
- 정상적인 생성자에서는 이를 검사해서 에러를 던지겠지만,
readObject에서 이 검사를 빠뜨리면 종료 시간이 시작 시간보다 빠른 말도 안 되는 객체가 탄생할 수 있습니다. - 해결책:
readObject메서드 안에서defaultReadObject를 호출한 후, 반드시 역직렬화된 객체가 유효한지 검사해야 합니다. 만약 유효하지 않다면InvalidObjectException을 던져야 합니다.
3. 두 번째 방어: 방어적 복사 (Defensive Copying)
단순히 유효성 검사만으로는 부족합니다. 공격자가 바이트 스트림 끝에 객체 내부의 가변 필드(예: Date)에 대한 참조를 추가로 집어넣을 수 있기 때문입니다.
- 만약
readObject에서 내부 가변 필드를 방어적으로 복사하지 않고 그대로 읽어들인다면, 공격자는 역직렬화된 객체 내부의Date객체 참조를 손에 넣게 됩니다. - 그러면 공격자는 이 참조를 이용해 '불변'이어야 할 객체의 내부를 마음대로 수정할 수 있게 됩니다.
- 해결책: 가변 요소를 포함하는 객체라면,
readObject시에 해당 요소들을 방어적으로 복사한 후 유효성 검사를 수행해야 합니다.
4. 안전한 readObject를 위한 저자의 가이드라인
private이어야 하는 객체 참조 필드는 반드시 방어적으로 복사하세요.- 복사 후에는 반드시 불변식을 검사하여 어긋난다면 예외를 던지세요.
- 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면
ObjectInputValidation인터페이스를 사용하세요. - 직접적이든 간접적이든, 재정의 가능한 메서드(Overridable method)는 호출하지 마세요.
저자의 말:
*"readObject 메서드를 작성할 때는 언제나 public 생성자를 작성하는 자세로 임해야 한다. ... 바이트 스트림이 진짜 직렬화된 인스턴스라고 가정해서는 안 된다."*
비유 (Analogy):
이 상황은 '조립식 가구(객체)를 해외 직구(네트워크 전송)로 받는 상황'과 비슷합니다.
- 직렬화: 가구를 분해해서 상자에 담는 것.
- 역직렬화(
readObject): 배달된 상자를 열어 다시 조립하는 것.
만약 여러분이 상자를 열었을 때 설명서(바이트 스트림)가 중간에 누군가에 의해 바뀌어 있다면 어떻게 될까요? 다리가 5개 달린 의자가 만들어질 수도 있고, 나사 하나가 밖으로 삐져나와 누군가 그 나사를 잡아당기면 의자가 무너지게 조작될 수도 있습니다. 따라서 여러분은 가구를 조립할 때 1) 부품이 제대로 왔는지 확인하고(유효성 검사), 2) 중요한 연결 부위는 새 나사로 교체해서(방어적 복사) 외부의 조작으로부터 안전하게 완성해야 하는 것과 같습니다. [Item 88]
아이템 89. 인스턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라
1. 직렬화는 싱글턴의 천적입니다
아이템 3에서 다룬 싱글턴 패턴은 인스턴스가 오직 하나만 존재하도록 보장합니다. 하지만 클래스에 implements Serializable을 추가하는 순간, 더 이상 싱글턴이 아니게 됩니다. 역직렬화(deserialization) 과정은 내부적으로 생성자를 사용하는 것과 마찬가지라, 호출될 때마다 새로운 인스턴스를 만들어내기 때문입니다.
2. 과거의 해결책: readResolve 메서드
자바는 역직렬화된 객체를 다른 객체로 대체할 수 있는 readResolve 기능을 제공합니다.
- 작동 방식: 역직렬화가 끝나고 새로 만들어진 객체가 반환되기 직전에 이 메서드가 호출됩니다. 이때 메서드가 미리 만들어둔 진짜 인스턴스를 반환하게 하면, 역직렬화로 새로 생성된 가짜 객체는 가비지 컬렉터의 대상이 되어 사라집니다.
- 문제점: 이 방식은 깨지기 쉽습니다. 만약 클래스 내부에
transient로 선언되지 않은 참조 필드가 있다면, 그 필드의 내용이 역직렬화되는 시점에 조작된 스트림을 이용해 싱글턴 인스턴스의 참조를 훔쳐오는 공격(도둑 공격)이 가능해집니다.
3. 현대적인 해결책: 열거 타입(Enum)
저자 조슈아 블로크는 인스턴스 통제를 위해 readResolve를 쓰는 대신 열거 타입(Enum)을 사용할 것을 강력히 권고합니다.
- 절대적 보장: 자바 열거 타입은 선언된 상수 외에 다른 인스턴스가 존재하지 않음을 언어 차원에서 보장합니다.
- 간결함과 안전성: 직렬화/역직렬화 과정을 자바가 직접 안전하게 관리하므로, 개발자가 복잡한
readResolve코드를 짜거나 보안 취약점을 걱정할 필요가 없습니다. - 사용법: 싱글턴이 필요하다면 단순히 원소가 하나인 열거 타입을 만드세요.
4. readResolve는 언제 쓰나요?
그렇다면 readResolve는 완전히 버려진 기능일까요? 그렇지는 않습니다. 컴파일 타임에 어떤 인스턴스들이 있는지 알 수 없는 상황(예: 상속 구조가 필요한 경우) 등 열거 타입을 쓸 수 없는 환경에서는 어쩔 수 없이 readResolve를 사용해야 합니다. 이때는 클래스의 모든 참조 필드를 반드시 transient로 선언해야 안전합니다.
요약하자면:
- 직렬화 가능한 싱글턴을 만들 때 일반 클래스를 쓰면 보안 구멍이 생기기 쉽습니다.
- 열거 타입(Enum)을 사용하면 자바가 알아서 완벽하고 안전하게 인스턴스 수를 통제해 줍니다.
- 열거 타입을 쓸 수 없는 특수한 경우에만
readResolve를 쓰되, 모든 필드를transient로 만들어 방어하세요.
비유로 이해하기:
이 상황은 '클럽의 VIP 입장권'에 비유할 수 있습니다.
- 일반 클래스 방식: 종이로 된 VIP 초대장을 나눠주는 것과 같습니다. 누군가 복사기(역직렬화)로 가짜 초대장을 계속 찍어낼 수 있습니다. 주인이 일일이 대조해보고 가짜를 버려야(
readResolve) 하는데, 그 과정에서 실수하면 가짜 손님이 입장하게 됩니다. - 열거 타입 방식: 클럽 입구에 '지문 인식기'를 설치하는 것과 같습니다. 지문은 복사할 수 없으며, 시스템에 등록된 단 한 명의 진짜 VIP만 들어올 수 있도록 기계가 완벽하게 통제해주는 것과 같습니다. [Item 89]
아이템 90. 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라
- 직렬화로 인해 발생할 수 있는 보안 문제와 버그를 막는 가장 강력한 방법은 **직렬화 프록시 패턴**을 사용하는 것입니다.
- 이 패턴은 복잡한 객체 상태를 안전하게 주고받을 수 있도록 도와줍니다.1. 직렬화 프록시 패턴이란?
Serializable을 구현하기로 결정했을 때, 클래스의 인스턴스를 직접 직렬화하는 대신 논리적 상태를 담고 있는 중첩 클래스(프록시)를 대신 직렬화하는 방식입니다. 이는 직렬화의 '숨은 생성자' 문제를 해결하고 객체의 불변성을 보장하는 데 매우 유용합니다.
2. 구현 방법
- 중첩 클래스(프록시) 설계: 바깥 클래스의 논리적 상태를 정밀하게 표현하는
private static중첩 클래스를 설계합니다. 이 클래스는 단순히 바깥 클래스의 데이터를 인수로 받아 복사하는 생성자 하나만 있으면 됩니다. writeReplace메서드 구현: 바깥 클래스에 직렬화 시스템이 인스턴스 대신 프록시를 반환하게 하는writeReplace메서드를 추가합니다. 이 메서드 덕분에 직렬화 시스템은 바깥 클래스의 인스턴스를 절대로 생성해낼 수 없습니다.readObject방어: 공격자가 바깥 클래스로 위조된 스트림을 보내는 것을 막기 위해, 바깥 클래스의readObject에서 무조건 예외를 던지도록 설정합니다.- 프록시의
readResolve구현: 중첩 클래스인 프록시 안에readResolve메서드를 구현하여, 역직렬화 시점에 바깥 클래스의 공개된 생성자나 정적 팩터리를 사용해 다시 바깥 클래스의 인스턴스로 변환하여 반환하게 합니다.
3. 이 패턴의 강력한 장점
- 불변성 유지: 객체를 생성할 때와 똑같은 생성자/팩터리를 사용해 역직렬화하므로, 객체가 생성된 후에는 필드를
final로 선언할 수 있어 진정한 불변을 유지할 수 있습니다. - 보안 강화: 가짜 바이트 스트림 공격이나 내부 필드 탈취 공격을 프록시 수준에서 차단할 수 있습니다.
- 유연성: 역직렬화된 인스턴스의 클래스가 원래 직렬화된 클래스와 달라도 정상 작동합니다. 예를 들어,
EnumSet은 원소 개수에 따라RegularEnumSet이나JumboEnumSet을 선택해 반환하는데, 이 패턴을 사용하면 이를 자연스럽게 처리할 수 있습니다.
4. 한계점
- 사용자가 확장할 수 있는 클래스(상속용 클래스)에는 적용할 수 없습니다.
- 객체 그래프에 순환이 있는 클래스에는 적용할 수 없습니다.
- 일반적인 방식보다 성능이 약간 느릴 수 있습니다(저자의 테스트 결과
Period클래스 기준 약 14% 정도 지연).
'개발 언어 > JAVA' 카테고리의 다른 글
| 이펙티브 자바 10장 : 에러 (0) | 2025.12.22 |
|---|---|
| 이펙티브 자바 9장: 좋은 코드를 위한 일반적인 프로그래밍 원칙 (Items 57~68) (2) | 2025.12.17 |
| jvm 밑바닥까지 파헤치기 - 6장 클래스 파일 구조 요약 (0) | 2025.10.03 |
| 주니어를 위한 가비지 컬렉터와 메모리 할당 전략 (0) | 2025.08.31 |
| 18. JDBC (0) | 2024.04.01 |