Project2025.04 - 2025.06 (개인 개선 2026.02)

Aidiary

산모가 일기를 쓰면 AI가 감정을 분석해 피드백을 제공하는 서비스입니다. 부모 사진으로 아기 캐릭터를 생성하는 기능도 함께 제공합니다.

Role

Fullstack Developer

Period

2025.04 - 2025.06 (개인 개선 2026.02)

Team

2인 팀

캡스톤 디자인 (2인 팀)으로 시작해 수료 후 개인적으로 아키텍처 개선을 이어갔습니다. 인증, 일기 CRUD, AI 연동 등 Spring Boot + Flask 이중 서버 백엔드 전체와 Docker Compose 기반 인프라를 담당했습니다. AI 합성(~30초)의 동기 블로킹 문제를 @Async 시도 → 부하 테스트에서 메모리 고갈 확인 → RabbitMQ 전환으로 단계적으로 해결하여 TPS를 1.16에서 1,949까지 개선했으며, 도메인 카테고리 해시 기반 캐시로 반복 API 호출 비용을 줄였습니다.
Frontend
ReactTypeScriptZustandWeb Worker API
Backend
Spring BootJPAMariaDBRabbitMQCaffeine
AI
FlaskMediaPipeGemini 2.5 Flash
Infra
Docker ComposeAWS EC2S3CloudFront
Tools
GitGitHub Actions

System Architecture

Project Architecture

WAS 처리량 1.16 → 1,949 TPS, 동기 대기 30초 → 비동기 즉시 응답(4.9ms)

AI 이미지 합성 동기 블로킹 → RabbitMQ 비동기 전환

WAS 수용 처리량 1.16 → 1,949 TPS (큐 발행 기준)

Problem

ML 이미지 합성(~30초) 작업이 Tomcat 스레드를 직접 점유하여, 동시 사용자 10~20명 수준에서도 전체 서비스가 마비되었습니다.Flask ML 서버에 동기 호출을 보내고 응답을 기다리는 구조여서, 한 요청이 30초씩 스레드를 점유하면 일기 작성·건강 기록 등 AI와 무관한 API까지 스레드를 할당받지 못해 전부 무응답 상태에 빠졌습니다.k6 부하 테스트 결과 최대 처리량이 1.16 TPS에 불과했습니다.

Approach

@Async로 먼저 시도한 뒤, 부하 테스트에서 한계를 확인하고 RabbitMQ로 전환했습니다.@Async로 톰캣 스레드를 즉시 반환하도록 전환하자 처리량은 개선됐으나, 부하가 몰리자 JVM 내부 큐가 포화되어 메모리 고갈이 발생했습니다. 서버 재배포 시 메모리 큐의 미처리 작업이 유실되는 구조적 한계도 확인했습니다.이에 RabbitMQ를 도입하여 메시지 영속성을 확보하고, AI 합성 처리를 WAS와 물리적으로 분리했습니다. WAS는 큐에 메시지를 발행하고 즉시 응답을 반환하며, Python Worker가 메시지를 소비해 ML 처리를 수행합니다. Worker 완료 시 Webhook으로 Spring Boot에 콜백하여 결과를 저장하고, 클라이언트는 폴링으로 완료 여부를 확인하는 구조로 설계했습니다.Worker 장애 시에는 DLQ로 메시지를 보존하고, 메시지 재전달에 대비한 멱등성 가드를 각 단계에 적용했습니다.

Result

WAS 처리량 1.16 → 1,949 TPS, 동기 대기 30초 → 비동기 즉시 응답(4.9ms)으로 개선했습니다.500 VU 부하에서 p95 318ms, 총 175,463건을 오류 없이 처리했습니다. Flask 서버를 의도적으로 중단시킨 상태에서도 코어 API는 정상 응답하는 장애 격리를 확인했고, 미처리 메시지는 큐에 보존되어 복구 후 자동으로 재처리되었습니다.
@Async 한계: 부하 테스트에서 로컬 큐 포화로 메모리 고갈 실측 / WAS 내부 메모리 큐 의존으로 서버 종료·재배포 시 작업 유실
장애 격리: AI 연산을 별도 Worker로 분리하여, Flask 서버가 다운되더라도 로그인·일기 작성 등 주요 기능은 정상 동작
DLQ 및 3중 멱등성 가드: Worker 장애 시 메시지 유실 방지(DLQ), 메시지 재전달로 인한 중복 처리 방지를 Worker → Webhook → DB 각 단계에서 적용

RabbitMQ 비동기 처리 흐름 — WAS와 AI 처리 분리

sequenceDiagram participant C as Client participant W as WAS (Spring Boot) participant Q as RabbitMQ participant P as Python Worker C->>+W: POST /api/images/analyze W->>Q: 작업 메시지 발행 W-->>-C: 202 Accepted (4.9ms) Q->>+P: 메시지 소비 (prefetch=1) Note over P: MediaPipe 특징 추출<br/>AI 이미지 생성 (~30s) P->>P: basic_ack() P->>-W: POST /api/images/webhook (완료 콜백) W->>C: 폴링으로 완료 알림

