티스토리 뷰

나는 그동안 클라우드나 온프레미스 웹 서비스 개발을 주로 해오다가, 이번에 처음으로 온디바이스(On-device) 솔루션 환경 개발을 맡게 됐다. 그러다 보니 서버를 .exe 파일로 빌드해서 배포하는 생소한 경험을 하게 됐는데, 백그라운드 루프가 별도의 에러 없이 꺼지는 현상을 발견했다. 에러 로그가 없다보니 어느 시점에 종료되는지 정도만 파악할 수 있었는데... LLM에게 코드 재검토를 맡겨보니 asyncio가 Segmentation Fault(Segfault) 에러를 발생시킬 수 있다는 걸 처음 알게 됐다.
왜 하필 다른데서는 다 잘 되다가 온디바이스(Windows/EXE) 환경에서 문제가 발생했을까?
결론부터 말하자면, 메모리 관리의 '관대함' 차이 때문이다. 넉넉한 리소스의 리눅스 서버에선 가비지 컬렉션(GC)이 비교적 느슨하게 동작하여 문제가 숨겨져 있다가, 리소스가 제한적이고 타이트하게 컴파일된 .exe 환경으로 넘어오자 잠재되어 있던 '태스크 소멸 현상'이 발생하게 된 것이다.
태스크가 사라지는 현상 (Garbage Collection)

문제의 핵심은 파이썬의 가비지 컬렉션(GC)에 있었다. asyncio.create_task()로 만든 태스크 객체는 어디선가 참조(Reference)를 유지하고 있지 않으면, 파이썬 엔진은 이 태스크가 더 이상 필요 없다고 판단한다.
그렇게 되면 백그라운드 태스크가 한창 돌아가는 와중에 GC가 "어? 이거 아무도 안 쓰네?" 하고 메모리에서 해제해버린다. 이때 실행 중이던 코드는 이미 해제된(혹은 엉뚱한) 메모리 주소를 건드리게 되고, 결국 운영체제는 Segmentation Fault를 던지며 프로세스를 강제 종료시킨다.
로그 한 줄 남기지 못하고 서버가 죽어버렸던 이유가 바로 이것이었다 (멀티쓰레드로 동작해서 일 수도 있다).
해결 방법: 강한 참조(Strong Reference) 유지하기
참조를 유지하지 않아서 태스크에 할당된 메모리를 해제해버린다면 강제로 할당하면 된다. 이 방식은 조금 투박해 보일 수 있지만, 이는 파이썬 공식 문서에서도 명시한 권장 해결 방법이다.
import asyncio
from fastapi import FastAPI
app = FastAPI()
# 1. 태스크를 담아둘 전역 세트 생성 (강한 참조 유지용)
background_tasks = set()
async def some_heavy_task(name: str):
try:
# 실제 백그라운드 로직 수행
await asyncio.sleep(10)
print(f"Task {name} 완료")
except Exception as e:
print(f"Task 에러 발생: {e}")
finally:
# 3. 작업이 완전히 끝나면 세트에서 제거 (메모리 누수 방지)
# 이 시점에는 안전하게 메모리에서 해제되어도 무방하다.
background_tasks.discard(task)
@app.get("/trigger")
async def trigger(name: str):
# 2. 태스크 생성 후 즉시 세트에 추가
task = asyncio.create_task(some_heavy_task(name))
background_tasks.add(task)
# 또는 콜백을 활용해 깔끔하게 제거할 수도 있다.
# task.add_done_callback(background_tasks.discard)
return {"status": "started", "message": "백그라운드 루프가 안전하게 시작되었습니다."}
생성된 태스크 객체가 GC의 타겟이 되지 않도록 세트(Set)나 리스트 등에 명시적으로 담아두어 참조 카운트를 1 이상으로 유지하는 것이다. 아래는 공식 문서에서 제안한 방식이다.

코드를 간단하게 정리해보자면 생성된 태스크 객체가 GC의 타겟이 되지 않도록 어딘가(Set이나 List)에 명시적으로 담아두는 것이다. 태스크가 실행 중인 동안 참조 카운트(Reference Count)를 1 이상으로 유지하여 메모리에서 해제되는 것을 방지한다.
그런데 이 segfault 에러는 async 동작을 전부 sync로 변경하니 사라지게 됐다. 명시적인 할당 없이 매서드를 동기로 변경하면 왜 해결이 될까?
동기(Sync)로 전환했을 때 해결되는 이유

이는 메모리 관리의 책임 주체가 바뀌었기 때문이다.
asyncio 루프는 태스크를 '약한 참조'로 느슨하게 쥐고 있어 GC에 취약하다. 반면, FastAPI의 워커 스레드는 동기 함수(def)가 실행되는 동안 그 내부 리소스를 스택 메모리에 강하게 고정(Lock-in)시킨다. 즉, OS 스레드 자체가 일종의 거대한 '강한 참조' 역할을 해주기 때문에, 온디바이스의 제한적인 메모리 환경에서도 객체가 증발하지 않고 살아남을 수 있었던 것이다.
마치며
아마 LLM이 없었으면 이 에러는 영원히 해결하지 못했을지도 모른다. 스레드를 한두 개 더 만들거나 백그라운드 작업을 맡기는 건 서버 개발자 입장에서 특별한 작업이 아니라고 생각했었기에 더 당혹스러웠다.
파이썬 이벤트루프를 통한 CG의 동작 개념을 100% 이해하진 못했지만, 이번 경험을 통해 환경에 따라 기술의 정석이 바뀔 수 있다는 점을 다시한번 느꼈다.
제한적인 온디바이스 환경에서는 유연성을 갖춘 비동기식보다 고전적이고 확실한 동기 방식이 정답일 수 있다는 것을 배웠다. 슬픈점은 이걸 배우기 위해서 너무 많은 시간 소모가 있었다는 것 ㅠㅠ
'개발 > 뭔지모르면여기' 카테고리의 다른 글
| 해외 결제를 위한 Stripe 사용해보기 with. SpringBoot + Kotlin (0) | 2025.05.26 |
|---|---|
| 파일 저장 시스템에서 이름 중복 처리 하기 (1) | 2025.05.06 |
| IOS에서 올려주는 NFD 방식을 NFC로 마이그레이션하기 with. PostgreSQL, Java, Kotlin (0) | 2025.04.10 |
| 언제나 복잡한 이름 순 정렬 구현하기 (0) | 2025.04.07 |
| 고유한 객체 ID를 만들어보자 (UUID vs SnowFlake, 커스텀 ID 생성) (1) | 2025.02.10 |
- Total
- Today
- Yesterday
- CORS
- rag
- docker
- Redis
- 스프링부트
- 티스토리챌린지
- lambda
- ChatGPT
- 후쿠오카
- Spring
- serverless
- ecs
- S3
- JWT
- Kotlin
- java
- OpenAI
- AWS
- cache
- 람다
- Log
- springboot
- 오블완
- AWS EC2
- GIT
- terraform
- 인프런
- EKS
- CloudFront
- elasticsearch
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |

