스레드 안전성 - JVM 끝까지 파헤치기

이상윤·2026년 2월 20일

스레드 안정성이라는 용어를 많이 접해봤을 것이다. 이 용어를 브라이어 게츠는

여러 스레드가 한 객체에 동시에 접근할 때, 어떤 런타임 환경에서든 다음 두 조건을 모두 충족하면서 객체를 호출하는 행위가 올바른 결과를 얻을 수 있다면, 그 객체는 스레드 안전하다.

  • 특별한 스레드 스케쥴링이나 대체 실행 수단을 고려할 필요 없다.
  • 추가적인 동기화 수단이나 호출자 측에서 조율이 필요 없다.

자바 언어의 스레드 안정성

자바 언어에서 공유된 자원의 스레드 안정성은 다음 5단계로 나눌 수 있다. 이제 각 단계에 대해 알아보자.

  • 불변
  • 절대적 스레드 안전
  • 조건부 스레드 안전
  • 스레드 호환
  • 스레드 적대적

불변

불변이란 문자 그대로 변하지 않는다는 뜻이며, 특히 JDK 5 이후에서의 자바 언어에서 불변 객체는 객체 자체의 메서드 구현과 호출자 모두에서 아무런 안전장치가 없어도 스레드 안전하다. 불변 객체가 올바르게 만들어진다면, 이 불변성이 선사하는 안정성은 가장 직접적이고 완전무결하다.

자바 언어에서 기본 데이터 타입은 final로 정의되기만 하면 불변성이 보장되며, 자바 언어는 값 타입을 지원하지 않기 때문에 공유 데이터가 객체라면 객체의 메서드가 자신의 필드를 수정하지 않도록 해야한다.

자바 클래스 라이브러리에서 대표적인 불변 타입으로는 열거 타입이 있는데, Long, Double, BigInteger등 java.lang.Number의 하위 클래스들 역시 대부분 불변이다. 하지만 여기서 AtomicInteger과 AtomicLong은 불변이 아닌데, 이 두 클래스의 코드를 직접 읽고 수정 가능하게 한 이유를 생각해보자.

이들은 만들어진 목적 자체가 여러 스레드들과 값을 공유하며 이를 계속 변화시키는 것에 있기 때문이다.
그래서 값을 변경할 때 마다 새로운 객체를 생성할 필요도 없고 여러 스레드가 변수로 활용할 수 있다는 장점이 있다.

절대적 스레드 안전

절대적 스레드 안전은 브라이어 게츠가 말한 안전성 정의를 완벽하게 만족한다. 하지만 그 정의는 사실 매우 엄격해, 자바 API에서 스레드 안전하다고 표시된 클래스 대부분이 절대적 스레드 안전을 의미하지는 않는다.

그럼 이들 중 하나인 java.util.Vector에 대해 알아보자. Vector는 add(), get() 등 모든 메서드가 synchronized 메서드이므로 스레드 안전하다고 할수 있다. 하지만 이가 호출자가 추가로 동기화 할 필요가 절대로 없다는 뜻은 아니다. 다음 코드를 살펴보자.

// 호출자 측의 코드
int lastIndex = vector.size() - 1; // 1단계: 사이즈 확인
vector.remove(lastIndex);         // 2단계: 해당 인덱스 삭제

만약 이런 코드가 존재할 때, 스레드 A가 vector의 size 10을 얻었다고 하자. 그 다음 으로는 remove()를 실행할텐데, 실행하기 직전 스레드 B가 끼어들어 remove()작업을 수행해버렸다.
그럼 A가 remove()를 실행하면 당연하게도 ArrayIndexOutOfBoundsException이 발생하게 되고, 이런 문제를 방지하기 위해서는 호출자가 Lock을 걸어 vector 객체를 보호해야 하므로 위 브라이어의 정의에 부합하지 않는다고 할 수 있다.

조건부 스레드 안전

조건부 스레드 안전은 우리가 일반적으로 스레드 안전하다라고 말할 때 그 안전 수준을 말하며, 위의 Vector도 여기 속한다.
조건부 스레드 안전한 객체는 단일한 작업(메서드)을 별도 보호조치 없이 스레드로부터 안전하게 처리한다. 하지만 측정 순서로 연달아 호출하는 상황에서도 정확성을 보장하려면 호출자에서 추가로 동기화해야 할 수 있다.

