프로젝트 목록PROJECT / PERSONAL PRODUCT

개인 서비스 · 운영 중/2026.07 — 운영 중

익숙한 엑셀 화면으로 만나는 실시간 주식 정보

담당 역할1인 개발 · 운영 — 기획, 프론트엔드, 백엔드, DB 설계, 인프라 운영
개발 기간아이디어 → 출시 10일, 2026.07 출시 후 운영 중
프론트엔드Next.js · React · TypeScript · Redux Toolkit
백엔드 · 데이터Node.js(Fastify) · Prisma · MySQL · 실시간 시세 수집 워커
배포 · 인프라Docker Compose 블루그린 무중단 배포
운영 서버GMKtec G3 미니PC — Intel N100(4코어) · RAM 16GB

01 / OVERVIEW

어떤 프로젝트인가요?

엑셀주식은 엑셀을 닮은 화면에서 실시간 시세와 차트, 종목별 AI 분석을 한 번에 보는 주식 정보 서비스입니다. 아이디어 검증부터 출시까지 10일이 걸렸고, 이후 광고 없이 일 사용자 600~700명 규모로 성장해 지금도 운영 중입니다.

02 / GOALS

왜 만들었나요?

주식 정보 서비스는 대부분 화면이 복잡해 처음 쓰는 사람이 원하는 정보를 찾기 어렵습니다. 누구에게나 익숙한 엑셀의 행·열 구조를 그대로 가져오면 학습 비용 없이 종목을 비교할 수 있겠다는 가설로 시작했습니다.

03 / DEEP DIVE

핵심 기능 집중 소개

01

종목별 AI 분석 — 근거를 주입하는 RAG 파이프라인

어떤 고민이 있었는가

  • 종목을 클릭하면 "오늘 왜 움직였는지"를 AI가 설명해 주는 기능이 필요했습니다. 그런데 LLM에 그대로 물어보면 현재가와 등락률부터 틀리는 환각이 발생했습니다.
  • 시세가 실시간으로 바뀌는 도메인이라 "언제 다시 분석할지"의 기준도 필요했습니다. 한국·미국 시장의 장전·정규장·시간외 세션마다 정보의 신선도 요구가 달라, 단일 캐시 TTL로는 비용과 품질을 동시에 잡을 수 없었습니다. 그래서 한국 3개·미국 4개, 총 7개 시장 세션별 TTL과 8개 갱신 규칙을 정책 문서로 먼저 정리했습니다.

어떻게 해결했는가

AI 종목 분석 파이프라인 — 시장 상태 정책과 근거 주입, 검증 게이트
  1. 사용자가 종목을 클릭하면 재분석 판정 정책을 거친다.
  2. 개장 직후 5분 보합, 가격·등락률 동일, 휴장이면 기존 분석을 재사용한다.
  3. 등락률이 3.5%p 이상 급변하거나 7개 시장 세션별 TTL(정규장 30분, 장전·프리마켓 60분, 시간외·애프터마켓 2시간)이 지나면 온디맨드 잡 큐에 적재한다.
  4. 요청자당 최대 5건이 큐에 들어가고, 동시성 3의 워커가 시세를 재확인한 뒤 처리한다.
  5. 근거 주입 단계에서 DB의 일봉·수급·PER/PBR·재무, 서버가 계산한 RSI·MACD·볼린저밴드, 키워드로 랭킹·검증한 RSS 기사를 프롬프트에 넣는다.
  6. AI 릴레이가 AI API로 생성하며, 30초 무응답 시 헤지 요청을 병렬로 보낸다.
  7. 검증 게이트에서 형식과 거절 문구를 검사하고 등락률을 서버 실측값으로 덮어쓴 뒤 저장·캐시하고 화면에 반영한다.
  8. 실패하면 최대 3회 재시도하고, 최종 실패 후 5분간 재적재를 차단한다.
  • 분석 요청을 온디맨드 잡 큐로 받고(요청자당 최대 5건) 워커가 동시성 3으로 처리하는 비동기 구조를 만들었습니다. 같은 종목의 유효한 분석이 있으면 생성 없이 재사용합니다.
  • 재분석 판정은 이 정책대로 백엔드 큐·워커와 프론트 API 4곳이 같은 기준으로 판단하게 맞췄습니다 — 정규장 30분 · 장전·프리마켓 60분 · 시간외·애프터마켓 2시간, 장 마감·휴장 중에는 시간 경과만으로 재분석하지 않음, 등락률이 3.5%p 이상 급변하면 TTL과 무관하게 즉시 재분석, 개장 직후 5분간의 등락률 0%는 기준가 리셋 착시로 보고 무시.
  • 환각은 "근거 주입"으로 막았습니다. 일봉·수급·PER/PBR·재무제표를 DB에서 조회하고, RSI·MACD·볼린저밴드·최대낙폭 같은 지표는 서버가 직접 계산해 프롬프트의 [근거 데이터] 블록에 넣습니다. 시세·재무는 정형 데이터라 벡터 검색보다 정확한, 도메인에 맞춘 retrieval입니다.
  • 뉴스 근거는 언론사 RSS 6종을 5분 캐시로 모으고, 종목·사건 키워드 매칭으로 랭킹한 기사를 발행 시각과 관련성 검증을 거쳐 함께 주입합니다.
  • 생성 결과는 검증 게이트를 통과해야 저장됩니다 — 응답 형식 강제, 거절 문구 거부, 등락률 숫자는 AI 출력 대신 서버 실측값으로 덮어쓰기.
  • 응답 지연에는 헤지 요청으로 대응했습니다. 30초 안에 답이 없으면 자체 AI 릴레이 서버의 다른 엔드포인트에 병렬 요청을 추가하고 먼저 온 답을 씁니다(최대 2개 병렬 · 총 200초 · 3회 재시도).

