Big Project · AI's EYE 04
개별 기능 완성을 넘어선 통합적 MVP 판단
각 담당자가 자신의 기능을 실행할 수 있다면 서비스도 거의 완성된 것처럼 보인다. 왜 통합 이후에 더 많은 문제가 나타났는지 이해하기 어렵다.
근거 · 프로젝트 문서 · 코드 · 테스트
1장: 개별 기능 완성의 착시: 통합이 단순 조립이 아닌 이유
솔라는 모니터 구석에 띄워 둔 프로젝트 요약 문서의 한 문장에 시선이 멈췄다.
“Vision·Dashboard·AICC의 개별 완료를 하나의 end-to-end MVP로 바꾼 통합 판단”
문장 자체는 명료했다. 카메라로 매장 상황을 분석하는 Vision, 그 결과를 보여주는 점주용 Dashboard, 그리고 데이터를 기반으로 본사 차원의 분석을 제공하는 AICC. 각 팀이 맡은 기능을 성공적으로 개발 완료했고, 이제 이걸 하나로 합쳐서 최소 기능 제품(MVP)으로 완성했다는 뜻이다. 솔라는 고개를 끄덕였다.
‘각자 맡은 퍼즐 조각을 다 만들었으니, 이제 맞추기만 하면 그림이 완성되는 거네. 드디어 끝이 보이는구나.’
안도감이 밀려왔다. 복잡하게 얽혀 있던 프로젝트가 드디어 깔끔하게 정리되는 느낌이었다. 솔라는 만족스러운 미소를 지으며 자리에서 일어났다. 거실로 나오자 언니 루나가 막 도착한 택배 상자 세 개를 테이블 위에 올려두고 있었다.
“언니, 이게 다 뭐야?” “아, 솔라. 마침 잘 됐다. 이것 좀 같이 볼래? 내가 요즘 관심 있는 조립식 로봇 키트인데, 세 부분으로 나눠서 팔더라고. 머리, 몸통, 다리. 이것들만 합치면 근사한 로봇이 완성된대.”
루나가 가리킨 상자들은 각기 다른 디자인의 포장지로 싸여 있었다. 하나는 ‘첨단 비전 시스템 탑재 헤드 유닛’이라는 문구가, 다른 하나는 ‘중앙 제어 및 디스플레이 코어 바디’가, 마지막 상자에는 ‘지능형 기동 레그 파츠’라고 적혀 있었다. 마치 솔라가 방금 본 프로젝트의 세 가지 기능을 그대로 옮겨 놓은 것 같았다.
“재밌겠는데? 각자 상자 안에 부품이 다 들어있는 거지? 그럼 조립은 금방 하겠네.”
솔라는 자신만만하게 가장 화려한 ‘헤드 유닛’ 상자를 집어 들었다. 테이프를 뜯고 상자를 열자, 정교하게 만들어진 로봇 머리가 완충재에 싸여 있었다. 카메라 렌즈처럼 생긴 눈과 복잡한 센서들이 꽤 그럴싸해 보였다. 상자 안에는 이 머리 부품을 완성하는 데 필요한 작은 나사들과 설명서가 동봉되어 있었다. 설명서대로 몇 분간 뚝딱거리자, 흠잡을 데 없이 완벽한 로봇 머리 하나가 완성되었다.
“자, 머리는 완성! 다음은 몸통.”
솔라는 의기양양하게 두 번째 상자를 열었다. 몸통 부품 역시 마찬가지였다. 액정 화면이 달린 단단한 프레임은 그 자체로 완결된 부품처럼 보였다. 솔라는 설명서를 보며 작은 부품 몇 개를 조립해 완벽한 몸통을 만들었다. 이제 남은 건 이 둘을 합치는 일이었다.
솔라는 완성된 머리를 집어 몸통 위에 올렸다. 그리고는 이내 고개를 갸웃했다. 머리 부품 아래에는 네모난 모양의 연결 단자가 튀어나와 있었다. 그런데 몸통 부품의 연결 홈은 동그란 모양이었다. 아무리 방향을 바꿔보고 힘을 줘 봐도, 두 부품은 아귀가 맞지 않아 공허하게 덜그럭거릴 뿐이었다.
“어? 이게 왜 안 맞지?”
황당한 마음에 마지막 다리 부품 상자까지 열어보았다. 역시나 그 자체로는 완벽하게 조립되는 다리 한 쌍이 들어 있었다. 하지만 몸통과 연결되는 부분은 볼트와 너트로 조여야 하는 방식이었고, 몸통 부품에는 나사 구멍조차 없었다.
솔라는 테이블 위에 놓인 완벽하지만 서로 연결되지 않는 세 개의 덩어리를 망연히 바라보았다. 각 상자 안에서는 분명 ‘완성품’이었는데, 한데 모아놓으니 그저 고철 덩어리나 다름없었다.
“이게 뭐야… 다들 자기 상자 안에서는 완벽한데, 정작 서로 어떻게 연결될지는 아무도 신경 안 썼잖아. 이거 불량품 아니야?”
솔라의 불퉁한 말투에 루나가 조용히 다가와 세 개의 로봇 부품을 나란히 놓았다.
“불량품은 아닐 거야. 각 부품을 만든 사람들은 자기 상자 안에서 최고의 결과물을 만드는 데만 집중했겠지. 머리를 만든 사람은 가장 멋진 머리를, 몸통을 만든 사람은 가장 튼튼한 몸통을.”
루나는 네모난 연결 단자와 동그란 홈을 손가락으로 가리켰다.
“이 머리를 만든 사람과 몸통을 만든 사람이, 부품을 만들기 전에 서로 만나 ‘우리 연결 부분은 네모난 형태로 만들자’고 약속했다면 어땠을까?”
그 말에 솔라는 순간 숨을 멈췄다. 방금 전 모니터에서 봤던 그 문장이 뇌리를 스쳤다. Vision, Dashboard, AICC. 각자 자기 기능에만 집중해서 완벽한 결과물을 만들었다고 자부했을 개발팀들의 얼굴이 떠올랐다.
“아…”
낮은 탄식이 흘러나왔다.
“Vision 팀은 최고의 이미지 분석 결과를 내놓았을 테고, Dashboard 팀은 가장 보기 좋은 화면을 만들었겠지. 하지만 Vision 팀이 분석 결과를 ‘JSON 파일’로 준다고 했는데, Dashboard 팀은 ‘XML 파일’을 기다리고 있었다면… 이 로봇 부품들처럼 되는 거구나.”
솔라는 그제야 깨달았다. 개별 기능의 ‘완성’이라는 말이 얼마나 큰 착각을 불러일으키는지. 각자 자기 영역 안에서 완벽하다는 사실이, 오히려 시스템 전체가 조화롭게 작동할 것이라는 환상을 만들었다. 진짜 문제는 기능 자체가 아니라, 기능과 기능이 만나는 그 경계선에 숨어 있었다.
“각자 완성했다는 사실이, 서로 어떻게 연결될지에 대한 ‘합의’가 없었다는 걸 교묘하게 가리고 있었던 거네. 연결 부분에 대한 약속, 즉 ‘합의되지 않은 인터페이스’가 문제였던 거야.”
솔라는 덜그럭거리는 로봇 부품을 만지작거리며 말했다. 눈앞의 로봇 키트는 더 이상 단순한 장난감이 아니었다. ‘통합이 단순한 조립이 아니다’라는 사실을 보여주는 통렬한 증거물이었다.
문득 새로운 의문이 고개를 들었다.
“이상하네. 그렇게 중요한 거라면, 처음부터 다 같이 모여서 그 ‘약속’부터 정하고 시작했어야 하는 거 아니야? 왜 개발이 다 끝난 것처럼 보이는 이제 와서야 이런 문제를 마주하게 된 거지? 그 ‘약속’이라는 게 대체 뭐길래?”
2장: 침묵의 계약: StoreState와 OrderEvent가 공통 언어인 이유
솔라는 테이블 위에 흩어져 있는 로봇 부품들을 한데 모으고, 그 옆에 깨끗한 노트 한 권을 펼쳐 놓았다. 어젯밤, 각자 완벽하지만 서로 연결되지 않는 부품들을 보며 느꼈던 황당함은 어느새 진지한 고민으로 바뀌어 있었다. 그녀는 펜을 들고 노트 위에 ‘연결부 표준 규격서’라는 제목을 적었다. 그리고는 머리 부품과 몸통 부품을 연결하기 위한 가상의 설계도를 그리기 시작했다. 연결 단자의 모양은 사각형으로 통일하고, 핀의 개수와 각 핀의 역할을 정의했다. ‘1번 핀: 전원 공급, 2번 핀: 데이터 송신…’
그 모습에, 거실로 나온 루나가 조용히 커피잔을 내려놓았다. 솔라는 인기척을 느끼고 고개를 들었다.
“언니, 봐봐. 내가 이 로봇들을 위한 ‘약속’을 만들고 있었어. 이렇게 처음부터 연결부 모양이랑 각 핀이 무슨 역할을 할지 정해뒀으면, 어제 같은 일은 없었을 거 아냐. 근데 왜 프로젝트에서는 이런 걸 처음부터 안 정했을까? 이게 바로 그 ‘계약’ 아니야?”
솔라의 질문에는 순수한 궁금증과 함께 약간의 책망이 섞여 있었다. 이렇게 명백히 중요한 것을 놓쳤을 리가 없다는 듯한 표정이었다.
루나는 솔라가 그려놓은 설계도를 잠시 들여다보더니, 고개를 저었다.
“그 ‘약속’을 아예 안 한 건 아닐 거야. 아마 말로는 했을지도 몰라. ‘Vision 팀, 분석 끝난 매장 데이터를 API 서버로 보내주세요.’ ‘네, 알겠습니다. 분석 결과 보내드릴게요.’ 처럼 말이지.”
루나는 솔라의 노트북을 가리켰다.
“직접 한번 확인해 볼까? Vision 팀이 보낸 ‘분석 결과’와, 그 결과를 받아서 본사용 리포트를 만들어야 하는 AICC 팀이 기대한 ‘분석 결과’가 어떻게 달랐는지.”
루나는 노트북 화면에 두 개의 간단한 텍스트 상자를 나란히 띄웠다. 왼쪽에는 ‘Vision 결과물 (예시)’ 라고 적혀 있었고, 오른쪽에는 ‘AICC 요구사항 (예시)’ 라고 적혀 있었다.
Vision 결과물 (예시)
{
"status": "busy",
"customer_count": 15,
"timestamp": "2023-10-27 10:00:00"
}
AICC 요구사항 (예시)
{
"state": "BUSY",
"people": 15,
"eventTime": 1698368400
}
솔라는 두 상자를 번갈아 보며 미간을 찌푸렸다.
“어… 숫자는 같은데…?”
“맞아, 핵심 정보인 ‘15명’은 같지. 하지만 이걸 기계가 처리한다고 생각해 봐. AICC 시스템은 state 라는 이름의 데이터를 찾을 텐데, Vision이 보낸 데이터에는 status 라는 이름만 있어. 그럼 AICC는 자기가 필요한 정보를 찾지 못하고 오류를 내겠지.”
루나의 말대로였다. status와 state, customer_count와 people. 의미는 통하지만 이름이 달랐다. 더 심각한 것도 있었다. status 값은 소문자 “busy”인데, state 값은 대문자 “BUSY”를 기대하고 있었다. 시간 정보는 더했다. 하나는 사람이 읽기 좋은 문자열 형태("2023-10-27 10:00:00")였고, 다른 하나는 컴퓨터가 계산하기 좋은 숫자 형태(Unix 타임스탬프)였다.
솔라는 키보드에 손을 올리고 가상으로 두 시스템을 연결하는 상상을 했다. Vision의 결과물을 복사해서 AICC의 입력으로 붙여넣는 순간, 화면 가득 붉은 에러 메시지가 쏟아지는 모습이 눈앞에 선했다. ‘KeyError: ‘state’ not found.’, ‘ValueError: ‘busy’ is not a valid state.’
“이건… 번역이 필요한 수준이잖아. 각자 자기 편한 언어로 말하고 있었던 거네. 둘 다 ‘매장 상태 데이터’라는 같은 주제로 이야기하고 있다고 믿으면서.”
바로 그 순간, 솔라는 ‘계약’의 진짜 의미를 깨달았다. 그것은 ‘무엇을’ 주고받을지에 대한 모호한 합의가 아니었다. 그것은 데이터를 주고받는 모든 구성 요소가 함께 사용하는 ‘공통 언어’의 문법 그 자체였다.
루나는 고개를 끄덕이며 화면에 새로운 내용을 띄웠다. StoreState와 OrderEvent라는 이름의 정의서였다.
공통 계약: StoreState
state: Enum (“IDLE”, “BUSY”, “CROWDED”)customerCount: IntegereventTime: String (ISO 8601 Format)
“그래서 이 ‘공통 언어’를 만든 거야. 프로젝트에 참여하는 모든 컴포넌트는 이제부터 매장의 상태를 표현할 때, status나 people 같은 자기만의 단어를 쓰지 않아. 오직 StoreState라는 공통 계약서에 정의된 state, customerCount, eventTime 이라는 단어와 형식을 사용하기로 약속한 거지.”
솔라의 눈이 동그래졌다. “아…! StoreState와 OrderEvent는 그냥 이름이 아니라, 모두가 함께 보는 일종의 사전이었구나. 이 사전을 따르면 Vision도, AICC도, 심지어 점주가 보는 대시보드까지 헷갈릴 일이 없는 거고.”
기능 통합은 단순히 코드의 파이프라인을 연결하는 행위가 아니었다. 그것은 개발 시작 전에, 아니, 개발하는 내내 모든 구성원이 동일한 사전을 참조하며 자신의 결과물이 약속된 언어, 즉 ‘계약’을 정확히 구사하고 있는지 확인하는 과정이었다. 개별 기능이 아무리 훌륭해도, 이 공통 언어를 따르지 않으면 그저 알아들을 수 없는 소음에 불과했다.
솔라는 다시 한번 ‘합의되지 않은 인터페이스’가 문제였던 로봇 부품들을 떠올렸다. 이제는 그 문제가 훨씬 선명하게 보였다. 네모난 단자, 동그란 홈 같은 겉모습의 문제가 아니었다. 각 핀에 어떤 전압의 전기를, 어떤 신호 규격으로 보낼지에 대한 ‘침묵의 계약’이 없었던 것이 본질이었다.
“좋아. 이제 알겠어. 모두가 StoreState라는 공통 언어(사전)를 쓰기로 합의했으니, 이제 정말 문제가 없겠네! 그냥 이 사전대로 만들기만 하면 되는 거잖아?”
솔라가 자신감에 차서 말했다. 그러나 루나는 예상과 달리 가볍게 미소만 지을 뿐이었다.
“정말 그럴까? 만약 두 사람이 똑같은 사전을 가지고 완벽한 문법의 편지를 썼다고 해도, 한 명은 편지를 밀봉해서 방수 가방에 넣고, 다른 한 명은 편지를 그냥 빗속에 던져버린다면 어떨까?”
순간 솔라의 표정이 굳었다. 같은 언어, 같은 사전. 이제 문제는 해결된 줄 알았는데, 또 다른 복병이 숨어있는 듯한 예감에 휩싸였다.
3장: 환경 통합의 힘: Docker Compose와 PostgreSQL로 통일된 시험대
솔라는 어젯밤 루나가 남긴 ‘빗속의 편지’ 비유를 곱씹으며 자신의 노트북 앞에 앉았다. ‘공용 언어’라는 완벽한 사전을 손에 넣었지만, 편지를 전달하는 방식이 제각각이라면 소용없다는 말. 찜찜한 기분을 떨치기 위해, 솔라는 직접 행동에 나서기로 했다. 어제 배운 StoreState 계약에 맞춰, 들어온 JSON 데이터가 유효한지 검사하는 작은 파이썬 스크립트를 짜기 시작했다.
몇 분 지나지 않아 완벽하게 작동하는 스크립트가 완성됐다. state에 “IDLE”이 아닌 “idle”이 들어오면 에러를 뱉고, customerCount가 숫자가 아니면 경고를 띄웠다. 자신의 노트북 환경에서는 흠잡을 데 없이 돌아갔다.
“이거 봐, 언니! StoreState 계약을 지키지 않은 엉터리 데이터는 전부 걸러내는 수문장 스크립트야.”
솔라는 뿌듯한 얼굴로 스크립트가 담긴 USB를 들고 루나의 자리로 갔다. 루나는 자신의 노트북에 USB를 꽂고, 솔라가 작성한 코드를 실행했다. 잠시 후, 화면에 나타난 것은 성공 메시지가 아닌 길고 붉은 에러 메시지였다.
ModuleNotFoundError: No module named 'fastjsonschema'
“어? 왜 안 되지? 내 컴퓨터에선 잘 됐는데… fastjsonschema 라이브러리 설치 안 했어?”
솔라의 목소리에 당혹감이 서렸다. 루나는 차분하게 답했다.
“응, 나는 보통 jsonschema를 쓰거든. 그리고 네 스크립트는 PostgreSQL 14 버전에 최적화된 라이브러리를 사용했는데, 내 노트북에는 12 버전이 깔려있네. 아마 데이터베이스 연결 부분에서도 오류가 날 거야.”
루나는 붉은 에러 메시지를 손가락으로 가리켰다. “똑같은 StoreState라는 ‘언어’로 쓴 편지(데이터)를 읽으려고 해도, 솔라 너는 최신 만년필과 두꺼운 종이로 확인하는데, 나는 낡은 연필과 얇은 종이를 쓰는 것과 같아. 읽는 도구와 환경이 다르니, 같은 내용도 제대로 읽을 수 없게 되는 거지.”
솔라는 그제야 깨달았다. 개발자 각자의 노트북은 그 자체로 미묘하게 다른 세계였다. 사용하는 운영체제, 설치된 프로그램의 버전, 네트워크 설정, 심지어는 데이터베이스 이름 같은 사소한 것까지 모두 달랐다. 각자의 세계에서 ‘완벽하게’ 작동한다는 사실은, 다른 세계에서도 그러리라는 것을 전혀 보장해주지 못했다. ‘공용 언어’라는 계약은 있었지만, 그 언어가 사용될 ‘무대’가 제각각이었던 것이다.
“그럼… 모든 개발자가 자기 컴퓨터를 똑같이 맞추기라도 해야 해? 매번 ‘이 라이브-러리 설치했어?’, ‘파이썬 버전은 뭐야?’ 물어보면서?”
솔라가 막막하다는 듯이 말했다. 그때 루나가 자신의 노트북 화면을 돌려 보여주었다. 화면에는 docker-compose.yml이라는 이름의 텍스트 파일이 열려 있었다.
version: '3.8'
services:
postgres-db:
image: postgres:14-alpine
restart: always
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=store_db
ports:
- '5432:5432'
vision-api:
build: ./vision_service
ports:
- '8001:8001'
depends_on:
- postgres-db
aicc-service:
build: ./aicc_service
ports:
- '8002:8002'
depends_on:
- postgres-db
“모든 개발자가 자기 컴퓨터를 바꿀 필요는 없어. 대신, 우리 프로젝트만을 위한 ‘휴대용 가상 컴퓨터 세트’를 만드는 거야. 이 파일이 바로 그 설계도고.”
루나는 postgres-db 부분을 가리켰다.
“여기 봐. image: postgres:14-alpine. 이게 무슨 뜻이냐면, ‘이 프로젝트에 참여하는 사람은 누구든, 자기 컴퓨터에 뭐가 깔려있든 상관없이, 무조건 PostgreSQL 14 버전이 설치된 미니 컴퓨터를 사용한다’고 약속하는 거야. 데이터베이스 이름도 store_db로 고정되지.”
솔라의 눈이 커졌다. Vision 서비스, AICC 서비스도 마찬가지였다. 각자의 코드를 실행하는 데 필요한 모든 환경 설정이 이 파일 하나에 전부 정의되어 있었다. 개발자는 자기 컴퓨터에서 docker-compose up이라는 명령어 하나만 실행하면, 이 설계도에 따라 Vision, AICC, 그리고 공용 데이터베이스가 모두 약속된 상태로 자기 컴퓨터 안에 나타나는 것이었다.
“내 컴퓨터에서만 잘 되는 게 의미가 없었네. 모두의 컴퓨터에서 ‘동일한 실패’와 ‘동일한 성공’을 경험하게 해주는 게 더 중요했던 거야.”
솔라는 docker-compose.yml 파일을 바라보았다. 이것은 단순한 설정 파일이 아니었다. 제각각이던 무대를 하나로 통일시키는 강력한 도구였다. ‘공용 언어’라는 계약이 허공에 떠다니지 않도록 붙잡아주는 단단한 땅, 즉 ‘단일 통합 시험대’ 그 자체였다. StoreState 계약이 ‘무엇을’에 대한 약속이었다면, 이 통합 시험대는 ‘어떻게’와 ‘어디서’에 대한 약속이었다.
“알겠다. 이제 정말 모든 게 해결된 것 같아! 모두가 StoreState라는 같은 사전을 쓰고, docker-compose라는 동일한 시험장에서 대화하잖아. 계약도, 환경도 통일했으니 이젠 정말 전체 흐름이 매끄럽게 작동하겠지?”
솔라는 마침내 모든 퍼즐 조각을 맞췄다는 확신에 찬 목소리로 말했다. 그러나 솔라의 자신감 넘치는 표정을 보면서도, 루나는 대답 대신 알 수 없는 미소만 지을 뿐이었다. 마치 잘 닦인 시험대 위에서도 예상치 못한 일이 벌어질 수 있다는 것을 이미 알고 있다는 듯이.
4장: end-to-end 검증만이 드러내는 진실: 숨겨진 상호의존성
모든 것이 완벽해 보였다. 솔라는 자신의 노트북 화면에 가득 찬 터미널 창을 흐뭇하게 바라보았다. docker-compose up 명령어를 입력한 후, 약속된 컨테이너들이 차례로 기동하는 로그가 녹색과 파란색으로 깔끔하게 올라왔다. postgres-db, vision-api, aicc-service. 지난 며칠간 골머리를 앓게 했던 모든 문제가 해결된 듯했다. ‘공용 언어’인 StoreState 계약이 있었고, 모두가 함께 오를 ‘단일 통합 시험대’인 Docker Compose 환경도 마련되었다.
솔라는 거실에 있는 루나를 향해 의기양양하게 외쳤다. “언니, 드디어 전체 시스템이 에러 없이 전부 떴어! 계약도, 환경도 통일했더니 이렇게 평화롭네. 이제 정말 끝난 거 아닐까?”
솔라의 말에는 틀린 곳이 없었다. 각 서비스는 자신의 역할을 수행할 준비가 되었다는 로그를 뿜어내고 있었다. 각자의 방에서 완벽한 상태로 대기 중인 셈이었다. 하지만 루나는 솔라의 자신감 넘치는 표정을 보면서도, 대답 대신 노트북을 들고 솔라의 옆으로 다가와 앉았다.
“정말 그런지 한번 확인해 볼까? 진짜 사용자처럼 말이야. Vision API에 매장이 붐비는 상황의 이미지 데이터를 한번 넣어보자.”
루나의 말에 솔라는 간단한 테스트 스크립트를 실행했다. 가상의 카메라가 촬영한 것처럼, 15명의 고객이 있는 매장 이미지 정보를 Vision API로 전송했다. 터미널 창에 새로운 로그가 찍혔다.
[vision-api] INFO: Image processed. Customer count: 15. State: BUSY. Sending data to main API...
[main-api] INFO: StoreState received from Vision. Saving to store_db... OK.
솔라는 재빨리 데이터베이스 관리 도구를 열어 store_db의 store_events 테이블을 확인했다. 방금 들어온 데이터가 정확히 기록되어 있었다.
| id | state | customerCount | eventTime |
|---|---|---|---|
| 1 | BUSY | 15 | 2023-10-27T10:00:00.123Z |
“봐! 완벽해. Vision이 분석한 데이터가 API를 거쳐 데이터베이스에 정확히 저장됐어. 이제 점주 화면에도 ‘현재 매장 상태: 붐빔’이라고 뜨겠지.” 솔라의 목소리에는 자부심이 가득했다. 단위 테스트, 통합 테스트에서 확인했던 모든 것이 실제 환경에서도 문제없이 작동하고 있었다.
루나는 고개를 끄덕이며 다음 단계로 넘어갔다. “점주에게 현재 상태를 보여주는 건 성공했네. 그럼 이제 본사 관리자가 볼 차례 아냐? AICC 서비스가 이 데이터를 분석해서 리포트를 만들어야지.”
솔라는 AICC 서비스의 로그를 확인했다. AICC는 1분마다 데이터베이스에서 새로운 StoreState 이벤트를 가져와 분석 리포트를 생성하도록 설정되어 있었다. 잠시 후, AICC 서비스의 새 로그가 나타났다.
[aicc-service] INFO: Checking for new events since last run... Found 0 new events.
순간 솔라의 표정이 굳었다. “어? ‘새로운 이벤트가 0개’라고? 왜?”
솔라는 다시 데이터베이스 테이블을 확인했다. BUSY 상태를 알리는 데이터는 분명히 존재했다. AICC 서비스는 store_db에 접근할 권한도 있었고, StoreState라는 공용 언어도 알고 있었다. 각자 방 안에서는 모두 완벽했는데, 왜 AICC는 바로 옆방에 있는 데이터를 보지 못하는 걸까?
“말도 안 돼. 데이터가 저기 있는데 왜 못 찾는다는 거야. AICC 서비스가 store_db가 아니라 다른 데이터베이스를 보고 있는 거 아니야?”
솔라가 혼란에 빠져 중얼거렸다. 루나는 docker-compose.yml 파일을 가리켰다. 모든 서비스는 postgres-db라는 단 하나의 데이터베이스를 바라보도록 명시되어 있었다. 환경 문제는 아니었다.
루나가 조용히 질문을 던졌다. “AICC 서비스가 데이터베이스에서 데이터를 가져올 때, 어떤 조건으로 가져오는지 코드를 한번 볼까?”
솔라는 AICC 서비스의 코드 일부를 찾아 열어보았다. 데이터를 조회하는 SQL 쿼리문이 있었다.
SELECT * FROM store_events WHERE eventTime > :last_checked_time
“마지막으로 확인한 시간 이후의 데이터를 가져오는 평범한 코드인데… 아!”
솔라는 무언가 깨달은 듯 두 서비스의 로그를 나란히 띄웠다.
[main-api] ... Saving to store_db ... "eventTime": "2023-10-27T10:00:00.123Z"
[aicc-service] ... Checking for new events since ... '2023-10-27T19:00:00.000+09:00'
시간은 10:00와 19:00로 달랐지만, 각각 UTC(세계 표준시)와 KST(한국 표준시)였기에 같은 시점이었다. 문제는 다른 곳에 있었다. 두 시스템의 시간 정밀도에 대한 암묵적인 가정이 달랐던 것이다. API는 밀리초(.123Z)까지 정밀하게 시간을 기록했지만, AICC는 시간대를 한국 기준으로 변환하면서 밀리초를 버리고 정각(:00.000)부터 조회하고 있었다. 데이터가 기록된 시간과 조회를 시작하는 시간이 너무 아슬아슬하게 맞물려, 데이터가 누락된 것이다.
아무도 의도하지 않은 문제였다. API 개발자는 더 정확한 정보를 남기기 위해 밀리초를 기록했고, AICC 개발자는 일반적인 분석 주기인 ‘정시’를 기준으로 쿼리했을 뿐이다. 각자의 단위 테스트에서는 절대 발견할 수 없는 종류의 오류였다. 오직 카메라에서 시작된 하나의 데이터가 본사 리포트까지 가는 기나긴 여정, 즉 전체 흐름을 끝에서 끝까지(end-to-end) 추적했을 때만 비로소 드러나는 문제였다.
솔라는 허탈한 웃음을 터뜨렸다. “이건… 배신이네. 모두가 같은 계약서를 쓰고 같은 시험대에 올랐다고 생각했는데, 서로 다른 시계를 차고 있었던 거잖아. 각자 자기 부분만 보면 완벽했지만, 두 기능 사이에 아무도 책임지지 않는 ‘시간의 틈’이 있었던 거야.”
솔라는 깨달았다. 시스템은 단순히 기능의 합이 아니었다. 각 기능들 사이에는 눈에 보이지 않는 연결고리, 즉 ‘숨겨진 상호의존성’이 거미줄처럼 얽혀 있었다. 시간대, 주소 형식, 패키지 이름 같은 사소한 것들이 전체 시스템의 발목을 잡을 수 있었다. 그리고 이 거미줄은 개별 테스트라는 밝은 조명 아래에서는 보이지 않다가, 전체 흐름이라는 어둠 속에서만 모습을 드러냈다.
솔라는 방금 발견한 시간 문제를 노트에 기록했다. 이 문제를 해결하는 것은 간단할지 모른다. 하지만 이 문제가 던지는 질문은 결코 가볍지 않았다.
“이런 ‘숨겨진 상호의존성’이 또 얼마나 있을까? MVP를 완성했다는 건, 이런 숨겨진 문제들까지 모두 찾아서 해결했다는 뜻일까? 대체 언제 ‘완성’이라고 말할 수 있는 거지?”
5장: MVP 완료의 재정의: 사용자 시나리오의 완결성으로 판단
솔라는 자신의 방 책상 앞에 앉아, 깨끗한 A4 용지를 가만히 내려다보고 있었다. 어젯밤, 눈에 보이지 않는 ‘시간의 틈’이 시스템 전체를 마비시킬 뻔한 것을 목격한 이후, ‘완성’이라는 단어의 의미가 머릿속에서 뒤죽박죽이 되어버렸다. 언제 끝났다고 말할 수 있는 걸까? 이 질문이 끈질기게 그녀를 따라다녔다.
답을 찾기 위해, 솔라는 펜을 들어 프로젝트의 기능들을 하나씩 적기 시작했다. 그녀 나름의 ‘MVP 완료 체크리스트’였다.
- [✓] Vision: 이미지 분석 기능 작동함.
- [✓] API: 데이터 수신 및 저장 기능 작동함.
- [✓] PostgreSQL: 데이터베이스 테이블 생성 및 조회 가능함.
- [✓] Dashboard: 데이터 표시 기능 작동함.
- [✓] AICC: 분석 서비스 실행됨.
리스트는 금세 채워졌다. 모든 항목 옆에는 자신 있게 체크 표시까지 그었다. 각 기능은 분명히 ‘작동’하고 있었다. 하지만 솔라는 만족스럽지 않았다. 이 체크리스트는 어제 발견했던 ‘시간의 틈’ 문제를 전혀 설명해주지 못했다. 개별 기능이 모두 완벽하게 체크되어 있어도, 시스템은 여전히 실패할 수 있다는 사실을 이제는 알았다. 이 리스트는 허상에 불과했다. 솔라는 펜을 내려놓고 의자에 깊숙이 몸을 묻었다.
그때, 조용히 다가온 루나가 솔라가 만든 체크리스트를 잠시 들여다보더니, 말없이 새 종이 두 장을 그 옆에 나란히 놓았다. 그리고 각 종이의 맨 위에 질문을 하나씩 적었다.
첫 번째 종이: “점주는 현재 매장 상태를 즉시 파악할 수 있는가?” 두 번째 종이: “본사는 지난 일주일간의 데이터를 바탕으로 매장 운영에 대한 분석을 할 수 있는가?”
솔라는 루나가 적은 두 개의 질문과 자신의 체크리스트를 번갈아 보았다. 자신의 리스트는 ‘무엇을 만들었는가’에 대한 개발자의 관점이었다. 하지만 루나의 질문은 달랐다. 그것은 ‘사용자가 무엇을 얻을 수 있는가’에 대한 관점이었다.
“MVP가 ‘최소 기능 제품(Minimum Viable Product)’의 약자라고들 하지만, 사람들은 종종 ‘기능(Product)’에만 집중하지. 진짜 중요한 건 ‘가치(Viable)’인데 말이야.”
루나는 솔라의 체크리스트를 손가락으로 가볍게 밀어냈다. “이제 이 기능 목록은 잠시 잊어버리자. 대신 이 두 개의 질문에 ‘예’라고 답할 수 있는지, 처음부터 끝까지 직접 확인해 보는 거야. 사용자의 여정이 시작되고 끝나는 그 완결된 흐름을 따라서.”
솔라는 루나의 말에 고개를 끄덕였다. 첫 번째 질문, ‘점주’의 시나리오부터 시작하기로 했다.
솔라는 가상의 카메라가 ‘붐비는’ 매장 이미지를 포착하는 상황을 시뮬레이션했다. Vision이 이미지를 분석해 ‘BUSY’ 상태와 고객 수를 API로 보냈다. API는 그 데이터를 받아 PostgreSQL에 저장했다. 여기까지는 어제 확인했던 부분이었다. 마지막 관문은 점주가 보는 Dashboard였다. 솔라는 Dashboard가 DB의 최신 상태를 가져와 화면에 ‘현재 매장 상태: 붐빔’이라고 붉은색으로 표시하는 것을 확인했다. 데이터가 생성되어 사용자에게 의미 있는 정보로 전달되기까지, 막힘이 없었다.
“좋아. 첫 번째 시나리오는 통과네.”
이제 두 번째 질문, ‘본사’의 시나리오 차례였다. 이 여정은 훨씬 더 길었다. 카메라에서 시작해 Vision, API, PostgreSQL을 거쳐, 일주일치 데이터를 집계하고, 그 결과를 Gemini 같은 분석 엔진에 넘겨 통찰을 얻은 뒤, 최종적으로 본사 화면에 보고서 형태로 보여줘야 했다. 어제 문제가 발생했던 바로 그 구간이 포함된 흐름이었다.
솔라는 마음을 가다듬고 데이터의 흐름을 하나하나 추적했다. 어제 발견했던 ‘시간의 틈’ 문제는 이미 수정되어 있었다. API와 AICC 서비스는 이제 시간 정보를 처리할 때 같은 표준(ISO 8601)과 정밀도를 사용하도록 계약이 보강되었다. 데이터는 누락 없이 AICC로 흘러 들어갔고, 서비스는 지난 일주일간의 데이터를 집계하여 ‘오후 2-4시 사이에 방문객이 급증하는 패턴’이라는 의미 있는 분석 결과를 내놓았다. 그리고 그 결과가 본사 화면에 깔끔한 그래프와 함께 나타났다.
Vision → API → PostgreSQL → 점주 화면 → 기간 집계 → Gemini → 본사 화면
이 기나긴 화살표의 모든 연결고리가 끊김 없이 이어져, 마침내 본사 관리자에게 ‘가치 있는 정보’를 전달하는 것을 두 눈으로 확인한 순간, 솔라는 숨을 내쉬었다.
“알겠다… MVP가 완성되었다는 건, 기능 목록의 모든 칸에 체크 표시를 하는 게 아니었어. 우리가 사용자에게 전달하기로 약속한 핵심적인 가치가, 이 전체 여정을 무사히 완주하는 상태. 그걸 확인하는 거였구나.”
솔라는 그제야 자신이 처음 봤던 프로젝트 문서의 한 문장을 완전히 이해하게 되었다. ‘Vision·Dashboard·AICC의 개별 완료를 하나의 end-to-end MVP로 바꾼 통합 판단’. 그것은 단순히 세 개의 부품을 합쳤다는 뜻이 아니었다. 점주와 본사 관리자라는 두 명의 핵심 사용자가 겪는 시나리오가, 처음부터 끝까지 완벽하게 작동하여 의미 있는 가치를 만들어내는 것을 확인한 후에야 내릴 수 있는, 가장 중요한 ‘판단’의 기록이었던 것이다.
솔라는 아까 만들었던 자신의 체크리스트를 집어 들었다. 그리고는 기능 목록을 가로지르는 긴 줄을 그었다. 그 아래, 그녀는 새로운 제목을 적었다.
“MVP 검증 결과 보고서”
그리고는 루나가 처음 적어주었던 두 개의 질문을 보고서의 첫 항목으로 옮겨 적기 시작했다.
- 사용자 시나리오 1: 점주의 실시간 매장 상태 확인
- 흐름: Vision → API → DB → Dashboard
- 결과: PASS. 사용자가 가치를 얻는 데까지의 전체 흐름이 끊김 없이 성공적으로 작동함.
- 사용자 시나리오 2: 본사의 주간 데이터 기반 분석
- 흐름: DB → 기간 집계 → AICC/Gemini → 본사 Dashboard
- 결과: PASS. 숨겨진 상호의존성(시간 처리 문제) 해결 후, 전체 흐름이 성공적으로 작동함.
이것이 바로, ‘언제 완성이라고 말할 수 있는가?’에 대한 솔라 스스로 내린 답이었다. MVP의 완성은 개별 기능의 작동 여부가 아니라, 사용자 시나리오의 완결성으로 판단하는 것이었다.