파이썬의 동기와 비동기

JSK·2025년 9월 12일

파이썬 공부방

목록 보기
6/8
post-thumbnail

동기 방식과 비동기 방식의 차이

파이썬의 동기와 비동기의 가장 큰 차이는 대기 시간이 발생했을 때 그 시간동안 무엇을 하는지이다.
동기 처리 방식: 한 함수가 끝날 때까지 다음 함수는 실행되지 않는다. 만약 함수 실행 중에 대기 시간이 발생할 경우, 다른 작업은 실행되지 않고 해당 함수가 끝날 때까지 기다려야 한다.
비동기 처리 방식: 함수 실행 중에 대기 시간(IO 작업, 네트워크 요청 등)이 발생하면 해당 함수 실행을 잠시 멈추고, 이벤트 루프에 등록된 다른 coroutine을 실행한다. 이를 통해 대기 시간에 다른 작업을 수행하며 전체 실행 시간을 줄일 수 있다.

비동기 방식을 사용하는 이유

비동기 방식은 주로 DB 처리나 API 호출, IO 작업 등 대기 시간이 긴 경우에 효율적이다. 주로 실행할 작업이 대기 시간이 긴 경우에 비동기 방식을 사용한다.

만약 많은 데이터가 담긴 테이블 두 개를 읽어 들이는 작업이 한 함수 안에서 실행된다고 생각해 보자. 이때 동기 방식으로 코드를 작성하면 이런 코드가 될 것이다.

def read_large_table():
	data1 = read_large_table1()
    data2 = read_large_table2()
    return data1, data2

위 함수는 동기 방식이기 때문에 read_large_table1의 실행이 끝난 후에 read_large_table2가 실행될 것이다. 하지만 위 함수는 두 테이블을 개별적으로 읽어 들이는 것이 목적이므로 두 작업 사이에 순차성이 있어야 할 필요가 없다.

async def read_large_table():
    task1 = asyncio.create_task(async_read_large_table1())
    task2 = asyncio.create_task(async_read_large_table2())
    
    data1 = await task1
    data2 = await task2
    return data1, data2

위 함수는 이 작업을 비동기적으로 실행시키는 함수로 이 경우 두 함수는 이벤트 루프에 동시에 등록되어 동시적(concurrent)으로 실행된다. 따라서 동기 방식을 사용한 경우보다 더 빠르게 작업을 끝낼 수 있을 것이다. 이때 두 함수는 모두 비동기 함수이다.(동기 함수는 coroutine이 아니기 때문에 await 할 수 없으므로, asyncio에서 비동기 방식으로 동작하지 않는다)

만약 read_large_table1이 약 3초, read_large_table2가 약 2초가 걸린다고 하면 동기 방식은 작업을 끝내는데 3초+2초=5초가 걸릴 것이다. 하지만 비동기 방식에서는 max(3초,2초)=3초가 걸릴 것이다.(단, 이는 I/O 작업의 경우이며, CPU 연산이 많이 필요한 작업은 비동기 방식을 사용하더라도 큰 성능 향상을 기대하기 힘들다)

이렇게 비동기 방식을 상황에 맞게 사용하면 불필요한 대기 시간을 줄이고 좀 더 효율적으로 작업을 처리할 수 있다.

async와 coroutine

def func():
	task1()
    task2()

위 함수는 동기 방식으로 각각의 task가 순차적으로 실행된다. 따라서 task1이 완료된 후에 task2가 실행된다.
그렇다면 파이썬에서 비동기 함수를 사용하려면 어떻게 해야 할까? 함수를 선언할 때 async라는 선언문을 사용해 해당 함수가 비동기 함수임을 명시해야 한다.

async def func():
	await task()

위 같은 함수가 바로 비동기 함수이다. 이렇게 선언된 비동기 함수는 coroutine이라는 것을 반환하는데 coroutine은 비동기 처리를 위해 사용되는 특별한 함수로 실행 중에 일시 중단(await)될 수도 있고, 이벤트 루프에 의해 다시 재개될 수도 있다.
Coroutine은 실행 시 제어권을 자신을 호출한 호출자에게 직접 반환하지 않고, await을 만날 때 이벤트 루프에 제어권을 넘긴다. 이벤트 루프에서는 그 시간동안 다른 coroutine 작업을 실행하고, 해당 작업이 완료되면 다시 돌아와서 원래 작업을 실행한다.

