이 둘은 혼동하기 쉽지만, 핵심은 동시에 실행되는 것처럼 보이는지와 실제로 동시에 실행되는지의 차이
동시성은 처음들었을때 이름 때문에 동시에 실행하는 것으로 착각했지만 쉽게 비유하면 동시성은 한 사람의 요리사가 3가지의 요리를 하는 것이고 병렬성은 세 사람의 요리사가 같은 시간대에 각자의 요리를 하는 것이다.
| 구분 | 동시성 (Concurrency) | 병렬성 (Parallelism) |
|---|---|---|
| 핵심 개념 | 여러 작업을 번갈아 가며 처리함 | 여러 작업을 동시에 처리함 |
| 주체 | 논리적인 개념 (싱글 코어에서도 가능) | 물리적인 개념 (멀티 코어가 필수) |
| 목표 | 유휴 시간(Idle time) 최소화, 응답성 향상 | 작업 속도(Throughput) 향상 |
개발 환경에서의 유휴 시간 (I/O Wait)
컴퓨터 내부에서 CPU는 초당 수억 번의 연산을 할 만큼 매우 빠른 반면, DB에서 데이터를 가져오거나 외부 API를 호출하는 작업은 CPU 입장에서는 야주 느림
CPU가 일을 멈추는 순간:
- 네트워크 요청: API 응답이 올 때까지 기다릴 때.
- 디스크 읽기/쓰기: DB에 쿼리를 날리고 결과를 기다릴 때.
- 사용자 입력: 사용자가 버튼을 누르기를 기다릴 때.
-> 이처럼 CPU는 멀쩡히 살아있는데, 데이터가 도착하지 않아 아무 연산도 못 하고 대기하는 상태를 "유휴 시간이 발생했다"고 한다.
개발 예시
- 동시성: 수천 명의 사용자가 동시에 API 요청을 보낼 때, 서버가 이를 빠르게 전환하며 응답해주는 능력 (이때 코루틴이나 가상 스레드가 효율을 극대화)
- 병렬성: 대용량 데이터를 정산하거나 통계를 낼 때, 여러 개의 CPU 코어를 동원해 데이터를 쪼개서 한 번에 처리하는 방식.
일반적인 함수 (Sub-routine)
함수는 호출되면 시작점부터 끝까지 쭉 실행되고, 한 번 return 하면 종료된다. 중간에 멈췄다가 나중에 그 지점부터 다시 시작하는 게 불가능.
코루틴 (Co-routine)
코루틴은 실행 도중에 "나 잠시 멈출게(suspend), 다른 작업 먼저 하고 와!"라고 양보할 수 있다. 그리고 나중에 다시 돌아와서 멈췄던 그 지점부터 실행을 재개(resume)할 수 있는 함수
나는 코틀린을 공부하면서 코루틴이라는 말을 처음 들었는데 왜 자바를 사용할 때는 코루틴을 들어보지 못했을까?
-> 자바는 스레드 중심이기 때문
자바(특히 Spring MVC): 하나의 요청을 하나의 물리적인 스레드가 전담해서 처리하는 방식을 고수
-> 동시 처리가 더 필요하면 스레드를 더 만드는 방식을 사용했으나 이는 유휴 시간 낭비와 문맥교환의 비용이 발생하는 문제가 있음
코틀린: 태생부터 "어떻게 하면 비동기 코드를 동기 코드처럼 쉽게 짤까?"를 고민한 언어로 언어 자체에 suspend라는 키워드를 넣었고 이 기능을 통해 컴파일러가 복잡한 비동기 로직을 알아서 코루틴 형태로 변환해준다.
java 21: 가상 스레드(Virtual Thread)라는 것이 도입되었다.
-> 사실상 자바판 코루틴
-> 가상 스레드 안에서 Thread.sleep()을 호출하면, 과거처럼 OS 스레드가 멈추는 게 아니라 가상 스레드만 멈추고 실제 OS 스레드는 다른 일을 하러 감.
- 경량화 (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()대신에 어떤 함수를 사용할까?
1. 실무에서 Thread.sleep() 잘 쓰지 않는 이유
실무 수준의 백엔드 개발(특히 Spring MVC 기반)에서는 Thread.sleep()을 직접 호출할 일이 거의 없다. 왜냐하면 내가 작성하는 코드는 대부분 Tomcat 같은 WAS(Web Application Server)가 관리하는 스레드 위에서 동작하기 때문이다.
프레임워크의 대행: 나는 주로 컨트롤러나 서비스 로직을 짜는데 DB에서 데이터를 가져올 때까지 기다리는 일은 JDBC 드라이버나 커넥션 풀이 알아서 처리한다.. 이때 스레드가 차단(Blocking)되긴 하지만, 개발자가 직접 sleep을 걸어 기다리게 하지는 않음
성능 저하의 주범: 스레드를 강제로 재우는 것은 서버의 처리량을 깎아먹는 행위라, 코드 리뷰에서 반려될 가능성이 높은 '금기어' 중 하나라고 한다.
1) API 호출 재시도
외부 API 서버에 요청을 보냈는데 "잠시 후 다시 시도하세요(429 Too Many Requests)"라는 응답을 받았다면 -> 즉시 다시 요청하면 또 거절당할 확률이 높다.
이때 "1초만 쉬었다가 다시 해보자"라는 의미로 Thread.sleep(1000)을 넣고 while 문을 돌린다.
2) 폴링(Polling) 메커니즘
어떤 작업이 완료되었는지 주기적으로 확인해야 할 때 사용.
ex) "파일 업로드 후 처리가 끝났나? (확인) -> 아니네, 2초 쉬고 다시 확인하자."
3) 테스트 코드 작성 시
비동기로 동작하는 기능(예: 알림 발송)을 테스트할 때, 메인 스레드가 먼저 종료되어 버리면 결과를 확인할 수 없다.
이때 비동기 작업이 끝날 시간을 벌어주기 위해 테스트 코드 끝에 잠깐 sleep을 걸어두기도 한다.
4) 가짜 부하 생성 (시뮬레이션)
코루틴을 공부할 때처럼, "이 작업은 약 2초 정도 걸리는 무거운 작업이야"라고 가정하고 코드 동작을 확인하고 싶을 때 의도적으로 넣을 수 있다.
코루틴에서는 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로 선언된 라이브러리 함수들을 사용한다.
코루틴 내부에서 코루틴을 지원하지 않는(Blocking) DB 라이브러리를 사용하면, 코루틴의 최대 장점인 '비차단(Non-blocking)' 특성이 사라져버린다.
- 상황: 코루틴 안에서 jdbc.query()를 호출
- 결과: JDBC는 내부적으로 Thread.sleep()과 비슷하게 스레드를 꽉 붙잡고 DB 응답이 올 때까지 놓아주지 않음
- 부작용: 이 코루틴을 실행하던 스레드가 묶여버리니, 다른 코루틴들이 이 스레드를 쓰지 못하고 줄줄이 대기. 결과적으로 일반 스레드 모델과 다를 바 없는 비효율적인 서버가 됨
1. 코루틴 전용 라이브러리 사용 (R2DBC)
가장 깔끔한 방법은 처음부터 비차단(Non-blocking)을 지원하는 DB 라이브러리를 쓰는 것
R2DBC (Reactive Relational Database Connectivity): 자바의 JDBC를 대체하기 위해 나온 표준
// 코틀린 코드 예시
suspend fun getUser() = withContext(Dispatchers.IO) {
// Dispatchers.IO는 I/O 작업용 스레드들을 따로 관리
// 여기서 JDBC 호출을 하면 일반 로직용 스레드는 방해받지 않음
userRepository.findById(1L)
}
원리: 메인 로직을 담당하는 일꾼들이 방해받지 않도록, 차단(Blocking)이 발생하는 작업만 별도의 격리된 스레드 풀(Dispatchers.IO)로 보내버리는 방식입니다.