python

Thread, Sync, Async 의 동작 방식

몽자비루 2025. 12. 17. 22:56

지금까지 다양한 자동화 도구를 만들어 왔는데, 이때에 거의 Sync 방식으로 작성해두었다.

 

그러다가 동작이 완료되는 데까지 시간이 너무 오래걸리게 되어 병렬적으로 Thread 방식으로 개선도 진행해보고

리소스 사용량이 너무 많아 크래시가 발생하길래 Async방식도 시도해봤는데, 이 과정에서

이 세가지 방식의 차이점이 무엇인지, 그리고 어떤 장단점이 있는지, 사용방법은 어떤지 정리하고자 한다.

 

먼저 제목에도 있다시피 Sync, Async, Thread 세 가지는 함수를 어떻게 기다리고, 어떻게 이어서 실행하느냐

즉, 작업을 언제 멈추고 언제 재개하며 언제 동시에 동작시킬 지에 대한 규칙의 차이가 있다.

 

1. Sync (동기)

Sync방식은 하나의 작업이 끝나야 다음 작업을 진행하는 방식으로 기다리는 동안 아무것도 하지 않는다.

자세히 말하자면, 기다리는 동안 해당 스레드가 멈춰 있어 유휴 시간동안 아무처리도 못하는 비효율이 발생한다.

 

실행 방식을 확인할 수 있는 간단한 소스 코드는 아래와 같다.

import time

def work():
    print("시작")
    time.sleep(2)
    print("끝")

print("메인 시작")
work()
print("메인 끝")


# 실행 결과
"""
메인 시작
시작
끝
메인 끝
"""

위 내용을 보면 순차적인 진행을 100% 보장하며, 구도가 간단해 코드가 직관적인 동시에

흐름 추적 및 디버깅이 쉬워서 반드시 순서대로 진행되어야 하지만 대기가 적은 경우에 자주 사용된다.

 

다만, 대기가 많은 경우 기다리는 동안 리소스 낭비가 발생할 수 있고, 확장성이 낮아서 처리량이 늘어날수록

작업 속도가 느려지고 리소스 샤용량이 늘어나서 안정적으로 프로그램을 돌리기에 한계가 있다.

2. Async/Await (비동기)

이러한 한계를 해결하기 위한 방법으로 Async 방식, 즉 비동기 방식을 선택할 수 있다.

 

비동기 방식은 기다릴 때 기다리고 준비되면 다시 실행할 수 있는 방식으로 리소스를 잡고 있지 않는다.

Async 방식을 가장 쉽게 알 수 있는 코드는 아래와 같다.

import asyncio

async def func1():
    print("실행1")
    await asyncio.sleep(3)
    print("실행2")

async def func2():
    print("실행3")
    await asyncio.sleep(0)
    print("실행4")

async def main():

    print("asyncio 시작")
    
    task1 = asyncio.create_task(func1())
    task2 = asyncio.create_task(func2())
    await task1
    await task2

    print()
    print("await 시작")
    await func1()
    await func2()

asyncio.run(main())


#실행 결과:
"""
asyncio 시작
실행1
실행3
실행4
실행2

await 시작
실행1
실행2
실행3
"""

여기서 async def 는 비동기 함수, await은 멈춤 지점, asyncio 실행 엔진이라고 볼 수 있다.

 

먼저 asyncio에 대해서 먼저 언급하자면, task1 = asyncio.creat_task(func1()) 은 해당 함수를 실행한다.

 

이후 await task1 은 해당 함수가 끝날때까지 기다리는 역할을 하는 것으로 위 내용의 경우에는

func1 에서 await asyncio.sleep(3) 지점에 도착하면 제어권을 넘기고 func2를 실행한다\

 

그렇다면 task1 = asyncio.creat_task(func1()) , task2 = asyncio.creat_task(func2()) 여기에서

func1의 실행1이 먼저 진행되고 3초동안 기다리면서 func2가 실행 3, 4 가 출력된다.

 

그리고 "await 시작" 이 바로 출력되지 않는 이유는 await task1, await task2 가 코드에 있기 때문이다.

 

이 두개로 인해 func1 과 func2 가  끝나기 전까지는 이 뒤의 실행이 동작하지 않는데 정확히 말하자면

await 을 만나면 코루틴은 대기 상태로 전환되고 이벤트 루프는 실행 가능한 다른 task를 처리한다.

 

추가로 실무에서는 asyncio.gather()를 자주 사용한다.

 

이전에는 task1 = asyncio.creat_task(func1()) 처럼 작업을 각각 예약하고 각각 await을 진행했지만,

코드가 복잡해지는 것을 방지하기 위해 await asyncio.gather(func1(), func2()) 를 사용하기도 한다.

 

그렇게 되면 내부적으로 create_task 를 실행하는 것과 동일하게 여러 비동기 작업을 동시에 실행시키고

모든 작업이 끝날 때까지 기다렸다가 결과를 한번에 반환해주므로 코드 관리가 훨씬 쉬워진다.

 

그렇다면 await func1() 은 무엇일까?

task1 = asyncio.creat_task(func1()) 그리고 await task1 을 합친 것이라고 볼 수 있다.

즉, func1을 실행시키고 func1 이 끝날 때까지 기다리는 형태로 동작하게 된다.

 

