동시성과 병렬성, 코루틴

Elena·2026년 1월 28일
post-thumbnail

1. 동시성과 병렬성

이 둘은 혼동하기 쉽지만, 핵심은 동시에 실행되는 것처럼 보이는지실제로 동시에 실행되는지의 차이

동시성은 처음들었을때 이름 때문에 동시에 실행하는 것으로 착각했지만 쉽게 비유하면 동시성은 한 사람의 요리사가 3가지의 요리를 하는 것이고 병렬성은 세 사람의 요리사가 같은 시간대에 각자의 요리를 하는 것이다.

구분동시성 (Concurrency)병렬성 (Parallelism)
핵심 개념여러 작업을 번갈아 가며 처리함여러 작업을 동시에 처리함
주체논리적인 개념 (싱글 코어에서도 가능)물리적인 개념 (멀티 코어가 필수)
목표유휴 시간(Idle time) 최소화, 응답성 향상작업 속도(Throughput) 향상
  • 유휴시간(Idle Time):시스템 자원이 아무것도 하지 않고 놀고 있는 시간을 의미. 서버의 효율성을 따질 때 가장 먼저 줄여야 할 대상

개발 환경에서의 유휴 시간 (I/O Wait)
컴퓨터 내부에서 CPU는 초당 수억 번의 연산을 할 만큼 매우 빠른 반면, DB에서 데이터를 가져오거나 외부 API를 호출하는 작업은 CPU 입장에서는 야주 느림
CPU가 일을 멈추는 순간:

  • 네트워크 요청: API 응답이 올 때까지 기다릴 때.
  • 디스크 읽기/쓰기: DB에 쿼리를 날리고 결과를 기다릴 때.
  • 사용자 입력: 사용자가 버튼을 누르기를 기다릴 때.

-> 이처럼 CPU는 멀쩡히 살아있는데, 데이터가 도착하지 않아 아무 연산도 못 하고 대기하는 상태를 "유휴 시간이 발생했다"고 한다.

개발 예시

  • 동시성: 수천 명의 사용자가 동시에 API 요청을 보낼 때, 서버가 이를 빠르게 전환하며 응답해주는 능력 (이때 코루틴이나 가상 스레드가 효율을 극대화)
  • 병렬성: 대용량 데이터를 정산하거나 통계를 낼 때, 여러 개의 CPU 코어를 동원해 데이터를 쪼개서 한 번에 처리하는 방식.

2. 코루틴이란?

  • 일반적인 함수 (Sub-routine)
    함수는 호출되면 시작점부터 끝까지 쭉 실행되고, 한 번 return 하면 종료된다. 중간에 멈췄다가 나중에 그 지점부터 다시 시작하는 게 불가능.

  • 코루틴 (Co-routine)
    코루틴은 실행 도중에 "나 잠시 멈출게(suspend), 다른 작업 먼저 하고 와!"라고 양보할 수 있다. 그리고 나중에 다시 돌아와서 멈췄던 그 지점부터 실행을 재개(resume)할 수 있는 함수

나는 코틀린을 공부하면서 코루틴이라는 말을 처음 들었는데 왜 자바를 사용할 때는 코루틴을 들어보지 못했을까?
-> 자바는 스레드 중심이기 때문

  • 자바(특히 Spring MVC): 하나의 요청을 하나의 물리적인 스레드가 전담해서 처리하는 방식을 고수
    -> 동시 처리가 더 필요하면 스레드를 더 만드는 방식을 사용했으나 이는 유휴 시간 낭비와 문맥교환의 비용이 발생하는 문제가 있음

  • 코틀린: 태생부터 "어떻게 하면 비동기 코드를 동기 코드처럼 쉽게 짤까?"를 고민한 언어로 언어 자체에 suspend라는 키워드를 넣었고 이 기능을 통해 컴파일러가 복잡한 비동기 로직을 알아서 코루틴 형태로 변환해준다.

  • java 21: 가상 스레드(Virtual Thread)라는 것이 도입되었다.
    -> 사실상 자바판 코루틴
    -> 가상 스레드 안에서 Thread.sleep()을 호출하면, 과거처럼 OS 스레드가 멈추는 게 아니라 가상 스레드만 멈추고 실제 OS 스레드는 다른 일을 하러 감.

