티스토리 뷰

Java & Kotlin

[Java] 동시성 제어를 위한 synchronized 키워드의 의미와 한계점

망나니개발자 2026. 1. 6. 10:00
반응형

 

 



1. 동시성 제어를 위한 synchronized 키워드의 의미와 한계점


[ synchronized 키워드란? ]

자바 언어는 설계 초기부터 대량의 요청을 처리할 수 있도록 멀티 스레드를 염두하여 설계되었다. 이를 위해 자바는 1.0부터 손쉽게 동시성을 제어할 수 있는 synchronized 키워드를 제공하였다. synchronized 키워드를 사용하면 임계 영역의 동기화를 쉽게 구현할 수 있다. synchronized 키워드는 메서드 선언부에 붙여 사용하거나 특정 객체를 대상으로 동기화 블록을 만들어 사용할 수도 있다.

public class BankAccount {
    private int balance = 0;

    // synchronized 메서드
    public synchronized void deposit(int amount) {
        balance += amount;
    }

    // synchronized 블록
    public void withdraw(int amount) {
        synchronized (this) {
            if (balance >= amount) {
                balance -= amount;
            }
        }
    }
}

 

 

[ synchronized의 동작 방식과 모니터 락 ]

자바 컴파일러(javac)가 synchronized 키워드를 컴파일하면 monitorenter와 monitorexit 라는 두 가지 바이트코드 명령어가 각각 동기화 블록 전후에 생성된다. 이때 두 명령어 모두 락으로 사용할 객체를 참조 타입 매개 변수로 받는데, 이것이 가능한 이유는 모든 객체가 내부에 자신의 락을 가지고 있기 때문이다. 이를 모니터 락(monitor lock)이라고 부르며, 객체 내부에 존재하기 때문에 우리가 확인하기는 불가능하다. 따라서 암묵적인 락(intrinsic lock)이라고도 부르며, 내부적으로는 뮤텍스로 동작한다.

스레드가 synchronized 블록에 진입하면 해당 객체의 모니터 락을 획득하고, 블록을 벗어날 때 락을 해제하게 된다. 스레드가 객체의 모니터 락을 획득하려고 시도할 때, 이미 다른 스레드가 해당 락을 보유하고 있다면, 현재 스레드는 락이 해제될 때까지 대기 상태에 들어간다. 이때 해당 스레드의 상태는 RUNNABLE 상태에서 BLOCKED 상태로 전환되고, 락을 획득할 때까지 무한정 대기하는 것이다. 참고로 BLOCKED 상태가 되면 락을 다시 획득하기 전까지는 계속 대기하고, CPU 실행 스케줄링에 들어가지 않는다.

예를 들어 위의 BankAccount 객체에 대해 2개의 스레드가 동시에 withdraw를 시도하는 상황이 생겼다고 하자. 이러한 상황에서 내부적인 동작 방식을 정리하면 다음과 같다.

  1. 스레드 t1 은 해당 객체의 모니터 락을 획득했기 때문에 withdraw() 메서드에 진입할 수 있다.
  2. 스레드 t2 도 withdraw() 메서드 호출을 시도하는데, 이를 위해 해당 객체의 락이 필요하다.
  3. 스레드 t2 는 객체의 모니터 락 획득을 시도하지만 락이 없다. 따라서 t2 스레드는 락을 획득할 때 까지 BLOCKED 상태로 대기한다.
  4. t2 스레드의 상태는 RUNNABLE에서 BLOCKED 상태로 변하고,락을 획득할 때까지 무한정 대기한다.
  5. 참고로 BLOCKED 상태가 되면 락을 다시 획득하기 전까지는 계속 대기하고, CPU 실행 스케줄링에 들어가지 않는다.

 

 

 

 

 

[ synchronized의 재진입성 ]

synchronized 키워드는 재진입성(reentrancy)을 지원한다. 즉, 한 스레드가 동일한 락을 다시 획득할 수 있다. 예를 들어, synchronized 메서드 내부에서 또 다른 synchronized 메서드를 호출하는 경우, 동일한 스레드가 이미 락을 보유하고 있기 때문에 추가적인 락 획득이 허용된다. 이로 인해 데드락(deadlock) 상황을 방지할 수 있다.

자바는 재진입성을 지원하기 위해 내부적으로 락 획득 횟수를 카운트하는 메커니즘을 사용한다. 스레드가 synchronized 블록에 진입할 때(monitorenter 명령어를 실행할 때) 마다 카운터가 증가하고, 블록을 벗어날 때(monitorexit 명령어를 실행할 때) 마다 카운터가 감소한다. 카운터가 0이 되면 락이 해제된다. 같은 스레드가 락을 다시 얻으면 횟수를 증가시키고, 소유한 스레드가 synchronized 블록 밖으로 나가면 횟수를 감소시킨다. 이렇게 횟수가 0이 되면 해당 락은 해제되는 것이다.

 

 

 

[ 다양한 synchronized의 최적화 기법들 ]

자바의 스레드(플랫폼 스레드)는 기본적으로 커널 스레드로 구현되어 있다. 따라서 synchronized 키워드를 사용하여 락을 획득하거나 해제할 때마다 운영체제의 커널 모드로 전환되는 오버헤드가 발생한다. 따라서 자바는 이러한 오버헤드를 줄이기 위해 다양한 최적화 기법을 synchronized 동작 방식에 도입하였다.