따라서 await 으로 함수를 실행하게 되면 func1 이 끝날때까지 다른 함수가 실행되지 못하므로

3초의 대기 시간동안에도 func2 를 실행시키지 못하지만, sync 와 달리 대기 시간동안 리소스를 잡지 않는다.

 

즉, await 기준으로 순서가 보장되며 CPU 사용을 최소화할 수 있어서 대기가 많은 작업에 최적화 되어있다.

대규모 I/O 처리가 가능하고 특히 Playwright 에서는 async /await 을 기본적으로 사용하게 된다.

 

3. Thread

그렇다면 여기에서 나아가서, 여러 동작을 병렬적으로 실행하고 싶을 땐 아래와 같이 thread 함수를 사용한다.

 

thread 동작 결과를 가장 쉽게 보여줄 수 있는 코드는 아래와 같다.

import threading
import time

threads = []
num = 0
lock = threading.Lock()

def add_number():
    global num
    # with lock:
    temp = num          
    time.sleep(0.001)   
    temp += 1           
    num = temp          

for _ in range(100):
    t = threading.Thread(target=add_number)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print("Lock 없음 결과:", num)

#실행 결과:
"""
Lock 없음 결과: 5
"""

약간 복잡할 수 있는데, 먼저 lock 이라는 개념을 보지 말고 위 동작을 확인해보자.

 

t 에 threading.Tread(target=add_number) 으로 add_number 함수를 넣은 뒤에

t.start 로 add_number 함수를 Thread 방식으로 동작시키면, 병렬적으로 add_number 이 실행된다.

 

근데 왜 num 은 100이 이 아닌 5가 되었을까?

num = temp 를 하기 전에 다른 add_number 함수가 num 변수를 가져갔기 때문이다.

 

Thread 는 쉽게 말하자면 여러명의 사람이 해당 동작을 실행하는 방식이다.

 

위 함수를 보면 add_number은 global number 을 가져가는데 이때 20개의 add_number 이

0이라는 숫자를 가져간 다음에 1을 더하고 다음에 num 에 1을 덮어씌워 버리게 된다. 

 

그다음에 또다시 20개의 함수가 1을 가져가고 거기에 1을 더해 2를 덮어씌워 버리는 것을 반복한다.

num 은 결과적으로 100이 되지 않고 중복되는 Thread 동작으로 인해 임의의 숫자가 된다.

import threading
import time

threads = []
num = 0
lock = threading.Lock()

def add_number_with_lock():
    global num
    with lock:
        temp = num          
        time.sleep(0.001)   
        temp += 1           
        num = temp

for _ in range(100):
    t = threading.Thread(target=add_number_with_lock)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print("Lock 있음 결과:", num)

#실행 결과:
"""
Lock 있음 결과: 100
"""

Thread 를 사용하는 환경에서 num 을 100으로 만들기 위해서는 with lock 이 있다.

 

with lock 이란 해당 구간을 Thread 가 한번만 들어올 수 있게 만들어 temp 가 반드시

이전에 +1된 값을 받아오므로, 위와 같이 동시에 num에 접근할 수 없다.

 

이 Thread 함수는 return을 받을 수 없기 때문에, 만약 함수 안에서 어떠한 값을 받고 싶다면

위와 같이 공유 변수를 사용하거나 list/dict 같은 컨테이너에 담는 방법이 가장 흔하고,

실무에서는 안전성을 위해서 queue.Queue 에 담아서 사용하기도 한다.

 

마지막으로 join() 에 대해서 이야기하자면, join() 은 메인 스레드가 해당 스레드의 종료를

기다리도록 만들어, 작업이 끝나기 전에 프로그램이 종료되는 것을 방지한다.

 

Thread는 여러 thread 가 동시에 진행되므로, 동작이 끝나지 않았는데도 프로그램이 끝날 수 있다.

이때 이 Thread 가 끝나기 전까지는 실행된 프로그램을 종료를 강제로 대기시키는 것이다.

 

이렇게 작업을 병렬 처리하므로 API 로 여러 서버를 동시에 전송하거나 병렬적인 작업엔 유리하다.

 

다만 순서 제어가 어렵고 공유 변수 관리를 필수적으로 진행해야 하므로, 디버깅이 어렵고

특히 구조가 커지거나 lock 실수를 하는 경우, 버그 혹은 데드락까지 생길 수 있을 정도로 위험성이 높다. 

 

 

이렇게 세가지 동작 방식에 대해 알아봤는데, 어떤 것이 더 좋다나쁘다를 따지는 것이 아니라

본인이 만들고자 하는 프로그램의 성격과 리소스 흐름을 정확하게 파악하는 것이 무엇보다 중요하다.

 

- Sync  : 순서가 중요하고 대기가 적을 때
- Async : I/O 대기가 많고 확장성이 필요할 때
- Thread: 병렬 처리가 필요하지만 async 사용이 어려울 때

 

다만, Python의  Thread 는 GIL 로 인해 CPU 연산 병렬 처리에는 한계가 있으므로 Thread는 한계가 있다.

따라서 계산량이 아주 많은 작업을 할 때에는 Multiprocessing 을 고려해야 할 수도 있다.