파이썬 코루틴과 비동기 함수

50032 단어 백엔드백엔드

본 포스트는 인프런의 "파이썬 동시성 프로그래밍 : 데이터 수집부터 웹 개발까지 (feat. FastAPI)" 강의를 듣고 회고 목적으로 작성하였습니다.

📌 I/O 바운드 & CPU 바운드, 블로킹

  • I/O 바운드
    • I는 Input을 의미하고 O는 Output을 의미한다.
    • 프로그램이 실행될 때 실행 속도가 I/O에 의해 제한됨을 의미.
    • 사용자가 입력을 하고 해당하는 입력에 대해 더하기 100을 한 결과값을 출력해주는 프로그램을 개발한다고 가정해보자.
      • 사용자가 입력을 100초 후에 숫자 15를 입력하면 115가 약 100초 후에 출력
      • 사용자가 입력을 15초 후에 숫자 3을 입력하면 103이 15초 후에 출력
      • 사용자가 입력을 하는 시간에 따라서 프로그램의 실행 속도가 결정된다!!
    • 사용자가 키보드로 입력하는 경우 뿐만 아니라, 컴퓨터와 컴퓨터끼리 통신을 할 때에도 I/O 바운드가 발생
      • 어떤 프로그램에서 특정 웹에 요청을 하여 응답을 기다리는 코드가 있다고 하면 요청을 하는 것이 I가 되고 응답을 하는 것이 O가 되는 I/O 바운드 인 것이다.
  • CPU 바운드

    • 프로그램이 실행될 때 실행 속도가 CPU 속도에 의해 제한됨을 의미한다.
    • 정말 복잡한 수학 수식을 계산하는 경우 컴퓨터의 실행속도가 느려진다.
    • def cpu_bound_func(number: int):
         total = 1
         arrange = range(1, number + 1)
         for i in arrange:
             for j in arrange:
                 for k in arrange:
                     total *= i * j * k
      
         return total
      
      
      if __name__ == "__main__":
         result = cpu_bound_func(50)
         print(result)
  • 블로킹

    • 바운드에 의해 코드가 멈추게 되는 현상


📌동기 VS 비동기

  • 동기(Sync)

    • 코드가 동기적으로 동작한다 \Rightarrow 코드가 반드시 작성된 순서 그대로 실행 된다.
  • 비동기(Async)

    • 코드가 비동기적으로 동작한다 \Rightarrow 코드가 반드시 작성된 순서 그대로 실행되는 것이 아니다.

동기와 비동기의 차이점을 한 가지 예를 들어서 살펴보겠습니다.
짜장면을 배달하는 배달부가 있습니다. 하나의 주문에서 짜장면 배달부가 처리해야 할 일은 다음과 같습니다.

delivery_func:
1. 짜장면을 주문자에게 배달한다.
2. 해당 주문자가 다 먹은 그릇을 현관 앞에 놔두면 그 그릇을 수거한다.

만약 주문자가 A,B,C 가 있다 하겠습니다.

동기
A에게 배달을 완료한 뒤에 A가 다 먹을 때 까지 기다린다.(A에게 배달을 완료, 요청 I)
20분 후 A가 다 먹은 짜장면 그릇을 현관문에 내놓았다면 그 그릇을 수거한 다음 B에게 배달하러 간다.(A에게 그릇을 받는다, 응답 O)
B에게 배달을 완료한 뒤에 B가 다 먹을 때 까지 기다린다.(B에게 배달을 완료, 요청 I)
13분 후 B가 다 먹은 짜장면 그릇을 수거한 다음 C에게 배달하러 간다.(B에게 그릇을 받는다, 응답 O)

비동기
A에게 배달을 완료하고(요청 I), B에게 배달을 완료한 뒤(요청 I), C에게 배달(요청 I)
이후 A에게 가서 그릇을 수거(응답 O)하고 B 그릇을 수거하고(응답 O), 마지막으로 C 그릇을 수거(응답 O)

이를 코드로 구현하여 확인해보자.

동기

import time

