2026년 7월 4일

마스터하기: Concurrency와 Asynchronicity - `async/await`와 이벤트 루프, 현대 애플리케이션의 반응성과 효율성 비결

20
마스터하기: Concurrency와 Asynchronicity - `async/await`와 이벤트 루프, 현대 애플리케이션의 반응성과 효율성 비결

마스터하기: Concurrency와 Asynchronicity - async/await와 이벤트 루프, 현대 애플리케이션의 반응성과 효율성 비결

마스터하기: Concurrency와 Asynchronicity - async/await와 이벤트 루프, 현대 애플리케이션의 반응성과 효율성 비결

1. 개념 소개: 정의, 탄생 배경, 왜 중요한지

1. 개념 소개: 정의, 탄생 배경, 왜 중요한지

현대 소프트웨어 개발에서 애플리케이션의 반응성(Responsiveness)과 효율성(Efficiency)은 사용자 경험과 시스템 성능을 결정하는 핵심 요소입니다. 이를 달성하기 위한 중요한 개념 중 하나가 바로 **동시성(Concurrency)**과 **비동기성(Asynchronicity)**입니다. 많은 초중급 개발자들이 이 두 개념을 혼동하거나 async/await 문법을 단순히 "빨라지는 마법" 정도로만 이해하는 경우가 많습니다. 이 글에서는 이 개념들을 명확히 이해하고 실무에 적용하는 방법을 안내합니다.

동시성(Concurrency)과 병렬성(Parallelism)

  • 동시성(Concurrency): 여러 작업을 동시에 진행되는 것처럼 보이게 처리하는 능력입니다. 이는 단일 코어 CPU에서도 가능하며, 여러 작업을 왔다 갔다 전환(Context Switching)하면서 실행하는 방식으로 이루어집니다. 마치 한 사람이 여러 개의 요리를 동시에 준비하는 것과 같습니다. (양파를 썰다가, 물이 끓으면 면을 넣고, 다시 양파를 써는 등)
  • 병렬성(Parallelism): 여러 작업을 물리적으로 동시에 처리하는 능력입니다. 이는 멀티 코어 CPU나 여러 대의 머신에서 각 코어나 머신이 독립적으로 작업을 실행함으로써 이루어집니다. 여러 요리사가 각자 하나의 요리를 동시에 준비하는 것에 비유할 수 있습니다.

핵심은 동시성은 "여러 작업을 동시에 관리하는 것"이고, 병렬성은 "여러 작업을 동시에 실행하는 것"입니다.

비동기성(Asynchronicity)

비동기성은 특정 작업의 완료를 기다리지 않고 다음 작업을 즉시 시작하는 프로그래밍 모델을 의미합니다. 작업이 완료되면 미리 정의된 콜백(Callback) 함수나 다른 메커니즘을 통해 결과를 통보받습니다. 반면 **동기성(Synchronicity)**은 한 작업이 완료될 때까지 다음 작업을 시작하지 않고 기다리는 모델입니다.

탄생 배경: 왜 필요할까?

과거의 애플리케이션은 CPU 연산 위주의 작업이 많았습니다. 하지만 현대 애플리케이션은 데이터베이스 접근, 네트워크 통신(API 호출), 파일 I/O 등 I/O(Input/Output) 바운드 작업이 대부분을 차지합니다. 이러한 I/O 작업은 CPU가 연산하는 시간에 비해 훨씬 느립니다.

만약 동기 방식으로 I/O 작업을 처리한다면, 하나의 요청이 데이터베이스에서 데이터를 가져오거나 외부 API 응답을 기다리는 동안, 해당 스레드는 아무것도 하지 않고 대기하게 됩니다. 이는 엄청난 자원 낭비이며, 특히 웹 서버처럼 수많은 동시 요청을 처리해야 하는 환경에서는 심각한 성능 저하와 응답성 문제로 이어집니다.

이러한 문제를 해결하기 위해 비동기 프로그래밍이 등장했습니다. CPU가 I/O 작업을 기다리는 동안 다른 작업을 처리하여 자원 활용률을 극대화하고, 사용자에게는 애플리케이션이 멈추지 않고 계속 반응하는 듯한 경험을 제공합니다.