스레드 호환

스레드 호환이란 객체 자체는 스레드로부터 안전하지 않지만 호출자가 적절히 조치하면 멀티스레드 환경에서도 안전하게 사용할 수 있다는 뜻이다. 이런 클래스는 일반적으로 스레드 안전하지 않다라고 말하며, 자바의 클래스 대다수가 이 분류에 속한다.

스레드 적대적

스레드 적대적이란 호출자가 동기화 조치를 취하더라도 멀티스레드 환경에서 안전하게 사용할 수 없다는 뜻이다. 자바 언어는 처음부터 스레드를 지원한 덕분에 스레드 적대적 코드는 드물며, 이는 대체로 해로우므로 사용하지 말도록 하자.

스레드 안전성 구현

스레드 안전성에 대해 살펴봤으니 이제 스레드 안전성을 수현하는 방법도 알아보자.

상호 배제 동기화

상호 배제 동기화는 가장 일반적이면서 가장 중요한 동시성 보장 수단이다. 동기화란 공유 데이터에 여러 스레드가 접근하려는 상황에서 그 어떤 시점에든 단 하나의 스레드(세마포어를 사용하면 n개의 스레드)만 데이터를 사용할 수 있다는 뜻이다.
뮤텍스가 대표적인 동기화 수단이며, 임계 영역과 세마포어도 상호 배제 구현에 자주 쓰인다. 따라서 상호 배제 동기화 라는 말에서 상호 배제가 원인 또는 수단이고, 동기화가 결과 또는 목적이다.

자바에서 상호 배제 동기화의 가장 기본적인 수단은 synchronized 키워드다. javac가 이 키워드를 컴파일하면 monitorenter과 monitorexit이라는 두 가지 바이트코드 명령어가 생성되며 각각 동기화 블록 전후에 실행된다. 자바 가상 머신 명세에 따르면 monitorenter 명령어를 실행할 때는 먼저 객체의 락을 얻으려 시도하고 객체가 잠겨있지 않거나 현재 스레드가 락을 소유하고 있으면 락 카운터 값을 1씩 증가시키고, monitorexit 명령어를 실행할 때 1씩 감소시키며 카운터가 0이되면 락을 해제한다. 락을 얻지 못한 스레드는 현재 락을 소유한 스레드가 일을 마치고 락을 해제할 때까지 블록된다.

또한, 명세를 더 읽어보면 다음 두 결론을 내릴 수 있다.

  • 같은 스레드라면 synchronized로 동기화된 블록에 다시 진입할 수 있다. 즉, 락을 이미 소유한 스레드는 동기화된 블록에 여러번 진입해도 블록되지 않는다.
  • synchronized로 동기화된 블록은 락을 소유한 스레드가 작업을 마치고 락을 해제할 때까지 다른 스레드의 진입을 무조건 차단한다. 락을 소유한 스레드가 락을 해제하도록 강제할 방법이 없다는 뜻이기도 하다. 또한 락을 기다리는 다른 스레드를 인터럽트해 깨울 방법도 없다.

따라서 synchronized 명령어는 주의해서 사용해야 한다.

다음으로 락을 소유한다는 건 실행 비용 측면에서 상당히 무거운 작업인데, 이는 스레드를 재우고 깨우는데 운영 체제의 도움을 얻을 수 밖에 없고 이에 따라 사용자 모드와 커널 모드 사이의 전환이 불가피하기 때문이다.
하지만 자바 가상 머신은 나름대로의 최적화를 수행한다. 예컨대 스레드를 블록하라고 운영체제에 알리기 전에 바쁜 대기(spinning 또는 busy waiting) 코드를 추가하여 모드 전환이 자주 일어나지 않게끔 하기도 한다.

