금은시세 조회 개발일지 - Python + FastAPI + Next.js 사이드 프로젝트 오답 노트

silverstar·2026년 2월 22일

개발일지

목록 보기
1/1

금은나우(GoldSilver Now) 개발일지 — 금은 시세 플랫폼 개발하면서 겪은 캐시 / 포트 / 마스터 테이블 누락 문제들 🟡

한 줄 소개: 한국금거래소 시세를 크롤링해 금·은 실시간 시세를 조회하고 추이를 시각화하는 플랫폼을 만들었다.
Python 크롤러 → PostgreSQL → FastAPI → Next.js로 이어지는 풀스택 구조를 처음부터 직접 설계하면서 겪은 문제들을 STAR 기법으로 기록한다.

📎 GitHub: https://github.com/grace287/GoldSilver-Now


목차

  1. 프로젝트 개요
  2. 크롤러 실행 시 ModuleNotFoundError
  3. Windows Alembic 마이그레이션 UnicodeDecodeError
  4. DB엔 들어가는데 프론트에 데이터가 안 나올 때
  5. metals 테이블이 비어있어서 시세가 null로 나올 때
  6. PowerShell에서 docker exec가 쪼개져 실행될 때
  7. 차트 기간 확장 — 5년치 데이터와 커스텀 툴팁
  8. 은 시세도 살 때/팔 때로 통일한 이유
  9. 오늘 요약 카드와 날짜 표시
  10. 전체 회고

1. 프로젝트 개요

왜 만들었나

금 시세를 자주 확인하다 보니 한국금거래소 사이트가 모바일에서 불편하고, 과거 추이를 한눈에 보기 어렵다는 걸 느꼈다. 크롤링 연습 프로젝트로 시작했지만, 제대로 된 데이터 플랫폼으로 만들어보고 싶었다.

"지금, 금과 은의 온도."

시스템 구조

[Python 크롤러]
  → BeautifulSoup으로 한국금거래소 스크래핑
  → APScheduler로 5~10분 간격 자동 수집
     ↓
[PostgreSQL]
  → metals (마스터), prices (시세 이력)
     ↓
[FastAPI + Redis]
  → /api/prices/today, /api/prices/history, /api/prices/change-rate
     ↓
[Next.js]
  → 실시간 시세 대시보드 + Recharts 시각화

데이터 모델 설계

처음부터 테이블을 두 개로 나눴다.

테이블역할누가 채우나
metals마스터 (gold/silver 고정값)마이그레이션 시드
prices시세 이력크롤러가 매번 INSERT

이 역할 구분을 처음에 문서화해두지 않아서 나중에 혼란이 생겼다. (→ 5번 항목 참고)


2. 크롤러 실행 시 ModuleNotFoundError

#Python #Package #Troubleshooting

Situation

crawler/main.pyfrom crawler.scheduler import run_crawl, start_scheduler처럼 절대 경로 임포트를 사용하고 있었다. crawler/ 폴더 안에서 python main.py로 실행하려고 했다.

Trouble

ModuleNotFoundError: No module named 'crawler'

crawler/ 디렉터리를 cwd로 두고 실행하면 Python이 상위 경로를 패키지 루트로 인식하지 않는다. 그래서 crawler 패키지 자체를 못 찾는 상황이었다.

추가로 처음엔 npm run crawl로 실행하려다가, 크롤러는 Python이라 package.json이 없어 ENOENT 에러도 났다.

Action

두 가지 방법을 모두 동작하게 만들었다.

방법 1 — 프로젝트 루트에서 -m으로 실행 (공식 방법)

# 루트에서
python -m crawler.main
python -m crawler.main --schedule  # 스케줄러 포함

방법 2 — crawler/ 안에서 실행할 때도 동작하도록 path 보정

# crawler/main.py 상단
import sys, os

if __name__ == "__main__" and __package__ is None:
    # crawler/ 폴더에서 python main.py로 실행할 때만 적용
    sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    __package__ = "crawler"