자바 언어 개발자들은 실제 동시성의 활용 행태를 분석한 결과, 대부분의 synchronized 블록이 짧은 시간 동안만 락을 보유하고 곧 바로 해제하여 경쟁이 심하지 않다는 것을 발견했다. 이 짧은 시간 동안 운영 체제 모드 전환이 발생하면 오버헤드가 커지므로, 자바는 멀티코어 시스템을 활용한 최적화 기법을 도입하였다. 멀티코어 시스템에서는 여러 스레드가 동시에 실행될 수 있으므로, 한 스레드가 락을 획득한 상태에서도 다른 스레드가 실행될 수 있다. 자바는 이러한 특성을 활용하여, 하나의 스레드가 대기 상태로 들어가지 않고도 락이 해제되는지 옆 코어에서 지켜보고 있도록 하였다. 이러한 방식을 스핀 락(spin lock)이라고 부른다. 스핀락은 스레드 전환 부하는 없애지만 프로세서 시간을 소비하는 부작용이 따르기 때문에 락이 잠시만 잠겨 있을 때만 효과가 좋다. 따라서 초기에는 스핀 락이 활용되다가, 다른 스레드가 오래 기다리게 될 것 같으면 그제서야 블로킹 모드로 전환된다. 즉, 다른 스레드가 진입하면 블로킹 방식으로 확장시킨다는 것이다. 이를 통해 많은 리소스를 최적화할 수 있었다.

또한 JDK 6에서는 스핀 락을 최적화한 적응형 스핀(adaptive spin)이 도입되었다. ‘적응형’이란 말은 스핀 시간이 고정되지 않고, 그 대신 같은 락의 이전 스핀 시간과 락 소유자의 상태에 따라 결정된다는 뜻이다. 하나의 락 객체에서 스핀 락이 성공했다면 가상 머신은 다음번 스핀도 성공할 가능성이 높다고 판단한다. 그래서 기존 스핀 횟수 한계까지 락을 얻지 못하더라도 ‘믿고 조금 더’ 시도해 본다. 반대로 스핀 락 획득에 거의 실패한다면 다음번에도 가능성이 낮을 걸로 판단하여 스핀 로직을 완전히 생략할 수 있다. 이를 통해 프로세서 자원 낭비를 방지할 수 있었다. 보다 자세한 내용은 해당 포스팅을 참고하도록 하자.

그 외에도 스레드를 블록하라고 운영 체제에 알리기 전에 바쁜 대기(busy waiting 또는 spining) 코드를 추가하여 모드 전환이 자주 일어나지 않게끔 하기도 한다.

스핀 락과 적응형 스핀 그리고 JIT 컴파일을 통한 락 제거 그리고 락 범위의 확장과 경량 락까지, 이렇듯 synchronized 키워드는 다양한 최적화 기법을 통해 성능을 향상시켰고, JDK 6 이후 버전에서는 사실상 ReentrantLock과 성능 차이가 거의 없다고 알려져 있다.

 

 

[ synchronized의 한계 ]

synchronized 키워드는 자바에서 동기화를 구현하는 가장 기본적인 방법으로, 프로그래밍 언어에서 문법으로 제공하여 사용이 편리하다. 또한 synchronized 키워드로 인해 블록이 자동으로 지정되므로, 개발자가 명시적으로 락을 획득하고 해제하는 코드를 작성할 필요가 없어서 용이하다. 하지만 뚜렷한 몇 가지 한계점도 존재한다.

  • 공정성 문제: 락의 획득 순서가 보장되지 않음
  • 무한 대기 문제: 대기하는 스레드를 깨우는 등의 제어를 할 수 없음

 

먼저 락의 획득 순서가 보장되지 않는다는 점이다. 만약 BankAccount 객체의 withdraw 메서드를 여러 스레드가 동시에 호출한다면, 어떤 스레드가 먼저 락을 획득할지는 예측할 수 없다. 최악의 경우 가장 먼저 도착한 스레드는 영원히 락을 획득하지 못하고 무한정 대기 상태에 빠질 수 있다. 관련 부분은 '자바 가상 머신 명세'에 정의되어 있지 않으며, 이는 공정성(fairness)이 보장되지 않는다는 의미이다.

더욱 치명적인 문제로는 BLOCKED 상태로 대기하는 스레드를 제어할 수 없다는 점이다. synchronized 키워드를 사용하면, 락을 획득하지 못한 스레드는 BLOCKED 상태로 대기하게 된다. 이러한 상황에서 개발자는 해당 스레드에 인터럽트를 걸어 깨우거나, 특정 시간까지만 대기하는 타임아웃을 설정하는 등의 세밀한 제어가 불가능하다. 즉, 한 번 대기 상태에 들어가면, 락을 획득할 때까지 무한정 기다릴 수밖에 없다.

 

즉, synchronized는 매우 편리하지만 제공하는 기능이 너무 단순한 것이다. 오늘날에는 멀티 스레드가 더 중요해지고, 점점 더 고도화된 동시성 제어가 필요해지면서 synchronized의 한계점이 부각되고 있다. 이에 따라 자바 5부터는 java.util.concurrent 패키지를 통해 Lock 인터페이스와 ReentrantLock 클래스 등 더 강력하고 유연한 동시성 제어 도구들이 제공되고 있다.

 

 

 

 



 

 

관련 포스팅

 

 

 

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2026/01   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함