Post

자바 스레드

동시성 제어 기법 이해 및 스레드 풀(Tomcat) 최적화하기

자바 스레드

Thread

Process & Thread

프로세스(Process)는 실행 중인 하나의 프로그램으로, 운영체제로부터 메모리 공간을 할당 받아 독립적인 실행 환경을 가진다. 스레드(Thread)는 프로세스 내의 실질적인 작업을 하는, 가장 작은 작업 실행 단위이다. JVM(Java Virtual Machine)은 주로 하나의 프로세스로 실행되며, 스레드는 JVM에 의해 관리된다. 멀티스레딩(Multi-Threading)은 동시성를 관리하는 기법으로, 스레드는 프로세스의 메모리 자원(힙 영역 등)을 공유해 스레드 간 데이터 교환이 효율적이다. 그러나 여러 스레드가 자원에 동시 접근하며 동시성 문제가 발생할 수 있다는 문제가 있다.

동시성(Concurrency)은 여러 작업을 짧은 시간 간격으로 번갈아 가며 처리(Context Switching)하여 논리적으로 동시에 실행되는 것처럼 보이게 하는 개념이다. 이는 단일 코어에서도 가능하며 여러 작업을 효율적으로 관리하는 데 중점을 둔다. 반면 병렬성(Parallelism)은 여러 개의 코어에서 여러 작업이 물리적으로 동시에 실행되는 것을 의미하며 멀티코어 환경에서만 가능하다. 멀티스레딩은 동시성을 관리하는 기법이며 멀티코어 환경에서 이를 실행할 때 병렬성을 통해 성능을 극대화할 수 있다.


자바 스레드 생성

자바에서 스레드를 생성하는 방법은 크게 두 가지로 나뉜다. 첫 번째는 Thread 클래스를 직접 상속하는 방식이고, 두 번째는 Runnable 인터페이스를 구현하는 방식이다. Thread를 상속하면 클래스 자체가 스레드의 속성을 가지게 되지만, Runnable을 구현하면 실행할 작업을 별도의 객체로 정의하고 이를 Thread 인스턴스에 전달하여 실행시킨다. Runnable 방식은 Thread를 직접 상속받을 때 자바가 다중 상속을 지원하지 않아 생기는 문제를 해결할 수 있고, 설계 관점에서는 작업(Task)과 실행(Thread)의 역할을 분리할 수 있다.

Thread 클래스 상속

1
2
3
4
5
6
7
8
9
10
11
12
// 1. Thread 클래스 상속한 MyThread 클래스 정의
class MyThread extends Thread {
		// 2. run() 메서드를 오버라이드 및 스레드 코드 작성
    public void run() {
        System.out.println("Thread 상속으로 실행");
    }
}

// 3. Thread 객체 생성
MyThread myThread = new MyThread();
// 4. start() 메서드로 스레드 시작
myThread.start();

Runnable 인터페이스 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. Runnable 인터페이스를 구현하는 클래스 정의
class MyRunnable implements Runnable {
		// 2. run() 메서드를 오버라이드 및 스레드 코드 작성
    public void run() {
        System.out.println("Runnable 구현으로 실행");
    }
}

// 3. Runnable 객체 생성
MyRunnable myRunnable = new MyRunnable();
// 4. Thread 객체 생성
Thread thread = new Thread(myRunnable);
// 5. start() 메서드로 스레드 시작
thread.start();

멀티스레딩의 문제

경쟁 조건(Race Condition)

멀티스레드 환경에서는 논리적으로 완벽해 보이는 코드도 특정 실행 타이밍에 따라 예기치 않은 결과가 발생할 수 있다. 여러 스레드가 하나의 공유 변수를 동시에 수정하려 할 때 발생하는 문제를 경쟁 조건(Race Condition)이라고 한다. 예를 들어 아래의 UnsafeCounter 클래스는 여러 스레드가 동시에 calculate() 메서드를 호출하면 sum의 최종값이 예상과 다르게 나온다.

