8 min read

자바 병렬 프로그래밍 - 6장 작업 실행

스레드에서 작업 실행

흔히 사용하는 작업이라는 단위는 수행할 로직을 특정 범위를 가지며 수행하는 일종의 단위이다. 그래서 이러한 작업을 스케쥴링하거나 분산 시키려면 해당 작업이 전체 로직 내에서 충분히 작은 단위로 구성되어야한다.

서버 어플리케이션에서의 작업의 단위는 클라이언트의 요청 하나를 처리하는것을 하나의 작업이라고 볼 수 있다. 이러한 클라이언트의 개별 요청 단위를 작업으로 지정하면 작업간의 독립성을 보장하며 크기도 적절하게 설정된 것이라 할 수 있다.

그러면 작업을 순차적으로 실행하는 경우를 살펴보자. 아래의 코드처럼 단일 스레드를 통해 요청되는 작업들을 순차적으로 실행하는 경우이다.

public class SingleThreadWebServer {
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while (true) {
            Socket connection = socket.accept();
            handleRequest(connection);
        }
    }

    private static void handleRequest(Socket connection) {
        // request-handling logic here
    }
}

위 클래스는 아주 단순하고 이론적으로는 잘못된 부분이 없다. 다만 한번에 하나의 요청만을 처리할 수 있기 때문에 프로덕션 환경에서는 성능이 매우 안 좋을 것을 예상할 수 있다. 메인 스레드는 소켓 연결을 기다리고 요청이 오면 이를 처리한다. 만약 웹서버가 이전 요청을 처리중이라면 현재 들어온 요청의 경우 이전 작업이 끝날 때까지 기다려야한다. 즉, 다른 요청을 전혀 처리하지 못한다는 문제가 발생한다. 실제로 이런 문제를 가지는 구조는 웹 서버로 제대로 활용될 수 없다.

위와 같은 구조의 문제점을 해결하기 위해 하나의 요청이 들어올 때마다 새로운 스레드를 만드는 구조로 변경해보자.

public class ThreadPerTaskWebServer {
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while (true) {
            final Socket connection = socket.accept();
            Runnable task = new Runnable() {
                public void run() {
                    handleRequest(connection);
                }
            };
            new Thread(task).start();
        }
    }

    private static void handleRequest(Socket connection) {
        // request-handling logic here
    }
}

앞선 에제와 달리 메인 스레드에서 요청을 기다리고 이를 처리해주는 작업을 별도의 스레드에게 요청이 생길때마다 맡긴다. 즉, 메인스레드는 직접 요청을 처리하지 않고 매번 새로운 스레드를 만드는 역할을 수행한다. 이러한 구조는 동시에 여러 작업을 병렬로 처리할 수 있게 된다. 다만 요청을 전송하는 속도에 비해 요청을 처리해 응답을 넘겨주는 속도가 빨라야 한다는 제약이 있다.

매 요청마다 스레드를 생성하게 하면 프로덕션에서는 스레드의 비용 때문에 사용할 수 없다. 특히 실행 중인 스레드는 메모리를 소모한다. 그리고 메모리는 소모하면서 idle인 상태로만 존재한다면 리소스 낭비로 이어진다. 또한 GC에 부하가 늘어나며 스레드간의 CPU를 점유하기 위한 경쟁도 증가하게 된다. 이러한 문제점들을 해결하기 위해서 제한된 수의 스레드만으로 동작하게 하는것이 좋다.

Executor 프레임워크

스레드 풀을 통해 스레드를 관리할 수 있고 이로 하여금 메모리를 조절하여 작업을 할당 시킬 수 있다. java에서는 Executor 프레임워크의 일부분으로 이러한 스레드 풀을 편리하게 사용할 수 있는 api를 제공한다. 앞서 살펴본 직접 스레드를 생성하는 코드들보다 훨씬 추상화가 잘 되어서 사용이 편리하다.

이러한 Executor 인터페이스는 작업 등록과 작업 실행을 분리하는 표준적인 방법이고 각 작업은 Runnable을 구현하여 정의한다. 앞선 포스팅에서 살펴본 프로듀서-컨슈머 패턴에 기반하여 내부적인 스레드풀을 구현한다.

앞서서 살펴본 웹서버 예제 또한 Executor를 통해서 간단하게 구현 가능하다. 풀 내에서 100개의 스레드를 가지도록 한다.

public class TaskExecutionWebServer {
    private static final int NTHREADS = 100;
    private static final Executor exec
            = Executors.newFixedThreadPool(NTHREADS);

    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while (true) {
            final Socket connection = socket.accept();
            Runnable task = new Runnable() {
                public void run() {
                    handleRequest(connection);
                }
            };
            exec.execute(task);
        }
    }

    private static void handleRequest(Socket connection) {
        // request-handling logic here
    }
}

