1. 들어가며
이전 포스팅에서는 Spring Boot가 어떻게 동시 요청을 처리하는지를 Tomcat의 스레드풀 관점에서 살펴보았다. 당시에는 스레드가 요청마다 어떻게 할당되고, 동기 방식의 로직이 어떤 한계를 가지는지까지 함께 정리했다.
2025.03.09 - [삽질로그] - 여러 요청이 동시에 들어오면, Spring Boot는 어떻게 처리할까?
여러 요청이 동시에 들어오면, Spring Boot는 어떻게 처리할까?
1. 들어가며지난 글에서는 웹소켓 핸들러를 Event 기반으로 처리하여 동기 방식으로 해결했다.이를 통해 코드가 더 깔끔해지고 유지보수가 쉬워졌지만, 한 가지 새로운 고민이 생겼다. 🔗 이전
mingking2.tistory.com
이처럼 스레드풀과 비동기 처리 구조에 대한 지속적인 관심은 단순한 학습 차원이 아닌, 실제 문제 해결을 위한 실무적 배경에서 비롯되었다.
이번 글에서는 실제로 구축한 IoT 대여 시스템에서 발생한 “요청 병목” 문제를 해결한 사례를 바탕으로, Spring의 비동기 처리 기술 중 하나인 CompletableFuture를 어떻게 적용했는지 구체적으로 설명한다.
특히 하나의 아두이노 장치가 8개의 슬롯을 제어하는 구조 속에서, 문을 여닫는 요청이 순차적으로 처리되어 발생한 병목 문제, 그리고 이를 CompletableFuture로 병렬 처리하며 데이터 정합성을 보장하는 설계 구조를 중심으로 공유하고자 한다.
2. 시스템 구조 요약
이번에 구축한 IoT 대여 시스템은 다음과 같은 흐름으로 구성되어 있다:
- 사용자가 어플리케이션에서 QR 코드를 스캔해 대여 요청을 보낸다.
- 요청은 Spring Boot 기반의 라즈베리파이 서버로 전송되며, 이 서버는 WebSocket을 통해 아두이노(대여기)와 실시간 통신한다.
- 하나의 아두이노 장치는 총 8개의 슬롯(물리적인 문)을 제어하며, 각각의 슬롯은 솔레노이드로 작동한다.
이러한 구조에서의 요청 처리 흐름은 다음과 같다:
[사용자] → 대여 요청 → [Spring 서버] → DB 반영 → WebSocket → [아두이노] → 문 제어 → 응답
이 구조에서는 서버가 DB에 대여 상태를 먼저 반영한 뒤, WebSocket을 통해 아두이노에 명령을 전달한다.
즉, 시스템 전반에 비동기적 요소가 있음에도 실제 처리 흐름은 동기적으로 고정되어 있다.
3. 문제 정의
📌 문제 1: 순차 처리로 인한 병목
기존 구현에서는 각 대여 요청을 하나씩 순차적으로 처리하도록 설계되어 있다.
- 예를 들어, 사용자가 슬롯 1을 대여하는 동안, 슬롯 2에 대한 요청은 무조건 대기해야 한다.
- 아두이노는 8개의 슬롯을 동시에 제어할 수 있지만, 서버의 로직이 모든 요청을 직렬화하고 있기 때문에 성능이 극단적으로 저하된다.
➡️ 결과적으로, 3초짜리 요청이 3개가 들어오면, 전체 처리 시간은 9초가 된다.
이 구조는 사용자 수가 늘어날수록 병목이 가중된다.
📌 문제 2: 응답 이전의 DB 반영으로 인한 정합성 문제
초기 시스템 설계에서는 아두이노로 명령을 전송하기 전에, 이미 사용자 요청을 DB에 반영(대여 중 처리)하고 있었다.
이 구조는 다음과 같은 심각한 문제를 유발한다:
- 아두이노가 응답을 실패하거나 문 제어에 실패해도, DB는 이미 '대여 완료'로 기록된다.
- 중복 대여, 반납 불가, 시스템 충돌 등의 문제가 필연적으로 발생한다.
- 사용자는 문이 열리지 않았음에도 성공 알림을 받는다.
➡️ 결과적으로, 시스템은 실제 장치의 상태와 DB 상태가 불일치하게 되고,
사용자는 이용이 불가능한 슬롯을 '대여 완료' 상태로 인식하게 된다.
이는 중복 대여, 비정상 반납, 관리자의 수동 개입으로 이어지며 시스템 신뢰도를 크게 훼손한다.
4. 해결 방향
기존 구조는 처리 순서를 고정하여 병목과 정합성 문제를 유발했다.
이를 해결하기 위해 서버는 다음과 같은 두 가지 조건을 동시에 만족해야 한다:
- 요청을 병렬로 처리할 수 있어야 한다.
- 아두이노의 실제 응답을 받은 이후에만 DB를 변경해야 한다.
이 두 조건을 만족하기 위해서는 비동기 처리가 필수적이며, Spring에서는 대표적으로 다음 두 가지 방식이 존재한다:
- @Async: 간단한 비동기 처리에 유용하다.
- CompletableFuture: 흐름 제어와 정합성 제어에 적합하다.
이제 각각의 비동기 처리 방식이 어떤 구조와 특징을 갖고 있으며,
본 시스템에 얼마나 적합한지를 자세히 분석해보자.
5. @Async - Spring의 기본 비동기 처리 방식
📌 개념과 원리
@Async는 Spring에서 가장 간단하게 사용할 수 있는 비동기 처리 방식이다.
해당 어노테이션을 메서드에 붙이면, 메인 스레드와는 별도로 구성된 스레드 풀(TaskExecutor) 내의 스레드에서 비동기적으로 해당 메서드를 실행한다.
@Async
public void openDoor(Long slotId) {
arduinoClient.sendOpenCommand(slotId);
}
이렇게 호출된 openDoor()는 호출자의 실행 흐름과 분리되어 백그라운드에서 실행되므로,
호출자는 결과를 기다리지 않고 즉시 다음 로직을 이어갈 수 있다.
✅ 장점
사용이 간편하다.
- @EnableAsync 설정만으로 바로 사용 가능
- 메서드 수준에서 비즈니스 로직을 간단히 분리할 수 있음
- void, Future<T>, CompletableFuture<T> 형태로 결과를 반환 가능
⚠️ 한계점
1. 예외 처리 어려움
@Async 내부에서 발생한 예외는 호출 측에서 잡을 수 없다. 기본적으로 로그만 남기고 흐름이 끊긴다.
Future나 CompletableFuture로 감싸도, 그 값을 명시적으로 .get() 하지 않으면 예외가 호출자에게 전달되지 않는다.
2. 흐름 제어 불가
후속 작업을 .thenApply(), .thenRun() 등으로 체이닝하거나, 조건 분기 흐름을 제어할 수 없다.
@Async는 단순히 "백그라운드에서 돌리기" 용도다.
3. 타임아웃 미지원
@Async 자체에는 실행 시간 제한 기능이 없다. 따라서 장시간 대기나 응답 없음 문제에 대응이 어렵다.
4. 후속 트리거 연결 어려움
비동기 작업의 결과를 기반으로 후속 작업(DB 반영 등)을 연결하는 구조가 불편하다.
복잡한 로직에서는 구조가 비대해지고 유지보수가 어렵다.
📉 우리 시스템과의 적합성
@Async는 간단한 offloading, 예: 이메일 발송, Slack 알림, 보고서 생성 등의 작업에는 매우 유용하다.
하지만 우리의 IoT 대여 시스템은 다음과 같은 조건을 요구한다:
- 하드웨어의 명확한 응답 이후에만 DB 변경
- 예외 발생 시 로깅 및 사용자 피드백 필요
- 슬롯 요청이 병렬로 처리되어야 함
➡️ 따라서 @Async는 우리 시스템에는 적합하지 않다.
🔄 점진적 확장
물론 @Async와 CompletableFuture를 함께 사용할 수도 있다.
@Async
public CompletableFuture<String> asyncOpenDoor(Long slotId) {
// 처리 로직...
return CompletableFuture.completedFuture("success");
}
하지만 이 방식도 .thenApply() 등 체이닝 로직을 자연스럽게 연결하기 어렵고, 전체 흐름 제어에는 제약이 많다.
이 한계를 보완하는 방식이 바로 다음에 설명할 CompletableFuture이다.
6. CompletableFuture - 비동기 흐름 제어의 핵심
💡 CompletableFuture를 이해하기 전에: Future란?
Java의 비동기 처리 개념에서 Future는 “미래에 결과가 도착할 거야”라고 약속하는 객체이다.
즉, 지금은 결과가 없지만, 나중에 결과가 준비되면 .get() 같은 메서드로 받아올 수 있다.
그러나 기존 Future는 다음과 같은 제약이 있었다:
- 결과를 기다리려면 무조건 .get()으로 블로킹해야 한다.
- 예외 처리나 후속 작업 연결이 어렵다.
🔄 그래서 나온 것이 CompletableFuture
CompletableFuture는 Future의 한계를 극복한 비동기 흐름 제어 객체이다.
특히 작업 완료 후 자동 후속 실행, 예외 처리, 타임아웃, 병렬 흐름 제어 등 다양한 기능을 제공한다.
🎯 쉽게 말해:
Future는 “결과가 오면 알려줘”
CompletableFuture는 “결과가 오면 이렇게 처리하고, 실패하면 이렇게 대처하고, 그 후엔 이 작업도 이어서 해!”
까지 전부 체계적으로 설계할 수 있는 비동기 설계 도구이다.
📌 개념 및 특징
CompletableFuture는 Java 8부터 도입된 표준 비동기 처리 API로, 복잡한 비동기 흐름을 체이닝 방식으로 제어할 수 있다.
public CompletableFuture<Void> processRental(Long slotId) {
return CompletableFuture.runAsync(() -> {
arduinoClient.sendOpenCommand(slotId);
}).thenRunAsync(() -> {
rentalRepository.updateStatus(slotId, "대여완료");
}).exceptionally(ex -> {
log.error("슬롯 " + slotId + " 처리 중 오류 발생: " + ex.getMessage());
return null;
});
}
✏️ 주요 기능
| 분류 | 메서드 | 설명 |
| 작업 시작 | supplyAsync() | 값을 반환하는 비동기 작업 시작 |
| runAsync() | 값을 반환하지 않는 비동기 작업 시작 | |
| 후속 작업 연결 | thenApply() | 이전 결과를 받아 새로운 값을 반환 |
| thenRun() | 이전 결과와 무관한 후속 작업 실행 | |
| 예외 처리 | exceptionally() | 예외 발생 시 대체 로직 실행 |
| handle() | 성공/실패 모두 처리 가능 | |
| 타임아웃 처리 | orTimeout() | 지정한 시간 초과 시 예외 발생 |
| completeOnTimeout() | 시간 초과 시 기본 값 반환하고 종료 | |
| 복수 작업 제어 | thenCombine() | 두 작업의 결과를 조합 |
| allOf() | 여러 작업을 병렬로 실행하고 모두 완료될 때까지 대기 | |
| 후처리 및 정리 | whenComplete() | 성공/실패 관계없이 후처리 실행 |
| 스레드 풀 지정 | Executor | 사용자 지정 스레드 풀에서 작업 실행 가능 |
✅ 장점
| 항목 | 설명 |
| 정밀한 흐름 제어 | 후속 작업을 조건, 순서, 병렬 여부에 따라 자유롭게 체이닝 가능 |
| 예외 핸들링 | 각 단계에서 발생한 예외를 잡고 처리 가능 |
| 타임아웃 설정 | .orTimeout() 등으로 작업 제한시간 지정 가능 |
| 후속 트리거 | 응답 성공 여부에 따라 DB 처리, 알림 등 연결 가능 |
📌 @Async + CompletableFuture vs 순수 CompletableFuture
| 방식 | 설명 |
| @Async + CompletableFuture | Spring의 빈 관리, 트랜잭션 등과 통합은 유리하나, 흐름 제어에는 한계가 있다 |
| 순수 CompletableFuture | 더 유연하고 강력한 흐름 제어 구조 구현 가능. 다만 스레드 풀(Executor) 설정 필요 |
➡️ 실무에서는 상황에 따라 혼용하지만, 흐름 제어 중심 시스템에서는 순수 CompletableFuture를 선호한다.
📉 우리 시스템과의 적합성
우리 IoT 시스템은 다음 요건을 가진다:
- 문이 실제 열렸을 때에만 DB 변경
- 아두이노 응답 실패 시 예외 처리 필요
- 병렬 요청 처리 필수
- 실패 시 사용자 알림 또는 관리 알림 필요
➡️ CompletableFuture는 위 요구를 모두 만족하며, 정합성 확보와 실시간 응답 제어에 가장 적합한 구조를 제공한다.
7. 어떻게 CompletableFuture를 적용했는가?
앞서 언급했듯이 @Async 기반 비동기 처리 방식은 단순한 offloading 용도에는 적합하지만, 하드웨어 응답을 기준으로 흐름을 제어하고, 그 결과에 따라 DB 상태를 일관되게 유지해야 하는 상황에서는 한계를 드러낸다.
이에 따라 우리는 CompletableFuture를 핵심 처리 흐름에 직접 도입하여 요청 병렬 처리 + 응답 기반 DB 반영 + 예외 제어 + 타임아웃 제어를 동시에 구현하였다.
📌 전체 흐름 요약

