멀티스레드와 동시성

Ch12. 스레드 풀과 Executor 프레임워크 - 스레드를 직접 사용할 때의 문제

webmaster 2024. 9. 25. 23:01
728x90

실무에서 스레드를 직접 생성해서 사용하면 다음과 같은 3가지 문제가 있다.

  • 스레드 생성 시간으로 인한 성능 문제
  • 스레드 관리 문제
  • Runnable 인터페이스의 불편함

스레드 생성 시간으로 인한 성능 문제

스레드를 사용하려면 먼저 스레드를 생성해야 한다. 그런데 스레드는 다음과 같은 이유로 매우 무겁다.

  • 메모리 할당
    •  각 스레드는 자신만의 호출 스택(call stack)을 가지고 있어야 한다. 이 호출 스택은 스레드가 실행되는 동안 사용하는 메모리 공간이다. 따라서 스레드를 생성할 때는 이 호출 스택을 위한 메모리를 할당해야 한다.
  • 운영체제 자원 사용
    • 스레드를 생성하는 작업은 운영체제 커널 수준에서 이루어지며, 시스템 콜(system call)을 통해 처리된다. 이는 CPU와 메모리 리소스를 소모하는 작업이다.
  • 운영체제 스케줄러 설정
    • 새로운 스레드가 생성되면 운영체제의 스케줄러는 이 스레드를 관리하고 실행 순서를 조정해야 한다. 이는 운영체제의 스케줄링 알고리즘에 따라 추가적인 오버헤드가 발생할 수 있다.
  • 참고로 스레드 하나는 보통 1MB 이상의 메모리를 사용한다.

스레드를 생성하는 작업은 상대적으로 무겁다. 단순히 자바 객체를 하나 생성하는 것과는 비교할 수 없을 정도로 큰 작업이다. 예를 들어서 어떤 작업 하나를 수행할 때마다 스레드를 각각 생성하고 실행한다면, 스레드의 생성 비용 때문에, 이미 많은 시간이 소모된다. 아주 가벼운 작업이라면, 작업의 실행 시간보다 스레드의 생성 시간이 더 오래 걸릴 수도 있다. 이런 문제를 해결하려면 생성한 스레드를 재사용하는 방법을 고려할 있다. 스레드를 재사용하면 처음 생성할 때를 제외하고는 생성을 위한 시간이 들지 않는다. 따라서 스레드가 아주 빠르게 작업을 수행할 있다.

 

스레드 관리 문제

서버의 CPU, 메모리 자원은 한정되어 있기 때문에, 스레드는 무한하게 만들 수 없다. 예를 들어서, 사용자의 주문을 처리하는 서비스라고 가정하자. 그리고 사용자의 주문이 들어올 때마다 스레드를 만들어서 요청을 처리한다고 가정하겠다. 서비스 마케팅을 위해 선착순 할인 이벤트를 진행한다고 가정해 보자. 그러면 사용자가 갑자기 몰려들 수 있다. 평소 동시에 100개 정도의 스레드면 충분했는데, 갑자기 10000개의 스레드가 필요한 상황 이 된다면 CPU, 메모리 자원이 버티지 못할 것이다.

이런 문제를 해결하려면 우리 시스템이 버틸 수 있는, 최대 스레드의 수까지만 스레드를 생성할 수 있게 관리해야 한다. 또한 이런 문제도 있다. 예를 들어 애플리케이션을 종료한다고 가정해보자. 이때 안전한 종료를 위해 실행 중인 스레드가 남은 작업은 모두 수행한 다음에 프로그램을 종료하고 싶다거나, 또는 급하게 종료해야 해서 인터럽트 등의 신호를 주고 스레드를 종료하고 싶다고 가정해 보자. 이런 경우에도 스레드가 어딘가에 관리가 되어있어야 한다.

 

Runnable 인터페이스의 불편함

public interface Runnable {
     void run();
}
  • 반환 값이 없다: run() 메서드는 반환 값을 가지지 않는다. 따라서 실행 결과를 얻기 위해서는 별도의 메커니즘을 사용해야 한다. 쉽게 이야기해서 스레드의 실행 결과를 직접 받을 수 없다. 앞에서 공부한 SumTask의 예를 생각해 보자. 스레드가 실행한 결과를 멤버 변수에 넣어두고, join() 등을 사용해서 스레드가 종료되길 기다린 다음에 멤버 변수에 보관한 값을 받아야 한다.
  • 예외 처리: run() 메서드는 체크 예외(checked exception)를 던질 수 없다. 체크 예외의 처리는 메서드 내부에서 처리해야 한다.

이런 문제를 해결하려면 반환 값도 받을 있고, 예외도 쉽게 처리할 있는 방법이 필요하다. 추가로 반환 값 뿐만 아니라 해당 스레드에서 발생한 예외도 받을 수 있다면 더 좋을 것이다.

 

해결책

스레드 생성 시간으로 인한 성능 문제, 스레드 관리 문제를 해결하기 위해서는 스레드를 생성하고 관리하는 풀(Pool)이 필요하다.

스레드 풀 - 1

  • 스레드를 관리하는 스레드 풀(스레드가 모여서 대기하는 수영장 풀 같은 개념)에 스레드를 미리 필요한 만큼 만들어둔다.
  • 스레드는 스레드 풀에서 대기하며 쉰다.
  • 작업 요청이 온다.

스레드 풀 - 2

  • 스레드 풀에서 이미 만들어진 스레드를 하나 조회한다.
  • 조회한 스레드 1로 작업을 처리한다.

스레드 풀 - 3

  • 스레드 1은 작업을 완료한다.
  • 작업을 완료한 스레드는 종료하는 게 아니라, 다시 스레드 풀에 반납한다. 스레드 1은 이후에 다시 재사용될 수 있다.

이렇게 스레드 풀이라는 개념을 사용하면 스레드를 재사용할 수 있어서, 재사용 시 스레드의 생성 시간을 절약할 수 있다. 그리고 스레드 풀에서 스레드가 관리되기 때문에 필요한 만큼만 스레드를 만들 수 있고, 또 관리할 수 있다.

 

사실 스레드 풀이라는 것이 별것이 아니다. 그냥 컬렉션에 스레드를 보관하고 재사용할 수 있게 하면 된다. 하지만 스레드 풀에 있는 스레드는 처리할 작업이 없다면, 대기("WAITING") 상태로 관리해야 하고, 작업 요청이 오면 "RUNNABLE" 상태로 변경해야 한다. 막상 구현하려고 하면 생각보다 매우 복잡하다는 사실을 알게 될 것이다. 여기에 생산자 소비자 문제까지 겹친다. 잘 생각해 보면 어떤 생산자가 작업(task)을 만들 것이고, 우리의 스레드 풀에 있는 스레드가 소비자가 되는 것이다.

 

이런 문제를 한방에 해결해 주는 것이 바로 자바가 제공하는 Executor 프레임워크다. Executor 프레임워크는 스레드 풀, 스레드 관리, Runnable의 문제점은 물론이고, 생산자 소비자 문제까지 한방에 해결해 주는 자바 멀티스레드 최고의 도구이다. 지금까지 우리가 배운 멀티스레드 기술의 총집합이 여기에 들어있다. 

728x90