Java Thread Pool 동작 원리

주싱·2023년 3월 13일
12

더 나은 도구

목록 보기
6/13
post-custom-banner

몇 년 전까지만 해도 직접 쓰레드를 생성하고 해제 해야하는 책임을 가진 환경에서 주로 개발을 했습니다. 자바 언어로 넘어오며 쓰레드 풀이라는 개념을 접하게 되었는데, 사용 상의 문제는 없었지만 내부 동작을 모르니 어딘지 모르게 찝찝한 부분이 있었습니다. 그 동안 바쁘다는 핑계로 충분히 학습하지 못하고 사용한 것 같아 쓰레드 풀(Thread Pool)의 동작 원리에 대해 조금 더 깊이 학습해 보려고 합니다. 이왕이면 테스트 코드도 작성하고 동작을 하나씩 직접 확인해 보도록 하겠습니다.

값 비싼 쓰레드

자바의 쓰레드는 운영체제의 자원 중 하나인 쓰레드에 대응됩니다. 따라서 쓰레드는 생성부터 실행되고 종료되기 까지 운영체제의 관리를 필요로 합니다. 그래서 운영 비용이 비싼 자원 중 하나라고 할 수 있습니다.

쓰레드 재활용

자바에서 제공하는 쓰레드 풀(Thread Pool)을 활용하면 병렬 작업이 필요할 때 마다 (값 비싼) 쓰레드를 생성하고 해제하는 대신 쓰레드 풀에 작업을 요청하는 방식으로 손쉽게 프로그램을 작성할 수 있습니다. 쓰레드 풀의 핵심 목표는 작업이 필요할 때 마다 쓰레드를 생성하는 대신 최대한 쓰레드를 재활용하여 생성과 해제 그리고 쓰레드 전환(Context Switch) 비용을 최대한 줄이는 것입니다.

동작 원리

이 글(Introduction to Thread Pools in Java)을 읽고 큰 개념을 잡을 수 있었고, 공개되어 있는 ThreadPoolExecutor 클래스 코드를 확인하며 더 정확히 이해할 수 있었습니다. 쓰레드 풀의 동작 원리는 간단히 이랬습니다.

  1. 쓰레드 풀은 처음에는 일정 개수(coreThreadPool)까지 작업을 위한 새로운 쓰레드를 생성합니다. 앞서 요청된 작업이 이미 완료되었더라도 새로운 작업에 새로운 작업 쓰레드를 생성해 신속히 coreThreadPool 개수 만큼의 쓰레드를 생성합니다.
  2. coreThreadPool 쓰레드는 작업이 완료되어도 쓰레드를 종료하지 않고 작업 큐에서 다음 요청을 계속해서 대기합니다.
  3. 이제 이후부터 요청된 작업은 새로운 쓰레드 생성 대신, 작업을 단지 큐에 추가합니다. 그러면 기존에 생성된 coreThreadPool 쓰레드들이 큐에서 작업을 꺼내어 요청된 작업을 처리합니다.
  4. 만약 새로운 작업 요청이 있을 때 작업 큐가 가득차 있다면 쓰레드 풀은 coreThreadPool 개수를 초과해서 최대 maximumThreadPool 개수까지 추가로 쓰레드를 생성하고 작업을 할당합니다.
  5. 다른점은 coreThreadPool 개수를 초과해 생성된 쓰레드는 작업 완료 후 설정된 시간(keepAliveTime) 만큼만 다음 요청을 대기한 후 유휴(Idle) 상태가 지속되면 실행을 종료한다는 사실입니다.

이와 같은 방법으로 쓰레드 풀은 비싼 쓰레드 자원을 최대한 재활용하여 효율적으로 시스템이 운영될 수 있도록 돕습니다.

주의할 점

위 동작 원리를 보면 몇 가지 주의해야 할 상황들이 보입니다. 먼저 coreThreadPool 개수의 쓰레드가 생성된 이후부터는 작업이 큐에 추가된다는 사실을 주목해야 합니다. 큐에 추가된 작업은 기존에 생성된 쓰레드에서 작업을 처리하는데 오랜 시간이 걸린다면 지연이 발생할 수 밖에 없습니다. 또는 쓰레드 풀의 원리를 전혀 이해하지 못하면 다음과 같은 코드를 작성할 수도 있습니다. 예를 들면 coreThreadPool을 n개로 설정하고, 무한 루프를 도는 이벤트 루프 같은 작업 n개를 시작한 후에 추가로 n+1 개의 다른 작업을 요청하는 실수를 할 수도 있습니다. n개 이후부터의 작업은 영원히 실행되지 않을 것입니다.