동기화의 수단이 synchronized밖에 없는것은 아니다. JDK 5부터는 java.util.concurrent.locks.lock 인터페이스가 새로운 상호 배제 동기화를 제공한다.
Reentrantlock이 Lock 인터페이스를 구현한 대표적인 예이며, synchroinzed와 똑같이 재진입이 가능한 락이며 코드는 다르지만 사용법은 synchronized와 매우 비슷하다. 하지만 대기 중 인터럽트, 페어 락, 둘 이상의 조건 지정 등 몇 가지 진보된 기능을 제공한다.

  • 대기 중 인터럽트
    락을 소유한 스레드가 오랜시간 락을 해제하지 않을 때 같은 락을 얻기 위해 대기중인 스레드들은 락을 포기하고 다른 일을 할 수 있어 실행시간이 매우 긴 동기화 블록을 다루는 데 유용하다.
  • 페어 락
    같은 락을 얻기위해 대기하는 스레드가 많을 때 락 획득을 시도한 시간 순서대로 락을 얻는 방식이다. 기본은 언페어 락이지만 페어 락을 사용하면 Reentrantlock의 성능이 급격히 감소할 수 있으므로 주의하자.
  • 둘 이상의 조건 지정
    Reentrantlock은 동시에 여러 개의 Condition 객체와 연결지을 수 있다. synchronized도 연결 지을 수 있지만 조건을 둘 이상 주고 싶다면 또 다른 락을 추가해야한다. Reentrantlock에서는 newCondition() 메서드를 여러 번 호출하기만 하면 된다.

이 둘의 성능은 크게 차이나지 않으며 Reentrantlock이 synchronized의 기능을 모두 포괄한다. 그럼 Reentrantlock을 사용하는게 맞아 보이지만 이 책의 저자는 다음과 같은 이유로 synchronized를 추천한다.

  • synchronized는 자바 구문 수준의 동기화 수단이며 매우 명확하고 간결하다. 모든 자바 개발자가 synchronized에 익숙하므로 고급 기능이 필요하지 않다면 그냥 사용하자.
  • Lock은 finally 블록에서 해제해야 한다. 그렇지 않으면 동기화로 보호한 코드 블록에서 예외 발생 시 소유중인 락이 해지되지 않을 가능성이 있다. 락 해제를 개발자가 직접 보장해야 한다. 반면 synchronized는 예외 발생 시 락 해제까지 가상 머신이 보장한다.
  • 동기화 최적화는 자바 가상 머신에 맡기는 게 유리하며, synchronized를 사용하면 자바 가상 머신이 스레드 및 락과 관련된 다양한 내부 정보를 활용할 수 있지만 Lock을 이용하면 자바 가상 머신이 어느 스레드가 어느 락을 소유하고 있는지 알기 어렵기 때문이다.

논블로킹 동기화

상호 배제 동기화의 가장 큰 문제는 스레드 일시 정지와 깨우기가 초래하는 성능 저하이며, 이런 동기화를 블로킹 동기화라고 한다.
문제 해결 방법이라는 관점에서 상호 배제 동기화는 비관적 동시성 전략에 속한다. 락과 같은 동기화 장치가 없다면 반드시 문제가 생길거라 가정하여, 경합이 실제로 벌어지는지와 상관없이 락을 건다(실제로는 가상 머신이 필요 없는 락의 상당수를 없애 준다). 이렇게 하면, 사용자 모드에서 커널 모드로 전환되고, 락 카운터를 계산하고, 블록된 스레드를 깨워야 하는지 확인하는 작업이 뒤따른다.
하지만 하드웨어 명령어 집합이 발전하면서 또 다른 선택지가 생겨났다. 충돌 감지를 기반으로 하는 낙관적 동시성 전략이 그 주인공이다. 이 전략에서는 잠재적으로 위험할 수 있더라도 일단 작업을 진행한다. 공유 데이터를 놓고 경합하는 다른 스레드가 없다면 성공이며, 충돌이 발생하면 보완 조치를 취하는데 가장 흔한것은 경합하는 공유 데이터가 없을 때까지 계속 재시도하는 것이다.
이렇게 하면 스레드를 블록할 일이 없으므로 논블로킹 동기화라고 하며, 이 방식을 따르는 프로그래밍 기법을 락프리 프로그래밍이라고 한다.

이 전략에 하드웨어 명령어 집합의 발전이 필요했던 이유는 작업 진행과 충돌 감지라는 두 단계를 한 명령어처럼 원자적으로 수행할 수 있어야 했기 때문이다.
대표적인 예들 중, 자바에서 이용할 수 있는 CAS(Compare-and-Swap)에 대해 알아보자.

