카테캠

[카테캠] 프리코스 1주 2일차 - 비동기와 테스트

wlsgk7779 2026. 5. 21. 23:11

1. 비동기 입문 - 개념과 문법

비동기 프로그래밍 : 하나의 작업이 응답을 기다리는 동안, 다른 작업을 먼저 진행하는 것

비동기는 단일 쓰레드에서 이루어지며 I/O 대기를 최소화 하는 방법

비동기는 동시에 여러 연산을 하는 것이 아니라, 대기 상태에 빠진 작업이 다른 작업에게 실행 권한을 양보하는 구조

비동기 프로그래밍을 하려면 asyncio 패키지를 import 해야한다.

asyncio 패키지를 통해 할 수 있는 것들

▷코루틴 : 비동기로 동작하는 함수

태스크 : 루프에 예약되어 실행 대기 중인 작업

루프 : 모든 비동기 작업을 지휘하는 중앙 엔진

▷비동기 함수 :

async def로 정의할 수 있다.

비동기 함수를 호출하면 실행되는 것이 아닌, 비동기적으로 실행될 수 있는 상태인 코루틴을 반환

▷await :

작업이 끝날 때까지 기다리되, 다른 작업이 있으면 먼저 하라는 의미

반드시 async def 로 정의된 비동기 함수 안에서만 사용할 수 있다

기다릴 수 있는 대상 (비동기 처리 가능 대상이나 Awaitable 객체) 앞에 붙일 수 있다

▷asyncio.run(코루틴) :

새로운 이벤트 루프를 생성하여 코루틴을 실행하고 루프를 종료한다.

프로그램에서 딱 한 번만 호출한다.

이미 루프가 실행 중이면 런타임에러 발생

▷create_task :

코루틴을 태스크 객체로 감싸서 이벤트 루프에 즉시 실행을 예약한다

명시적으로 await를 호출하여 기다리지 않아도 루프에 여유가 생기면 백그라운드에서 실행

태스크의 생성은 task = asyncio.create_task(코루틴) 으로 할 수 있다.

await와 달리 해당 코루틴 동작을 멈추지 않고 아래 문장을 쭉 실행하게 된다.

또한 실행한 task에 반환값이 있다면 r = await task로 반환값을 기다려 받을 수 있고,

만약 이미 task 실행이 완료된 상태라면 바로 반환값이 나오게 된다.

▷await asyncio.sleep() :

이 문구는 time.sleep() 과 다르게 해당 쓰레드 동작이 멈추지 않고, 이벤트 루프에게 제어권을 넘겨줘

이벤트 루프가 다른 task를 실행할 수 있도록 한다.

2. 동시처리 극대화

여러 개의 독립적인 비동기 작업을 한 번에 묶어서 실행하는 것

전체 실행 시간은 가장 오래 걸리는 단일 작업의 시간과 동일하다

▶첫 번째 방법 : gather() - 결과를 한 번에 모으는 수집가

▷asyncio.gather()을 사용하면 여러 코루틴을 한 번에 실행한다.

▷모든 작업이 끝나면 그 결과들을 리스트 형태로 모아서 반환

▷단, 이전 작업의 결과가 다음 작업에 필요한 경우(의존성이 있는 경우) 순차 실행을 해야한다.

▷gather()은 인자로 받은 코루틴들을 동시에 실행하고 전부 끝나면 리스트로 결과를 수집하는데,

이때 결과 리스트는 실행한 순서를 보장한다. (호출한 순서대로 결과를 저장)

▷어떤 task에서 오류가 발생하면 나머지는 그냥 실행함

gather() 사용 예시

▶두 번째 방법 : TaskGroup() - 안전한 울타리를 제공하는 관리자

▷관련된 작업들을 하나의 그룹으로 묶어 더 안전하게 생명주기 관리

▷모든 작업이 완료될 때까지 기다리고, 예외 발생 시 모든 작업을 안전하게 취소한다.

▷모든 예외를 ExceptionGroup으로 묶어서 발생시키고 except* 문법으로 타입별 분리 처리

▷async with asyncio.TaskGroup() as tg : 와 같이 async with 구문을 사용해야함!

▷task에 대한 결과 값은 개별적으로 t1.result(), t2.result()로 조회할 수 있다.

TaskGroup() 사용 예시

except* 을 이용한 예외그룹 처리 예시

▶ asyncio.gather()와 asyncio.TaskGroup() 비교

3. 안정적인 비동기 : 예외와 타임아웃

단일 코루틴에서는 비동기도 익숙한 try/except가 동일하게 적용됨!