결과적으로

  • 분석 요청의 약 90%가 10~30초 안에 완료되고, 시장 상태 기반 TTL 덕분에 같은 종목을 중복 생성하지 않고 캐시로 재사용합니다.
  • 분석 조회 쿼리도 함께 최적화해, 전체 DB 읽기의 64%를 차지하던 조회의 실행 계획 예상 행을 2,618행에서 1행으로 줄였습니다.
02

첫 화면과 최종 화면이 한 프레임도 어긋나지 않게 — SSR · hydration 일치

어떤 고민이 있었는가

  • 시세표의 열 너비 · 글꼴 · 테마 · 패널 열림 상태가 브라우저 로컬 설정에 달려 있어, 서버가 그린 첫 HTML과 클라이언트가 설정을 복원한 화면이 달랐습니다. 그 사이에 "빈 목록 → 채워짐" 깜빡임과 레이아웃 이동이 생기고, 최악의 경우 hydration 불일치로 React가 화면을 다시 그렸습니다.
  • 데이터도 문제였습니다. 첫 화면의 패널 여러 개가 각자 브라우저에서 조회를 시작해, 로딩 문구가 먼저 보이고 콘텐츠가 뒤늦게 채워졌습니다.

어떻게 해결했는가

첫 화면 일치 — 설정을 쿠키로 서버에 전달해 같은 모양의 HTML을 그리고, 페인트 전 스크립트 · hydration · 설정 복원까지 화면이 바뀌지 않게 잠금 테스트로 고정
  1. Start: 브라우저가 첫 페이지를 요청한다. 서버는 쿠키에서 표시 설정(글꼴 배율 · 테마 · 패널 · 모바일 여부)을 허용 목록 기준으로 읽는다. 쿠키에는 셀 값 · 보유 종목 · 신원이 없다.
  2. 서버는 SSR 시세 캐시와 서버 프리페치(지수 차트 · 시장 현황 · 스파크라인)만으로 카드 10장 · 선택 지수 · 차트 · 시세표를 HTML에 싣는다. 서버 렌더 중 외부 요청은 0건이다.
  3. 조회에 실패하거나 시간을 넘긴 패널이 있으면 그 패널만 브라우저 조회로 전환하고, 성공한 패널의 콘텐츠는 지우지 않는다.
  4. 페인트 전 인라인 스크립트가 서버와 같은 허용 목록으로 글꼴 · 테마 · 모바일 표시를 적용한 뒤 첫 페인트가 일어난다(FCP 612ms 데스크톱 · 544ms 모바일).
  5. React가 새 store로 서버 상태를 그대로 채택해 hydration한다(복구 렌더 0회). 이어 열 너비 · 패널 열림 · 언어 같은 로컬 설정을 복원하는데, 쿠키와 같은 값이라 화면이 바뀌지 않는다.
  6. 이후에는 새 데이터 수신과 사용자 조작만 화면을 바꾼다. 잠금 테스트 92개가 46개 시나리오 × KR/US × 데스크톱/390px에서 서버 HTML → 새 store → hydration → 설정 복원의 매 렌더 출력을 비교한다.
  • "첫 화면과 초기화 후 화면은 항상 일치한다"를 정책으로 세우고, 최종 상태만이 아니라 중간에 빈 목록 · 로딩으로 바뀌었다 돌아오는 것도 실패로 정의했습니다.
  • 표시 설정(글꼴 배율 · 테마 · 패널 · 모바일 여부)을 localStorage 대신 허용 목록 기반 쿠키에 두어 서버가 같은 모양으로 HTML을 그리고, 페인트 전 인라인 스크립트가 같은 규칙을 적용합니다. 쿠키에는 셀 값 · 보유 종목 · 신원이 들어가지 않습니다.
  • 기본 탭 '주요지수'는 SSR 시세 캐시와 서버 프리페치만으로 카드 10장 · 선택 지수 · 차트를 HTML에 싣고, 서버 렌더 중 외부 요청 0건을 테스트로 잠갔습니다. 스켈레톤은 최종 화면과 행 구조가 같고 값 자리만 비웁니다. 조회에 실패한 패널만 브라우저 조회로 전환하고, 성공한 패널의 콘텐츠는 지우지 않습니다.
  • 잠금 테스트로 고정했습니다 — 46개 설정 시나리오를 KR · US × 데스크톱 · 390px에서 서버 HTML → 새 store → 실제 hydration → 설정 복원까지 돌리며 매 렌더 출력 · 최종 DOM · hydration 오류를 비교합니다(테스트 92개). 실패하면 기대값 갱신이나 skip이 아니라 구현을 고칩니다.
  • 첫 HTML도 줄였습니다. 빈 셀마다 반복되던 인라인 스타일을 공통 CSS로 옮기고, 셀 편집 메뉴 · 서식 대화상자는 실제 열 때 불러옵니다.

