Synchronized는 Java 언어에 내장된 동기화 메커니즘으로, 코드의 임계 영역에 대한 독점적 액세스를 제공합니다. Oracle, 2024에 따르면, synchronized 수정자는 표시된 메서드나 블록을 특정 시점에 오직 하나의 스레드만 실행할 수 있도록 보장합니다. 이 메커니즘은 모니터를 기반으로 합니다. 모니터는 운영 체제의 기본 개념으로, 모든 복잡성 수준의 멀티스레드 애플리케이션이 올바르게 작동하도록 보장합니다.
핵심 요점
Synchronized는 Java의 키워드로, 한 번에 하나의 스레드만 보호된 코드 섹션을 실행하도록 보장하여 동시 액세스 시 데이터 손상을 방지합니다. Java 첫 번째 버전에서 등장했으며 모든 수준의 개발자에게 스레드 안전성을 보장하는 가장 간단한 방법으로 남아 있습니다.
synchronized 수정자는 두 가지 작업을 해결합니다: 상호 배제(mutual exclusion)와 변경 가시성(visibility). 스레드가 synchronized 블록을 종료하면 모든 변경 사항이 동일한 객체에서 동기화된 블록에 진입하는 다른 스레드에 보이도록 보장됩니다.
Synchronized는 전체 메서드나 모니터 객체를 지정하여 임의의 코드 블록에 적용할 수 있습니다. 두 경우 모두 JVM은 바이트코드 수준에서 monitorenter 및 monitorexit 명령어를 삽입합니다.
동기화 없이 멀티스레드 애플리케이션에서는 두 스레드가 동시에 동일한 데이터를 수정할 때 경합 조건(race condition)이 발생하여 예측 불가능한 결과가 초래됩니다. Synchronized는 이 문제를 해결하기 위한 Java의 첫 번째이자 주요 도구가 되었으며, 모든 개발자가 접근할 수 있는 간단한 선언적 구문을 제공합니다.
Synchronized 메커니즘은 모니터 개념에 기반합니다. 모니터는 모든 Java 객체에 내장된 고급 동기화 프리미티브입니다. 모니터는 객체에서 synchronized 블록이 처음 사용될 때 해당 객체와 연결됩니다.
Java의 모든 객체에는 관련 모니터가 있습니다. 스레드가 synchronized 블록에 진입하면 객체의 모니터를 획득합니다. 모니터가 이미 다른 스레드에 의해 보유된 경우, 스레드는 해제될 때까지 차단됩니다. 바이트코드에서 이는 monitorenter 및 monitorexit 명령어 쌍에 해당합니다.
JVM은 여러 수준을 통해 synchronized를 최적화합니다: 단일 스레드 액세스를 위한 biased locking(편향 잠금), 낮은 경합을 위한 lightweight locking(경량 잠금), OS가 관여하는 높은 경합을 위한 heavyweight locking(무거운 잠금). 이러한 수준은 코드 변경 없이 성능을 향상시킵니다.
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Synchronized는 happens-before 관계를 설정합니다: synchronized 블록을 종료하기 전 스레드의 모든 작업은 동일한 객체에서 동기화된 블록에 진입한 후 다른 스레드에 보입니다. 이는 상호 배제뿐만 아니라 모든 스레드의 데이터 일관성도 보장합니다.
Java는 synchronized를 적용하는 두 가지 방법을 제공합니다: 메서드 수준과 블록 수준입니다. 선택은 성능과 동기화 세분성에 영향을 미칩니다.
메서드에 synchronized 수정자를 표시하면 현재 인스턴스(인스턴스 메서드의 경우) 또는 Class 객체(정적 메서드의 경우)에서 자동으로 동기화됩니다. 이는 상호 배제를 보장하는 가장 간단한 방법이지만, 임계 영역이 메서드의 아주 일부만 차지하고 나머지 코드에 동기화가 필요하지 않은 경우 과도할 수 있습니다.
Synchronized 블록은 정밀한 제어를 제공합니다: 모니터 객체를 지정하고 필요한 코드 부분만 동기화하며 메서드의 나머지는 잠금 외부에 둡니다. 이는 모니터 보유 시간을 최소화하고 멀티스레드 환경에서 애플리케이션 전체 성능을 향상시킵니다. 다른 스레드는 모니터 해제를 기다리지 않고 관련 없는 코드를 병렬로 실행할 수 있습니다.
class DataProcessor {
private final Object lock = new Object();
public void process() {
// 임계 영역 외부 코드 - 동기화 없음
prepareData()
synchronized (lock) {
// 이 블록만 보호됨
updateSharedState()
}
// 잠금 없이 계속
cleanup()
}
}
| 기준 | Synchronized 메서드 | Synchronized 블록 |
|---|---|---|
| 모니터 | this(인스턴스) 또는 Class | 모든 객체 |
| 세분성 | 전체 메서드 | 필요한 코드만 |
| 가독성 | 높음 | 중간 |
| 성능 | 큰 메서드에서 낮음 | 작은 임계 영역에서 높음 |
Android 개발에서 synchronized는 SharedPreferences, 데이터베이스 액세스 및 UI 구성 요소를 보호하는 데 널리 사용됩니다. 그러나 인터페이스 멈춤 위험 때문에 메인 스레드에서의 사용은 강력히 권장되지 않습니다.
Android의 SharedPreferences는 기본적인 스레드 안전성을 제공하지만, Editor를 통해 여러 스레드에서 편집할 때 외부 동기화가 필요할 수 있습니다. 별도의 잠금 객체를 사용한 synchronized 블록이 변경의 일관성을 보장합니다.
class PreferencesManager(private val prefs: SharedPreferences) {
private val lock = Any()
fun writeToken(token: String) {
synchronized (lock) {
prefs.edit()
.putString("auth_token", token)
.apply()
}
}
}
Android에서 synchronized의 주요 제한은 스레드 차단입니다. Mutex를 사용하는 코루틴과 달리 synchronized는 시스템 스레드 전체를 차단합니다. 메인 스레드에서는 ANR을 유발합니다. 최신 Android 개발에서는 synchronized를 코루틴(suspend Mutex) 또는 원자적 유형(AtomicInteger)으로 대체하는 것이 권장됩니다.
최신 Java와 Kotlin은 synchronized의 여러 대안을 제공하며, 각각 동일한 문제를 더 적은 제약이나 더 나은 성능으로 해결합니다.
Lock 인터페이스는 ReentrantLock 및 ReadWriteLock 구현과 함께 타임아웃, 인터럽트 가능한 대기, 여러 Condition 큐를 제공합니다. synchronized보다 유연하지만 finally에서 명시적 해제가 필요하여 unlock을 잊으면 오류 위험이 증가합니다.
AtomicInteger, AtomicLong, AtomicReference 등의 클래스는 CAS(Compare-And-Swap) 기반의 Lock-Free 알고리즘을 사용합니다. 중간 정도의 경합 시나리오에서 synchronized보다 훨씬 빠릅니다. 스레드를 차단하지 않고 OS 커널 컨텍스트 스위칭 없이 낙관적 재시도를 수행하기 때문입니다.
ThreadLocal은 대안적 접근 방식을 제공합니다: 각 ThreadLocal 변수는 단일 스레드 내에서 격리되어 읽기와 쓰기에 동기화가 필요하지 않습니다. 이는 스레드 간에 공유되지 않아야 하는 데이터에 대한 synchronized의 필요성을 완전히 제거합니다. ThreadLocal은 프레임워크(Spring, Hibernate)에서 트랜잭션 컨텍스트와 세션 저장에 적극적으로 사용됩니다.
Android용 Kotlin 프로젝트에서 synchronized의 대안은 kotlinx.coroutines의 Mutex입니다. 운영 체제 스레드를 차단하지 않고 잠금이 해제될 때까지 코루틴을 일시 중단합니다. 이를 통해 풀 스레드를 효율적으로 사용하고 리소스 해제를 오래 기다리는 동안 ANR을 방지할 수 있습니다.
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
val mutex = Mutex()
var counter = 0
suspend fun safeIncrement() {
mutex.withLock {
counter++
}
}
Synchronized의 성능은 최신 Java 버전에서 크게 변경되었습니다. 이전에는 “무거운” 메커니즘으로 간주되었지만, 최신 JVM은 고급 JIT 컴파일러 최적화를 통해 대부분의 오버헤드를 제거했습니다. 런타임에 가상 머신이 어떻게 동기화된 코드를 가속화하는지 자세히 살펴보겠습니다.
JVM JIT 컴파일러는 여러 최적화를 적용합니다: biased locking은 잠금이 항상 동일한 스레드에 의해 획득되는 경우 동기화를 제거합니다; lock coarsening은 인접한 synchronized 블록을 하나로 병합합니다; lock elimination은 객체가 하나의 스레드만 액세스할 수 있는 경우 동기화를 제거합니다. 이러한 최적화는 낮은 경합에서 synchronized를 사실상 무료로 만듭니다.
JVM은 각 객체의 경합 수준을 결정합니다: 경합이 없는 경우 biased locking이 활성화되고, 두 번째 스레드가 나타나면 잠금이 스핀 대기와 함께 lightweight 모드로 전환되며, 장기간 대기 중에만 시스템 뮤텍스를 사용하는 heavyweight로 에스컬레이션됩니다. 이 에스컬레이션은 자동으로 발생하며 개발자가 수동으로 전략을 선택할 필요가 없습니다.
최신 벤치마크(Java 17+)에서 synchronized는 낮은 및 중간 경합에서 ReentrantLock과 비슷한 성능을 보여줍니다. 높은 경합에서는 타임아웃 및 인터럽트를 지원하는 더 효율적인 대기 큐 덕분에 Lock이 유리할 수 있습니다. 경합이 일정한 고부하 시스템에서는 fair 모드의 ReentrantLock이 더 예측 가능한 동작을 제공합니다.
원자적 클래스(AtomicInteger, AtomicReference)는 CAS 기반 Lock-Free 구현 덕분에 단순한 카운터와 플래그에 가장 빠릅니다. 스레드를 전혀 차단하지 않으며, 충돌 시 작업이 루프에서 단순히 재시도됩니다. 이는 4-8개 스레드의 카운터 증가 작업에서 synchronized와 비교하여 3-5배의 성능 향상을 제공합니다.
자주 묻는 질문
Synchronized는 상호 배제와 가시성을 모두 제공합니다. Volatile은 변경의 가시성만 보장합니다. volatile 변수에 쓰기는 모든 스레드에 보이지만 동시 수정을 방지하지는 않으므로 경합 조건으로부터 보호하지 않습니다.
네, 다른 모니터 순서로 중첩된 동기화에서 데드락이 가능합니다. 예를 들어, 한 스레드가 synchronized(a) { synchronized(b) }를 호출하고 다른 스레드가 synchronized(b) { synchronized(a) }를 호출합니다. 중첩된 synchronized 블록을 피하거나 일관된 모니터 순서를 고정하세요.
모니터는 각 Java 객체와 연결된 동기화 메커니즘입니다. 해당 객체에서 하나의 스레드만 synchronized 코드를 실행하도록 보장합니다. 모니터에는 잠금, 대기 큐, wait/notify를 통해 알림을 기다리는 스레드 풀이 포함됩니다.
최신 Java 버전(17+)에서 synchronized는 JIT 최적화(biased locking, lock coarsening) 덕분에 성능에서 Lock에 뒤지지 않습니다. Lock은 속도 때문이 아니라 타임아웃, 인터럽트 가능한 대기, 여러 Condition 큐와 같은 추가 기능 때문에 선호됩니다.
정적 synchronized 메서드는 인스턴스가 아닌 지정된 클래스의 Class 객체의 모니터를 사용합니다. 이는 동기화가 클래스의 모든 인스턴스에 적용됨을 의미합니다. 비정적 메서드와 정적 synchronized 메서드는 다른 모니터를 사용하며 서로를 차단하지 않습니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.