Skip to main content
Summary

스레드는 왜 필요한가

프로세스는 실행 중인 프로그램이다. 이전 장에서 본 것처럼 운영체제는 각 프로세스에 독립적인 주소 공간과 자원을 부여한다. 이 구조는 안전하다. 한 프로세스가 메모리를 잘못 건드려도 보통 다른 프로세스의 메모리까지 직접 망가뜨리지는 못한다. 대신 프로세스를 새로 만들고 전환하는 비용은 상대적으로 크다.

스레드는 한 프로세스 안에서 실행되는 더 작은 실행 단위다. 같은 프로세스에 속한 스레드들은 코드 영역, 힙 메모리, 열린 파일, 네트워크 연결 같은 자원을 공유한다. 대신 각 스레드는 자신만의 실행 위치, 레지스터 상태, 스택을 가진다. 쉽게 말해 프로세스가 하나의 작업장이라면, 스레드는 그 작업장 안에서 동시에 일하는 작업자다.

이 구조는 실무에서 매우 유용하다. 웹 서버는 여러 요청을 동시에 처리해야 한다. GUI 프로그램은 사용자의 입력을 받으면서 백그라운드에서 파일을 내려받아야 한다. 배치 프로그램은 여러 파일을 병렬로 처리하고 싶을 수 있다. 이때 매번 별도 프로세스를 만드는 대신 스레드를 사용하면 메모리 사용량과 생성 비용을 줄일 수 있다.

다만 스레드를 쓴다고 모든 일이 자동으로 빨라지는 것은 아니다. I/O를 기다리는 일이 많다면 스레드가 대기 시간을 숨겨 주는 데 도움이 될 수 있다. 반대로 CPU를 계속 사용하는 작업이라면 코어 수, 컨텍스트 스위칭 비용, 작업 분할 방식에 따라 성능이 달라진다. 스레드는 성능을 높일 수 있는 도구이지, 무조건 처리량을 늘려 주는 마법은 아니다.

하지만 스레드는 편리한 만큼 위험하다. 같은 메모리를 공유하기 때문이다. 한 스레드가 데이터를 읽는 동안 다른 스레드가 그 데이터를 바꾸면, 실행 순서에 따라 결과가 달라질 수 있다. 동시성 버그가 어려운 이유가 여기에 있다. 코드는 분명 같지만, 스케줄링 순서가 매번 조금씩 달라져 어떤 날은 정상이고 어떤 날은 장애가 된다.

스레드와 동시성 제어AI 생성 이미지

프로세스와 스레드의 차이

프로세스와 스레드는 모두 실행 단위지만, 실무에서 판단해야 할 기준은 다르다.

첫째, 메모리 공유 방식이 다르다. 프로세스는 기본적으로 서로 독립된 주소 공간을 가진다. 데이터를 주고받으려면 파이프, 소켓, 공유 메모리 같은 별도 통신 방법이 필요하다. 반면 스레드는 같은 힙 메모리를 공유한다. 그래서 같은 객체나 전역 변수에 바로 접근할 수 있다. 이 장점은 곧 위험이 된다. 공유가 쉬울수록 잘못 수정할 가능성도 커진다.

둘째, 생성과 전환 비용이 다르다. 일반적으로 스레드는 프로세스보다 가볍다. 주소 공간을 새로 만들 필요가 없고, 이미 열린 자원도 함께 사용할 수 있다. 그래서 많은 서버 프로그램이 작업 단위로 스레드 풀을 사용한다. 단, 스레드도 공짜는 아니다. 스택 메모리를 차지하고, 너무 많아지면 컨텍스트 스위칭 비용이 증가한다. 스레드를 많이 만든다고 항상 처리량이 늘지는 않는다.

셋째, 장애 격리 수준이 다르다. 프로세스는 비교적 강한 격리를 제공한다. 한 워커 프로세스가 죽어도 마스터 프로세스가 새 워커를 띄우는 구조를 만들 수 있다. 반면 같은 프로세스 안의 스레드는 운명을 공유한다. 한 스레드가 심각한 메모리 오류를 일으키거나 프로세스를 종료시키면 전체 서비스가 영향을 받을 수 있다.

