들어가며
CellCraft는 유전자 조절 네트워크 추론 작업을 수행할 수 있는 웹 플랫폼으로, 현재 7개의 GRN 추론 알고리즘(TENET, FastTENET, FastSCODE, GENIE3, GRNBoost2, LEAP, Scribe)을 통합 제공하고 있습니다.
본 글에서는 각 추론 알고리즘의 패키지 의존성 충돌 문제를 해결하기 위해 Docker 기반 플러그인 아키텍처를 설계한 과정을 정리하고자 합니다. 추론 알고리즘을 웹에서 관리하고 실행할 수 있게 개발하는 과정에서 겪은 시행착오와 Docker를 도입한 배경, 플러그인 아키텍처를 어떻게 설계했는지에 대한 과정을 공유해보겠습니다.
패키지 의존성 충돌 문제
알고리즘별 런타임 다양성
CellCraft가 통합 제공하는 알고리즘들은 각각 다른 런타임 환경을 요구합니다. 각 플러그인의 Dockerfile과 의존성 파일을 기준으로 정리하면 다음과 같습니다.
| 알고리즘 | 런타임 | 베이스 이미지 | 주요 의존성 | GPU |
|---|---|---|---|---|
| TENET | Python | debian:bullseye-slim | JPype, numpy, statsmodel, scipy | X |
| FastTENET | Python | nvidia/cuda:12.1.0 | numpy, lightning, scikit-learn, scipy | O |
| FastSCODE | Python | nvidia/cuda:12.1.0 | numpy, lightning, scikit-learn, scipy | O |
| GENIE3 | Python | debian:bullseye-slim | arboreto, statsmodels | X |
| GRNBoost2 | Python | debian:bullseye-slim | arboreto, statsmodels | X |
| LEAP | R + Python | debian:bullseye-slim | R 4.4.2 (LEAP, igraph 등 ~70개) | X |
| Scribe | R + Python | debian:bullseye-slim | R 4.4.2 (Scribe, monocle3 등 ~150개) | X |
| GRNViz | R + Python | debian:bullseye-slim | R 4.4.2, networkx, matplotlib, plotly | X |
Python 전용 알고리즘, R + Python 혼용 알고리즘, GPU(CUDA 12.1) 알고리즘이 혼재하며, 특히 R 기반 플러그인(LEAP, Scribe)은 시스템 라이브러리(cairo, pango, harfbuzz 등)를 포함한 수십~수백 개의 패키지를 요구합니다.
이러한 복잡한 의존성을 가지고 있는 알고리즘들을 단일 환경에서 실행하면 라이브러리 버전 충돌과 환경 오염이 불가피한 상황이었습니다. 이에 따라, 알고리즘 간의 실행 환경 격리를 어떤 방식으로 구현할지 여러 테스트 과정을 거쳐 결정하게 되었습니다.
격리 방식 결정
| 격리 방식 | 한계 |
|---|---|
| 가상환경 (venv/conda) | R과 Python 혼용 시 시스템 라이브러리(libssl, libcurl 등) 충돌 해결 불가. CUDA 환경은 별도 관리 필요 |
| 프로세스 격리 | subprocess 등으로 알고리즘을 별도 프로세스로 실행하면 메모리 공간은 분리되지만, 파일시스템과 시스템 라이브러리는 같은 호스트를 공유하므로 R/Python/CUDA 라이브러리 충돌 해결 불가. |
| VM (가상머신) | 클라우드 환경에서는 VM 풀이나 경량 VM(Firecracker 등)으로 구현 가능하지만, CellCraft의 온프레미스 환경에서는 단일 서버에 알고리즘별 VM을 상시 유지해야 하므로 하이퍼바이저 오버헤드와 고정 메모리 할당으로 리소스 효율이 낮음 |
반면에 Docker 컨테이너는 위와 같이 각각의 격리 방식이 가진 한계를 어느 정도 해소할 수 있었습니다.
- 컨테이너마다 독립된 파일시스템, 네트워크, 시스템 라이브러리를 가져 R/Python/CUDA 환경을 완전히 분리
- 프로세스 격리보다 강력한 커널 수준의 격리이면서, VM보다 경량
- 같은 이미지에서 여러 컨테이너를 독립적으로 생성하여 병렬 실행 시 상태 오염 방지
플러그인 아키텍처 설계
하지만 알고리즘들의 실행 환경을 Docker 이미지로 정의하여 단순 격리를 통해 해결될 문제는 아니었습니다. 확장성을 고려하여 추후에 알고리즘이 추가될 때마다 새롭게 알고리즘에 대한 코드 수정과 Dockerfile을 작성해야 한다면 유지보수가 어려워집니다. 이에 일반적으로 소프트웨어 아키텍처에서의 플러그인 아키텍처(Plugin Architecture) 패턴을 참고하여 설계했습니다.
플러그인 아키텍처는 코어 시스템과 플러그인 모듈로 구성되며, 코어를 수정하지 않고 플러그인을 추가/제거할 수 있는 확장 가능한 구조입니다. 플러그인 아키텍처가 충족해야하는 특징은 아래와 같습니다.
- 확장성(Extensibility): 코어 수정 없이 새로운 기능 추가
- 표준화된 계약(Contract): 플러그인이 따르는 인터페이스 정의
- 플러그인 독립성(Independence): 플러그인 간 상호 의존 없음
- 격리(Isolation): 플러그인과 코어, 플러그인 간 독립적 동작
CellCraft에서는 해당 패턴을 Docker 이미지 단위로 구현하였고 개별적인 플러그인 모듈에 대한 정의 및 구현을 아래와 같이 수행 했습니다.
| 플러그인 특성 | CellCraft 구현 |
|---|---|
| 코어 시스템 | FastAPI + Celery + ContainerManager(작업 컨테이너 정의 모듈) |
| 플러그인 모듈 | GRN 추론 알고리즘 (표준 디렉토리 구조) |
| 계약 | metadata.json + Snakefile + Dockerfile |
| 격리 | Docker 컨테이너 (프로세스/파일시스템/네트워크 완전 분리) |
| 플러그인 독립성 | Dockerfile (독립된 Docker 이미지) |
| 등록/발견 | GUI 기반 플러그인 등록, DB 및 파일시스템 기반 관리, GHCR Pull |
플러그인 계약
모든 플러그인이 따라야 하는 표준화된 디렉토리 구조를 정의했습니다.
{plugin_name}/
├── Dockerfile # 실행 환경 정의
├── Snakefile # 실행 규칙 정의
├── metadata.json # 입력/출력/파라미터 정의
├── dependency/ # 패키지 의존성
│ ├── requirements.txt # Python 패키지
│ ├── environment.yml # Conda 환경
│ └── renv.lock # R 패키지
└── scripts/ # 분석 스크립트
해당 구조에서 metadata.json이 플러그인의 주요한 인터페이스 계약 역할을 합니다. 입력 파일 형식, 출력 파일 형식, 사용자 설정 가능한 파라미터, GPU 사용 여부 등을 명세하며, 코어 시스템은 계약을 참조하여 모든 플러그인을 동일한 방식으로 실행합니다.
코어 시스템 입장에서의 실행 흐름은 단순합니다.
1. metadata.json 기반으로 등록된 DB Plugin 테이블에서 플러그인 정보 읽기
2. 사용자가 웹 GUI를 통해 설정한 파라미터 및 입력 파일 확인
3. 플러그인의 Snakefile 템플릿에 사용자 입력값을 맵핑하여 해당 작업에 대한 Snakefile 동적 생성
4. Dockerfile로 정의된 이미지를 기반으로 고유 컨테이너 생성
5. 컨테이너 내부에서 동적으로 생성된 Snakefile을 기반으로 알고리즘 실행
6. Snakefile에 정의한 출력 파일 규칙에 따라 결과 파일 저장
실제 작업 실행의 핵심은 3번의 Snakefile 동적 생성입니다. 각 플러그인은 실행 규칙이 정의된 Snakefile 템플릿을 가지고 있으며, 사용자가 GUI에서 설정한 파라미터(입력 파일 경로, 분석 파라미터, 클러스터 선택 등)가 템플릿의 플레이스홀더에 맵핑되어 작업별 고유한 Snakefile이 생성됩니다.
# 플러그인의 Snakefile 템플릿
rule preprocessing:
input: "{input.h5ad}"
params:
cell_group="{cell group}",
clusters={clusters}
output: "{output.h5ad}"
# 사용자 입력이 맵핑된 후 동적 생성된 Snakefile
rule preprocessing:
input: "user/john/data/pbmc.h5ad"
params:
cell_group="leiden",
clusters=["cluster_1", "cluster_2"]
output: "user/john/workflow_1/algorithm_1/results/output.h5ad"
이를 통해, 코어 시스템은 해당 플로우를 통해 플러그인이 어떤 알고리즘인지 알 필요 없이 계약에 정의된 메타데이터와 Snakefile 템플릿만으로 모든 플러그인을 동일한 방식으로 실행합니다. 또한 새로운 알고리즘을 추가할 때 코어 시스템의 코드를 수정할 필요 없이, 위 계약에 맞게 사용자가 웹에서 플러그인 등록을 진행하면 플러그인 디렉토리와 계약 정보가 DB에 저장되어 사용할 수 있게됩니다.
Official / Local 2계층 구조
Docker 기반 플러그인 아키텍처에서는 컨테이너를 실행하기 위해 Dockerfile을 기반으로 이미지를 사전에 빌드해야 합니다.
초기에는 플러그인 계층을 나누지 않고, 작업 실행 시점에 이미지가 없으면 빌드하는 Lazy 빌드 방식으로 개발했습니다. 하지만 각 플러그인의 의존성 규모와 GPU 플러그인의 CUDA 베이스 이미지 용량으로 인해 플러그인 하나당 빌드에 평균 15분 이상이 소요되었고, 작업 실행 시 빌드 시간만큼 병목이 발생하는 문제가 있었습니다.
이를 해결하기 위해 플러그인을 Official(사전 빌드)과 Local(사용자 정의) 두 계층으로 분리했습니다.
backend/plugin/
├── official/ # Git submodule (읽기 전용)
│ ├── GRNBoost2/
│ ├── TENET/
│ ├── FastTENET/
│ └── version.json
└── local/ # 사용자 정의 (읽기/쓰기)
└── {plugin_name}/
Official 플러그인은 CellCraft가 기본 제공하는 7개 GRN 추론 알고리즘과 1개의 시각화 알고리즘으로, GitHub Container Registry(GHCR)에 사전 빌드된 이미지로 배포됩니다. 서버 시작 시 자동으로 이미지를 Pull하므로 작업 실행 시 빌드 과정 없이 즉시 컨테이너를 생성할 수 있습니다. 또한, 읽기 전용이며 사용자가 수정할 수 없습니다.
# Official: GHCR에서 이미지 해석
image_name = f"ghcr.io/cxinsys/cellcraft-{plugin_name}:{version}"
Local 플러그인은 사용자가 웹 GUI를 통해 직접 스크립트와 의존성을 업로드하여 생성 가능합니다. 업로드 시 Dockerfile이 동적으로 생성되며, 사용자가 플러그인 관리 페이지에서 빌드를 요청하면 비동기 Celery Task로 이미지가 빌드됩니다. 빌드가 완료된 후에 작업 실행이 가능하며, 수정과 재빌드가 가능합니다.
# Local: 로컬 빌드 이미지
image_name = f"plugin-{plugin_name}"
2계층 분리의 핵심은 빌드 시점의 분리입니다. Official 플러그인은 배포 시점에 빌드를 완료하여 실행 시 병목을 제거하고, Local 플러그인은 등록 시점에 사용자가 명시적으로 빌드를 수행하여 작업 실행과 빌드를 분리했습니다.
컨테이너 격리 실행
컨테이너 실행 전략: 플러그인 단위 vs 작업 단위
Docker 기반 격리에서 컨테이너를 어떤 단위로 생성할지 두 가지 선택지가 있었습니다.
- 플러그인 단위 컨테이너: 알고리즘별로 하나의 컨테이너를 상시 유지하고, 여러 작업이 해당 컨테이너 안에서 실행되는 방식입니다. 컨테이너를 생성/삭제하는 오버헤드가 없고 관리가 단순하지만, 같은 알고리즘의 병렬 실행 시 상태 오염(중간 결과물 덮어쓰기)이 발생할 수 있고, 작업 단위 리소스 격리가 불가능합니다.
- 작업 단위 컨테이너: 작업 실행 요청마다 새로운 컨테이너를 생성하고, 완료 후 삭제하는 방식입니다. 작업 단위로 파일시스템과 리소스가 격리되어 상태 오염을 방지할 수 있지만, 컨테이너 생성 → 삭제를 반복해야 하고, 동시에 관리해야 하는 컨테이너 수가 늘어납니다.
CellCraft에서는 같은 알고리즘이 여러 사용자에 의해 동시에 실행되는 상황이 빈번하고, 작업별 리소스 추적이 필요했기 때문에 작업 단위 컨테이너 방식을 선택했습니다. 다만, 작업 단위 컨테이너 생명주기 관리의 복잡도가 높아졌고 생명주기 관리가 필요한 상황이었습니다.
실행 흐름
작업 실행 요청이 들어오면 매 실행마다 고유한 Docker 컨테이너를 생성합니다.
# snakemake_utils.py — 컨테이너 설정
container_config = {
'image': image_name,
'name': f"plugin-{plugin_name}-task-{task_id[:8]}-{timestamp}",
'volumes': {
host_backend_path: {"bind": "/workspace", "mode": "rw"}
},
'environment': {
'MAMBA_ROOT_PREFIX': '/opt/micromamba',
'PYTHONUNBUFFERED': '1',
'CELERY_TASK_ID': task_id
},
'user': f"{os.getuid()}:{os.getgid()}",
'network': 'cellcraft_app-network',
'labels': {
'celery.task_id': task_id,
'plugin.name': plugin_name,
'container.type': 'plugin-execution'
}
}
- 컨테이너 이름에 task_id 포함: 어떤 작업의 컨테이너인지 식별 가능
- labels: task_id, plugin_name, container_type을 메타데이터로 부여하여 label 기반 검색/정리 가능
- user 설정: 호스트와 동일한 UID/GID로 실행하여 볼륨 마운트 시 파일 권한 문제 방지
컨테이너가 생성되면 내부에서 Snakemake를 통해 분석 워크플로우를 실행합니다.
# 컨테이너 내부에서 실행되는 명령
cd /workspace && \
snakemake --unlock --snakefile {path} 2>/dev/null || true && \
snakemake {targets} --snakefile {path} -j 1 --printshellcmds 2>&1 | tee {log_file}
Docker Socket Mounting 구성
작업 실행 요청을 받아 플러그인 정보를 기반으로 작업을 실행하는 Celery Worker 자체도 Docker 컨테이너에서 실행되기 때문에, Celery Worker 컨테이너 안에서 플러그인 컨테이너를 생성해야 합니다. 이를 위해 호스트의 Docker 소켓을 Worker 컨테이너에 마운트하여, Worker가 호스트의 Docker 데몬에 직접 명령을 보내는 방식으로 구현했습니다.
# docker-compose.yml
celery:
volumes:
- /var/run/docker.sock:/var/run/docker.sock # 호스트 Docker 소켓 마운트
- ./backend:/app
컨테이너 내부에 별도의 Docker 데몬을 설치하는 Docker-in-Docker(DinD) 방식도 있지만, 데몬이 중복 실행되는 메모리 오버헤드와 이미지 캐시가 호스트와 분리되어 중복 다운로드가 필요한 문제가 있어 소켓 마운트 방식을 선택했습니다.
컨테이너 생명주기 관리
앞서 언급한 것처럼 작업 단위 컨테이너 방식의 트레이드오프로, 컨테이너 생성 → 삭제를 반복하면서 정상적으로 정리되지 못한 컨테이너가 누적되어 리소스를 낭비하는 문제가 발생할 수 있습니다. 특히 CellCraft의 장기 실행 작업(수일~수주)에서는 그 사이에 Worker 크래시, 사용자의 작업 취소, 서버 재시작 등 다양한 비정상 종료 시나리오가 발생할 수 있어, 컨테이너 생명주기를 체계적으로 관리해야 했습니다.
ContainerManager
작업과 컨테이너 간의 관계를 추적하고 관리하기 위해 ContainerManager 클래스를 구현했습니다.
class ContainerManager:
def __init__(self):
self._task_containers: Dict[str, str] = {} # task_id → container_id
self._container_tasks: Dict[str, str] = {} # container_id → task_id
self._cleanup_in_progress: Set[str] = set() # 정리 중인 컨테이너
self._lock = threading.Lock()
양방향 매핑: task_id로 container_id를 조회하는 것과 container_id로 task_id를 조회하는 것 모두 가능합니다. 작업 완료 시에는 task_id로, Docker 이벤트 처리 시에는 container_id로 조회하여 작업과 컨테이너를 일관성 있게 조회할 수 있습니다.
스레드 안전: Celery Worker는 여러 작업을 동시에 처리하므로, 매핑의 등록/해제가 동시에 발생할 수 있습니다. 그러므로 threading.Lock으로 동시 접근을 제어했습니다.
종료 시나리오별 정리 전략
| 시나리오 | 정리 방법 |
|---|---|
| 정상 완료 | 태스크 finally 블록에서 컨테이너 정지 + unregister_container |
| 작업 실패 | on_failure 훅에서 stop_task_container 호출 |
| 사용자 작업 취소 | on_revoke 훅에서 강제 정리 + 라벨 기반 백업 검색 |
| Worker 프로세스 종료 | SIGTERM/SIGINT 시그널 핸들러에서 cleanup_all_task_containers |
| 매핑 유실 | 라벨 기반 검색 (celery.task_id={task_id})으로 컨테이너 발견 후 정리 |
특히 사용자 취소 시나리오에서는 stop_task_container 로직으로 매핑에서 컨테이너를 찾지 못할 경우를 대비하여 컨테이너 생성 시 부여한 라벨 기반 백업 검색을 수행합니다.
def stop_task_container(self, task_id, timeout=10):
container_id = self.get_container_id(task_id)
if not container_id:
# 매핑에 없으면 라벨로 검색
return self._stop_container_by_task_label(task_id, timeout)
# 중복 정리 방지
if not self._mark_cleanup_in_progress(container_id):
return True # 이미 정리 중
try:
container = self.docker_client.containers.get(container_id)
container.kill(signal='SIGTERM')
container.wait(timeout=timeout)
container.remove(force=True)
self.unregister_container(task_id)
return True
except docker.errors.NotFound:
self.unregister_container(task_id)
return True # 이미 정리됨
finally:
self._unmark_cleanup_in_progress(container_id)
중복 정리 방지: 여러 경로(finally, on_failure, on_revoke)에서 동시에 같은 컨테이너를 정리하려 할 수 있습니다. _cleanup_in_progress 집합으로 이미 정리 중인 컨테이너에 대한 중복 시도를 방지합니다.
시그널 핸들러
Worker 프로세스가 SIGTERM이나 SIGINT로 종료될 때, 실행 중인 모든 플러그인 컨테이너를 정리합니다.
def _setup_signal_handlers(self):
def signal_handler(signum, frame):
self.cleanup_all_task_containers()
if signum == signal.SIGTERM:
os._exit(1)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
cleanup_all_task_containers는 매핑에 등록된 컨테이너를 먼저 정리하고, 추가로 container.type=plugin-execution 라벨을 가진 모든 컨테이너를 검색하여 매핑에서 누락된 컨테이너까지 정리합니다.
결과
| 기준 | 이전 (단일 환경) | 현재 (Docker 플러그인 아키텍처) |
|---|---|---|
| 의존성 관리 | R/Python/CUDA 충돌 | 알고리즘별 독립 이미지로 완전 분리 |
| 상태 격리 | 병렬 실행 시 결과물 오염 | 실행마다 독립 컨테이너 생성 |
| 확장성 | 알고리즘 추가 시 코어 수정 필요 | 플러그인 계약에 맞는 디렉토리 구성만으로 추가 |
| 컨테이너 정리 | 해당 없음 | 다중 경로 정리 + 라벨 기반 백업 + 시그널 핸들러 |
| 배포 | 플러그인 실행 환경을 로컬 빌드 | Official: GHCR 기반 Pre-built / Local: 온디맨드 빌드 |
개선해야할 문제
위와 같이 Docker 기반의 플러그인 아키텍처 설계에 대해서 순차적으로 정리해보니 개선해야할 문제점을 파악할 수 있었습니다.
현재 CellCraft에서는 이전 글에서 다룬 Redis Lua 세마포어를 통해 동시에 실행되는 작업 수를 논리적으로 제어하고 있지만, 각 Docker 컨테이너가 실제로 사용하는 리소스를 물리적으로 제한하지는 않고 있습니다.
# 현재: 리소스 제한 없이 컨테이너 생성
container_config = {
'image': image_name,
'volumes': { ... },
# cpu, memory 제한 설정 없음
}
# GPU: 모든 GPU 디바이스에 접근 가능
container_config['device_requests'] = [
docker.types.DeviceRequest(count=-1, capabilities=[['gpu']]) # -1 = 모든 GPU
]
즉, Redis 세마포어가 특정 작업에 대해 CPU 4슬롯을 사용한다고 논리적으로 기록하더라도, 실제 컨테이너는 호스트의 모든 CPU/메모리를 제한 없이 사용할 수 있는 상태입니다.
하지만 현재 운영에서 큰 문제가 발생하지 않는 이유는, 컨테이너 내에서 실행하는 Snakemake를 -j 1(단일 작업)으로 실행하고 있고, 알고리즘별로 파라미터에 사용할 CPU/GPU 코어 수를 지정하는 옵션이 존재하여 알고리즘 내부에서 리소스 사용이 제한적이기 때문입니다.
그럼에도 구조적으로는 논리적 예약과 물리적 제한이 일치하지 않는 상태이므로, 안전하게 처리하기 위해서는 Redis 게이팅의 슬롯 정보를 컨테이너 생성 시 연동하여 컨테이너에 물리적 리소스 제한을 적용해서 개선해야할 것 같습니다.
# 개선 방향: 슬롯 수에 따른 컨테이너 리소스 제한
if resource_type == 'cpu':
container_config['nano_cpus'] = resource_slots * 1_000_000_000 # 슬롯 수만큼 CPU 코어 제한
elif resource_type == 'gpu':
container_config['device_requests'] = [
docker.types.DeviceRequest(count=resource_slots, capabilities=[['gpu']]) # 슬롯 수만큼 GPU 할당
]
마치며
CellCraft에서 다루는 추론 알고리즘들은 복잡한 실행 환경을 요구했기 때문에, 격리와 확장성을 동시에 확보하기 위해 플러그인 아키텍처로 설계했습니다. 하지만 플러그인 아키텍처를 도입한 것만으로 모든 것이 해결된 것은 아니었습니다.
생물정보학이라는 도메인 특성상, 다루는 데이터가 크고 복잡하며 분석 작업의 단계가 다양하고 분석 도구마다 일관되지 않은 특징이 있었습니다. 인풋/아웃풋 데이터 형식, 파라미터 구조, 실행 환경이 도구마다 전부 달랐기 때문에, 플러그인이라는 체계로 묶지 않으면 각 도구를 하나씩 따로 처리해야 하는 상황이었습니다. 플러그인 아키텍처가 안정성과 확장성을 보장해주었지만, 각 도구를 플랫폼이 요구하는 형식과 플러그인 구조로 변환하는 과정에서 도메인의 다양성과 플랫폼의 표준화 사이의 간극을 줄이는 로직을 설계하는 것이 가장 어렵게 느껴졌습니다.
해당 경험을 통해 느낀 점은, 결국 도메인을 잘 이해해야 기술적 트레이드오프를 적절히 판단할 수 있다는 것이었습니다. 개인적으로 학부 시절 학장님이 항상 강조하셨던 말씀이 떠올랐습니다.
예술 뒤에 기술이 숨어야 한다
고객에게는 한 줄로 소개할 수 있는 기능, 직관적으로 설명할 수 있는 비즈니스 모델을 내세운 서비스를 지향할수록, 그 뒤에서는 더 고도화된 엔지니어링이 요구되는 것 같습니다. CellCraft의 플러그인 시스템은 사용자 입장에서는 "드래그 앤 드롭으로 분석 도구를 연결해서 실행한다"는 단순한 경험이지만, 그 뒤에는 본 글에서 다룬 격리, 계약 표준화, 컨테이너 생명주기 관리 등의 여러 엔지니어링이 필수적입니다.
CellCraft의 플러그인은 추론 알고리즘을 웹 플랫폼에서 실행할 수 있게 하는 핵심 비즈니스 로직이기에, 아직도 부족하고 계속 발전시켜야 하는 기능입니다. 시간이 많이 들어갈 수밖에 없는 문제이지만, 그만큼 서비스의 완성도를 결정하는 중요한 작업이기에 많은 고민이 들어가는 것 같습니다.
참고 자료
'백엔드' 카테고리의 다른 글
| Celery 비동기 시스템 개선기: 리소스 기반 동시성 제어 (0) | 2026.03.28 |
|---|---|
| Celery 비동기 시스템 개선기: Timeout 오류 해결 (0) | 2026.03.28 |
| Alembic을 이용한 DB 테이블 생성 레거시 개선하기 (0) | 2026.03.25 |
| Docker로 GPU 환경 구성하기 (0) | 2025.03.30 |
| Celery를 활용한 비동기 작업 처리 (0) | 2025.03.02 |