1
2
3
4
5
6
7
8
9
class UnsafeCounter {
    private int sum = 0;

    public void calculate() {
        // 이 연산은 원자적이지 않다.
        sum = sum + 1;
    }
    public int getSum() { return sum; }
}

sum = sum + 1 연산은 실제로는 ‘값을 읽고(read)’, ‘1을 더하고(modify)’, ‘결과를 쓰는(write)’ 세 단계로 이루어진다. 이 과정이 하나의 단위로 묶여있지 않기 때문에, 한 스레드가 값을 읽어 연산하는 사이 다른 스레드가 끼어들어 이전 값을 덮어쓰면서 업데이트가 누락되는 문제가 발생하는 것이다.


경쟁 조건 해결 전략

경쟁 조건을 해결하고 데이터의 정합성을 보장하기 위한 크게 두 가지 전략이 있다. 첫 번째는 synchronized를 이용해 임계 구역 설정하는 것이고, 두 번째는 락 없이 원자적 연산으로 동기화하는 방식이다.

가장 고전적인 방법은 synchronized 키워드를 사용하여 임계 구역(Critical Section)을 설정하는 것이다. synchronized는 특정 메서드나 코드 블록에 락을 설정하여 한 번에 단 하나의 스레드만 해당 영역에 접근하도록 강제한다. 이를 통해 여러 단계의 연산을 원자적으로 실행할 수 있어 정확한 계산 결과를 보장한다.

1
2
3
4
5
6
7
8
class SafeCounter {
    private int sum = 0;

    public synchronized void calculate() {
        sum = sum + 1;
    }
    public int getSum() { return sum; }
}

한편 java.util.concurrent.atomic 패키지synchronized와 같은 락을 사용하지 않고도 동시성 문제를 해결할 수 있는 클래스들을 제공한다. 대표적으로 AtomicInteger는 내부적으로 CPU의 CAS(Compare-And-Swap) 명령어를 활용하여 원자적 연산을 보장한다. 락 경합이 발생하지 않으므로 synchronized보다 더 나은 성능을 보이는 경우가 많다.

1
2
3
4
5
6
7
8
9
10
11
import java.util.concurrent.atomic.AtomicInteger;

class AtomicCounter {
    private AtomicInteger sum = new AtomicInteger(0);

    public void calculate() {
        // incrementAndGet은 값을 1 증가시키고 가져오는 원자적 연산이다.
        sum.incrementAndGet();
    }
    public int getSum() { return sum.get(); }
}

동시성 컬렉션 활용

java.util.concurrent 패키지멀티스레드 환경에서 안전하게 사용할 수 있는 다양한 컬렉션(Thread-Safe Collections)을 제공한다. 그중 CopyOnWriteArrayList는 “쓸 때 복사(Copy-on-Write)” 전략을 사용하는 리스트이다. 읽기 작업 시에는 아무런 잠금 없이 기존 데이터를 매우 빠르게 조회한다. 하지만 addremove 같은 쓰기 작업이 발생하면 내부 데이터 배열의 전체 복사본을 만든 후 그 복사본을 수정하고 마지막으로 내부 포인터를 새 배열로 교체한다. 이 방식은 쓰기 비용이 비싸지만 읽기 작업이 쓰기 작업보다 압도적으로 많은 경우 잠금 없이 매우 효율적인 읽기 성능을 제공한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public class UserServlet {
    // ArrayList 대신 CopyOnWriteArrayList 사용
    private final List<User> users = new CopyOnWriteArrayList<>();

    public void join(final User user) {
        // addIfAbsent와 같은 원자적 메서드를 사용하면 '확인 후 추가' 문제를 해결할 수 있다.
        // (CopyOnWriteArrayList에는 없지만 ConcurrentHashMap 등에 존재)
        // 아래 로직은 여전히 경쟁 조건에 노출될 수 있다.
        if (!users.contains(user)) {
            users.add(user);
        }
    }
}

읽기/쓰기 성능 최적화