왜 중요한가?

  1. 응답성 향상: 웹 UI나 API 서버에서 I/O 대기 시간으로 인해 애플리케이션이 멈추는 것을 방지하여 사용자 경험을 개선합니다.
  2. 처리량 증가: 하나의 스레드가 여러 I/O 작업을 동시에 관리할 수 있게 되어, 동일한 자원으로 더 많은 요청을 처리할 수 있습니다. 이는 서버의 확장성과 직결됩니다.
  3. 자원 효율성: I/O 대기 시간에 CPU가 놀지 않고 다른 유용한 작업을 수행할 수 있도록 하여 시스템 자원을 보다 효율적으로 사용합니다.
  4. 확장성: 특히 마이크로서비스 아키텍처나 대규모 분산 시스템에서 여러 서비스 간의 통신이 비동기적으로 이루어지면 시스템 전체의 병목 현상을 줄이고 확장성을 높일 수 있습니다.

2. 핵심 원리 설명 (비유와 다이어그램 활용)

2. 핵심 원리 설명 (비유와 다이어그램 활용)

비동기 프로그래밍의 핵심은 **이벤트 루프(Event Loop)**와 async/await 구문입니다. 이 두 가지가 어떻게 상호작용하여 비동기 작업을 효율적으로 처리하는지 살펴보겠습니다.

동기 vs. 비동기: 식당 비유

비동기 프로그래밍을 이해하는 가장 좋은 방법 중 하나는 식당 비유를 드는 것입니다.

  • 동기(Synchronous) 식당: 손님이 주문을 하면, 주방장은 그 주문의 요리가 완전히 완성될 때까지 다른 주문을 받거나 다른 요리를 시작할 수 없습니다. 한 손님의 요리가 끝나야 다음 손님에게 가서 주문을 받습니다.

    • 장점: 순서가 명확하고 예측하기 쉽습니다.
    • 단점: 주방장이 재료를 손질하거나 조리 기구를 예열하는 등 "기다려야 하는" 시간 동안 아무것도 하지 못하고 낭비합니다. 전체 처리 속도가 느려집니다.
  • 비동기(Asynchronous) 식당: 손님이 주문을 하면, 주방장은 일단 주문을 받고 "요리가 완성되면 호출기로 알려드리겠습니다"라고 말한 뒤 다음 손님의 주문을 받습니다. 주방장은 재료를 준비하거나 요리하는 동시에, 다른 손님들의 주문을 계속 받거나 다른 요리 과정을 시작할 수 있습니다. 요리가 완성되면 호출기로 해당 손님을 부릅니다.

    • 장점: 주방장이 대기 시간(예: 재료 익는 시간) 동안 다른 유용한 작업을 수행할 수 있어 전체적인 처리량이 늘어납니다. 손님들은 음식이 나오기까지 기다리는 동안에도 다른 손님들이 주문하는 모습을 보며 "서비스가 멈추지 않는다"고 느낍니다.
    • 단점: 여러 요리를 동시에 관리해야 하므로 복잡성이 증가할 수 있습니다.

여기서 주방장은 싱글 스레드에 비유할 수 있고, 요리 과정은 작업, 호출기는 콜백(Callback) 또는 Promise에 해당합니다.

Blocking vs. Non-blocking

  • 블로킹(Blocking): 작업이 완료될 때까지 해당 스레드의 실행 흐름을 멈추고 기다리는 방식입니다. 동기 방식에서 주로 발생합니다. (예: time.sleep() 또는 네트워크 요청의 응답을 무작정 기다리는 것)
  • 논블로킹(Non-blocking): 작업이 즉시 결과를 반환하거나, 완료되지 않았다면 "아직 준비되지 않았다"는 신호를 보내고, 해당 스레드는 기다리지 않고 다음 작업을 진행합니다. 비동기 방식의 핵심입니다.

이벤트 루프(Event Loop)의 작동 원리

JavaScript (Node.js)나 Python (asyncio)과 같은 언어들은 기본적으로 싱글 스레드 기반의 이벤트 루프를 사용하여 비동기 작업을 처리합니다. "싱글 스레드인데 어떻게 동시에 여러 작업을 처리하지?"라는 의문이 들 수 있는데, 이는 CPU 연산 작업과 I/O 대기 작업을 분리하여 관리하기 때문에 가능합니다.

다음은 이벤트 루프의 간략한 작동 흐름을 보여주는 다이어그램입니다.

