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

Aidiary

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

Role

Fullstack Developer

Period

2025.04 - 2025.06 (개인 개선 2026.02)

Team

2인 팀

GitHub
캡스톤 디자인 (2인 팀)으로 시작해 수료 후 개인적으로 아키텍처 개선을 이어갔습니다. 인증, 일기 CRUD, AI 연동 등 Spring Boot + Flask 이중 서버 백엔드 전체와 Docker Compose 기반 인프라를 담당했습니다. k6 부하 테스트에서 AI 추론(~30초)의 동기 호출이 서비스 전체를 마비시키는 문제를 발견하고 RabbitMQ 비동기 처리로 전환하여 TPS를 1.16에서 1,949까지 개선했으며, Redis 캐싱과 Caffeine 다층 캐시로 반복 API 호출 비용을 줄였습니다.
Frontend
ReactTypeScriptZustandWeb Worker API
Backend
Spring BootJPAMariaDBRabbitMQRedisCaffeine
AI
FlaskMediaPipeGemini 2.5 Flash
Infra
Docker ComposeAWS EC2S3CloudFront
Tools
GitGitHub Actions

System Architecture

Aidiary Architecture Diagram

WAS 처리량 1.16 → 1,949 TPS, 응답 30s → 4.9ms

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

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

Problem

ML 이미지 합성(~30초)이 Tomcat 스레드를 직접 점유해, 동시 사용자 10~20명에서 서비스 전체가 마비됐습니다.Flask ML 추론을 동기 호출하는 구조에서, 스레드 풀이 30초씩 점유돼 일기 작성·건강 기록 등 무관한 API까지 전부 무응답.
  • k6 실측 최대 처리량: 1.16 TPS

Approach

RabbitMQ로 WAS와 AI 처리를 분리하고, 3중 멱등성 가드로 중복 처리를 방지했습니다.먼저 @Async + ConcurrentHashMap을 시도했지만 Scale-out 시 서버 간 상태 불일치와 메모리 누수(GC 후 +28MB 잔류) 문제가 드러나 RabbitMQ로 전환.
  • Spring Boot: 큐 발행 → 202 Accepted (4.9ms)
  • Python Worker: prefetch_count=1 + 수동 ACK → Webhook 결과 전달
  • DLQ로 Worker 장애 시 메시지 보존

Result

WAS 처리량 1.16 → 1,949 TPS, 응답 레이턴시 30,000ms → 4.9ms.500 VU 부하에서 p95 318ms, 에러율 0%. 총 175,463건 처리. AI 처리 지연과 무관하게 WAS 가용성 유지.
의존 방향 정리: AI 서비스 호출을 인터페이스 기반으로 추상화하고, Entity 대신 DTO만 전달하도록 설계. 트랜잭션 경계를 분리하여 AI 처리 모듈을 별도 서버로 떼어낼 때 변경 범위를 최소화
장애 격리: 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: 폴링으로 완료 알림

감정·건강 데이터를 반영한 맞춤 응답, Caffeine + Redis + DB 3계층 캐시

주차별 맞춤 정보 — 산모 상태 기반 개인화 응답 + 다층 캐시

개인화 응답 + Gemini API 호출 최소화

Problem

임신 주차 정보가 모든 산모에게 동일한 응답을 반환해, 개인화된 서비스를 제공하지 못하고 있었습니다.기존 구현은 주차 번호만으로 Gemini를 호출해 42주 고정 콘텐츠를 생성하는 구조였습니다. 같은 20주차라도 최근 감정이 불안한 산모와 안정적인 산모가 동일한 정보를 받고 있었고, 체중·혈압 등 건강 데이터도 반영되지 않았습니다.

Approach

산모의 일기 감정 분석 이력과 건강 기록을 Gemini 프롬프트에 주입해 개인화하고, 사용자×주차×컨텍스트 조합의 다양성에 대응하는 3계층 캐시를 설계했습니다.UserContextService가 최근 7일 일기 감정 빈도와 최신 건강 기록을 수집해 요약 텍스트를 생성합니다. 이 컨텍스트를 SHA-256으로 해싱해 캐시 키(userId:contextHash)로 사용합니다. 동일한 컨텍스트(감정·건강 상태 변화 없음)에는 캐시가 HIT되고, 일기를 쓰거나 건강 기록이 바뀌면 해시가 달라져 자동으로 새 Gemini 호출이 발생합니다.
  • L1 Caffeine: 200 엔트리, 2분 TTL — 동일 사용자 반복 조회 시 네트워크 홉 없이 응답
  • L2 Redis: 24h + Jitter TTL — 서버 간 공유, 캐시 스탬피드 방지
  • L3 DB: PersonalizedWeekContent 엔티티로 영속화 — 서버 재시작·Redis 장애 시에도 유실 없음
  • 컨텍스트 미입력 사용자는 기존 42주 공통 캐시로 Fallback

