"비동기로 처리하면 더 빨라요"
이 말을 백엔드 개발자라면 한 번쯤 들어봤을 것이다.
하지만 진짜로 빠를까?
사실 비동기는 "더 빨라진다"보다 "기다리는 시간을 겹친다"가 더 정확한 표현이다.
이번 글에서는 동기/비동기의 개념 부터 해서 동기 코드를 어떻게 해야 성능 개선된 비동기 코드로 만들 수 있는지에 대해서 다루어 볼 것이다.
1. 동기/비동기 톺아보기 (feat . Blocking / NonBlocking)
많은 개발자들이 비동기를 "동시에 여러 작업을 한다"로 이해하지만, 본질은 다르다.
비동기의 핵심은 "스레드를 붙잡고 기다리느냐(Blocking)",
혹은 "스레드를 다른 일에 돌려주느냐(NonBlocking)"에 있다.
- 동기 : 요청을 보낸 스레드가 결과를 받을 때까지 대기
-> 스레드는 아무 일도 못함 - 비동기 : 요청을 보낸 스레드는 즉시 반환,
결과는 콜백 / 코루틴을 통해 나중에 재개
-> 같은 스레드가 여러 작업을 효율적으로 전환
즉, 비동기는 "동시에 한다"보다 "기다리지 않는다" 에 초점이 있다.
䷸ 그럼 블로킹 / 논블로킹은 뭔데?
하지만 위까지만 보면
"동기 = Blocking"
"비동기 = NonBlocking" 으로 보이기 쉽다. 하지만 이건 부분적으로만 맞는 말이다.
이 네가지는 서로 다른 차원의 개념인데 즉, 호출 방식과 대기 방식이 겹쳐서 만들어지는 2X2 조합이다.
아래 이미지를 보면 훨씬 명확해진다.