graph TD
    A[Call Stack] --> B{Is Call Stack empty?};
    B -- Yes --> C{Is Microtask Queue empty?};
    C -- No --> D[Move Microtask to Call Stack];
    C -- Yes --> E{Is Callback Queue empty?};
    E -- No --> F[Move Callback to Call Stack];
    E -- Yes --> B;
    G[Web APIs / Kernel Operations] --> H[Callback Queue];
    I[Asynchronous Operations (e.g., fetch, setTimeout)] --> G;
    J[Microtasks (e.g., Promise resolve, process.nextTick)] --> K[Microtask Queue];
  1. Call Stack (호출 스택): 현재 실행 중인 동기 코드(함수 호출 등)가 쌓이는 곳입니다. 스택의 맨 위부터 차례대로 실행됩니다.
  2. Web APIs / Kernel Operations (웹 API / 커널 작업): 브라우저 환경에서는 setTimeout, fetch, DOM 이벤트 등의 Web API가, Node.js나 Python asyncio 환경에서는 파일 I/O, 네트워크 I/O 등을 처리하는 운영체제 커널의 기능이 여기에 해당합니다. 비동기 작업이 시작되면, 이 작업들은 Call Stack에서 제거되어 이 영역으로 위임됩니다. 실제 I/O 작업은 별도의 스레드나 커널의 비동기 I/O 메커니즘에 의해 처리됩니다.
  3. Callback Queue (콜백 큐 / 태스크 큐): Web API 또는 커널에서 위임받은 비동기 작업이 완료되면, 해당 작업과 연결된 콜백 함수(예: setTimeout의 콜백, fetch.then() 부분)가 이 큐에 들어갑니다.
  4. Microtask Queue (마이크로태스크 큐): Promise.resolve(), Promise.reject(), process.nextTick (Node.js) 등으로 생성된 콜백이 들어가는 큐입니다. Callback Queue보다 우선순위가 높습니다.
  5. Event Loop (이벤트 루프): Call Stack이 비어있는지 지속적으로 확인합니다. Call Stack이 비어있다면, Microtask Queue에 대기 중인 콜백이 있는지 확인하고, 있다면 Call Stack으로 옮겨 실행합니다. Microtask Queue마저 비어있다면, Callback Queue에 대기 중인 콜백이 있는지 확인하고, 있다면 Call Stack으로 옮겨 실행합니다. 이 과정을 반복하면서 비동기 작업을 스케줄링하고 실행합니다.

핵심: 싱글 스레드는 CPU 연산 작업을 처리하는 데만 사용하고, I/O 대기 시간 동안에는 이벤트 루프가 다른 준비된 콜백을 실행하여 "놀지 않고" 일하는 것입니다.

async/await: 비동기 코드의 가독성 혁명

async/await는 비동기 코드를 동기 코드처럼 읽기 쉽게 작성할 수 있도록 해주는 문법적 설탕(Syntactic Sugar)입니다. 내부적으로는 Promise(JavaScript)나 Coroutine(Python)을 기반으로 동작합니다.

  • async 키워드: 함수 앞에 async를 붙이면 해당 함수는 항상 Promise (JS) 또는 코루틴 객체 (Python)를 반환하는 비동기 함수가 됩니다.
  • await 키워드: async 함수 내에서만 사용할 수 있습니다. await 뒤에 오는 비동기 작업(Promise 또는 Coroutine)이 완료될 때까지 함수의 실행을 일시 중지합니다. 하지만 이 일시 중지는 해당 스레드를 블로킹하는 것이 아니라, 제어권을 이벤트 루프에 넘겨주어 이벤트 루프가 그동안 다른 작업을 처리할 수 있게 합니다. 비동기 작업이 완료되면 이벤트 루프는 중단되었던 함수의 실행을 재개합니다.

이러한 메커니즘 덕분에 개발자는 복잡한 콜백 함수나 Promise.then() 체인을 사용하는 대신, 마치 동기 코드를 작성하듯이 비동기 흐름을 제어할 수 있게 됩니다.

3. 코드 예제 2개

여기서는 Python의 asyncio와 JavaScript의 fetch를 활용한 async/await 예제를 통해 비동기 프로그래밍의 실제 작동 방식을 보여줍니다.

예제 1: Python - 여러 웹 페이지 비동기적으로 가져오기