읽기 작업은 데이터를 변경하지 않으므로 여러 스레드가 동시에 수행해도 안전하지만 쓰기 작업은 단 하나의 스레드만 수행해야 한다. ReadWriteLock은 이러한 읽기/쓰기 패턴을 명확히 구분하여 동기화 효율을 높이는 방식이다. 이 락은 읽기 잠금(Read Lock)과 쓰기 잠금(Write Lock) 두 개로 분리된다. 읽기 잠금은 여러 스레드가 동시에 획득할 수 있는 공유 잠금이며, 쓰기 잠금은 단 하나의 스레드만 획득할 수 있는 독점 잠금이다. 이를 통해 쓰기 작업이 없을 때는 여러 스레드가 동시에 데이터를 읽을 수 있어 읽기 성능이 크게 향상된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

class ReadWriteData {
    private String data;
    private final ReadWriteLock lock = new ReentrantReadWriteLock();

    public String read() {
        lock.readLock().lock(); // 읽기 락 획득
        try {
            return data;
        } finally {
            lock.readLock().unlock(); // 읽기 락 해제
        }
    }

    public void write(String newData) {
        lock.writeLock().lock(); // 쓰기 락 획득
        try {
            this.data = newData;
        } finally {
            lock.writeLock().unlock(); // 쓰기 락 해제
        }
    }
}

Thread Pool

스레드를 매번 생성하고 파괴하는 것은 상당한 비용이 들기 때문에 일반적으로 실제 애플리케이션에서는 스레드를 미리 생성해두고 재활용하는 스레드 풀(Thread Pool)을 사용한다. Executors 유틸리티 클래스를 통해 다양한 종류의 스레드 풀을 생성할 수 있으며 대표적으로 고정된 자원을 활용하는 FixedThreadPool방식, 그리고 유연하게 자원을 조절하할 수 있는 CachedThreadPool 방식이 있다.

FixedThreadPool은 이름처럼 고정된 개수의 스레드를 유지하는 스레드 풀이다. 처리 가능한 스레드 수를 초과하는 작업이 들어오면, 내부의 BlockingQueue에 해당 작업이 저장되어 순서를 기다린다. 이는 시스템의 자원 사용량을 예측 가능하게 제어할 수 있어, 안정성이 중요한 작업에 적합하다.

1
2
3
// 2개의 스레드를 가진 고정 스레드 풀 생성
ExecutorService fixedPool = Executors.newFixedThreadPool(2);
fixedPool.submit(() -> { /* 작업 내용 */ });

한편 CachedThreadPool은 작업 요청에 따라 스레드 개수를 동적으로 조절한다. 유휴 상태의 스레드가 있으면 재사용하고, 없으면 새로 생성한다. SynchronousQueue를 사용하여 작업을 큐에 저장하지 않고 즉시 스레드에 전달하는 특징이 있다. 응답성이 중요하고 짧게 끝나는 작업이 불규칙적으로 발생하는 환경에 유용하지만 요청이 폭주할 경우 스레드 수가 과도하게 증가하여 시스템 장애를 유발할 수 있다.

1
2
3
// 동적으로 스레드를 관리하는 캐시 스레드 풀 생성
ExecutorService cachedPool = Executors.newCachedThreadPool();
cachedPool.submit(() -> { /* 작업 내용 */ });

ExecutorsnewFixedThreadPool이나 newCachedThreadPool은 편리하지만 실제로 사용할 때는 주의가 필요하다. newFixedThreadPool은 기본적으로 작업 큐의 크기가 무한대(Integer.MAX_VALUE)로 설정되어 있어 메모리 부족(OOM)을 유발할 수 있고, newCachedThreadPool은 스레드 수가 거의 무제한으로 증가할 수 있어 시스템 자원을 고갈시킬 수 있다.

