← back

AI Commons

AI 기반 강의 콘텐츠 자동 생성 플랫폼. 슬라이드, 영상, 음성을 AI로 제작.

2025.08 ~ 현재 · 메인 개발자 (기획 / 개발 / 테스트 전담)
NestJSReactPostgreSQLPrismaOpenAIGemini/Vertex AIElevenLabsffmpegBullMQRedis

background

대학 교수들이 15주 분량의 강의 콘텐츠를 제작하는 데 막대한 시간이 소요된다. AI Commons는 교수가 참고자료를 업로드하면 AI가 강의 구조를 설계하고, 슬라이드와 영상을 자동으로 생성하여 LMS에 바로 배포할 수 있게 한다.


architecture

graph TD
    LMS["LMS (Canvas)"] -->|SSO JWT| CLIENT_UI
    subgraph "Client · React 19 + Vite"
        CLIENT_UI["강의설계 · 슬라이드 · 동영상 · 보이스 · 관리자"]
        CLIENT_STATE["React Query + Zustand"]
    end
    CLIENT_UI -->|REST API| API
    subgraph "Server · NestJS 11"
        API[REST API]
        AUTH["Auth · JWT SSO Guard"]
        MODULES["Modules · courses/slides/videos/voices/files/jobs"]
        INTEG["Integration · OpenAI/Gemini/ElevenLabs/Commons/LMS"]
        WORKERS["BullMQ Workers"]
        CRON["Cron · 스토리지 정리"]
    end
    API --> AUTH --> MODULES
    MODULES --> INTEG
    MODULES --> WORKERS
    subgraph "Storage"
        PG[(PostgreSQL · Prisma)]
        REDIS[(Redis · Queue+Lock)]
        NAS[NAS · 미디어]
        VS[OpenAI Vector Store]
    end
    WORKERS --> PG
    WORKERS --> REDIS
    WORKERS --> NAS
    INTEG --> VS
        

도메인 기반 모듈 구조

각 비즈니스 도메인(강의설계, 슬라이드, 영상, 보이스)이 클라이언트 Feature와 서버 Module의 쌍으로 구성. 도메인 간 의존성을 최소화하여 독립적으로 개발/배포 가능.

비동기 작업 파이프라인

AI 콘텐츠 생성(슬라이드 이미지, 영상 인코딩, TTS 등)은 수 초~수 분이 소요되므로 BullMQ 워커로 비동기 처리. 클라이언트는 폴링으로 작업 진행률을 실시간 표시.

AI 서비스 Integration 레이어

OpenAI, Gemini, ElevenLabs 등 외부 AI 서비스를 별도 Integration 모듈로 추상화. 각 서비스의 API 차이를 내부에서 흡수하여, 비즈니스 로직은 특정 AI 벤더에 종속되지 않는 구조.

LMS 연동

JWT SSO로 LMS(Canvas) 사용자가 별도 로그인 없이 접근. 생성된 콘텐츠는 Commons(자사 CMS)를 통해 LMS에 직접 배포.


what i built

과목 설계 파이프라인

교수가 PDF 참고자료를 업로드하면 OpenAI Vector Store에 저장하고, File Search로 관련 내용을 검색하여 강의 설계에 활용
프롬프트 체이닝으로 과목 개요 → 주차별 주제 → 차시별 학습요소 → 세부 콘텐츠를 단계적으로 생성
생성된 구조를 교수가 검토/수정 가능. 수정 시 하위 콘텐츠만 부분 재생성
과목 유형(온/오프/블렌디드)에 따라 중간/기말고사 주차 자동 배치
벡터 스토어 만료 기간(한 학기)을 설정하고, 만료 항목 자동 삭제 크론으로 스토리지 관리

슬라이드 제작 파이프라인

이미지 생성

3단계 프롬프트 체인: 덱 공통 디자인 지침(팔레트, 타이포, 레이아웃) → 페이지별 개별 지침(역할, 서사, 텍스트 볼륨) → 두 지침을 합성하여 Gemini가 최종 이미지 생성
페이지 역할(cover/content/closing)에 따라 지침 우선순위를 다르게 적용
비율 자동 조정, 컬러 팔레트 설정, 다국어 대응, AI 워터마크 삽입