이 예제는 asyncio 라이브러리를 사용하여 여러 웹 페이지의 내용을 비동기적으로 가져오는 방법을 보여줍니다. aiohttpasyncio 기반의 비동기 HTTP 클라이언트 라이브러리입니다.

import asyncio
import aiohttp
import time

# 비동기적으로 웹 페이지를 가져오는 함수
async def fetch_page(session, url):
    """
    주어진 URL에서 웹 페이지 내용을 비동기적으로 가져옵니다.
    """
    print(f"Fetching {url}...")
    async with session.get(url) as response:
        # await는 I/O 작업(네트워크 응답 대기, 콘텐츠 읽기)이 완료될 때까지
        # 현재 코루틴의 실행을 일시 중지하고, 이벤트 루프에 제어권을 넘깁니다.
        # 그동안 이벤트 루프는 다른 fetch_page 코루틴을 실행할 수 있습니다.
        content = await response.text()
        print(f"Finished fetching {url} (Length: {len(content)} bytes)")
        return url, len(content)

async def main():
    """
    여러 웹 페이지를 비동기적으로 가져오는 메인 함수입니다.
    """
    urls = [
        "http://example.com",
        "http://www.google.com",
        "http://www.naver.com",
        "http://www.daum.net",
        "http://www.bing.com",
    ]

    start_time = time.time()

    # aiohttp.ClientSession은 HTTP 요청을 처리하는 세션 객체입니다.
    # 여러 요청에 걸쳐 쿠키, 연결 풀 등을 공유할 수 있습니다.
    async with aiohttp.ClientSession() as session:
        # asyncio.gather는 여러 코루틴을 동시에 실행하고, 모든 코루틴이 완료될 때까지 기다립니다.
        # 각 코루틴의 결과를 리스트로 반환합니다.
        # 이 부분이 비동기 동시성의 핵심입니다.
        tasks = [fetch_page(session, url) for url in urls]
        results = await asyncio.gather(*tasks) # await로 모든 작업이 끝날 때까지 기다립니다.

    end_time = time.time()

    print("\n--- Fetch Results ---")
    for url, length in results:
        print(f"URL: {url}, Content Length: {length}")
    print(f"\nTotal time taken: {end_time - start_time:.2f} seconds")

if __name__ == "__main__":
    # Python 3.7+에서는 asyncio.run()을 사용하여 비동기 메인 함수를 실행합니다.
    # 이는 이벤트 루프를 생성하고, 코루틴을 실행하며, 루프를 닫는 모든 과정을 처리합니다.
    asyncio.run(main())

실행 방법:

  1. pip install aiohttpaiohttp 라이브러리를 설치합니다.
  2. 위 코드를 async_fetch.py로 저장하고 python async_fetch.py로 실행합니다.

코드 설명:

  • fetch_page 함수는 async로 정의되어 비동기 함수임을 나타냅니다. 이 함수는 웹 요청(session.get)과 응답 콘텐츠 읽기(response.text())라는 두 가지 I/O 바운드 작업을 포함합니다.
  • await 키워드는 이러한 I/O 작업이 완료될 때까지 현재 fetch_page 코루틴의 실행을 일시 중지하고, 제어권을 이벤트 루프에게 넘겨줍니다. 이 시간 동안 이벤트 루프는 다른 fetch_page 코루틴들을 실행할 수 있습니다.
  • main 함수에서는 asyncio.gather(*tasks)를 사용하여 모든 fetch_page 코루틴을 동시에(concurrently) 실행시킵니다. await asyncio.gather(...)는 이 모든 비동기 작업이 완료될 때까지 기다립니다.
  • 결과적으로 여러 웹 페이지를 거의 동시에 요청하고 응답을 기다리므로, 동기적으로 하나씩 처리하는 것보다 훨씬 빠르게 모든 페이지를 가져올 수 있습니다.

예제 2: JavaScript - 여러 API 호출 비동기적으로 처리하기

이 예제는 웹 브라우저 또는 Node.js 환경에서 fetch API와 async/await를 사용하여 여러 API 엔드포인트에서 데이터를 비동기적으로 가져오는 방법을 보여줍니다.

