서론: 무심코 추가한 동기화가 시스템을 멈추게 한다
자바(Java) 기반의 백엔드 애플리케이션을 개발하다 보면, 여러 스레드가 동시에 공유 자원에 접근할 때 발생하는 데이터 경합(Race Condition) 문제를 흔히 마주하게 됩니다. 이때 많은 개발자들이 가장 쉽고 직관적인 해결책으로 메서드나 블록에 synchronized 키워드를 추가합니다. 로컬 환경에서의 간단한 테스트에서는 이 마법의 키워드가 모든 동시성 문제를 해결해 주는 것처럼 보입니다.
하지만 대규모 트래픽이 발생하는 운영(Production) 환경에서, 깊은 고민 없이 남용된 synchronized는 심각한 성능 병목(Bottleneck)을 유발하거나, 서버 전체를 마비시키는 교착 상태(Deadlock)의 주범이 됩니다. 진정한 의미의 스레드 안전성(Thread-safety)을 확보하기 위해서는 단순히 코드를 잠그는(Lock) 것을 넘어, 자바 메모리 모델(JMM)의 원리와 락 경합이 시스템에 미치는 영향을 완벽하게 이해해야 합니다. 본 포스트에서는 synchronized를 사용할 때 개발자가 반드시 주의해야 할 핵심 원칙과 그 수학적, 구조적 근거를 깊이 있게 파헤쳐 봅니다.
---
1. 동기화의 반쪽짜리 이해: 상호 배제(Mutual Exclusion)와 메모리 가시성(Visibility)
개발자들이 synchronized에 대해 가지는 가장 흔한 오해는, 이 키워드가 오직 "한 번에 하나의 스레드만 코드를 실행하도록 보장하는 상호 배제(Mutual Exclusion) 기능만 수행한다"고 믿는 것입니다 [1]. 객체가 일관된 상태에서 생성되고, 이를 수정하는 메서드에 락(Lock)을 걸면 데이터가 오염되지 않는다는 논리입니다. 이는 맞는 말이지만, 자바 동기화의 절반의 진실에 불과합니다.
왜 상호 배제만으로는 부족할까요? 자바 언어 명세(JLS)에 따르면 long과 double을 제외한 변수를 읽고 쓰는 동작은 그 자체로 원자적(Atomic)입니다 [2]. 즉, 동기화 없이 변수를 읽더라도 최소한 누군가 저장한 '어떤 값'을 읽는 것은 보장됩니다. 이 때문에 어떤 이들은 성능을 높이겠다며 원자적 데이터를 읽을 때는 synchronized를 생략하는 위험한 시도를 합니다 [2, 3].
하지만 멀티코어 환경에서 하나의 스레드가 수정한 공유 데이터의 변경 사항이 다른 스레드에게 즉시 보인다는 보장은 전혀 없습니다 [2]. 각 CPU 코어는 메인 메모리가 아닌 자신만의 로컬 캐시(L1, L2 Cache)에 데이터를 보관하고 최적화를 위해 명령어를 재배열(Reordering)하기 때문입니다.
따라서 synchronized를 사용할 때는 반드시 메모리 가시성(Memory Visibility)을 고려해야 합니다. 동기화 블록에 진입하는 스레드는, 이전에 동일한 락을 해제한 스레드가 수행한 모든 메모리 수정 사항을 확실하게 볼 수 있어야 합니다 [2]. 요약하자면, 공유되는 가변 데이터(Shared Mutable Data)를 읽거나 쓸 때는 단순히 충돌을 막기 위해서뿐만 아니라, 최신 데이터를 안정적으로 통신(Communication)하기 위해 읽기와 쓰기 양쪽 모두에 동기화를 적용해야 합니다 [3, 4]. 만약 배타적 실행(Mutual Exclusion)은 필요 없고 스레드 간의 최신 값 통신만 필요하다면, volatile 키워드가 가볍고 적절한 대안이 될 수 있습니다 [4].
---
2. 에일리언 메서드(Alien Method)와 교착 상태(Deadlock)의 함정
synchronized 영역 내부에서 개발자가 범하는 가장 치명적이고 빈번한 실수는 바로 클라이언트에게 제어권을 양도하는 것입니다 [5]. 쉽게 말해, 동기화된 블록 내부에서 재정의(Override)할 수 있는 메서드를 호출하거나, 클라이언트가 함수 객체(예: 이벤트 리스너, 콜백) 형태로 제공한 메서드를 호출해서는 안 됩니다.
자바의 관점에서 동기화된 클래스는 이러한 외부 메서드가 내부적으로 무슨 일을 하는지 전혀 알 수 없고 제어할 수도 없습니다. 이를 '에일리언 메서드(Alien Method)'라고 부릅니다 [5].
왜 동기화 블록 안에서 에일리언 메서드를 호출하면 시스템이 붕괴될까요?
에일리언 메서드가 실행되는 동안 현재 스레드는 여전히 객체의 락(Monitor Lock)을 쥐고 있습니다. 만약 이 에일리언 메서드 내부에서 또 다른 공유 자원의 락을 획득하려고 시도한다면 어떻게 될까요? 이때 다른 스레드가 반대 순서로 락을 요청하고 있다면, 두 스레드는 서로가 가진 락이 해제되기만을 영원히 기다리는 동적 락 순서 교착 상태(Dynamic Lock-ordering Deadlock)에 빠지게 됩니다 [6, 7]. 더욱 심각한 것은, 에일리언 메서드가 백그라운드 스레드를 생성하여 원래 객체의 동기화된 메서드를 다시 호출하려고 시도할 경우, 자바의 락은 재진입성(Reentrancy)을 가지고 있음에도 불구하고 다른 스레드가 락을 시도하므로 교착 상태나 데이터 훼손(Data Corruption)이 발생할 수 있다는 점입니다 [5].
이러한 재앙을 피하기 위해서는 락을 보유한 상태에서 수행하는 작업을 최소화해야 합니다. 해결책은 에일리언 메서드 호출을 동기화 블록 바깥으로 빼내는 열린 호출(Open Call)을 사용하는 것입니다 [8]. 락을 유지한 상태로 필요한 데이터의 복사본(Snapshot)만 생성한 뒤 락을 즉시 해제하고, 동기화 영역 밖에서 콜백이나 리스너를 실행하면 교착 상태의 위험을 완벽히 제거할 수 있습니다 [8]. 또한 성능 향상을 원한다면 자바의 동시성 컬렉션인 CopyOnWriteArrayList를 옵저버 패턴에 도입하여 명시적인 synchronized 없이도 안전하게 리스너를 순회하도록 설계하는 것이 최선의 모범 사례입니다 [8].
---
3. 락 경합(Lock Contention)과 락 범위의 최소화 원칙
초보 개발자들은 스레드 안전성을 확보하기 위해 클래스의 모든 메서드에 무작정 synchronized를 붙이곤 합니다. 하지만 대규모 트래픽을 처리하는 애플리케이션에서 과도한 동기화는 끔찍한 성능 저하를 초래합니다.
현대의 JVM과 운영체제는 동기화의 비용을 줄이기 위해 많은 발전을 이뤄냈습니다. 단일 스레드만 접근하는 락은 JVM이 스스로 최적화하여 없애기도 합니다. 따라서 멀티코어 시대에 과도한 동기화가 치르는 진짜 비용은 단순히 락을 얻기 위해 소모하는 CPU 시간이 아닙니다. 진짜 비용은 바로 '락 경합(Lock Contention)으로 인해 잃어버린 병렬성의 기회'와 '메모리 일관성을 맞추기 위한 지연 시간'입니다 [8].
스레드가 락을 얻기 위해 대기(Blocking) 상태에 빠지면, 운영체제는 해당 스레드를 중단하고 다른 스레드를 실행하기 위해 무거운 컨텍스트 스위칭(Context Switching)을 수행합니다. 여러 스레드가 빈번하게 하나의 락을 두고 경쟁하면 CPU는 실제 로직을 처리하는 대신 스레드를 교체하는 데 대부분의 자원을 낭비하게 됩니다.
따라서 synchronized 블록은 그 범위를 극단적으로 좁혀야 합니다. 이를 동시성 프로그래밍에서는 "Get in, get out(들어가서, 빨리 빠져나와라)" 원칙이라고 부릅니다 [9].
1.
락은 오로지 공유되는 가변 데이터의 일관성을 검사하고 변경하는 아주 짧은 순간에만 유지해야 합니다 [8].
2.
데이터 갱신과 무관한 네트워크 I/O 호출, 콘솔 출력, 무거운 수학적 연산은 절대로 synchronized 블록 안에 포함시켜서는 안 됩니다 [10]. I/O 작업은 수백 밀리초의 지연을 유발할 수 있으며, 이 시간 동안 해당 객체를 사용하려는 시스템의 모든 스레드가 멈춰버리는 끔찍한 병목이 발생합니다.
---
4. 서비스 거부 공격(DoS) 예방을 위한 비공개 락(Private Lock) 객체 활용
클래스를 동기화할 때 일반적으로 메서드 시그니처에 synchronized를 선언하거나 synchronized(this) 형태로 객체 자신의 내재적 락(Intrinsic Lock / Monitor Lock)을 사용합니다. 하지만 이 방식은 외부에 공개된(Publicly Accessible) 락을 사용하는 것이므로 예상치 못한 취약점을 노출할 수 있습니다 [11].
만약 클라이언트가 악의적인 의도를 가지고(또는 버그로 인해) 여러분이 만든 객체의 락을 획득한 후 아주 긴 시간 동안(혹은 영원히) 놓아주지 않는다면 어떻게 될까요? 해당 객체의 동기화된 메서드를 호출하려는 서버 내의 모든 정상적인 스레드들은 대기 상태에 빠지게 되고, 결국 시스템 전체가 정지하는 서비스 거부 공격(Denial-of-Service Attack, DoS) 상태에 놓이게 됩니다 [11].
이러한 취약점을 원천적으로 차단하기 위해서는 외부에서 접근할 수 없는 비공개 락 객체(Private Lock Object) 패턴을 사용해야 합니다 [11, 12].
// 서비스 거부 공격을 방어하는 비공개 락 객체 관용구
private final Object lock = new Object();
public void someMethod() {
synchronized(lock) {
// 공유 가변 상태를 안전하게 읽고 쓴다.
}
}
Java
복사
이 패턴에서 lock 인스턴스 변수는 클래스 내부에 private으로 숨겨져 있으므로, 외부의 클라이언트나 상속받은 하위 클래스조차 이 락을 탈취할 수 없습니다. 실수로 락을 변경하는 일을 막기 위해 lock 변수는 반드시 final로 선언해야 합니다 [12]. 이 기법은 특히 동시성 처리가 내부적으로 완벽하게 캡슐화되어야 하는 '무조건적 스레드 안전(Unconditionally Thread-safe)' 클래스를 설계할 때 반드시 준수해야 할 모범 사례입니다.
---
결론 및 요약
자바에서 synchronized는 스레드 간의 상호 배제와 가시성을 보장하는 가장 기본적이고 강력한 도구입니다. 하지만 본 포스트에서 살펴보았듯, 잘못된 방식으로 남용할 경우 시스템의 확장성을 무너뜨리고 디버깅조차 불가능한 교착 상태를 유발할 수 있습니다.
안전하고 성능이 뛰어난 멀티스레드 애플리케이션을 구축하기 위한 핵심 체크리스트는 다음과 같습니다.
1.
메모리 가시성 확보: 공유되는 가변 데이터는 읽기와 쓰기 모두에 동기화를 적용하여 스레드 간 최신 값을 통신해야 합니다.
2.
락의 범위 최소화: 동기화 블록 내에서 무거운 연산이나 I/O를 수행하지 말고 락을 최대한 빨리 해제하십시오.
3.
에일리언 메서드 회피: 락을 쥔 상태에서 외부 메서드나 콜백을 호출하지 말고, 열린 호출(Open Call)을 사용해 교착 상태를 예방하세요.
4.
현대적인 대안 적극 활용: 굳이 synchronized를 직접 작성하기보다는, java.util.concurrent.atomic 패키지의 원자적 변수나 ConcurrentHashMap과 같은 최적화된 동시성 컬렉션을 사용하는 것이 성능과 안전성 면에서 훨씬 우수합니다 [13, 14].
스레드 프로그래밍에서 '직감'에 의존한 동기화는 필연적으로 실패를 부릅니다. 자바 메모리 모델의 한계와 동작 원리를 깊이 이해하고, 최소한의 락으로 최대한의 병렬성을 끌어내는 정교한 설계를 실천하시길 바랍니다.
참고문헌
[1] [JAVA][Effective Java 3rd Edition] — Item 78: Synchronize access to shared mutable data The synchronized keyword ensures that only a single thread can execute a method or block at one time. Many programmers think of synchronization solely as a means of mutual exclusion, to prevent an object from being seen in an inconsistent state by…
[2] [JAVA][Effective Java 3rd Edition] — one thread’s changes might not be visible to other threads. Not only does synchronization prevent threads from observing an object in an inconsistent state, but it ensures that each thread entering a synchronized method or block sees the effects of all previous modifications that w…
[3] [JAVA][Effective Java 3rd Edition] — with synchronization when reading or writing atomic data. This advice is dangerously wrong. While the language specification guarantees that a thread will not see an arbitrary value when reading a field, it does not guarantee that a value written by one thread will be visible to another. S…
[4] [JAVA][Effective Java 3rd Edition] — that reads or writes the data must perform synchronization. In the absence of synchronization, there is no guarantee that one thread’s changes will be visible to another thread. The penalties for failing to synchronize shared mutable data are liveness and safety failures…
[5] [JAVA][Effective Java 3rd Edition] — Item 79: Avoid excessive synchronization Item 78 warns of the dangers of insufficient synchronization. This item concerns the opposite problem. Depending on the situation, excessive synchronization can cause reduced performance, deadlock, or even nondeterministic behavior. T…
[6] Java Concurrency in Practice — 10.1.1 Lock-ordering deadlocks LeftRightDeadlock in Listing 10.1 is at risk for deadlock. The leftRight and rightLeft methods each acquire the left and right locks. If one thread calls leftRight and another calls rightLeft, and their actions are interleaved as shown in Figure 10.1, they will deadloc…
[7] Java Concurrency in Practice — A program will be free of lock-ordering deadlocks if all threads acquire the locks they need in a fixed global order. Verifying consistent lock ordering requires a global analysis of your program’s locking behavior. It is not sufficient to inspect code paths that acquire multiple locks individually;…
[8] [JAVA][Effective Java 3rd Edition] — regions. Obtain the lock, examine the shared data, transform it as necessary, and drop the lock. If you must perform some time-consuming activity, find a way to move it out of the synchronized region without violating the guidelines in Item 78. The first part of this item was about correc…
[9] Java Concurrency in Practice — 11.4.1 Narrowing lock scope (“Get in, get out”) An effective way to reduce the likelihood of contention is to hold locks as briefly as possible. This can be done by moving code that doesn’t require the lock out of synchronized blocks, especially for expensive operations and potentially block-ing ope…
[10] Java Concurrency in Practice — Whenever you use locking, you should be aware of what the code in the block is doing and how likely it is to take a long time to execute. Holding a lock for a long time, either because you are doing something compute-intensive or because you execute a potentially blocking operation, introduces the r…
[11] [JAVA][Effective Java 3rd Edition] — to execute a sequence of method invocations atomically, but this flexibility comes at a price. It is incompatible with high-performance internal concurrency control, of the sort used by concurrent collections such as ConcurrentHashMap. Also, a client can mount a denial-of…
[12] [JAVA][Effective Java 3rd Edition] — inadvertently changing its contents, which could result in catastrophic unsynchronized access (Item 78). We are applying the advice of Item 17, by minimizing the mutability of the lock field. Lock fields should always be declared final. This is true whether you use an ordinary monitor lock (…
[13] [JAVA][Effective Java 3rd Edition] — synchronized modifier to its declaration. This ensures that multiple invocations won’t be interleaved and that each invocation of the method will see the effects of all previous invocations. Once you’ve done that, you can and should remove the volatile modifier from nextSeria…
[14] [JAVA][Effective Java 3rd Edition] — method from within a synchronized region. More generally, keep the amount of work that you do from within synchronized regions to a minimum. When 358 you are designing a mutable class, think about whether it should do its own synchronization. In the multicore era, it is more important tha…

.png&blockId=363b967d-93d5-81f9-87c4-e9d2cd145f26&width=3600)
.png&blockId=363b967d-93d5-809e-a6d6-c0a8e6a60a40)