스크립트 생성

OpenAI Responses API로 페이지별 순차 생성. 이전 페이지의 컨텍스트를 유지하여 일관된 흐름의 강의 스크립트 생성

기타

기존 PDF/PPTX 업로드 시 AI 스크립트를 자동 생성하는 가져오기 모드
웹 기반 편집기에서 텍스트, 이미지, 레이아웃 수정 및 개별 재생성
BullMQ 워커로 전체 차시 일괄 비동기 생성, 실시간 진행률 표시

영상 생성 파이프라인

슬라이드 이미지와 TTS 음성을 합성하여 강의 영상을 자동 생성. 슬라이드 간 무음 여백(short/medium/long)을 삽입하여 자연스러운 전환 제공
ElevenLabs TTS 요청 시 함께 반환되는 단어별 타임스탬프를 활용하여 VTT 자막을 자동 생성하고 영상과 동기화. 워터마크 위치 설정, 슬라이드 비율에 맞춘 영상 해상도 자동 조정 지원
완성된 영상, 자막, 메타데이터를 패키지로 묶어 Commons(자사 CMS)로 직접 내보내기

음성 생성

ElevenLabs TTS를 활용한 AI 음성 생성. 교수가 음성 샘플을 녹음/업로드하면 보이스 클로닝으로 개인화된 강의 음성 제공
내장 보이스와 클로닝 보이스를 구분 관리. 글로벌 기본 보이스와 과목별 보이스를 분리 설정 가능

분산 환경에서의 동시성·안정성 설계

작업 유형별 BullMQ 큐 분리 — 각 큐의 특성(CPU/IO)에 맞는 동시성 한도 설정
ffmpeg CPU 핀닝으로 영상 인코딩 프로세스의 자원 격리
Redis 분산 락, 원자적 버전 관리, 고아 Job 자동 복구
Graceful Shutdown + PM2 통합으로 무중단 배포
Circuit Breaker로 외부 AI API 장애 격리

인증 & 인프라

OpenAPI(Swagger) 기반 API 설계
JWT SSO — LMS 로그인 사용자가 별도 인증 없이 접근. 역할 기반 권한 체크
정적 파일 서빙 최적화 — Apache Alias로 미디어 파일 직접 서빙
PgBouncer 환경에서의 Prisma prepared statement 충돌 해결

challenges

슬라이드 이미지 생성 방식 전환 — 템플릿에서 AI 네이티브 이미지 생성으로

상황 개발 초기 약 2개월간 HTML 템플릿 기반으로 슬라이드를 생성했다. LLM이 슬라이드의 구조와 내용을 JSON으로 생성하면, 미리 만들어둔 타입별 UI 템플릿에 매핑하여 렌더링하는 방식이었다. 하지만 템플릿 타입이 한정적이어서 표현의 다양성이 부족했고, 새로운 레이아웃이 필요할 때마다 템플릿을 추가로 개발해야 했으며, 디자인 품질도 만족스럽지 못했다.

상세: 네 가지 선택지 비교와 결정 과정

1. 외부 서비스 (Canva, Plus AI, SlideSpeak 등)

각각을 검토했지만, 서비스에 필요한 수준의 커스터마이징이 불가능하거나 비용 구조가 맞지 않았다.

2. LaTeX 기반 생성

분명한 장점이 있었다. AI를 활용하면 안정적이고 예측 가능한 포맷으로 슬라이드가 나오고, 학계에서 익숙한 형식이기도 했다. 다만 디자인 자유도가 제한적이고, 교수들이 기대하는 현대적인 시각 품질을 달성하기 어려웠다.

3. HTML 템플릿 고도화

기존 방식의 연장선이라 근본적 한계가 해결되지 않는다.

4. AI 이미지 생성 (Gemini NanoBanana)

그 무렵 Gemini의 새로운 이미지 생성 모델이 공개되었다. LLM으로 슬라이드 이미지를 직접 생성한다는 것이 당시에는 생소한 접근이었고, 실제로 초기 버전에는 미세한 글자 깨짐이나 이미지 수정 시 원하는 대로 반영되지 않는 문제가 있었다.

