지난 1편에서는 여러 쓰레드가 동시에 하나의 자원에 접근할 때 발생하는 경쟁 상태(Race Condition)와 임계 구역에 대해 알아보았다.
연산 3단계(읽기, 수정, 쓰기)로 나뉘어 실행되면서 다른 쓰레드의 연산을 덮어쓰는 문제가 발생했었다.
이번 글에서는 자바 애플리케이션 내부에서 이 문제를 어떻게 해결할 수 있는지 알아보고자 한다.
자바의 기본 동시성 제어, Synchronized
가장 단순하고 널리 알려진 해결책은 자바가 언어 차원에서 제공하는 synchronized 키워드를 사용하는 것이다.
이 키워드를 메서드에 붙이면, 해당 메서드는 한 번에 단 하나의 쓰레드만 실행할 수 있도록 보장한다.
public class StockService {
private int stock = 100;
// 메서드에 synchronized 키워드 추가
public synchronized void decrease() {
if (stock > 0) {
stock = stock - 1;
}
}
public int getStock() {
return stock;
}
}
이렇게 수정하고 100명의 사용자가 동시에 접근하는 테스트를 다시 실행해보면 남은 재고는 정확히 0이 된다.
원리는 간단하다. 쓰레드 A가 decrease 메서드에 진입하면 Lock을 걸어버리는 구조다. 뒤이어 도착한 쓰레드 B는 메서드의 Lock이 풀릴 때까지 대기하고 있다가 쓰레드 A가 연산을 마치고 Lock이 풀리면 그제서야 쓰레드 B가 진입하여 연산을 수행한다.
블로킹(Blocking) 방식의 한계
synchronized는 사용하기 매우 쉽지만 치명적인 단점이 존재한다. 쓰레드의 상태를 변경하는 과정에서 발생하는 비용 문제가 있다.
쓰레드 A가 차지하여 Lock에 걸려 진입하지 못한 쓰레드 B는 대기 상태(Blocked)로 전환된다. 이후 Lock이 풀리면 운영체제는 다시 쓰레드 B를 실행 상태로 만든다. 이렇게 실행 중인 쓰레드를 멈추고 다른 쓰레드로 교체하는 과정을 컨텍스트 스위칭(Context Switching)이라고 한다.
단순히 숫자 하나를 빼는 아주 짧은 연산을 위해 매번 무거운 컨텍스트 스위치을 발생시키며 쓰레드를 재우고 깨우는 것은 성능상 큰 손해일 수 있다.
쓰레드를 대기 상태로 전환하지 않는 스핀 락(Spin Lock)에 대해
그렇다면, 쓰레드를 대기 상태로 전환하지 않고 락을 획득하는 방법은 없을까? 락을 얻을 때까지 멈추지 않고 계속해서 무한 루프를 돌며 락의 상태를 확인하는 방식을 스핀 락(Spin Lock)이라고 한다.
스핀 락을 직접 구현하기 위해서는 한 가지 중요한 조건이 있다. 락이 비어있는지 확인 후에 비어있다면 락을 차지하는 두가지 행동이 하나의 연산으로 이루어져야 한다는 점이다. 이 부분은 자바에서 기본 util 라이브러리로 제공하는 AtomicBoolean을 이용할 수 있다.
import java.util.concurrent.atomic.AtomicBoolean;
public class SpinLock {
// 락의 상태를 관리하는 변수 (false: 락 해제 상태, true: 락 획득 상태)
private final AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
// expect가 false일 때만 true로 변경하고 루프를 탈출한다.
// true라면, 계속해서 while 문을 헛돌며 대기한다.
while (!lock.compareAndSet(false, true)) {
// 락을 획득할 때까지 아무것도 하지 않고 대기 (Busy Waiting)
}
}
public void unlock() {
// 작업이 끝나면 락을 다시 false로 변경하여 반환한다.
lock.set(false);
}
}
위 코드에서 사용된 compareAndSet 메서드는 현재 값이 기댓값 false와 같을 때만 true로 변경해주는 역할이다. 하드웨어 레벨에서 동시성을 보장해주기 때문에 여러 쓰레드가 동시에 접근해도 안전하게 락을 획득할 수 있다.
재고 차감 로직에 직접 만든 Spin Lock을 적용해보면 아래와 같다.
public class SpinLockStockService {
private int stock = 100;
private final SpinLock spinLock = new SpinLock();
public void decrease() {
spinLock.lock(); // 락 획득 시도 (획득할 때까지 무한 대기)
try {
if (stock > 0) {
stock = stock - 1;
}
} finally {
spinLock.unlock(); // 예외가 발생하더라도 반드시 락을 해제하도록 finally 블록 사용
}
}
public int getStock() {
return stock;
}
}
이 방식은 쓰레드가 잠들기 않기 때문에 컨텍스트 스위칭에대한 비용이 발생하지 않는다. 따라서 재고 차감처럼 아주 짧은 시간에 끝나는 연산은 synchronized를 사용하는 것보다 스핀 락을 사용하는 것이 성능적인 측면에서는 더 좋을 수 있다.
하지만, 락을 획득할 때까지 CPU를 계속 점유하기때문에 작업이 오래걸리거나 쓰레드가 너무 많아지면 오히려 성능적인 측면에서 저하가 될 수 있으니 고려해서 상황에 맞게 사용해야 한다.

마무리
이번 편에서는 자바 애플리케이션 레벨에서 동시성 문제를 해결하는 방법에 대해 알아보았다.
하지만 이 방법들은 아주 치명적인 한계가 존재한다. 하나의 애플리케이션 (하나의 JVM) 안에서만 동작한다는 것인데, 실제 대용량 트래픽을 처리하는 서비스에서는 서버가 한 대가 아니라 여러 대로 구성된 분산 서비스이다. 서버가 여러 대라면 애플리케이션 내부의 락만으로는 서로 다른 서버 간의 동시성을 막을 수 없다.
다음 편에서는 여러 대의 서버 환경에서도 동시성을 제어할 수 있도록, 데이터베이스(DB) 자체의 락 기능을 활용하는 방법에 대해 알아보고자 한다.
'ETC' 카테고리의 다른 글
| 대용량 트래픽과 동시성 제어 4편 - 레디스(Redis)를 활용한 분산 락(Distributed Lock) (0) | 2026.03.18 |
|---|---|
| 대용량 트래픽과 동시성 제어 3편 - 데이터베이스(DB) 락(비관적 락과 낙관적 락) (0) | 2026.03.16 |
| 대용량 트래픽과 동시성 제어 1편 - 다수의 사용자가 동시에 요청할 때 발생하는 문제에 대해 (0) | 2026.03.09 |
| 원격 데스크톱으로 VMware 실행 시 무한 로딩 문제 해결 (0) | 2025.06.02 |
| CI(Continuous Integration)/CD(Continuous Delivery & Continuous Deployment)란? (0) | 2024.03.18 |