https://tobetirdev.tistory.com/169
[나만무] SnapAgent의 의사 결정 과정
https://tobetirdev.tistory.com/168 [나만무] 10기 4조 팀장의 후기** 개인의 의견이 다수 포함되어 있음 **** 프로젝트에 대한 포스팅은 나중에 진행 ** 대략 2달만의 포스팅...ㅋㅋㅋ핀토스가 어떻게 끝났는
tobetirdev.tistory.com
** 위 포스팅에 이어지는 내용입니다 **
** 백엔드와 인프라를 담당했기에, 프론트 쪽은 기술하지 않는점 양해 부탁드립니다 **
나만무에 대한 마지막 포스팅
우리 팀은 SnapAgent를 제작하면서 어떠한 기술적 접근과 고민을 했을까?
노드 구현
우선 AI Agent를 만들기 위해, 어떤 노드가 필요했고 그를 어떻게 구현할지부터 생각해보자.
우선 RAG 파이프라인을 담당할 지식 노드가 필요했고, 또한 LLM을 호출하여 임베딩된 결과를 바탕으로 답변할 LLM 노드도 필요했다.
지식 노드
포트로 query를 받아 벡터 연산을 처리하고 documents/context를 출력 포트에 저장한다.
LLM이 템플릿 없이도 바로 context를 읽을 수 있게 포트 이름을 고정한다.
LLM 노드
입력 포트(query, context, variables등)로 들어온 값을 템플릿에 채워 LLM을 호출한다. 출력은
response 포트로 VariablePool에 저장하며, 토큰과 모델 메타데이터도 실행 기록으로 저장한다.
구현 노드가 13개 정도 되기 때문에, 간단히 요약하자면 노드는 “입력 포트 → 서비스 호출/계산 → 출력 포트” 패턴을 따르며,
상태 업데이트는 Assigner, 흐름 제어는 IfElse/Classifier, 외부 연동은 HTTP, 결과 확정은 Answer/End로 구현하였다.
노드 - 엣지 구조 워크플로우
워크플로우의 전체적인 실행 흐름은 다음과 같다.
1. 클라이언트가 보낸 그래프 JSON에서 nodes/edges를 꺼낸다.
2. Validator로 그래프가 괜찮은지 검사하고, 위상정렬로 실행 순서를 얻는다(선행 노드가 끝나야 후행 노드가 실행됨)
3. 노드 인스턴스를 만들고, 엣지 정보를 바탕으로 입력/출력 관계를 연결한다.
4. 노드를 순서대로 실행한다. 노드가 만든 결과가 저장되고, 다음 노드가 그 결과를 꺼내쓴다.
5. End 노드까지 실행되면 response를 최종 응답으로 반환합니다.
BaseNode를 부모 노드로 설정하여, 모든 노드가 공통으로 갖는 속성(입출력 연결, 상태)를 정의했다.
따라서 실행 엔진이 노드 타입을 몰라도, BaseNode를 사용하면 공통 인터페이스로 동일하게 호출할 수 있다.
문제 및 해결
노드들이 컨텍스트 내부 구조와 키에 강하게 의존해 결합도가 커졌다. V2 버전은 포트/변수/Validator로 “이 포트에 이런 타입이 들어와야 한다”는 계약을 명시하고, 서비스는 ServiceContainer를 통해 주입해 노드-컨텍스트 결합을 낮추도록 재설계했다.
그래서 어떻게 V2 Validator가 포트/매핑을 검사하는가?
노드 정의에 입력/출력 포트가 있는지 확인하고, 엣지에 source_port/target_port가 비어 있으면 에러로 막는다.
입력이 필요한 노드(LLM 등)는 variable_mappings가 채워져 있는지 검사해 “어떤 포트가 어디 값을 읽는지”를 확인한다.
이런 식으로 검사한다.
실행 엔진(V2)에서 분기/미선택 경로 처리 흐름
기술적 챌린지에도 언급한 분기/미선택 경로 처리 흐름이다. 문제가 되는 상황은 분기 발생시 미선택 경로의 incoming_count를 처리하지 않으면, 그 다음 노드가 실행되지 않는다라는 문제다.