결정

솔직히 그 시점에서 LaTeX이 당장의 안정성 면에서는 더 나은 선택이었을 수 있다. 하지만 서비스가 데모로 나가야 하는 일정을 고려했을 때, AI 이미지 생성 모델이 빠르게 개선되고 있는 흐름을 보고 미래를 생각해서 Gemini 이미지 생성 방식을 선택했다. 초기의 불완전함은 프롬프트 엔지니어링으로 대응하면서, 모델 자체의 발전을 기다리는 전략이었다.

일관성 확보

AI 이미지 생성의 약점인 결과 일관성 문제는 3단계 프롬프트 체인(덱 공통 디자인 지침 → 페이지별 개별 지침 → Gemini 이미지 합성)으로 대응했다. 이후 Gemini의 상위 모델이 출시되면서 초기에 있던 글자 깨짐이나 수정 정밀도 문제가 크게 개선되었고, 이 결정은 올바른 방향이었음이 증명되었다.

배운 점 기술적 구현만큼이나 "지금 어디에 시간을 투자할 것인가, 그리고 어떤 문제가 시간이 지남에 따라 자연스럽게 해결될 것인가"를 판단하는 것이 중요하다는 걸 배웠다. 이 전환 덕분에 이미지 프롬프트 엔지니어링과 서비스의 다른 핵심 기능(영상 생성, 과목 설계 등)에 더 많은 시간을 투자할 수 있었다.

Vertex AI 마이그레이션 이후 분산 Rate Limiting

상황 Google이 Gemini API의 사용량을 대폭 축소하면서 Vertex AI로의 마이그레이션을 공식 권고했고, 이에 따라 전환했다. 하지만 Vertex AI에는 당장 Gemini API보다 더한 RPM 제한이 존재했고, 다수 사용자가 동시에 작업하면 429 에러가 빈번하게 발생했다.

사고 워커가 여러 서버에 분산되어 있어서 개별 서버의 요청 수 제어만으로는 전체 RPM을 관리할 수 없었다. "서버 간 공유 상태"가 필요했고, TCP AIMD 알고리즘을 차용한 분산 permit 시스템을 설계했다.

상세: 4-Layer 아키텍처와 AIMD 알고리즘

4단계 호출 레이어

Worker
  │  로컬 병렬 제어 (동시 이미지 생성 수 제한)
  ▼
Distributed Rate Limiter
  │  Redis 기반 전역 permit + 429 적응 제어
  ▼
Circuit Breaker
  │  5xx/네트워크 장애 감지 → 서비스 차단
  ▼
API Client → Vertex AI (retry 비활성화, 직접 제어)

분산 Rate Limiting (AIMD)

TCP 혼잡 제어의 AIMD(Additive Increase/Multiplicative Decrease) 알고리즘을 차용한 4-State FSM을 설계했다.

NORMAL (permit 40) → 429 발생 → COOLDOWN (5s→10s→20s→30s)
COOLDOWN → 타이머 만료 → PROBE (permit 1개로 테스트)
PROBE → 3회 연속 성공 → DEGRADED (1→2→4→8→16→32→40)
DEGRADED → baseLimit 도달 → NORMAL

Redis Lua 원자적 연산

permit 획득/반환/429 보고를 3개의 Lua 스크립트로 구현. Redis의 단일 스레드 실행으로 다중 서버에서도 race condition이 발생하지 않는다. 같은 burst에서 발생한 여러 429가 limit을 과도하게 줄이는 것을 방지하기 위해 waveId 기반 중복 제거도 구현.

Circuit Breaker

외부 API 호출에 Circuit Breaker 패턴(CLOSED→OPEN→HALF_OPEN)을 적용. 429 에러는 서비스 장애가 아니므로 커스텀 에러 필터로 Circuit Break 대상에서 제외하고, 분산 Rate Limiter가 별도로 처리하도록 분리.

Redis 장애 대비

Redis 자체가 장애가 나는 경우를 대비하여 로컬 동시성 제어로 자동 폴백. 10초 복구 윈도우 후 Redis 재시도.

