Project

AI 갓생비서

"어떻게 더 나아질까?" 그 고민, AI 비서에게 맡기세요.

프로젝트 소개

AI 갓생비서는 일정 CRUD, 반복 일정, 만다라트 목표 관리에 Claude 기반 AI 추천 챗봇까지 얹은 개인 스케줄링 백엔드입니다. 인증은 JWT 액세스/리프레시 토큰 로테이션에 Redis 캐싱과 로그아웃 블랙리스트를 더해서 구성했고, 일정이 바뀌면 폴링 없이 SSE로 바로 화면에 반영되게 했습니다. AI 챗봇은 Spring AI(ChatClient)로 Claude를 붙였는데, 텍스트 한 줄 던지고 끝나는 게 아니라 구조화된 형태로 여러 일정을 한 번에 추천하거나 기존 일정 수정까지 제안하고, 대화 맥락도 계속 이어갑니다. 여기에 pgvector 기반 RAG로 사용자의 만다라트 목표나 과거 일정을 참고하도록 해서 조금 더 개인화된 제안이 나오게 했고요. 배포는 AWS(EC2 + RDS + ElastiCache)에 GitHub Actions로 CI/CD까지 직접 구성해서 올렸습니다.

GitHub 저장소 보기
백엔드 아키텍처
요청 인증
브라우저 → Bearer JWT JwtAuthenticationFilter → Redis 블랙리스트 확인 SecurityContext
요청 처리
Controller (도메인별 7종) Service Layer PostgreSQL · Redis (캐시 · 세션 · rate limit)
실시간 갱신 (SSE)
일정 등록 · 수정 · 삭제 캐시 무효화 (SCAN) ScheduleEventPublisher 브라우저 (EventSource)
색인 (비동기)
일정 · 만다라트 저장 →(Async) 임베딩 생성 pgvector 저장
RAG 질의
사용자 질문 임베딩 변환 유사도 검색 (cosine ≥ 0.35, top 5) 컨텍스트 구성 Claude API 응답 검증 Client

실제 화면
2026.06 ~ 2026.08 (2개월) 1명 (개인 프로젝트)
핵심 지표
응답 74% 단축 캐싱·인덱스 최적화
블로킹 99% 단축 임베딩 색인 비동기 전환
186개 하네스 기반 테스트 자동화
핵심 임팩트
Java Spring Boot Spring Security PostgreSQL Redis Spring AI (Claude) +4
My Contributions
Technical Case Studies

문제 해결 사례

막혔던 문제를 어떻게 정의하고, 원인을 찾아 풀었는지 정리했습니다.

01. Redis 캐시 직렬화 버그와 그 회귀

Redis Jackson
Resolved

Root Cause

AiService에서 갑자기 ClassCastException이 터졌다. 캐시 히트 시점에만 나는 걸 보고 Redis 직렬화 쪽을 의심했는데, 파보니 GenericJackson2JsonRedisSerializer(ObjectMapper) 생성자가 기본(no-arg) 생성자와 달리 넘겨받은 ObjectMapper에 default typing을 켜주지 않고 있었다. 그래서 캐시 히트 시 List<ScheduleResponseDto>가 List<LinkedHashMap>으로 역직렬화되고 있었고, DTO 필드를 타입으로 바로 꺼내 쓰던 AiService가 이걸 제일 먼저 걸려 넘어진 것이었다.

Final Action & Impact

  • DefaultTyping.EVERYTHING + 화이트리스트로 1차 수정 이 프로젝트 패키지로 제한한 PolymorphicTypeValidator를 커스텀 ObjectMapper에 켜서 일단 해결한 줄 알았다
  • 근데 곧바로 회귀가 났다 EVERYTHING이 Long/String 같은 필드 값에도 @class 태그를 붙이는데, 화이트리스트는 프로젝트 패키지/java.util만 허용해서 id: Long을 역직렬화할 때 InvalidTypeIdException이 났다. 그것도 캐시 히트 경로에서만 재현되는 골치 아픈 케이스였다
  • 화이트리스트를 넓혀서 마무리 PolymorphicTypeValidator에 java.lang/java.time을 추가해 해결했다. 같은 걸 또 겪지 않으려고 RedisConfigTest로 캐시 라운드트립 후 타입을 검증하는 회귀 테스트도 추가했다

02. 성능·RAG 파이프라인 전수 감사 — 인덱스 누락부터 임베딩 블로킹까지

Performance RAG Async
Resolved

Root Cause

