마냑

나만의 스토리를 만들고 채팅으로 이어나가는 AI 스토리챗 서비스

운영 중AI·SW 마에스트로

기간
2026. 6. — 현재
역할
백엔드·인프라
3인

AS-IS → TO-BE

AS-IS

  • 표준 4xx가 catch-all에 걸려 500으로 응답
  • 봇의 /swagger-ui 폴링마다 5xx 알림 발생
  • AI 호출 두 종류가 read timeout 15초를 공유
  • 삭제 API에 소유권 검증 없음

TO-BE

  • 4xx 전용 핸들러로 상태 코드 정정, Sentry 미전송
  • 실제 서버 오류만 알림에 남음
  • storyline 30초 · compile 120초로 분리
  • 404 판정 뒤 403 적용 (리소스 존재 비노출)

서비스

사용자가 자신만의 스토리를 만들고, 그 세계관 안에서 AI와 대화를 이어가는 서비스입니다. 만든 스토리는 공유 링크로 열어 줄 수 있습니다.

AI·SW마에스트로 제17기 서울센터 프로젝트로, 정식 과제명은 「내가 쓰고 AI가 이어가는 나만의 이야기」입니다. ICT 기술분류는 인공지능 / 신뢰·산업 AI.

3인 팀(팀명 김앤강)에 멘토 3인이 붙어 진행 중이며, 서버·웹·안드로이드·AI·인프라를 저장소로 나눠 개발합니다. 저는 API 서버(manyak-server)와 운영 인프라(manyak-terraform) 를 맡고 있습니다.

과정은 예비 과정 2개월과 본 과정 6개월로 나뉘고, 본 과정에서는 정규 멘토링과 함께 프로젝트를 개발합니다.

구성

저장소 역할 기술
manyak-server API 서버 Kotlin, Spring Boot, JPA, Flyway, Spring Security
manyak-web 웹 클라이언트 Next.js 16 App Router, React 19, TanStack Query
manyak-ai AI 서버 Python
manyak-android 안드로이드 Kotlin
manyak-terraform 운영 인프라 Terraform

에러 핸들링: “Sentry에는 5xx만 남는다”

알림이 울려도 아무도 안 보는 상태를 만들지 않는 것이 목표였습니다. 원칙은 하나 — 예상 가능한 4xx는 Sentry로 보내지 않는다. 이 원칙을 세운 뒤 위반하는 경로를 두 번 찾아 고쳤습니다.

1. SSE 엔드포인트의 Accept 불일치가 500이 되던 문제

SSE 엔드포인트(produces=text/event-stream)에 Accept: application/json으로 요청하면 Spring이 HttpMediaTypeNotAcceptableException을 던집니다. 그런데 GlobalExceptionHandler에 전용 핸들러가 없어 catch-all로 떨어졌고, 500으로 응답되면서 Sentry에도 올라갔습니다.

클라이언트가 헤더를 잘못 보낸 것이라 서버 잘못이 아닌데 서버 오류로 집계되고 있었습니다. 표준 4xx 세 개에 전용 핸들러를 붙였습니다.

예외 이전 이후
HttpMediaTypeNotAcceptableException 500 406
HttpMediaTypeNotSupportedException 500 415
HttpRequestMethodNotSupportedException 500 405

세 핸들러 모두 Sentry 전송 함수를 타지 않습니다. 실제 SSE 요청으로 406이 나가고 Sentry에 안 남는 것까지 통합 테스트로 막았습니다.

2. 봇의 스캔이 5xx 노이즈를 만들던 문제

운영에서 Swagger 경로(/swagger-ui.html, /v3/api-docs)를 비활성화했더니, 그 경로가 404가 아니라 500을 반환했습니다. permitAll이라 보안 필터는 통과하는데 매핑이 없어 NoResourceFoundException이 catch-all로 떨어진 것입니다.

문제는 스캐너 봇이 이 경로를 계속 두드린다는 점이었습니다. 봇이 한 번 긁을 때마다 5xx 이벤트가 쌓였습니다. NoResourceFoundException 핸들러를 추가해 404로 정정하고 Sentry 전송에서 뺐습니다. catch-all은 그대로 둬서 진짜 5xx만 남게 했습니다.

AI 호출 타임아웃을 용도별로 분리

스토리 AI 클라이언트가 storyline 생성compile 두 호출에 read timeout 15초를 함께 쓰고 있었습니다. compile은 LLM으로 장문을 생성하는 작업이라 15초를 넘기는 경우가 있었고, 그때마다 정상 동작이 타임아웃으로 실패했습니다.

호출별로 RestClient를 나눴습니다.

호출 connect read
storyline 5초 30초
compile 5초 120초

같은 지연을 줬을 때 storyline은 실패하고 compile은 견디는 회귀 테스트를 함께 넣어, 두 타임아웃이 실제로 독립인지 검증했습니다.

소유권 검증에서 존재 여부를 흘리지 않기

스토리·채팅 삭제 API에 소유권 검증이 빠져 있어, 회원 소유 리소스를 타인이나 미인증 요청이 지울 수 있었습니다.

고치면서 판정 순서를 신경 썼습니다. 소유권을 먼저 보면 “403이 떴다 = 그 id의 리소스가 존재한다”가 새어 나갑니다. 그래서 404(없음)를 먼저 판정하고 그다음 403을 적용했습니다. 게스트가 만든 리소스(user_id가 NULL)는 익명 삭제를 허용하고, 회원 소유는 본인만 허용합니다.

크래시 창에서 사라지던 스토리 복구

백그라운드 생성은 스토리 저장과 완료 마킹이 서로 다른 트랜잭션입니다. 그 사이에 프로세스가 죽으면 스토리는 저장됐는데 요청 행은 PENDING으로 남습니다.

이 상태에서 복구 작업이 다시 돌면 이미 만들어진 세션을 만나 409로 실패 처리했습니다. 스토리는 DB에 있는데 사용자는 받지 못하는 상황이었습니다.

트랜잭션 구조를 바꾸는 대신, 복구 시 저장된 스토리를 원래 응답 형태로 재구성해서 돌려주도록 했습니다. AI 호출과 과금을 다시 타지 않고, 같은 요청을 여러 번 보내도 같은 스토리가 나옵니다.

그 외 구조 결정


이 성과는 2026년도 과학기술정보통신부의 재원으로 정보통신기획평가원의 지원을 받아 수행된 결과물임 (IITP-2026-AI·SW마에스트로)

This work was supported by the Institute of Information & Communications Technology Planning & Evaluation(IITP) grant funded by the Ministry of Science and ICT(MSIT) (IITP-2026-AI·SW Maestro)

기술 스택

KotlinSpring BootJava 21PostgreSQLRedisFlywayTerraformAWSSentryAmplitude

프로젝트 목록으로