결과적으로

  • 첫 방문 FCP 612ms(데스크톱) · 544ms(모바일), LCP 484ms · 348ms, hydration 복구 렌더 0회 — 운영 실측(2026-09-12 · 13, 50Mbps · 20ms 회선).
  • 모바일 브라우저 20개 동시 진입에서 페이지 JavaScript 오류 0건, 첫 화면 일치 잠금 93개를 포함한 관련 테스트 440개 통과.
03

저사양 미니PC 한 대로 하루 1,000만 건 처리

어떤 고민이 있었는가

  • 서버는 GMKtec G3 미니PC(Intel N100 4코어 · 16GB RAM) 한 대였습니다. 실시간 시세를 다루면서도 유지비를 최소로 묶어야 해서, 처리량을 하드웨어가 아니라 구조로 확보해야 했습니다.
  • 가장 큰 부담은 시세 조회였습니다. 사용자마다 상류 API를 호출하면 요청 수가 접속자 수에 비례해 늘고, 시세 공급자 API 한도(WebSocket 구독 90종목 · 벌크 조회 200종목)와 N100의 CPU가 먼저 바닥납니다.

어떻게 해결했는가

국내 시세 수집 — 10초 틱마다 사용자 요청을 하나로 병합해 실시간 WS · 벌크 REST 공급자가 교대하고, 누락은 보조 공급자로 보충, 30초 버킷 스냅샷을 배치 저장
  1. Start: 10초마다 한 틱이 돈다. 모든 사용자의 시세 요청을 종목 집합 하나(최대 200종목)로 병합한다.
  2. 개장 세션(프리·정규·애프터)이 아니면 최근 성공 스냅샷이나 종가를 표시하고 끝낸다.
  3. 짝수 틱은 실시간 WebSocket 체결 버퍼(최대 90종목 구독, 30초 넘은 체결 제외), 홀수 틱 또는 WS 미연결 시에는 벌크 현재가 REST 1회 호출(최대 200종목)로 시세를 채운다.
  4. 누락 종목이 있으면 보조 공급자 폴링(100종목 단위)으로 보충하고, 결과를 전원이 공유한다. 공급자가 모두 실패하면 last-good 데이터로 표를 유지한다.
  5. 화면에는 SSE·시세표 응답으로 내보내고, 30초 버킷 스냅샷을 100행 단위 createMany로 저장한다. 저장 직후 5일이 지난 행을 1만 행 단위로 지운다(완결세션 밴드는 7일).
  6. 실측(운영 로그): 피크 DB 217 QPS, 장중 분당 최대 4,456 HTTP 요청, 스냅샷 하루 약 130만 행.
  • 시세 조회를 사용자별이 아니라 10초 격자로 병합했습니다. 모든 사용자의 요청을 종목 집합 하나로 모아 짝수 틱은 WebSocket 체결 버퍼, 홀수 틱은 벌크 REST 1회 호출로 채우고, 누락 종목만 보조 공급자 폴링으로 보충해 결과를 전원이 공유합니다. 공급자가 모두 실패해도 last-good 데이터로 표를 유지합니다.
  • 저장은 30초 버킷 스냅샷을 100행 단위 createMany로 묶어 쓰고, 보존 기간(5일)이 지난 행은 저장 직후 1만 행 단위로 지워 테이블 크기를 일정하게 유지합니다.
  • CPU 프로파일링으로 병목을 직접 찾았습니다. 호출마다 새로 만들던 시간대 변환 객체(Intl.DateTimeFormat)가 CPU 표본의 21%를 차지하는 것을 확인하고 재사용 구조로 바꿨습니다.

