본문으로 건너뛰기
홈
기술
기술 전체
프로그래밍68
컴퓨터 과학63
AI48
웹 개발36
인프라33
데이터31
소프트웨어 공학18
소개
← 목록으로프로그래밍 › Java › 동시성

스레드

목차

스레드와 공유 상태

서버가 여러 요청을 받거나 프로그램이 파일을 읽는 동안 다른 일을 해야 할 때 스레드를 사용한다. 스레드는 한 프로세스 안에서 실행되는 작업 흐름이며, 같은 프로세스의 객체를 공유할 수 있다. 이 공유가 편리한 만큼, 여러 스레드가 같은 값을 바꾸면 실행 순서에 따라 결과가 달라진다.

  • 동시성(concurrency): 여러 작업의 진행 구간이 겹치는 성질이다. 한 코어에서 번갈아 실행해도 동시적일 수 있다.
  • 병렬성(parallelism): 여러 작업이 실제로 같은 순간에 실행되는 성질이다. 여러 코어에서 실행하는 경우가 대표적이다.

즉, 동시성은 작업을 함께 다루는 구조, 병렬성은 같은 시각에 실행되는 상태를 설명한다. 여러 스레드를 만들었다고 언제나 더 빨라지는 것은 아니다. 작업량, 코어 수, 대기 시간, 락 경쟁에 따라 결과가 달라진다.

sequenceDiagram
    participant A as 스레드 A
    participant S as 공유 카운터
    participant B as 스레드 B
    A->>S: 값 읽기 (0)
    B->>S: 값 읽기 (0)
    A->>S: 1 저장
    B->>S: 1 저장
    Note over S: 두 번 증가했지만 결과는 1

count++는 한 번에 끝나는 연산처럼 보이지만 실제로는 읽기 → 계산 → 쓰기가 필요하다. 두 스레드가 같은 옛값을 읽으면 한 번의 증가가 사라진다. 이것이 경쟁 조건(race condition)이다.

동기화(Synchronized)

한 번에 한 스레드만 실행해야 하는 코드를 임계 영역(critical section)이라고 한다. Java의 synchronized는 지정한 객체의 모니터 락을 사용해 같은 락을 잡는 스레드끼리 임계 영역을 동시에 실행하지 못하게 한다.

final class Counter {
    private int value;

    public synchronized void increment() {
        value++;
    }

    public synchronized int get() {
        return value;
    }
}

Counter counter = new Counter();
Runnable task = () -> {
    for (int i = 0; i < 1_000; i++) {
        counter.increment();
    }
};

Thread first = new Thread(task);
Thread second = new Thread(task);
first.start();
second.start();
first.join();
second.join();

System.out.println(counter.get()); // 두 작업이 끝난 뒤 2000

두 메서드가 같은 counter 인스턴스의 락을 사용한다. increment()의 읽기·수정·쓰기가 다른 increment()와 겹치지 않는다. get()도 같은 락을 사용하므로 값을 읽을 때 필요한 가시성을 확보한다. join()은 두 스레드가 끝나기를 기다리는 코드이며, 없으면 메인 스레드가 작업 중간의 값을 출력할 수 있다. 이 예제 코드는 메서드나 main처럼 InterruptedException을 처리할 수 있는 문맥 안에서 실행해야 한다.

synchronized가 객체 전체를 다른 스레드에서 사용하지 못하게 만드는 것은 아니다. 같은 모니터 락을 사용하는 코드만 서로 배제된다. 다른 락을 사용하거나 동기화 없이 공유 필드에 접근하는 코드가 있으면 여전히 문제가 생길 수 있다.

락을 줄이는 방법

가장 단순한 방법은 가능한 한 변경 가능한 상태를 공유하지 않는 것이다. 각 스레드가 독립된 값을 계산한 뒤 결과만 합치면 공유 카운터 자체가 필요하지 않을 수 있다. 카운터처럼 하나의 값에 대한 원자적 증가만 필요하다면 AtomicInteger 같은 원자적 타입도 선택지다. 여러 값이 함께 만족해야 하는 규칙이 있다면 한 연산 단위로 동기화해야 한다.

import java.util.concurrent.atomic.AtomicInteger;

AtomicInteger completed = new AtomicInteger();
completed.incrementAndGet();
int snapshot = completed.get();

단, 원자적 변수를 여러 개 사용한다고 그 변수들을 묶은 비즈니스 규칙까지 자동으로 원자적이 되지는 않는다. 예를 들어 잔액을 확인하고 차감하는 두 단계는 하나의 보호된 연산이어야 한다.

실수하기 쉬운 지점

  • 락을 잡은 채 네트워크 호출이나 오래 걸리는 작업을 하면 다른 스레드가 긴 시간 기다릴 수 있다.
  • 서로 다른 순서로 락 두 개를 잡으면 두 스레드가 상대의 락을 기다리는 교착 상태(deadlock)가 생길 수 있다.
  • sleep()으로 실행 순서를 맞추려 하면 실행 환경에 따라 다시 깨진다. 완료 대기에는 join()이나 목적에 맞는 동시성 도구를 사용한다.
  • 공유 데이터를 읽기만 하는 코드도 쓰기 코드와 같은 동기화 규칙을 따르는지 확인해야 한다.

스레드 문제를 볼 때는 먼저 어떤 값이 공유되는지, 누가 수정하는지, 어떤 연산까지 한 덩어리여야 하는지 표시해보면 동기화 범위를 정하기 쉽다.

같은 카테고리의 글