포스트

동시성과 멀티 스레딩

동시성과 멀티 스레딩

멀티 스레딩과 동시성

이론의 시작은 메모리 구조에서 출발합니다.

  • 프로세스 : 운영체제로부터 자원을 할당받는 작업의 단위입니다. 각 프로세스는 독립된 메모리 영역(Code, Data, Stack, Heap)을 가집니다. 서로 메모리를 공유하지 않기 때문에 하나가 죽어도 다른 프로세스에 영향을 주지 않지만, 통신(IPC) 비용이 매우 비쌉니다.
  • 스레드 : 프로세스 내에서 실행되는 흐름의 단위입니다. 스레드들은 프로세스의 Heap 영역과 Static 영역을 공유하고, 자신만의 Stack 영역만 가집니다.

스레드가 메모리를 공유한다는 점은 “데이터 통신이 빠르다”는 엄청난 장점을 주지만, 동시에 “누구나 공유 데이터를 수정할 수 있다” 는 문제가 발단이 됩니다. 여기서 모든 동시성 문제가 시작됩니다.

동시성(Concurrency) vs 병렬성(Parallelism)

  • 동시성(Concurrency) : 싱글 코어에서 여러 스레드가 번갈아 가며 실행되는 것입니다. 실제로는 한 번에 하나만 실행되지만, CPU가 너무 빨라서 마치 동시에 실행되는 것처럼 느껴집니다. (논리적 개념)
  • 병렬성(Parallelism) : 멀티 코어에서 여러 스레드가 실제로 동시에 실행되는 것입니다. (물리적 개념)

동시성 프로그래밍의 3가지 문제 현상

  • Race Condition(경쟁 상태) : 두 스레드가 동시에 데이터를 수정하여 결과가 꼬이는 현상.
  • Deadlock(교착 상태) : 두 스레드가 서로가 가진 락이 풀리기를 무한히 기다리는 현상.
  • Livelock(라이브락) : 서로 길을 비켜주려다 결과적으로 아무도 못 가는 현상.

스레드 안전성(Thread-Safety)을 위한 자바의 무기

멀티 스레드 환경에서 여러 스레드가 공유 자원에 동시에 접근해도 데이터의 일관성이 유지되는 상태를 ‘Thread-Safe’라고 합니다. 자바는 이를 위해 3가지 계층의 해결책을 제시합니다.

가시성(Visibility) 문제와 volatile

CPU는 성능을 위해 메인 메모리(RAM)가 아닌 자신의 L1, L2 캐시에서 데이터를 읽어옵니다. 이 때문에 스레드 A가 값을 수정해도 스레드 B는 바뀐 값을 보지 못하는 현상이 발생합니다. volatile 키워드는 “이 변수는 캐시가 아닌 무조건 메인 메모리에서 읽고 써라” 라고 강제하여 가시성을 확보합니다. 하지만 연산 자체의 원자성은 보장하지 않습니다.

원자성(Atomicity)과 synchronized (Blocking)

한 스레드가 작업을 끝낼 때까지 다른 스레드의 접근을 막는 상호 배제(Mutual Exclusion) 입니다.

메서드나 블록에 synchronized를 걸면 모니터 락(Monitor Lock) 을 획득한 스레드만 진입할 수 있습니다.

확실하게 안전하지만, 락을 기다리는 스레드들이 멈추기 때문에 성능 저하와 데드락(Deadlock) 위험이 있습니다.

Non-blocking과 Atomic 클래스(CAS 알고리즘)

락을 걸지 않고도 동시성을 해결하는 현대적인 방식입니다. AtomicInteger 등이 대표적입니다.

CAS(Compare And Swap) : “현재 내 메모리 값이 내가 알고 있는 값과 같다면, 새로운 값으로 바꿔라”라는 연산을 CPU 레벨에서 원자적으로 수행합니다. 락을 걸지 않으므로 성능이 매우 우수합니다.

스레드 풀과 ExecutorService

스레드 풀의 존재 이유는 단 하나, “스레드를 생성하고 파괴하는 비용이 시스템에서 손에 꼽힐 정도로 비싸기 때문” 입니다.

자바에서 new Thread().start()를 호출할 때 OS(운영체제) 내부에서는 다음과 같은 무거운 작업이 일어납니다.

  1. JVM이 OS 커널에 스레드 생성 시스템 콜(System Call)을 요청합니다.
  2. OS는 스레드를 위한 메모리 공간(스택 영역, 기본 약 1MB)을 할당합니다.
  3. OS의 스케줄러에 새로운 스레드를 등록합니다.

