이 글은 유데미 강의 ‘Java 멀티스레딩, 병행성 및 성능 최적화 - 전문가 되기’ 의 쿠폰을 받아 수강하고 작성한 글입니다.
멀티스레딩?
하나의 프로세스는 하나 이상의 스레드를 가질 수 있다. 프로그램들은 때에 따라 단일 스레드로 동작하기도, 여러 스레드로 동작하기도 한다.
멀티스레딩이란, 말 그대로 하나의 프로세스가 여러 개의 스레드를 사용하도록 프로그램을 작성하는 프로그래밍 기법이다.
이번 글에서는 멀티스레딩에 대해 아래와 같은 점들을 살펴본다.
- 단일 스레드 프로그램의 단점
- 멀티스레딩의 이점
- 멀티스레딩을 도입할 때 유의할 점
단일 스레드와 멀티 스레드
프로세스를 카페, 스레드를 점원에 비유해 점원이 한 명인 카페와 여러 명인 카페의 운영 방식을 상상해보자.
점원이 한 명인 카페의 문제점
먼저 한 명의 점원이 주문 받기, 메뉴 제작 및 서빙을 모두 진행한다고 하자. 여러 개의 주문이 들어온다면, 각 주문은 한 번에 하나씩 차례대로 처리될 것이다.
주문이 적을 때에는 큰 문제가 느껴지지 않을 수도 있다. 하지만 거의 동시에 20개의 주문이 들어왔다고 가정해보자. 마지막 손님은 주문한 메뉴를 받기 위해 아주 많은 시간을 기다려야 할 것이다. 중간에 주문이 계속 들어온다면 점원이 주문을 받기 위해서도 시간을 소요하게 되므로 기다려야 할 시간은 더 지체될 수도 있다.
프로세스의 스레드 또한 그러하다. 하나의 스레드로 이루어진 프로그램은 한 번에 하나의 일만 할 수 있으므로, 나머지 작업들은 제 순번을 기다린다. 내부적으로 작업을 대기 큐에 저장하거나 하는 일이 아니라면 작업이 유실될 수도 있으며, 타임아웃 등에 의해 제대로 처리되지 않을 수 있다. (20번째 주문을 한 손님이 기다리다 못해 주문을 취소하고 카페를 나가는 것처럼 말이다.)
위와 같은 문제가 발생한다면, 누구나 가장 쉬운 해결책으로 ‘점원을 더 고용하기’를 떠올릴 것이다. 점원이 여러 명 존재한다면 누군가가 주문을 받는 동안 나머지는 음료를 계속 만들 수도 있고, 여러 메뉴가 동시에 만들어질 수도 있기 때문이다.
점원을 더 뽑으면 전부 해결될까?
하지만 사실 점원을 늘려 주문 처리 시간을 개선하기 위해서는 단순히 점원을 늘리는 것 이외에도 더 많은 요소가 고려되어야 한다.
먼저 적정 점원 수를 고려해야 한다. 점원이 1명일 때보다는 3명일 때 (대부분의 경우에는) 주문 처리 속도가 빨라지겠지만, 3명일 때보다 30명일 때 처리 속도가 10배로 늘어나지는 않을 테니 말이다.
이와 같이 스레드의 개수도 적정 개수 이상으로 늘리면 오히려 프로그램의 성능이 저하될 수 있다.
또한, 여러 점원이 함께 수행할 수 없는 일이 있다는 것을 인지해야 한다. 결제를 할 수 있는 단말기가 하나라 한 번에 한 주문만 받을 수 있다면, 여러 점원이 결제 처리를 담당해봤자 효율을 개선할 수 없을 것이다. 몇 명 이상의 인원이 함께 수행할 수 없는 일이 있을 수도 있다. 커피 머신이 3개라면 3명까지의 점원만이 동시에 커피를 내릴 수 있게 된다.
이제 위에서 설명한 예시들을 머릿속에 잘 담아두고, 이를 프로세스와 스레드의 관점에서 다시 한 번 살펴보자.
멀티스레드와 임계 영역 (Critical Section)
public class CoffeeMachine {
private int coffeeBean;
public CoffeeMachine(int coffeeBean) {
this.coffeeBean = coffeeBean;
}
public void makeCoffee() {
coffeeBean--;
System.out.println("커피 완성! 남은 원두량: " + coffeeBean);
}
}
위와 같은 커피 머신 클래스가 있다고 하자. 이 클래스의 인스턴스를 만들어, 여러 스레드에서 makeCoffee를 마구 호출하도록 코드를 작성한다면 어떻게 될까? 결과는 다음과 같다.
우리는 원두량이 1씩 줄기를 기대했지만 그렇지 않았다. 그 이유는, makeCoffee 메스드가 원자적으로 동작하지 않기 때문이다.
coffeeBean - -
연산을 풀어 써보면, coffeeBean = coffeeBean - 1
임은 모두 알 것이다. 사실 이 연산은 한 line이지만 2개의 연산을 포함하고 있다. 바로 coffeeBean에서 1을 빼는 뺄셈 연산과 coffeeBean 변수에 새 값을 할당하는 대입 연산이다.
2개의 연산이 원자적으로 이루어지지 않으면, 위와 같이 원하지 않았던 연산 결과를 만나게 될 수 있다. 이와 같이 공유 자원에 대한 원자성을 보장해야 하는 영역을 임계 영역(Critical Section) 이라고 한다.
멀티스레딩의 핵심은 임계 영역 내 연산의 원자성을 안정적으로 보장하되 원자성을 보장하느라 너무 많은 효율 저하를 일으키지 않도록 하는 것이다. Java에서는, 지금까지 이러한 멀티스레딩 효율성을 위해 새 릴리즈를 발표할 때마다 여러 도구를 도입해왔다.
아래에서는 이러한 도구들을 간략하게 소개한다.
Java에서 멀티스레딩을 지원하기 위해 도입된 도구들
Thread
Thread thread = new Thread(() -> System.out.println("Hello, I'm " + Thread.currentThread()));
스레드 그 자체를 의미하는 클래스로, 프로그램 실행 시 기본적으로 생성되는 Main 스레드 이외의 스레드를 만들어 작업을 할당할 수 있다.
public Thread(Runnable runnable) {
this(null, runnable, newName(), null, true);
}
Thread 생성자의 인자 Runnable은 함수형 인터페이스이기 때문에, Java 8에서 도입된 람다식을 통해 스레드가 수행할 동작을 간단하게 정의할 수 있다.
Synchronized
임계 영역에 대해 한 번에 하나의 스레드만 접근을 허용하도록 하는 키워드이다.
위에서 소개한 CoffeeMachine 클래스의 makeCoffee 메서드를 아래와 같이 변경해보자.
public synchronized void makeCoffee() {
coffeeBean--;
System.out.println("커피 완성! 남은 원두량: " + coffeeBean);
}
그리고 다시 여러 스레드가 이 메서드를 실행하도록 하면 결과가 아래와 같다.
이번엔 뺄셈 연산과 대입 연산이 원자적으로 수행되었기 때문에, 원하는 연산 결과를 확인할 수 있다.
Volatile
synchronized가 임계 영역의 원자성을 보장하는 키워드라면, volatile은 공유 자원의 값을 메모리에 최신화함으로써 항상 유효값으로 보장하기 위한 키워드이다.
private volatile int value = 5;
volatile로 선언된 변수는 그 값이 최신화될 때 다른 스레드가 모두 이 사실을 인지할 수 있도록 만든다.
synchronized가 여러 스레드가 공유 자원을 변경/조회할 때 필요하다면, volatile은 여러 스레드에서 공유 자원을 읽지만 공유 자원을 변경시키는 것은 단일 스레드일 때 사용하면 synchronized에 비해서는 효율적 처리를 보장할 수 있다.
ReentrantLock
위에서 소개한 synchronized을 사용하여 임계 영역에 대한 동시 접근을 막는다면, 다른 스레드가 임계 영역을 실행 중인 동안 임계 영역에 접근하고자 하는 다른 스레드는 모두 무한정 대기하게 된다.
여기서 중요한 포인트는 무한정 대기
이다. 경우에 따라서는 임계 영역에 접근할 수 없는 경우 접근을 포기하거나 다른 일을 먼저 할 수도 있어야 하는데, synchronized만으로는 그런 유연한 처리가 어렵다.
이 경우 ReentrantLock의 tryLock을 사용하면 가능하다. makeCoffee를 아래와 같이 변경하고 실행해도 원자성을 보장한 결과를 얻을 수 있다.
public void makeCoffee() {
try {
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
coffeeBean--;
System.out.println("커피 완성! 남은 원두량: " + coffeeBean);
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
System.err.println(e.getMessage());
}
}
또한, ReentrantLock은 읽기락/쓰기락을 별도로 지원한다. 이들을 잘 사용하면 필요한 최소한의 락을 걸어 락이 야기하는 효율 저하를 일부 개선할 수 있다.
Semaphore
위에서 살펴본 Lock이 단일 스레드만이 임계 영역에 접근할 수 있는 도구였다면, Semaphore는 정해진 개수만큼의 스레드가 임계 영역에 접근할 수 있도록 하는 도구이다. 위의 카페 예시에서 설명한 것처럼 커피 머신이 3개가 있는 카페가 있다면 이 카페에서는 동시에 3명의 점원이 커피를 내릴 수 있는 것과 같다.
Semaphore는 signal을 기준으로 동작한다. Semaphore는 이 signal을 통해 임계 영역에 접근하기 위해 대기(wait) 중인 스레드를 깨우는(notify) 방식으로 이용할 수 있다.
Concurrent Collections
위의 ReentrantLock, Semaphore 등의 클래스는 모두 java.util.concurrent
패키지에 속하는 클래스들이다. 해당 유틸 패키지는 다양한 동시성 관련 클래스를 제공하는데, 여기에는 우리가 기본적으로 사용하는 Collections 프레임워크를 Lock을 통해 스레드 안정성을 보장하도록 한 클래스들이 존재한다.
Atomic Class (Lock-Free)
위의 락, 세마포어와 같은 도구들은 모두 결국 락을 기반으로 동작한다. 즉, 임계 영역의 원자성 보장을 위해 blocking
을 수행한다.
blocking은 어쩔 수 없이 성능 저하를 가져오기 때문에, 효율적 멀티스레딩을 위한 더 나은 대안은 결국 Non-blocking
으로 문제를 해결하는 것이다. 이들 중 대표적 사례로, CAS(Compare And Set) 기반으로 동작하는 concurrent 패키지 내의 Atomic Class가 있다.
Virtual Thread (Java 21)
위에서 다룬 도구들이 임계 영역의 안정성을 위한 것들이었다면, Virtual Thread는 스레드의 비용 자체의 효율성을 위해 도입된 기법이다.
기존 스레드는 OS 스레드와 1:1로 대응되는 스레드로, 생성 비용이 매우 크고 너무 많은 개수를 생성할 수 없었다. 때문에 적절한 개수의 스레드를 고려하여 스레드 풀을 생성하고, 스레드 간 문맥 전환(Context Switching)이 너무 자주 일어나지 않도록 고려해야 한다는 점에서 멀티스레딩의 난이도가 더 올라간 것도 있다.
Java 21에서 도입된 Virtual Thread를 이용하면 OS 스레드에 비해 훨씬 가벼운, OS 스레드와 1:N 관계를 갖는 가상의 스레드를 운용할 수 있다. 가상 스레드는 생성 비용이 무시 가능할 만큼 작고 아주 많은 개수를 생성해도 부담이 없으며, 문맥 전환 비용 또한 아주 낮다. 앞으로 Virtual Thread의 사용이 보편화된다면 훨씬 더 저비용으로 멀티스레드 프로그램을 운영할 수 있게 될 것이다.
멀티스레딩을 효율적으로 학습하기 좋은 강의
이 글에서는 멀티스레딩의 필요성, Java에서 멀티스레딩을 위해 지원하는 기능들에 대해 아주 간략히 살펴봤다.
만약 이 글을 읽고 멀티스레딩에 관심이 생겼거나, 멀티스레딩을 본격적으로 도입하고자 자료를 찾던 중 이 글을 읽은 사람이 있다면, 아래와 같은 유데미의 멀티스레딩 강의를 추천한다.
【한글자막】 Java 멀티스레딩, 병행성 및 성능 최적화 - 전문가 되기
이 강의는 본 글보다 훨씬 더 스레드와 멀티스레딩 도구에 대해 자세히 설명하고 있으며, 성능 측정을 위해 스펙에 따라 적절한 스레드 개수를 결정하는 방법에 대해서도 소개하고 있다. 이를 통해, 멀티스레딩에 입문하는 사람들로 하여금 멀티스레딩을 잘못 운용하여 오히려 성능 저하를 일으키지 않도록 바람직한 가이드라인을 제시한다.