따라서 대용량 트래픽을 처리하는 환경에서는 코어 스레드 수, 최대 스레드 수, 큐의 종류와 크기, 그리고 작업 거부 정책(RejectedExecutionHandler)까지 명시적으로 설정한 ThreadPoolExecutor를 직접 생성하여 사용하는 것이 중요하다. 또한 여러 인스턴스로 구성된 분산 환경에서는 자바의 synchronized만으로는 데이터 정합성을 보장할 수 없으므로, 데이터베이스의 트랜잭션이나 비관적/낙관적 락 또는 Redis 등을 이용한 분산 락을 반드시 사용해야 한다.


경쟁 조건 재현 및 해결

동시성 버그는 특정 타이밍 조건이 만족될 때만 발생하여 발견하고 재현하기 어렵다는 특징이 있다.

‘확인 후 실행(Check-Then-Act)’ 사례

사용자 가입 로직에서 리스트에 중복된 사용자가 있는지 확인(check)하고, 없으면 추가(add)하는 ‘확인 후 실행’ 패턴은 대표적인 경쟁 조건 발생 구간이다. 아래 코드에서 join 메서드는 여러 스레드가 동시에 호출할 경우 스레드에 안전하지 않다.

1
2
3
4
5
6
7
8
9
10
public class UserServlet {
    private final List<User> users = new ArrayList<>();

    // 이 메서드는 스레드에 안전하지 않다.
    private void join(final User user) {
        if (!users.contains(user)) {  // 1단계: 확인(check)
            users.add(user);          // 2단계: 추가(add)
        }
    }
}

한 스레드가 contains 검사를 통과한 직후 다른 스레드가 끼어들어 동일한 검사를 통과할 수 있다. 결국 두 스레드 모두 add 로직을 실행하여 리스트에 중복 데이터가 추가된다. 실제 운영 환경에서는 서버 부하, 네트워크 지연, GC 등이 이러한 타이밍을 유발할 수 있다.

경쟁 조건 해결

이 문제를 해결하기 위해서는 ‘확인 후 실행’ 과정을 하나의 원자적인 단위로 만들어야 한다. 가장 직접적인 방법은 synchronized 블록으로 해당 로직을 감싸서 한 번에 하나의 스레드만 접근하도록 하는 것이다. 또는 java.util.concurrent 패키지에서 제공하는 스레드 안전(Thread-safe) 컬렉션을 사용할 수도 있다. 예를 들어 CopyOnWriteArrayList는 데이터 변경 시 내부 배열을 복사하는 방식으로 동작하여 읽기 성능을 극대화하는 특징을 가진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public class ThreadSafeUserServlet {
    private final List<User> users = new CopyOnWriteArrayList<>();

    private void join(final User user) {
        // '확인 후 실행'을 원자적으로 만들기 위해 synchronized 블록 사용
        synchronized(users) {
            if (!users.contains(user)) {
                users.add(user);
            }
        }
    }
}

Spring WAS Thread Pool

Tomcat Thread Pool 파라미터

스레드와 동시성 개념은 Spring Boot에 내장된 WAS, Tomcat에서 집약적으로 사용된다. Tomcat은 들어오는 모든 HTTP 요청을 내부 스레드 풀의 스레드에 할당하여 처리한다. Tomcat의 동작 방식을 제어하는 핵심 파라미터는 application.yml 파일에서 설정할 수 있다. 이 값들은 서버가 과부하로 인해 장애가 발생하는 것을 방지하므로 중요하다.

1
2
3
4
5
6
server:
  tomcat:
    threads:
      max: 200 # 실제 요청을 처리하는 최대 스레드 수
    max-connections: 8192 # 서버가 동시에 유지할 수 있는 최대 연결 수
    accept-count: 100 # 모든 스레드가 사용 중일 때 대기 큐의 크기
  • threads.max: 작업 스레드의 최대 개수

    실제 HTTP 요청을 처리하는 작업 스레드(Worker Thread)의 최대 개수를 의미한다. 아무리 많은 요청이 동시에 들어와도 실제로 서버 내부에서 동시에 처리되는 작업의 수는 이 값을 초과할 수 없다.

  • max-connections: 최대 동시 연결 수

    서버가 동시에 수립하고 유지할 수 있는 총 TCP 연결의 수를 지정한다. 요청을 처리 중인 연결과 처리는 끝났지만 연결은 유지하고 있는 Keep-Alive 상태의 연결이 모두 포함된다. 이 한도를 초과하는 연결 요청은 대기 상태가 된다.

  • accept-count: 연결 대기 큐의 크기

    max-connections 한도까지 연결이 모두 꽉 찬 상태에서 추가로 들어오는 연결 요청을 운영체제 수준에서 대기시킬 수 있는 큐의 크기를 의미한다. 이 큐마저 가득 차면 서버는 새로운 연결 요청을 거부하게 된다.