클라이언트의 요청이 올 때마다 이 비싼 작업을 반복한다면, 실제 비즈니스 로직을 처리하는 시간보다 스레드를 만들고 부수는 데 CPU와 메모리를 다 써버리게 됩니다. 이를 해결하기 위해 미리 일정 개수의 스레드를 생성해 두고(Pool), 작업이 끝나면 파괴하지 않고 다음 작업을 위해 대기시키는 구조가 바로 스레드 풀입니다.

스레드 풀의 내부 아키텍처와 동작 원리

자바의 스레드 풀은 기본적으로 ‘작업 대기열(Task Queue)’‘일꾼 스레드(Worker Threads)’ 로 구성된 생산자-소비자(Producer-Consumer) 패턴으로 동작합니다.

  1. 작업 제출 : 클라이언트(메인 스레드 등)가 처리해야 할 작업(Task)을 스레드 풀에 던집니다.
  2. Task Queue(BlockingQueue) : 제출된 작업들은 큐(Queue)에 차곡차곡 쌓입니다.
  3. Worker Threads(일꾼 스레드) : 풀 안에 대기 중인 스레드들이 큐에서 작업을 하나씩 꺼내어 실행합니다.
  4. 재사용 : 작업 처리가 끝난 스레드는 죽지 않고, 다시 큐를 바라보며 다음 작업을 기다립니다.

ExecutorService : 자바 스레드 풀의 지휘자

이 복잡한 큐 관리와 스레드 생명주기 관리를 개발자가 직접 짜는 것은 매우 위험합니다. 자바는 이를 완벽하게 추상화한 java.util.concurrent.ExecutorService 인터페이스를 제공합니다. 개발자는 그저 “작업”만 정의해서 던져주면, ExecutorService가 알아서 스레드를 배정하고 실행합니다.

자바의 Executors 팩토리 클래스를 통해 실무에서 자주 쓰이는 2가지 대표적인 풀을 생성할 수 있습니다.

newFixedThreadPool(int nThreads)

스레드의 개수를 딱 nThreads개로 고정합니다. 스레드가 모두 작업 중이면, 새로운 작업은 큐에서 무한정 대기합니다.

  • (OOM 위험) : 내부적으로 크기가 무한대(Integer.MAX_VALUE)인 LinkedBlockingQueue를 사용합니다. 트래픽이 폭주하여 처리 속도보다 작업이 들어오는 속도가 빠르면, 큐에 수백만 개의 작업이 쌓여 결국 OutOfMemoryError(OOM) 가 발생하며 서버가 죽습니다. 실무에서는 이를 피하기 위해 팩토리 메서드를 쓰지 않고, 큐의 최대 크기를 명시한 ThreadPoolExecutor를 직접 커스텀하여 생성하는 것이 안전합니다.
newCachedThreadPool()

필요한 만큼 스레드를 계속 무한정 만들어냅니다. 60초 동안 놀고 있는 스레드는 자동으로 파괴하여 자원을 회수합니다.

  • Thread Explosion : 작업이 몰리면 스레드가 수만 개까지 생성될 수 있습니다. 스레드 간의 잦은 전환(Context Switching)으로 인해 CPU가 마비되는 현상이 발생할 수 있으므로, 제어할 수 없는 외부 요청을 받는 웹 서버에서는 절대 사용해선 안 됩니다.

우아한 종료 (Graceful Shutdown)

애플리케이션이 종료될 때, ExecutorService를 그냥 방치하면 작업 중이던 스레드가 강제로 끊기며 데이터가 유실될 수 있습니다.

  • shutdown() : 더 이상 새로운 작업은 받지 않지만, 이미 큐에 들어있는 작업들은 끝까지 다 처리하고 종료합니다.
  • shutdownNow() : 현재 실행 중인 스레드에 강제로 인터럽트(Interrupt)를 걸어 즉시 멈추고, 큐에서 대기 중인 작업 목록을 반환합니다.

작업의 결과 돌려받기 : CallableFuture

단순한 Runnablerun() 메서드의 반환 타입이 void라 작업 결과를 돌려받을 수 없습니다. 결과값이 필요한 경우 Callable<T> 인터페이스를 사용합니다. 이를 ExecutorService.submit()으로 던지면, 미래에 완료될 결과를 담을 상자인 Future<T> 객체를 즉시 반환받습니다. 이후 future.get()을 호출하면, 해당 스레드의 작업이 끝날 때까지 기다렸다가(Blocking) 결과를 꺼내올 수 있습니다.

이 기사는 저작권자의 CC BY-NC 4.0 라이센스를 따릅니다.