Captain's Log
← Logbook
어드민 풀스택 · 리뷰 데이터 파이프라인··Completed

영화 추천 AI 에이전트 '팝콘톡'

네이버 클라우드 위에 올린 영화 추천 AI 에이전트. 추천을 생성하는 데서 끝내지 않고 '실재하는 영화인가·조건을 충족하는가·근거로 든 리뷰가 실재하는가'를 검증한 뒤 내보낸다. 5인 팀 2주 프로젝트로 우수상을 받았고, 나는 어드민 콘솔(FE·BE)과 리뷰 파이프라인을 맡아 명세 35개를 먼저 쓰고 CI로 지키게 했으며, 리뷰 7건뿐이던 DB를 124,939건으로 채웠다.

AI에이전트추천검증데이터파이프라인어드민OpenAPINCPCLOVA
영화 추천 AI 에이전트 '팝콘톡'

Overview

팀 프로젝트(5인, 팀 'hope') · 2026.08 · 어드민 풀스택 · 데이터 파이프라인

NIPA·네이버클라우드 Sovereign AI PBL 과정의 팀 프로젝트로 팝콘톡(Pop Talk) 을 2주 만에 만들었다. 자연어로 상황을 말하면 영화를 추천하되, 추천의 근거를 서비스가 검증한 뒤에 내보내는 개인화 AI 에이전트다. 교육 평가에서 우수상을 받았다.

  • 5명이 웹·WAS·챗봇·배치·어드민을 나눠 맡았고, 나는 어드민 콘솔의 프론트와 백엔드, 그리고 리뷰 데이터 수집·적재·변환을 담당했다
  • 인프라는 네이버 클라우드 한 VPC 안에 서버 8대로 구성했다 — 웹 2대, WAS 2대, 챗봇 백엔드 2대, 배치 1대, 그리고 사설 대역으로 들어가는 유일한 문인 bastion 1대
  • 추천은 CLOVA Embedding으로 만든 벡터 검색 위에서 동작하고, 챗봇 백엔드는 CLOVA Studio와 LangGraph로 에이전트를 구성했다
  • 어드민 콘솔은 OpenAPI 3.1 명세 35개 엔드포인트를 먼저 쓰고 화면을 붙였으며, 명세를 깨뜨리는 변경은 CI가 막는다
  • 리뷰 데이터는 관람평 117,514건을 모아 적재하고, 그중 일부를 HyperCLOVA X로 재작성해 가상 회원 리뷰로 만들어 서비스 리뷰 124,939건을 갖췄다
  • Skill: 백오피스 기획·풀스택 구축, API 계약 설계(OpenAPI)와 CI 강제, 웹 스크래핑 파이프라인 설계, LLM 프롬프트 튜닝, 클라우드 인프라 점검
  • Stack: Next.js 16 · React 19 · TypeScript · Seed Design, FastAPI, PostgreSQL, Selenium, HyperCLOVA X(HCX-007) · CLOVA Embedding, Naver Cloud Platform(VPC · ALB · Cloud DB for PostgreSQL · Object Storage · NAT Gateway)

인프라 구성 — 네이버 클라우드 한 VPC 안에 웹·WAS·앱 서버를 두 대씩 두고 앞뒤로 로드밸런서를 걸었다. DB는 가장 안쪽 사설 서브넷에 있고, 사설 대역이 바깥과 통신하는 길은 NAT Gateway뿐이며, 사람이 들어가는 문은 bastion 하나다인프라 구성 — 네이버 클라우드 한 VPC 안에 웹·WAS·앱 서버를 두 대씩 두고 앞뒤로 로드밸런서를 걸었다. DB는 가장 안쪽 사설 서브넷에 있고, 사설 대역이 바깥과 통신하는 길은 NAT Gateway뿐이며, 사람이 들어가는 문은 bastion 하나다

Problem

생성형 AI는 영화를 곧잘 추천한다. 문제는 그 추천을 믿을 근거가 없다는 것이다. 모델이 말한 영화가 실제로 존재하는지, "2시간 안쪽"이라는 조건을 정말 충족하는지, 추천 이유로 든 평이 실재하는 사람의 말인지 사용자는 확인할 방법이 없다. 기존 흐름은 질문에서 추천으로 곧장 이어지고 거기서 끝난다.

그래서 팀은 흐름 중간에 두 단계를 더 넣기로 했다. 취향 분석과 추천 생성 뒤에 검증을 두고, 검증을 통과한 것만 이유와 함께 카드로 내보낸다.