3. 왜 코루틴이 유휴 시간을 해결할까?

  • 기존 방식 (Blocking Thread)
  1. 스레드가 DB에 데이터를 요청
  2. 데이터가 올 때까지 이 스레드는 멈춘 상태(Blocked)로 대기
    -> 이 스레드는 유휴 상태지만, 메모리는 그대로 점유하고 있어 다른 일을 할 수 없다.
  • 코루틴 방식 (Non-blocking / Suspend)
  1. 코루틴이 DB에 데이터를 요청
  2. 데이터를 기다려야 하는 순간, 코루틴은 "나 잠시 쉴게!" 하고 스레드에서 내려옴(Suspend)
  3. 그럼 방금까지 쓰던 스레드가 자유의 몸이 되어 다른 사람의 요청(다른 코루틴)을 즉시 처리
    -> 결과적으로 CPU와 스레드에 유휴 시간이 거의 생기지 않게 됨

Thread와의 차이 정리

  • 경량화 (Lightweight):
  • 스레드(Thread): 생성할 때마다 Stack 메모리(보통 1MB)를 할당받고 Context Switching(문맥 교환) 비용이 큼
  • 코루틴: 스레드 하나 위에서 여러 개의 코루틴이 돌아감. 객체 수준의 메모리만 사용하므로 수만 개를 생성해도 부담이 적음

비차단(Non-blocking) 코드의 가독성:

  • 기존의 비동기 방식(Callback, CompletableFuture): 코드가 복잡해지면 '콜백 지옥'에 빠지기 쉬움
  • 코루틴: 비동기 로직을 마치 동기 코드(순차적 코드)처럼 짤 수 있게 해줌

코드

// Java - 기존 방식
void processOrder() {
    System.out.println("1. 주문 처리 시작 (Thread: " + Thread.currentThread().getName() + ")");
    
    // DB 조회 (약 2초 걸리는 작업이라고 가정)
    // 이 시간 동안 이 스레드는 '유휴 상태'로 아무것도 못 하고 묶여 있음.
    String user = dbRepository.findUser(); 
    
    System.out.println("2. " + user + "님 주문 완료 (Thread: " + Thread.currentThread().getName() + ")");
}
// Kotlin - 코루틴 방식
suspend fun processOrder() = coroutineScope {
    println("1. 주문 처리 시작 (Thread: ${Thread.currentThread().name})")

    // 비동기 DB 조회 (일시 중단 가능)
    // await() 하는 동안 코루틴은 일시 중단되고 스레드는 다른 요청을 처리하러 떠남
    val user = async { dbRepository.findUser() }.await()

    // DB 작업이 끝나면 다시 돌아와서 실행을 재개(Resume)합니다.
    println("2. ${user}님 주문 완료 (Thread: ${Thread.currentThread().name})")
}

순서 정리
1. 실행 (Start): Thread-A가 코루틴을 실행
2. 중단 (Suspend): 네트워크 요청을 만나면 코루틴은 상태를 저장하고 Thread-A에서 내려옴
3. 활용: Thread-A는 이제 다른 유저의 요청을 처리하러 감 (유휴 시간 소멸)
4. 재개 (Resume): 요청이 끝나면, (꼭 A가 아니더라도) 노는 스레드 중 하나가 코루틴을 다시 깨워 남은 작업을 완료

자바를 쓰면서도 나는 Thread.sleep()을 써본적이 거의 없는것 같다 Thread.sleep()은 언제 쓰는것이고 코틀린의 코루틴에서는 Thread.sleep()대신에 어떤 함수를 사용할까?

- Thread.sleep()

1. 실무에서 Thread.sleep() 잘 쓰지 않는 이유
실무 수준의 백엔드 개발(특히 Spring MVC 기반)에서는 Thread.sleep()을 직접 호출할 일이 거의 없다. 왜냐하면 내가 작성하는 코드는 대부분 Tomcat 같은 WAS(Web Application Server)가 관리하는 스레드 위에서 동작하기 때문이다.

  • 프레임워크의 대행: 나는 주로 컨트롤러나 서비스 로직을 짜는데 DB에서 데이터를 가져올 때까지 기다리는 일은 JDBC 드라이버나 커넥션 풀이 알아서 처리한다.. 이때 스레드가 차단(Blocking)되긴 하지만, 개발자가 직접 sleep을 걸어 기다리게 하지는 않음

  • 성능 저하의 주범: 스레드를 강제로 재우는 것은 서버의 처리량을 깎아먹는 행위라, 코드 리뷰에서 반려될 가능성이 높은 '금기어' 중 하나라고 한다.

  1. 그럼 Thread.sleep()은 언제 쓰는 걸까?
    주로 인프라적인 제어재시도 로직이 필요할 때 사용