아까 예시를 들었던 대량 데이터 읽기 작업을 예로 들어보면 대량의 데이터를 읽는 작업 내내 제어권을 가지고 있는 대신, 제어권을 이벤트 루프에 넘겨 이벤트 루프가 다른 coroutine에서 또 다른 대량의 데이터를 읽어들인다. 이후 대량의 데이터를 읽어들이는 작업이 완료되면 이벤트 루프가 다시 해당 coroutine을 재개하여 읽어들인 데이터를 반환하는 것이다.

async 함수 안에서의 await

위 에서 예시를 들었던 코드를 보면 서브 함수를 호출할 때, 함수명 앞에 await이라는 키워드가 붙어있다. 비동기 함수 안에서는 다른 비동기 함수를 호출하여 사용할 수 있는데 이때 await을 반드시 앞에 붙여야 한다. 만약 await을 붙이지 않으면 corutine 객체가 생성은 되지만 이 객체는 이벤트 루프에 스케줄링되지 않으면 실행되지 않는다. (실행하려면 await, asyncio.run, asyncio.create_task 같은 방법이 필요하다. 따라서 await 키워드를 사용할 수 없는 동기 함수 내에서는 비동기 함수를 사용하기 위해서는 asyncio.run()을 사용하는 등의 방안이 필요하다)
여기서 await의 의미는 해당 작업이 실행되는 동안 제어권을 이벤트 루프에 넘기겠다는 뜻이다. 따라서 해당 함수가 실행되는 동안 이벤트 루프는 다른 작업을 수행할 수 있는 것이다. 그리고 작업이 끝나면 다시 해당 지점으로 돌아와 다음 작업을 수행한다.

비동기 방식을 사용할 때의 주의점

비동기 방식의 가장 큰 특징은 한 작업 중 대기 시간이 발생하면, 이벤트 루프가 그 동안 다른 작업을 실행한다는 것이다. 이러한 동시성 덕분에 작업 효율이 높아지지만, 개발자 입장에서는 몇 가지 주의점이 있다.

디버깅 어려움
여러 작업이 이벤트 루프에서 동시에 실행되므로, 각 작업이 언제 시작되고 끝나는지 예측하기 어렵다. 그 결과, 코드가 의도한대로 실행되지 않거나, 에러 발생 지점을 파악하기가 어려울 수 있다.

공유 자원 관리
비동기 함수들이 동시에 실행되면 변수나 파일, 네트워크 리소스 등 공유 자원에 동시에 접근할 수 있다. 비동기 함수는 위에서 말했듯이 각 작업이 언제 시작되고 끝나는지 예측하기 어렵기 때문에 언제 어떤 작업이 해당 자원에 접근하는 지 알기 힘들다. 따라서 자원 관리에 문제가 발생할 가능성이 있다.

예외 처리
비동기 함수 내부에서 발생한 예외는 즉시 상위 함수로 전달되지 않고, Task 객체 안에 저장된다. 따라서 await을 사용하지 않으면 예외가 호출자에게 전달되지 않고 숨겨진 채로 남아, 프로그램에서 문제를 발견하기 어렵다.
예를 들어 asyncio.create_task()를 이용해 실행한 task를 await하지 않으면 작업 실행 중 예외가 발생하더라도 호출자에게 전달되지 않으므로 오류 발생 여부를 알 수 없게 된다.

이벤트 루프 과부화
너무 많은 Task를 동시에 생성하면 이벤트 루프가 과부하되어 성능이 떨어지거나 메모리 사용량이 급증할 수 있다. 따라서 동시에 실행되는 작업의 수를 적절히 조절하는 것이 필요하다.

profile
학사지만 AI하고 싶어요...

0개의 댓글