이 결정이 곧 내가 맡은 두 갈래의 이유가 됐다. 첫째, 검증 결과를 사람이 최종 확인해야 하므로 백오피스가 필요했다. AI가 인증한 영화를 관리자가 검수하고 노출을 결정하는 화면이 없으면 "검증했다"는 말은 증명되지 않는다. 둘째, 근거로 인용할 리뷰가 있어야 검증이 성립하는데, 서비스 DB의 리뷰는 시작 시점에 단 7건이었다.

Solution

팀은 서비스를 네 구간으로 나눴다. 영화 데이터를 모아 적재하는 파이프라인, 그것을 사람이 검수하는 관리자 검토, 검수를 통과한 영화만 노출하는 사용자 서비스, 그리고 감상평을 취향으로 되먹이는 개인화 배치다. 내가 맡은 두 곳은 두 번째 구간 전체와, 세 번째 구간이 근거로 쓸 리뷰를 만드는 일이었다.

서비스 전체 흐름 — ① KOFIC·KMDB에서 영화를 모아 적재하고 ② 관리자가 인증·노출을 판정하며 ③ 인증되고 노출 중인 영화만 임베딩 벡터 검색의 대상이 되고 ④ 감상평이 매일 취향 프로필로 되먹임된다. 점선 상자는 2주 안에 구현하지 못한 구간이다서비스 전체 흐름 — ① KOFIC·KMDB에서 영화를 모아 적재하고 ② 관리자가 인증·노출을 판정하며 ③ 인증되고 노출 중인 영화만 임베딩 벡터 검색의 대상이 되고 ④ 감상평이 매일 취향 프로필로 되먹임된다. 점선 상자는 2주 안에 구현하지 못한 구간이다

어드민 콘솔 — 명세를 먼저 쓰고 CI가 지키게 한다

2주짜리 프로젝트에서 5명이 같은 API를 두고 일하면 구두 합의는 반드시 어긋난다. 그래서 화면보다 명세를 먼저 썼다. OpenAPI 3.1로 28개 경로에 걸쳐 엔드포인트 35개를 정의하고, 여기서 세 가지를 뽑아냈다.

먼저 백엔드가 없는 상태에서 화면을 붙였다. 명세만으로 목 서버를 띄우면 page 파라미터에 문자열을 넣었을 때 422가 돌아오는 것까지 동작한다. 다음으로 같은 명세를 읽기용과 호출용 두 장의 정적 문서로 빌드해 배포했다 — 훑어보는 사람과 실제로 두드려보는 사람의 필요가 다르기 때문이다. 마지막으로 명세를 건드리는 PR에서 깨뜨리는 변경을 CI가 잡는다.

세 번째를 위해 diff 도구를 직접 만들어야 했다. 표준 린터에는 diff 명령이 없고, npm 생태계의 diff 도구는 OpenAPI 3.0까지만 읽는데 이 명세는 3.1이며, 3.1을 제대로 읽는 도구는 Go 바이너리라 npm 프로젝트에 끌어오기 무거웠다. 엔드포인트·파라미터·응답 필드의 삭제, 선택 항목의 필수화, 타입 변경 등 14가지를 깨뜨리는 변경으로 판정하고, 새 엔드포인트나 선택 파라미터 추가처럼 안전한 것은 통과시킨다. 응답 enum 값 추가는 엄밀히는 깨뜨리는 변경이지만 상태값이 계속 늘어나는 단계라 경고에 그치게 두었다.

화면 쪽에서 값이 가장 컸던 결정은 상태 뱃지를 하나로 접은 것이다. 화면마다 상태 태그를 따로 만들어 놓아 같은 "정지"가 화면마다 다르게 보였다. 디자인 시스템의 tone 정의가 이 도메인과 그대로 맞아떨어져 네 종류를 하나로 줄였고, 표 안의 타이포도 본문과 보조 두 단으로만 정리했다 — 그전에는 한 셀 안에 세 가지 크기가 섞여 아홉 가지 조합이 있었다. 메뉴 10개는 하는 일 기준으로 네 개의 접이식 그룹으로 묶었다. 평평하게 늘어놓으면 매일 여는 화면과 분기에 한 번 여는 화면이 같은 무게로 보인다.

데이터는 3단 폴백으로 뒀다. 실 DB가 사설 서브넷 안에 있어 팀 밖에서는 화면이 뜨지 않는데, 실 DB → 스냅샷 300편 → 목 데이터 순으로 내려가게 해서 실제 데이터로 그린 화면을 잃지 않으면서 어디서나 열리게 만들었다.