하지만 여러 작업을 동시에 gather() 등으로 실행한다면 어떻게 될까?

▶gather()에서의 예외

▷먼저 asyncio.gather의 기본 동작은 예외 전파이다.

-> 예외 전파란 하나의 예외가 발생하면 즉시 호출자에게 전파되며, 나머지 Task는 계속 실행

▷예외를 수집하는 옵션도 있는데 이는 "return_exceptions=True" 이다.

이 옵션은 예외를 '반환 가능한 결과값'의 하나로 취급하도록 하여 결과 리스트에 오류를 저장한다

▷위 옵션을 통해 작업별 상태를 확인하고, 선택적으로 재시도하고, 성공한 결과는 먼저 활용할 수 있다.

▷위 사진처럼 다중 코루틴 예외를 처리할 때는 상황에 따라 적절한 것을 선택하여 사용할 수 있다.

▶비동기 time out 설정

▷비동기 방식에서는 하나의 지연이 전체 흐름에 영향을 미친다.

▷이때 Timeout을 설정하여 일정 시간 이후에 다른 대응을 할 수 있도록 제한을 둘 수 있다.

▷asyncio.wait_for()을 이용하여 구현할 수 있다.

asyncio.wait_for() 사용 예시

▷위 사진과 같이 인자로 실행할 코루틴과 timeout 값을 설정하여 사용할 수 있다.

이때 timeout으로 설정한 시간보다 작업 시간이 길면 asyncio.TimeoutError 가 발생하기 때문에

try-except 문으로 감싸서 처리해줘야 한다.

▷Timeout이 없을 때는 전체 시스템 흐름이 꼬이고 사용자 경험이 악화되지만,

Timeout이 있다면 빠른 피드백을 제공하고, 시스템 리소스도 보호할 수 있다.

4. 비동기 심화와 유의사항

cancel()

어떤 task에 대해 더 이상 필요가 없어진다면 시스템 자원 확보를 위해 즉시 중단해야한다.

이때 cancel() 을 사용할 수 있음!

task.cancel() 을 하면 해당 task에 대해 취소 요청을 하는 것이다. (즉시 중단은 아님)

그리고 await task를 한다면 해당 task는 취소 되었으므로 asyncio.CanceledError 가 발생하게 됨.

여기서 task는 create_task()를 통해 반환된 Task 객체이다.

task.cancel() 사용 예시

asyncio의 주요 에러 정리

세마포어

무한한 동시성은 시스템 파괴를 부른다.

그래서 허락된 만큼만 통과시키는 톨게이트 역할이 필요한데 이를 Semaphore라고 한다.

이를 통해 비동기의 장점을 살리되, 외부 서비스에 과도한 부담을 주지 않도록 동시 실행 수를 엄격하게 조절한다.

sem = asyncio.Semaphore(최대 접근 수)로 세마포어 객체를 받은 후에

async with sem: 구문 안에 내용을 쓰면 해당 구문 안을 실행하는 최대 task 수는 세마포어 수로 제한이 되고, 이를 초과하면 대기를 한다.

API호출이나 DB연결 등 실무에 필수적이다.

세마포어 사용 예시

async with 구문

열어둔 문은 자원의 누수로 이어진다.

비동기 코드를 다루는 네트워크 클라이언트나 데이터베이스 연결 객체는 단순한 변수가 아닌 물리적 자원이다.

비동기로 열었다면, 반드시 비동기로 닫아야한다.

async with는 작업 중 에러가 발생하더라도 자원 정리 단계를 반드시 보장하는 비동기 패턴이다.

async with 구문 사용 예시

▶위 3가지 정리

동기식 블로킹 함수 대처

동기식 블로킹 함수가 실행되면 이벤트 루프 전체가 멈춰버리는 (Freezing) 치명적 병목이 발생한다.

해결책으로는 run_in_executor를 사용하여 블로킹 함수를 메인 루프가 아닌 별도의 스레드 풀로 격리하여 실행한다.

사용법으로는 먼저 loop = asyncio.get_event_loop()로 현재 사용 중인 루프를 가져오고,

await loop.run_in_executor(executor, 동기함수, 인자)로 별도 스레드 풀에서 동기함수를 실행할 수 있다.

인자 1 executor는 보통 None으로 둬서 기본 스레드풀을 사용하게 한다. (지정해줄 수 있음)

인자 2 동기함수는 ( ) 없이 써줘야 한다!

run_in_executor() 사용 예시

핵심 포인트는 동기함수를 별도 스레드에서 실행해서 루프 블로킹을 방지하고,