def delivery(name, mealtime):
   print(f"{name}에게 배달 완료!")
   time.sleep(mealtime)  # mealtime 만큼 시간을 기다리는 함수.
   print(f"{name} 식사 완료, {mealtime}시간 소요...")
   print(f"{name} 그릇 수거 완료")

def main():
   delivery("A", 1)
   delivery("B", 2)
   delivery("C", 3)

if __name__ == "__main__":
   start = time.time()
   print(main())  # None
   end = time.time()
   print(end - start)

💻 출력

A에게 배달 완료!
A 식사 완료, 1시간 소요...
A 그릇 수거 완료
B에게 배달 완료!
B 식사 완료, 2시간 소요...
B 그릇 수거 완료
C에게 배달 완료!
C 식사 완료, 3시간 소요...
C 그릇 수거 완료
None
6.0337913036346436

비동기

import time
import asyncio

async def delivery(name, mealtime):
    print(f"{name}에게 배달 완료!")
    await asyncio.sleep(mealtime)
    print(f"{name} 식사 완료, {mealtime}시간 소요...")
    print(f"{name} 그릇 수거 완료")
    return mealtime

async def main():
    result = await asyncio.gather(
        delivery("A", 1),
        delivery("B", 2),
        delivery("C", 3),
    )
    print(result)

if __name__ == "__main__":
    start = time.time()
    asyncio.run(main())
    end = time.time()
    print(end - start)

💻 출력

A에게 배달 완료!
B에게 배달 완료!
C에게 배달 완료!
A 식사 완료, 1시간 소요...
A 그릇 수거 완료
B 식사 완료, 2시간 소요...
B 그릇 수거 완료
C 식사 완료, 3시간 소요...
C 그릇 수거 완료
[1, 2, 3]
3.021336793899536

결과를 보면 비동기 코드의 시간이 더 작은 것을 볼 수 있다.
하지만 이렇다고 비동기 코드가 동기보다 무조건 좋은 것은 아니다.
각각 상황에 따라 좋은 코드가 있을 뿐 이다.



📌파이썬 코루틴의 이해

루틴이란?
\rightarrow 일련의 명령 (코드의 흐름)

  • 메인 루틴

    • 프로그램의 메인 코드의 흐름
  • 서브 루틴

    • 우리가 알고 있는 보토으이 함수나 메소드 (메인 루틴을 보조하는 역할)
    • 하나의 진입점과 하나의 탈출점이 있는 루틴이다.
      def hello_world(): # 진입점
      	print("hello world")
      	return 0 # 탈출점
    • 프로그램이 실행되면 서브 루틴은 별도의 공간(스코프)에 해당 로직들을 모아 놓고 있다.
      서브루틴이 호출이 될 때, 해당하는 로직들의 공간으로 이동했다가 return을 통해 원래 호출 시점(메인 루틴)으로 돌아오게 된다.
  • 코루틴
    • 서브 루틴의 일반화된 형태
    • 다양한 진입점과 다양한 탈출점이 있는 루틴
      • 코루틴은 서브루틴과는 달리 해당 로직들이 진행되는중간에 멈춰서 특정 위치로 돌아 갔다가 다시 코루틴에서 진행되었던 위치로 돌아와 나머지 로직을 수행할 수 있다.
    • 파이썬 비동기 함수는 코루틴 함수로 만들 수 있다.

다음은 평소에 많이 쓰는 서브루틴 함수의 구조이다.

def hello_world():
	print("Hello World")

이제 def 앞에 async 키워드를 붙이면 이 함수는 코루틴이 된다.

async def hello_world():
	print("Hello World")

하지만 이 상태로 함수를 실행시키면 RuntimeWarning이 발생한다. 에러를 잘 살펴보면 hello_world 함수가 awaited 되지 않았다고 한다. await를 추가해보자.

async def hello_world():
	print("Hello World")

if __name__ == "__main__":
	await hello_world()   # SyntaxError: 'await' outside function

위 코드를 실행시켜 보면 또 다시 에러가 발생한다. await가 function 밖에서 실행되고 있다는 에러이다. 즉, await라는 키워드는 코루틴 안에서 사용해야 한다.