도메인 모델에서 가장 중요한 결정은 상태를 두 축으로 쪼갠 것이다. 초기 기획에는 상태가 한 축이었고 거기에 노출 플래그가 붙어 있었는데, "인증했지만 아직 안 보이게 두고 싶다"와 "인증을 취소했지만 화면에서는 유지하고 싶다"를 한 축으로는 표현할 수 없었다. 인증 축과 노출 축을 분리하고 사용자 노출 조건을 두 축의 교집합 하나로 정의하자 애매하던 '보류' 상태가 통째로 사라졌다.

어드민 상태 모델 — 인증 축(대기·인증·반려·재검토)과 노출 축(준비중·노출중·숨김)이 서로를 덮어쓰지 않는다. 사용자에게 보이는 조건은 두 축의 교집합 하나뿐이고, 예외는 반려가 노출 축까지 함께 내리는 경우 하나다. 모든 판정은 감사 로그로 남는다어드민 상태 모델 — 인증 축(대기·인증·반려·재검토)과 노출 축(준비중·노출중·숨김)이 서로를 덮어쓰지 않는다. 사용자에게 보이는 조건은 두 축의 교집합 하나뿐이고, 예외는 반려가 노출 축까지 함께 내리는 경우 하나다. 모든 판정은 감사 로그로 남는다

리뷰 파이프라인 — 없는 근거를 만든다

리뷰가 7건인 상태로는 검증이 불가능하다. 네이버 관람평을 모아 채웠다.

requests로는 되지 않았다. HTTP 200에 1MB가 와도 관람평 카드는 0개다. 관람평이 서버 HTML에 없고 브라우저에서 JS로 그려지며, 초기 20건 이후는 컨테이너를 스크롤해야 순차로 로드된다. Selenium으로 실제 브라우저를 띄웠다.

수집하면서 정렬당 310건이라는 상한을 찾았다. 스크롤을 아무리 늘려도 한 정렬에서는 310건에서 멈춘다. 정렬 탭을 공감순과 최신순 사이에서 전환하면 목록이 새로 구성되고 겹침이 낮아, 두 정렬을 합쳐 중복을 제거하면 편당 최대 약 570건까지 나온다. 차단을 피하려고 요청 간격을 매번 다르게 줬고 — 스크롤 사이 1.42.6초, 영화 사이 48초 — 연속 다섯 편이 0건이면 자동으로 멈추게 했다. 총 14시간을 돌려 차단은 0회였다.

적재는 멱등하게 설계했다. 리뷰 내용 기반 해시에 UNIQUE 인덱스를 걸어 몇 번을 다시 넣어도 늘어나지 않는다. 1차로 시연용 3편 1,717건을 넣어 경로를 검증한 뒤, 2차에서 전체를 넣어 신규 58,445건이 10초 만에 들어갔다.

수집 리뷰를 그대로 회원 리뷰로 복사하면 같은 문장이 목록에 두 번 보인다. HyperCLOVA X로 문체를 유지한 채 재작성해 가상 회원 300명에게 배정했고, 여기서 프롬프트를 세 번 갈았다. 1차는 원문을 길이 5.5배로 부풀리고 제목을 문장에 끼워 넣었으며 혹평을 순화하다 뜻을 뒤집었다. 2차에서 길이·제목·왜곡은 잡혔지만 반말이 존댓말로 바뀌는 것이 열 건 중 세 건 남았다. 3차에서 원문의 어미를 코드로 판정해 지시문에 주입하자 문체 유지가 열 건 중 열 건이 됐다.

여기서 하나를 더 배웠다. 메타 정보 누출은 프롬프트로 막히지 않았다. 제목과 평점을 문장에 넣지 말라고 아무리 못 박아도 확률적으로 샌다. 결국 프롬프트가 아니라 코드 필터로 걷어내고 세 번 실패하면 폐기하는 쪽으로 바꿨다. 1차 시연분에서는 동시 호출로 요청 제한에 걸려 순차 호출로 내렸다.

작성자 ID는 저장 시점에 해시 처리해 원본이 디스크에 남지 않게 했고, 원문이 실제 사용자의 글이므로 데이터셋 전체를 내부 시연 전용으로 못 박았다.

영화 데이터가 들어오고 임베딩되는 배치 경로 — KOFIC에서 영화 원장을, KMDB에서 상세 메타를 받아 적재하고, 관리자가 인증·노출한 영화만 임베딩 큐에 들어간다. 매칭에 실패한 673편(11%)은 표시를 남겨 목록에서 경고로 드러낸다. 리뷰 수집은 이 배치 바깥에서 돌려 같은 DB에 직접 적재했다영화 데이터가 들어오고 임베딩되는 배치 경로 — KOFIC에서 영화 원장을, KMDB에서 상세 메타를 받아 적재하고, 관리자가 인증·노출한 영화만 임베딩 큐에 들어간다. 매칭에 실패한 673편(11%)은 표시를 남겨 목록에서 경고로 드러낸다. 리뷰 수집은 이 배치 바깥에서 돌려 같은 DB에 직접 적재했다