따라서 스레드는 빠른 공유와 낮은 비용이 필요할 때 적합하다. 반대로 강한 격리, 독립 배포, 장애 전파 차단이 중요하다면 프로세스나 별도 서비스로 나누는 편이 더 안전할 수 있다.

경쟁 상태는 어떻게 생기는가

경쟁 상태는 여러 실행 흐름이 같은 자원에 접근하고, 그 접근 순서에 따라 결과가 달라지는 상황이다. 대표적인 예는 카운터 증가다.

counter = counter + 1

이 한 줄은 보기에는 하나의 동작처럼 보인다. 하지만 CPU 수준에서는 보통 여러 단계로 나뉜다.

1. counter 값을 메모리에서 읽는다
2. 읽은 값에 1을 더한다
3. 계산한 값을 다시 counter에 저장한다

두 스레드가 동시에 counter를 1씩 증가시킨다고 해 보자. counter의 초기값은 10이다.

스레드 A: counter를 읽음 -> 10
스레드 B: counter를 읽음 -> 10
스레드 A: 1을 더함 -> 11
스레드 B: 1을 더함 -> 11
스레드 A: 저장 -> 11
스레드 B: 저장 -> 11

두 번 증가했으니 결과는 12가 되어야 한다. 하지만 실제 결과는 11이다. 두 스레드가 같은 값을 읽고 각각 계산한 뒤, 나중에 저장한 값이 앞선 저장을 덮어썼기 때문이다. 이런 현상을 갱신 손실이라고 부른다.

동시성 문제는 단순한 카운터에서만 생기지 않는다. 주문 재고 차감, 포인트 적립, 계좌 이체, 캐시 갱신, 작업 큐 처리처럼 공유 상태가 있는 곳이면 어디서든 발생할 수 있다. 특히 테스트에서 잘 드러나지 않는다. 실행 순서, CPU 개수, 부하, 네트워크 지연, 가비지 컬렉션 타이밍에 따라 문제가 나타났다 사라진다.

그래서 동시성 코드는 정상 동작 사례보다 실패 가능한 순서를 먼저 생각해야 한다. 이 코드가 동시에 두 번 실행되면 어떻게 되는가. 읽은 뒤 쓰기 전에 다른 스레드가 끼어들면 어떻게 되는가. 예외가 발생해 락이 풀리지 않으면 어떻게 되는가. 이런 질문이 동시성 설계의 출발점이다.

임계 구역과 락

공유 자원을 안전하게 다루려면 한 번에 하나의 실행 흐름만 접근해야 하는 구간을 정해야 한다. 이를 임계 구역이라고 한다. 예를 들어 counter를 읽고, 증가시키고, 저장하는 세 단계는 중간에 끊기면 안 된다. 이 구간 전체가 하나의 단위처럼 실행되어야 한다.

락은 임계 구역에 들어가기 전에 획득하고, 작업이 끝나면 반환하는 장치다. 락을 가진 스레드만 공유 자원에 접근할 수 있다. 다른 스레드는 락이 풀릴 때까지 기다린다.

lock 획득
counter = counter + 1
lock 반환

락을 사용할 때 가장 중요한 원칙은 범위를 작게 유지하는 것이다. 락을 잡은 상태에서 오래 걸리는 작업을 하면 다른 스레드가 모두 기다린다. 특히 네트워크 호출, 파일 I/O, 데이터베이스 쿼리처럼 지연 시간이 큰 작업을 락 안에서 실행하면 전체 처리량이 급격히 떨어질 수 있다.

또 하나의 원칙은 반드시 반환되게 만드는 것이다. 중간에 예외가 발생해 락을 반환하지 못하면 다른 스레드가 영원히 기다릴 수 있다. 많은 언어는 이를 막기 위해 finally, defer, using, synchronized 블록 같은 구조를 제공한다. 핵심은 락 획득과 반환의 짝이 깨지지 않게 하는 것이다.