1) API 호출 재시도
외부 API 서버에 요청을 보냈는데 "잠시 후 다시 시도하세요(429 Too Many Requests)"라는 응답을 받았다면 -> 즉시 다시 요청하면 또 거절당할 확률이 높다.
이때 "1초만 쉬었다가 다시 해보자"라는 의미로 Thread.sleep(1000)을 넣고 while 문을 돌린다.

2) 폴링(Polling) 메커니즘
어떤 작업이 완료되었는지 주기적으로 확인해야 할 때 사용.
ex) "파일 업로드 후 처리가 끝났나? (확인) -> 아니네, 2초 쉬고 다시 확인하자."

3) 테스트 코드 작성 시
비동기로 동작하는 기능(예: 알림 발송)을 테스트할 때, 메인 스레드가 먼저 종료되어 버리면 결과를 확인할 수 없다.
이때 비동기 작업이 끝날 시간을 벌어주기 위해 테스트 코드 끝에 잠깐 sleep을 걸어두기도 한다.

4) 가짜 부하 생성 (시뮬레이션)
코루틴을 공부할 때처럼, "이 작업은 약 2초 정도 걸리는 무거운 작업이야"라고 가정하고 코드 동작을 확인하고 싶을 때 의도적으로 넣을 수 있다.

- delay(), await()

코루틴에서는 thread.sleep()과 비슷한 역할을 하는것이 delay()인데 위의 코드 예시를 보면 delay()를 사용하지 않고 awiat()만 사용한 것을 확인 할 수 있다. 왜일까?

둘 다 현재 코루틴을 멈추고(Suspend) 스레드를 해방시킨다는 점에서는 본질적으로 동일하다. delay가 정해진 시간을 기다린다면, await은 결과값이 도착할 때까지 기다리는 것이 차이점이다.

  • delay(1000): "1초 동안 일시 중단해!" (시간 기준)
  • await(): "데이터가 올 때까지 일시 중단해!" (이벤트 기준)

그렇다면 왜 delay()를 직접 안 썼을까?
실제 비즈니스 로직(DB 조회, API 호출)에서는 작업이 정확히 몇 초 걸릴지 알 수 없다. 만약 delay(1000)를 쓰고 DB 조회를 한다면, DB가 0.1초 만에 끝나도 0.9초를 더 놀게 된다.

반면 await()나 DB 라이브러리 내부의 suspend 함수를 쓰면, 결과가 나오는 즉시 코루틴이 깨어난다.

즉, delay()는 테스트용이나 특정 주기마다 반복 작업이 필요할 때 주로 쓰고, 실제 데이터 처리에는 await()이나 suspend로 선언된 라이브러리 함수들을 사용한다.

DB 라이브러리 자체가 코루틴을 지원하지 않는다면?

코루틴 내부에서 코루틴을 지원하지 않는(Blocking) DB 라이브러리를 사용하면, 코루틴의 최대 장점인 '비차단(Non-blocking)' 특성이 사라져버린다.

  • 상황: 코루틴 안에서 jdbc.query()를 호출
  • 결과: JDBC는 내부적으로 Thread.sleep()과 비슷하게 스레드를 꽉 붙잡고 DB 응답이 올 때까지 놓아주지 않음
  • 부작용: 이 코루틴을 실행하던 스레드가 묶여버리니, 다른 코루틴들이 이 스레드를 쓰지 못하고 줄줄이 대기. 결과적으로 일반 스레드 모델과 다를 바 없는 비효율적인 서버가 됨

해결책

1. 코루틴 전용 라이브러리 사용 (R2DBC)
가장 깔끔한 방법은 처음부터 비차단(Non-blocking)을 지원하는 DB 라이브러리를 쓰는 것

R2DBC (Reactive Relational Database Connectivity): 자바의 JDBC를 대체하기 위해 나온 표준

  1. 전용 스레드 풀 할당 (withContext)
    현실적으로 이미 JDBC(JPA, MyBatis 등)를 쓰고 있어서 바꿀 수 없는 경우가 많은데, 이럴 때는 "무거운 짐만 옮기는 전용 일꾼(스레드)"을 따로 지정.
// 코틀린 코드 예시
suspend fun getUser() = withContext(Dispatchers.IO) {
    // Dispatchers.IO는 I/O 작업용 스레드들을 따로 관리
    // 여기서 JDBC 호출을 하면 일반 로직용 스레드는 방해받지 않음
    userRepository.findById(1L) 
}

원리: 메인 로직을 담당하는 일꾼들이 방해받지 않도록, 차단(Blocking)이 발생하는 작업만 별도의 격리된 스레드 풀(Dispatchers.IO)로 보내버리는 방식입니다.

profile
一切唯心造

0개의 댓글