주차별 정보 487ms → 0.1ms — 도메인 카테고리 해시로 캐시 무효화 제어

Gemini 반복 호출 제거 — 컨텍스트 해시 캐싱으로 호출 최소화

응답 487ms → 0.1ms / Gemini API 호출 최소화

Problem

주차별 정보와 오늘의 질문 기능이 매 요청마다 Gemini API를 호출하고 있어, 응답 시간 평균 487ms에 API 비용이 요청 수에 비례해 증가하는 구조였습니다.성능 문제 외에 기능적 결함도 있었습니다. 주차별 정보는 주차 번호만으로 Gemini를 호출해 42주 고정 콘텐츠를 생성하고 있어, 같은 20주차라도 최근 감정이 불안한 산모와 안정적인 산모가 동일한 정보를 받았습니다. 오늘의 질문은 기획 의도상 하루 하나의 질문을 모든 산모가 공유해야 하는데, LLM 특성상 요청마다 다른 질문이 생성되는 결함이 있었습니다.

Approach

같은 주차·같은 건강 상태의 산모에게는 동일한 응답이 돌아오므로, 상태가 바뀌지 않은 요청은 캐싱할 수 있다고 판단했습니다.먼저 산모의 최근 7일 일기 감정 빈도와 최신 건강 기록을 수집해 Gemini 프롬프트에 주입하여 개인화했습니다. 그런데 개인화로 인해 상태 조합이 다양해지고 변화 시점도 불규칙하여, 고정 TTL 캐시로는 무효화 시점을 제어할 수 없었습니다.처음에는 원시 컨텍스트를 그대로 SHA-256으로 해싱했으나, 혈압이 1mmHg만 변해도 새 키가 생겨 캐시 히트율이 급락했습니다. 이에 감정(긍정/중립/부정)·혈압(정상/주의/위험)·태동(활발/보통/저조) 등 도메인 기준으로 카테고리화한 뒤 건강 상태와 주차정보를 조합해 해시 키로 사용하여, 의미 있는 상태 변화에서만 캐시가 무효화되는 구조를 설계했습니다. Caffeine 로컬 캐시를 선택해 네트워크 홉 없이 즉시 반환하도록 했습니다.오늘의 질문은 하루 1회 생성되는 공통 데이터이므로 캐싱 대상에서 분리하고, 날짜 키로 DB에 저장해 Gemini 호출을 일 1회로 고정했습니다.

Result

주차별 정보 응답 487ms → 0.1ms(Caffeine HIT), 42개 주차별 유저 분포 시뮬레이션에서 캐시 히트율 95.80%를 달성했습니다.오늘의 질문은 Gemini 호출이 일 1회로 고정되었고, 주차별 정보는 산모의 감정·건강 상태에 따라 다른 맞춤 응답을 제공하게 되어 개인화와 비용 절감을 동시에 달성했습니다.
용도별 캐시 전략 분리: 개인화 데이터(컨텍스트 해시 기반 Caffeine) vs 공통 데이터(날짜 기반 DB 저장) — 데이터 특성에 맞는 캐시 설계
캐시 키 설계: 원시 값 해싱 → 히트율 급락 → 도메인 카테고리(감정 3단계·혈압 3단계·태동 3단계)로 양자화 후 SHA-256 해싱, 의미 있는 변화에서만 무효화
Fallback 전략: 컨텍스트 미입력 사용자 → 42주 공통 캐시 / 캐시 MISS → Gemini 직접 호출
PregnancyWeekCacheService.java도메인 카테고리 해시 기반 캐시
// 원시 값이 아닌 도메인 카테고리로 양자화 후 해싱
String category = categorize(ctx.emotions(), ctx.bloodPressure(), ctx.fetalMovement());
String cacheKey = ctx.userId() + ":" + sha256(ctx.week() + ":" + category);

// Caffeine (JVM 내 캐시, 네트워크 홉 없이 즉시 반환)
PregnancyWeekDTO dto = localCache.getIfPresent(cacheKey);  // ← ~0.1ms
if (dto != null) return dto;

// MISS → Gemini 호출 → 캐시 저장
dto = geminiClient.getPersonalizedWeekInfo(ctx);  // ← ~487ms
localCache.put(cacheKey, dto);
return dto;

Key Results

1.16 → 1,949 TPS

WAS 수용 처리량

RabbitMQ 비동기 전환 (큐 발행 기준), 500 VU / p95 318ms

487ms → 0.1ms

Gemini API 캐시 최적화

도메인 카테고리 해시 기반 캐시, 용도별 전략 분리 (개인화: 컨텍스트 해시 / 공통: 날짜 기반 DB)

Next Project

StoLink

Cascading Failure 해결 · N+1 최적화 · 결제 동시성 제어

포트폴리오에 대해 질문해보세요!