자 그렇다면 이 에러를 사라지게 하려면 어떻게 해야할까?
asyncio라는 패키지를 사용할 수 있다. 아래 코드처럼 asyncio.run() 함수를 사용해보자.

import asyncio

async def hello_world():
   print("hello world")

if __name__ == "__main__":
   asyncio.run(hello_world())  # hello world

그럼 여기서 궁금한 점이 있을것이다. 위에서 말했던 await는 언제 사용할까?
await는 코루틴 내에서 다른 코루틴을 호출하고 그 결과를 받을 때 사용하며, 그 의미는 async가 붙어서 정의된 함수는 비동기로 호출되는 코루틴이라는 뜻이다. await는 말 그대로 다른 비동기 코루틴을 호출하되, 해당 작업이 완료될 때까지 기다린다는 뜻으로 해석하면 된다.
다음과 같이 코루틴 안에 print문에 await를 붙여서 실행하보자.

import asyncio

async def hello_world():
   await print("hello world")

if __name__ == "__main__":
   asyncio.run(hello_world())  # TypeError: object NoneType can't be used in 'await' expression

그럼 위와 같이 TypeError가 발생한다. 즉, await 객체가 아니라서 발생하는 에러이다.
await객체는 어웨이터블 이라고 한다.

간단히 말하면 객체가 await 표현식에서 사용될 수 있을 때 어웨이터블 객체라고 말한다.
어웨이터블 객체에는 코루틴, task 및 future가 있다.

파이썬 코루틴은 어웨이터블 이므로 다른 코루틴에서 await를 사용할 수 있고, 코루틴 함수를 호출하여 반환되는 코루틴 객체 또한 사용할 수 있다. 위에서 사용했던 비동기 코드의 일부를 가져와서 살펴보자.

async def delivery(name, mealtime)
    print(f"{name}에게 배달 완료!")
    await asyncio.sleep(mealtime)
    print(f"{name} 식사 완료, {mealtime}시간 소요...")
    print(f"{name} 그릇 수거 완료")
    return mealtime

async def main():
    result = await asyncio.gather(
        delivery("A", 1),
        delivery("B", 2),
        delivery("C", 3),
    )
    print(result)

코드를 보면 deliverymain은 모두 코루틴 함수이다. main함수를 보면 await asyncio.gather(delivery()) 가 포함되어 있는데, delivery가 코루틴 함수이기 때문에 await를 사용할 수 있는 것이다.
또한 delivery함수를 보면 await asyncio.sleep()이 있다. 이는 asyncio 안에 있는 sleep 함수를 사용했기 때문에 어웨이터블 객체이고, 따라서 await를 사용할 수 있다.

다음으로 Task는 코루틴을 동시에 예약하는데 사용한다.
코루틴이 asyncio.create_task()와 같은 함수를 사용하여 task로 싸일 때, 코루틴은 곧 실행되도록 자동으로 예약이 된다.

Future는 비동기 연산의 최종 결과를 나타내는 특별한 row-level 어웨이터블 객체이다.


다음으로 간단한 asyncio 메소드 들을 알아보겠다

  • asyncio.run(coroutine)

    • 코루틴을 실행하고 결과를 반환한다.
  • asyncio.create_task(coroutine)

    • 코루틴을 Task로 감싸고 실행을 예약한다. Task 객체를 반환한다.
async def delivery(name, mealtime):  
    print(f"{name}에게 배달 완료!")
    await asyncio.sleep(mealtime) 
    print(f"{name} 식사 완료, {mealtime}시간 소요...")
    print(f"{name} 그릇 수거 완료")
    return mealtime 

async def main():
    await delivery('A',1)
    await delivery('B',2)
    await delivery('C',3)


if __name__ == "__main__":
    start = time.time()
    asyncio.run(main())
    end = time.time()
    print(end - start)
  
# A에게 배달 완료!
# A 식사 완료, 1시간 소요...
# A 그릇 수거 완료
# B에게 배달 완료!
# B 식사 완료, 2시간 소요...
# B 그릇 수거 완료
# C에게 배달 완료!
# C 식사 완료, 3시간 소요...
# C 그릇 수거 완료
# 6.017804145812988 

