PHpullh

LANGUAGE COMPARISON

Async를 언어별로 비교하기

같은 개념을 실제 예제로 나란히 확인하고, 각 언어의 개별 설명으로 이어갈 수 있습니다.

동시성과 병렬성을 먼저 구분하고 보기

아래 코드들은 모두 "비동기"라는 이름을 달고 있지만, 실제로 CPU 코어를 여러 개 쓰는 것과 하나의 코어에서 기다리는 시간을 겹쳐 쓰는 것은 전혀 다른 일입니다. 이 구분을 흐린 채로 언어를 옮기면 "왜 스레드를 안 늘렸는데 데이터가 깨지지" 또는 "왜 async를 붙였는데 안 빨라지지"라는 두 가지 반대 방향의 오해에 빠집니다.

JavaScript와 TypeScript는 기본적으로 이벤트 루프 하나 위에서 돕니다. await는 다른 스레드로 일을 넘기는 것이 아니라, 이 함수가 멈춘 동안 루프가 다른 작업을 처리하게 양보하는 것입니다. 그래서 I/O는 겹쳐지지만 CPU를 오래 쓰는 계산은 루프를 통째로 막습니다. Python의 asyncio도 같은 구조이며, 병렬 계산이 필요하면 별도의 프로세스로 나가야 합니다.

Go의 고루틴은 다릅니다. 런타임 스케줄러가 다수의 고루틴을 다수의 OS 스레드에 나눠 얹기 때문에 별도 설정 없이 진짜 병렬로 실행됩니다. Java의 CompletableFuture와 C#의 Task도 스레드 풀 위에서 동작해 병렬 실행이 가능합니다. Kotlin 코루틴은 중간에 있습니다. 코루틴 자체는 경량 작업 단위이고, 실제로 몇 개의 스레드를 쓸지는 어떤 디스패처에 올리느냐가 결정합니다. Rust의 async는 더 명시적이어서, 언어는 상태 기계만 만들어 주고 실행기는 라이브러리가 제공합니다.

Kotlin

코루틴 기초 — launch & async

Kotlin 코루틴의 핵심 빌더. launch는 fire-and-forget, async는 결과를 반환합니다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // launch — 결과 없이 백그라운드 실행
    val job = launch {
        delay(100)
        println("launch 완료")
    }

    // async — 결과를 Deferred로 반환
    val deferred1 = async { fetchUser(1) }
    val deferred2 = async { fetchUser(2) }

    // await() — 결과 대기
    val user1 = deferred1.await()
    val user2 = deferred2.await()
    println("$user1, $user2")

    // awaitAll — 여러 Deferred 동시 대기
    val results = awaitAll(
        async { fetchUser(3) },
        async { fetchUser(4) },
    )
    println(results)

    job.join()  // launch 완료 대기
    println("모두 완료")
}

suspend fun fetchUser(id: Int): String {
    delay(50)  // 네트워크 시뮬레이션 (스레드 차단 없음)
    return "User#$id"
}

Python

asyncio — async/await 기초

Python의 비동기 I/O 라이브러리. 코루틴으로 I/O 대기 시간을 효율적으로 활용합니다.

import asyncio
import time

# 기본 코루틴
async def fetch_data(url: str, delay: float) -> str:
    print(f"시작: {url}")
    await asyncio.sleep(delay)   # 비차단 대기
    print(f"완료: {url}")
    return f"data:{url}"

# asyncio.gather — 병렬 실행
async def main_parallel():
    start = time.perf_counter()

    results = await asyncio.gather(
        fetch_data("url1", 1.0),
        fetch_data("url2", 1.5),
        fetch_data("url3", 0.5),
    )

    elapsed = time.perf_counter() - start
    print(f"총 소요: {elapsed:.2f}초")   # ~1.5초 (병렬)
    print(results)

# asyncio.create_task — 태스크 생성
async def main_tasks():
    task1 = asyncio.create_task(fetch_data("a", 1.0))
    task2 = asyncio.create_task(fetch_data("b", 0.5))

    # 여기서 다른 작업 가능
    print("태스크 생성 후 바로 실행 가능")

    r1 = await task1
    r2 = await task2
    return r1, r2

# 타임아웃
async def with_timeout():
    try:
        result = await asyncio.wait_for(
            fetch_data("slow", 5.0),
            timeout=2.0
        )
    except asyncio.TimeoutError:
        print("타임아웃!")

asyncio.run(main_parallel())

Go

고루틴 & WaitGroup

Go의 경량 스레드인 고루틴과 동기화 도구 WaitGroup으로 병렬 처리를 구현합니다.

package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()   // 완료 시 카운터 감소
	fmt.Printf("worker %d 시작
", id)
	time.Sleep(time.Duration(id) * 100 * time.Millisecond)
	fmt.Printf("worker %d 완료
", id)
}

// Mutex로 경쟁 조건 방지
type SafeCounter struct {
	mu sync.Mutex
	v  map[string]int
}

func (c *SafeCounter) Inc(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.v[key]++
}

func (c *SafeCounter) Value(key string) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.v[key]
}

