성진혁
← Projects

소셜 핀테크 — 원장 파생 지표 · 또래 비교 · 미션

FinMate — 청년 금융 온보딩

기간
2026.04 – 2026.07
역할
풀스택 — 앱의 프론트·백엔드 단독
참여 인력
4명 — 기획 2 / 풀스택 1(본인) / 데이터 1 · 가가제작소, 하나금융그룹 × 금융감독원 2026 청년 금융인재
React 19TypeScript strictViteTailwind CSS v4Motion v12React Router 7html-to-imageJava 21 · Spring Boot 3.5PostgreSQL 16Flyway · Testcontainersfal.ai (FLUX)
GitHub에서 코드 보기 ↗

서비스

20대가 금융을 미루지 않고 오늘 작은 행동 하나로 시작하게 만드는 모바일 서비스입니다.

20대는 금융관리의 필요성은 알지만 '무엇부터 해야 할지 몰라서', '어렵고 귀찮아서' 시작을 미룹니다. 필요한 건 더 많은 정보가 아니라 마찰 없는 첫 걸음입니다.

그래서 또래의 금융 행동을 구경하게 해 동기를 만들고, 지금 할 수 있는 작은 미션으로 연결하고, 하루가 끝나면 그날의 소비를 그림 한 장으로 남깁니다.

  1. 피드
  2. 분석
  3. 마이
  4. 미션
  5. 기록
모바일 홈 화면. 오늘의 예산을 물잔 모양 게이지로 31퍼센트까지 채워 보여주고 아래에 소비 상위 항목이 이어진다
마이 — 지표를 가로로 넘기고, 일간·주간·월간을 바꾸면 화면 전체가 그 기간으로 갈아입는다 (시연용 목 데이터로 촬영)
또래 그룹 목록과 오늘의 금융 스토리 카드 그리드가 보이는 화면
피드 — 소득·소비·지역이 비슷한 또래를 찾고, 그들의 금융 이야기를 카드로 본다 (시연용 목 데이터로 촬영)
데모 UI
React 19 · TypeScript strict · Tailwind v4 · Motion v12 · 커스텀 SVG 차트
직접 띄워보기
cd finmate-app && npm install && npm run devlocalhost:5173 (모바일 390×844 · 화면은 finmate-app, 서버는 finmate-api)

e2e가 화면에서 재현하는 것

  • 차트 라이브러리를 쓰지 않고 SVG와 Motion으로 직접 그렸다 — 물잔 수위는 사인파 두 장의 수평 루프다
  • 화면의 모든 수치는 거래 원장에서 파생된다. 예산 챌린지의 일·주·월 판정도 전부 거래에서 계산한다

위 화면은 로컬 데모 실행 결과입니다. 운영 중인 서비스가 아닙니다.

요약

01청년 금융 · 인구 집계

또래 비교가 매 요청 원장 88만 행을 다시 세던 것을 사람×월 사전 집계로 접어 p50 32.5ms → 0.72ms

변경 전 원장 88만 7천 행을 순차 스캔하고 디스크 정렬까지 거치던 경로와, 변경 후 사람과 월 단위로 접어 둔 1만 4천 행 집계만 읽는 경로를 비교한 도식
또래 비교 — 요청마다 다시 세기 vs 한 번 접어 두고 읽기

문제 원인

  1. 또래 비교는 소득대가 같은 사람 전부를 가로질러 집계하므로 한 달치가 105,484행
  2. 조건이 기간뿐이라 (persona_id, occurred_on) 인덱스의 선행 컬럼이 없어 Parallel Seq Scan
  3. count(DISTINCT persona_id)가 정렬을 강요해 work_mem을 넘기고 디스크 정렬 2,496kB 발생

해결 과정

  1. 고치기 전에 먼저 측정 — 개인 화면도 함께 재보니 p95 0.96ms로 문제가 아니었고, 예상과 달리 인구 집계만 비쌌음
  2. 싼 것부터 순서대로 검증 — 쿼리 수정(count DISTINCT 제거)으로 디스크 정렬을 없애 p50 28.0ms
  3. 커버링 인덱스 (flow, occurred_on) INCLUDE (persona_id, amount)는 50MB를 쓰고 24.0ms에 그쳐 채택하지 않음
  4. 사람×월 사전 집계를 도입하되 원장을 유일한 진실로 두어 언제든 통째로 재생성 가능하게 설계

