무엇을 만들었나
팝콘톡(Pop Talk) — 영화를 골라주는 것이 아니라, 고른 이유를 검증해서 보여주는 개인화 AI 에이전트다. 5명이 팀 'hope'로 8월 7일부터 13일까지 만들었고, 교육 평가에서 우수상을 받았다.
문제 정의는 단순했다. 생성형 AI는 영화를 잘 추천한다. 다만 그 추천을 믿을 근거가 없다. 그래서 흐름에 두 단계를 더 넣었다.
기존 질문 → 추천 → 끝
팝콘톡 질문 → 취향 분석 → 추천 생성 → 추천 검증 → 이유 제공
핵심 가치를 넷으로 못 박았다 — 개인화 · 설명가능성 · 검증 · 신뢰. 이 중 검증이 첫 주에 배운 것을 다 끌어다 쓰게 만든 조건이었다. AI가 말한 영화가 실제 DB에 있는지, 조건(러닝타임·관람등급)을 충족하는지, 근거로 든 리뷰가 실재하는지를 서비스가 확인한 뒤에야 카드로 내보낸다.
이걸 한 장으로 그리면 네 구간이 된다. 수집이 재료를 넣고, 검토가 걸러내고, 사용자 화면이 걸러진 것만 쓰고, 배치가 사용자의 감상평을 다시 취향으로 되돌린다. 재료가 화면까지 가려면 관리자 검토를 반드시 통과해야 한다는 것이 이 그림의 핵심이다.
팝콘톡 전체 흐름 — ① 영화 수집·적재, ② 관리자 검토(인증 축과 노출 축을 따로 판정), ③ 인증되고 노출 중인 영화만 대상으로 하는 임베딩 벡터 검색, ④ 감상평을 매일 취향 프로필로 되먹이는 배치. 점선 상자는 2주 안에 구현하지 못한 구간이다
NCP 위에 올린 모양
첫 주 랩 순서가 그대로 구축 순서가 됐다.
popcorn-vpc (10.0.0.0/20)
├─ lb-public Public ALB → web × 2
├─ lb-private Private ALB → was × 2 · backend × 2
├─ app-private web 2 · was 2 · backend 2 · batch 1 (Rocky 9.6)
├─ db-private Cloud DB for PostgreSQL (Standalone)
├─ bastion-public 로컬에서 들어오는 유일한 문
└─ nat-public NAT Gateway — private 서브넷의 아웃바운드
- web Next.js · was FastAPI · backend CLOVA Studio 챗봇 에이전트(LangGraph) · batch APScheduler
- 배치가 하는 일 두 가지 — KOFIC·KMDB에서 영화 데이터 일일 동기화, 그리고 CLOVA Embedding으로 영화·리뷰 임베딩(추천은 벡터 검색으로)
- ACG는 서버 그룹별로 나누고, 22번은 오직 bastion ACG에서만 열었다. private 서브넷은 NAT로만 밖에 나간다
- 저장소는 NAS(초기 세팅)와 Object Storage(포스터 등 정적 콘텐츠)로 나눴다
NCP 인프라 구성 — 사용자는 공인 로드밸런서로만 들어오고, 웹 뒤의 WAS·앱 서버는 사설 로드밸런서 뒤에 숨는다. DB는 가장 안쪽 사설 서브넷에 있고, 사설 대역이 외부 API를 부르는 길은 NAT Gateway뿐이며, 사람이 들어가는 문은 bastion 하나다
첫 주에 "네트워크를 먼저 그리지 않으면 위가 전부 임시방편이 된다"고 적었는데, 2주차에 서버를 다시 만들 일이 없었다는 게 그 문장의 값이었다.
내가 맡은 첫 번째 갑판 — 어드민 콘솔
AI가 추천한 영화를 사람이 검수하고 인증하는 백오피스다. 프론트와 백엔드를 모두 맡았다. 8월 8일 새벽 첫 커밋부터 39번 커밋까지 갔다.
명세를 먼저 썼다. OpenAPI 3.1로 엔드포인트 35개를 정의하고, 그걸로 세 가지를 얻었다.
- 백엔드 없이 화면부터 — prism 목 서버를 명세만으로 띄워 붙였다.
?page=abc가422를 돌려주는 것까지 명세만으로 동작한다 - 문서 두 장 — 같은 명세를 Redoc(읽기용)과 Swagger UI(호출용) 정적 페이지로 빌드해 배포했다
- 깨뜨리는 변경은 CI가 막는다 — 명세를 건드리는 PR에서 검사가 돈다
세 번째에서 diff 도구를 직접 만들어야 했다. @redocly/cli에는 diff 명령이 없고, npm openapi-diff는 OpenAPI 3.0까지만 읽는데 이 명세는 3.1이며, 3.1을 제대로 보는 oasdiff는 Go 바이너리라 npm 프로젝트에 끌어오기 무거웠다. 엔드포인트·파라미터·응답 필드 삭제, 필수화, 타입 변경 등 14가지를 깨뜨리는 변경으로 잡고, 새 엔드포인트나 선택 파라미터 추가는 통과시킨다. 응답 enum 값 추가는 엄밀히는 깨뜨리는 변경이지만 상태값이 계속 늘어나는 단계라 경고만 하게 뒀다.
화면 쪽에서 가장 값이 컸던 결정은 상태 뱃지를 하나로 통일한 것이다. 화면마다 .statusTag·.actionTag·.changeTag·.reportTag를 따로 만들어 놓아 같은 "정지"가 화면마다 다르게 보였다. 디자인 시스템의 tone 정의(승인·완료 / 거절·제재)가 이 도메인과 그대로 맞아떨어져 하나로 접었다.
데이터는 3단 폴백으로 뒀다 — 실 DB → 스냅샷 300편 → 목 데이터. 실 DB가 VPC 안에 있어 팀 밖에서는 화면이 안 뜨는 문제를, 실 데이터로 뜬 화면을 유지한 채 해결하는 방법이었다.
명세를 먼저 쓴 덕에 화면과 API와 DB가 어느 순서로 오가는지도 미리 그릴 수 있었다. 백엔드를 만드는 사람은 이 그림의 화살표만 따라가면 됐다.
어드민 시퀀스 — 관리자가 목록을 열고 판정하고 노출을 토글할 때 화면·API·DB가 오가는 순서. 판정은 인증·반려·수정·재검토 넷뿐이고 모든 판정은 감사 로그로 남으며, 반려만 예외적으로 노출 축까지 함께 내린다. 명세에 적힌 엔드포인트를 그대로 썼다
두 번째 갑판 — 리뷰 데이터
추천을 검증하려면 근거로 쓸 리뷰가 있어야 하는데, 서비스에는 리뷰가 7건이었다. 네이버 관람평을 모아 채웠다.
requests로는 안 됐다. HTTP 200에 1MB가 와도 관람평 카드는 0개 — 브라우저에서 JS로 그려지고, 초기 20건 뒤로는 컨테이너를 스크롤해야 순차 로드된다. Selenium으로 갔다.
그 과정에서 정렬당 310건 상한을 찾았다. 스크롤을 아무리 늘려도 310에서 멈춘다. 정렬 탭(공감순↔최신순)을 전환하면 목록이 새로 구성되고 겹침이 낮아, 두 정렬을 합쳐 중복을 제거하면 편당 최대 약 570건까지 나온다.
수집 영화 1,197편 · 관람평 117,514건 (14시간, 차단 0회)
DB 적재 dev.reviews 118,301건 (내용 SHA-256 UNIQUE — 몇 번을 넣어도 안 늘어남)
회원 변환 가상 회원 300명 · 회원 리뷰 6,638건
합계 서비스 리뷰 124,939건
수집 리뷰를 그대로 회원 리뷰로 복사하면 같은 문장이 목록에 두 번 보인다. HyperCLOVA X(HCX-007)로 문체를 유지한 채 재작성했고, 여기서 프롬프트를 세 번 갈았다.
| 회전 | 증상 |
|---|---|
| v1 | 길이 5.5배로 부풀림 · 제목 끼워넣기 · 혹평 순화 · 뜻이 뒤집힘 |
| v2 | 길이·제목·왜곡은 잡힘, 반말이 존댓말로 바뀌는 것이 10건 중 3건 잔존 |
| v3 | 원문 어미를 코드로 판정해 지시문에 주입 → 문체 유지 10/10 |
여기서 배운 게 하나 더 있다. 메타 정보 누출(제목·평점을 문장에 끼워넣는 것)은 프롬프트로 못 막았다. 아무리 금지해도 확률적으로 새어 나온다. 결국 코드 필터로 걸러내고 3회 실패하면 폐기하는 쪽으로 바꿨다. 1차 시연분에서는 429(요청 제한) 를 맞아 순차 호출로 내렸다.
작성자 ID는 저장 시점에 SHA-256으로 해시해 원본이 디스크에 남지 않게 했고, 원문이 실제 사용자의 글이라 내부 시연 전용으로 못 박았다.
이번에 남은 것
2주는 기능을 다 만들기에는 짧고, 무엇을 안 만들지 정하기에는 충분한 시간이었다. 검증이라는 가치 하나를 붙잡으니 나머지가 정리됐다 — 리뷰가 없으면 검증이 불가능하니 데이터 수집이 최우선이 되고, 검증 결과를 사람이 확인해야 하니 어드민이 필요해지고, 벡터 검색이 필요하니 배치 서버가 임베딩을 돌린다.
기술적으로 가장 오래 갈 것 같은 건 "명세를 먼저 쓰고 CI로 지키게 한다" 쪽이다. 팀이 다섯이고 기간이 2주면 구두 합의는 반드시 어긋난다. 명세가 깨질 때 PR이 실패하고 무엇이 깨지는지 댓글이 달리면, 그 합의는 사람의 기억 밖에 놓인다.
반대로 아쉬운 건 문서가 코드보다 빨리 늙는 것을 막지 못한 구간이다. V0.5 기획 기준(상태 1축 + 노출 플래그)과 실제 구현(인증·서비스 2축)이 갈라졌고, 뒤늦게 V0.6·V0.7로 다시 그려야 했다. 명세는 CI가 지켜줬지만 다이어그램은 지켜주는 것이 없었다.