이 코드를 넣으면 crawler/ 안에서 python main.py를 쳐도 패키지가 정상 인식된다. -m으로 실행할 때는 __package__가 이미 설정되어 있어서 이 블록이 실행되지 않는다.

Retrospective

패키지 구조를 쓰는 Python 스크립트는 실행 위치에 따라 sys.path가 달라진다는 걸 이번에 제대로 이해했다. 다음에 비슷한 구조를 만들면 진입점 스크립트에 이 패턴을 그대로 쓸 수 있다.

README에도 두 가지 실행 방법을 명시해뒀다.


3. Windows Alembic UnicodeDecodeError

#Windows #Encoding #Alembic #Troubleshooting

Situation

cd backend && alembic upgrade head로 마이그레이션을 실행했다. Windows 로컬 환경이었다.

Trouble

UnicodeDecodeError: 'cp949' codec can't decode byte 0xe2 in position 17

Alembic이 alembic.ini를 읽을 때 Windows 기본 인코딩인 cp949를 사용하는데, alembic.ini 첫 줄 주석에 UTF-8 문자인 em dash(—) 가 들어있었다.

# Alembic config — GoldSilver Now  ← 이 '—'가 cp949로 디코딩 실패

Action

em dash를 ASCII 하이픈으로 바꿨다.

# Before
# Alembic config — GoldSilver Now

# After
# Alembic config - GoldSilver Now

단 한 글자 바꿔서 해결됐다.

Retrospective

크로스플랫폼 프로젝트에서는 설정 파일·주석에 ASCII만 사용하는 게 안전하다. 유니코드 문자가 필요한 경우엔 해당 도구가 UTF-8을 명시적으로 지원하는지 먼저 확인해야 한다.

에러 메시지의 'cp949' codec can't decode byte 0xe2 in position 17에서 position 17 근처를 확인하면 문제의 비-ASCII 문자를 빠르게 찾을 수 있다.


4. DB엔 들어가는데 프론트에 데이터가 안 나올 때

#Cache #API #Frontend #Troubleshooting

Situation

크롤러를 돌려서 prices 테이블에 데이터가 쌓이는 걸 psql로 확인했다. 근데 웹 화면에는 시세가 계속 비어있었다.

Trouble

원인이 세 겹으로 쌓여있었다.

원인 1 — API 포트 불일치

프론트는 NEXT_PUBLIC_API_URL=http://localhost:8080으로 요청하는데, 실제 API는 8000에서 떠 있었다. fetch 자체가 엉뚱한 곳을 찌르고 있었던 것.

원인 2 — Redis에 빈 응답이 캐시됨

크롤러가 데이터를 넣기 전에 페이지를 먼저 열었다. 이때 API가 { gold: null, silver: null }을 반환했고, 이걸 Redis가 1분간 캐시했다. 크롤러가 DB에 데이터를 넣어도 TTL 동안은 계속 빈 응답이 나왔다.

원인 3 — Next.js fetch 캐시

fetch의 기본 캐시 동작 때문에 한 번 받은 응답이 일정 시간 유지됐다.

Action

포트 통일

frontend/.envNEXT_PUBLIC_API_URL을 백엔드 실제 포트와 맞췄다.

# frontend/.env
NEXT_PUBLIC_API_URL=http://localhost:8000

빈 응답 캐시 차단

# FastAPI
if gold is None and silver is None:
    # 둘 다 null이면 Redis에 넣지 않음
    return {"gold": None, "silver": None}

# 정상 데이터만 캐시
redis.setex("today_prices", 60, json.dumps(result))

Next.js 캐시 끄기

// 시세 fetch에 no-store 적용
const res = await fetch(`${API_URL}/api/prices/today`, {
  cache: 'no-store'
})

// 페이지 레벨
export const revalidate = 0

에러 상태 노출

API fetch 실패 시 "시세 API에 연결할 수 없습니다 (현재 URL: ...)" 배너를 화면에 보여줬다. 포트 설정이 잘못됐을 때 바로 파악할 수 있다.

Retrospective

"데이터가 안 나온다"는 증상의 원인은 캐시(Redis, Next, 브라우저)연결 대상(URL/포트) 이 두 가지에서 대부분 해결된다. 다음에 비슷한 상황이 오면 이 순서로 점검하면 빠르다.