테스트 코드로 확인하기

이제 테스트 코드를 작성해서 쓰레드 풀이 정말 이해한 원리대로 동작하는지 확인해 보겠습니다.

전체 테스트 코드는 GitHub에서 확인할 수 있습니다.

1. 테스트 코드 구조

테스트 코드는 쓰레드 풀을 생성하고 몇 개의 작업을 쓰레드 풀에 요청하며 쓰레드 풀의 동작과 상태 변화를 확인합니다. 이를 위해 각 단계를 메서드로 분리하고 순서대로 실행되도록 설정합니다. 또한 각 메서드에서 쓰레드 풀의 상태를 공유하기 위해 클래스 단위로 테스트 인스턴스가 생성되도록 합니다.

@TestInstance(Lifecycle.PER_CLASS) // 테스트 인스턴스를 클래스 단위로 생성하도록 설정
@TestMethodOrder(OrderAnnotation.class) // 테스트 실행 순서 설정
public class ThreadPoolExecutorTest {

    @Test
    @Order(0) // 메서드 실행 순서 지정
    void createLazyThreadPool() {
        ...
    }

    @Test
    @Order(1)
    void createFirstThread() {
				...
    }

2. 테스트 준비

테스트에서 공유해서 사용할 Callable 타입의 heavyTask를 생성합니다. heavyTask는 1초간 실행되는 무거운 작업을 시뮬레이션하기 위해 단지 1초간 sleep하는 동작만 수행합니다.

public class ThreadPoolExecutorTest {
	// 1초간 실행되는 무거운 작업 시뮬레이션
    Callable<Boolean> heavyTask;
		...

	@BeforeAll
    void setUp() {
        // 1초간 실행되는 무거운 작업 시뮬레이션
        heavyTask = () -> {
            Thread.sleep(1000);
            return true;
        };
				...
    }

3. 쓰레드 풀 생성 (Lazy)

이제 쓰레드 풀을 생성합니다. 쓰레드 풀 생성 시 테스트를 위해 coreThreadPool은 2, maximumThreadPool은 3, keepAliveTime은 1초, 그리고 내부 큐 용량은 1로 고정합니다. 쓰레드 풀은 생성될 때 미리 쓰레드를 생성하지 않고, 나중에 작업이 요청되는 시점에 게으른(Lazy)게 생성하는 특성을 가집니다.

	@Test
    @Order(0)
    void createLazyThreadPool() {
        final int corePoolSize = 2;
        final int maximumPoolSize = 3;
        final long keepAliveTime = 1L;
        final int queueCapacity = 1;

        threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime,
                TimeUnit.SECONDS, new LinkedBlockingQueue<>(queueCapacity));

        assertEquals(0, threadPool.getPoolSize());
        assertEquals(0, threadPool.getQueue().size());
    }

4. 첫 쓰레드 생성

그리고 첫번째 heavyTask 작업을 쓰레드 풀에 요청합니다. 그러면 쓰레드 풀의 첫 번째 쓰레드가 생성되고 요청된 작업이 할당됩니다.

	@Test
    @Order(1)
    void createFirstThread() {
        future1 = threadPool.submit(heavyTask);

        assertEquals(1, threadPool.getPoolSize());
        assertEquals(0, threadPool.getQueue().size());
    }

5. coreThreadPool 개수 만큼의 쓰레드 생성

연이어 두 번째 작업을 요청합니다. 쓰레드 풀에 아직 coreThreadPool 개수 만큼 쓰레드가 생성되지 않았기 때문에 작업을 위한 쓰레드가 추가로 생성됩니다. 이제 쓰레드 풀의 크기가 coreThreadPool 개수인 2가 됩니다.

	@Test
    @Order(2)
    void reachToCorePoolSize() throws InterruptedException {
        future2 = threadPool.submit(heavyTask);

        assertEquals(2, threadPool.getPoolSize());
        assertEquals(0, threadPool.getQueue().size());
    }

6. 작업 큐를 가득 채움

세 번째 작업을 요청하는 시점에 쓰레드 풀은 coreThreadPool 개수 만큼 쓰레드가 생성된 상태입니다. 따라서 이번 작업을 위한 새로운 쓰레드는 생성되지 않고 단지 큐에 작업이 추가됩니다. 쓰레드 풀의 크기는 여전히 2이고, 큐에 1개의 작업이 추가됩니다. 앞서 큐의 용량을 1로 했기 때문에 이 작업으로 큐가 가득찬 상태가 됩니다. 마지막에 확인하겠지만 이 세 번째 작업은 즉시 실행되지 못하고 큐에 쌓였다가 이전 작업이 실행된 후(약 1초 후) 차례대로 실행됩니다.