Result

같은 주차라도 산모의 감정·건강 상태에 따라 다른 맞춤 응답을 제공하며, 3계층 캐시로 반복 요청 시 Gemini API 비용을 절감합니다.개인화로 사용자×주차×컨텍스트 조합이 다양해져 캐시가 실질적으로 필요한 구조가 됐습니다. DB 영속화 덕에 Redis 장애나 서버 재시작에도 기존 응답을 즉시 복원할 수 있습니다.
캐시 키 설계: SHA-256(userId + week + emotions + healthData) → 감정·건강 변화 시 해시 자동 변경으로 무효화
Fallback 전략: 컨텍스트 미입력 → 42주 공통 캐시 / Redis 장애 → DB → Gemini 직접 호출
PregnancyWeekCacheService.javaL1 → L2 → L3 → Gemini 순차 조회
String cacheKey = ctx.userId() + ":" + ctx.contextHash();  // ← 감정·건강 변화 시 해시 변경

// L1: Caffeine (JVM 내 캐시, 네트워크 홉 없이)
PregnancyWeekDTO dto = localCache.getIfPresent(cacheKey);  // ← L1
if (dto != null) return dto;

// L2: Redis (서버 간 공유, JSON 역직렬화 필요)
String json = redisTemplate.opsForValue().get(redisKey);   // ← L2
if (json != null) {
    dto = objectMapper.readValue(json, PregnancyWeekDTO.class);
    localCache.put(cacheKey, dto);   // L1 승격
    return dto;
}

// L3: DB → L1+L2 승격 / MISS → Gemini 호출 → 전 계층 저장
// (이하 동일 패턴: 역직렬화 → populateCache → return)

응답 487ms → 3ms, Gemini 호출 일 1회 고정

오늘의 질문 — 매 요청 Gemini API 호출 → Redis 날짜 기반 캐싱

487ms → 3ms / Gemini 일 1회

Problem

'오늘의 질문'이 요청마다 Gemini API를 호출해 매번 다른 질문을 생성하는 기능 결함이 있었고, 응답도 avg 487ms로 느렸습니다.기획 의도는 하루에 하나의 질문을 모든 산모가 공유하는 것이었는데, 구현을 확인해보니 요청마다 Flask → Gemini API를 동기 호출하고 있었습니다.
  • 같은 날인데 요청마다 다른 질문 생성 → '오늘의 질문' 기능 의미 자체가 무너짐
  • API 비용이 요청 수에 비례해 선형 증가 (사용자 100명 = Gemini 100번 호출)

Approach

다층 캐시에서 이미 사용 중인 Redis를 활용해, 날짜 자체를 키로, 자정까지 남은 시간을 TTL로 설정하여 하루 1회만 Gemini API를 호출하도록 했습니다.daily_question:{yyyyMMdd} 형태로 키를 설계하면 날짜가 바뀌는 순간 자동으로 만료되어, 별도 스케줄러 없이 다음 날 첫 요청이 새 질문을 생성합니다. 하루 뒤 버려질 일회성 데이터이므로 DB 영속화 대신 TTL 자동 만료가 적합했습니다.
  • HIT 시 Redis에서 ~1ms 즉시 반환, AI API 호출 없음
  • MISS(하루 첫 요청)에만 Flask → Gemini 호출 (~500ms, 하루 1번만)
  • 캐시 무효화 전략이 필요 없음 — 날짜 변경 자체가 무효화

Result

응답 487ms → 3ms, Gemini API 호출이 요청 수 비례(N회) → 일 1회로 고정됐습니다.모든 산모가 같은 날 동일한 질문을 받아 기능 일관성이 확보됐고, 기능 결함(요청마다 다른 질문)도 캐싱으로 동시에 해결됐습니다.

Key Results

1.16 → 1,949 TPS

WAS 수용 처리량

RabbitMQ 비동기 전환 (큐 발행 기준), 500 VU / p95 318ms / 에러율 0%

487ms → 3ms

오늘의 질문 API

Redis 날짜 기반 캐싱, Gemini 호출 일 1회 고정

3계층 캐시

주차별 맞춤 정보

감정·건강 컨텍스트 개인화, Caffeine + Redis + DB

Next Project

Knowledge Garden

NestJS 백엔드 · Next.js 프론트엔드 · LLM 챗봇