뮤텍스, 세마포어, 조건 변수

동시성 제어 도구는 이름이 비슷하지만 목적이 조금씩 다르다.

뮤텍스는 상호 배제를 위한 락이다. 한 시점에 하나의 스레드만 임계 구역에 들어가게 한다. 공유 객체의 상태 변경, 전역 캐시 갱신, 카운터 증가처럼 동시에 실행되면 안 되는 구간을 보호할 때 사용한다. 뮤텍스의 핵심은 소유권이다. 락을 획득한 스레드가 작업을 끝내고 해제해야 한다.

세마포어는 동시에 접근 가능한 개수를 제한하는 도구다. 값이 1인 세마포어는 겉으로는 뮤텍스처럼 한 번에 하나만 통과시키는 데 사용할 수 있다. 다만 일반적인 뮤텍스처럼 "획득한 스레드가 해제한다"는 소유권 의미가 항상 강하게 붙어 있는 것은 아니다. 그래서 세마포어는 보통 제한된 자원의 수를 관리할 때 더 자연스럽다. 예를 들어 외부 API를 동시에 10개 요청까지만 보내고 싶다면 세마포어의 허용 개수를 10으로 둘 수 있다. 데이터베이스 커넥션 풀, 다운로드 동시 개수 제한, 작업 큐 소비자 수 제한 같은 곳에 자주 쓰인다.

조건 변수는 어떤 조건이 만족될 때까지 스레드를 기다리게 하는 도구다. 예를 들어 큐가 비어 있으면 소비자 스레드는 할 일이 없다. 계속 반복문을 돌며 확인하면 CPU를 낭비한다. 대신 조건 변수를 사용해 큐에 데이터가 들어올 때까지 잠들고, 생산자 스레드가 데이터를 넣은 뒤 깨울 수 있다.

조건 변수를 사용할 때는 보통 락과 함께 사용한다. 조건을 확인하는 일과 대기 상태로 들어가는 일이 안전하게 연결되어야 하기 때문이다. 여기서 중요한 점은 대기 중인 스레드가 락을 계속 붙잡고 있는 것이 아니라는 점이다. 조건 변수의 대기 연산은 보통 락을 원자적으로 내려놓고 잠든 뒤, 깨어날 때 다시 락을 획득한 상태로 돌아온다. 그래야 생산자 스레드가 같은 락을 잡고 큐에 데이터를 넣을 수 있다.

또한 깨어났다고 해서 조건이 반드시 참이라고 가정하면 안 된다. 여러 스레드가 동시에 깨어났거나, 깨어난 뒤 다른 스레드가 먼저 데이터를 가져갔을 수 있다. 일부 환경에서는 명확한 신호 없이 깨어나는 경우도 고려해야 한다. 그래서 조건은 if가 아니라 while로 다시 확인하는 방식이 안전하다.

lock 획득
while queue가 비어 있음:
    조건 변수에서 대기  # 대기하는 동안 lock은 풀리고, 깨어나면 다시 획득됨
데이터 꺼내기
lock 반환

이런 패턴은 처음에는 번거로워 보인다. 하지만 동시성 제어의 목적은 코드 줄 수를 줄이는 것이 아니라, 실행 순서가 바뀌어도 올바른 결과가 나오게 만드는 것이다.

데드락은 왜 발생하는가

데드락은 여러 스레드가 서로가 가진 자원을 기다리며 아무도 앞으로 나아가지 못하는 상태다. 가장 흔한 예는 두 개의 락을 서로 다른 순서로 잡는 경우다.

스레드 A: userLock 획득 -> orderLock 대기
스레드 B: orderLock 획득 -> userLock 대기

A는 orderLock을 기다리고, B는 userLock을 기다린다. 그런데 userLock은 A가 들고 있고, orderLock은 B가 들고 있다. 둘 다 상대가 락을 풀어야 진행할 수 있으므로 프로그램은 멈춘다.

