Java에서 기본적인 스레드 안정성(Thread Safety)를 지원하는 방법에 대해 알아봅시다.
싱글 스레드 프로세스의 경우 프로세스 내에서 단 하나의 스레드만 작업하기 때문에 프로세스의 자원을 다룰 때에 문제가 없습니다. 하지만 멀티 스레드환경에서는 여러개의 스레드가 하나의 프로세스에서 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 줍니다.
그래서 프로그래머들은 이러한 원인으로 문제가 발생하지 않도록 공유 데이터를 사용하는 부분을 임계 영역(Critical Section)으로 설정하고, 공유 데이터가 가지고 있는 락(Lock)을 가지고 있는 스레드만 작업하도록 허용할 수 있습니다.
하나의 스레드가 진행 중인 작업을 다른 스레드가 간섭하지 못하도록 막는 것을 스레드 동기화(synchronization)이라고 합니다. 자바에서는 Synchoronized 키워드를 사용하여 스레드의 동기화를 지원합니다.
public synchronized void doWork() { // ... 여러 스레드가 동시에 실행할 수 없는 작업 ... }
첫 번째 방법은 메서드 앞에 synchronized를 붙이는 것인데, 메서드 전체가 임계 영역으로 설정됩니다. 스레드는 synchronized 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 락을 얻어서 작업을 수행하다가 메서드가 종료되면 반환합니다.
public void processOrder() { // ... 동기화가 필요 없는 작업 ... synchronized (lockObject) { // 이 블록 내의 작업만 동기화가 필요함 } // ... 동기화가 필요 없는 다른 작업 ... }
두 번째 방법은 메서드 내의 코드 일부를 블럭 { } 으로 감싸고, 블럭 앞에 synchronized 키워드와 참조 변수를 붙이는 것입니다. 이때 참조 변수는 락을 걸고자 하는 객체를 참조하여야 합니다. 이 방법을 사용하면 { } 으로 설정한 블럭 안의 작업을 수행할 때 객체의 락을 획득하여 작업을 수행하며 이 블럭이 종료되면 자동으로 락을 반납합니다.
두 방법 모두 락의 획득과 반납이 모두 자동적으로 이루어지므로 우리는 오직 임계 영역을 잘 설정해주면 됩니다.
synchronized 키워드는 가장 간단하고 직관적으로 스레드 안정성으로 선언할 수 있는 방법입니다. 하지만 효율성 측면에서는 항상 최선의 선택이 아닐 수 있습니다.
첫 번째로, 여러 스레드가 동시에 하나의 임계 영역에 접근하려고 할 때, Synchronized 키워드는 나머지 스레드를 모두 대기 상태(Block) 상태로 만듭니다. 이 과정에서 Context Switching이 발생하는데 이는 무거운 작업이므로 상당한 오버헤드를 발생시킵니다.
두 번째로, Synchonized는 락을 획득하거나 대기(Block)하는 단순한 메커니즘을 갖습니다. 따라서 일정 시간만 대기하거나 기다리는 동안 다른 작업을 수행하는 유연한 프로그래밍이 어렵습니다. 또한 현대 시스템에서 읽기 작업에서는 유연한 정책을 사용하는데 Synchronized는 그렇지 못합니다.
java.util.concurrent.locks 패키지 (ReentrantLock)ReentrantLock은 synchronized와 유사한 기능을 제공하지만 더 정교하고 유연한 제어가 가능한 락(Lock) 구현체입니다. synchronized는 블록 구조를 벗어나면 자동으로 락이 해제되지만, ReentrantLock은 개발자가 직접 lock()과 unlock() 메서드를 호출하여 락을 획득하고 해제해야 합니다.
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class Counter { private final Lock lock = new ReentrantLock(); private int count = 0; public void increment() { lock.lock(); // 락 획득 try { count++; } finally { lock.unlock(); // 반드시 finally 블록에서 락 해제 } } }
java.util.concurrent.atomic 패키지atomic 패키지는 여러 스레드에서 단일 변수에 대한 원자적(atomic) 연산을 지원하는 클래스들을 제공합니다. 이 클래스들은 내부적으로 CAS(Compare-And-Swap) 알고리즘을 사용하여 락 없이도 동시성을 보장합니다. CAS는 스레드를 대기 상태로 만들지 않기 때문에(non-blocking) synchronized보다 훨씬 가볍고 성능이 좋습니다.
import java.util.concurrent.atomic.AtomicInteger; public class AtomicCounter { private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); // 원자적으로 1 증가 } public int getCount() { return count.get(); } }
volatile 키워드volatile 키워드는 변수를 메인 메모리에 직접 읽고 쓰도록 강제하여 여러 스레드 간의 가시성(Visibility) 문제를 해결합니다. 한 스레드가 volatile 변수의 값을 변경하면, 다른 모든 스레드는 즉시 최신 값을 볼 수 있게 됩니다.
public class TaskRunner { private volatile boolean running = true; public void run() { while (running) { // 작업 수행 } } public void stop() { running = false; // 이 변경 사항은 run() 메서드의 스레드에 즉시 보임 } }
스레드 로컬(ThreadLocal)은 이름에서 알 수 있듯이 각 스레드를 위한 지역 변수를 만드는 개념입니다. 즉, 여러 스레드가 동시에 같은 변수에 접근하더라도, 실제로는 각 스레드가 자신만의 독립적인 저장 공간을 갖게 되어 다른 스레드에 영향을 주지 않고 안전하게 값을 사용하고 수정할 수 있습니다.
이는 공유 자원에 대한 동기화 문제를 근본적으로 다른 방식으로 해결합니다. synchronized나 ReentrantLock처럼 여러 스레드가 하나의 자원에 순서대로 접근하도록 줄을 세우는 방식(동기화)이 아니라, 아예 각 스레드에게 자신만의 개인 공간을 제공하여 충돌 자체를 방지하는 것입니다.
ThreadLocal을 사용할 때 가장 주의해야 할 점은 메모리 누수 가능성입니다. 스레드 풀 환경에서는 스레드가 작업을 마친 후에도 제거되지 않고 재사용됩니다. 만약 스레드 로컬 변수를 사용한 뒤 값을 제거(remove())하지 않으면, 재사용되는 스레드에는 더 이상 필요 없는 객체가 계속 남아 메모리를 차지하게 됩니다.
따라서 작업이 완료되는 시점에는 반드시 finally 블록 등에서 threadLocal.remove()를 호출하여 값을 명시적으로 제거해주는 것이 매우 중요합니다.
Java의 정석 3판.