요청 처리 흐름

아래 설정을 예시로 10개의 동시 요청이 들어왔을 때 서버는 다음과 같이 단계별로 동작한다.

1
2
3
4
5
6
7
server:
  tomcat:
    threads:
      min-spare: 2
      max: 5
    max-connections: 5
    accept-count: 5
  • 1단계: 서버 시작
    • min-spare: 2 설정에 따라 Tomcat은 즉시 2개의 작업 스레드를 미리 생성하여 대기시킨다 (예: nio-8080-exec-1, nio-8080-exec-2).
  • 2단계: 첫 5개의 요청 처리 (1-5번째 스레드)
    • 요청 1, 2: 대기 중인 스레드에 즉시 할당되어 처리를 시작한다 (예: nio-8080-exec-1, nio-8080-exec-2).
    • 요청 3, 4, 5: 이어서 도착하는 3-5번째 요청에 대해서는 스레드 풀에 여유 스레드가 없으므로, threads.max: 5 한도에 도달할 때까지 3개의 스레드를 새로 생성하여 할당한다 (예: nio-8080-exec-3, nio-8080-exec-4, nio-8080-exec-5). 이 시점에서 서버는 5개의 연결(max-connections)을 모두 사용하고 5개의 스레드(threads.max)가 모두 작업 중인 최대 부하 상태가 된다.
  • 3단계: 다음 5개의 요청 처리 (6~10번째 스레드)
    • 요청 6, 7, 8, 9, 10: 이제 6-10번째 요청이 들어오면, max-connectionsthreads.max가 모두 꽉 찼기 때문에 즉시 처리될 수 없다. 이 5개의 연결 요청은 OS 레벨의 연결 대기 큐(backlog)에 쌓이게 된다. 이 때 accept-count: 5 설정에 따라 최대 5개의 연결 요청까지 대기할 수 있으며 그 이상의 연결 요청은 거절된다.
  • 4단계: 작업 완료 및 큐 처리
    • 그 후 먼저 작업을 시작했던 스레드(예: exec-1)가 일을 끝내고 다시 스레드 풀의 대기 상태로 돌아가면 그 스레드는 즉시 연결 대기 큐(accept-count 큐)에 있던 다음 요청(6번째 요청)을 가져와 처리하기 시작한다. 이 과정이 반복되면서 10개의 모든 요청은 결국 5개의 스레드에 의해 순차적으로 모두 처리된다.

스레드풀 최적화

스레드풀 관련 설정값들을 무조건 높게 설정한다고 효율적이지 않다. 각 설정값을 과도하게 높이면 오히려 성능 저하와 자원 고갈을 유발할 수 있다. threads.max를 너무 높게 설정하면 스레드 간의 잦은 컨텍스트 스위칭 비용이 증가하고 스레드마다 할당되는 메모리로 인해 자원이 고갈될 수 있다. max-connections가 과도하면 불필요하게 많은 소켓 자원과 메모리를 낭비하게 되며 accept-count를 너무 크게 잡으면 클라이언트 입장에서는 응답이 없는 상태로 계속 기다리다 타임아웃이 발생할 수 있다. 이는 서버의 근본적인 처리 성능 문제를 감추는 역효과를 낳기도 한다. 따라서 실제로는 트래픽 패턴에 기반한 부하 테스트를 통해 서비스에 맞는 최적값을 찾는 과정을 반드시 거쳐야한다.

This post is licensed under CC BY 4.0 by the author.