결과적으로

  • 피크 기준 DB 217 QPS, 장중 분당 최대 4,456건의 HTTP 요청을 N100 한 대에서 처리했습니다(운영 로그 실측).
  • 시세 스냅샷만 하루 약 130만 행이 쌓이며, DB 쿼리 기준 하루 1,000만 건 이상의 데이터 처리를 유지합니다.
04

DB 메모리 사용량 5GB → 1.5GB

어떤 고민이 있었는가

  • 사용자가 늘면서 운영 DB(MySQL)가 12~15분마다 메모리 부족으로 강제 종료됐습니다. 컨테이너 메모리 한도를 1.8GB → 2.3GB → 3GB로 올려도 며칠 뒤 다시 꺼졌고, 실제 사용량(RSS)이 버퍼 풀 설정값(384MiB)의 10배까지 불어나 무엇이 메모리를 쓰는지조차 알 수 없었습니다.
  • 도구도 그대로 믿을 수 없었습니다. docker inspect는 OOMKilled=false라고 답했지만, VM 커널 로그(dmesg)에는 mysqld가 cgroup OOM으로 죽은 기록이 남아 있었습니다.

어떻게 해결했는가

MySQL 메모리 장애 — 한도 증설로 해결되지 않자 측정으로 원인 3가지를 찾아 각각 조치하고, 쿼리 경량화까지 더해 운영 실측으로 검증
  1. Start: 운영 MySQL이 12~15분마다 강제 종료됐다. VM 커널 로그(dmesg)에서 cgroup OOM kill을 확인했다. docker inspect의 OOMKilled=false는 오보였다.
  2. 1차 대응으로 컨테이너 메모리 한도를 1,792 → 2,304 → 3,072MiB로 올렸지만 며칠 뒤 다시 꺼졌다.
  3. 측정: RSS가 버퍼 풀 384MiB의 10배까지 불어나 있었고, prepared statement가 60연결에서 약 310MiB를 차지하고 있었다.
  4. 원인 ① Prisma prepared statement가 연결당 100개씩 누적 → statement_cache_size를 32로 축소.
  5. 원인 ② glibc malloc 아레나가 반환하지 않는 메모리(사용 +109MB에 RSS +1,022MiB) → MALLOC_ARENA_MAX=2, 리두 로그 1GiB · io_capacity 1000.
  6. 원인 ③ 정렬 데이터가 큰 쿼리의 정렬 메모리 부족(1038 Out of sort memory) → 인덱스 순서를 그대로 읽어 filesort 제거.
  7. 쿼리 경량화: 종목 50개 묶음의 중첩 UNION 제거(임시 테이블 50 → 0, 쿼리 메모리 −55.6%), 장 마감 조회에 커버링 인덱스(읽는 행 1,440,731 → 599).
  8. 검증(운영 실측): DB 메모리 약 1.5GB에서 안정, 핵심 조회 p95 156.8 → 55.7ms, 20명 동시 접속 246.1 → 64.7ms. End.
  • 짐작으로 설정을 바꾸지 않고, 메모리를 어디에 얼마나 쓰는지 직접 측정해 원인 세 가지를 찾았습니다. ① Prisma가 연결마다 미리 만들어 두는 실행 계획(prepared statement)이 연결당 100개 × 60연결로 약 310MB를 계속 차지했습니다. ② glibc의 메모리 관리(malloc 아레나)가 다 쓴 메모리를 운영체제에 돌려주지 않아, 실제 사용량은 109MB 늘었는데 프로세스 메모리는 1GB 넘게 늘었습니다. ③ 정렬할 데이터가 큰 쿼리에서 정렬용 메모리가 부족해 오류(1038 Out of sort memory)가 났습니다.
  • 원인마다 조치를 짝지었습니다. 실행 계획 캐시를 연결당 100개에서 32개로 줄이고(statement_cache_size), MALLOC_ARENA_MAX=2로 메모리가 쌓이기만 하는 현상을 막고, 정렬은 인덱스에 이미 정해진 순서를 그대로 읽어 별도 정렬(filesort)을 없앴습니다.
  • 쿼리도 가볍게 고쳤습니다. 종목 50개를 한 번에 묶어 조회하던 중첩 UNION 쿼리를 단순하게 바꿔 임시 테이블을 50개에서 0개로 없앴고(쿼리 메모리 −55.6%), 장 마감 데이터 조회에는 필요한 열만 담은 커버링 인덱스를 만들어 읽는 행 수를 1,440,731행에서 599행으로 줄였습니다.