비동기 전환이 안 되는 옛 코드를 대처할 수 있다 등

* 비동기는 무조건 빠른 것이 아니다!

* 단일 요청의 처리 속도를 높이는 것이 아니라, 동시에 처리할 수 있는 요청의 수(처리량)를 극대화하는 기술이다.

* 부하 상황에서는 지연 시간의 변동성이 커질 수 있음을 설계 시 인지해야한다.

5. 테스트의 개념과 pytest 시작하기

프로젝트가 커질수록 기능 추가나 구조 변경 시 기존 기능이 깨지는 일이 빈번하게 발생

테스트 없는 코드 수정은 예기치 않은 오류를 반드시 동반

 

▶테스트란?

 

어떤 입력을 넣었을 때, 기대한 결과가 정확히 나오는지 코드로 검증하는 과정

단위 테스트(Unit Test) : 개별 코드 단위 (메서드, 클래스) 검증, 격리된 환경에서 빠르게 실행, 회귀 테스트 기반(?)

통합 테스트(Integration Test) : 모듈 간 상호작용 검증, 데이터 흐름 및 인터페이스 확인, 종속성 및 외부 시스템 통합 확인

시스템 테스트(System Test) : 전체 시스템 기능 검증, 실제 운영 환경과 유사한 조건, 성능-부하-스트레스 테스트 포함

인수 테스트(Acceptance Test) : 최종 사용자 관점의 검증, 비즈니스 요구사항 충족 확인, 알파-베타 테스트 및 인수 기준 검증

▶테스트 코드를 작성하는 이유

▷문제점 조기 발견

▷리팩토링 용이성

▷개발 효율성 극대화 : 어느정도 개발 단계 이후에는 사용하는게 디버깅 시간을 압도적으로 단축

▶테스트의 3단계 핵심 구조 (AAA)

준비 (Arrange) : 테스트를 위한 초기 상태 설정 (a=2, b=3 등)

실행(Act) : 검증할 대상 기능 호출 (add(a,b) 등)

검증(Assert) : 예상된 결과와 실제 결과 비교("결과는 예상대로 5이다" 등)

▶unittest vs pytest

pytest 방식에서는 assert 문 하나로 모든 비교 연산 (==, !=, <, in) 가능 !!

또한 간결함, 호환성, 확장성 등의 이유로 pytest를 사용

▷pytest의 명칭 규약

pytest가 테스트를 자동으로 수집하기 위한 필수 조건!

  1. test_*.py 또는 *_test.py 로 모듈 이름을 짓기
  2. 테스트 함수 명은 반드시 test_ 로 시작하기
  3. 클래스로 묶을 경우 이름을 'Test'로 시작하고 상속은 불필요

6. 예외처리와 경계값 테스트

테스트의 진짜 목적은 '성공'을 확인하는 것이 아니다.

테스트는 코드가 올바르게 실패하는지 검증하고,

미래의 코드 수정을 위한 안전망이며,

더 나은 소프트웨어 구조를 강제하는 설계 도구이다.

정상적인 케이스만 확인하는 것은 절반의 테스트이며, 잘못된 입력에서 올바르게 실패하는가?도 확인해봐야한다.

의도된 에러를 낚아채는 예외 처리를 확인하기 위해서는 with pytest.raises()를 사용할 수 있다.

 

▷with pytest.raises()

특정 코드를 실행했을 때 의도한 예외가 발생하는지 명시적으로 검사하는 것이다.

with pytest.raises(예외이름, 예외객체, match == " ") : 구문을 이용할 수 있는데 이 안에 예외를 유발하는 실제 함수를 호출하면 된다.

단순히 에러의 종류만 포착하는 것이 아니라, match 인자를 통해 발생한 에러 메시지가 특정 문자열을 포함하는지도 알 수 있다.

with pytest.raises() 사용 예시

7. 테스트 자동화하기

테스트를 하나하나 매개변수만 바꿔가면서 테스트 하는 것은 시간도 많이 걸리고 비효율적일 수 있다.

이를 해결할 수 있는 두 가지 핵심 기능이 있는데 바로 FixtureParametrize이다.

▶Fixture (사전 준비)

Fixture는 테스트 전 필요한 준비 작업 (DB연결, 객체 생성 등)을 한 번만 정의할 수 있도록 하는 것이다.

conftest.py파일에 작성하면 모든 파일에서 import 없이 즉시 자동 공유가 가능하다.

Fixture 문법은 @pytest.fixture 인데 ( ) 안에 데코레이터 옵션도 넣을 수 있다.

