Day 35 — java.util.concurrent: 스레드를 관리하자
목차 20
지난 시간 Day 34 에서 우리는 동시성의 함정을 정면으로 마주했어요. 여러 스레드가 같은 좋아요 카운터를 동시에 건드리니 좋아요가 새고, synchronized 라는 자물쇠로 그걸 막았죠. 데드락까지 만들어보고 풀어봤고요. 꽤 묵직한 하루였어요.
그런데 그때 우리가 스레드를 어떻게 만들었는지 떠올려보세요. Step 1 에서 스레드를 100개나 new Thread(...) 로 직접 만들었어요. 좋아요 1만 건을 처리하려고 1만 개를 만든 적도 있고요. 스레드 하나를 만드는 데도 시간과 메모리가 든다고 했는데, 매번 새로 만들고 버리는 건 사실 꽤 낭비예요.
오늘은 발상을 바꿔요. 스레드를 미리 몇 개 만들어두고 재사용 하면서, 우리는 "일감만 던지는" 방식을 배워요. 이걸 맡아주는 도구가 ExecutorService 예요. 결과를 돌려받는 Future, 풀의 종류, 시간을 예약하는 스케줄러, 그리고 Day 34 에서 살짝 예고했던 — synchronized 보다 가벼운 Atomic 변수까지, 자바가 동시성을 위해 미리 만들어둔 도구 상자 java.util.concurrent 를 열어볼 거예요.
지난 시간에 직접 손으로 스레드를 다뤄봤기 때문에, 오늘 이 "고수준 도구" 들이 왜 편한지 몸으로 느껴질 거예요. 그럼 시작해봐요!
🎯 학습 목표
- 스레드를 직접 만드는 대신 스레드풀(thread pool) 에 일감을 맡기는
ExecutorService를 이해해요. - 일감의 결과를 돌려받는
Callable과Future, 그리고get()이 "막혀서 기다린다(블로킹)" 는 성질을 알아요. - 풀에도 종류가 있다는 것 —
newFixedThreadPool과newCachedThreadPool의 차이와 트레이드오프를 가늠해요. - 시간을 예약해 실행하는
ScheduledExecutorService를 다뤄요. synchronized보다 가벼운AtomicInteger로 카운터를 안전하게 세요 (Day 34 회수).- 여러 작업이 끝나길 기다리는
CountDownLatch와, 스레드들이 발을 맞추는CyclicBarrier를 익혀요.
오늘의 로드맵
- Step 1 — 스레드를 매번 만드는 건 부담이다:
ExecutorService등장. - Step 2 — 일꾼 풀에 일감 던지기:
submit과 풀 생명주기. - Step 3 — 결과를 돌려받기:
Callable과Future. - Step 4 — 풀에도 종류가 있다:
FixedvsCached. - Step 5 — 시간을 예약하는 풀:
ScheduledExecutorService. - Step 6 —
synchronized보다 가벼운 카운터:Atomic변수. - Step 7 — 다 끝날 때까지 기다리기:
CountDownLatch. - Step 8 — 모두 모이면 함께 출발:
CyclicBarrier.
Step 1: 스레드를 매번 만드는 건 부담이다 — ExecutorService 등장
지난 시간에 우리는 일이 생길 때마다 new Thread(...) 로 일꾼을 직접 만들었어요. 스레드 100개를 만든 것도 그래서였죠. 그런데 스레드를 새로 만드는 건 공짜가 아니에요. 만들고 없애는 데 시간이 걸리고, 한 개당 메모리도 꽤 차지해요. 요청이 들어올 때마다 매번 새 스레드를 만들고 버리면 그 비용이 만만치 않아요.
식당을 떠올려봐요. 손님이 한 명 올 때마다 요리사를 새로 채용하고, 요리가 끝나면 바로 해고한다면 어떨까요? 채용·해고에 드는 시간이 요리 시간보다 더 들지도 몰라요. 그래서 식당은 요리사 몇 명을 미리 고용 해두고, 주문서만 계속 주방에 넣죠.
스레드도 똑같이 해요. 일꾼 스레드 몇 명을 미리 만들어두고 재사용하는 곳을 스레드풀(thread pool), 그 풀에 일감을 맡아 돌려주는 매니저를 ExecutorService(실행 서비스) 라고 불러요. 우리는 스레드를 직접 안 만들고, "할 일" 만 던지면 돼요.
직접 만들기 (Day 33~34) 스레드풀 (오늘)
일감마다 스레드 만들고 버림 일꾼 3명을 미리 고용해 재사용
일감 1 → [스레드 생성] → 버림 일감 1 ┐
일감 2 → [스레드 생성] → 버림 일감 2 ┤
일감 3 → [스레드 생성] → 버림 일감 3 ┼→ 노는 일꾼이 집어가 처리
... (만드는 비용 N번) 일감 N ┘ (일꾼은 계속 재사용)
코드로 보면 이렇게 간단해요. Executors.newFixedThreadPool(3) 으로 일꾼 3명짜리 풀을 만들고, execute(...) 에 할 일을 던지면 끝이에요.
// com/instagram/javabasic/concurrent/ExecutorIntro.java
public static int runTasks(int taskCount) throws InterruptedException {
// 여러 스레드가 동시에 넣어도 안전한 수거함이에요 (끝낸 일감을 여기 모아요).
ConcurrentLinkedQueue<Integer> done = new ConcurrentLinkedQueue<>();
// 일꾼 3명을 미리 고용해 둬요. 이제 스레드를 직접 안 만들고 일감만 던지면 돼요.
ExecutorService pool = Executors.newFixedThreadPool(3);
for (int i = 1; i <= taskCount; i++) {
int taskId = i;
// execute() 에 "할 일" 을 던지면, 노는 일꾼이 알아서 집어가 처리해요.
pool.execute(() -> done.add(taskId));
}
pool.shutdown(); // 더는 새 일감 안 받음 (받은 건 마저 끝냄)
pool.awaitTermination(5, TimeUnit.SECONDS); // 다 끝날 때까지 잠깐 기다림
return done.size();
}
runTasks(10) 을 부르면, 일꾼은 3명뿐인데 일감 10개를 나눠서 다 처리해요. 스레드를 10개 새로 만든 게 아니라, 3명이 돌아가며 10개를 해치운 거예요. 100개를 던져도 일꾼은 여전히 3명이고요. 우리가 신경 쓸 건 "일꾼을 몇 명 둘까(풀 크기)" 와 "무슨 일감을 던질까" 뿐이에요. 스레드를 만들고 버리는 귀찮고 비싼 일은 ExecutorService 가 알아서 해줘요.
💡
ExecutorService는 일꾼 스레드를 미리 만들어두고 재사용하는 매니저예요. 우리는 스레드를 직접 만들지 않고execute()로 일감만 던져요. 스레드 생성·소멸 비용을 아끼는 게 핵심이에요.
🙋 학생 질문 — "튜터님, 코드 끝에 shutdown 이랑 awaitTermination 은 왜 필요해요?"
좋은 질문이에요. 풀의 일꾼 스레드는 일감이 없어도 "혹시 또 올까" 하고 계속 살아서 기다려요. 그래서 다 썼으면 우리가 직접 "이제 그만, 문 닫아" 라고 알려줘야 해요. 그게 shutdown() 이에요. 호출하면 더는 새 일감을 안 받지만, 이미 받은 일감은 마저 끝내요.
awaitTermination(5, TimeUnit.SECONDS) 은 "받은 일감이 다 끝날 때까지 최대 5초 기다려줘" 라는 뜻이에요. 이걸 안 하면, 일감이 아직 처리되는 중인데 getCount() 를 읽어 엉뚱하게 0 이 나올 수 있어요. 풀을 닫지 않으면 프로그램이 영영 안 끝나기도 하고요. 그래서 풀은 꼭 닫아줘야 하는데, 다음 Step 에서 이걸 더 깔끔하게 하는 방법을 배울 거예요.
Step 2: 일꾼 풀에 일감 던지기 — submit과 풀 생명주기
이제 인스타 피드를 떠올려봐요. 피드를 열면 이미지 여러 장이 한꺼번에 로딩되죠. 이 로딩 일감들을 풀에 던져 처리해볼게요. 일감을 던지는 메서드는 execute() 말고 submit() 도 있어요. 둘 다 일감을 맡기지만, submit() 은 나중에 결과를 받을 수 있는 통로를 주는데 (그건 다음 Step 에서) 지금은 결과 없이 던지기만 해볼게요.
풀을 다 쓰고 정리하는 방법은 두 가지가 있어요. 먼저 Step 1 에서 본 손으로 정리하는 방법 이에요.
// com/instagram/javabasic/concurrent/FeedTaskExecutor.java
public static int loadImagesExplicit(int imageCount) throws InterruptedException {
ConcurrentLinkedQueue<Integer> loaded = new ConcurrentLinkedQueue<>();
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int id = 1; id <= imageCount; id++) {
int imageId = id;
pool.submit(() -> loaded.add(imageId)); // 이미지 한 장 로딩을 일감으로 던지기
}
pool.shutdown(); // 새 일감 그만 받기 (받은 건 마저 처리)
pool.awaitTermination(5, TimeUnit.SECONDS); // 받은 일감이 끝날 때까지 대기
return loaded.size();
}
shutdown() 과 awaitTermination() 을 손으로 챙겨야 하죠. 그런데 깜빡하고 안 닫으면 풀이 계속 살아남아 문제가 돼요. 다행히 더 안전한 방법이 있어요. try-with-resources 예요. Day 23 에서 배웠던, "괄호 안에 자원을 넣으면 블록을 벗어날 때 자동으로 닫아주는" 그 문법이에요.
ExecutorService 도 이 괄호 안에 넣을 수 있어요. 블록을 벗어나는 순간 close() 가 자동으로 호출되는데, 이 close() 가 바로 shutdown + 기다리기를 알아서 해줘요.
// com/instagram/javabasic/concurrent/FeedTaskExecutor.java
public static int loadImages(int imageCount) {
ConcurrentLinkedQueue<Integer> loaded = new ConcurrentLinkedQueue<>();
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
for (int id = 1; id <= imageCount; id++) {
int imageId = id;
pool.submit(() -> loaded.add(imageId));
}
} // ← 여기서 close() 호출: 모든 일감이 끝날 때까지 자동으로 기다린 뒤 풀을 닫아요
return loaded.size();
}
try 의 닫는 중괄호 } 를 지나는 순간, 던진 일감이 전부 끝날 때까지 기다린 다음 풀이 닫혀요. shutdown 을 깜빡할 일이 없죠. 그래서 앞으로는 이 try-with-resources 방식을 주로 쓸 거예요.
💡 풀은 다 쓰면 반드시 닫아야 해요.
shutdown()+awaitTermination()을 손으로 챙기는 대신, try-with-resources 괄호에 풀을 넣으면close()가 자동으로 정리해줘요. 깜빡할 위험이 없어 더 안전해요.
Step 3: 결과를 돌려받기 — Callable과 Future
지금까지 던진 일감은 "그냥 해줘" 였어요. 결과를 돌려받지는 않았죠. 그런데 "이미지를 받아 썸네일을 만들고, 그 크기를 알려줘" 처럼 결과가 필요한 일도 많아요.
이럴 땐 submit() 에 Callable(콜러블) 을 줘요. 우리가 람다로 던지던 Runnable 이 결과 없는 "할 일" 이라면, Callable 은 결과를 return 하는 "할 일" 이에요. 그리고 submit() 은 그 결과를 나중에 꺼낼 수 있는 영수증을 돌려주는데, 이 영수증을 Future(퓨처, 미래의 결과) 라고 불러요.
pool.submit(Callable) ──▶ Future (결과 영수증, 아직 값은 없음)
│
│ ... 일꾼이 계산하는 동안 ...
▼
future.get() ──▶ 결과가 나올 때까지 여기서 막혀 기다림 → 값을 받음
// com/instagram/javabasic/concurrent/ThumbnailFuture.java
public static int generateThumbnail(int originalWidth)
throws InterruptedException, ExecutionException {
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
// submit 에 Callable(결과를 return 하는 일) 을 주면 Future 를 돌려받아요.
Future<Integer> future = pool.submit(() -> originalWidth / 2);
return future.get(); // 결과가 나올 때까지 여기서 막혀 기다렸다가 받아와요
}
}
핵심은 future.get() 이에요. 결과가 필요해지면 이걸 부르는데, 결과가 아직 안 나왔으면 나올 때까지 그 자리에서 "막혀서" 기다려요. 이렇게 결과를 기다리느라 멈추는 걸 블로킹(blocking) 이라고 해요.
그래서 여러 장을 한꺼번에 맡길 땐 요령이 있어요. 먼저 전부 submit 해서 Future 들을 모아두고 (이러면 일꾼들이 동시에 일해요), 그다음에 하나씩 get() 으로 거두는 거예요.
// com/instagram/javabasic/concurrent/ThumbnailFuture.java
public static int totalThumbnailWidth(int[] originalWidths)
throws InterruptedException, ExecutionException {
try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
// 1) 먼저 전부 submit 해서 영수증(Future) 을 모아둬요. 이러면 셋이 동시에 일해요.
List<Future<Integer>> futures = new ArrayList<>();
for (int width : originalWidths) {
futures.add(pool.submit(() -> width / 2));
}
// 2) 그다음에 하나씩 get() 으로 결과를 거둬 더해요.
int total = 0;
for (Future<Integer> f : futures) {
total += f.get();
}
return total;
}
}
만약 submit 하자마자 바로 get() 으로 기다렸다면, 첫 장이 끝나야 둘째 장을 시작하니 결국 하나씩 차례로 한 셈이 돼요. 먼저 다 던져두고 나중에 거두는 게 동시 처리의 핵심이에요.
💡
Callable은 결과를 돌려주는 일감,Future는 그 결과를 나중에 받는 영수증이에요.future.get()은 결과가 나올 때까지 막혀서 기다려요(블로킹). 여러 개는 먼저 다submit한 뒤 나중에get()으로 거둬요.
🙋 학생 질문 — "get() 이 매번 막혀서 기다리는 게 좀 답답한데, 더 좋은 방법은 없나요?"
아주 날카로운 지적이에요. 맞아요, get() 의 블로킹은 불편해요. 예를 들어 외부 API 세 곳을 호출해 결과를 합친다고 해봐요. get() 으로 하나씩 기다리면, "이 결과가 오면 이어서 저걸 하고, 그게 오면 또 이걸 하고" 같은 흐름을 짜기가 번거로워요. 결과를 기다리는 동안 그 스레드는 아무것도 못 하고 멈춰 있고요.
그래서 "결과가 나오면 막힘 없이 이어서 이걸 해줘" 하고 작업을 연결할 수 있다면 훨씬 좋겠죠. 자바에는 그걸 위한 도구가 따로 있어요. 바로 CompletableFuture 인데, 이건 다음 시간(Day 36) 의 주제예요. 오늘 배운 Future 가 그 출발점이니, "결과를 미래에 받는다" 는 감각을 잘 익혀두세요.
Step 4: 풀에도 종류가 있다 — Fixed vs Cached
지금까지 우리는 newFixedThreadPool(n) 만 썼어요. 이름 그대로 일꾼 수가 n 으로 고정 된 풀이에요. 그런데 풀에도 종류가 있어서, 일감의 성격에 따라 골라 써요. 대표적인 둘을 비교해볼게요.
newFixedThreadPool(2) newCachedThreadPool()
일꾼 수 = 2 로 고정 일꾼 수 = 그때그때 (필요하면 늘림)
일감이 많으면 → 줄 서서 대기 일감이 오는데 노는 일꾼 없으면 → 새로 만듦
한 번에 도는 일꾼 ≤ 2 (예측 가능) 일 끝낸 일꾼은 잠깐 뒀다 재사용
→ 부하를 제한하고 싶을 때 → 짧은 일감이 간헐적으로 몰릴 때
newFixedThreadPool(2) 는 일꾼이 딱 2명이에요. 일감이 6개 와도 한 번에 도는 일꾼은 2명을 절대 안 넘어요. 나머지 일감은 줄을 서서(큐) 차례를 기다리죠. 시스템에 거는 부하가 예측되니, 대부분의 서버 작업에 잘 맞아요.
// com/instagram/javabasic/concurrent/PoolTypeDemo.java
public static FixedResult runOnFixed(int poolSize, int taskCount) throws InterruptedException {
Set<String> threadNames = Collections.synchronizedSet(new HashSet<>());
ConcurrentLinkedQueue<Integer> done = new ConcurrentLinkedQueue<>();
ExecutorService pool = Executors.newFixedThreadPool(poolSize);
for (int i = 1; i <= taskCount; i++) {
int id = i;
pool.submit(() -> {
threadNames.add(Thread.currentThread().getName()); // 누가 이 일감을 했나 기록
done.add(id);
});
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
return new FixedResult(done.size(), threadNames.size());
}
여기서 threadNames 에 일감을 처리한 스레드 이름을 모아봤어요. runOnFixed(2, 6) 을 돌리면 일감 6개는 다 처리되지만, 실제로 일한 서로 다른 스레드 수는 2를 절대 안 넘어요. 일꾼이 2명으로 고정이니까요.
반대로 newCachedThreadPool() 은 크기 제한이 없어요. 일감이 왔는데 노는 일꾼이 없으면 새로 만들고, 일 끝낸 일꾼은 잠깐 뒀다가 재사용해요. 짧은 일감이 우르르 몰릴 때 빠르지만, 일감이 폭주하면 일꾼이 끝없이 늘어날 위험도 있어요.
// com/instagram/javabasic/concurrent/PoolTypeDemo.java
public static int runOnCached(int taskCount) throws InterruptedException {
ConcurrentLinkedQueue<Integer> done = new ConcurrentLinkedQueue<>();
ExecutorService pool = Executors.newCachedThreadPool();
for (int i = 1; i <= taskCount; i++) {
int id = i;
pool.submit(() -> done.add(id));
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
return done.size();
}
💡
newFixedThreadPool(n)은 일꾼 수가n으로 고정돼 부하가 예측돼요(서버 작업의 기본).newCachedThreadPool()은 필요에 따라 일꾼을 늘리고 재사용해 짧은 일감 처리에 빠르지만, 폭주하면 일꾼이 무한정 늘 수 있어요. 일감 성격에 맞게 골라요.
Step 5: 시간을 예약하는 풀 — ScheduledExecutorService
지금까지의 풀은 일감을 던지면 "지금 당장" 처리했어요. 그런데 "3초 뒤에 한 번" 이나 "5초마다 계속" 처럼 시간을 예약 하고 싶을 때가 있어요. 인스타로 치면 "새 알림 왔나" 를 주기적으로 확인하는 일이에요.
이런 예약 실행은 ScheduledExecutorService(예약 실행 서비스) 가 맡아요. Executors.newScheduledThreadPool(n) 으로 만들고, 두 가지 방식으로 예약해요.
schedule(일감, 지연, 단위)— 지연 시간 뒤에 딱 한 번 실행scheduleAtFixedRate(일감, 처음지연, 주기, 단위)— 처음 지연 뒤부터 주기마다 반복 실행
먼저 일회성 예약이에요. schedule 에 Callable 을 주면 결과를 담은 ScheduledFuture 를 돌려줘서, Step 3 에서 배운 get() 으로 결과를 받을 수 있어요.
// com/instagram/javabasic/concurrent/NotificationScheduler.java
public static String checkOnce(long delayMillis) throws InterruptedException, ExecutionException {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1, daemonFactory());
try {
ScheduledFuture<String> future =
scheduler.schedule(() -> "새 알림 1건", delayMillis, TimeUnit.MILLISECONDS);
return future.get(); // 예약한 시간이 지나 결과가 나올 때까지 기다렸다 받기
} finally {
scheduler.shutdownNow();
}
}
다음은 주기 실행이에요. scheduleAtFixedRate 로 일정 간격마다 같은 일감을 반복하게 해요. 주의할 점은, 가만히 두면 이 반복이 영영 계속된다는 거예요. 그래서 다 쓰면 shutdownNow() 로 꼭 멈춰줘야 해요.
// com/instagram/javabasic/concurrent/NotificationScheduler.java
public static int runPeriodically(int targetRuns, long periodMillis) throws InterruptedException {
ConcurrentLinkedQueue<Long> runs = new ConcurrentLinkedQueue<>(); // 실행될 때마다 한 칸씩 쌓임
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1, daemonFactory());
try {
scheduler.scheduleAtFixedRate(() -> runs.add(1L), 0, periodMillis, TimeUnit.MILLISECONDS);
// targetRuns 번 채워질 때까지 잠깐씩 지켜봐요 (최대 5초 안전선).
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (runs.size() < targetRuns && System.nanoTime() < deadline) {
Thread.sleep(5);
}
return runs.size();
} finally {
scheduler.shutdownNow(); // 다 봤으면 주기 실행을 멈춰요
}
}
💡
ScheduledExecutorService는 시간을 예약해 실행해요.schedule은 지연 뒤 한 번,scheduleAtFixedRate는 주기마다 반복이에요. 주기 실행은 가만 두면 영영 도니까, 다 쓰면shutdownNow()로 멈춰야 해요.
Step 6: synchronized보다 가벼운 카운터 — Atomic 변수
지난 시간을 떠올려봐요. 좋아요 카운터의 count++ 가 동시에 눌리면 갱신이 새서, synchronized 로 막았죠. synchronized 는 확실해요. 하지만 한 번에 한 스레드만 들여보내느라, 나머지는 문 앞에서 줄 서서 기다려야 해서 비용이 있어요.
그런데 단순히 "숫자 하나를 안전하게 세는" 정도라면, 더 가벼운 도구가 있어요. AtomicInteger 예요. incrementAndGet() 은 (읽기 → 더하기 → 쓰기) 세 단계를, 자물쇠 없이도 끊기지 않는 "한 동작" 으로 처리해줘요.
비결은 CAS(Compare-And-Swap, 비교 후 교체) 라는 약속이에요. "내가 읽은 값이 아직 그대로면 바꾸고, 그새 누가 바꿨으면 다시 읽어서 시도" 를 CPU 차원에서 보장해줘요. 문을 잠가 남을 막는 게 아니라, 막지 않고 부딪히면 다시 도전하는 방식이에요.
synchronized (문 잠그기) Atomic (CAS, 부딪히면 재시도)
A 들어감 → B 문 앞에서 대기 A: 5 읽음 → 아직 5? 응 → 6 쓰기 성공
A 나옴 → B 들어감 B: 5 읽음 → 아직 5? 아니 6! → 다시 읽어 재시도
(한 번에 한 명, 나머지는 멈춤) (아무도 안 멈춤, 어긋나면 다시)
// com/instagram/javabasic/concurrent/AtomicLikeCounter.java
public class AtomicLikeCounter {
// 0 으로 시작하는 원자적 정수. 여러 스레드가 동시에 건드려도 안전해요.
private final AtomicInteger count = new AtomicInteger();
// synchronized 없이도 안전하게 1 증가. 읽기+더하기+쓰기가 끊기지 않는 한 동작이에요.
public void like() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
Day 34 의 좋아요 카운터와 똑같은 워크로드 — 스레드 100개가 각자 1000번씩 like() — 를 돌려도, 이번엔 결과가 항상 정확히 100,000 이에요. synchronized 를 붙인 Day 34 의 LikeCounterSafe 와 결과는 똑같이 정확하지만, 카운터처럼 단순한 경우엔 Atomic 이 더 가볍고 빨라요. 락을 걸고 푸는 무게 없이, 부딪힐 때만 살짝 다시 시도하면 되니까요.
💡
AtomicInteger.incrementAndGet()은 락 없이도 증가를 "한 동작" 으로 보장해요(CAS). 단일 숫자 카운터라면synchronized보다 가볍고 빨라요. Day 34 의 좋아요 새는 문제를 더 가벼운 도구로 푼 셈이에요.
🙋 학생 질문 — "그럼 synchronized 는 이제 안 쓰고 Atomic 만 쓰면 되는 거 아니에요?"
아쉽게도 그렇지 않아요. Atomic 은 "값 하나 를 안전하게" 다룰 때 좋아요. 좋아요 수, 조회수처럼 숫자 한 개를 세는 거라면 딱이죠.
그런데 "출금하고 입금하기" 처럼 여러 값을 한 묶음으로 안전하게 바꿔야 할 때는 Atomic 하나로는 부족해요. 계좌 A 에서 빼고 계좌 B 에 더하는 두 동작이 "통째로 한 번에" 일어나야 하는데, Atomic 은 각각의 숫자만 지켜줄 뿐 둘을 묶어주진 못하거든요. 이럴 땐 여전히 synchronized 같은 락으로 그 구간 전체를 보호해야 해요.
그래서 둘은 경쟁 관계가 아니라 쓰임이 달라요. 단일 값 → Atomic, 여러 값의 묶음 → 락 정도로 기억해두면 좋아요. 이 이야기는 마지막 생각해볼 주제에서 한 번 더 곱씹어볼게요.
Step 7: 다 끝날 때까지 기다리기 — CountDownLatch
게시물에 이미지 여러 장을 올릴 때, "전부 다 올라간 다음에" 게시물을 공개하고 싶어요. 한 장이라도 아직 올라가는 중이면 공개를 미뤄야죠. 즉 메인 흐름이 "N 개의 일이 모두 끝날 때까지" 기다려야 해요.
Day 33 에서 배운 join() 으로도 기다릴 수 있어요. 하지만 일감을 풀에 던졌을 땐 스레드를 우리가 직접 쥐고 있지 않아서, join 을 걸 대상이 없어요. 일꾼은 풀이 관리하니까요.
이럴 때 쓰는 게 CountDownLatch(카운트다운 래치) 예요. 로켓 발사 카운트다운을 떠올려봐요. 카운트를 N 으로 시작해두고, 일 하나가 끝날 때마다 countDown() 으로 1씩 줄여요. 기다리는 쪽은 await() 로 멈춰 있다가, 카운트가 0 이 되는 순간 풀려서 다음으로 나아가요.
latch = 3 으로 시작
업로드1 끝 → countDown() → 2
업로드2 끝 → countDown() → 1
업로드3 끝 → countDown() → 0 ← 0 이 되는 순간
await() 가 풀림 → 게시물 공개!
// com/instagram/javabasic/concurrent/UploadLatch.java
public static boolean publishWhenAllUploaded(int imageCount) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(imageCount); // 카운트를 imageCount 로 시작
ConcurrentLinkedQueue<Integer> uploaded = new ConcurrentLinkedQueue<>();
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int id = 1; id <= imageCount; id++) {
int imageId = id;
pool.submit(() -> {
uploaded.add(imageId); // 이미지 한 장 업로드 끝
latch.countDown(); // 카운트 1 줄이기
});
}
boolean allDone = latch.await(5, TimeUnit.SECONDS); // 0 이 될 때까지 기다림
pool.shutdown();
return allDone && uploaded.size() == imageCount;
}
latch.await(...) 한 줄에서 메인은 멈춰 기다려요. 업로드 8장이 각자 countDown() 을 해서 카운트가 0 이 되면, 그제야 await 가 풀리고 게시물을 공개해요. 한 가지 기억할 점은, CountDownLatch 는 한 번 0 이 되면 끝 이라 다시 못 써요(일회용). 매번 새로 만들어야 해요.
💡
CountDownLatch는 "N 개의 일이 다 끝날 때까지 기다리는" 일회용 신호등이에요. 일이 끝날 때마다countDown(), 기다리는 쪽은await()로 0 이 될 때까지 멈춰요. 풀에 던진 일감처럼join을 못 걸 때 특히 유용해요.
Step 8: 모두 모이면 함께 출발 — CyclicBarrier
CountDownLatch 는 "메인이 일꾼들을 기다리는" 한 방향 신호였어요. 그런데 일꾼들끼리 서로 발을 맞춰야 할 때도 있어요. 예를 들어 배치 작업을 여러 단계로 나눠 할 때, 모든 일꾼이 1단계를 끝낼 때까지 기다렸다가 다 같이 2단계로 넘어가야 결과가 어긋나지 않아요.
이렇게 "정해진 인원이 모두 도착하면 함께 출발" 하게 해주는 게 CyclicBarrier(순환 장벽) 예요. 각 스레드는 자기 몫을 끝내면 await() 로 장벽 앞에서 기다려요. 마지막 한 명이 도착해 인원이 다 차는 순간, 장벽이 열려 전원이 동시에 다음 단계로 나아가요.
일꾼A ──┐
일꾼B ──┼──▶ [장벽] 전원 도착했나? ──▶ 다 왔으면 함께 다음 단계로
일꾼C ──┘ (장벽이 다시 닫혀 다음 단계에 재사용)
// com/instagram/javabasic/concurrent/BatchBarrier.java
public static int runBatches(int workers, int rounds) throws InterruptedException {
// 한 단계가 통째로 끝날 때마다(=전원이 그 단계 장벽을 통과할 때마다) 한 칸씩 쌓여요.
ConcurrentLinkedQueue<Integer> finishedRounds = new ConcurrentLinkedQueue<>();
// 인원 workers 명. 전원이 도착하면 두 번째 인자(장벽 동작) 가 한 번 실행돼요.
CyclicBarrier barrier = new CyclicBarrier(workers, () -> finishedRounds.add(1));
Thread[] team = new Thread[workers];
for (int i = 0; i < workers; i++) {
team[i] = new Thread(() -> {
try {
for (int r = 0; r < rounds; r++) {
// ... 이 단계에서 내가 맡은 몫을 처리한다고 치고 ...
barrier.await(); // 동료가 다 올 때까지 기다렸다가 함께 다음 단계로
}
} catch (InterruptedException | BrokenBarrierException e) {
Thread.currentThread().interrupt();
}
});
}
for (Thread t : team) {
t.start();
}
for (Thread t : team) {
t.join();
}
return finishedRounds.size();
}
CyclicBarrier 를 만들 때 두 번째 인자로 준 람다(() -> finishedRounds.add(1))는 전원이 장벽을 통과할 때마다 한 번씩 실행되는 "단계 완료 도장" 이에요. 그래서 일꾼 3명이 2단계를 돌면, 도장이 정확히 2번 찍혀요.
이름의 "순환(Cyclic)" 이 핵심이에요. 한 번 열린 장벽이 다시 닫혀서 다음 단계에서 또 쓸 수 있어요. CountDownLatch 가 일회용 신호등이라면, CyclicBarrier 는 매 단계 다시 모이는 집결지예요.
| CountDownLatch | CyclicBarrier | |
|---|---|---|
| 누가 기다리나 | 보통 메인이 일꾼들을 | 일꾼들이 서로를 |
| 재사용 | 안 됨 (일회용) | 됨 (매번 다시) |
| 방향 | 한 방향 (끝나길 기다림) | 서로 (다 같이 출발) |
| 인스타 예 | 업로드 다 되면 공개 | 단계별 배치를 발맞춰 |
💡
CyclicBarrier는 정해진 인원이 모두 도착하면 함께 다음으로 나아가게 해요. 일꾼들끼리 단계를 맞출 때 써요.CountDownLatch와 달리 재사용되고, 전원 도착 때마다 장벽 동작을 한 번씩 실행할 수 있어요.
마무리
오늘은 자바가 동시성을 위해 미리 만들어둔 도구 상자, java.util.concurrent 를 열어봤어요. 지난 시간에 손으로 직접 스레드를 다뤄봤기에, 이 고수준 도구들이 얼마나 편한지 느껴졌을 거예요.
ExecutorService— 일꾼 스레드를 미리 만들어 재사용하는 풀. 스레드를 직접 안 만들고execute/submit으로 일감만 던져요. 다 쓰면 try-with-resources 로 깔끔하게 닫아요.Callable과Future— 결과를 돌려주는 일감과 그 영수증.future.get()은 결과가 나올 때까지 막혀서 기다려요(블로킹).- 풀의 종류 —
newFixedThreadPool은 크기 고정으로 부하가 예측되고,newCachedThreadPool은 탄력적이에요. 일감 성격에 맞게 골라요. ScheduledExecutorService— 지연·주기로 시간을 예약해 실행해요.AtomicInteger— 단일 숫자를 락 없이 안전하게 세요(CAS).synchronized보다 가벼워요.CountDownLatch와CyclicBarrier— 여러 작업이 끝나길 기다리거나, 일꾼들이 발을 맞추게 해요.
스레드를 직접 다루던 지난 시간과 달리, 오늘은 "일감을 맡기고 결과를 받는" 한 단계 높은 시야를 얻었어요. 이 감각은 나중에 서버를 다룰 때 — 수천 명의 요청을 스레드풀로 처리하는 상황에서 — 그대로 살아나요.
다음 시간엔 — 비동기를 조합하자
오늘 Future.get() 이 결과가 나올 때까지 막혀서 기다린다는 걸 봤죠. 그런데 외부 API 세 곳을 호출해 결과를 합치는 상황을 떠올려봐요. get() 으로 하나씩 기다리면, 기다리는 동안 스레드는 멈춰 있고, "이게 오면 저걸 하고, 저게 오면 또 이걸 하고" 같은 흐름을 짜기도 번거로워요.
그래서 다음 시간엔 CompletableFuture 를 배워요. "결과가 나오면 막힘 없이 이어서 이걸 해줘" 하고 작업을 줄줄이 연결하는 도구예요. thenApply, thenCompose, thenCombine 으로 여러 비동기 작업을 우아하게 조합하고, 여러 외부 호출을 병렬로 묶는 실전 패턴까지 다뤄요. 오늘 배운 Future 가 바로 그 이야기의 출발점이에요. 수고 많으셨어요!
과제
오늘 배운 도구들을 직접 다뤄볼 시간이에요. 셋 다 인스타그램에서 실제로 일어날 법한 상황이에요. 막히면 본문의 데모 코드를 참고하되, 먼저 스스로 구조를 떠올려보세요.
과제 1 (기초): 댓글 알림을 스레드풀로 한꺼번에 보내기
상황 배경: 인기 게시물에 댓글이 여러 개 달리면, 각 댓글마다 작성자에게 알림을 보내야 해요. 알림 하나하나를 일감으로 만들어 스레드풀에 던져 처리해봐요. Step 1·2 의 ExecutorIntro·FeedTaskExecutor 를 참고하세요.
🎯 해결 미션:
NotificationDispatcher같은 클래스를 만들고,dispatchAll(int notificationCount)메서드를 두세요.Executors.newFixedThreadPool(4)로 풀을 만들되, try-with-resources 로 감싸 자동으로 닫히게 하세요.notificationCount개의 알림을 각각submit으로 던지고, 보낸 알림을 안전한 수거함(ConcurrentLinkedQueue)에 모으세요.- try 블록을 벗어난 뒤 수거함 크기를 돌려주고,
dispatchAll(20)이 항상 20 을 돌려주는지 확인하세요. 왜 try-with-resources 덕분에 20 이 보장되는지 한 줄로 설명해보세요.
과제 2 (응용): 여러 게시물의 좋아요 수를 동시에 조회해 합산하기
상황 배경: 한 사용자의 프로필 화면에서, 그 사람이 올린 게시물들의 좋아요 수를 모두 더해 "총 좋아요" 를 보여주려 해요. 게시물마다 좋아요 수를 세는 일을 동시에 맡기고, 결과를 모아 합쳐봐요. Step 3 의 ThumbnailFuture 를 참고하세요.
🎯 해결 미션:
LikeAggregator같은 클래스에totalLikes(int[] postLikeCounts)메서드를 두세요.- 각 게시물의 좋아요 수를 돌려주는 일을
Callable로 만들어submit하고, 돌려받은Future<Integer>들을 리스트에 먼저 전부 모으세요. - 그다음에
Future를 하나씩get()해서 합산하세요. (먼저 다 던지고 나중에 거두는 순서가 왜 중요한지 떠올려보세요.) totalLikes(new int[]{10, 20, 30})이 60 을 돌려주는지 확인하세요. 만약submit하자마자 바로get()했다면 무엇이 달라지는지 한 줄로 적어보세요.
과제 3 (심화): 업로드가 모두 끝나면 공개 + 성공 수를 Atomic으로 세기
상황 배경: 이미지 여러 장을 올리는 게시물에서, 전부 업로드되면 게시물을 공개하면서, 동시에 성공한 업로드 수 도 정확히 세고 싶어요. Step 6·7 의 AtomicLikeCounter·UploadLatch 를 합쳐봐요.
🎯 해결 미션:
AlbumPublisher같은 클래스에publish(int imageCount)메서드를 두세요.CountDownLatch를imageCount로 시작하고,AtomicInteger successCount도 하나 두세요.- 풀에
imageCount개의 업로드 일감을 던지되, 각 일감은 업로드 성공 시successCount.incrementAndGet()을 부르고, 끝나면 꼭latch.countDown()을 하게 하세요. (성공 여부와 상관없이countDown은finally에서 하는 게 안전해요 — 왜 그럴지 생각해보세요.) latch.await(...)로 전부 끝나길 기다린 뒤, 공개 여부와successCount를 함께 돌려주세요.publish(8)에서 성공 수가 8 로 정확히 맞는지, 여러 번 실행해도 그런지 확인하세요.
생각해볼 주제
혼자 고민해도 좋고, 동료와 토론해도 좋아요. 정답을 외우기보다 "나라면 어떻게 설명할까" 를 떠올리며 읽어보세요.
1. ExecutorService를 닫지 않으면 프로그램이 안 끝나는데, 왜 그럴까?
스레드풀을 만들어 일감을 다 처리했는데도, shutdown() 을 깜빡하면 프로그램이 끝나지 않고 멈춰 있는 걸 볼 수 있어요. 분명히 할 일은 다 했는데 말이죠.
풀의 일꾼 스레드가 일감이 없을 때 무엇을 하고 있는지 떠올려보세요. Day 33 에서 배운 "스레드가 살아 있는 동안엔 프로그램이 안 끝난다" 는 것과 연결해보면, 왜 풀을 꼭 닫아야 하는지, 그리고 try-with-resources 가 왜 그렇게 든든한지 보일 거예요.
2. Future.get()이 "막혀서 기다린다"는 게 왜 아쉬울까?
Future 로 결과를 미래에 받는 건 분명 편해요. 그런데 외부 API 세 곳을 호출해 결과를 합쳐야 하는 상황을 그려보세요. get() 으로 첫 결과를 기다리는 동안 그 스레드는 아무 일도 못 하고 멈춰 있어요.
세 호출을 먼저 다 submit 해두면 동시에 진행은 되지만, 결과를 "이어 붙이는" 흐름 — 예를 들어 "첫 결과가 오면 그걸로 둘째 호출을 하고" 같은 — 을 짜려면 여전히 번거로워요. 무엇이 불편한지 구체적으로 짚어보면, 다음 시간에 배울 CompletableFuture 가 왜 필요한지 미리 감이 잡힐 거예요.
3. Atomic이 synchronized보다 가벼운데, 그럼 항상 Atomic만 쓰면 될까?
AtomicInteger 로 좋아요 수를 세니 synchronized 없이도 정확하고 가벼웠어요. 그렇다면 모든 동기화를 Atomic 으로 바꾸면 더 빠르고 좋지 않을까요?
"계좌 A 에서 100 을 빼서 계좌 B 에 100 을 더한다" 같은, 두 값이 한 묶음으로 바뀌어야 하는 상황을 떠올려보세요. Atomic 은 숫자 하나씩만 지켜줘요. A 를 줄이는 것과 B 를 늘리는 것 사이에 다른 스레드가 끼어들면 어떻게 될까요? Atomic 으로 충분한 경우와, 여전히 락이 필요한 경우의 경계가 어디인지 생각해보세요.
✅ 예시 답안정답 보기
오늘 과제는 스레드를 직접 만들지 않고 도구에 맡기는 흐름이에요. 과제 1 은 알림을 스레드풀에 던져 한꺼번에 처리하고, 과제 2 는 여러 결과를 Future 로 모아 합산하고, 과제 3 은 CountDownLatch 로 전부 끝나길 기다리면서 AtomicInteger 로 성공 수를 안전하게 세요. concurrent 패키지의 오늘 예제(ExecutorIntro·FeedTaskExecutor·ThumbnailFuture·UploadLatch·AtomicLikeCounter)를 옆에 두고 비교하면 편해요.
과제 예시답안
과제 1 예시답안 — 댓글 알림을 스레드풀로 한꺼번에 보내기
핵심 접근
알림 하나하나를 일감으로 만들어 고정 풀에 던지는 게 전부예요. 핵심은 풀을 try-with-resources 로 감싸는 거예요. 그러면 try 블록을 벗어날 때 close() 가 자동으로 호출되고, 이 close() 가 던진 일감이 전부 끝날 때까지 기다린 뒤 풀을 닫아줘요. 그래서 블록을 벗어난 다음 수거함 크기를 읽으면 항상 정확히 notificationCount 가 나와요. 보낸 알림은 여러 스레드가 동시에 넣어도 안전한 ConcurrentLinkedQueue 에 모아요.
예시 구현
// com/instagram/javabasic/concurrent/solution/day35/NotificationDispatcher.java
public class NotificationDispatcher {
public static int dispatchAll(int notificationCount) {
ConcurrentLinkedQueue<Integer> sent = new ConcurrentLinkedQueue<>();
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
for (int i = 1; i <= notificationCount; i++) {
int notificationId = i;
pool.submit(() -> sent.add(notificationId)); // 알림 한 건 발송을 일감으로
}
} // ← close(): 던진 알림이 전부 처리될 때까지 기다린 뒤 풀을 닫아요
return sent.size();
}
}
dispatchAll(20) 은 일꾼이 4명뿐이지만 알림 20건을 나눠 처리하고, close() 가 완료를 보장해 항상 20 을 돌려줘요. "왜 20 이 보장되는가" 의 답은 한 줄로 — try-with-resources 의 close() 가 모든 일감이 끝날 때까지 기다려주기 때문 이에요.
채점 포인트
| 항목 | 확인 |
|---|---|
newFixedThreadPool 으로 풀을 만들고 일감을 submit 했는가 |
✅ |
| 풀을 try-with-resources 로 감싸 자동으로 닫히게 했는가 | ✅ |
보낸 알림을 ConcurrentLinkedQueue 같은 안전한 수거함에 모았는가 |
✅ |
dispatchAll(20) 이 항상 20 을 돌려주는 이유(close 가 완료를 기다림)를 설명했는가 |
✅ |
흔한 실수
- try-with-resources 없이
shutdown()·awaitTermination()을 빠뜨리면, 일감이 아직 처리 중인데sent.size()를 읽어 20 보다 작은 값이 나올 수 있어요. 풀을 닫고 기다리는 단계를 꼭 거쳐야 해요. - 수거함을 평범한
ArrayList로 두면, 여러 스레드가 동시에add하다가 오히려 경쟁 상태가 생겨요(Day 34 의 그 함정!). 동시 접근에는ConcurrentLinkedQueue같은 안전한 컬렉션을 써야 해요.
과제 2 예시답안 — 여러 게시물의 좋아요 수를 동시에 조회해 합산하기
핵심 접근
게시물마다 좋아요 수를 세는 일을 Callable 로 만들어 submit 하면 Future<Integer> 를 돌려받아요. 여기서 순서가 중요해요. Future 들을 먼저 전부 모은 다음, 나중에 하나씩 get() 으로 거둬야 해요. 그래야 일꾼들이 동시에 일하거든요. 만약 submit 하자마자 바로 get() 으로 기다리면, 첫 게시물이 끝나야 둘째를 시작하니 결국 하나씩 차례로 한 셈이 돼요.
예시 구현
// com/instagram/javabasic/concurrent/solution/day35/LikeAggregator.java
public static int totalLikes(int[] postLikeCounts) throws InterruptedException, ExecutionException {
try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
// 1) 게시물마다 좋아요 수를 돌려주는 일을 submit 하고 Future 를 먼저 전부 모아요.
List<Future<Integer>> futures = new ArrayList<>();
for (int likes : postLikeCounts) {
futures.add(pool.submit(() -> likes));
}
// 2) 그다음에 하나씩 get() 으로 결과를 거둬 합산해요.
int total = 0;
for (Future<Integer> f : futures) {
total += f.get();
}
return total;
}
}
totalLikes(new int[]{10, 20, 30}) 은 60 을 돌려줘요. "만약 submit 하자마자 바로 get() 했다면?" 의 답은 한 줄로 — 결과는 똑같이 60 이지만, 동시에 안 돌고 하나씩 차례로 처리돼서 느려져요.
채점 포인트
| 항목 | 확인 |
|---|---|
좋아요 수를 돌려주는 일을 Callable 로 만들어 submit 했는가 |
✅ |
Future 를 먼저 전부 모은 뒤 get() 으로 거뒀는가 |
✅ |
totalLikes({10,20,30}) 이 60 을 돌려주는가 |
✅ |
| "먼저 다 던지고 나중에 거두는" 순서가 왜 중요한지(동시 처리) 설명했는가 | ✅ |
흔한 실수
for루프 안에서pool.submit(...).get()처럼 던지자마자 바로 거두면, 동시성이 사라지고 순차 실행이 돼요. 결과는 맞지만 빠르지 않아요.Callable의 결과 타입과Future<Integer>의 타입을 맞추지 않아 컴파일 에러가 나기 쉬워요. "결과를return하는 일 =Callable, 그 영수증 =Future" 를 기억하세요.
과제 3 예시답안 — 업로드가 모두 끝나면 공개 + 성공 수를 Atomic으로 세기
핵심 접근
두 도구를 합쳐요. CountDownLatch 로 "전부 끝나길" 기다리고, AtomicInteger 로 성공 수를 락 없이 안전하게 세요. 가장 중요한 건 countDown() 을 finally 에서 부르는 거예요. 만약 업로드 중 하나가 실패해서 예외가 나면, countDown() 을 건너뛰게 되고 그러면 카운트가 0 이 안 돼 await() 가 영영 안 풀려요(게시물이 영영 공개 안 됨). 그래서 성공이든 실패든 카운트는 반드시 줄여야 해요.
예시 구현
// com/instagram/javabasic/concurrent/solution/day35/AlbumPublisher.java
public record PublishResult(boolean published, int successCount) {}
public static PublishResult publish(int imageCount) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(imageCount); // 전부 끝나길 기다릴 카운트
AtomicInteger successCount = new AtomicInteger(); // 성공 수를 락 없이 안전하게
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int id = 1; id <= imageCount; id++) {
pool.submit(() -> {
try {
successCount.incrementAndGet(); // 업로드 성공 (여기선 모두 성공으로 가정)
} finally {
latch.countDown(); // 성공이든 실패든 카운트는 꼭 줄인다
}
});
}
boolean published = latch.await(5, TimeUnit.SECONDS); // 전부 끝나면 공개
pool.shutdown();
return new PublishResult(published, successCount.get());
}
publish(8) 은 8장이 모두 끝나길 기다린 뒤 published=true, successCount=8 을 돌려줘요. 여러 번 실행해도 AtomicInteger 라 성공 수가 한 건도 안 새고 정확히 8 이에요.
채점 포인트
| 항목 | 확인 |
|---|---|
CountDownLatch 를 imageCount 로 시작하고 await() 로 기다렸는가 |
✅ |
성공 수를 AtomicInteger.incrementAndGet() 으로 안전하게 셌는가 |
✅ |
countDown() 을 finally 에 둬서 실패해도 카운트가 줄도록 했는가 |
✅ |
publish(8) 의 성공 수가 여러 번 실행해도 정확히 8 인가 |
✅ |
흔한 실수
countDown()을try본문 끝에 두면, 일감이 중간에 예외로 끊겼을 때 카운트가 안 줄어await()가 영영 안 풀려요. 반드시finally에 둬야 해요.- 성공 수를 평범한
int로 세면 Day 34 의 경쟁 상태가 그대로 재현돼 숫자가 새요. 여러 스레드가 함께 세는 값은AtomicInteger로 해야 정확해요.
생각해볼 주제 예시답안
생각해볼 주제 1 예시답안 — ExecutorService를 닫지 않으면 프로그램이 안 끝나는데, 왜 그럴까?
[문제 상황 요약]
스레드풀로 일감을 다 처리했는데도, shutdown() 을 깜빡하면 프로그램이 끝나지 않고 멈춰 있어요. 할 일은 다 했는데 왜 안 끝날까요?
[튜터의 가이드 및 해설]
풀의 일꾼 스레드가 "일감이 없을 때 무엇을 하고 있는지" 를 떠올리면 답이 보여요. 일꾼 스레드는 일감이 떨어져도 죽지 않고, "혹시 또 일감이 올까" 하며 계속 살아서 대기해요. 재사용이 목적이니 당연한 거죠.
그런데 Day 33 에서 배웠듯, 자바 프로그램은 살아 있는 (데몬이 아닌) 스레드가 하나라도 남아 있으면 끝나지 않아요. main 이 끝나도, 풀의 일꾼 스레드들이 계속 대기 중이라 프로그램 전체가 종료되지 못하고 매달려 있는 거예요.
그래서 풀은 다 쓰면 shutdown() 으로 "이제 그만, 더는 일감 안 와" 라고 알려서 일꾼들이 정리되게 해야 해요. 그런데 이걸 손으로 챙기면 깜빡하기 쉽죠. 그래서 try-with-resources 가 든든한 거예요. 블록을 벗어나는 순간 close() 가 자동으로 shutdown 을 해주니, 닫는 걸 잊을 수가 없어요. "자원은 빌렸으면 반드시 돌려준다" 는 원칙이 스레드풀에도 똑같이 적용되는 셈이에요.
🎯 면접관을 홀리는 핵심 멘트
"ExecutorService 의 일꾼 스레드는 재사용을 위해 일감이 없어도 살아서 대기합니다. 자바는 데몬이 아닌 스레드가 하나라도 남아 있으면 종료되지 않으므로, 풀을 닫지 않으면 main 이 끝나도 프로그램이 매달립니다. 그래서 저는 풀을 반드시 shutdown 하거나, JDK 19+ 부터 ExecutorService 가 AutoCloseable 인 점을 활용해 try-with-resources 로 감싸 close() 가 자동으로 정리하도록 합니다. 자원 해제를 코드 구조로 강제하는 게 깜빡 실수를 없애는 핵심이라고 봅니다."
생각해볼 주제 2 예시답안 — Future.get()이 "막혀서 기다린다"는 게 왜 아쉬울까?
[문제 상황 요약]
Future 로 결과를 미래에 받는 건 편해요. 그런데 외부 API 세 곳을 호출해 결과를 합쳐야 한다면, get() 으로 첫 결과를 기다리는 동안 그 스레드는 아무 일도 못 하고 멈춰 있어요. 무엇이 불편한 걸까요?
[튜터의 가이드 및 해설]
두 가지가 아쉬워요. 하나는 기다리는 스레드가 놀고 있다 는 점이에요. get() 은 결과가 올 때까지 그 스레드를 막아둬요. 세 호출을 먼저 다 submit 해두면 호출들끼리는 동시에 진행되지만, 결과를 거두는 스레드는 여전히 get() 마다 멈춰 서서 기다려요.
다른 하나는 작업을 이어 붙이기가 번거롭다 는 점이에요. 예를 들어 "첫 API 결과가 오면 그걸 입력으로 둘째 API 를 호출하고, 그 결과가 오면 셋을 합친다" 같은 흐름을 짠다고 해봐요. Future 만으로는 get() 으로 결과를 꺼내고, 그걸 다시 submit 하고, 또 get() 하고… 이런 식으로 손이 많이 가요. "결과가 나오면 알아서 이어서 이걸 해줘" 라고 선언하는 방식이 아니거든요.
그래서 자바에는 결과를 막힘 없이 연결하는 도구가 따로 있어요. 다음 시간에 배울 CompletableFuture 인데, thenApply·thenCompose 같은 메서드로 "결과가 오면 이어서 이걸" 을 줄줄이 엮을 수 있어요. 오늘 Future 의 블로킹을 직접 느껴봤기 때문에, 그게 왜 반가운 도구인지 금방 와닿을 거예요.
🎯 면접관을 홀리는 핵심 멘트
"Future.get() 은 블로킹입니다. 결과가 올 때까지 호출 스레드를 멈춰 세우고, 여러 비동기 작업을 '결과가 오면 이어서' 형태로 조합하기도 번거롭습니다. submit 을 먼저 모아 동시 실행은 시킬 수 있지만, 거두는 단계가 여전히 동기적입니다. 그래서 작업을 연결해야 할 때는 CompletableFuture 의 thenCompose·thenCombine 으로 콜백을 선언적으로 엮어, 스레드를 막지 않고 파이프라인을 구성합니다. Future 는 결과를 받는 출발점, CompletableFuture 는 결과를 조합하는 도구라고 구분합니다."
생각해볼 주제 3 예시답안 — Atomic이 synchronized보다 가벼운데, 그럼 항상 Atomic만 쓰면 될까?
[문제 상황 요약]
AtomicInteger 로 좋아요 수를 세니 synchronized 없이도 정확하고 가벼웠어요. 그렇다면 모든 동기화를 Atomic 으로 바꾸면 더 빠르고 좋지 않을까요?
[튜터의 가이드 및 해설]
경계는 "값 하나 냐, 여러 값의 묶음 이냐" 예요. Atomic 은 숫자 하나를 안전하게 다룰 때 빛나요. 좋아요 수, 조회수처럼 단일 값을 세고 더하는 거라면 락보다 가볍고 빨라요.
그런데 "계좌 A 에서 100 을 빼서 계좌 B 에 100 을 더한다" 를 떠올려봐요. 여기엔 값이 둘이에요. A 를 줄이는 동작과 B 를 늘리는 동작이 통째로 한 번에 일어나야, 중간에 누가 잔액을 들여다봐도 "돈이 사라지거나 두 배가 된" 이상한 상태를 안 봐요. Atomic 은 A 와 B 를 각각 안전하게 지켜줄 뿐, 둘을 하나로 묶어주진 못해요. A 를 줄인 직후 B 를 늘리기 전에 다른 스레드가 끼어들 수 있거든요.
이렇게 여러 값이 함께 지켜야 하는 약속(불변식) 이 있을 때는 여전히 synchronized 같은 락으로 그 구간 전체를 보호해야 해요. 그래서 둘은 경쟁 관계가 아니라 쓰임이 달라요. 단일 값의 카운터·플래그에는 Atomic, 여러 값을 한 묶음으로 바꿔야 하는 복합 동작에는 락 — 이렇게 구분하면 돼요.
🎯 면접관을 홀리는 핵심 멘트
"Atomic 과 synchronized 는 보호 단위가 다릅니다. AtomicInteger 는 단일 변수의 읽기-수정-쓰기를 CAS 로 락 없이 원자적으로 만들어, 카운터나 플래그에는 synchronized 보다 가볍고 빠릅니다. 하지만 '계좌 이체' 처럼 두 개 이상의 값이 하나의 불변식으로 함께 바뀌어야 하는 경우엔 Atomic 하나로 묶을 수 없어, 그 구간 전체를 락으로 보호해야 합니다. 그래서 저는 단일 값은 Atomic, 복합 상태의 불변식은 락으로 구분해 적용합니다."