1. 서론: 멀티스레드 환경에서 전통적 Lock의 병목 현상
대규모 트래픽을 처리하는 백엔드 애플리케이션을 개발하다 보면 데이터의 일관성을 유지하기 위해 멀티스레드 환경의 동시성(Concurrency) 문제와 반드시 마주하게 됩니다. 주니어 개발자 시절에는 흔히 공유 가변 상태(Shared Mutable State)를 보호하기 위해 메서드나 블록에 synchronized 키워드를 붙이거나 ReentrantLock을 사용하여 상호 배제(Mutual Exclusion)를 보장하는 방식을 택합니다.
하지만 시스템의 트래픽이 증가하고 스레드가 많아지면, 이러한 전통적인 락(Lock) 방식은 심각한 성능 저하의 원인이 됩니다. 특정 스레드가 락을 획득하여 임계 영역(Critical Section)에 진입하면, 락을 얻지 못한 나머지 스레드들은 운영체제에 의해 강제로 BLOCKED 또는 WAITING 상태로 전환되기 때문입니다 [1]. 이 과정에서 CPU는 현재 실행 중인 스레드의 상태(레지스터, 프로그램 카운터 등)를 저장하고 다른 스레드의 상태를 적재하는 컨텍스트 스위칭(Context Switching)을 수행합니다.
컨텍스트 스위칭은 CPU 캐시를 무효화시키고 상당한 메모리 대역폭을 소모하는 아주 무거운 작업입니다. 만약 스레드가 락을 기다리며 멈추고 깨어나는 과정이 반복된다면, 암달의 법칙(Amdahl's Law)에 따라 아무리 CPU 코어를 늘려도 직렬화된 병목 구간 때문에 시스템 전체의 처리량(Throughput)은 더 이상 증가하지 않습니다 [2]. 이러한 운영체제 수준의 블로킹(Blocking) 오버헤드를 극복하기 위해 컴퓨터 과학자들은 하드웨어의 지원을 받는 새로운 알고리즘을 고안해냈는데, 이것이 바로 락 프리(Lock-Free) 방식입니다.
2. Lock-Free란 무엇인가? 하드웨어 수준의 동시성 제어
Lock-Free 프로그래밍은 단어의 의미 그대로 "물리적인 락(Lock)을 사용하지 않고 여러 스레드가 동시에 공유 자원에 접근할 수 있도록 보장하는 알고리즘"을 뜻합니다.
이 개념의 수학적이고 학술적인 정의는 "전체 시스템의 스레드 중 적어도 하나는 항상 실행을 진행(Progress)할 수 있는 상태를 보장하는 것"입니다. 즉, 어떤 스레드가 운영체제의 스케줄링에 의해 중단되거나 지연되더라도, 다른 스레드의 실행을 영원히 방해(Block)하지 않아야 합니다.
이러한 Lock-Free 메커니즘을 가능하게 하는 핵심 원동력은 운영체제의 소프트웨어 락이 아니라, 최신 CPU 하드웨어가 제공하는 CAS(Compare-And-Swap) 명령어에 있습니다.
CAS (Compare-And-Swap)의 원리
CAS 연산은 본질적으로 읽고-비교하고-수정하기라는 세 가지 독립적인 단계를 하드웨어 레벨에서 단일 원자적(Atomic) 명령어로 압축한 것입니다. CAS 알고리즘은 보통 세 개의 인자를 가집니다.
1.
V (Variable): 메인 메모리에 저장된 현재의 공유 변수 값
2.
E (Expected): 스레드가 연산을 시작하기 전 메모리에서 읽어온 예상 값
3.
N (New): 새롭게 기록하고자 하는 값
하드웨어는 V의 현재 값이 스레드가 예상했던 E와 완벽히 일치할 때만 V의 값을 N으로 교체합니다. 만약 다른 스레드가 먼저 개입하여 V의 값을 변경했다면, V와 E는 다를 것이므로 업데이트는 실패하고 스레드는 변경된 최신 V 값을 다시 읽어들여 처음부터 연산을 재시도합니다. 자바에서는 java.util.concurrent.atomic 패키지 하위의 AtomicInteger, AtomicReference 등의 클래스가 이러한 락 없는 하드웨어 기반 원자성 연산을 제공합니다 [3].
3. AtomicBoolean과 while 루프를 활용한 Lock-Free 스핀락 구현
Lock-Free의 동작 방식을 가장 직관적으로 이해할 수 있는 코드는 바로 AtomicBoolean과 while 루프를 결합한 형태입니다. 이를 컴퓨터 과학에서는 스핀락(Spin-Lock)이라고 부릅니다. 일반적인 운영체제 락이 스레드를 재우는(Sleep) 방식이라면, 스핀락은 스레드가 깨어있는 상태로 계속해서 락 획득을 시도(Spin)하는 방식입니다.
다음은 자바에서 AtomicBoolean을 사용하여 Lock-Free하게 동작하는 스핀락을 구현한 샘플 코드입니다.
import java.util.concurrent.atomic.AtomicBoolean;
public class SimpleSpinLock {
// 락의 상태를 나타내는 원자적 불리언 변수 (false: 락 해제 상태, true: 락 획득 상태)
private final AtomicBoolean isLocked = new AtomicBoolean(false);
/**
* Lock 획득 시도 메서드
*/
public void lock() {
// CAS 연산을 통해 isLocked 값이 false일 때만 true로 변경을 시도합니다.
// 반환값이 false라는 것은 다른 스레드가 이미 락을 획득했다는 의미이므로,
// while 루프에 갇혀 무한히 재시도(Busy-Waiting)하게 됩니다.
while (!isLocked.compareAndSet(false, true)) {
// CPU 사이클을 소모하며 락이 풀리기를 기다림 (Spinning)
// 구현에 따라 Thread.yield() 등을 호출하여 CPU를 양보할 수도 있습니다.
}
}
/**
* Lock 해제 메서드
*/
public void unlock() {
// 락을 해제할 때는 단순히 값을 false로 되돌립니다.
isLocked.set(false);
}
}
Java
복사
코드 실행 흐름 분석
1.
스레드 A와 스레드 B가 동시에 lock() 메서드를 호출합니다.
2.
메인 메모리의 isLocked 초기값은 false입니다.
3.
두 스레드가 동시에 compareAndSet(false, true) 하드웨어 명령어를 실행합니다.
4.
CPU 버스 중재에 의해 스레드 A가 찰나의 차이로 먼저 성공합니다. 스레드 A의 compareAndSet은 true를 반환하므로 while 루프를 빠져나와 임계 영역으로 진입합니다.
5.
찰나의 차이로 늦은 스레드 B는 메모리 값이 이미 true로 바뀐 것을 확인합니다. 즉, 예상값 false와 불일치하므로 compareAndSet은 false를 반환합니다.
6.
스레드 B는 !false(즉, true)가 되어 while 조건이 성립하고, 스레드 A가 unlock()을 호출하여 값을 다시 false로 바꿀 때까지 쉴 새 없이 루프를 회전하며 대기합니다. 이를 바쁜 대기(Busy-Waiting)라고 부릅니다 [4, 5].
4. 스핀락과 Busy-Waiting의 아키텍처적 트레이드오프
위에서 구현한 Lock-Free 스핀락은 얼핏 보면 스레드를 BLOCKED 상태로 만들지 않으므로 무조건적으로 더 빠르고 훌륭한 동시성 제어 기법처럼 보입니다. 하지만 모든 소프트웨어 공학의 결정이 그렇듯, 여기에는 매우 날카로운 트레이드오프(Trade-off)가 존재합니다.
장점 (Pros): 컨텍스트 스위칭 비용의 완전한 소멸
가장 큰 이점은 커널 레벨의 컨텍스트 스위칭 오버헤드가 없다는 점입니다. 스레드 B는 운영체제에 의해 잠들지 않고 CPU 코어를 쥔 채로 실행 가능한 상태(Runnable)를 유지합니다.
수학적으로 표현하면, 스레드를 재우고 깨우는 컨텍스트 스위치 비용을 라고 하고, 스레드 A가 임계 영역의 작업을 처리하는 데 걸리는 시간을 라고 가정해 봅시다. 만약 임계 영역이 아주 단순한 덧셈 연산이나 노드 교체 작업이라서 가 성립한다면, 스레드를 재우는 것보다 잠시 while 루프를 돌며 기다리는 편이 성능상 압도적인 이득을 가져다줍니다.
단점 (Cons): CPU 사이클의 무의미한 낭비와 기아 상태
반대로 스레드 A가 임계 영역에서 수행하는 작업이 I/O 네트워크 통신이거나 복잡한 연산이라서 락을 쥐고 있는 시간()이 매우 길어진다면 어떻게 될까요?
스레드 B는 락을 얻지 못한 채 while 루프를 돌면서 해당 CPU 코어의 연산력을 100% 사용하게 됩니다 [5]. 의미 없는 루프를 돌며 CPU 사이클을 극도로 낭비하는 현상이 발생하며, 이로 인해 오히려 다른 유용한 스레드들이 CPU를 할당받지 못해 시스템 전체 성능이 곤두박질칩니다.
심지어 운이 나쁜 특정 스레드는 compareAndSet 경쟁에서 계속 패배하여 영원히 락을 획득하지 못하는 기아 상태(Starvation)에 빠지거나, 여러 스레드가 서로의 상태 변경을 끝없이 시도하며 CPU만 소모하는 라이브락(Livelock) 상태에 직면할 위험성도 다분합니다.
5. 결론: 언제 무엇을 선택해야 하는가?
지금까지 알아본 바와 같이, Lock-Free 구조와 Atomic 객체를 활용한 스핀락 구현은 은탄환(Silver Bullet)이 아닙니다. 자바와 같은 현대적인 프로그래밍 언어의 동시성 패러다임을 설계할 때는 다음의 기준을 가이드로 삼아야 합니다.
1.
상태가 단순하고 연산이 극도로 짧은 경우: 조회수 카운터 증가, 고유 ID 채번, 링크드 리스트의 노드 추가 등 CPU 사이클이 수십 번 내외로 아주 짧게 끝나는 작업이라면 AtomicInteger, AtomicBoolean 등 CAS 기반의 Lock-Free 연산이나 스핀락을 사용하는 것이 최상의 성능을 냅니다 [3].
2.
임계 영역이 길거나 블로킹 작업이 섞인 경우: 데이터베이스 접근, API 호출 등 외부 I/O가 포함되거나 연산 시간이 긴 작업에서는 절대로 while 루프 기반의 바쁜 대기(Busy-Waiting)를 사용해서는 안 됩니다. 이때는 코틀린 코루틴의 Mutex와 같이 스레드를 아예 블로킹하지 않고 비동기적으로 중단(Suspend)시키고 락을 넘겨주거나 [6], 운영체제의 잠재우기 기술을 활용하는 전통적인 synchronized, ReentrantLock을 사용하는 편이 전체 시스템의 리소스 활용 측면에서 훨씬 안전하고 효율적입니다.
결론적으로, 진정한 대규모 백엔드 성능 튜닝은 "어느 한 기술이 무조건 좋다"고 맹신하는 데서 나오는 것이 아닙니다. CPU와 운영체제가 멀티스레드를 어떻게 스케줄링하고 메모리를 제어하는지에 대한 깊은 이해를 바탕으로, 내가 짠 코드의 실행 시간(Wait Time)과 락 경합도(Contention)를 수학적으로 계산하여 적절한 트레이드오프를 취하는 데서 완성된다는 사실을 기억하시기 바랍니다.
참고문헌
[1] HTTP The Definitive Guide — Other robots.txt Wisdom Here are some other rules with respect to parsing the robots.txt file: The robots.txt file may contain fields other than User-Agent, Disallow, and Allow, as the specification evolves. A robot should ignore any field it doesn’t understand. For backward compatibility, breaking …
[2] Java Concurrency in Practice — ants. Don’t do this. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67 4.11 Thread-safe mutable point class. . . . . . . . . . . . . . . . . . . . . . 69 4.12 Vehicle tracker that safely publishes underlying state. . . . . . . . . 70 4.13 Extending Vector to have a put-if-absent method. .…
[3] 코틀린 코루틴
[4] Java Concurrency in Practice — 201 DemonstrateDeadlock; 210li Dispatcher; 212li, 214li DoubleCheckedLocking; 349li ExpensiveFunction; 103li Factorizer; 109li FileCrawler; 91li FutureRenderer; 128li GrumpyBoundedBuffer; 292, 294li GuiExecutor; 192, 194li HiddenIterator; 84li ImprovedList; 74li Indexer; 91li IndexerThread; 157li In…
[5] Java Concurrency in Practice — ’how fast’; 222 See also GUI; latency; responsive- ness; vs. ’how much’; 222 ’how much’; 222 See also capacity; scalability; throughput; importance for server applications; 223 vs. ’how fast’; 222 HttpSession thread-safety requirements; 58fn I I/O See also resource(s); asynchronous non-interruptable…
[6] 코틀린 코루틴