덤으로 나온 것 — 인프라 점검

어드민 백엔드를 설계하려면 서버가 실제로 어떻게 열려 있는지 알아야 했다. NCP API로 조회만 해서 실제 상태를 뜬 뒤 아키텍처 문서와 대조했더니 서버 8대·ACG 8개·규칙 25건이 나왔고, 문서와 어긋난 곳과 조치가 필요한 곳을 합쳐 열다섯 건을 정리했다.

가장 급한 것은 bastion의 22번 포트가 인터넷 전체에 열려 있던 것이었다. 특정 IP만 허용하는 규칙이 먼저 들어가 있었지만 그 아래 전체 허용 규칙이 남아 있어 앞 규칙이 무의미했다. bastion은 사설 서브넷 전체로 들어가는 유일한 입구라 영향이 컸다. 그 밖에 팀 문서에 DB 비밀번호가 평문으로 적혀 있던 것, 인터넷에서 백엔드로 들어올 입구가 정의되지 않아 배포 경로부터 정해야 했던 것, DB가 단일 구성이라 장애점이 되는 것을 우선순위 아홉 단계로 묶어 넘겼다. 조회만 하고 아무것도 바꾸지 않았다 — 담당자 판단이 필요한 영역이었다.

Result

  • 우수상 — 교육 평가에서 팀 'hope'의 팝콘톡이 우수상을 받았다
  • 어드민 콘솔 완성 — 커밋 39건으로 화면 10개, 명세 35개 엔드포인트, 정적 API 문서 두 장을 2일 반 만에 배포까지 마쳤다
  • API 계약이 사람 기억 밖에 놓였다 — 깨뜨리는 변경 14종을 CI가 잡고, 백엔드보다 화면이 먼저 나와도 붙일 수 있게 됐다
  • 리뷰 7건 → 124,939건 — 관람평 1,197편 117,514건을 수집(14시간, 차단 0회)해 118,301건으로 적재하고, 가상 회원 300명의 리뷰 6,638건을 더했다
  • 프롬프트 튜닝 3회전으로 문체 유지 10/10 — 원문 어미를 코드로 판정해 주입하는 방식으로 반말·존댓말 왜곡을 없앴다
  • 인프라 리스크 15건 문서화 — bastion SSH 전체 개방을 포함해 조치 우선순위 아홉 단계로 정리해 팀에 전달했다

Retrospective

2주는 기능을 다 만들기엔 짧고, 무엇을 안 만들지 정하기엔 충분했다. 검증이라는 가치 하나를 붙잡으니 나머지가 저절로 정렬됐다. 리뷰가 없으면 검증이 불가능하니 데이터 수집이 최우선이 되고, 검증 결과를 사람이 확인해야 하니 어드민이 필요해지고, 벡터 검색을 쓰기로 했으니 배치가 임베딩을 돌린다. 기능 목록을 먼저 쓰지 않고 가치를 먼저 정한 것이 짧은 기간에서 가장 잘한 판단이었다.

계약은 문서가 아니라 실패하는 검사로 지켜진다. 명세를 잘 써두는 것만으로는 아무것도 막지 못한다. 깨질 때 PR이 실패하고 무엇이 깨졌는지 댓글이 달려야 비로소 그 합의가 사람의 기억 밖에 놓인다. 도구가 없어서 직접 만들어야 했지만, 만들고 나니 5명이 같은 API를 두고도 어긋나지 않았다.

LLM에게 하지 말라고 하는 것과 못 하게 만드는 것은 다르다. 메타 정보 누출은 프롬프트를 아무리 단단히 써도 확률적으로 샜고, 코드 필터를 붙이고 나서야 멈췄다. 반대로 문체 유지는 프롬프트로 풀렸는데, 그것도 "문체를 유지하라"가 아니라 원문의 어미를 코드로 판정해 지시문에 넣어준 뒤에야 됐다. 모델에게 판단을 맡길 자리와 코드가 미리 정해줄 자리를 나누는 것이 프롬프트 엔지니어링의 실질이었다.

아쉬운 것은 문서가 코드보다 빨리 늙는 것을 막지 못한 구간이다. 초기 기획의 상태 모델은 상태 한 축에 노출 플래그를 붙인 형태였는데, 실제 구현은 인증과 서비스 두 축으로 갔다. 뒤늦게 다이어그램을 다시 그려야 했다. 명세는 CI가 지켜줬지만 다이어그램을 지켜주는 것은 없었다 — 다음에는 그림도 생성물로 만들어 소스에서 뽑아내야 한다.

Links