빈 응답도 캐시하면 안 된다는 교훈을 얻었다. 크롤링 직후 반영이 중요한 서비스에선 빈 결과를 캐시하지 않는 게 맞다.


5. metals 테이블이 비어있어서 시세가 null로 나올 때

#Database #Architecture #Troubleshooting

Situation

psql로 확인해 보니 prices에는 크롤러가 넣은 행이 잘 있는데, metals에는 행이 없었다. 시세 API는 PriceMetal을 JOIN해서 metal.name = 'gold'로 필터하는 구조라, metals가 비어있으면 JOIN 결과가 없어서 시세가 null로 나왔다.

Trouble

metals마스터 데이터다. 금과 은의 이름·심볼처럼 고정된 기준 데이터를 담는 테이블이고, 마이그레이션 시드로 한 번 채우는 것이 맞다. 크롤러는 시세 이력만 prices에 INSERT하는 역할이다.

이 역할 구분을 처음에 문서화하지 않아서 "크롤러가 다 알아서 넣어주는 거 아닌가?"라는 착각이 생겼다.

Action

마이그레이션 파일에 시드를 추가했다.

# alembic/versions/xxxx_seed_metals.py
def upgrade():
    op.execute("""
        INSERT INTO metals (name, symbol)
        VALUES ('gold', 'XAU'), ('silver', 'XAG')
        ON CONFLICT DO NOTHING;
    """)

alembic upgrade head를 실행하면 테이블 생성과 시드가 한 번에 된다. 이미 마이그레이션을 올린 상태에서 metals가 비어있다면 수동 INSERT로 해결한다.

INSERT INTO metals (name, symbol)
VALUES ('gold', 'XAU'), ('silver', 'XAG');

README와 트러블슈팅 문서에도 이 내용을 명시해뒀다.

Retrospective

마스터 테이블과 이력 테이블을 나눌 때는, "누가 채우는지"를 문서와 마이그레이션으로 명확히 해두는 게 중요하다. 다음 프로젝트에서도 같은 구조를 쓴다면 README 초반에 이 구분을 한 줄로 적어두겠다.


6. PowerShell에서 docker exec가 쪼개져 실행될 때

#Windows #Docker #PowerShell #Troubleshooting

Situation

docker ps 출력 결과를 보다가 그 아래에 docker exec 명령을 붙여넣어서 실행하려고 했다.

Trouble

The term 'CONTAINER' is not recognized as the name of a cmdlet...
The term 'healthy' is not recognized...

PowerShell이 docker ps 출력까지 포함한 여러 줄을 각각 명령으로 실행했다. docker ps 결과에 있는 "CONTAINER", "healthy" 같은 단어가 개별 명령으로 해석됐고, docker exec 한 줄도 인자가 잘못 쪼개져서 실패했다.

Action

해결 자체는 단순했다. docker exec 명령만 한 줄 복사해서 실행하면 된다.

docker exec -it goldsilver-now-db-1 psql -U goldsilver -d goldsilver_now

README에 이 명령을 고정해두고 "PowerShell에서는 이 명령만 한 줄 복사해서 실행하세요" 라는 주석을 달아뒀다.

Retrospective

터미널에 여러 줄을 한꺼번에 붙여넣으면 셸이 줄 단위로 실행한다는 걸 잊었다. 앞으로는 문서에 "이 명령 한 줄만 복사해서 실행" 안내를 함께 달겠다.


7. 차트 기간 확장

#Frontend #UX #Chart #API

Situation

처음에는 7일과 30일 탭만 있었다. 금 투자 관점에서 3개월, 6개월, 1년, 3년, 5년 추이도 볼 수 있으면 훨씬 유용하다고 판단했다.

Trouble

해결해야 할 것들이 여러 개 있었다.

  • 90일 이상 데이터를 백엔드 API가 지원해야 한다
  • 기간이 많아지면 첫 로드 때 다 불러올 수 없다 → on-demand 로드 전략 필요
  • Y축은 "만원" 단위로 읽기 쉽게, 툴팁은 정확한 원화 포맷으로
  • Recharts에서 커스텀 툴팁 박스 구현