func main() {
	// WaitGroup 기본 패턴
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}
	wg.Wait()
	fmt.Println("모든 worker 완료")

	// RWMutex — 읽기는 동시, 쓰기는 독점
	var rwmu sync.RWMutex
	data := make(map[string]string)

	// 쓰기
	rwmu.Lock()
	data["key"] = "value"
	rwmu.Unlock()

	// 읽기 (여러 고루틴 동시 가능)
	rwmu.RLock()
	fmt.Println(data["key"])
	rwmu.RUnlock()

	// SafeCounter 사용
	counter := SafeCounter{v: make(map[string]int)}
	for i := 0; i < 100; i++ {
		go counter.Inc("key")
	}
	time.Sleep(100 * time.Millisecond)
	fmt.Println("카운트:", counter.Value("key"))
}

Java

CompletableFuture — 비동기 프로그래밍

Java 8의 CompletableFuture로 비동기 작업을 조합하고 체이닝합니다.

import java.util.concurrent.*;
import java.util.List;

public class AsyncDemo {

    static CompletableFuture<String> fetchUser(int id) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(100);
            return "User#" + id;
        });
    }

    static CompletableFuture<String> fetchOrders(String userId) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(100);
            return "Orders of " + userId;
        });
    }

    public static void main(String[] args) throws Exception {
        // thenApply — 변환 (Function)
        CompletableFuture<String> upper = fetchUser(1)
            .thenApply(String::toUpperCase);

        // thenAccept — 소비 (Consumer)
        fetchUser(1).thenAccept(u ->
            System.out.println("받음: " + u));

        // thenCompose — 플랫맵 (다른 CF 반환)
        CompletableFuture<String> orders = fetchUser(1)
            .thenCompose(AsyncDemo::fetchOrders);

        // thenCombine — 두 CF 결과 합치기
        CompletableFuture<String> combined =
            fetchUser(1).thenCombine(fetchUser(2),
                (u1, u2) -> u1 + " & " + u2);

        // allOf — 모두 완료 대기
        CompletableFuture<Void> all = CompletableFuture.allOf(
            fetchUser(1), fetchUser(2), fetchUser(3));
        all.join();

        // anyOf — 가장 빠른 것
        CompletableFuture<Object> any = CompletableFuture.anyOf(
            fetchUser(1), fetchUser(2));

        // 예외 처리
        CompletableFuture<String> safe = fetchUser(-1)
            .exceptionally(ex -> "기본 사용자")
            .handle((result, ex) -> {
                if (ex != null) return "에러: " + ex.getMessage();
                return result;
            });

        System.out.println(orders.get());
        System.out.println(combined.get());
        System.out.println(safe.get());
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

PHP

Generator 코루틴 흉내

Fiber가 없더라도 generator로 코루틴 스타일 흐름을 이해할 수 있는 예제입니다.

<?php
function task(): Generator {
    echo "before
";
    yield 'step-1';
    echo "after
";
}

$gen = task();
echo $gen->current() . PHP_EOL;
$gen->next();

JavaScript

비동기 함수 시작

async function run()이 await Promise.resolve(1)로 값을 꺼내 출력하고, 반환 타입은 Promise<void>로 적었습니다. 비동기 함수의 골격을 잡을 때 이 형태에서 출발합니다. await은 이미 이행된 값이라도 마이크로태스크 한 턴을 소비해서, 뒤 코드가 동기 코드보다 늦게 실행됩니다.

// 비동기 함수 시작
async function run() {
  const value = await Promise.resolve(1);
  console.log(value);
}
run();

TypeScript

비동기 함수 시작

async function run()이 await Promise.resolve(1)로 값을 꺼내 출력하고, 반환 타입은 Promise<void>로 적었습니다. 비동기 함수의 골격을 잡을 때 이 형태에서 출발합니다. await은 이미 이행된 값이라도 마이크로태스크 한 턴을 소비해서, 뒤 코드가 동기 코드보다 늦게 실행됩니다.

// 비동기 함수 시작
async function run(): Promise<void> {
  const value = await Promise.resolve(1);
  console.log(value);
}
run();

C#

비동기 함수 시작

async function run()이 await Promise.resolve(1)로 값을 꺼내 출력하고, 반환 타입은 Promise<void>로 적었습니다. 비동기 함수의 골격을 잡을 때 이 형태에서 출발합니다. await은 이미 이행된 값이라도 마이크로태스크 한 턴을 소비해서, 뒤 코드가 동기 코드보다 늦게 실행됩니다.

// 비동기 함수 시작
using System;
using System.Threading.Tasks;

var value = await Task.FromResult(1);
Console.WriteLine(value);

C++

비동기 함수 시작

async function run()이 await Promise.resolve(1)로 값을 꺼내 출력하고, 반환 타입은 Promise<void>로 적었습니다. 비동기 함수의 골격을 잡을 때 이 형태에서 출발합니다. await은 이미 이행된 값이라도 마이크로태스크 한 턴을 소비해서, 뒤 코드가 동기 코드보다 늦게 실행됩니다.

// 비동기 함수 시작
#include <future>
#include <iostream>

int main() {
    auto job = std::async(std::launch::async, [] { return 1; });
    std::cout << job.get() << "\n";
}

Rust

비동기 함수 시작

async function run()이 await Promise.resolve(1)로 값을 꺼내 출력하고, 반환 타입은 Promise<void>로 적었습니다. 비동기 함수의 골격을 잡을 때 이 형태에서 출발합니다. await은 이미 이행된 값이라도 마이크로태스크 한 턴을 소비해서, 뒤 코드가 동기 코드보다 늦게 실행됩니다.

// 비동기 함수 시작
use std::thread;
fn main() {
    let handle = thread::spawn(|| 1);
    println!("{}", handle.join().unwrap());
}

비동기 코드에서 반복해서 보는 사고들

블로킹 호출이 섞이는 경우. 단일 루프 모델에서 동기 파일 읽기나 동기 HTTP 클라이언트를 호출하면 그 순간 모든 작업이 함께 멈춥니다. 부하가 낮을 때는 전혀 티가 안 나다가 동시 요청이 늘면 응답 시간이 계단식으로 무너집니다. 라이브러리를 고를 때 비동기 대응 여부를 먼저 확인해야 하는 이유입니다.

순차 await로 만든 가짜 동시성. 독립적인 요청 세 개를 await로 하나씩 기다리면 시간은 그대로 더해집니다. 동시에 보내려면 먼저 작업들을 시작해 놓고 한꺼번에 기다려야 합니다. 각 언어가 이를 위한 도구를 갖고 있습니다. JavaScript의 Promise.all, Python의 asyncio.gather, Kotlin의 async 후 await, Go의 sync.WaitGroup, Java의 allOf, C#의 Task.WhenAll이 같은 자리를 채웁니다.

시작 시점의 차이. JavaScript의 프로미스와 C#의 Task는 만들어지는 순간 이미 실행이 시작됩니다. 반면 Rust의 future는 게으르기 때문에 await하거나 실행기에 넘기기 전까지 아무 일도 하지 않습니다. Rust에서 "코드가 그냥 실행이 안 된다"는 당황스러운 경험 대부분이 여기서 옵니다. Kotlin의 async도 기본은 즉시 시작이지만 시작 옵션으로 지연시킬 수 있습니다.

버려지는 예외. 결과를 받지 않는 fire-and-forget 작업에서 예외가 나면 조용히 사라지기 쉽습니다. JavaScript는 처리되지 않은 프로미스 거부로 경고를 내고, Go는 고루틴 안의 panic이 프로세스 전체를 죽이며, Java는 미래를 아무도 조회하지 않으면 예외가 묻힙니다. 백그라운드 작업에는 예외를 반드시 로깅하는 경계를 직접 만들어 두어야 합니다.

취소와 시간 제한은 나중에 붙이기 가장 어려운 기능입니다. Go는 context.Context를 함수 체인 전체에 첫 인자로 흘려보내는 관례가 있고, Kotlin은 코루틴 스코프가 자식 작업을 함께 취소하며, C#은 CancellationToken을 넘깁니다. Python은 태스크 취소가 CancelledError로 전달됩니다. 공통점은 취소 신호가 호출 체인 끝까지 전달되어야 의미가 있다는 것입니다. 중간에 한 군데라도 신호를 삼키면 상위에서 타임아웃을 걸어도 실제 작업은 계속 돕니다.

동시성 코드에서는 짧은 코드가 특히 위험합니다. await를 붙이는 것만으로 확장성이 생기지 않고, 고루틴을 무제한으로 띄우면 연결 수 제한에 먼저 걸립니다. 어떤 언어를 쓰든 동시 실행 개수를 세마포어나 워커 풀로 묶는 작업은 직접 해야 합니다. 상세한 예제는 Go 고루틴, Kotlin 코루틴, Python asyncio, Java 비동기 문서에 있습니다.

디버깅이 어려워지는 지점

비동기 코드는 스택 트레이스가 잘 남지 않습니다. 작업이 시작된 위치와 예외가 발생한 위치가 서로 다른 스택에 있기 때문에, 트레이스만 봐서는 어느 요청에서 시작된 일인지 알 수 없습니다. 각 언어가 나름의 보완책을 갖고 있습니다. Kotlin은 코루틴 이름과 디버그 모드에서의 스택 병합을, .NET과 최근의 JavaScript 런타임은 비동기 경계를 넘는 스택 복원을 제공합니다. 그럼에도 가장 확실한 방법은 요청 단위 식별자를 만들어 모든 로그에 함께 남기는 것입니다.

테스트도 다른 종류의 어려움을 만듭니다. 실제 시간에 의존하는 대기를 테스트에 넣으면 느리고 불안정해집니다. 가상 시간을 지원하는 테스트 도구를 쓰거나, 타이머를 주입 가능한 의존성으로 빼 두면 재현 가능한 테스트를 쓸 수 있습니다. 동시성 버그는 재현이 어렵기 때문에, 재현 가능한 환경을 만드는 데 드는 초기 비용이 대체로 회수됩니다.