웹 애플리케이션을 운영하는 백엔드 개발자라면, 로컬 개발 환경에서는 한 번도 발생하지 않던 기이한 버그가 트래픽이 몰리는 운영(Production) 서버에서만 간헐적으로 발생하는 것을 경험해 보셨을 것입니다. 누락된 결제 금액, 잘못 계산된 조회수, 혹은 원인을 알 수 없는 NullPointerException 등의 문제를 파헤쳐 보면 십중팔구 동시성(Concurrency) 제어 실패라는 결론에 도달하게 됩니다.
대부분의 자바(Java) 기반 웹 프레임워크(예: Spring)는 기본적으로 서블릿(Servlet)을 통해 싱글톤(Singleton) 객체에 여러 스레드가 동시에 접근하는 구조를 가집니다[1]. 따라서 코드를 작성하는 개발자가 스레드 안전성(Thread Safety)을 제대로 이해하고 보장하지 않으면 데이터 무결성이 치명적으로 훼손될 수 있습니다.
이 블로그 포스트에서는 "무조건 synchronized를 붙여라" 식의 단순한 결론을 지양합니다. 대신 자바 메모리 모델(JMM)과 컴퓨터 과학의 원리를 바탕으로 동시성 문제가 왜 발생하며, 코드를 구현하는 입장에서 어떤 부분을 주의해야 하는지 실전 가이드를 제공합니다.
1. 스레드 안전성(Thread Safety) 문제의 본질: 가변 상태(Mutable State)
자바 동시성 프로그래밍의 바이블인 Java Concurrency in Practice의 저자 브라이언 고츠(Brian Goetz)는 동시성 문제를 다음과 같은 명언으로 정의했습니다.
> "It's the mutable state, stupid." (문제는 가변 상태야, 바보야) [2]
모든 멀티스레딩 문제는 본질적으로 '변경 가능한 상태(Mutable State)'에 여러 스레드가 적절한 조율 없이 동시에 접근하려 할 때 발생합니다[2]. 스레드 안전한 클래스를 만든다는 것은, 동시 접근 하에서도 그 객체의 불변성(Invariants)이 깨지지 않도록 상태 공간을 안전하게 캡슐화하고 관리하는 것을 의미합니다.
상태가 없는(Stateless) 객체는 언제나 스레드 안전합니다[1]. 상태를 저장하는 인스턴스 변수나 클래스 변수가 없다면, 각 스레드는 메서드 내의 지역 변수만을 사용하므로 다른 스레드의 실행 결과에 영향을 받을 일이 전혀 없습니다. 하지만 비즈니스 로직을 처리하기 위해 반드시 상태를 유지해야 한다면, 우리는 다음과 같은 세 가지 대표적인 동시성 함정을 피해야 합니다.
2. 코드로 보는 3가지 주요 동시성 함정과 해결책
개발자가 코드를 작성하면서 가장 빈번하게 마주치는 동시성 오류의 패턴과 이를 해결하기 위한 샘플 코드를 살펴보겠습니다.
2.1 단일 연산의 착각: 읽고-수정하고-쓰기 (Read-Modify-Write)
조회수나 방문자 수를 카운트하는 아주 간단한 로직을 생각해 봅시다.
public class UnsafeCounter {
private int count = 0;
public void increment() {
count++; // 스레드 안전하지 않음!
}
public int getCount() { return count; }
}
Java
복사
코드상으로는 count++가 매우 간결한 단일 연산(Atomic operation)처럼 보입니다. 하지만 컴파일된 바이트코드 및 CPU 수준에서 이 연산은 결코 원자적이지 않으며, 다음 3개의 독립적인 연산으로 나뉘어 실행됩니다[3].
1.
읽기(Fetch): 메모리에서 count의 현재 값을 읽어 CPU 레지스터로 가져옵니다.
2.
수정(Add): 값에 1을 더합니다.
3.
쓰기(Write): 새로운 값을 다시 메모리에 저장합니다.
만약 스레드 A가 값을 읽고 1을 더하는 찰나의 순간에, 스레드 B가 아직 쓰기가 완료되지 않은 과거의 값(0)을 읽어버린다면 어떻게 될까요? 두 스레드가 모두 연산을 마쳤음에도 최종 결과는 2가 아닌 1이 저장되는 전형적인 경쟁 상태(Race Condition)가 발생합니다.
해결책: 원자적 변수(Atomic Variables) 활용
이러한 문제를 해결하려면 연산 전체가 한 덩어리로 실행됨을 보장해야 합니다. 자바의 java.util.concurrent.atomic 패키지는 하드웨어 수준의 CAS(Compare-And-Swap) 명령어를 사용하여 논블로킹(Non-blocking) 방식으로 원자성을 보장합니다[4].
import java.util.concurrent.atomic.AtomicInteger;
public class SafeCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 하드웨어 CAS 연산 기반의 스레드 안전한 증가
}
}
Java
복사
2.2 확인 후 행동 (Check-Then-Act): 지연 초기화의 덫
메모리나 객체 생성 비용을 아끼기 위해 실무에서 널리 사용되는 '지연 초기화(Lazy Initialization)' 패턴 역시 대표적인 경쟁 상태 유발자입니다[5].
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if (instance == null) { // Check
instance = new ExpensiveObject(); // Act
}
return instance;
}
}
Java
복사
이 코드는 "관찰 결과에 의존하여 다음 행동을 결정"하는 Check-Then-Act 패턴입니다. 스레드 A가 instance == null임을 확인하고 객체를 생성하려는 순간, 운영체제의 스케줄러에 의해 CPU 제어권이 스레드 B로 넘어갈 수 있습니다. 스레드 B 역시 아직 객체가 생성되지 않았으므로 null임을 확인하고 새 객체를 생성합니다. 결과적으로 메모리에는 서로 다른 두 개의 객체가 중복 생성되어 자원이 낭비되거나, 데이터 일관성이 완전히 무너질 수 있습니다[6], [5].
해결책: 동기화(Synchronization) 또는 관용구 사용
이 경우 getInstance() 메서드 전체를 락(Lock)으로 보호하거나, 자바의 클래스 로딩 메커니즘을 이용한 지연 초기화 홀더 클래스(Lazy Initialization Holder Class) 관용구를 사용하여 락 오버헤드 없이 스레드 안전성을 확보해야 합니다[5].
2.3 메모리 가시성 (Memory Visibility)과 재정렬 (Reordering)
멀티스레드 프로그래밍에서 가장 직관적으로 이해하기 어렵고 디버깅하기 힘든 현상이 바로 가시성(Visibility)과 재정렬(Reordering) 문제입니다[7].
public class NoVisibility {
private static boolean ready;
private static int number;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (!ready) {
Thread.yield();
}
System.out.println(number);
}).start();
number = 42;
ready = true;
}
Java
복사
위 코드를 실행하면 어떤 결과가 나올까요? 대부분 정상적으로 42가 출력되겠지만, 놀랍게도 영원히 루프를 돌며 종료되지 않거나, 0이 출력될 수도 있습니다[8].
그 이유는 자바 메모리 모델(JMM)과 최신 멀티코어 CPU의 하드웨어 구조 때문입니다. 각 CPU 코어는 성능 최적화를 위해 메인 메모리(RAM)가 아닌 자신만의 고속 로컬 캐시(L1, L2 Cache)에서 데이터를 읽고 씁니다. 메인 스레드가 ready = true로 변경하더라도, 백그라운드 스레드가 실행 중인 코어의 캐시에 그 변경 사항이 즉각적으로 반영(flush)된다는 보장이 없습니다(이를 오래된 데이터, Stale Data 현상이라고 합니다) [9].
더불어, 컴파일러나 프로세서는 코드의 실행 순서가 단일 스레드 관점에서 결과에 영향을 주지 않는다고 판단되면 성능을 위해 명령어의 실행 순서를 마음대로 재정렬(Reordering)할 수 있습니다[7]. 즉, number = 42보다 ready = true가 먼저 실행되어 버릴 수도 있다는 뜻입니다.
해결책: volatile 키워드의 사용
공유되는 상태 플래그 변수에 volatile 키워드를 선언하면, JMM은 해당 변수에 대한 모든 읽기와 쓰기를 캐시가 아닌 메인 메모리에서 직접 수행하도록 강제합니다. 또한, 주변 코드의 재정렬을 방지하는 메모리 장벽(Memory Barrier)을 세워 데이터의 가시성을 완벽하게 보장합니다[10].
3. 시스템 확장성을 고려한 동시성 제어: 암달의 법칙 (Amdahl's Law)
데이터의 무결성과 안전성을 확보했다면, 개발자의 다음 고민은 '성능(Performance)'과 '확장성(Scalability)'으로 넘어가야 합니다. 객체의 상태를 안전하게 만드는 가장 무식하고 쉬운 방법은 모든 메서드에 synchronized 키워드를 붙여 독점적인 락(Exclusive Lock)을 거는 것입니다. 하지만 이는 애플리케이션을 심각한 병목(Bottleneck) 상태로 몰아넣습니다.
수학적으로 이 확장성의 한계를 명확히 설명하는 것이 암달의 법칙(Amdahl's Law)입니다[11]. 시스템 내에서 락 경합으로 인해 병렬화할 수 없는 순차적 코드의 비율을 라고 할 때, 서버의 프로세서(N)를 무한히 늘려도 얻을 수 있는 최대 성능 향상비율 은 다음과 같이 제한됩니다.
즉, 락을 획득하고 해제하기 위해 스레드들이 직렬화(Serialized)되어 대기하는 구간()이 커질수록, 스케일 아웃을 통해 CPU 코어를 아무리 늘려도 시스템의 전체 처리량(Throughput)은 결코 늘어나지 않습니다.
성능 최적화의 기술: 락 분할(Lock Splitting)과 스트라이핑(Lock Striping)
이러한 확장성의 수학적 한계를 극복하기 위해, 자바의 ConcurrentHashMap은 단일 락으로 맵 전체를 무식하게 보호하는 기존의 Hashtable과 달리 락 스트라이핑(Lock Striping)이라는 정교한 기법을 사용합니다[12].
데이터가 저장되는 공간을 여러 개의 독립적인 세그먼트나 해시 버킷으로 쪼개고, 각 구역마다 별도의 독립적인 락을 부여합니다. 이를 통해 서로 다른 구역의 데이터에 접근하는 수십 개의 스레드가 락 경합 없이 동시에 읽고 쓸 수 있게 되어, 데이터의 안전성과 트래픽 확장성이라는 두 마리 토끼를 모두 잡게 됩니다.
4. 실전! 스레드 안전한 클래스를 설계하는 4가지 원칙
실무에서 개발자가 동시성 코드를 구현할 때 가이드라인으로 삼아야 할 핵심 원칙 4가지를 정리해 드립니다.
1.
상태 공간을 최소화하고 불변 객체(Immutable Object)를 적극 활용하라:
•
클래스의 필드에 final을 선언하여 객체를 불변으로 만들면, 별도의 동기화 비용이 전혀 들지 않으며 어떠한 멀티스레드 상황에서도 완벽한 스레드 안전성을 보장받습니다[10]. 상태가 아예 변하지 않으므로 경쟁 상태 자체가 성립할 수 없기 때문입니다.
2.
모든 가변 상태는 락으로 보호하라:
•
여러 스레드가 접근할 수 있는 변경 가능한(Mutable) 변수는 반드시 락(Lock)을 통해 접근을 통제해야 합니다. 또한 객체의 불변 조건(Invariant)을 구성하는 여러 개의 변수가 있다면, 이 변수들은 반드시 동일한 락으로 묶어서 한 번의 복합 연산으로 보호해야만 일관성이 유지됩니다[13].
3.
락의 범위를 최소화하라:
•
네트워크 I/O 통신이나 무거운 데이터베이스 쿼리와 같이 대기 시간(Blocking)이 긴 작업은 절대로 락을 쥔 상태로 수행해서는 안 됩니다. 공유 상태의 조작이 끝났다면 최대한 빨리 락을 해제해야 암달의 법칙에 따른 직렬화 병목을 피할 수 있습니다[14].
4.
동기화 정책을 문서화(Documentation)하라:
•
클래스가 내부적으로 어떤 락(예: this 또는 별도의 private final Object lock)을 사용하여 상태를 보호하고 있는지 Javadoc이나 @GuardedBy, @ThreadSafe 어노테이션으로 명확히 문서화해야 합니다[15], [16]. 그래야 미래의 동료 개발자가 유지보수 과정에서 동기화 정책을 실수로 깨뜨리지 않습니다.
5. 결론 및 요약
자바 환경에서 완벽한 스레드 안전성(Thread Safety)을 구현하는 것은 단순히 키워드 몇 개를 붙이는 작업이 아니라, 운영체제의 메모리 구조와 하드웨어의 동작 원리를 이해하는 데서 출발합니다.
•
코드가 겉보기에 하나의 연산처럼 보일지라도 CPU 레벨에서는 여러 단계(Read-Modify-Write)로 쪼개져 실행될 수 있으므로 원자성(Atomicity) 훼손을 끊임없이 의심해야 합니다.
•
멀티코어 환경에서는 변수의 변경 사항이 다른 스레드의 캐시에 즉각 반영되지 않는 메모리 가시성(Visibility) 문제가 발생하므로, volatile이나 동기화 블록을 적절히 사용해야 합니다.
•
애플리케이션의 트래픽 확장성을 고려하여 락의 범위를 최소화하고, 가능하다면 객체를 불변(Immutable) 상태로 설계하는 것이 가장 안전한 방어책입니다.
동시성은 시스템의 성능을 비약적으로 끌어올리는 강력한 무기이지만, 제어되지 않은 공유 상태의 변경은 언제 터질지 모르는 시한폭탄과 같습니다. 이 가이드가 여러분이 작성하는 자바 백엔드 시스템을 더욱 견고하고 장애 없는 아키텍처로 이끄는 든든한 나침반이 되기를 바랍니다.
참고문헌
[1] Java Concurrency in Practice — Index 393 as thread-based service example; 150–155 shutdown as cancellation reason; 136 thread-based stopping; 150–161 servlets framework thread safety requirements; 10 threads benefits for; 4 stateful, thread-safety issues atomicity; 19–23 liveness and performance; 29–32 locking; 23–29 stateless as…
[2] Java Concurrency in Practice — try { BigInteger i = extractFromRequest(req); encodeIntoResponse(resp, cache.compute(i)); } catch (InterruptedException e) { encodeError(resp, "factorization interrupted"); } } } Listing 5.20. Factorizing servlet that caches results using Memoizer. 110 Chapter 5. Building Blocks Summary of Part I We…
[3] Java Concurrency in Practice — tion; visibility; 64-bit operations nonatomic nature of; 36 and compound actions; 22–23 and multivariable invariants; 57, 67–68 and open call restructuring; 213 and service shutdown; 153 and state transition constraints; 56 caching issues; 24–25 client-side locking support for; 80 field updaters; 33…
[4] Java Concurrency in Practice — non-interruptable blocking; 148 threads use to simulate; 4 utilization measurement tools; 240 idempotence and race condition mitigation; 161 idioms See also algorithm(s); conventions; design patterns; documen-tation; policy(s); protocols; strategies; double-checked locking (DCL) as bad practice; 348…
[5] Java Concurrency in Practice — tion; invariant(s); state; multiple-variable invariant lack of thread safety issues; 24 parallelization use; 183 state variables; 66, 66–67 lock splitting use with; 235 task concurrency advantages; 113 inducing lock ordering for deadlock avoidance; 208–210 initialization See also construction/constr…
[6] Java Concurrency in Practice — tion. Don’t do this. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19 2.3 Race condition in lazy initialization. Don’t do this. . . . . . . . . . . 21 2.4 Servlet that counts requests using AtomicLong. . . . . . . . . . . . . 23 2.5 Servlet that attempts to cache its last result without …
[7] Java Concurrency in Practice — reordering; 34 See also deadlock; optimization; or- der(ing); ordering; synchro-nization; time/timing; initialization safety limitation; 350 memory barrier impact on; 230 operations; 339 volatile variables warning; 38 replace-if-equal operation as atomic collection operation; 86 representation See a…
[8] Java Concurrency in Practice — tion. Don’t do this. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19 2.3 Race condition in lazy initialization. Don’t do this. . . . . . . . . . . 21 2.4 Servlet that counts requests using AtomicLong. . . . . . . . . . . . . 23 2.5 Servlet that attempts to cache its last result without …
[9] Java Concurrency in Practice — testing use; 259 logging output and client-side locking; 150fn operation; 81fg ordering impact; 339 thread dangers of; 5–8 timing dependencies impact on race conditions; 20 thread execution in thread safety definition; 18 interrupted (Thread) usage precautions; 140 InterruptedException flexible inte…
[10] Java Concurrency in Practice — ing during construction. . . . . . . . . . . . . . . . . . . . . . . . . . 42 3.9 Thread confinement of local primitive and reference variables. . . . 44 3.10 Using ThreadLocal to ensure thread confinement. . . . . . . . . . . 45 3.11 Immutable class built out of mutable underlying objects. . . . . …
[11] Java Concurrency in Practice — See also barrier(s); conditional; latch(es); as latch role; 94 ThreadGate example; 304 global variables ThreadLocal variables use with; 45 good practices See design; documentation; encap- sulation; guidelines; perfor-mance; strategies; graceful degradation and execution policy; 121 and saturation po…
[12] Java Concurrency in Practice — String immutability characteristics; 47fn striping See also contention; lock; 237, 237 Amdahl’s law insights; 229 ConcurrentHashMap use; 85 structuring thread-safe classes object composition use; 55–78 subclassing safety issues; 304 submit, execute vs. uncaught exception handling; 163 suspension, th…
[13] Java Concurrency in Practice — Guard each mutable variable with a lock. Guard all variables in an invariant with the same lock. Hold locks for the duration of compound actions. A program that accesses a mutable variable from multiple threads without synchronization is a broken program. Don’t rely on clever reasoning about why you…
[14] Java Concurrency in Practice — See also barrier(s); conditional; latch(es); as latch role; 94 ThreadGate example; 304 global variables ThreadLocal variables use with; 45 good practices See design; documentation; encap- sulation; guidelines; perfor-mance; strategies; graceful degradation and execution policy; 121 and saturation po…
[15] Java Concurrency in Practice — 4.5 Documenting synchronization policies Documentation is one of the most powerful (and, sadly, most underutilized) tools for managing thread safety. Users look to the documentation to find out if a class is thread-safe, and maintainers look to the documentation to understand the implementation stra…
[16] Java Concurrency in Practice — A.2 Field and method annotations The class-level annotations above are part of the public documentation for the class. Other aspects of a class’s thread-safety strategy are entirely for maintainers and are not part of its public documentation. Classes that use locking should document which state var…