위 사진처럼 일반 함수 위에 @pytest.fixture를 붙여준 다음, 테스트 함수 인자로 Fixture 함수 이름을 넣어주면 된다.

그러면 테스트 함수 안에서 인자로 받은 fixture함수의 return 값이 자동으로 주입된다.

▷conftest.py

conftest.py는 Fixture 공유 파일이다.

한마디로 conftest.py 안에 있는 fixture들은 같은 폴더나 하위 폴더 파일들에서 별도로 import하지 않고 사용가능하다.

여러 테스트 파일이 같은 fixture을 사용해야 할 때 쓸 수 있다.

이때 파일 이름은 반드시 conftest.py 로 해야한다.

▶parametrize

하나의 테스트 함수에 여러 입력값을 주입하여 반복 테스트를 간결하게 수행할 수 있게 해주는 마커이다.

일단 Marker라는 것은 pytest에서 테스트 함수에 메타데이터를 붙이거나 동작을 제어하는 데코레이터이다.

@pytest.mark.마커이름 으로 사용할 수 있는데 이 중 parametrize는 여러 입력값과 결과 값을 전달하여 중복코드를 제거하고, 케이스를 확장할 수 있다.

위 사진처럼 매개변수에 첫번 째로 "인자이름1, 인자이름2 .. " 처럼 사용할 변수들을 쓰고, 두번째로는 튜플을 감싸는 리스트를 적는데, 각 튜플은 테스트할 값들이다. 하나의 튜플이 하나의 테스트 케이스가 되는 것이고, 튜플 안의 값들이 변수로 들어가는 것처럼 동작한다.


8. 실전테스트

▶Mock

테스트를 할 때 실제 환경에 영향을 주는 위험한 로직은 가짜 객체인 'Mock'으로 대체하여 완벽하게 고립시켜야한다.

1. 대체 : unittest.mock.patch를 사용해 특정 모듈이나 함수를 Mock 객체로 전환

2. 검증 : assert_called_once_with(인자)를 사용해 해당 함수가 기대한 인자와 함께 정확히 호출되었는지 확인

 

Mocking의 원리는 의존성을 격리하는 것이다.

테스트 대상이 외부 의존성(API, DB)에 요청을 보낼 때 이를 Mock이 가로채서 가짜 응답을 만들어 반환한다.

 

▷Mock 패밀리 : Mock , MagicMock , patch

Mock : 최소형 가짜로 호출과 반환만 흉내내는 기본 가짜이다. 매직 메서드(__len__, __iter__)은 사용불가하다.

MagicMock : 매직 메서드를 자동 처리해주는 Mock이다.

patch : 가짜를 꽂는 도구로 실제 코드 위치에 MagicMock을 주입하고, 테스트가 끝나면 자동으로 원래 함수를 복구한다.

(*보통 직접 import 하는 것은 patch이고, 그 결과 객체는 자동으로 MagicMock이 된다.)

 

▷가짜로 바꾸는 법 ! @unittest.mock.patch

실제 테스트에서 특정 모듈이나 함수를 patch를 통해 mock으로 바꾸는 법은

1. 먼저 from unittest.mock import patch 를 통해 patch를 import하고,

2. Mock을 주입할 테스트 함수 위에 @patch("가짜로 바꿀 위치")를 쓰고, 함수에는 def 테스트함수(mock)으로 MagicMock을 변수로 받아와 사용할 수 있다.

 

▷가짜 응답 제어 : return_value 와 side_effect

mock.return_value = (설정값) 을 통해 정답 응답을 고정값으로 설정한다. 이 경우 인자가 뭐가 오든 같은 값을 반환한다.

mock.side_effect = [ ... ] 을 통해 mock을 호출할 때마다 순차적으로 리스트 안 값을 호출한다.

 

▷patch의 2가지 사용법

1. 위와 같이 @patch(" ")을 사용하는 방법으로, 이 경우 함수 전체에 패치를 적용하며 mock을 자동으로 주입한다.

2. 테스트 함수 안에 with patch() as mock:  문을 사용하여 해당 구문 안만 패치하고, as로 mock을 받을 수 있다.

-> 공통점으로는 범위 종료 시 자동으로 복구된다. with문 같은 경우 with문 위와 탈출 후에는 mock이 적용되지 않는다.

 

* 함수 전체를 가짜로 -> @patch()

* 일부 구간만 가짜로 -> with patch() as

* 여러 곳을 패치할 땐 @patch() 를 중첩하여 사용하며, 인자 순서 = 아래에서 위 이다.

 

▷호출 검증 메서드 비교