캐시랑 AI 쪽은 이미 손을 봤으니, 그 바깥은 괜찮은지 한 번 더 처음부터 훑어보기로 했다. 코드를 하나씩 따라가 보니 생각보다 많이 나왔다. User.email에 인덱스가 없어서 인증 경로(JwtAuthenticationFilter의 loadUserByUsername + 각 서비스의 requesterEmail resolve)마다 요청당 최소 두 번씩 풀스캔이 나고 있었고, ReportService는 리포트 하나 뽑으려고 유저의 전체 일정 이력을 범위 제한 없이 읽고 있었다. IDENTITY 생성전략은 반복 일정·만다라트 벌크 저장의 JDBC 배치를 아예 막고 있었고, RAG 쪽도 유사도 하한선 없이 무관한 결과를 topK만큼 채워 넣거나 임베딩 색인이 일정 쓰기 요청 스레드를 동기로 붙잡고 있는 걸 확인했다.

Final Action & Impact

  • User.email에 유니크 인덱스 추가 5만 행을 직접 시딩해서 재봤다(UserEmailIndexPerformanceTest). Seq Scan 3.45ms가 Index Scan 0.05ms로 줄었고, 이건 모든 인증 요청 경로에 다 적용되는 개선이었다
  • ReportService 조회 범위를 이번/직전 기간으로만 좁혔다 3년치(1,095건) 일정을 가진 유저를 기준으로 "이번 주" 리포트를 재보니, 전체 조회 3.85ms(1,095건 반환)가 bounded 조회 0.75ms(21건 반환)로 줄었다. RAG 컨텍스트는 매칭된 id만 따로 조회하도록 분리했다
  • IDENTITY를 SEQUENCE(allocationSize=50)로 바꿔 진짜 배치 INSERT가 되게 1,000건 saveAll 기준 241ms가 147ms로 줄었다(39% 단축). 시퀀스 값이 실제로 50 배수로 전진하는 걸 직접 확인해서 배치가 제대로 도는지도 검증했다. 기존 IDENTITY 시퀀스와 값이 겹치지 않게 별도 시퀀스를 새로 만들었다
  • RAG 유사도 하한선(threshold 0.35) 추가 적당히 값을 잡고 싶지 않아서 실제 OpenAI 임베딩으로 코사인 유사도를 직접 재봤다. "이번 주말 등산 코스"를 질의했을 때 "치과 스케일링 예약"(0.2530), "분기 회계 마감 보고서"(0.2389) 같은 결과가 하한선 적용 후 실제로 빠지는 걸 확인했다
  • 임베딩 색인·삭제를 비동기로 돌려서 쓰기 요청 블로킹 해소 전용 embeddingTaskExecutor를 등록하고 색인·삭제 메서드에만 @Async를 붙였다(검색은 결과를 바로 써야 해서 동기로 남겨뒀다). 300ms 지연을 모킹한 기준으로 호출자 블로킹 시간이 310ms → 1ms로 줄었다. 다만 코사인 유사도 순서가 실제 관련도와 어긋나는 구간이 남아있는 건 재보면서 확인한 그대로 한계로 남겨뒀다

03. ADMIN 전체 조회 캐시가 다른 유저 쓰기에 무효화되지 않던 사각지대

Cache Redis
Resolved

Root Cause

"삭제했는데 새로고침해도 계속 떠 있어요"라는 제보를 받았다. 재현해보니 ADMIN 계정에서만 나는 문제였다. 프론트는 항상 userId 파라미터 없이 GET /api/schedules를 호출하는데, ADMIN이 이렇게 조회하면 targetUserId가 null이 돼서 "{email}-null-null" 키로 캐싱되고 있었다. 그런데 evictScheduleCacheForUser()의 "*-{userId}-*" 패턴은 이 "null" 세그먼트를 못 잡는다. 그래서 다른 유저가 일정을 만들거나 지워도 ADMIN의 전체 목록 캐시는 그대로 남아있었던 것이다.

Final Action & Impact

  • "-null-" 패턴도 유저별 evict 대상에 넣었다 어떤 유저가 쓰기를 하든 ADMIN의 전체 목록 캐시도 같이 무효화되도록 evictScheduleCacheForUser()를 고쳤다
  • 재현해서 확인하고 테스트로 남겨뒀다 Redis에서 실제 stale 키를 재현해 원인을 확인한 다음 ScheduleCacheEvictionTest 2건(create/delete)을 추가했다. ADMIN/USER 테스트 계정을 따로 만들어서 수정 전/후 동작 차이를 curl로 직접 확인했다