Action

백엔드 — 기간 상한 확장

# GET /api/prices/history?metal=gold&days=1825
@router.get("/history")
async def get_history(metal: str, days: int = Query(default=30, le=1825)):
    # days 상한을 90 → 1825(5년)로 변경
    ...

프론트 — 탭과 on-demand 로드

const PERIODS = [
  { label: '1주일', days: 7 },
  { label: '1개월', days: 30 },
  { label: '3개월', days: 90 },
  { label: '6개월', days: 180 },
  { label: '1년', days: 365 },
  { label: '3년', days: 1095 },
  { label: '5년', days: 1825 },
]

// 7일·30일은 페이지 서버에서 미리 fetch
// 90일 이상은 탭 선택 시 클라이언트에서 fetch
// 한 번 불러온 기간은 state에 캐시 → 재선택해도 재요청 없음
const [cache, setCache] = useState<Record<number, PriceHistory[]>>({})

const loadHistory = async (days: number) => {
  if (cache[days]) return  // 이미 로드한 기간
  const data = await fetchHistory(metal, days)
  setCache(prev => ({ ...prev, [days]: data }))
}

Y축 포맷

const yTickFormatter = (value: number) => {
  if (value >= 10000) return `${(value / 10000).toFixed(1)}`
  return `${value.toLocaleString()}`
}

커스텀 툴팁

const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
  if (!active || !payload?.length) return null
  const { date, buy, sell } = payload[0].payload

  return (
    <div className="bg-white border rounded-lg shadow-md p-3 text-sm">
      <p className="font-medium mb-1">{date}</p>
      <p className="text-blue-600">
        살 때:{buy.toLocaleString()}
      </p>
      <p className="text-amber-600">
        팔 때:{sell.toLocaleString()}
      </p>
    </div>
  )
}

<Tooltip content={<CustomTooltip />} />

Retrospective

기간이 많은 차트는 전부 미리 로드보다 선택한 기간만 on-demand 로드가 낫다. 7일·30일만 SSR로 미리 받고, 나머지는 클라이언트에서 필요할 때 fetch하는 방식이 초기 로드 성능과 서버 부담 둘 다 잡는 데 적절했다.

Y축은 "만원" 단위로 짧게, 툴팁은 정확한 원화로 길게 — 표시 목적을 분리해서 설계하면 가독성이 좋아진다.


8. 은 시세 살때/팔때 통일

#Frontend #UX #Data

Situation

처음에 은 시세는 단일 가격(1g 기준 얼마)만 보여줬다. 금은 "살 때 / 팔 때" 두 줄로 나오는데 은만 한 줄이라 UI가 일관되지 않았다.

Trouble

PriceCard 컴포넌트에 singlePriceLabel 옵션이 있으면 단일 가격으로 렌더링하도록 분기가 있었다. 이걸 제거해서 금·은 모두 두 줄로 통일해야 했다.

Action

page.tsx에서 은 PriceCard에 넘기던 singlePriceLabel prop을 제거했다.

// Before
<PriceCard
  metal="silver"
  singlePriceLabel="/ 1g"   ← 이거 제거
  subtitle="은 1g 기준"
/>

// After
<PriceCard
  metal="silver"
  subtitle="은 1g 기준"      ← 단위는 부제로만 안내
/>

"지난 시세" 섹션 문구도 "지난 시세는 아래 차트에서 확인할 수 있습니다"로 정리했다. 새 컴포넌트를 만들 필요 없이 문구 연결로 충분했다.

Retrospective

같은 컴포넌트로 금·은을 모두 처리하되 옵션 하나로 분기를 둔 설계 덕분에, 통일할 때 prop 하나 제거로 끝났다. "이미 있는 UI가 요구사항을 충족한다면 새 컴포넌트보다 옵션·문구 조정이 낫다" 는 걸 다시 확인했다.


9. 오늘 요약 카드

#Frontend #UI #Accessibility

Situation