위 코드를 보면 asyncio를 써서 더 빠르게 처리되어야 할텐데 시간이 동일하게 걸렸다.
왜냐하면 코루틴 함수를 여러 개를 한 번에 실행해야 하는데 await 코루틴함수 이런식으로 쓰면 그냥 코루틴을 호출하는 것이지 다음 태스크를 실행하도록 예약을 하는 것이 아니기 때문이다.
여러 코루틴을 동시에 실행하기 위해선 create_task()로 등록해야한다. 아래 코드처럼 변경하여 실행하면 시간이 3초로 줄어들 것이다.

async def main():
    task1 = asyncio.create_task(delivery('A',1))
    task2 = asyncio.create_task(delivery('B',2))
    task3 = asyncio.create_task(delivery('C',3))
    await task1
    await task2
    await task3
  • asyncio.sleep(delay, result=None, loop=None)
    • delay 초 동안 블로킹 한다.
    • result가 제공되면, 코루틴이 완료될 때 호출자에게 반환된다.
    • sleep()은 현재 태스크를 일시 중단해서 다른 태스크를 실행할 수 있게 한다.
  • asyncio.gather(*aws, loop=None, return_exceptions=False)
    • aws 시퀀스에 있는 어웨이터블 객체를 동시에 실행합니다.
    • aws에 있는 어웨이터블이 코루틴이면 자동으로 task로 예약됩니다.
    • 즉, 동시에 task를 실행한다!
    • 모든 어웨이터블이 성공적으로 완료되면, 결과는 반환된 값들이 합쳐진 리스트입니다. 결괏값의 순서는 aws에 있는 어웨이터블의 순서와 일치합니다.
import time
import asyncio

async def delivery(name, mealtime):
    print(f"{name}에게 배달 완료!")
    await asyncio.sleep(mealtime)
    print(f"{name} 식사 완료, {mealtime}시간 소요...")
    print(f"{name} 그릇 수거 완료")
    return mealtime

async def main():
    result = await asyncio.gather(   # 동시에 delivery 함수들이 실행된다.(동시성)
        delivery("A", 1),
        delivery("B", 2),
        delivery("C", 3),
    )
    print(result)    # result에 delivery 함수의 return 값이 인자로 들어간다.

if __name__ == "__main__":
    start = time.time()
    asyncio.run(main())
    end = time.time()
    print(end - start)

💻 출력

A에게 배달 완료!
B에게 배달 완료!
C에게 배달 완료!
A 식사 완료, 1시간 소요...
A 그릇 수거 완료
B 식사 완료, 2시간 소요...
B 그릇 수거 완료
C 식사 완료, 3시간 소요...
C 그릇 수거 완료
[1, 2, 3]  # 
3.021336793899536

출력을 보면 어웨이터블 객체 delivery가 동시에 실행되는 것을 알 수 있고, 이를 동시성이라 한다.



📌파이썬 코루틴 활용

웹 데이터를 가져오는 실습을 하며 코루틴을 활용해보자.
requests 패키지를 사용하여 웹의 데이터를 가지고 올 수 있다.

def io_bound_func():
	result = requets.get("https://naver.com")
	return result

위 함수는 한번 요청을 보내고 응답을 받으면 끊기게 되는데, 이는 세션을 사용하면 해결할 수 있다.