결과 429가 발생해도 시스템이 자동으로 요청량을 줄이고 점진적으로 복구하는 구조를 확보. 사용자에게는 일시적으로 속도가 느려질 뿐 실패로 이어지지 않으며, 시스템이 외부 API 상태에 따라 스스로 적응.

영상 생성 파이프라인 성능 개선

상황 ffmpeg는 CPU 집약적인 프로세스다. 여러 사용자가 동시에 영상을 생성하면 ffmpeg이 모든 CPU를 점유하여 같은 서버에서 동작하는 API 서버와 다른 워커까지 영향을 받았다. 또한 영상 생성 자체도 느렸고, 슬라이드 전환 시 음성이 끊김 없이 이어져서 부자연스러웠다.

사고 컴퓨팅 자원을 늘리지 않고 현재 환경에서 ffmpeg 성능을 최적화하는 방법을 찾아야 했다. CPU 격리, 병렬 처리, 오디오 품질 세 가지 측면에서 접근했다.

상세: CPU 격리, 병렬 처리, 싱크 드리프트 해결

1. ffmpeg CPU 핀닝으로 자원 격리

ffmpeg이 모든 CPU를 점유하는 것이 블로커 이슈였다. Linux의 taskset을 사용하여 ffmpeg 프로세스를 특정 CPU 코어에 고정(pinning)하여, 나머지 코어는 API 서버와 다른 워커가 사용할 수 있도록 격리했다.

taskset -c 0-1 ffmpeg -y -loop 1 -i slide.webp -i audio.mp3 ...

환경변수로 사용할 CPU 수를 설정 가능하게 하여, 서버 사양에 따라 유연하게 조정.

2. TTS/세그먼트 병렬 처리

영상 생성 과정에서 TTS 음성 생성과 세그먼트 인코딩을 순차적으로 처리하고 있었다. async.mapLimit으로 병렬 처리를 도입하되, 각 단계의 특성에 맞게 동시성을 다르게 설정했다.

TTS 생성:     mapLimit(pages, ttsConcurrency=10)      // I/O 바운드 (API 호출)
세그먼트 생성: mapLimit(pages, segmentConcurrency=2)   // CPU 바운드 (ffmpeg)
비디오 큐:     concurrency: 2~4                        // 전체 영상 작업 동시 수

TTS는 외부 API 호출이라 I/O 바운드이므로 10개까지 병렬, 세그먼트 인코딩은 CPU 바운드이므로 2개로 제한.

3. 오디오/비디오 싱크 드리프트 수정

슬라이드가 많아질수록 오디오와 비디오의 싱크가 서서히 어긋나는 현상이 있었다. 원인은 ffmpeg 인코딩 시 비디오 스트림(5fps, 200ms 프레임 간격)과 오디오 스트림의 길이가 미세하게 달라서, concat 시 오차가 누적되는 것이었다.

ffprobe로 실제 duration을 정확히 측정하고, -itsoffset과 오디오 패딩으로 밀리초 단위 정렬. 50장 이상의 영상에서도 싱크 드리프트 제로.

4. 슬라이드 간 음성 여백

슬라이드 전환 시 오디오에 포즈가 없이 다음 슬라이드 첫 문장이 바로 이어져서 부자연스러웠다. ffmpeg의 anullsrc 필터로 무음 오디오를 생성하고, 동일한 포맷/길이의 무음은 캐시하여 반복 생성을 최소화하면서 자연스러운 전환을 확보.

앞으로 현재는 API 서버와 같은 머신에서 ffmpeg을 실행하고 있지만, 별도로 운용 중인 변환 서버를 활용하여 서버가 유휴 상태일 때 배치로 영상 인코딩을 처리하는 방안도 검토하고 있다.

Knowledge Structure First — 대용량 참고자료 기반 강의 설계의 한계와 대안

상황 과목 설계 기능은 이미 OpenAI Vector Store + File Search 기반으로 구현되어 있었다. 교수가 PDF를 업로드하면 벡터 스토어에 저장하고, file_search가 관련 청크를 찾아서 LLM이 강의를 생성하는 구조다. 하지만 대용량 파일(20개 이상)을 업로드하면 이 구조만으로는 한계가 있었다.