결과

  1. p50 32.5ms → 0.72ms, 읽는 버퍼 23,326 → 206 (전체 2,000명 · 원장 887,002행 기준)
  2. 사전 집계는 14,000행 1.9MB로 원장의 1.1%, 전체 재생성 401ms
  3. 두 방식의 결과가 소득대 6개 그룹 전부 일치하는 것을 통합 테스트로 고정
02AI 그림일기 · 외부 의존 비동기

행 잠금이 외부 호출 구간을 지키지 못하던 것을 상태 전이로 집는 방식으로 바꿔 두 인스턴스가 같은 작업을 가져가지 못하게 함

행 잠금은 트랜잭션이 끝나면서 풀려 정작 3에서 6초 걸리는 외부 호출 동안에는 아무도 그 행을 지키지 않았고, 지금은 PENDING을 SUBMITTED로 바꾸는 원자적 UPDATE로 집는 구조를 비교한 도식
비동기 작업 집기 — 잠금 vs 상태 전이

문제 원인

  1. 하루의 소비를 그림 한 장으로 만드는 기능이라 외부 이미지 생성 API 호출에 3~6초가 걸림
  2. 처음엔 SELECT … FOR UPDATE SKIP LOCKED로 집고 별도 트랜잭션에서 외부 API를 불렀는데, 잠금은 그 트랜잭션이 끝나며 풀리므로 정작 호출이 도는 동안에는 아무도 그 행을 지키지 않음
  3. 그렇다고 잠근 채로 부르면 3~6초 동안 DB 커넥션을 붙들게 되어 커넥션 풀이 마름

해결 과정

  1. 잠금이 아니라 상태로 집기 — PENDING을 SUBMITTED로 바꾸는 UPDATE … RETURNING 한 문장이 원자적이라 두 인스턴스가 같은 행을 가져갈 수 없음
  2. SKIP LOCKED는 서로 다른 행을 동시에 집을 때 기다리지 않게 하는 역할로만 남김
  3. 집은 직후 죽으면 job id 없는 SUBMITTED가 남으므로, 일정 시간이 지난 것은 PENDING으로 되돌리는 회수 경로를 따로 둠
  4. 수거 대상은 job id가 있는 SUBMITTED로 한정 — 제출 전에 죽은 행까지 수거하면 멀쩡한 작업을 실패로 버리게 됨

결과

  1. 동시에 요청해도 그림이 하나만 생기는 것을 Testcontainers 실 PostgreSQL 통합 테스트로 고정
  2. (persona_id, entry_date) 유니크 제약으로 같은 날을 두 번 요청해도 그림은 하나
  3. 실패는 세 번까지 재시도하고 이유를 남기며, 응답이 오지 않는 작업은 되돌려 다시 집게 함

구현 기능

위 문제 해결 외에, 서비스가 돌아가기 위해 구현한 것들입니다.

수치를 싣지 않은 이유

동시 사용자 부하는 측정하지 않았습니다. 위 수치는 단일 클라이언트가 순차로 호출해 잰 값이라 처리량 지표가 아닙니다. 화면이 이 백엔드에 붙는 작업은 일부만 됐습니다 — 2026-08-07에 둘을 함께 띄워 확인해 보니, 마이 탭의 기준일·예산·소비 상위는 실제 원장에서 파생돼 화면까지 오지만 피드·분석·미션·기록 네 탭은 아직 목 데이터로 그립니다(탭을 옮겨도 API를 부르지 않습니다). 또래 비교·미션·인사이트 API는 만들어 두었지만 앱이 아직 부르지 않으므로, 끝에서 끝까지의 응답 시간은 주장하지 않습니다. 위 화면 사진 두 장도 목 데이터로 찍은 것입니다.

← Projects