세션이란 서버와 클라이언트간 연결을 유지시켜주는 역할을 한다. 사용자가 웹 브라우저를 통해 웹서버에 접속한 시점으로부터 웹 브라우저를 종료하여 연결을 끝내는 시점까지, 같은 사용자로부터 오는 일련의 요청을 하나의 상태로 보고 그 상태를 일정하게 유지한다.
즉, 방문자가 웹 서버에 접속해있는 상태를 하나의 단위로 보고 그것을 세션이라고 한다.
( 출처: https://cheershennah.tistory.com/135 [Today I Learned. @cheers_hena 치얼스헤나] )

requests 패키지에서 세션에 대한 글이 있다. requests.org (어렵다.. 좀 더 공부를 해야한다..)

일단 코드를 보며 이해해보자.

# pip install requests
import requests

def main():
	url = "https://naver.com"
    
    session = requests.Session() # 세션을 열고
    session.get(url)  # url에 대한 정보를 가져오고
    session.close()  # 세션을 꼭 닫아줘야 한다!!!

위 코드는 다음과 같이 with 구문을 이용하면 간략하게 사용할 수 있다.

def main():
	with requests.Session() as session:
    	session.get(url)

음.. 일단 어떻게 세션을 열고 닫고, url의 정보를 가져오는지 알았다.
다음 코드를 보자.

def fetcher(session, url):
	with session.get(url) as response:
    	return response.text

def main():
	url = "https://naver.com"
    
    with requests.Session() as session:
    	result = fetcher(session, url)
        print(result)

위 코드를 보면 main() 함수에서 세션을 열고 fetcher 함수을 호출한다. fetcher 함수는 session과 url을 입력으로 받아 url의 text정보를 반환하고, main 함수는 그것을 출력한다.
위 코드를 실행시켜 보면 네이버의 첫 페이지에 대한 html 내용이 나오는 것을 확인할 수 있다.
이런 방식으로 웹에서 데이터를 가져올 수 있다.

이제 우리는 간단하게 웹의 데이터를 가져오는 방법을 알았다.
이제 여러개의 url에 요청을 보내고 응답을 받는 시간을 재는 코드를 보자.

import requests
import time


def fetcher(session, url):
    with session.get(url) as response:
        return response.text


def main():
    urls = ["https://naver.com", "https://google.com",
            "https://instagram.com"] * 10

    with requests.Session() as session:
        result = [fetcher(session, url) for url in urls]
        print(result)


if __name__ == "__main__":
    start = time.time()
    main()
    end = time.time()
    print(end - start)  # 12

위 코드는 네이버, 구글, 인스타그램을 각각 10번씩 순회하며 데이터를 가져오는데, 그 시간은 약 12초가 걸린다.(시간은 조금씩 다르게 나올 수 있다.)

이제 이 코드를 코루틴을 사용하여 비동기 코드로 바꿔서 동시성프로그래밍으로 바꿔보자.
requests 패키지는 동기적인 코드가 있는 패키지이고, 우리는 비동기적 코드가 필요하다. 바로 aiohttp 패키지이다. 더욱 자세한 내용은 aiohttp 사이트 에 가서 확인해보면 좋겠다.
필자는 pip install aiohttp==3.7.3 버전을 다운받았다.

import aiohttp
import time
import asyncio


async def fetcher(session, url):
    async with session.get(url) as response:
        return await response.text()


async def main():
    urls = ["https://naver.com", "https://google.com",
            "https://instagram.com"] * 10

    async with aiohttp.ClientSession() as session:
        result = await asyncio.gather(*[fetcher(session, url) for url in urls]) # 언패킹
        print(result)


if __name__ == "__main__":
    start = time.time()
    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
    asyncio.run(main()) # 코루틴 함수 실행
    end = time.time()
    print(end - start)  # 4.8

마찬가지로 우리가 필요한 패키지들을 임포트하자.
aiohttp 사이트에서 확인해보면 알 수 있듯이 세션을 열때 aiohttp.ClientSession()을 사용한다.
이후에 fetcher 함수와 main 함수 앞에 async 키워드를 붙여 코루틴으로 바꿔준다. 또한, 기존의 response.textresponse.text() 로 어웨이터블 객체가 되기 때문에 앞에 await 키워드를 붙여주자. 여기서 await가 사용이 되었기 때문에 with 문 또한 비동기함수가 되어야 한다. 그러므로 with 앞에 async 키워드를 붙여준다.
동시성프로그래밍을 하기 위해서 await asyncio.gather() 메소드를 사용해야 하고, 그러면 with구문 앞에 또 async 키워드를 붙여줘야 한다.


참고

좋은 웹페이지 즐겨찾기