// 비동기적으로 API 데이터를 가져오는 함수
async function fetchApiData(url) {
    console.log(`Fetching data from ${url}...`);
    try {
        // await는 Promise가 resolve될 때까지 함수의 실행을 일시 중지합니다.
        // 그동안 이벤트 루프는 다른 fetchApiData 호출을 처리하거나,
        // 다른 UI 이벤트 등을 처리할 수 있습니다. (논블로킹)
        const response = await fetch(url);

        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json(); // 응답 본문을 JSON으로 파싱하는 작업도 비동기적입니다.
        console.log(`Finished fetching from ${url}`);
        return { url, data };
    } catch (error) {
        console.error(`Error fetching from ${url}:`, error);
        return { url, error: error.message };
    }
}

async function main() {
    const apiUrls = [
        "https://jsonplaceholder.typicode.com/posts/1", // 가상의 블로그 포스트
        "https://jsonplaceholder.typicode.com/users/1", // 가상의 사용자 정보
        "https://jsonplaceholder.typicode.com/comments/1", // 가상의 댓글
        "https://jsonplaceholder.typicode.com/todos/1", // 가상의 할 일
    ];

    console.log("Starting multiple API calls...");
    const startTime = performance.now(); // 브라우저 환경에서 시간 측정

    // Promise.all은 여러 Promise를 동시에 실행하고, 모든 Promise가 resolve될 때까지 기다립니다.
    // 각 Promise의 결과를 배열로 반환합니다.
    // Python의 asyncio.gather와 유사한 역할을 합니다.
    const promises = apiUrls.map(url => fetchApiData(url));
    const results = await Promise.all(promises); // await로 모든 Promise가 끝날 때까지 기다립니다.

    const endTime = performance.now();
    console.log("\n--- API Call Results ---");
    results.forEach(result => {
        if (result.data) {
            console.log(`URL: ${result.url}, Data ID: ${result.data.id}`);
        } else {
            console.log(`URL: ${result.url}, Error: ${result.error}`);
        }
    });
    console.log(`\nTotal time taken: ${(endTime - startTime).toFixed(2)} milliseconds`);
}

// Node.js 환경에서 실행 시, top-level await를 지원하지 않는 버전을 위해 IIFE 사용
// 브라우저 환경에서는 직접 main() 호출 가능
if (typeof window === 'undefined') { // Node.js 환경 확인
    (async () => {
        await main();
    })();
} else {
    main(); // 브라우저 환경
}

실행 방법:

  1. 이 코드를 웹 브라우저의 개발자 도구 콘솔에 붙여넣고 실행합니다.
  2. 또는 Node.js 환경에서 .js 파일로 저장하고 node your_file.js로 실행합니다 (Node.js 14+는 top-level await를 지원하지만, 이전 버전 호환성을 위해 IIFE로 감쌌습니다).

코드 설명:

  • fetchApiData 함수는 async로 정의되어 비동기 함수임을 나타냅니다. fetch는 네트워크 요청을 시작하고 Promise를 반환합니다.
  • await fetch(url)fetch Promise가 resolve될 때까지 fetchApiData 함수의 실행을 일시 중지합니다. 그동안 이벤트 루프는 다른 fetchApiData 호출을 진행하거나 다른 JavaScript 코드를 실행할 수 있습니다.
  • await response.json() 역시 응답 스트림을 읽고 JSON으로 파싱하는 비동기 작업이므로 await가 필요합니다.
  • main 함수에서는 apiUrls.map(url => fetchApiData(url))를 통해 각 URL에 대한 fetchApiData 호출을 Promise 배열로 만듭니다.
  • Promise.all(promises)는 이 모든 Promise들을 동시에 실행하고, 모든 Promise가 성공적으로 완료될 때까지 기다립니다.
  • await Promise.all(...)를 통해 모든 API 호출이 완료된 후에야 다음 코드(endTime 계산, 결과 출력)가 실행됩니다.
  • 이 방식 또한 동기적으로 API를 하나씩 호출하는 것보다 훨씬 빠르게 모든 데이터를 가져올 수 있습니다.

4. 실무 적용 사례