문제 file_search가 "알아서" 관련 청크를 찾아주지만, 파일이 많아질수록 어떤 청크가 검색되는지 제어할 수 없고, 관련 없는 청크가 컨텍스트를 차지하며, 여러 문서에 걸친 종합적 이해가 불가능했다. NotebookLM처럼 대용량 데이터를 활용한 교육 자료 생성이 안 되는 근본적 문제가 있었다.

상세: 관성을 의심하고 본질에서 다시 출발하기

출발점: "컨텍스트에 문서를 넣는다"는 프레이밍 자체가 함정이다

이 프레이밍을 받아들이는 순간, 해결책은 "어떻게 더 잘 요약/압축할까"로 수렴한다. 하지만 문제를 "교육 가능한 지식을 어떻게 표현할까"로 재정의하면, 문서 원본은 고려 대상에서 제외된다.

기존 가정 해부: 관성인가 필연인가

  • "문서 내용을 LLM 컨텍스트에 넣어야 한다" → 관성. 인간 교수는 50권의 교재를 동시에 펼쳐놓고 강의를 설계하지 않는다. 이미 읽고 이해한 상태에서 설계한다. "컨텍스트에 넣는다"는 것은 LLM에게 "읽기와 사고를 동시에 하라"고 요구하는 것이다.
  • "문서를 청크로 쪼개서 벡터로 검색해야 한다" → 관성. RAG의 표준 패턴을 의심 없이 따르고 있었다. 하지만 강의 설계에 필요한 건 특정 문장 검색이 아니라 "이 자료들에서 뭘 가르칠 수 있는가"에 대한 구조적 이해다.
  • "하나의 LLM 호출이 전체를 처리해야 한다" → 관성. 강의 설계라는 작업 자체가 단일 사고 과정이 아니다. 읽기, 설계, 채우기를 분리할 수 있다.
  • "원본 문서의 표현을 보존해야 한다" → 대부분 관성. 강의 설계에 필요한 것은 원본의 문장이 아니라 지식 구조다. 원본은 세부 콘텐츠 생성 단계에서만 필요.
  • "모든 문서가 동등하게 중요하다" → 관성. 실제로는 핵심 교재 1~2권이 있고 나머지는 보조 자료일 가능성이 높다.

본질만 남기기

모든 관성을 걷어내면 남는 본질: 입력은 "N개의 문서에 담긴 가르칠 수 있는 지식", 출력은 "학습 목표 → 순서 → 구조를 가진 강의 설계". 필요한 건 문서의 텍스트가 아니라 교육 가능한 지식의 구조적 표현이다.

제안: Knowledge Structure First

기존: 문서 → (검색) → LLM → 강의. LLM이 읽기와 사고를 동시에 수행.

제안: 문서 → 지식 구조 → LLM → 강의. 읽기와 사고를 분리.

  • Phase 1 (읽기): 업로드 시점에 각 문서의 지식 구조를 비동기로 추출 — 개념, 개념 간 선후관계, 난이도, 교육 가능 단위(teachable units)
  • Phase 2 (설계): 지식 구조(JSON)만으로 강의 설계 수행. 이 시점에 문서 원본은 컨텍스트에 없음. LLM은 읽기를 안 하고 설계만 함
  • Phase 3 (채우기): 특정 레슨의 세부 콘텐츠 생성 시에만 source_ref로 해당 문서의 해당 부분을 가져와서 원본 기반으로 생성

핵심 인사이트

지식 구조 추출은 문서 수에 대해 sublinear하게 스케일링한다 — 문서가 늘어나면 중복 개념이 기하급수적으로 증가하기 때문이다. 문서 500개여도 고유한 교육 가능 개념은 수백~수천 개이고, 개념 하나를 표현하는 데 50-100 토큰이면 충분하다. 128K 토큰이라는 컨텍스트 공간은 충분히 넓다. 문제는 그 공간에 얼마나 밀도 높은 정보를 넣느냐다.

배운 점 이 고민 과정에서 "당연하다고 여기는 패턴도 근본부터 의심해보는 것", "문제의 프레이밍 자체를 바꿔보는 것"이 중요하다는 걸 배웠다.