- 요청 수신 즉시 DB를 업데이트하지 않고, 아두이노의 실제 응답을 받은 이후에만 thenApply() 내부에서 DB를 갱신한다.
- 타임아웃 또는 예외가 발생하면 .orTimeout() 및 .exceptionally()을 통해 제어한다.
- .thenApply() 이후 사용자에게 성공/실패 응답 반환한다.
✏️ 요청 등록 및 응답 매핑
public class WebSocketResponseManager {
private final ConcurrentHashMap<String, CompletableFuture<String>> responseMap = new ConcurrentHashMap<>();
public CompletableFuture<String> registerRentalRequest(String payload) {
CompletableFuture<String> future = new CompletableFuture<>();
responseMap.put(payload, future);
return future;
}
public void completeRental(String payload) {
CompletableFuture<String> future = responseMap.remove(payload);
if (future != null) {
future.complete(payload);
} else {
log.warn("❌ Future not found for payload: {}", payload);
}
}
}
- 사용자가 대여 요청을 보내면 해당 payload를 key로 CompletableFuture를 등록해 둔다.
- 이후 아두이노가 WebSocket을 통해 응답을 보내오면, completeRental()을 통해 future를 완료시킨다.
✏️ 대여 요청 핸들러
public CompletableFuture<Void> handleRental(String payload, Long slotId) {
return responseManager.registerRentalRequest(payload)
.orTimeout(5, TimeUnit.SECONDS) // 5초 내 응답 없으면 실패
.thenApply(response -> {
// ✅ 응답이 도착한 경우
rentalRepository.updateStatus(slotId, "대여완료");
return response;
})
.exceptionally(ex -> {
// ❌ 예외 또는 타임아웃 발생
log.error("응답 실패 또는 타임아웃: {}", ex.getMessage());
notificationService.alertAdmin(slotId, "대여 실패");
return null;
})
.thenRun(() -> {
log.info("슬롯 {}에 대한 대여 처리가 완료되었습니다.", slotId);
});
}
- registerRentalRequest()를 통해 대기 상태 등록
- WebSocket 응답 도착 시 thenApply()로 후속 처리
- 예외 발생 시 exceptionally()로 관리자 알림
- 타임아웃 설정: .orTimeout(5, TimeUnit.SECONDS)
📉 실제 적용 결과
- 기존 구조에서는 동시 3건 요청 시 총 9초 소요되던 요청 처리 시간이, CompletableFuture 적용 후 실제 문 개방 시간(약 3초)만큼만 소요되도록 개선됨.
- 정합성 문제도 사라짐: 아두이노 응답 없을 경우 DB 갱신이 일어나지 않음.
- 실패 대응도 자동화되어, 장비 오류 → DB 정합성 훼손 → 수동 개입의 악순환이 차단됨.
이 구조를 통해 우리는 단순한 “비동기 호출”이 아닌,
명확한 응답 흐름 기반의 제어된 병렬 시스템을 구성할 수 있었다.
8. 결론
이번 프로젝트에서는 단순한 비동기 호출을 넘어서, 실제 하드웨어 응답 기반으로 흐름을 제어하는 병렬 시스템을 구현했다.
기존 구조에서는 요청을 순차적으로 처리하면서 병목이 발생했고, 아두이노의 응답 여부와 상관없이 DB를 먼저 갱신하는 바람에 정합성 문제가 반복되었다. 응답 실패 시 수동 개입이 필요했던 점도 시스템 신뢰성을 떨어뜨렸다.
이를 해결하기 위해 CompletableFuture 기반의 명시적 비동기 흐름 제어 구조를 도입했고,
- 응답 이후에만 DB를 갱신함으로써 정합성을 확보하고
- 요청 병렬 처리로 성능을 향상시키며
- 타임아웃 및 예외 흐름까지 자동화할 수 있었다.
복잡한 구조일수록 기술 자체보다 ‘흐름 설계’가 중요하다는 점을 실감한 경험이었다.
앞으로도 정합성과 응답 기반 처리가 중요한 구조에서는, 이번 설계를 템플릿처럼 확장해볼 생각이다.
'삽질로그' 카테고리의 다른 글
| yml vs properties – 스프링 설정, 무엇이 더 나은 선택인가? (0) | 2025.04.15 |
|---|---|
| Tomcat 해부학 - Spring Boot 안에 살아있다? (0) | 2025.04.09 |
| Tomcat은 그냥 서버가 아니다? (0) | 2025.03.23 |
| Spring Boot 비동기 처리 = 스레드풀? (0) | 2025.03.16 |
| 여러 요청이 동시에 들어오면, Spring Boot는 어떻게 처리할까? (2) | 2025.03.09 |