즉, "동기/비동기"는 결과를 누가 확인하느냐의 관점이고,
"Blocking/NonBlocking"은 스레드를 붙잡느냐의 관점이다.
2. 코루틴으로 보는 비동기 병렬의 효과
먼저 동기 코드 예시를 보자
fun loadAllSync(): List<SyncExampleDto> {
val users = userService.getUsers()
val orders = orderService.getOrders()
val products = productService.getProducts()
return SyncExampleDto(users, orders, products)
}
모든 유저 정보, 주문, 상품들을 조회하는 위 코드를 보면 각 호출이 순차적으로 실행하고, 하나가 끝나야 다음이 실행하는 구조인걸 볼 수 잇다.
모든 유저를 조회하는게 1초, 주문들을 조회하는데 1초, 상품들을 조회하는데 1초가 소모된다면
loadAllSync 메서드를 호출하고 리턴받기까지 총 3초가 소모된다.
그리고 코루틴을 통한 비동기 코드 예시를 보자
suspend fun loadAllAsync(): AsyncExampleDto = coroutineScope {
val users = async { userService.getUsers() }
val orders = async { orderService.getOrders() }
val products = async { productService.getProducts() }
AsyncExampleDto(users.await(), orders.await(), products.await())
}
모든 유저정보, 주문, 상품들을 조회하는 위 코드가 동시에 동작하게 된다.
각각 1초씩 걸린다 가정하에 loadAllAsync 메서드를 호출하고 리턴받기까지 총 1초가 소모된다.(가장 느린 작업 기준)
이처럼 비동기는 속도를 높이는 기술이 아니라, 대기 시간을 겹치는 기술이다.
⚠️ 코루틴의 함정 카드
하지만 위 loadAllAsync()에는 큰 함정이 있다.
바로 그 한 요청에서 3개의 자식 코루틴을 병렬로 띄운다는 것이다.
정확히는 coroutineScope { . . . } 자체를 돌리는 부모 코루틴 1개
async { . . . } 로 생기는 자식 코루틴 3개 -> users / orders / products
즉 총 4개의 코루틴이 관여하지만, 병렬 실행되는건 자식 3개이다.
이 상황에서 loadAllAsync에 트래픽이 몰리면 무슨 일이 생길까?
요청 1건에 async 3건이 날라간다.
QPS(초당 날리는 쿼리)가 50이라면 동시에 최대 150개의 외부 호출이 한 인스턴스에서 진행하게 된다.
QPS가 500이라면 최대 1500개까지 증폭된다.
위와 같은 상황은 아래와 같은 문제를 유발한다.
- 리소스 경합
DB 커넥션 풀, Http 커넥션 풀, API QPS 한도 초과 상황이 벌어진다.
대기열 수는 계속해서 늘어나고 p95와 p99가 높아지며 타임아웃과 재시도가 폭발적으로 증가되어 결국 장애 전파까지 도달하게 된다. - 공유 상태 안전성
메모리 캐시/ 집계 버퍼 등 공유 가변 데이터를 여러 코루틴이 건드리면 데이터 레이스가 발생한다.
(참고로 Data Race와 Race Condition은 다른 것이다.)
그래서 Mutex와 Semapore 같은 개념을 꼭 숙지해두어야 정상적인 동시성 처리를 수행할 수 있다.
3. 뮤텍스(Mutex)와 세마포어(Semaphore) - 병렬 제어의 기초
뮤텍스와 세마포어는 동시성 제어의 근본이자 "왜 병렬 처리가 안전하지 않을 수 있는가?" 를 설명하는 중심축이다.
코루틴 비동기는 동시에 수백 개의 작업을 띄울 수 있다.
그렇다면 동시에 실행된다는 건 정말 좋은 일일까??
🤔 만약 모든 요청이 DB에 동시에 접근한다면?
커넥션 풀 초과, 쿼리 대기, CPU 경쟁, OOM ... 이런 "경합"이 오히려 전체 시스템을 느리게 만든다.
그래서 우리는 얼마나 동시에 접근할 수 있는지를 제어해야 한다.
그 역할을 하는것이 바로 세마포어와 뮤텍스이다.
뮤텍스(Mutex)
뮤텍스는 동시 접근이 절대 허용되지 않아야 하는 임계 구역(Critical Section)을 보호한다.
즉, "이 코드 블록은 한 번에 딱 하나의 코루틴만 실행할 수 있다" 는 의미이다.
예시로 캐시 데이터를 갱신할 때 뮤텍스로 Race Condition을 방지하는 코드를 간략하게 알아보자.
val mutex = Mutex()
var cachedData: List<Data> = emptyList()
suspend fun updateCache(newData: List<Data>) {
mutex.withLock {
cachedData = merge(cachedData, newData)
}
}
여러 코루틴이 동시에 updateCache()를 호출하더라도 한 번에 한 개의 코루틴만 withLock 블록에 진입할 수 있다.
이는 Race Condition을 방지하고 데이터 무결성을 보장한다.
이처럼 뮤텍스는 데이터 안전 장치이다. 동시에 실행되는 비동기 로직 속에서도 데이터 일관성을 유지시킨다.
하지만 뮤텍스는 분산 환경에서는 의미가 없다. 단일 어플리케이션 환경일 때만 의미가 있다.
그래서 요즘은 실무에서 뮤텍스를 잘 사용하지는 않는다. 분산 락이나 데이터베이스 수준의 락을 사용한다.
무엇보다, 지금은 병렬의 성능에 대한 이야기를 하고 있으므로 빠르게 세마포어 부분을 알아보도록 하자.
세마포어(Semaphore)
세마포어는 동시에 접근 가능한 작업의 수를 제한하는 장치이다.
설정한 값만큼의 작업만 허용하고, 나머지는 대기열에서 기다린다.
내부적으로 카운터를 가지고 있으면서 코루틴이나 스레드가 작업을 시작할 때 acquire하면서, 작업이 끝나면 release한다.
카운터가 0이 되면 다음 진입자는 대기하게 되는 원리이다.
즉, 최대 N명까지만 들어갈 수 있다는 논리이다.
아래 예시를 보자.
val gate = Semaphore(permits = 10)
suspend fun fetchDataSafely(): List<Data> =
gate.withPermit {
repository.findAll() // 동시에 최대 10개까지만 DB 접근
}
위 상황에서 11번째 코루틴이 들어오게 되면 앞선 10개의 코루틴 중 하나가 release 될 때까지 suspend(대기) 상태로 기다린다.
10개가 초과한 요청이 들어와도 CPU를 점유하지 않는다. 이는 NonBlocking 대기 상태.
이처럼 세마포어는 폭주 방지 장치이다.
시스템이 버틸 수 있는 만큼만 동시 접근을 허용한다.
그러면 세마포어는 분산 환경에서 사용할 수 있을까?
세마포어는 리소스 접근량 제한이 목적이다. 이건 데이터 무결성과는 또 다른 이야기이다.
예를 들어, 각 서버가 DB 커넥션을 10개씩만 허용하도록 세마포어를 걸었다면, 분산 환경(또는 Scale-out) 환경에서도 여전히 효과가 있다.
방금 세마포어 예시 코드를 다시 보자.
val gate = Semaphore(permits = 10)
suspend fun fetchDataSafely(): List<Data> =
gate.withPermit {
repository.findAll() // 동시에 최대 10개까지만 DB 접근
}
서버 A와 B가 있다면 동시에 각각 10개의 DB 쿼리를 실행 가능하다.
전체적으로는 10 * 서버 수 만큼의 쿼리가 동시에 가능하다는 말이다.
이러한 방식은 완벽한 글로벌 제어는 아니지만 각 인스턴스가 자기 자원을 폭주시키지 않게 하는 로컬 안전장치 역할을 할 수 있다.
즉, 세마포어는 분산 환경에서도 개별 인스턴스의 안정성을 확보하는 용도지, 전역 총량을 제어하지는 못한다.
전역 총량을 정해두고 사용하려면 Redis에서 분산 세마포어를 사용하는게 좋다.
4. 병렬 제어는 limitedParallelism으로도 가능하다.
코루틴의 가장 큰 장점은 스레드를 직접 점유하지 않아도 수천 개의 비동기 작업을 동시에 실행할 수 있다는 것이다.
하지만 아까도 말했듯이, 무제한 병렬은 곧 리소스 폭주로 이어진다.
그래서 코틀린에서는 코루틴 레벨에서 병렬 개수를 제한하는 기능인
Dispatchers.IO.limitedparallelism을 제공한다.
limitedParallelism이란?
Dispatchers.IO는 기본적으로 IO 작업용 스레드풀인데, 이는 JVM의 CPU 코어 수에 따라 확장되지만 기본적으로는 무제한에 가까운 동시 실행이 허용된다.
이 때, limitedParallelism을 적용하면 I/O디스패처가 최대 n개의 코루틴만 동시에 실행하게 제어할 수 있다.
val ioLimited = Dispatchers.IO.limitedParallelism(64)
coroutineScope {
repeat(500) {
launch(ioLimited) {
callExternalApi()
}
}
}
위 코드는 동시에 64개만 동시에 실행하게 제어하는 코드이다. 나머지는 순서대로 suspend-> resume된다.
즉, limitedParallelism은 스레드풀 차원의 동시성 제어이다.
세마포어처럼 코드 블록별로 permit을 나누는 것이 아니라, 스케줄러 자체에 병렬 제한을 거는 방식이다.
여기서 갑자기 스케줄러가 왜 나와? 라고 할 수 있겠지만, 코루틴의 내부 실행 구조를 이해하면 아주 자연스럽게 넘어갈 수 있다.
코루틴은 스레드를 대체하는 기술이 아니고, 스레드 위에서 동작하는, 스레드에서 가지처럼 뻗어져 나가는 경량 실행 단위이다.
그래서 코루틴은 스레드가 하는 일을 대기 없이 잘게 쪼개서 효율적으로 스케줄링 하는 도우미라고도 한다.
코루틴을 실행하면 실제로는 스레드풀 안에서 실행되고, 그 스레드풀의 작업 배분을 담당하는 존재가 바로 코루틴 스케줄러이다.
limitedParallelism VS Semaphore
이제 그러면 둘의 차이를 명확하게 비교해보자.
| 항목 | limitedParallelism | Semaphore |
| 제어 단위 | Dispatcher(thread-pool) | Code-block(Local Resource) |
| 목적 | 전체 병렬도 제한(Global throttle) | 특정 리소스 접근 제한 |
| 제어 대상 | 얼마나 동시에 실행 가능한가 | 어떤 리소스에 몇 명까지 들어올 수 있나 |
| 적용 범위 | 어플리케이션 전역 | 어플리케이션의 특정 DAO/API/SERVICE |
| 동작 위치 | Coroutine Schedular Level | Business Logic Level |
| 격리 기능 | ❌ | ✅ |
짧고 간결하게 설명하자면 limitedParallelism은 어플리케이션에서 코루틴 전체를 제어하고, Semaphore은 어플리케이션에서 특정 기능을 제어한다.
그래서 이 둘중 하나만 사용해도 괜찮지만, 구체적으로 제어하고 싶다면 서로 보완 관계로 사용해도 무방하다.
요즘은 뭐만 하면 오버엔지니어링이 되어버려서 이 또한 프로젝트 특성을 고려하여 선택과 집중을 잘 해야할 것이라 생각된다.
5. Semaphore의 확장팩. Bulkhead 패턴
세마포어를 이해했다면 이제 다음 단계는 벌크헤드(Bulkhead)이다.
이건 말 그대로 세마포어를 서비스 단위로 쪼개고 확장한 형태이다.
세마포어는 이 로직은 최대 N개의 동시 접근만 허용한다는 리소스 단위 제어다.
하지만 실무에서는 특정 서비스나 기능이 병목이 될 때, 전체 시스템까지 같이 느려지는 문제가 더 치명적이다.
즉, 동시에 몇개까지 접근 가능한가보다 누가 접근하고 있는가가 더 중요해지게 된다.
이때 사용하는 것이 바로 벌크헤드 패턴이다.
🛳️ 벌크헤드 패턴이 뭔데?
Bulkhead는 원래 선박 용어다.
배 안에 여러 구획을 나눠놓으면, 한 칸에 물이 새더라도 전체가 침몰하지 않는다.
시스템에서도 같은 원리로, 서비스별로 격리된 세마포어를 두어 장애 전파를 차단하는 설계가 벌크헤드다.