결과적으로

  • 5GB 가까이 쓰던 DB 메모리가 1.5GB 수준에서 안정됐습니다(운영자 실측). 이후 같은 장비에서 강제 종료 없이 더 많은 사용자를 받고 있습니다.
  • 핵심 조회의 응답 시간(느린 쪽 5% 기준)이 156.8ms에서 55.7ms로, 20명이 동시에 접속할 때는 246.1ms에서 64.7ms로 빨라졌습니다.
05

사용자가 늘며 드러난 병목 — 채팅 폴링과 트렌드 수집기

어떤 고민이 있었는가

  • 종목 채팅은 7초 주기 폴링 구조였는데, 사용자가 늘자 폴링의 99.9%가 "새 글 없음"인데도 매번 인증과 DB 조회를 태우고 있었습니다. 읽음 수는 독자가 늘 때마다 전체를 다시 세는 O(n²) 집계라 읽음 기록 테이블이 55만 행(296MiB)까지 커졌습니다.
  • 실시간 검색어·투자 트렌드 수집기를 붙이자 수집 컨테이너 CPU가 95.7%까지 치솟고 트렌드 요청의 59%가 3초를 넘겼습니다. 프로파일링해 보니 원인은 수집 자체가 아니라, 분당 랭킹 재계산과 383KB 체크포인트 JSON을 통째로 다시 쓰는 DB 쓰기 증폭이었습니다.

어떻게 해결했는가