실행 순서는 여전히 위상 정렬이지만, IfElse/Classifier 같은 분기 노드는 edge_handles로 “선택된 엣지”를 알려준다.
선택된 엣지들만 실행 큐에 반영하고, 미선택 분기의 downstream 노드 incoming_count를 0으로 만들면서도,
ready_queue에는 올리지 않아 “타지 않은 브랜치”를 건너뛰도록 했다.
따라서 조건 분기가 명확히 적용되고, 잘못된 브랜치가 실행되거나 순서가 꼬이는 일을 방지하였다.
임베딩 처리
물론 모델에 따라 다르지만, 임베딩은 무겁고 오래 걸리는 작업이다. S3 다운로드 -> 파싱 -> 청킹 -> Bedrock 호출 -> pgvector 저장까지 한번에 하면 API 워커의 이벤트 루프를 오래 잡아먹어 요청이 지연될 수 있다. 따라서 업로드된 문서는 API에서 바로 임베딩하지 않고 SQS 큐에 작업을 넣고, 별도 프로세스가 폴링하여 처리하도록 했다.
비동기 처리와 한계
우리 서비스는 FastAPI 기반 async 서버이다. DB를 동기호출로 묶어두면, await을 하는 동안 이벤트 루프가 막혀 다른 요청을 받지 못해 동시 처리량이 낮아질 우려가 있었다. 물론, 우리 서비스를 실제로 많은 사용자가 이용하도록 설계를 끝마치고 실제 운영을 해본 것은 아니다. 다만 트래픽이 늘어날때 유연하게 대응할 수 있는 시스템으로 설계하고자 했다.
워크플로우가 한번 돌때, 실행 기록, 노드 기록등 여러 DB I/O가 발생한다. 이걸 비동기로 돌려야 다른 사용자 요청이나 응답이 그 사이에 실행되어 응답 지연과 타임아웃을 줄일 수 있을 것이라 생각했다.
실제로 실행기록 저장이나 로그 발행(SQS)등은 실행 완료 후 비동기로 송신하도록 분리하여 응답 시간을 14%가량 줄인 효과가 있었다.
특히 외부 호출인 AWS LLM/Bedrock은 기본 SDK인 boto3이 동기 방식이라 이벤트 루프를 막을 수 있어서, 동기 호출을 스레드 풀에 던져놓고, 그 태스크를 await 하는 방식으로 구현했다. 비용문제와 에러 대비를 위해 asyncio.Semaphore(10)으로 동시 호출을 Rate Limit(초당 약 13회) 이하로 묶어 서비스의 안정성을 확보하기도 했다.
그런데...
사실 await, 코루틴에 대해서 확실히 알고 시작한것도 아니고(파이썬 개발 자체가 처음이라) 그냥 서비스를 모두 동기로 돌릴까도 생각했지만, 그래도 품질과 안정성, 확장성을 고려해서 한번 await를 적극적으로 이용해보자 하는 마음에 벌인 일이다.
이 판단이 맞는 판단일까? 그냥 잘못된 설계, 오버엔지니어링이자 "저는 ~~해봤습니다" 하기 위한 시도라고 생각할 수도 있을 것 같다.시간이 더 있었다면 부하테스트를 적극적으로 했을 텐데..
그러나 서비스 특성상, DB/네트워크 I/O가 많고, 워크플로우 실행중 외부 호출이 여러번 발생하는 경우, 유저가 많아진다면 비동기 처리가 적합하다는 생각은 하고 있다.
실제 유저가 100명, 1000명이 된다면 어떻게 요청을 처리할지에 대한 생각을 꾸준히 하면서, 앞으로도 계속 조금씩 개선할 생각이라 잘 배우고 다시 도전한다면 좋은 결과가 있을 것 같다...!
캐시
우리는 LLM 호출 비용·지연이 크고, 같은 질의/유사 질의가 반복될 가능성이 높아서 먼저 캐시를 뒤져보고,
없으면 LLM에 요청하는 흐름으로 응답 시간을 줄이고 비용을 절감하려고 했다.
단순 키 캐시만으로는 프롬프트가 조금만 달라도 재사용이 안 돼서, 의미 기반(시맨틱) 재사용도 함께 넣었다.
하지만 이는 정말 신중히 선택해야 할 전략이라는 생각이 들었다.
우리팀은 스타트업 쇼핑몰 개발자라는 페르소나를 설정하여 그에 적합한 시나리오를 구성했다.
기업입장에서, 고객이 얼만큼 유사한 질문을 해야 동일한 답변을 내놓을지에 대한 가이드가 있을 것이다.
예를 들어 "20대 여자 옷 추천"과, "20대 남자 옷 추천" 과 같은, 한 글자만 달라져도 완전히 의미가 달라지는 경우는
성별을 뜻하는 단어에 가중치를 두는 등 전략이 필요할 것이다.
또한 가이드라인이 바뀔때, 즉 RAG 문서의 내용이 업데이트 된다면 캐시에는 여전히 예전 데이터가 남아있어 부정확한 응답을 내놓을 가능성이 있었다. 따라서 TTL 설정도 필요하고, 여하튼 전략이 매우 달라져야 한다. 따라서 이를 "초보 개발자도 설정할 수 있도록 많은 파라미터를 설정하는 기능"이 있어야 했는데... 한달은 너무 짧은 것 같다 ㅎ...
인프라
사실 이전까지 거의 언급한 내용이긴 하다.
우선 로컬 환경은 도커 컴포즈 파일을 만들어 팀원들에게 동일 환경에서 테스트를 할 수 있도록 했다.
그리고 pgvector 대신 chromaDB를 사용했었는데, 프로덕션으로 올라가면서 운영/모니터링을 RDS 하나로 통일하고 싶어 변경했다.
배포는 GitHub Actions가 Docker 빌드→ECR/Hub 푸시→ECS 배포했고, 프론트는 Vercel로 배포했다.
우선 도커 컨테이너를 ECS Fargate에 올려 워커 프로세스 단위로 수평 확장했고, 무거운 임베딩/배치 작업은 아예 별도 워커 프로세스로 분리(SQS 폴링)해 API 워커를 가볍게 유지했다.
업로드 문서는 S3로 올렸으며, 별도 워커가 다운로드→파싱→청킹→Bedrock 임베딩→pgvector에 저장하도록 했다.
API 워커는 DB/pgvector/Redis에 await으로 접근해 이벤트 루프를 막지 않도록 했다.
캐시와 외부 호출은 ElastiCache에 키 캐시+시맨틱 캐시를 두어 LLM 호출 전에 먼저 캐시 적용을 시도했다.
또한 Bedrock/OpenAI/Anthropic 호출은 동기 SDK가 많아 스레드풀로 오프로딩한 뒤 await해 루프를 비워두었다(임베딩/LLM)
로그/사용량은 AWS Lambda도 사용했다. 흐름은 아래와 같다
1. API/워크플로우 실행 중 나온 실행 로그·토큰 사용량을 바로 DB에 쓰지 않고 SQS 큐(로그 큐, 사용량 큐)에 넣는다
2. 메시지가 쌓이면 Lambda가 자동으로 깨어나 PostgreSQL에 적재
이 방식은 피크 시간에도 API/ECS를 덜 건드리고, Lambda가 필요한 만큼만 잠깐 늘어났다가 줄어든다. 실패하면 DLQ로 빠져서 나중에 재처리할 수 있는 환경을 구축해 놓았다.
이외에도 Export/Import 기능이라던지 다른 문제도 있지만, 내가 담당한 기술은 아니기 때문에 이정도로 적어둔다.
또한 이 프로젝트를 사이드 프로젝트로 계속 폴리싱할 계획이기 때문에, 간간히 들러 업데이트를 진행할 예정!
'크래프톤정글10기 > 나만무' 카테고리의 다른 글
| [나만무] SnapAgent의 의사 결정 과정 (0) | 2025.12.12 |
|---|---|
| [나만무] 10기 4조 팀장의 후기 (1) | 2025.12.04 |