예시 코드를 봐 보자.
val productGate = Semaphore(20) // 상품 조회 최대 20개
val reviewGate = Semaphore(10) // 리뷰 조회 최대 10개
val stockGate = Semaphore(5) // 재고 조회 최대 5개
suspend fun getProductView(id: String): ProductView = coroutineScope {
val product = async { productGate.withPermit { productService.getProduct(id) } }
val review = async { reviewGate.withPermit { reviewService.getReview(id) } }
val stock = async { stockGate.withPermit { stockService.getStock(id) } }
ProductView(product.await(), review.await(), stock.await())
}
위 코드의 핵심은 세마포어를 리소스 단위가 아니라 기능 단위로 쪼갠다는 점이다.
상품 서비스가 느려져도 리뷰나 재고 서비스는 세마포어에 의해 독립적으로 동작한다. 즉 한 기능의 폭주가 전체 시스템을 끌고 내려가지 않는다.
🍃 Spring Boot에서 벌크헤드를 써보자
Resilience4j의 Bulkhead는 Spring Boot 어플리케이션 내부(인스턴스 단위) 에서 서비스별로 세마포어를 설정해둘 수 있다.
즉 서버 하나당 permit 제한을 주는 방식이다.
resilience4j:
bulkhead:
instances:
productService:
maxConcurrentCalls: 20 # 이 인스턴스에서 동시에 20개만 허용
maxWaitDuration: 0 # 대기 없이 바로 거절 (Fail-Fast)
reviewService:
maxConcurrentCalls: 10
maxWaitDuration: 0
stockService:
maxConcurrentCalls: 5
maxWaitDuration: 0
여기서 maxWaitDuration: 0은 매우 중요하다. 대기열을 길게 두면 결국 모든 스레드가 suspend된 채로 대기하면서 다른 요청까지 지연시키는 지연 전파가 발생하기 때문이다.
@Configuration
class ResilienceConfig {
@Bean
fun bulkheadRegistry(): BulkheadRegistry = BulkheadRegistry.ofDefaults()
}
@Service
class ProductFacade(
registry: BulkheadRegistry,
private val productClient: ProductClient,
private val reviewClient: ReviewClient,
private val stockClient: StockClient
) {
private val io = Dispatchers.IO.limitedParallelism(64)
private val bhProduct = registry.bulkhead("productService")
private val bhReview = registry.bulkhead("reviewService")
private val bhStock = registry.bulkhead("stockService")
private suspend fun <T> bulkheadExecute(bh: Bulkhead, block: suspend () -> T): T =
withContext(io) {
if (!bh.tryAcquirePermission())
throw RejectedExecutionException("Bulkhead full: ${bh.name}")
try {
block()
} finally {
bh.releasePermission()
}
}
suspend fun getProductView(id: String): ProductView = coroutineScope {
val product = async { bulkheadExecute(bhProduct) { productClient.getProduct(id) } }
val review = async { bulkheadExecute(bhReview ) { reviewClient.getReview(id) } }
val stock = async { bulkheadExecute(bhStock ) { stockClient.getStock(id) } }
ProductView(product.await(), review.await(), stock.await())
}
}
위처럼 각 Bulkhead 인스턴스는 별도의 세마포어 permit을 갖는다. (application.yml에서 세팅)
상품 요청이 몰려도 리뷰, 재고 서비스는 독립적으로 동작한다.
한쪽이 느려져도 나머지 서비스의 세마포어는 건드리지 않으므로 장애 전파가 차단된다는 것이다.
permit이 꽉 차게 되면 RejectedExecutionException이 발생하고 즉시 거절하게 된다.(Fail-Fast)
💣 벌크헤드 세팅이 되지 않으면 생기는 일
예를 들어 보자. 상품 서비스에 트래픽이 몰려 DB 풀을 모두 점유하면 리뷰 서비스, 재고 서비스도 DB 연결을 못 잡고 대기한다.
즉 모든 코루틴이 suspend 상태로 걸리게 되며 전체 응답이 지연되고 타임아웃이 발생한다.
이는 재시도가 폭발적으로 이루어지고 결국 장애 전파가 되어버린다.
결국 한 모듈의 느림이 전체 시스템의 장애로 확산된다는 것이다.
이게 바로 벌크헤드가 반드시 필요한 이유이다.
MSA에서의 조합(Bulkhead + limitedparallelism)
MSA에서는 한 요청이 여러 마이크로서비스로 Fan-Out 되기 쉽다. 이때 병렬을 제어하지 않으면 하나의 장애가 콜 체인을 타고 전체로 확산된다.
핵심은 인스턴스의 Dispatcher 상한으로 총량을 조절하고, 서비스/리소스 단위 벌크헤드로 구획을 나누는 게 중요하다.
여기서 의아한 점이 있을 것이다. 아까도 설명했지만 limitedParallelism과 Bulkhead는 인스턴스 단위라서 모든 인스턴스가 이를 공유하지 않는다.
하지만 이는 MSA에서 쓰면 안된다는 뜻은 아니고 그저 역할이 다르다는 것이다.
limitedParallelism과 Bulkhead는 각 인스턴스의 자해(폭주)를 막는 로컬 안전장치이다.
하지만 전역 총량을 반드시 보장해야 하는 경우에는 추가로 전역 제어 장치를 얹어야 한다.(레디스의 분산 세마포어 같은걸 끼얹자)
6. 마무리
"비동기로 처리하면 더 빨라요"
이 말은 절반만 맞다고 생각한다.
비동기는 속도를 높이는 기술이 아니라, 기다리는 시간을 겹치는 기술이다.
그리고 그렇게 겹치는 것을 제대로 제어하지 않으면 빠르기보다 오히려 시스템을 더 느리게 만들 수 있다.(서버가 터져버릴 수도?)
코루틴 비동기 처리는 효율적인 동시 실행의 시작점이고,
세마포어는 동시 실행 시의 안전 벨트 역할을 한다.
벌크헤드는 장애 전파를 막는 벽이 되어주고,
limitedParallelism은 단일 인스턴스에서의 병렬처리 폭주를 막는 제동장치가 되어준다.
이 모든 기술의 목적은 더 빠르게가 아니라 더 안정적으로 빠르게에 있다.
진짜 성능이란 속도보다 제어와 격리의 결과로 완성되는 것 같다.
비동기를 올바르게 설계하면 그건 단순한 최적화가 아니라 그 시스템의 생존 전략이지 않을까..?
'DEV > 개발일기 || 트러블슈팅' 카테고리의 다른 글
| Outbox만으로 충분할까? CDC와 DLQ를 통한 기초적인 이벤트 기반 설계를 알아보자 (0) | 2025.10.07 |
|---|---|
| Coroutine과 ReactiveMongo 다중DB 환경에서 @Transactional을 사용해 보자 (0) | 2025.03.10 |
| 데이터 압축을 위한 Gorilla 알고리즘 적용 사례: 사내 솔루션 개발기 및 회고 (5) | 2024.09.03 |
| JPA에서 대용량 데이터를 읽거나 수정/삭제 할때, 쿼리를 어떻게 작성해야 할까? (1) | 2024.06.10 |
| 공유 자원에서의 동시성 이슈는 어떻게 해결해야 할까? (0) | 2023.12.15 |