
한 줄 소개: 한국금거래소 시세를 크롤링해 금·은 실시간 시세를 조회하고 추이를 시각화하는 플랫폼을 만들었다.
Python 크롤러 → PostgreSQL → FastAPI → Next.js로 이어지는 풀스택 구조를 처음부터 직접 설계하면서 겪은 문제들을 STAR 기법으로 기록한다.
금 시세를 자주 확인하다 보니 한국금거래소 사이트가 모바일에서 불편하고, 과거 추이를 한눈에 보기 어렵다는 걸 느꼈다. 크롤링 연습 프로젝트로 시작했지만, 제대로 된 데이터 플랫폼으로 만들어보고 싶었다.
"지금, 금과 은의 온도."
[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번 항목 참고)
#Python #Package #Troubleshooting
crawler/main.py는 from crawler.scheduler import run_crawl, start_scheduler처럼 절대 경로 임포트를 사용하고 있었다. crawler/ 폴더 안에서 python main.py로 실행하려고 했다.
ModuleNotFoundError: No module named 'crawler'
crawler/ 디렉터리를 cwd로 두고 실행하면 Python이 상위 경로를 패키지 루트로 인식하지 않는다. 그래서 crawler 패키지 자체를 못 찾는 상황이었다.
추가로 처음엔 npm run crawl로 실행하려다가, 크롤러는 Python이라 package.json이 없어 ENOENT 에러도 났다.
두 가지 방법을 모두 동작하게 만들었다.
방법 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__가 이미 설정되어 있어서 이 블록이 실행되지 않는다.
패키지 구조를 쓰는 Python 스크립트는 실행 위치에 따라 sys.path가 달라진다는 걸 이번에 제대로 이해했다. 다음에 비슷한 구조를 만들면 진입점 스크립트에 이 패턴을 그대로 쓸 수 있다.
README에도 두 가지 실행 방법을 명시해뒀다.
#Windows #Encoding #Alembic #Troubleshooting
cd backend && alembic upgrade head로 마이그레이션을 실행했다. Windows 로컬 환경이었다.
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로 디코딩 실패
em dash를 ASCII 하이픈으로 바꿨다.
# Before
# Alembic config — GoldSilver Now
# After
# Alembic config - GoldSilver Now
단 한 글자 바꿔서 해결됐다.
크로스플랫폼 프로젝트에서는 설정 파일·주석에 ASCII만 사용하는 게 안전하다. 유니코드 문자가 필요한 경우엔 해당 도구가 UTF-8을 명시적으로 지원하는지 먼저 확인해야 한다.
에러 메시지의 'cp949' codec can't decode byte 0xe2 in position 17에서 position 17 근처를 확인하면 문제의 비-ASCII 문자를 빠르게 찾을 수 있다.
#Cache #API #Frontend #Troubleshooting
크롤러를 돌려서 prices 테이블에 데이터가 쌓이는 걸 psql로 확인했다. 근데 웹 화면에는 시세가 계속 비어있었다.
원인이 세 겹으로 쌓여있었다.
원인 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의 기본 캐시 동작 때문에 한 번 받은 응답이 일정 시간 유지됐다.
포트 통일
frontend/.env의 NEXT_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: ...)" 배너를 화면에 보여줬다. 포트 설정이 잘못됐을 때 바로 파악할 수 있다.
"데이터가 안 나온다"는 증상의 원인은 캐시(Redis, Next, 브라우저) 와 연결 대상(URL/포트) 이 두 가지에서 대부분 해결된다. 다음에 비슷한 상황이 오면 이 순서로 점검하면 빠르다.
빈 응답도 캐시하면 안 된다는 교훈을 얻었다. 크롤링 직후 반영이 중요한 서비스에선 빈 결과를 캐시하지 않는 게 맞다.
#Database #Architecture #Troubleshooting
psql로 확인해 보니 prices에는 크롤러가 넣은 행이 잘 있는데, metals에는 행이 없었다. 시세 API는 Price와 Metal을 JOIN해서 metal.name = 'gold'로 필터하는 구조라, metals가 비어있으면 JOIN 결과가 없어서 시세가 null로 나왔다.
metals는 마스터 데이터다. 금과 은의 이름·심볼처럼 고정된 기준 데이터를 담는 테이블이고, 마이그레이션 시드로 한 번 채우는 것이 맞다. 크롤러는 시세 이력만 prices에 INSERT하는 역할이다.
이 역할 구분을 처음에 문서화하지 않아서 "크롤러가 다 알아서 넣어주는 거 아닌가?"라는 착각이 생겼다.
마이그레이션 파일에 시드를 추가했다.
# 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와 트러블슈팅 문서에도 이 내용을 명시해뒀다.
마스터 테이블과 이력 테이블을 나눌 때는, "누가 채우는지"를 문서와 마이그레이션으로 명확히 해두는 게 중요하다. 다음 프로젝트에서도 같은 구조를 쓴다면 README 초반에 이 구분을 한 줄로 적어두겠다.
#Windows #Docker #PowerShell #Troubleshooting
docker ps 출력 결과를 보다가 그 아래에 docker exec 명령을 붙여넣어서 실행하려고 했다.
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 한 줄도 인자가 잘못 쪼개져서 실패했다.
해결 자체는 단순했다. docker exec 명령만 한 줄 복사해서 실행하면 된다.
docker exec -it goldsilver-now-db-1 psql -U goldsilver -d goldsilver_now
README에 이 명령을 고정해두고 "PowerShell에서는 이 명령만 한 줄 복사해서 실행하세요" 라는 주석을 달아뒀다.
터미널에 여러 줄을 한꺼번에 붙여넣으면 셸이 줄 단위로 실행한다는 걸 잊었다. 앞으로는 문서에 "이 명령 한 줄만 복사해서 실행" 안내를 함께 달겠다.
#Frontend #UX #Chart #API
처음에는 7일과 30일 탭만 있었다. 금 투자 관점에서 3개월, 6개월, 1년, 3년, 5년 추이도 볼 수 있으면 훨씬 유용하다고 판단했다.
해결해야 할 것들이 여러 개 있었다.
백엔드 — 기간 상한 확장
# 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 />} />
기간이 많은 차트는 전부 미리 로드보다 선택한 기간만 on-demand 로드가 낫다. 7일·30일만 SSR로 미리 받고, 나머지는 클라이언트에서 필요할 때 fetch하는 방식이 초기 로드 성능과 서버 부담 둘 다 잡는 데 적절했다.
Y축은 "만원" 단위로 짧게, 툴팁은 정확한 원화로 길게 — 표시 목적을 분리해서 설계하면 가독성이 좋아진다.
#Frontend #UX #Data
처음에 은 시세는 단일 가격(1g 기준 얼마)만 보여줬다. 금은 "살 때 / 팔 때" 두 줄로 나오는데 은만 한 줄이라 UI가 일관되지 않았다.
PriceCard 컴포넌트에 singlePriceLabel 옵션이 있으면 단일 가격으로 렌더링하도록 분기가 있었다. 이걸 제거해서 금·은 모두 두 줄로 통일해야 했다.
page.tsx에서 은 PriceCard에 넘기던 singlePriceLabel prop을 제거했다.
// Before
<PriceCard
metal="silver"
singlePriceLabel="/ 1g" ← 이거 제거
subtitle="은 1g 기준"
/>
// After
<PriceCard
metal="silver"
subtitle="은 1g 기준" ← 단위는 부제로만 안내
/>
"지난 시세" 섹션 문구도 "지난 시세는 아래 차트에서 확인할 수 있습니다"로 정리했다. 새 컴포넌트를 만들 필요 없이 문구 연결로 충분했다.
같은 컴포넌트로 금·은을 모두 처리하되 옵션 하나로 분기를 둔 설계 덕분에, 통일할 때 prop 하나 제거로 끝났다. "이미 있는 UI가 요구사항을 충족한다면 새 컴포넌트보다 옵션·문구 조정이 낫다" 는 걸 다시 확인했다.
#Frontend #UI #Accessibility
상단에 오늘 날짜와 금·은 살 때 시세를 한눈에 보여주는 요약 카드가 필요했다. 나머지 섹션(오늘 상세 카드, 차트)과 시각적으로 구분되어야 했다.
날짜를 서버에서 받을지, 클라이언트에서 구할지가 고민이었다. "오늘"이라는 기준은 사용자 로컬 기준이어야 하니 클라이언트에서 구하는 게 맞다고 판단했다.
레이아웃도 데스크탑에서는 날짜 | 금 카드 | 은 카드, 모바일에서는 날짜가 위, 카드들이 아래로 내려오는 반응형이어야 했다.
// 클라이언트에서 날짜 구하기
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를 적용했다.
카드/섹션을 구분할 때 배경색·테두리·그림자 중 한 가지 포인트만 일관되게 사용하는 게 유지보수에 유리하다. 골드 라인 하나가 생각보다 인상을 많이 바꿨다.

① 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 캐시가 적절한 균형이었다.
태그: Python FastAPI Next.js PostgreSQL Redis Recharts 크롤링 Docker Alembic 개발일지 사이드프로젝트