한 줄 정의

여러 스레드가 함께 고치는 가변 상태는 읽기와 쓰기 사이의 틈에서 갱신이 사라지므로, synchronized·아토믹 객체·동시성 컬렉션으로 그 틈을 보호하거나 가변 지점을 밖으로 노출하지 않아 틈 자체를 없애야 합니다.

쉽게 말하면

가게 하나에 점원이 여럿이고, 오늘 매출 합계를 벽의 화이트보드 하나에 적는다고 생각해 보세요. 점원마다 “보드 숫자를 읽고, 자기 판매액을 더하고, 새 숫자로 고쳐 쓰는” 세 동작을 합니다. 두 점원이 동시에 같은 숫자를 읽으면 나중에 쓴 사람이 앞사람의 판매액을 지워 버립니다. 읽은 순간부터 고쳐 쓰기까지의 그 짧은 틈이 임계 영역 입니다.

틈을 다루는 방법은 넷입니다. 보드 앞에 줄을 세워 한 번에 한 명만 쓰게 하거나(synchronized), 누르기만 하면 되는 계수기로 바꿔 “읽고 더하고 쓰는” 틈 자체를 없애거나(아토믹 객체), 여럿이 동시에 적어도 깨지지 않게 만들어진 장부를 쓰거나(동시성 컬렉션), 아예 원본 보드를 밖에 내놓지 않고 필요한 사람에게 사진만 건네는 것입니다(방어적 복사·읽기 전용 노출).

왜 중요한가?

운영 체제는 시분할(time slicing) 로 스레드를 짧게 실행하고 다른 스레드로 전환하기를 반복하며, 코어가 여럿이면 실제로 동시에 실행합니다. 문제는 언제 전환될지를 코드가 알 수 없다는 점입니다. num += 1처럼 한 줄로 보이는 연산도 “현재 값 읽기, 새 값 계산, 변수에 할당”의 여러 단계이고, 그 사이 어디서든 다른 스레드가 끼어들 수 있습니다.

var num = 0
for (i in 1..1000) {
    thread {
        Thread.sleep(10)
        num += 1
    }
}
Thread.sleep(5000)
print(num)   // 1000이 나올 가능성은 거의 없습니다. 981처럼 매번 다른 숫자

이런 손실은 실행 순서에 따라 나타났다 사라지므로 재현하고 고치기 어려운 버그 가 됩니다. 가변성이 없으면 문제 자체가 없지만 실제 애플리케이션에서 가변 상태를 완전히 피하기는 어렵습니다. 그래서 규칙은 하나입니다. 여러 스레드가 수정할 수 있는 공유 상태가 있다면, 그 상태에 대한 모든 연산이 올바르게 실행되는지 확인해야 합니다.

핵심 내용

갱신이 사라지는 원리

두 스레드만 놓고 보면 손실이 어디서 나는지 분명해집니다.

sequenceDiagram
    participant A as 스레드 A
    participant N as num
    participant B as 스레드 B
    A->>N: 읽기 (0)
    Note over A,B: 운영 체제가 B로 전환
    B->>N: 읽기 (0)
    B->>N: 쓰기 (1)
    Note over A,B: 다시 A로 전환
    A->>N: 쓰기 (1)
    Note over N: 증가는 두 번, 결과는 1. 증분 하나가 사라졌습니다

카운터 손실은 가장 단순한 형태이고, 객체 상태가 어긋나는 쪽이 더 위험합니다. 대표 사례가 한 스레드가 리스트를 순회하는 동안 다른 스레드가 요소를 추가하는 경우입니다. 기본 컬렉션은 순회 중 수정을 지원하지 않으므로 ConcurrentModificationException이 발생합니다.

var numbers = mutableListOf<Int>()
for (i in 1..1000) {
    thread {
        Thread.sleep(1)
        numbers.add(i)
    }
    thread {
        Thread.sleep(1)
        print(numbers.sum())   // 순회 중 추가가 겹치면 ConcurrentModificationException
    }
}

멀티스레드 디스패처에서 코루틴을 여럿 띄울 때도 같은 문제가 생기며, 스레드에서 쓰는 기술을 그대로 쓸 수 있습니다. 반대로 코틀린/JS는 싱글 스레드라 동기화를 걱정할 필요가 없습니다.

synchronized

코틀린/JVM에서 공유 상태를 다루는 가장 중요한 도구입니다. synchronized(lock) { ... }는 같은 잠금 객체를 쓰는 블록에는 한 번에 한 스레드만 진입 하도록 보장하고, 다른 스레드가 실행 중이면 끝날 때까지 기다립니다.