스크립트 생성 워크플로우 재설계 — 트레이드오프를 통한 근본적 해결

상황 슬라이드가 30장 이상이면 스크립트 생성 시 타임아웃 에러가 발생했다. 70장짜리 슬라이드에서는 10분 이상 로딩이 돌다가 실패하는 케이스가 보고되었다. 전체 페이지의 스크립트를 한 번의 API 호출로 생성하는 기존 방식의 한계였다.

사고 처음에는 배치 사이즈를 줄여서(25→20페이지) 병렬 요청으로 대응했지만, 근본적인 해결이 아니었다. 페이지 수가 늘어나면 결국 같은 문제가 재발했고, 병렬로 쪼개면 배치 간 맥락이 끊어져서 스크립트 품질도 떨어졌다.

상세: 배치 → 순차 전환과 트레이드오프

1차 대응: 배치 사이즈 축소 + 병렬 처리

전체 페이지를 한 번에 보내던 것을 20페이지씩 나누어 병렬로 요청. 각 배치에 전체 맥락(시스템 프롬프트)을 첨부하고, 유저 프롬프트에서 현재 생성할 페이지 인덱스를 지정. 타임아웃은 줄었지만, 배치 간 컨텍스트가 공유되지 않아 스크립트의 일관성이 떨어지는 문제가 남았다.

근본적 재설계: 순차 생성 + Responses API

발상을 전환했다. "한 번에 많이" 대신 "한 페이지씩 순차적으로" 생성하되, OpenAI Responses API의 previous_response_id를 활용하여 이전 페이지의 컨텍스트를 자동으로 유지하는 방식이다.

트레이드오프 분석

배치 방식 대비 순차 방식은 전체 완료 시간이 길어진다는 단점이 있었다. 하지만 다른 측면에서의 이점이 그 단점을 충분히 상쇄했다:

  • 안정성: 한 페이지씩 처리하므로 컨텍스트 초과로 인한 실패가 원천적으로 사라짐. 오류 발생 시 해당 페이지만 재시도(최대 2회)하고, 재시도 실패 시 명시적 에러 UI를 표시하고 중단
  • 품질: previous_response_id로 컨텍스트가 연속 유지되므로, 앞 페이지의 내용을 참고한 일관성 있는 스크립트 생성. 배치 방식에서는 불가능했던 부분
  • 사용성: 완료되는 스크립트를 순서대로 즉시 표시. 사용자는 전체 완료를 기다릴 필요 없이 첫 페이지부터 확인하고 검토 가능

배운 점 단순히 기술적 제약을 우회하는 것이 아니라, 제약 속에서 트레이드오프를 분석하고 오히려 더 나은 결과를 만들어내는 것이 설계의 핵심이라는 걸 배웠다. 10페이지 기준 배치 방식 68초(목표 강의시간 정확도 62%) → 순차 방식 162초(정확도 99%). 총 소요 시간은 2.4배 느리지만, 안정성·품질·사용성 세 가지를 동시에 얻었다.

클라이언트 성능 최적화

상황 데모 운영 중 "AI Commons를 여러 탭으로 열면 PC가 느려진다", "슬라이드 편집 시 버벅인다"는 피드백이 들어왔다. 전체적인 성능 테스트를 진행하면서 원인 조사에 착수했다.

분석 원인을 추적하니 하나의 큰 문제가 아니라 여러 겹의 비효율이 동시에 작용하고 있었다. 각각을 식별하고 Jira 하위 이슈로 분리하여 체계적으로 해결했다.

상세: 5가지 원인 분석 및 해결 과정

1. 비활성 탭의 불필요한 폴링

작업 상태를 확인하기 위한 폴링이 React Query 외부에서 setInterval로 구현되어 있어서, 탭이 비활성 상태여도 5초마다 서버에 요청을 계속 보내고 있었다. 탭을 여러 개 열면 이 요청이 누적되어 DB 커넥션 풀이 포화되는 핵심 원인이었다.

해결: React Query의 refetchInterval로 전환하여, AI 작업이 실제로 진행 중일 때만 폴링이 동작하고 비활성 탭에서는 자동으로 멈추도록 변경했다.