종목 채팅 폴링 — 방 버전이 같으면 인증·DB 조회를 통째로 건너뛰고, 새 글은 내용 없는 SSE 신호로만 알려 증분 폴 1회로 가져감
  1. Start: 브라우저가 증분 폴링을 보낸다(최근 글이 있으면 7초, 조용하면 60초 주기). 요청에는 마지막으로 받은 roomVersion과 커서를 함께 보낸다.
  2. 서버는 최근 200개 메시지의 지문(BIT_XOR(CRC32))으로 방 버전을 계산한다. 결과는 1초 동안 모든 사용자가 공유한다.
  3. 클라이언트가 보낸 버전과 같으면 인증·DB 조회를 건너뛰고 200 OK(unchanged)만 돌려준다. 폴링의 99.9%가 이 경로다.
  4. 버전이 다르면 인증 후 커서 이후의 새 메시지만 조회하고, 읽음 수는 원자적 increment로 올린 뒤 새 메시지와 새 roomVersion을 응답한다.
  5. 다른 사용자가 글을 쓰면 내용 없는 SSE 변경 신호만 보내고(동시 연결 상한 500), 브라우저는 평소의 증분 폴 1회로 내용을 가져간다.
  6. 워커가 4일 지난 읽음 기록을 1만 행 단위로 정리한다.
  • 채팅에 "방 버전 게이트"를 만들었습니다. 최근 200개 메시지의 지문(BIT_XOR(CRC32))으로 방 버전을 계산해 1초 동안 전 사용자가 공유하고, 클라이언트가 보낸 버전과 같으면 인증·DB 조회를 통째로 건너뛰고 unchanged만 돌려줍니다. 새 글은 내용 없는 SSE 신호로만 알리고(동시 연결 상한 500), 실제 데이터는 브라우저가 평소의 증분 폴 1회로 가져갑니다. 조용한 방은 폴링 주기를 7초에서 60초로 늘립니다.
  • 읽음 수는 전체 재집계 대신 원자적 increment로 바꾸고, 4일 지난 읽음 기록을 워커가 1만 행 단위로 정리하는 보존 정책을 추가했습니다.
  • 트렌드 수집기는 랭킹 재계산을 1분 → 60분 주기로 조정하고, 체크포인트를 통짜 JSON에서 채널별 행 단위 갱신으로 분해했으며, 수집 채널 300개의 재방문 주기를 45/60/65분으로 분산해 쓰기가 한 시점에 몰리지 않게 했습니다.

결과적으로

  • 부하 재현 실험에서 수집기 CPU 시간 -84.7%, DB 전송량 226MB→38.8MB, 트렌드 조회 p95 222.96ms→21.58ms를 확인했습니다.
  • 채팅은 동시 접속이 늘어도 DB 부하가 거의 늘지 않는 구조가 됐고, 한때 3.4초까지 치솟던 채팅 조회 p95가 정상화됐습니다.

04 / SCREENS

서비스 화면

서비스 보기새 탭

05 / STATUS

현재 상태

구글 애널리틱스 기준 (2026.09.30 측정)
7,600명월간 활성 사용자 (MAU)
600~700명일일 활성 사용자 (DAU)
13분 40초활성 사용자당 평균 참여 시간
Similarweb 기준 (2026.09.30 측정)
113,025월 방문 (Monthly Visits)
3분 54초평균 방문 시간 (Visit Duration)
11,172국가 순위 (대한민국)

2026년 9월 구글 애널리틱스 기준 MAU 7,600명 · 일 사용자 600~700명 · 활성 사용자당 평균 참여 시간 13분 40초로 운영 중입니다. Similarweb 기준으로는 월 방문 113,025회, 대한민국 국가 순위 11,172위입니다.

Docker Compose 기반 블루그린 배포로 배포 중 502 응답 0건을 유지하고, 장애 시 대기 노드로 전환하는 HA 구성을 갖췄습니다.

06 / REFLECTION

배운 점

출시가 아니라 운영이 진짜 시작이었습니다. 사용자가 늘 때마다 새로운 병목이 드러났고, 추측 대신 프로파일링과 실행 계획으로 원인을 좁히는 습관이 가장 큰 자산이 됐습니다.

제약이 설계를 만듭니다. 저사양 서버라는 조건이 있었기에 요청 병합, 배치 쓰기, 시장 상태 기반 캐시 정책 같은 구조적 해법이 나왔습니다.

AI 기능은 모델보다 파이프라인이 품질을 결정했습니다. 근거 주입과 검증 게이트 없이는 서비스에 내놓을 수 없다는 것을 배웠고, 이 경험이 이후 AI 기능을 설계하는 기본기가 됐습니다.

다음 프로젝트상품 데이터 연계 및 AI 리뷰 분석 시스템 백엔드 개발