데드락이 발생하려면 보통 네 가지 조건이 함께 만족된다. 첫째, 자원을 한 번에 하나의 실행 흐름만 사용할 수 있다. 둘째, 이미 자원을 가진 상태에서 다른 자원을 추가로 기다린다. 셋째, 다른 스레드가 가진 자원을 강제로 빼앗을 수 없다. 넷째, 자원을 기다리는 관계가 원형을 이룬다. 이 네 조건이 모두 모이면 데드락이 생길 수 있다.

실무에서 데드락은 애플리케이션 락에서만 생기지 않는다. 데이터베이스 트랜잭션에서도 자주 발생한다. 예를 들어 한 요청은 A 계좌를 잠근 뒤 B 계좌를 갱신하고, 다른 요청은 B 계좌를 잠근 뒤 A 계좌를 갱신하면 서로 기다릴 수 있다. 파일 잠금, 분산 락, 메시지 처리 순서에서도 같은 문제가 생긴다.

데드락을 줄이는 방법

가장 현실적인 예방책은 락 획득 순서를 고정하는 것이다. 여러 락을 잡아야 한다면 항상 같은 순서로 잡는다. 예를 들어 계좌 이체에서는 계좌 ID가 작은 것부터 잠그는 규칙을 둘 수 있다. 그러면 원형 대기 조건이 깨진다.

두 번째는 락을 잡은 채로 다른 오래 걸리는 작업을 하지 않는 것이다. 락 안에서 외부 API를 호출하거나 사용자 입력을 기다리면 위험하다. 대기 시간이 길어질수록 다른 스레드가 막힐 가능성이 커지고, 장애가 전파된다.

세 번째는 타임아웃과 실패 처리를 두는 것이다. 락을 영원히 기다리지 않고 일정 시간이 지나면 포기하거나 재시도한다. 데이터베이스 데드락처럼 시스템이 탐지해 한쪽 트랜잭션을 중단시키는 경우도 있다. 이때 애플리케이션은 실패를 예외로만 보지 말고, 안전한 재시도 전략을 가져야 한다.

네 번째는 공유 상태 자체를 줄이는 것이다. 불변 객체를 사용하거나, 작업 단위별로 데이터를 분리하거나, 메시지 큐를 통해 한 스레드가 특정 상태를 전담하게 만들 수 있다. 모든 문제를 락으로 해결하려고 하면 코드가 복잡해지고 성능도 나빠진다. 때로는 구조를 바꾸는 것이 더 좋은 동시성 제어다.

개발자가 기억해야 할 기준

스레드는 성능을 높이는 도구이지만, 자동으로 안전한 병렬 처리를 보장하지 않는다. 공유 메모리를 사용하는 순간 실행 순서가 프로그램의 일부가 된다. 그리고 그 순서는 개발자가 직접 제어하지 못한다.

따라서 동시성 코드를 작성할 때는 다음 기준을 먼저 확인해야 한다.

  • 어떤 데이터가 공유되는가

  • 동시에 읽기만 하는가, 쓰기도 하는가

  • 읽기와 쓰기가 하나의 원자적 동작으로 묶여야 하는가

  • 락의 범위가 너무 넓지는 않은가

  • 여러 락을 잡는 순서가 항상 같은가

  • 실패, 예외, 타임아웃 상황에서도 락이 해제되는가

동시성 문제는 재현하기 어렵다. 로그를 추가하면 타이밍이 바뀌어 버그가 사라지기도 한다. 로컬에서는 정상인데 운영 환경의 부하에서만 나타나기도 한다. 그래서 원리를 모른 채 증상만 따라가면 가장 고치기 어려운 버그가 된다.

좋은 동시성 설계는 스레드를 많이 쓰는 설계가 아니다. 공유할 필요가 없는 것은 공유하지 않고, 공유해야 한다면 접근 규칙을 명확히 정하며, 기다림이 필요한 곳에는 적절한 동기화 도구를 사용하는 설계다. 스레드의 목적은 일을 동시에 실행하는 것이고, 동시성 제어의 목적은 그 결과가 항상 올바르게 유지되도록 만드는 것이다.

책 목록

개발자가 알아야 할 컴퓨터 과학 지식 정리

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기