커피 시음 후기 기록용 AI 에이전트, 모카 개발기 (3) — 처음 만들어보는 제품의 아키텍처 설계하기
제일 작은 사이즈의 1인용 에이전트 아키텍처 만들기

처음 만들어보는데, 어떻게 시작해보지?
처음 만들어보는 제품을 설계할 땐 어떻게 시작하면 좋을까. 사실 이건 누가 생각하더라도 명확하다. 그냥 작고 유연하게 설계하면 되지 않을까? 유연하면 코드 변경에 쉽게 대응할 수 있고 작으면 크게 변경할 일이 생기더라도 절대적인 사이즈가 작으니 유리하다. 그래서 모카를 설계할 때 가장 큰 원칙은 ‘일단 작게 만든다.’ 였다.
폰/브라우저 ─ 앱 (React SPA)
│
│ REST
▼
Spring Boot
├─ 화면 자산 서빙 + REST API
├─ 에이전트 루프 + 카드 만들기
│
├──▶ PostgreSQL ─ 노트 원본
└──▶ OpenAI API ─ LLM 호출
무엇이든 필요한 순간이 오면 그때 도입한다. 최초에 슬랙앱으로 시작할 때 모카는 DB도 없었다. 모든 데이터는 JSON 또는 이미지였다. 동작 정확성을 높이기 위해 APP을 도입하면서 사용자를 위한 UI를 설계하게 됐고 그때 편의성을 높이기 위해 목록·필터·검색 기능이 함께 기획되었는데 그때 DB가 도입되었다. 파일은 전부 읽어서 메모리에서 거르는 수밖에 없고 인덱스를 도입하기도 쉽지 않았기 때문이다. 가장 전통적인 최적화 방법을 따르기로 한 것이다. 인프라 도입 뿐만 아니라 앱 설계도 해당 원칙을 따랐다. 가장 익숙한 설계는 프론트를 서빙할 웹서버를 따로 두는 것인데 모카는 그래들 빌드 스크립트에 프론트엔드 빌드를 포함시켰다. 그러니까 스프링 서버에서 프론트엔드 빌드 산출물을 서빙하는 것이다. 웹서버는 따로 두지 않았다. 혼자 로컬에 띄워서 사용하는 앱인데, 굳이 관리해야 할 서버를 늘리고 싶지 않았다.
에이전트의 아키텍처
이 서비스의 가장 핵심은 에이전트를 어떻게 설계할 것이냐이다. 사용자 발화가 들어오는 순간부터 OpenAI API로부터 그럴듯한 답변을 받기까지의 흐름을 어떻게 해야 복잡하지 않게 또 수정이 편리하게 설계할 수 있을까. 이 설계를 몇 번 갈아엎으면서 배운 건 설계는 둘째고 우선 그럴듯한 답변을 얻어내는 데부터 상당히 많은 수고로움이 든다는 것이다. 솔직히 지금도 그럴듯한 답변을 얻기 위해 수정해야하는 부분이 있지만… 우선 현재 상태부터 돌아보기로 했다.
"어제 마신 말릭커피의 에티오피아야. 복숭아 향미가 아주 좋았어" + 사진
│
▼
[1] 사진 글자 읽기 (OCR) ── 결정론: 사진이 있으면 반드시 돈다
│
▼
[2] 에이전트 루프 ── 확률: 모델이 tool을 골라 부른다
│ tool 셋: 노트 목록 보기 · 노트 하나 읽기 · 기록 제안하기
▼
[3] 웹 검색 보강 ── 결정론: 핵심 필드가 비면 반드시 돈다
│
▼
[4] 응답: 답변 텍스트 + 채워진 폼
│
▼ (사용자가 폼을 확인·수정하고 [저장])
[5] 저장, DB에 커밋 ── 결정론: 버튼이 유일한 방아쇠
[2] 에이전트 루프만 비결정적으로 두고 앞뒤 단계는 최대한 전부 결정론적으로 설계했다. 여기서 결정론적이라는 건 출력이 아니라 실행이다. 앞뒤 단계도 내부에서 LLM을 부르기 때문에 출력까지 보장할 수는 없지만, 실행 여부와 시점만큼은 모델의 재량이 아니라 코드가 확정한다. 처음엔 최대한 에이전트 루프에 의지하는 방향으로 설계했다. 약간의 희망(사실 기도였다)을 가지고서. 하지만 역시 에이전트는 내가 원하는대로 동작하지 않았고 나는 프롬프트를 수정하는 대신 루프 주변을 통제하기로 했다.
코드로 보면 이 흐름은 TurnRunner라는 클래스가 오케스트레이션 한다.
사진이 있으면 먼저 TurnPhotoOcr(vision LLM)가 원두 봉투의 글자를 읽는다. 이건 에이전트가 사용하는 tool이 아니라 확정적으로 실행되는 전처리이다.
또 다른 전처리기로는 발화에 시음일이 여러 날 섞여 있으면 날짜별로 갈라 놓는 분해기가 있다.
그다음 ChatClient(실제 구현은 OpenAI를 부르는 OpenAiChatClient)가 “모델에게 요청한다 → 모델이 tool을 쓰겠다고 하면 실행해서 결과를 돌려준다”의 루프를 수행하고,
루프가 끝나면 TurnProposalEnricher가 제안된 폼을 확인한다. 결과물에 로스터리 공식 테이스팅 노트나 원두 구성이 비어 있으면 웹 검색을 통해 빈 필드를 채운다.
단, 검색어가 될 커피 이름조차 없으면 검색은 돌지 않는다. 무엇을 찾을지가 없으니까.
tool 밖으로 쫓겨난 검색
[3]번 단계의 위치에는 약간의 에피소드가 있다. 처음에 웹 검색은 루프 안의 tool로 설계되었다. “모델이 필요하다고 판단하면 알아서 검색하겠지”라는, 나름 에이전트다운 설계라고 생각했다.
그러나 모델은 내 기도를 배반하고 tool을 부르지 않는 쪽을 더 많이 선택했다. 전환 후 실사용에서 검색 호출은 0회였고, 검색으로 채워진 필드도 0건이었다. 싱글 오리진은 향미 노트를 잃어버렸고 블렌드된 커피들은 향미 노트와 원두를 죄다 잃어버렸다. 원두 종류랑 노트를 입력하는 게 제일 손이 많이 가는데 이걸 안 해주다니. 그래서 검색은 루프 밖의 결정론적 단계로 쫓겨나게 됐다. 손으로 채우기 귀찮은 데이터가 비어있다면 검색을 수행해 반드시 채우도록 설계했다. 그렇게 설계에 하나의 원칙이 추가되었다.
반드시 일어나야 하는 일은 모델의 재량에 두지 않는다. 모델에게는 판단이 필요한 일만 맡기고, “항상 실행”이 요구되는 일은 확정적으로(코드로) 발생시킨다.
OCR이 tool이 아니라 전처리인 것도 같은 이유다. 사진이 있으면 읽는다. 여기에 모델이 선택할 게 없다.
에이전트는 데이터를 직접 변경하지 않는다
루프 안에서 모델이 쓸 수 있는 tool은 세 가지이다. list_notes(전체 노트 목록), get_note(노트 하나 전문), propose_record(기록 제안).
세가지 tool 모두 데이터를 변경하지 못한다. 검색을 하거나 기록을 제안할 뿐이다.
propose_record tool을 부르면 제안은 두 단계를 거친다.
먼저 RecordProposalValidator. 이름 그대로 제안 검증기다. 평점이 정해진 4범주 안의 값인지, 날짜가 형식에 맞는 진짜 날짜인지, 발화에 시음일이 여러 날 섞여 있는데 한 날짜로 뭉뚱그리지 않았는지, 사용자가 폼에서 직접 고친 값을 모델이 멋대로 되돌리지 않았는지 같은 규칙을 서버 코드가 검사한다.
모델의 출력을 믿고 그대로 사용하는 것이 아니라 이 서비스에서 필요한 형식에 맞는지 확정적으로 검증하는 단계이다.
통과한 제안은 TurnProposalSink에 담긴다. sink는 턴이 시작될 때 만들어져서 턴이 끝나면 버려지는 임시 보관 객체다.
루프가 도는 동안 제안을 여기 잠시 담아 뒀다가, 루프가 끝나면 서버가 꺼내서 응답에 실어 보내고, 그 내용이 화면의 폼을 채운다.
제안의 종착지가 DB가 아니라 이 일회용 객체라는 게 중요한 부분이다. 에이전트는 데이터를 직접 변경하지 않는다.
실제 저장은 사용자가 폼을 확인하고 [저장]을 눌렀을 때, 에이전트를 전혀 거치지 않는 별도 REST 경로를 통해서만 일어난다.
즉 모델이 내 의도와 다르게 동작하더라도 데이터는 안전하다. 환각으로 이상한 값을 제안하면? 폼에 이상한 값이 보일 뿐이고 이는 사용자가 충분히 고치거나 취소할 수 있다. LLM은 제안까지만 참여하고 최종 변경은 반드시 사용자의 승인을 거치게 하는 이 정책은 모카에서 가장 오랫동안 변경되지 않은 설계이다. 최초에 슬랙앱으로 시작했을 때부터 지금까지도 유지되고 있다.
덤으로, 검증에 실패한 제안은 예외로 종료되지 않고 거부 사유를 tool 사용 결과로 되돌려준다. “발화에 시음일이 여러 날 섞여 있다, 날짜별로 나눠 제안하라”처럼. 모델은 그 사유를 읽고 루프 안에서 스스로 고쳐서 다시 제안할 수 있다. 사람이 끼어들 필요가 없는 정정은 루프 안에서 이루어지게 된다.
그러고보니 내겐 Spring AI가 있었다.
그런데 이 에이전트 계층의 설계는 ChatClient, ToolCallback 같은 이름까지 포함해서 Spring AI를 참조했다.
나는 작고 연약한 서버 개발자로 회사에서도 사이드 프로젝트에서도 에이전트를 직접 설계해본 적이 없었다. 근데 궁금하니까 이번에 필요한 거 만들면서 시도해봐지~한게 여기까지 왔다.
따라서 모델 호출, tool, 대화 기록, 프롬프트 조립 등, 서비스에 필요한 컴포넌트 하나 하나가 또는 오케스트레이션 흐름들이 어떻게 서로 의존하고 흘러가야 깔끔할지 감이 부족했다.
그래서 참고할만한 선례를 알아봤는데 모카의 기술 스택과 일치하고 내게도 익숙한 생태계에서 선례를 찾았다. Spring AI. 나는 Spring AI를 기준으로 삼았다.
모델을 부르고 tool 루프를 돌리는 드라이버는 ChatClient라는 인터페이스 하나로 정하고, tool 하나는 정의와 실행기의 쌍(ToolCallback)으로 다루고, 그것들을 모아 공급하는 창구(ToolCallbackProvider)를 따로 두고, 대화 기록과 턴 입력 조립을 별개 부품으로 분리하는 것.
에이전트 계층의 이 설계도는 Spring AI의 구조를 따른 것이다.
내가 임의로 만든 설계도와 수많은 개발자들의 손과 머리로 다듬어진 설계도는 그 무게와 가치와 표준화 정도가 다를 수밖에 없다.
Spring AI를 의존성으로 추가한 건 아니다. 사실 그러고 싶었는데 그러지 못했다. 모카가 쓰는 OpenAI 기능을 당시 버전이 지원하지 않았고 규모가 그렇게 큰 서비스도 아니었기 때문에 설계 원칙을 참조하는 방향으로 정했다.
- 역할과 의미가 Spring AI 쪽과 동일한 컴포넌트는 이름을 그대로 쓴다(
ChatClient·ToolCallback). 여기서 “동일”은 역할 이야기에 좀 더 가깝다. - 역할은 비슷한데 동작이 꽤 다른 컴포넌트는 이름을 약간 변경한다. 모카의 대화 기록이
ChatMemory가 아니라FoldingChatMemory인 이유다. - 순수하게 이 서비스를 개발하면서 도입된 컴포넌트들(OCR 전처리, 제안 검증기, 루프 밖 검색 보강 등) 은 억지로 표준 어휘에 욱여넣지 않고 고유한 이름을 유지했다.
AgentTurnController ─ 요청 파싱과 응답 변환만. 얇게 (web/)
│
TurnRunner ─ 턴 1회의 지휘자. [1]→[2]→[3] 순서를 소유 «대응 없음»
│
├─ TurnPhotoOcr ─ [1] 사진 글자 읽기 — 루프 전 전처리 «대응 없음»
├─ UtteranceSegmenter ─ [1] 다중 날짜 분해 — 루프 전 전처리 «대응 없음»
│
├─ FoldingChatMemory ─ 대화 기록. 자체 컨텍스트 관리 규칙 내포 «이름 유사»
├─ TurnPromptAssembler ─ 발화·대화 기록·폼·OCR 결과 → 턴 입력 조립 «이름 유사»
│
├─ ChatClient ─ [2] "모델에 요청한다 ↔ tool 실행해 결과를
│ │ 돌려준다"의 반복을 돌리는 곳 (인터페이스) «이름 그대로»
│ └─ OpenAiChatClient 구현체 — OpenAI SDK
│
├─ ToolCallbackProvider ─ 턴마다 tool 3종을 묶어 루프에 건네는 창구 «이름 그대로»
│ └─ ToolCallback ×3 ─ tool 하나 = 모델에게 주는 사용 설명서
│ │ + 모델이 부르면 실제로 도는 코드 «이름 그대로»
│ ├─ list_notes · get_note — 노트 목록·전문 읽기
│ └─ propose_record — 채운 폼을 제안하기
│ ├─ RecordProposalValidator ─ 제안을 검증하는 검증기 «대응 없음»
│ └─ TurnProposalSink ─ 통과한 제안이 턴이 끝날
│ 때까지만 담기는 임시 보관함 «대응 없음»
│
└─ TurnProposalEnricher ─ [3] 빈 필드 웹 검색 보강 — 루프 밖 후처리 «대응 없음»
«이름 그대로»와 «이름 유사»는 대체로 루프의 뼈대(드라이버, tool, 대화 기록, 입력 조립)에 해당되고 «대응 없음»은 전부 그 루프를 감싸는 결정론적 장치들이다. Spring AI에서 참조한 건 “LLM 앱은 이런 부품으로 이루어진다”는 표준 골격이고, 그 주변으로 모델을 믿지 않기 위한 자체 하네스를 구성한 형태에 가깝다.
이렇게 해두면 나중에 Spring AI로 전환해야할 때가 왔을 때 전환이 조금 더 수월해질 수 있다. 이름이 같은 컴포넌트는 바로 교체하고, 이름이 유사한 부품은 그 수식어가 가리키는 자체 동작을 어떻게 살릴지 고민하고, “대응 없음” 부품은 프레임워크 위에 그대로 얹으면 된다.
데이터 영속 계층의 아키텍처
에이전트 설계를 제외하고 나면 일반적인 REST API 서버의 아키텍처가 남는다. 컨트롤러가 요청을 받아 넘기고, 서비스가 비즈니스 로직을 수행하고, 저장소가 DB를 읽고 쓴다. 이 서비스가 복잡한 비즈니스 로직을 가지고 있지 않기 때문에 여기서 제일 중요한 건 역시 트랜잭션 한 개 뿐이다.
NoteController ─ 요청 파싱과 응답 변환만. 얇게.
│
NoteService ─ 외부 세계와 이야기하는 층 (LLM 호출, 파일 이동)
│ ※ @Transactional 없음
NoteTxService ─ 한 트랜잭션 안에서 끝나야 하는 층
│ ※ @Transactional은 여기에만
NoteEntityRepository ─ 행 입출력
항상 트랜잭션으로 묶이는 로직과 그렇지 않은 로직을 구분하는 계층 이름을 어떻게 지을까 고민한다.
오케스트레이터, 파사드, 기타 등등… 이번에는 TxService와 Service를 사용해보기로 했다.
원칙은 항상 한가지이다. “이 로직은 한 트랜잭션 안에 있어야 하는가?”
그렇다면 NoteTxService, 그렇지 않다면 NoteService.
외부 서비스 상태와 DB 트랜잭션의 분리
이 분할은 외부 서비스 상태에 DB 트랜잭션 상태가 의존하지 않는다는 목적을 가지고 있다. 이유는 두 가지다. 외부 API가 5초를 늘어지면 그동안 DB 커넥션을 점유하게 되고, 트랜잭션이 롤백돼도 이미 지불된 LLM 과금과 이미 옮겨진 사진 파일은 되돌아오지 않는다. 애초에 외부 서비스의 트랜잭션과 DB 트랜잭션은 연결되어있지 않기 때문이다.
둘을 연결 시켰을 때의 장점도 생각해볼 수 있다. 외부 서비스가 실패했을 때 DB 데이터가 같이 롤백되어 쓰레기 데이터가 쌓이는 걸 방지할 수 있을 것이다. 그러나 모카의 경우 에이전트 루프의 동작 시간이 길어질 수도 있고 DB에 쌓인 쓰레기 데이터는 스케줄러 서비스 등을 통해 충분히 정리가능한 부분이기 때문에 두 트랜잭션을 좀 더 엄격하게 분리했다.
어디까지 인터페이스로 둘 것인가
“교체 가능성을 위해 인터페이스를 두라”는 조언은 항상 존재한다. 중요한 건 어디까지 인터페이스로 쓸 것이냐이다. 모든 것을 인터페이스로 두면 어차피 확장되지도 않을 거 클래스 갯수만 많아지는 쓰레기가 될 것이며, 그렇다고 인터페이스를 만들지 않으면 특정 벤더의 타입이 코드 전체에 스며들게 된다.
모카를 개발하며 세운 원칙은 간단하다. 교체 가능성이 있는 것만 인터페이스로 둔다.
LLM 호출(ChatClient·VisionClient·AliasGenerator·UtteranceSegmenter, SearchClient), 사진 저장(PhotoStore), 카드 렌더링(NoteRenderer·CardImageRenderer).
모카에서는 이게 전부이다. 의존하는 모델이 변경될 가능성, 사진 저장소가 로컬이 아니라 오브젝트 스토리지로 변경될 가능성 등등. 교체 가능성이 높은 것들 위주로 추려 도입했다.
모카에선 특히 LLM 호출과 연관된 컴포넌트들은 추후에 로컬 LLM으로 변경될 가능성이 꽤 있어 LLM에 의존하는 컴포넌트들은 인터페이스와 구현체로 설계하였다.
관리하는 데이터
모카의 데이터는 세 종류이다.
노트 ── DB가 정본
└─ 유일한 원본. 잃으면 끝.
사진 ── 파일이 정본
└─ 사진과 관련된 DB 데이터 색인용
카드 JPG ── 파생물
└─ 언제든 DB 데이터 기반으로 재생성
노트는 DB가 유일한 원본이다. 스키마(테이블 구조)는 Flyway 마이그레이션 파일이 단독 소유하고 JPA는 검증만 진행한다.
사진은 파일이 최종본이다. 사실 사진 데이터를 관리 해야할까에 대한 고민이 많다. 모카를 실제로 사용해보니 사진 데이터는 원두 데이터를 뽑기 위한 봉투 사진이 전부이고 그 사진이 딱히 예쁘거나 하지 않아서 보관할만한 가치가 과연 있는지에 대한 의문이 지속적으로 있는 상태이다. 따라서 현재 사진은 관리하는 데이터는 아니고 단순히 누적용 데이터로 두고 있다. 아, 노트 상세 보기 화면에서 사진을 노출하기까지는 제공하고 있다.
카드 JPG는 파생물이다.
공유용 시음 카드 또는 레시피 카드는 DB만 읽어서 언제든 전량 다시 만들 수 있고, 그래서 artifact/cards/ 폴더는 데이터 저장소가 아니라 일종의 캐시에 가깝다.
카드는 DB에 데이터를 저장할 때 생성되지 않는다.
카드 한 장을 생성하려면 헤드리스 브라우저(Chromium)를 띄워야 해서 4.7초가 걸리는데, 저장한 기록을 매번 공유하는 것이 아니기 때문이다.
그래서 카드는 공유 요청이 왔을 때 생성한다(전에 생성된 카드가 있다면 0.03초).
노트 데이터 저장이 지는 책임은 카드를 지우는 것이다. 노트를 고치는 모든 쓰기 경로는 쓰기 전에 그 노트의 카드 이미지를 지운다. 그래서 구버전의 노트가 공유되는 일이 없도록 차단한다.
3줄 요약
- 아키텍처의 크기는 당장 필요한 수준에 맞췄다.
- 반드시 일어나야 하는 일은 모델의 재량에 두지 않았다. 에이전트 루프는 결정론적 컴포넌트(OCR·검색 보강)으로 둘러싸여있다.
- 에이전트는 제안까지만 한다. DB를 바꾸는 이벤트는 언제나 사용자의 [저장] 버튼이다.
소스 코드 : mocha