2. 캐스케이드 캐시 무효화

React Query의 mutation 성공 콜백에서 관련 데이터의 캐시를 무효화할 때, 무효화 대상이 특정되지 않으면 슬라이드, 영상, 과목 등 모든 도메인의 캐시를 한꺼번에 날리고 있었다. React Query의 partial key matching 특성상 실제 refetch 수는 그보다 훨씬 많았고, 이 burst가 불필요한 API 재호출과 컴포넌트 리렌더링을 유발했다.

해결: 무효화 대상을 해당 리소스 도메인으로 명확히 한정하고, 공통 훅에서 무효화 로직을 직접 정의하지 않고 사용처에서 필요한 만큼만 옵션으로 추가하도록 구조를 개선했다.

3. 슬라이드 에디터 리렌더링

대량의 슬라이드를 편집할 때 불필요한 컴포넌트 리렌더링이 발생했다. 이벤트 핸들러나 배열 참조가 매 렌더마다 새로 생성되면서 하위 컴포넌트가 전부 다시 그려지고 있었다.

해결: React.memo와 커스텀 비교함수를 적용하고, 불안정한 참조들을 안정화하여 실제로 변경된 컴포넌트만 리렌더링되도록 개선했다.

4. 서버 순차 조회 → 병렬화

작업 상태를 조회하는 API에서 여러 종류의 Job 테이블을 순차적으로 조회하고 있었다. 최대 5번의 DB 왕복이 필요했고, 응답이 길어지면 커넥션 점유 시간이 늘어나 풀 포화를 악화시켰다.

해결: Promise.all로 병렬 조회하여 최대 5 round-trip → 1 round-trip으로 약 70% 커넥션 점유 시간 단축.

5. SSE와 폴링 로직 통일

작업 상태를 받는 경로가 SSE(Server-Sent Events)와 API 폴링으로 이원화되어 있어서, 같은 데이터를 두 경로로 중복 요청하는 경우가 있었다.

해결: 두 경로를 하나로 통일하여 중복 요청을 제거했다.

검증 결과

Chrome DevTools (CPU 4x throttling + Slow 4G) 환경에서 슬라이드 에디터 6개 탭 동시 실행, 전체 이미지 생성 작업 진행 중 10분간 측정했다.

지표 Before After 개선
Long Tasks (탭 평균) 136개 4.7개 96.5% ↓
총 블로킹 시간 (탭 평균) 10.85초 0.57초 94.7% ↓
Lighthouse 성능 점수 78/100 100/100 +22점
6탭 Long Tasks 합계 816개 28개 96.6% ↓
6탭 총 블로킹 합계 65.1초 3.4초 94.8% ↓

개선 후 모든 탭이 Lighthouse 100점을 달성했고, 탭 간 성능 편차도 사라졌다(Before: 75~90점 → After: 100점 균일). CPU 4x throttling 환경에서도 100점이므로, 실제 저사양 기기에서도 원활한 동작이 보장된다.

배운 점 성능 문제는 하나의 큰 원인이 아니라 여러 작은 비효율이 겹쳐서 발생한다는 것을 체감했다. 문제를 한 덩어리로 보지 않고, 각각의 원인을 분리하여 이슈로 나누고 하나씩 해결한 뒤 전체 효과를 검증하는 과정이 중요했다.


retrospective

분산 환경에서 매일 부딪히는 문제들. 동시성 제어, 외부 API의 예측 불가능한 동작, 비동기 작업 파이프라인의 실패 복구 같은 문제들을 직접 정의하고 풀어가면서, AI 서비스의 핵심은 비동기 처리와 실패 복원력이라는 것을 체감했다.

프롬프트도 버전 관리가 필요하다. 프롬프트 변경이 출력 품질에 직접 영향을 미치므로, 코드와 동일한 수준으로 버전 관리하는 체계를 구축했다.

기획부터 운영까지. 기능 기획, 설계, 개발, QA, 릴리즈, 운영 이슈 대응까지 다양한 경험을 했다. 그 과정에서 주어진 문제를 풀기보다 스스로 문제를 정의하고 해결하는 경우가 많았고, 그 습관이 자연스럽게 자리 잡았다.