val lock = Any()
var num = 0
for (i in 1..1000) {
    thread {
        Thread.sleep(10)
        synchronized(lock) {
            num += 1
        }
    }
}
Thread.sleep(1000)
print(num)   // 1000

실무에서는 클래스 안에서 동기화가 필요한 함수 전체를 블록으로 감싸는 형태가 흔합니다.

class Counter {
    private val lock = Any()
    private var num = 0
 
    fun inc() = synchronized(lock) {
        num += 1
    }
 
    fun dec() = synchronized(lock) {
        num -= 1
    }
 
    // 동기화 없이도 예외는 없지만, 오래된 값을 반환할 수 있습니다
    fun get(): Int = num
}

get()을 동기화하지 않아도 되는 이유는 읽기 한 번은 단일 단계라 중간에 끊길 것이 없기 때문입니다. 다만 다른 스레드가 방금 쓴 값이 보인다는 보장은 없어 오래된 값 을 돌려줄 수 있습니다. 상태의 부분마다 잠금을 따로 두는 클래스도 있지만 올바르게 구현하기가 매우 어려워 일반적이지 않습니다.

코루틴에서는

synchronized 대신 Mutex나 싱글 스레드로 제한된 디스패처를 사용합니다. 스레드 전환에는 비용이 들기 때문에, 여러 스레드에서 동기화하는 것보다 싱글 스레드에서 처리하는 편이 더 효율적인 클래스도 있습니다.

아토믹 객체

단순 할당처럼 프로세서 단계 하나로 끝나는 연산은 본래 원자적이지만, 그런 연산은 매우 간단한 것에 한정됩니다. 자바는 AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference 등 원자적 연산이 메서드로 포함된 클래스 를 제공합니다. incrementAndGet은 “읽고 더하고 쓰기”를 한 덩어리로 실행하므로 잠금 없이도 손실이 없습니다.

val num = AtomicInteger(0)
for (i in 1..1000) {
    thread {
        Thread.sleep(10)
        num.incrementAndGet()
    }
}
Thread.sleep(5000)
print(num.get())   // 1000

아토믹 객체는 빠르지만 값 하나 를 지킬 뿐입니다. 여러 객체에 걸친 연산을 한 단위로 맞춰야 한다면 동기화 블록이 필요합니다. 멀티플랫폼이라면 코틀린 라이브러리 AtomicFU가 같은 모양의 API(atomic(0), incrementAndGet(), .value)를 제공합니다.

동시성 컬렉션

컬렉션 자체가 동기화를 책임지는 자바 구현체입니다.

필요한 것선택특징
맵ConcurrentHashMap모든 연산이 스레드 안전. 순회는 그 시점의 스냅샷이라 ConcurrentModificationException이 없지만, 최신 상태를 보장하지는 않음
집합ConcurrentHashMap.newKeySet<T>()MutableSet을 구현하므로 일반 Set처럼 사용
중복 허용 목록ConcurrentLinkedQueue리스트 대신 쓰는 큐
val map = ConcurrentHashMap<Int, String>()
for (i in 1..1000) {
    thread {
        Thread.sleep(1)
        map.put(i, "E$i")
    }
    thread {
        Thread.sleep(1)
        print(map.toList().sumOf { it.first })   // 예외 없이 그 시점의 스냅샷을 순회
    }
}

변경 가능한 지점을 유출하지 마세요

가변 객체를 공개 상태로 노출 하는 것은 특히 위험합니다. 비공개 필드라도 참조를 그대로 반환하면 외부에서 내부 상태를 바꿀 수 있고, 이런 수정이 의도치 않게 일어나면 더욱 위험합니다.

data class User(val name: String)
 
class UserRepository {
    private val users: MutableList<User> = mutableListOf()
    fun loadAll(): MutableList<User> = users
}
 
val userRepository = UserRepository()
val users = userRepository.loadAll()
users.add(User("Kirill"))               // 비공개 상태가 밖에서 바뀝니다
print(userRepository.loadAll())         // [User(name=Kirill)]

첫 조치는 반환 타입을 MutableList에서 List로 업캐스팅 하는 것입니다. 하지만 이것만으로는 안전하지 않습니다.

  • 참조는 여전히 같은 객체입니다. 읽기 전용으로 보여도 실제로는 가변 리스트의 참조라 값이 바뀝니다. 아래 테스트는 두 변수가 같은 객체를 가리키므로 실패합니다.
  • 순회 중 수정이 여전히 가능합니다. 한 스레드가 loadAll()로 받은 리스트를 순회하는 동안 다른 스레드가 add를 부르면 ConcurrentModificationException이 납니다.
class UserRepository {
    private val users: MutableList<User> = mutableListOf()
    fun loadAll(): List<User> = users
    fun add(user: User) { users += user }
}
 