위와 같이 작업을 등록하는 로직과 이를 실행하는 로직을 분리 시키면 실행 정책을 쉽게 변경할 수 있다는 장점이 있다. 실행 정책이라 함은 어느 스레드가 해당 작업은 수행하는지, 작업을 어떤 순서로 실행할지, 최대 몇개의 작업을 큐에 넣어둘지 등의 작업을 수행하는데 필요한 전반적인 의사결정을 얘기한다. 이러한 실행 정책은 일종의 자원관리에도 도움을 준다. 예를 들어 가장 최적화된 실행 정책을 설정하려면 자바 프로세스가 구동되는 장비의 상태, WAS에 유입되는 트래픽의 수들을 고려하여 실행 정책을 설정한다. 그러다 보면 자연스레 시스템 리소스들에 대한 관리도 함께 수행된다.

스레드 풀은 풀 내부에 존재하는 스레드들이 수행할 작업들을 보관해야 하기에 작업 큐를 활용한다. 작업을 수행하는 스레드는 큐에서 다음 작업을 꺼내오고 실행하고 다음 작업이 존재할 때까지 대기한다. 이러한 스레드 풀의 스레드 수를 적절히 잘 설정하면 리소스를 효율적으로 사용하고 프로세서가 쉬지 않고 연산을 처리할 수 있게 한다. Executors 에서는 아래와 같은 종류의 스레드풀들을 제공한다.

  • newFixedThreadPool
  • newCachedThreadPool
  • newSingleThreadExecutor
  • newScheduledThreadExecutor

Executor를 종료하는 일은 중요하다. 왜냐하면 JVM은 모든 스레드가 종료되기 전에는 종료하지 않고 대기하기 때문에 Executor를 제대로 종료시키지 않으면 JVM 프로세스가 종료되지 않는 경우가 있다. Executor는 비동기적으로 작업을 실행시키기에 정확히 어떤 시점에 수행 중인 작업들이 완료되는지 알기 어렵다. 이러한 Executor는 graceful하게 종료하거나 abrupt하게 종료하거나 선택할 수 있다.

ScheduledThreadExecutor를 사용하면 작업에 대해서 지연되게 또는 주기적으로 실행할 수 있도록 한다. 이와 대비되는 java의 Timer 클래스는 다음과 같은 단점이 존재한다. Timer 클래스는 등록된 작업을 실행시키는 스레드를 하나만 생성해 사용한다. 만약 Timer에 현재 등록되어 진행중인 작업의 시간이 너무 오래 걸린다면 다른 작업은 실행이 예정된 시각에 진행되지 않을 수 있다. 이를 해결 하기 위해 ScheduledThreadExecutor에서는 각각의 지연작업과 주기적 작업마다 여러개의 스레드를 할당하여 각자의 실행 예상 시간을 맞춰주는 기능을 제공한다.

병렬로 처리할 만한 작업

Executor를 사용하려면 실행하려는 작업을 항상 Runnable 인터페이스에 맞춰 구현해야한다. Runnable의 경우 작업의 결과를 리턴 받지 못하는 형태로 작업을 수행한다. 반면 Callable과 Future를 사용하면 작업의 결과가 나올 때까지 대기시킬 수 있다. Callable의 추상 메서드인 call()을 실행하고 나면 그 작업의 결과값을 돌려받을 수 있으며 Exception 또한 발생시킬 수 있다. Executor에는 이와 같은 Callable, Runnable 뿐만 아니라 PrivilegedAction 등의 타입의 작업 또한 실행 시킬 수 있다.

Future는 특정 작업이 정상적으로 완료되었는지 아니면 작업이 취소되었는지 등에 대한 정보를 확인할 수 있도록 설계된 인터페이스다. Future의 중요한 특징은 한번 진행된 작업을 상태는 되돌릴 수 없게 구현된다는 점이다. 예를 들어, 한번 완료가 된 작업을 영원히 완료상태에 머누르게 된다. Future의 get() 메서드는 위와 같은 현재 작업의 상태에 따라 다르게 동작한다. 만약 작업이 완료 상태에 들어가 있으면 즉시 해당 작업의 결과를 리턴하거나 예외를 발생시킨다. 하지만 현재 작업이 진행 중이라면 해당 작업이 완료될 때까지 블로킹 되어 대기한다. 만약 대기하던 중간에 작업이 취소되면 CacellationException이 발생한다.