비동기 작업 관리의 필요성
아래와 같은 상황에 대해서 생각해보자
- 클라이언트의 요청을 받으면, 하나의 작업을 종료 요청 전까지 실행한다.
- 클라이언트는 여러명 존재하고, 한 번에 여러개의 작업을 요청할 수 있다.
- 클라이언트의 작업은 실시간으로 1초에 1회씩 클라이언트에게 데이터를 전달한다.
이러한 상황에서 여러개의 프로세스를 효과적으로 실행하기 위해서는 각 작업을 비동기적으로 관리해야 한다.
Java의 Executor 인터페이스와 그 구현체를 사용한다면 여러 작업들을 간단하게 관리할 수 있다.
Executor
Executor
Executor는 JDK 1.5부터 지원하기 시작한 concurrent 패키지의 인터페이스이다.
해당 인터페이스는 사용자 정의 스레드 풀, 비동기 IO 등의 유연한 스레드 작업을 정의하기 위해서 사용한다.
Executors
Executors는 다양한 Executor 생성 팩토리를 제공한다.
ExecutorService
ExecutorService는 비동기 작업을 위한 큐, 스케쥴링, 종료를 관리하는 인터페이스이다.
주로 여러 병렬 작업들을 관리하기 위해 사용한다
ScheduledExecutorService
ScheduledExecutorService는 작업을 지연 및 주기적으로 실행하기 위해서 사용한다.
Callable과 Runnable
Callable
Callable은 결과를 반환 가능한 비동기 작업으로 정의한다.
결과를 반환할 수 없는 Runnable과 대조되는 부분이다.
Runnable
Runnable은 Callable과 마찬가지로 비동기 작업을 정의하기 위해 사용한다.
하지만 결과를 반환할 수 없다는 부분에서 Callable과 차이가 있다.
Future
Future은 비동기 작업의 결과를 반환하고, 작업 완료 여부 확인 및 작업을 취소할 수 있는 인터페이스이다.
비동기 계산의 결과가 아직 계산되지 않았다는 것을 표현할 수 있다.
RunnableFuture
run 메서드 실행 시 결과를 설정하는 Future 인터페이스이다
Runnable과 Future을 결합한 인터페이스로 Runnable처럼 비동기 실행히 가능하고 결과를 추후 get을 통해서 얻을 수 있다.
ExecutorService로 비동기 작업 관리
ExecutorService를 통해 비동기 작업을 생성 및 실행하는 방법에 대해서 알아본다
비동기 작업의 생성
concurrent 패키지의 여러 기능들을 사용하면 상황에 따른 다양한 ExecutorService를 생성할 수 있다.
하지만 이번에는 Executors를 통해서 ExecutorService를 생성하는 방법에 대해 알아볼 것이다.
class Main{
public static void main(String[] args){
ExecutorService fixedThreadExecutor = Executors.newFixedThreadPool(5);
ExecutorService cachedThreadExecutor = Executors.newCachedThreadPool();
}
}
위 코드에서는 Executors를 통해 두 가지 방법으로 ExecutorService를 생성했다.
- newFixedThreadPool
- 미리 구성된 n개의 스레드 풀을 사용하여 비동기 작업을 관리한다.
- 장점
- n개의 스레드를 미리 준비하기에 스레드 생성 오버헤드가 거의 없다
- 메모리 계산이 쉽고 적절한 개수의 스레드를 생성해두면 효과적으로 사용 가능하다
- 단점
- 작업의 수가 스레드보다 많아지면 작업이 쌓여서 처리량이 낮아진다
- 풀의 스레드 수보다 작업이 적으면 메모리가 낭비된다
- newCachedThreadPool
- 스레드의 수를 가변적으로 결정하고, 유휴 스레드 관리를 통해 스레드를 재사용한다.
- 장점
- keepAliveTime ( default = 60초 ) 안에 동일한 작업이 들어오면 스레드를 재사용
- 스레드의 수를 가변적으로 결정하기에 여러개의 작업을 처리 가능
- 단점
- 작업이 몰리면 스레드 생성 / 파괴 오버헤드가 급증한다.
- 컴퓨팅 자원의 사용량을 예측하기 어렵다
비동기 작업의 실행 및 추적
아래와 같이 submit 메서드를 통해 비동기 작업을 추적할 수 있다.
class Main{
public static void main(String[] args){
int a = 5;
ExecutorService executor = Executors.newCachedThreadPool();
Future<Integer> future = executor.submit(() -> {
return a+10;
});
System.out.println(future.get());
}
}
submit()은 작업을 비동기적으로 실행하고 Future를 통해서 결과를 추적할 수 있다.
또한, Future.isDone()을 통해서 결과의 완료 여부도 확인할 수 있다.
비동기 작업의 종료
비동기 작업의 필요성
ExecutorService는 비동기 작업을 효과적으로 종료 및 정리할 수 있는 방법들을 제시한다.
그런데, 어차피 비동기 작업이 끝나면 작업을 정리할 필요가 없지 않을까라는 생각이 들 수 있다.
하지만, 스레드와 메모리 누수 문제를 예방하기 위해서는 비동기 작업의 명시적 종료과 필수적이다.
기본적으로 비동기 작업이 종료되면, 아래 두 ExecutorService는 다음과 같이 동작한다.
- newFixedThreadPool
- 스레드를 계속 열어두고 작업을 기다림
- newCachedThreadPool
- keepAliveTime동안 스레드를 유지하고, 스레드를 정리함
즉 두 방법 모두 스레드를 즉시 정리하지 않고 대기한다는 특징이 있다.
newCachedThreadPool은 좀 덜하지만, 종료하지 않으면 이는 심각한 자원 누수로 이어질 수 있다.
따라서 작업이 끝나더라도 명시적 종료를 통해 ExecutorService를 정상적으로 종료시켜아한다.
비동기 작업 명시적으로 종료하기
ExecutorService는 대표적으로 아래 두 가지 방법을 제시한다.
- shutdown : 작업이 완료될 때까지 기다리고, 완료된 후에 종료
- shutdownNow : 작업을 즉시 강제 종료
이를 예제 코드로 확인한다면 아래와 같다.
class Main{
public static void main(String[] args){
int a = 5;
ExecutorService executor = Executors.newCachedThreadPool();
Future<Integer> future = executor.submit(() -> {
return a+10;
});
System.out.println(future.get());
executor.shutdown();
}
}
이처럼 비동기 작업을 명시적으로 종료해야 메모리 누수 없이 자원을 관리할 수 있다.
마무리
회사에서 프로젝트를 진행하며 약간의 메모리 누수를 관측했고, 해당 원인을 찾으며 공부한 내용을 정리했다.
필자는 ExecutorService를 명시적으로 종료하지 않았기에 발생한 문제로 추정하고 있다.
기존에 Spring Boot의 기능만을 사용할 때는 구현을 우선으로 했고 배포는 염두에 두지 않았다.
하지만 배포를 염두에 두고 자원을 정리하면서 Java에서 제공하는 여러 기능들을 사용하게 되는 것 같다.
앞으로도 현재 진행하는 프로젝트의 질을 향상시키기 위한 다양한 방법에 대해서 알아볼 예정이다.
'CS 및 기본 개념' 카테고리의 다른 글
| [cs] HTTP 버전별 특징과 차이점 (4) | 2025.07.31 |
|---|---|
| [ NGINX ] NGINX를 리버스 프록시로 사용하는 WebSocket 시스템 구축 (6) | 2025.07.30 |
| [ AWS ] CDN (CloudFront) 으로 S3 정적 파일 관리하기 (3) | 2025.07.23 |
| [ CS ] 네트워크 주요 포트번호와 그 사용처 (3) | 2025.07.22 |
| [ Java ]Java 버전 간 차이점 (3) | 2025.07.07 |