    @Test
    @Order(3)
    void reachToQueueCapacity() {
        future3 = threadPool.submit(heavyTask);

        assertEquals(2, threadPool.getPoolSize());
        assertEquals(1, threadPool.getQueue().size());
    }

7. maximumThreadPool 개수 만큼 확장

이어서 네 번째 작업을 요청하게 되면 쓰레드 풀은 coreThreadPool 개수만큼 쓰레드가 생성되었고 작업 큐도 가득차 있기 때문에 추가로 쓰레드를 생성해서 네 번째 작업을 할당합니다. 이제 쓰레드 풀 크기가 3이 되고 작업 큐에는 아직 처리되지 못한 1개의 작업(세 번째 요청 작업)이 남아 있습니다.

    @Test
    @Order(4)
    void reachToMaxPoolSize() {
        future4 = threadPool.submit(heavyTask);

        assertEquals(3, threadPool.getPoolSize());
        assertEquals(1, threadPool.getQueue().size());
    }

8. 추가 작업 요청 거부

앞선 작업으로 인해 쓰레드 풀은 maximumThreadPool 개수까지 쓰레드를 생성했습니다. 따라서 연이은 다섯 번째 작업 요청은 실행이 거부되고 예외가 발생합니다.

    @Test
    @Order(5)
    void overMaxPoolSize() {
        assertThrows(
                RejectedExecutionException.class,
                () -> threadPool.submit(heavyTask)
        );
    }

9. 실행 타이밍 및 결과 상태 확인

다섯 번째 작업까지 지연없이 연이서 요청을 수행하며 쓰레드 풀의 상태 변화를 확인했다면 이제 실제 작업들이 의도했던 대로 어떤 것들은 병렬적으로, 어떤 작업은 차례대로 수행되는지 확인해 보겠습니다. 첫 번째, 두 번째, 네 번째 작업은 쓰레드를 새롭게 생성하며 실행되었음으로 거의 동시에 병렬적으로 실행되고 약 1초 후에 완료됩니다. 반면에 세 번째 요청 작업은 큐에 추가되고 앞선 작업이 완료된 후에 차례되고 실행됩니다. 마지막으로 모든 작업이 완료된 후 keepAliveTime (1초)이 지난 후에는 추가적인 작업 요청이 없음으로 coreThreadPool 개수(2개)의 쓰레드만 남겨두고 남은 1개의 쓰레드는 해제된 것을 확인할 수 있습니다.

    @Test
    @Order(6)
    void result() {
        // 쓰레드에 직접 할당된 작업은 즉시 실행 됩니다.
        await().atLeast(900, TimeUnit.MILLISECONDS)
                .atMost(1100, TimeUnit.MILLISECONDS)
                .until(() ->
                        future1.isDone() &&
                        future2.isDone() &&
                        future4.isDone()
                );

        // 큐에 할당된 작업은 이전 작업이 완료된 후 순차적으로 실행됩니다.
        await().atLeast(900, TimeUnit.MILLISECONDS)
                .atMost(1100, TimeUnit.MILLISECONDS)
                .until(() ->
                        future3.isDone()
                );

        // 모든 작업 완료 후, keepAliveTime 동안 작업 요청이 없으면 쓰레드 풀의 쓰레드 개수는 기본치(coreThreadPool)로 유지됩니다.
        assertEquals(2, threadPool.getPoolSize());
        assertEquals(0, threadPool.getQueue().size());
    }

정리

여기까지 테스트 코드와 함께 쓰레드 풀의 동작 원리이 대해 학습해 보았습니다. 그 동안 사실 쓰레드 풀을 잘못 사용해서 문제를 겪은 적은 없지만 공부하다 보니 역시 잘 모르고 사용했다면 특별한 경우에 문제가 될 수 있는 여지들이 있음을 알게 되었습니다. 특별히 큐에 작업이 추가되면서 작업이 지연되는 상황을 주의해야 겠습니다. 좋은 시간이었습니다. 감사합니다.

profile
소프트웨어 엔지니어, 일상
post-custom-banner

0개의 댓글