async/await와 비동기 프로그래밍은 현대 소프트웨어 개발의 다양한 분야에서 필수적으로 활용됩니다.

  1. 웹 서버 개발 (Node.js, Python FastAPI/Django Channels):

    • Node.js: 싱글 스레드 이벤트 루프 모델을 기반으로 하여, I/O 바운드 작업(DB 쿼리, 외부 API 호출)이 많은 웹 서버에 매우 효율적입니다. async/await는 수많은 동시 요청을 논블로킹 방식으로 처리하여 높은 처리량을 가능하게 합니다.
    • Python (FastAPI, Django Channels): Python의 asyncioasync/await는 FastAPI와 같은 최신 웹 프레임워크의 기반이 됩니다. 이는 기존 동기식 Django/Flask 애플리케이션보다 훨씬 높은 동시성 처리 능력을 제공하여, 마이크로서비스나 실시간 통신이 필요한 서비스(웹소켓)에 적합합니다.
  2. 데이터 크롤링 및 스크래핑:

    • 수많은 웹 페이지나 API 엔드포인트에서 데이터를 수집할 때, 동기적으로 하나씩 요청하면 엄청난 시간이 소요됩니다. async/await를 사용하여 여러 요청을 동시에 보내고 응답을 기다리면 데이터 수집 시간을 대폭 단축할 수 있습니다.
  3. GUI 애플리케이션 개발:

    • 데스크톱(Electron, PyQt/PySide) 또는 모바일 애플리케이션에서 네트워크 요청, 파일 읽기/쓰기 등 시간이 오래 걸리는 작업을 백그라운드에서 비동기적으로 처리하여 UI가 멈추거나 "응답 없음" 상태가 되는 것을 방지합니다. 사용자는 백그라운드 작업 중에도 UI를 조작할 수 있습니다.
  4. 마이크로서비스 아키텍처 간 통신:

    • 하나의 마이크로서비스가 다른 여러 서비스에 데이터를 요청해야 할 때, async/await를 사용하여 이 요청들을 병렬적으로 수행하고 결과를 취합함으로써 전체 응답 시간을 줄일 수 있습니다.
  5. 대용량 데이터 처리 파이프라인:

    • 데이터 로드, 전처리, 저장 등 각 단계가 I/O 바운드 작업인 경우, 이 단계들을 비동기적으로 연결하여 전체 데이터 처리 파이프라인의 효율성을 높일 수 있습니다.

5. 자주 하는 실수와 해결법

비동기 프로그래밍은 강력하지만, 제대로 이해하지 못하면 예상치 못한 버그나 성능 문제를 야기할 수 있습니다.

실수 1: Concurrency와 Parallelism 혼동

  • 문제: "비동기 코드를 사용하면 무조건 빨라진다"고 오해하는 경우가 많습니다. 특히 Python의 GIL(Global Interpreter Lock)과 같은 제약 조건이 있는 환경에서, CPU 바운드 작업을 async/await로 처리하려 할 때 병렬성으로 인한 성능 향상을 기대합니다.
  • 해결법: async/await는 I/O 바운드 작업에 특화된 동시성 모델입니다. 단일 스레드에서 I/O 대기 시간을 활용하여 여러 작업을 효율적으로 전환하는 것이지, 여러 CPU 코어를 활용하여 작업을 동시에 실행하는 병렬성을 제공하는 것은 아닙니다.
    • I/O 바운드 작업: async/await를 사용하세요.
    • CPU 바운드 작업: Python에서는 multiprocessing 모듈을 사용하여 여러 프로세스를 생성하거나, JavaScript/Node.js에서는 Worker Threads를 사용하여 별도의 스레드에서 CPU 집약적인 작업을 수행해야 합니다.

실수 2: await 키워드 누락

  • 문제: async 함수를 호출할 때 await를 붙이는 것을 잊어버리는 경우가 있습니다.
    async function doSomething() { /* ... */ }
    doSomething(); // Promise 객체만 반환하고, 완료를 기다리지 않습니다.
    console.log("Done?"); // "Done?"이 먼저 출력될 수 있습니다.
    
    Python에서는 코루틴 객체를 반환만 하고 실행하지 않습니다.
    async def do_something():
        await asyncio.sleep(1)
        print("Done!")
    
    do_something() # <coroutine object do_something at ...> 객체만 반환
    print("Called do_something") # "Called do_something"이 먼저 출력됩니다.
    
  • 해결법: async 함수가 반환하는 Promise (JS) 또는 코루틴 객체 (Python)의 완료를 기다리려면 반드시 await 키워드를 사용해야 합니다. 최상위(