⬛ Graceful Shutdown
어느 날, 개발자는 서버(피자 가게)를 업데이트하려고 한다. 그런데 문제는 주문 처리 중에 서버가 멈추면 고객이 화를 낼 거다! 😱
그래서 개발자는 생각한다.
개발자: "새로운 주문을 막고, 이미 받은 주문은 모두 끝낸 후에 서버를 꺼야 해!"
→ 여기서 등장한 것이 우아한 종료(Graceful Shutdown)!
🟢 ExecutorService 종료 메서드
- shutdown():
- 새로운 작업은 받지 않지만, 진행 중인 작업은 모두 완료 후에 종료.
- 논 블로킹 방식 (호출 후 바로 다음 코드 실행 가능).
- shutdownNow():
- 모든 작업을 중단하고 즉시 종료.
- 진행 중인 작업도 중단(인터럽트 발생).
- 블로킹 방식 (호출 후 작업이 중단될 때까지 기다림).
🟢 종료 상태 확인 메서드
- isShutdown():
- 서비스가 종료 상태인지 확인.
- isTerminated():
- 서비스가 종료되고, 모든 작업이 완료되었는지 확인.
🟢 작업 완료 대기 메서드
- awaitTermination(long timeout, TimeUnit unit):
- 모든 작업이 완료될 때까지 대기하지만, 지정된 시간(timeout)까지만 기다린다.
- 대기 중 인터럽트가 발생할 수 있음.
🟢 close()
- 자바 19부터 사용 가능.
- shutdown() 호출 후 하루가 지나도 작업이 완료되지 않으면 shutdownNow() 로 강제 종료.
⬛ Graceful Shutdown - 구현
우아한 종료를 구현해보자. 기본적으로 우아하게 종료하기 위해 기다리다가, 어쩔 수 없다면 강제 종료 하는 방식으로 접근한다.
우아한 종료의 단계는 다음과 같다.
- Step 1: shutdown() 호출
- 우선, shutdown()을 호출한다. 이제 새로운 주문은 받지 않겠다고 선언하는 것이다! 하지만, 이미 받은 주문은 계속 처리한다.
- Step 2: 작업 완료 기다리기 (awaitTermination())
- 하지만 모든 주문이 끝나려면 시간이 좀 걸릴 수 있다. 그래서 awaitTermination()을 사용한다. 예를 들어, 10초 동안 기다려본다.
- Step 3: 강제 종료 (shutdownNow())
- 만약 10초가 지나도 작업이 끝나지 않으면, 그때는 shutdownNow() 를 사용해서 강제로 종료한다! 진행 중인 작업도 멈추고 강제 퇴근시킨다.
- 직원들은 하던 일을 멈추고, 인터럽트 메시지를 받고 퇴장한다.
🔶 우아한 종료 구현코드
public class ExecutorShutdownMethod {
public static void main(String[] args) {
ExecutorService es = Executors.newFixedThreadPool(2);
es.execute(new RunnableTask("taskA"));
es.execute(new RunnableTask("taskB"));
es.execute(new RunnableTask("taskC"));
es.execute(new RunnableTask("longTask",100_000)); // 100초 대기
printState(es);
log("== shudown 시작 ==");
shutdownAndAwaitTermination(es);
log("== shudown 완료 ==");
printState(es);
}
static void shutdownAndAwaitTermination(ExecutorService es) {
es.shutdown(); // non-blocking, 새작업 안받음, 처리중 or 큐에 이미 대기중인 작업은 처리. 이후 풀의 스레드를 종료
try {
// 이미 대기중인 작업 모두 완료할 때 까지 10초 기다린다.
log("서비스 정상 종료 시도");
if(!es.awaitTermination(10, TimeUnit.SECONDS)) { // 블로킹 메서드 호출
// 정상 종료가 너무 오래걸리면... (10초안에 안끝나면)
log("서비스 정상 종료 실패 -> 강제 종료 시도");
es.shutdownNow(); // 강제 종료
// 작업이 취소될 때까지 대기 (인터럽트 자원 정리 대기)
if (!es.awaitTermination(10, TimeUnit.SECONDS)) {
log("서비스가 종료되지 않았습니다.");
}
}
} catch (InterruptedException ex) {
// awaitTermination()으로 대기중인 현재 스레드가 인터럽트 될 수 있다.
es.shutdownNow();
}
}
}
위 결과를 보면, longTask는 10초가 지나도 완료되지 않기 때문에 false를 반환한다.
⬛ Executor 스레드 풀 관리
간단한 만화를 통해 스레드 풀 관리법을 알아보자.
마이크의 피자 가게는 항상 주문이 넘쳐나고 있다. 하지만, 주문이 폭주하면 마이크는 직원(스레드)들을 어떻게 관리해야 할지 고민이 되었다.
- 마이크: "직원이 너무 적으면 주문이 쌓이고, 너무 많으면 자원이 낭비돼! 어떻게 하면 효율적으로 스레드를 관리할 수 있을까?"
빌은 마이크에게 스레드 풀 관리 전략을 알려주려고 온다! 🎩
- 빌: "걱정 마, 마이크! 스레드 풀을 적절하게 관리하는 방법이 있어. 필요한 만큼 직원(스레드)을 유지하면서 효율적으로 주문을 처리할 수 있지." 😎
빌은 마이크에게 스레드 풀을 어떻게 설정하는지 설명한다.
- 기본 스레드와 최대 스레드:
- 빌: "우선, 기본 스레드 수(corePoolSize)를 설정해. 이건 항상 준비되어 있는 직원 수야. 예를 들어, 2명을 기본으로 고용한다고 하자.
- 빌: "하지만 만약 주문이 너무 많으면 최대 스레드 수(maximumPoolSize)까지 늘릴 수 있어. 예를 들어, 4명까지 고용할 수 있도록 하자. 최대 스레드 수는 비상 인력처럼 필요한 경우에만 추가로 고용되는 거야."
- 대기 시간 설정:
- 빌: "추가로 고용된 직원은 일이 없으면 퇴근해야 하잖아? 그래서 대기 시간(keepAliveTime)을 설정할 수 있어. 예를 들어, 3초 동안 일이 없으면 초과 스레드들은 퇴근시킬 수 있지."
이어서 빌은 블로킹 큐(Blocking Queue)를 설명한다.
- 빌: "그리고 주문이 너무 많아지면, 스레드들이 감당할 수 없잖아? 그럴 때는 주문을 일단 대기 큐에 넣어서 순서대로 처리하게 할 수 있어. 예를 들어, 2개의 주문까지 대기할 수 있는 ArrayBlockingQueue를 사용하자.
마이크는 피자 가게에 이 설정들을 적용해보기로 한다.
- 기본 설정:
- 마이크는 2명의 직원이 항상 대기하고, 최대 4명까지 고용할 수 있는 스레드 풀을 설정했다. 블로킹 큐에는 최대 2개의 주문이 대기할 수 있도록 설정했다. 💼👷♂️👷♀️
- 주문이 들어오는 상황:
- 첫 번째 손님이 피자를 주문했어! 직원 1이 바로 피자 만들기에 들어가. 🎉
- 두 번째 손님이 피자를 주문했어! 직원 2도 피자를 만들기 시작했지.
- 세 번째 손님이 주문하자, 더 이상 기본 직원이 남아 있지 않았어! 😮 하지만 걱정 마. 주문은 블로킹 큐에 저장되고 기다리고 있어.
- 비상 인력 투입:
- 네 번째 손님이 들어오자, 큐가 가득 찼어! 😱 그러자 마이크는 비상 인력인 직원 3과 직원 4를 고용해서 추가 주문을 처리했어. 🛠️
- 거절된 주문:
- 다섯 번째 손님이 피자를 주문했지만, 모든 직원이 바쁘고 대기 큐도 가득 찼어. 마이크는 어쩔 수 없이 주문을 거절했지. (이 상황에서는 RejectedExecutionException 이 발생!)
에피소드 5: "스레드 종료와 자원 관리"
모든 주문이 끝나고 시간이 흘렀어. 이제 비상 인력들이 할 일이 없지? 마이크는 추가 직원들이 쉴 시간이 길어지자 3초 후에 퇴근시키기로 했어.
- 빌: "추가로 고용된 직원(스레드)들은 일이 없으면 3초 후에 자동으로 퇴근할 거야. 이렇게 하면 자원을 낭비하지 않고 효율적으로 관리할 수 있어!" 🕒
- 장면: 시간이 지나자, 추가 직원들이 퇴근하고, 기본 직원 2명만 다시 대기 상태로 돌아왔어. 🎩✨
에피소드 6: "모든 작업이 끝난 후의 정리"
마지막으로 마이크는 모든 주문이 처리된 후 스레드 풀을 종료했어. 모든 직원들이 가게 문을 닫고 퇴근하는 장면이 연출되었지. 💼
- 마이크: "스레드 풀을 사용하니까 주문 처리도 효율적이고, 자원 관리도 훨씬 쉬워졌어!" 😎
🟢 스레드 풀 관리 요약
- 기본 스레드: 항상 대기 중인 직원 수.
- 최대 스레드: 필요할 때만 추가로 고용되는 비상 인력.
- 대기 큐: 주문이 너무 많을 때 순서대로 처리할 수 있도록 대기시킴.
- 대기 시간: 일이 없으면 초과 직원(스레드)을 퇴근시키는 시간.
- 거절된 작업: 스레드와 큐가 모두 가득 차면 주문을 거절.
이렇게 마이크의 피자 가게는 스레드 풀을 통해 효율적으로 운영될 수 있다! 🍕
🔶 스레드 풀 관리 예시 코드
public class PoolSizeMainV1 {
public static void main(String[] args) {
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
// 기본 스레드2, 최대 스레드 4, 초과 스레드 생존 대기시간 3초, 큐 사이즈2
ThreadPoolExecutor es = new ThreadPoolExecutor(2, 4, 3000, TimeUnit.MILLISECONDS, workQueue);
printState(es);
es.execute(new RunnableTask("task1"));
printState(es,"task1");
es.execute(new RunnableTask("task2"));
printState(es,"task2");
es.execute(new RunnableTask("task3"));
printState(es,"task3");
es.execute(new RunnableTask("task4"));
printState(es, "task4");
es.execute(new RunnableTask("task5"));
printState(es, "task5");
es.execute(new RunnableTask("task6"));
printState(es, "task6");
try {
es.execute(new RunnableTask("task7"));
} catch (RejectedExecutionException e) {
log("task7 실행 거절 예외 발생: " + e);
}
sleep(3000);
log("== 작업 수행 완료 =="); printState(es);
sleep(3000);
log("== maximumPoolSize 대기 시간 초과 =="); printState(es);
es.close();
log("== shutdown 완료 =="); printState(es);
printState(es);
}
}
🟢 Executor 스레드 풀 관리
- 작업을 요청하면 core 사이즈 만큼 스레드를 만든다.
- core 사이즈를 초과하면 큐에 작업을 넣는다.
- 큐를 초과하면 max 사이즈 만큼 스레드를 만든다. 임시로 사용되는 초과 스레드가 생성된다.
- 큐가 가득차서 큐에 넣을 수도 없다. 초과 스레드가 바로 수행해야 한다.
- max 사이즈를 초과하면 요청을 거절한다. 예외가 발생한다.
- 큐도 가득차고, 풀에 최대 생성 가능한 스레드 수도 가득 찼다. 작업을 받을 수 없다.
⬛ 스레드 미리 생성
🟢 ThreadPoolExecutor.prestartAllCoreThreads()
- 기본 스레드를 미리 생성한다.
- 스레드 생성 시간을 줄일 수 있다.
🔶 예제 코드
public class PrestartPoolMain {
public static void main(String[] args) {
ExecutorService es = Executors.newFixedThreadPool(1000);
printState(es);
ThreadPoolExecutor poolExecutor = (ThreadPoolExecutor) es;
poolExecutor.prestartAllCoreThreads();
printState(es);
}
}
⬛ Executor 전략 - 고정 풀 전략
간단한 만화를 통해 고정 풀 전략들을 알아보자.
주문이 계속 늘어나고, 마이크는 새로운 전략이 필요했다. 그는 빌에게 조언을 구했다.
- 마이크: "직원을 고정된 수로만 유지할지, 상황에 따라 더 유연하게 늘릴지 고민이야."
빌은 고정 스레드 풀과 캐시 스레드 풀을 소개했다.
- 고정 팀 (Fixed Thread Pool):
- 빌: "고정된 수의 스레드만 운영하면, CPU와 메모리 리소스를 안정적으로 관리할 수 있어. 예를 들어, 항상 3명의 직원만 고용(초과 스레드 생성X)하는 거야. 주문이 들어오면 이 3명이 알아서 처리하지. 하지만 갑자기 주문이 폭주하면 대기 시간이 길어질 수 있어."
- 장점: 리소스 예측이 가능하고 안정적이야.
- 단점: 주문이 너무 많으면, 더 많은 직원을 고용할 수 없어 처리가 지연돼. 고객들은 서비스 응답이 점점 느려져 항의할 수 있지. 참고로, 큐 사이즈는 무한이야.
- 유연한 팀 (Cached Thread Pool):
- 빌: "반대로 캐시 풀 전략을 사용하면, 직원 수에 제한이 없어! 주문이 많아지면 필요한 만큼 직원(스레드)을 추가로 고용해서 처리할 수 있지. 하지만, 너무 많은 직원을 고용하면 자원을 너무 많이 써버릴 수도 있어. 서버가 다운될 위험이 생기지."
- 장점: 빠르게 처리 가능, 갑작스런 주문 폭주에도 대응 가능. (SynchronousQueue; 큐에 작업저장X, 바로처리)
- 단점: 스레드 수가 많아지면 자원 소비가 급격히 증가할 수 있어, 최악의 경우 서버가 멈출 위험이 있어.
마이크는 고민 끝에 고정된 스레드를 사용하지만, 긴급 시에만 추가 스레드를 투입하는 하이브리드 전략을 선택했다. 평소엔 기본 3명의 직원이 일하고, 갑자기 주문이 폭주하면 비상 인력 2명을 더 고용해서 문제를 해결했다.
🔶 Fixed Thread Pool 예시
public class PoolSizeMainV2 {
public static void main(String[] args) {
ExecutorService es = Executors.newFixedThreadPool(2);
//ExecutorService es = new ThreadPoolExecutor(2, 2, 0L,TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
log("pool 생성");
printState(es);
for (int i = 1; i <= 6; i++) {
String taskName = "task" + i;
es.execute(new RunnableTask(taskName));
printState(es, taskName);
}
es.close();
log("== shutdown 완료 ==");
}
}
🔶 Cached Thread Pool 예시
public class PoolSizeMainV3 {
public static void main(String[] args) {
//ExecutorService es = Executors.newCachedThreadPool();
// keepAliveTime 60초 -> 3초로 조절
ThreadPoolExecutor es = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 3, TimeUnit.SECONDS,
new SynchronousQueue<>()); // 대기큐X.이 큐는 생산자 작업을 소비자 스레드에게 직접 전달
log("pool 생성");
printState(es);
for (int i = 1; i <= 4; i++) {
String taskName = "task" + i;
es.execute(new RunnableTask(taskName));
printState(es, taskName);
}
sleep(3000);
log("== 작업 수행 완료 =="); printState(es);
sleep(3000);
log("== maximumPoolSize 대기 시간 초과 =="); printState(es);
es.close();
log("== shutdown 완료 ==");
printState(es);
}
}
고정 스레드 풀 전략은 서버 자원은 여유가 있는데, 사용자만 점점 느려지는 문제가 발생할 수 있다.
반면에 캐시 스레드 풀 전략은 서버의 자원을 최대한 사용하지만, 서버가 감당할 수 있는 임계점을 넘는 순간 시스템이 다운될 수 있다.
⬛ Executor 예외 정책
- AbortPolicy: 이 정책은 요청이 너무 많아지면 작업을 거절하고 예외를 던져서 시스템이 이를 인지하게 만든다. 즉, 서버가 '더는 못 받아!'라고 외치는 것이다.
- DiscardPolicy: 이 정책은 새로운 작업을 그냥 조용히 버린다. 아무 일도 일어나지 않은 것처럼! 하지만 고객에게는 아무런 경고도 없으니 조심해야 한다.
- CallerRunsPolicy: 만약 스레드 풀이 작업을 처리할 수 없으면, 작업을 요청한 주문을 넣은 스레드가 직접 그 일을 하게 된다. 이렇게 되면 작업이 빠르게 쌓이는 걸 어느 정도 막을 수 있다. 다만, 작업을 처리하는 속도가 느려지긴 한다.
🔶 예외 정책 처리 뼈대 코드
public class RejectMainV1 {
public static void main(String[] args) {
ExecutorService executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS,
new SynchronousQueue<>(), new ThreadPoolExecutor.AbortPolicy());
executor.submit(new RunnableTask("task1"));
try {
executor.submit(new RunnableTask("task2"));
} catch (RejectedExecutionException e) {
log("요청 초과");
//포기,다시 시도 등 다양한 고민을 하면 됨
log(e);
}
executor.close();
}
}
예외정책을 구현해보려면 위의 주석부분을 채우면된다. RejectedExecutionException 예외를 잡아서 작업을 포기하거나, 사용자에게 알리거나, 다시 시도하면 된다.
참고로, ThreadPoolExecutor 는 거절해야 하는 상황이 발생하면 rejectedExecution()을 호출한다.
🔶 DiscardPolicy
public class RejectMainV2 {
public static void main(String[] args) {
ExecutorService executor = new ThreadPoolExecutor(1, 1, 0,
TimeUnit.SECONDS, new SynchronousQueue<>(),new ThreadPoolExecutor.DiscardPolicy());
executor.submit(new RunnableTask("task1"));
executor.submit(new RunnableTask("task2"));
executor.submit(new RunnableTask("task3"));
executor.close();
}
}
public static class DiscardPolicy implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
// empty
}
🔶 CallerRunsPolicy
public class RejectMainV3 {
public static void main(String[] args) {
ExecutorService executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS, new SynchronousQueue<>(), new
ThreadPoolExecutor.CallerRunsPolicy());
executor.submit(new RunnableTask("task1"));
executor.submit(new RunnableTask("task2"));
executor.submit(new RunnableTask("task3"));
executor.submit(new RunnableTask("task4"));
executor.close();
}
}
생산자는 원래 작업을 계속 생성해야 하는데, 소비자 대신 그 작업을 처리하게 되면 자신이 해야 할 일(작업 생성)을 중단하고 작업을 처리해야 한다. 이러면 생산이 지연될 수 있다.
🔶 사용자 정의 전략 (RejectedExecutionHandler)
거절된 작업을 버리지만, 대신에 경로 로그를 남겨서 개발자가 문제를 인지할 수 있도록 해볼 수도 있다.
public class RejectMainV4 {
public static void main(String[] args) {
ExecutorService executor = new ThreadPoolExecutor(1, 1, 0,
TimeUnit.SECONDS, new SynchronousQueue<>(),new MyRejectedExecutionHandler());
executor.submit(new RunnableTask("task1"));
executor.submit(new RunnableTask("task2"));
executor.submit(new RunnableTask("task3"));
executor.close();
}
static class MyRejectedExecutionHandler implements RejectedExecutionHandler
{
static AtomicInteger count = new AtomicInteger(0);
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
int i = count.incrementAndGet();
log("[경고] 거절된 누적 작업 수: " + i); }
}
}
⬛ 정리
- "최적화하지 않는 것이 최적화다":
- 미래의 일어날지 모를 상황을 대비해 지나치게 최적화하지 말자.
- 예를 들어, 사용자가 적은데도 너무 많은 자원을 최적화에 사용하면 낭비다.
- 현재 상황에 맞는 최적화:
- 불확실한 미래보다 현재 상황에 맞춰 최적화하는 것이 중요하다.
- 모니터링을 통해 필요한 시점에만 최적화하는 것이 더 효율적이다.
- 스레드 풀 전략 선택:
- 고정 스레드 풀: 안정적인 처리에 유리.
- 캐시 스레드 풀: 빠른 사용자 요청 대응에 적합.
- 자원이 충분하다면 고정 풀 전략을 사용하되, 스레드 수를 늘려서 대응 가능.
- 적절한 거절:
- 자원을 효율적으로 활용하고, 시스템이 과부하되면 적절히 요청을 거절해 시스템 다운을 방지해야 한다.
결론: 미래를 대비한 과도한 최적화보다는, 현재 상황에 맞춰 유연하게 최적화하고 필요할 때 개선하자.
'Java' 카테고리의 다른 글
[Java] 김영한의 실전 자바 - 고급 2편 섹션5 File,Files (0) | 2024.10.28 |
---|---|
[Java] 김영한의 실전 자바 - 고급 2편 섹션2 I/O 기본1,2 (0) | 2024.10.28 |
[Java] 김영한의 실전 자바 - 고급 1편 섹션12 스레드 풀과 Executor 프레임워크 (0) | 2024.09.29 |
[Java] 김영한의 실전 자바 - 고급 1편 섹션9 생산자 소비자 문제2 (1) | 2024.09.26 |
[Java] 김영한의 실전 자바 - 고급 1편 섹션11 동시성 컬렉션 (0) | 2024.09.23 |