4. 순차처리 & 일괄처리 & 진정한 비동기
순차 처리
순차 처리는 가장 전통적인 방식으로, 작업을 하나씩 순서대로 처리. 즉, 하나의 작업이 완료될 때까지 다음 작업을 시작하지 않고 기다리는 방식
- 단순하고 직관적: 코드 흐름이 명확하며, 디버깅과 이해가 쉬움.
- 낮은 성능: 각 작업이 끝날 때까지 기다리므로, 시간이 오래 걸리는 작업이 있다면 프로그램 전체가 느려질 수 있음. 예를 들어, 네트워크 요청이나 파일 I/O 작업이 있을 때, 해당 작업이 끝날 때까지 다음 작업이 시작되지 않음.
- 자원 활용 비효율적: CPU나 메모리 같은 자원이 충분히 사용되지 않음.
def fetch_data_from_server():
print("Fetching data from server...")
# 네트워크 요청 시뮬레이션 (네트워크 지연이 있음)
time.sleep(2)
print("Data received")
def process_data():
print("Processing data...")
# 데이터 처리
time.sleep(1)
print("Data processed")
def main():
fetch_data_from_server()
process_data()
main()
일괄 처리(파이프라이닝, pipelining)
여러 작업을 배치로 모아서 한 번에 처리하는 방식이다. 즉, 여러 개의 작업을 한꺼번에 처리하지만, 여전히 순차적으로 실행된다.
파이프라이닝은 작업을 여러 단계로 나누어 동시에 진행할 수 있는 특징이 있다.
- 효율성 증가: 여러 작업을 묶어서 동시에 처리할 수 있어, 순차 처리보다는 빠릅니다.
- 비동기와 순차 처리의 중간 지점: 비동기적으로 처리되지는 않지만, 각 작업을 일괄 처리하는 방식으로 속도를 높일 수 있습니다.
- CPU 자원 활용 증가: 동시에 처리할 수 있는 작업이 많을 경우 CPU 자원을 더 효율적으로 사용할 수 있습니다.
import asyncio
async def fetch_data_from_server(server):
print(f"Fetching data from {server}...")
await asyncio.sleep(2) # 네트워크 요청 지연 시뮬레이션
print(f"Data received from {server}")
return f"Data from {server}"
async def main():
servers = ['Server 1', 'Server 2', 'Server 3']
# 일괄 처리: 각 서버에 대한 데이터를 동시에 요청
tasks = [fetch_data_from_server(server) for server in servers]
# 결과를 한꺼번에 처리
results = await asyncio.gather(*tasks)
print("All data fetched:", results)
asyncio.run(main())
- 순차 처리
- 작업을 하나씩 처리하며, 각 작업이 끝날 때까지 기다렸다가 다음 작업을 시작합니다. 이는 매우 단순하지만, 처리 시간이 길어질 수 있습니다.
- 일괄 처리(배치 처리)
- 여러 작업을 한꺼번에 모아서 동시에 처리하는 방식입니다. 이 방식은 순차 처리보다 더 빠르게 작업을 완료할 수 있습니다.
문제 설명
데이터베이스 처리를 예로 들었을 때, 서버의 처리 속도는 한 번에 얼마나 많은 요청을 처리할 수 있는지에 영향을 받습니다. 서버가 초당 100개의 요청을 처리할 수 있다고 가정해 봅시다.
-
순차 처리와 일괄 처리의 차이점
- 순차 처리는 한 번에 하나의 요청을 보내고, 해당 요청이 완료될 때까지 기다린 후에 다음 요청을 처리하는 방식입니다. 이는 각각의 요청이 완료될 때까지 대기 시간이 발생하므로 전체 처리 시간이 길어집니다.
- 반면 일괄 처리는 여러 요청을 동시에 서버에 보내는 방식입니다. 예를 들어, 한 번에 100개의 요청을 동시에 서버로 보내면, 서버는 이를 병렬로 처리할 수 있습니다. 이 방식은 여러 요청을 한꺼번에 처리함으로써 순차 처리보다 훨씬 빠르게 결과를 얻을 수 있는 장점이 있습니다.
-
일괄 처리에서 처리 시간이 발생하는 이유
- 일괄 처리 방식에서도 일정한 처리 시간이 필요합니다. 예를 들어, 한 번에 100개의 요청을 보낸다고 해도, 서버가 이를 처리하는 데 100밀리초가 걸릴 수 있습니다. 이는 서버가 요청을 처리하는 데 소요되는 시간입니다.
- 하지만 중요한 점은, 순차 처리에서는 100개의 요청 각각이 100밀리초씩 걸리지만, 일괄 처리를 하면 100개의 요청을 동시에 처리하므로 전체 요청을 처리하는 데 걸리는 시간은 100밀리초로 동일합니다. 이렇게 일괄 처리는 순차 처리보다 처리 효율이 훨씬 높습니다.
-
데이터베이스 과부하 문제
- 서버가 한 번에 처리할 수 있는 요청 수를 초과하면 과부하가 발생할 수 있습니다. 예를 들어, 서버가 한 번에 100개의 요청을 처리할 수 있는데, 한 번에 1000개의 요청을 보내면 서버가 처리 능력을 초과하여 성능이 저하됩니다.
- 또한, 너무 많은 요청을 준비하는 과정에서 추가적인 시간과 자원이 소모되기 때문에, 일괄 처리의 이점을 충분히 살리지 못하고 오히려 성능이 떨어질 수 있습니다. 즉, 적절한 양의 요청을 보내는 것이 중요합니다.
-
서버 처리 속도가 느릴 때의 문제점
- 서버의 처리 속도가 매우 느려서 한 번에 하나의 요청만 처리할 수 있는 상황이라면, 일괄 처리의 장점이 사라집니다. 아무리 많은 요청을 한꺼번에 보내더라도, 서버가 이를 순차적으로 처리해야 하기 때문에 전체 처리 시간은 순차 처리와 동일하게 됩니다.
따라서 서버가 처리 속도가 느릴 경우, 일괄 처리는 성능 향상에 큰 효과를 발휘하지 못할 수 있습니다.
완전한 비동기 처리
이 방식에서는 각 작업이 서로를 기다리지 않으며, 작업이 끝나는 대로 바로 다음 작업을 실행한다. 작업 중에 I/O가 발생하더라도 CPU는 다른 작업을 처리할 수 있으므로, 동시에 여러 작업이 실행되는 것처럼 보인다.
- 최고의 성능: CPU가 놀지 않고 I/O 대기 시간 동안 다른 작업을 처리할 수 있기 때문에 시스템 자원을 최대한으로 활용합니다.
- 복잡성 증가: 완전한 비동기 처리에서는 동시성 문제가 발생할 수 있습니다. 작업들이 동시에 실행되기 때문에 코드의 동기화와 예외 처리에 주의를 기울여야 합니다.
- 자원 최적화: 네트워크나 파일 I/O처럼 시간이 오래 걸리는 작업에서도 성능 저하 없이 다른 작업을 처리할 수 있습니다.
import asyncio
import aiohttp
# 결과를 저장하는 함수
def save_result_aiohttp(client_session):
sem = asyncio.Semaphore(100)
async def saver(result):
nonlocal sem, client_session
url = "http://127.0.0.1:8080/add"
async with sem:
async with client_session.post(url, data=result) as response:
return await response.json()
return saver
# 비동기 작업을 수행하는 함수
async def calculate_task_aiohttp(num_iter, task, task_difficulty):
tasks = []
async with aiohttp.ClientSession() as client_session:
saver = save_result_aiohttp(client_session)
for i in range(num_iter):
result = do_task(i, task_difficulty) # 작업 처리 함수 호출
task = asyncio.create_task(saver(result)) (1)
tasks.append(task)
await asyncio.sleep(0) (2)
await asyncio.wait(tasks) (3)
(1) 데이터베이스 저장을 즉시 await
하는 대신, asyncio.create_task
를 사용해 이벤트 루프에 데이터베이스 저장 요청을 넣고 함수가 끝나기 전에 작업이 완료됐는지 확인한다.
(2) 이 함수에서 가장 중요한 부분이다. 이벤트 루프가 실행을 기다리는 작업을 처리할 수 있도록 주 함수를 일시 중단한다. 이 부분이 없으면 큐에 들어간 작업은 프로그램이 끝날 때까지 실행되지 않는다.
(3) 완료되지 않은 작업을 기다린다. 만약 for 루프 안에서 asyncio.sleep
을 하지 않았다면 여기서 모든 저장이 어루어졌을 것이다.
이 코드의 성능 특성을 살펴보기 전에 먼저 asyncio.sleep(0)
문의 중요성부터 보자. 0초 동안 잠든다는 표현이 이상해 보일 수도 있지만 실제 이 문장은 함수가 실행을 이벤트 루프에 넘겨서 다른 작업을 실행하라고 양보하는 문장이다. 일반적으로 비동기적인 코드에서는 await 문이 실행될 때마다 이런 양보가 일어난다. CPU 위주의 코드에서는 보통 await를 하지 않으므로 강제로 한 번씩 양보해주는 일이 아주 중요하다.
이런 일을 하지 않으면 CPU 위주의 작업이 끝나기 전까지 아무 작업도 실행하지 않을 것이다. 이런 제어의 장점은 이벤트 루프에 실행을 넘길 최적의 시점을 우리가 정할 수 있다는 점이다.
실행을 넘기면 프로그램의 실행 상태가 바뀌므로 계산 중간에 이런 일을 하면 CPU 캐시가 바뀔 수도 있다. 게다가 이벤트 루프에 실행을 넘기는 일도 부가비용으므로 너무 자주 하면 안된다. 하지만 CPU 작업을 처리하는 중에는 어떤 I/O 작업도 할 수 없다. 따라서 전체 애플리케이션이 API로 이뤄져 있다면, CPU 위주 작업을 실행하는 동안은 아무 요청도 처리할 수 없다.
일반적으로는 50~100밀리초 정도마다 반복하리라 예상되는 루프에서 asyncio.sleep(0)
을 호출하기를 권장한다.
완전한 비동기 해법의 이점은 CPU 작업하는 도중에 I/O 작업도 할 수 있다는 점이다. 이는 전체 실행 시간에서 CPU 위주의 작업을 수행하는 데 걸린 시간을 상쇄해버리는 효과가 있다. 이벤트 루프의 부하 때문에 전체 비용을 다 상쇄하지는 못하지만, 거의 다 상쇄할 수는 있다. 실제로 난이도 8인 작업을 600번 반복할 때 완전한 비동기 코드는 순차 코드보다 7.3배 빠르게 실행되고, 일괄 처리 코드보다 I/O 부하를 2배 더 빠르게 처리한다.
하지만 개별 I/O 작업은 서버의 100밀리초 응답 시간보다 더 오래 걸렸다는 사실도 알 수 있다. 이 시간은 asyncio.sleep(0) 문을 호출하는 시간 간격과 (각 I/O 작업은 3번 await를 하는 반면, 각 CPU 작업은 1번 await한다) 이벤트 루프가 다음에 어떤 작업을 실행할지 결정하는 방법에 따라 달라진다.
비록 CPU만 사용해서 문제를 해결하여 달성할 수 있는 속도와는 큰 격차가 있지만, 비동기 코드는 순차적 코드보다 월등히 빠르다. 이런 문제를 완전히 해결하려면 multiprocessing 같은 모듈을 사용해서 I/O 작업을 별로의 프로세스로 완전히 분리하여 CPU 작업의 성능이 떨어지지 않도록 해야 한다.