val repo = UserRepository()
val oldElements = repo.loadAll()
repo.add(User("B"))
val newElements = repo.loadAll()
assert(oldElements != newElements)   // 실패. 같은 객체의 참조입니다

해결책은 두 가지입니다.

방어적 복사

참조 대신 사본 을 반환합니다. 복사하는 동안 다른 스레드가 요소를 추가하면 충돌하므로 복사 자체도 동기화해야 합니다. 컬렉션은 toList 같은 변환 함수로, 데이터 클래스는 copy로 복사합니다.

class UserRepository {
    private val users: MutableList<User> = mutableListOf()
    private val LOCK = Any()
 
    fun loadAll(): List<User> = synchronized(LOCK) {
        users.toList()
    }
 
    fun add(user: User) = synchronized(LOCK) {
        users += user
    }
}

읽기 전용 리스트를 통째로 교체

더 간단한 방법입니다. 내부 상태를 읽기 전용 List로 두고, 추가할 때 새 리스트를 만들어 프로퍼티를 바꿉니다. 밖에 나간 리스트는 절대 변하지 않으므로 그대로 반환해도 안전하고, 변경이 세터 한 곳에서만 일어나 추적하기도 쉽습니다. 멀티스레드를 허용하려면 수정 연산만 동기화하면 됩니다.

class UserRepository {
    private var users: List<User> = listOf()
    private val LOCK = Any()
 
    fun loadAll(): List<User> = users
 
    fun add(user: User) = synchronized(LOCK) {
        users = users + user
    }
}

요소 추가는 느려지지만 읽기는 잠금도 복사도 없이 빨라집니다. 쓰기보다 읽기가 많을 때 좋은 절충안입니다.

비교 / 트레이드오프

도구보장하는 것잘 맞는 상황한계
synchronized같은 잠금의 블록에 한 스레드만 진입여러 필드·여러 객체를 한 단위로 바꿔야 할 때잠금 대기 비용, 코루틴에서는 Mutex로 대체
아토믹 객체값 하나의 읽기·수정·쓰기가 원자적카운터, 플래그, 참조 하나값 여러 개에 걸친 불변식은 못 지킴
동시성 컬렉션컬렉션 연산 하나하나의 안전맵·집합·큐를 여러 스레드가 나눠 쓸 때순회는 스냅샷이라 최신 상태 보장 없음, 연산 둘을 묶으면 원자성이 깨짐
방어적 복사읽기 전용 리스트 교체
내부 상태MutableList + 잠금var List + 잠금
읽기 비용잠금 획득 후 복사, 읽을 때마다 발생참조 반환뿐
쓰기 비용잠금 획득 후 제자리 추가잠금 획득 후 새 리스트 생성
변경 추적어려움세터 한 곳이라 쉬움
잘 맞는 상황쓰기가 잦을 때읽기가 쓰기보다 훨씬 많을 때

내 생각

  • Spring 싱글톤 빈의 필드가 곧 공유 상태입니다. 서비스 빈에 mutableMapOf() 캐시나 var 카운터를 두는 순간 이 아이템의 문제가 그대로 재현됩니다. 요청 단위 상태는 지역 변수로 내리고, 정말 공유해야 하면 ConcurrentHashMap이나 AtomicLong이 기본값입니다.
  • Counter.get()의 “오래된 값”은 가시성 문제입니다. 자바 메모리 모델은 동기화 없는 읽기에 다른 스레드의 쓰기가 보인다는 것을 보장하지 않습니다. 읽기 경로까지 정확해야 하면 @Volatile로 가시성을 확보하거나 처음부터 AtomicInteger를 쓰는 편이 명확합니다.
  • JVM 안의 잠금은 서버가 둘이 되는 순간 무력합니다. synchronized와 아토믹 객체는 프로세스 하나 안에서만 유효하므로, 재고 차감처럼 여러 인스턴스가 같은 데이터를 고치는 작업은 DB 잠금이나 분산 잠금으로 올려야 합니다.
  • 읽기 전용 리스트 교체는 곧 copy-on-write입니다. 책의 두 번째 방식은 CopyOnWriteArrayList와 같은 발상이라, 설정값이나 리스너 목록처럼 거의 읽기만 하는 상태에 잘 맞습니다.
  • 가상 스레드에서는 synchronized 대신 ReentrantLock을 고려합니다. JDK 21~23에서는 synchronized 블록 안에서 블로킹하면 캐리어 스레드가 고정(pinning)되어 가상 스레드의 이점이 사라집니다. JDK 24부터 해결되었지만 그전 버전을 쓴다면 잠금 안에서 IO를 하는 코드는 피해야 합니다.

관련 개념