CAS 명령어는 피연산자를 세 개 요구하며, 메모리 위치(V), 예상하는 이전 값(A), 새로 설정할 값(B)이다.
CAS 명령어를 실행하는 프로세서는 V의 값이 A와 같으면 B로 교체하고, 그렇지 않으면 아무 작업도 하지 않는다. 그리고 V의 값과 관계없이 A를 반환한다. 이 작업들은 원자적으로 수행되고, 중간에 다른 스레드가 끼어들 수 없다.

이 연산은 핫스팟 내부 머신이 특별하게 처리하며, 그 방식은 JIT컴파일하여 메서드 호출은 없애고 밑단의 프로세서에 맞는 CAS 명령어로 대체하는 식이다. 이는 무조건 인라인하다고 생각해도 된다.

CAS는 완벽하다고 보일 수 있지만, 실제로는 ABA문제라는 허점이 하나 존재한다.

예를 들어 변수 V를 처음 읽었을 때 A이고 할당할 준비가 되었을 때도 A이라고 하자. 하지만 그 사이에 값이 B로 변경됐다 다시 A로 돌아왔다면 CAS연산은 한 번도 변경되지 않았다고 오해할 것이다. 정답은 모른다 이며, 이 문제를 ABA문제라고 한다.
이 문제를 해결하고자 java.util.concurrnent.atomic 패키지는 변숫값을 버전 관리하여 정확성을 보장한다. 하지만 이 문제는 대부분 프로그램 동시성의 정확성에 영향을 주지 않기 때문에, 해당 패키지의 입지는 견고하지 않다. 따라서, ABA문제를 해결해야 한다면 기존의 상호 배제 동기화를 이용하는게 효율적이다.

동기화가 필요 없는 매커니즘

태생부터 스레드에 안전한 코드 중 두 가지를 알아보자.

재진입 코드

순수 코드라고도 하며, 실행 중간에 아무 때나 끼어들어 다른 코드를 수행하고 와도 상관없는 코드를 말한다.제어가 돌아오면 마치 아무일도 없던 것처럼 오류도 없고 결과에도 영향을 끼치지 않는다. 특히 멀티스레딩 맥락에서는(세마포어 같은 요소가 없다면) 재진입 코드도 스레드 안전한 코드로 간주할 수 있다. 재진입성은 스레드 안전성보다 근본적인 특성이라서 그 자체로 스레드 안전함을 보장한다. 하지만 스레드 안전한 코드라고 해서 모두가 재진입 가능한건 아니다.

재진입 코드에는 몇 가지 특징이 있다. 예를 들어 전역 변수, 힙에 저장된 데이터, 공유 시스템 자원을 전혀 사용하지 않고 그 대신 필요한 모든 정보를 매개 변수로 받는다. 재진입이 불가능한 다른 메서드를 호출하지도 않는다.
재진입이 가능한 코드인지 판단하는 원칙은 다음과 같다. 메서드의 반환값을 예측할 수 있고 똑같은 입력에는 항상 똑같은 출력이 반환된다면, 그 메서드는 재진입 가능하고 스레드 안전하다.

스레드 로컬 저장소

코드 조각에서 사용하는 데이터를 다른 코드와 공유해야 한다면, 데이터를 공유하는 다른 코드도 같은 스레드에서 수행된다고 보장되는지 확인하자. 이 점만 보장된다면 공유 데이터의 가시 범위를 동일한 스레드로 제한할 수 있다.
이 방식을 광범위하게 적용하면 많은 웹 서버에서 스레드 로컬 저장소를 이용해 스레드 안전성 문제를 해결할 수 있다.

자바 언어에서는 여러 스레드가 같은 변수에 접근해야 한다면 volatile 키워드를 사용해서 휘발성 변수로 선언할 수 있다. 또한, java.lang.Threadlocal 클래스를 이용해서 스레드별 저장소를 만들 수 있다.
Thread 객체는 ThreadLocalMap 객체를 하나씩 가지고 있으며, 이는 키-값 쌍을 저장하는 객체로 키는 ThreadLocal.threadLocalHashCode이고 값은 로컬 스레드 변수다. ThreadLocal 객체 각각에는 고유한 ThreadLocalHashCode값이 담겨 있어서 대응하는 로컬 스레드 변수를 ThreadLocalMap에 넣고 뺄 수 있다.

이번 포스팅은 여기까지 하고, 다음으로는 락 최적화에 대해 알아보자.

0개의 댓글