상단에 오늘 날짜와 금·은 살 때 시세를 한눈에 보여주는 요약 카드가 필요했다. 나머지 섹션(오늘 상세 카드, 차트)과 시각적으로 구분되어야 했다.

Trouble

날짜를 서버에서 받을지, 클라이언트에서 구할지가 고민이었다. "오늘"이라는 기준은 사용자 로컬 기준이어야 하니 클라이언트에서 구하는 게 맞다고 판단했다.

레이아웃도 데스크탑에서는 날짜 | 금 카드 | 은 카드, 모바일에서는 날짜가 위, 카드들이 아래로 내려오는 반응형이어야 했다.

Action

// 클라이언트에서 날짜 구하기
const today = new Date().toLocaleDateString('ko-KR', {
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  weekday: 'long',
})

// 레이아웃
<section className="flex flex-col md:flex-row gap-4
  bg-amber-50 border-l-4 border-amber-400
  rounded-2xl shadow-md p-5">
  {/* 왼쪽: 날짜 */}
  <div className="flex flex-col justify-center min-w-[120px]">
    <span className="text-xs text-amber-600 font-medium">Today</span>
    <span className="text-sm font-semibold">{today}</span>
  </div>

  {/* 오른쪽: 금·은 살 때 요약 */}
  <div className="flex gap-3 flex-1">
    <SummaryChip metal="gold" price={goldBuy} />
    <SummaryChip metal="silver" price={silverBuy} />
  </div>
</section>

다른 섹션과 구분하기 위해 연한 앰버 배경 + 왼쪽 골드 세로 라인(border-l-4 border-amber-400) + rounded-2xl + shadow-md를 적용했다.

Retrospective

카드/섹션을 구분할 때 배경색·테두리·그림자 중 한 가지 포인트만 일관되게 사용하는 게 유지보수에 유리하다. 골드 라인 하나가 생각보다 인상을 많이 바꿨다.


10. 전체 회고

이번 프로젝트에서 배운 것들

① Python 패키지 임포트는 실행 위치에 민감하다

-m 플래그로 루트에서 실행하거나, 진입점 스크립트에서 sys.path를 직접 보정하면 어디서 실행해도 동작하게 만들 수 있다.

② 크로스플랫폼이면 설정 파일에 ASCII만 쓰자

em dash 하나 때문에 Windows에서 마이그레이션이 깨졌다. ini, yaml, toml 같은 설정 파일 주석에는 유니코드 특수문자를 쓰지 않겠다.

③ 빈 응답은 캐시하면 안 된다

크롤링 직후 반영이 중요한 서비스에서 { gold: null } 같은 빈 결과를 캐시하면, 데이터가 생겨도 TTL 동안 계속 빈 화면이 나온다. 빈 응답은 캐시 대상에서 제외하는 정책이 맞다.

④ 마스터 테이블과 이력 테이블은 "누가 채우는지"를 문서화하자

metals(마스터)는 마이그레이션 시드, prices(이력)는 크롤러. 이 구분을 README 초반에 명시해두지 않아서 불필요한 혼란이 생겼다.

⑤ "데이터가 안 나온다"면 캐시와 포트부터

증상만 보면 원인이 뭔지 모르는데, 캐시(Redis, Next, 브라우저) → 연결 대상(URL/포트) 순으로 점검하면 대부분 해결된다.

⑥ 차트 기간이 많으면 on-demand 로드

전부 미리 받으면 초기 로드가 느려진다. 자주 쓰는 7일·30일만 SSR로, 나머지는 선택 시 클라이언트 fetch + state 캐시가 적절한 균형이었다.


다음에 붙이고 싶은 것들

  • 가격 알림 (목표가 도달 시 푸시)
  • 국제 금 시세 연동 (환율 기반 환산)
  • 금 체온 지수 (GTI) — "지금 금 시장은 차갑다/뜨겁다"
  • 프리미엄 차트 구독 모델

태그: Python FastAPI Next.js PostgreSQL Redis Recharts 크롤링 Docker Alembic 개발일지 사이드프로젝트

0개의 댓글