Step 6. API Route와 인메모리 캐싱
Step 6. API Route와 인메모리 캐싱
이 문서에서 다루는 내용: Next.js App Router의 API Route를 사용해
buildGraph()결과를 HTTP 엔드포인트로 노출하고,Map기반 인메모리 캐시로 반복 요청 시 50개 이상의 JSON 파일을 다시 읽지 않도록 최적화합니다.
1. 요구사항
PM 김도연:
"이준혁 님, Step 3~5에서 buildGraph() 함수가 완성됐잖아요. JSON 파일 읽기부터 노드 필터링, 그룹 할당, 링크 생성까지 다 되는데요. 한 가지 문제가 있어요."
"이 함수가 호출될 때마다 data/raw/ 폴더에 있는 JSON 파일 51개를 전부 읽어서 그래프를 빌드하거든요. 프론트엔드에서 페이지를 열 때마다, 또는 사용자가 필터 값을 바꿀 때마다 매번 이 과정을 반복하면 응답이 느려질 수밖에 없어요."
"그리고 프론트엔드에서 buildGraph()를 직접 호출할 수는 없잖아요. 서버 사이드 함수니까요. 프론트엔드가 HTTP 요청으로 그래프 데이터를 가져올 수 있는 API 엔드포인트가 필요합니다."
"정리하면 두 가지예요. 첫째, 같은 조건으로 반복 요청하면 캐시해서 빠르게 응답해주세요. 둘째, 크롤러가 새 데이터를 수집한 뒤에는 캐시를 무효화하고 새로 빌드할 수 있어야 합니다."
2. 시니어의 접근 방식
시니어 이준혁 (8년차):
"좋아, 핵심은 buildGraph()를 HTTP 세계로 끌어내는 거야. 지금까지 만든 건 Node.js에서만 돌아가는 서버 사이드 함수잖아. 이걸 프론트엔드 브라우저에서도 호출할 수 있게 API Route로 감싸줘야 해."
"Next.js App Router에서는 src/app/api/ 아래에 route.ts 파일을 만들면 그게 곧 API 엔드포인트가 돼. 예를 들어 src/app/api/graph/route.ts 파일을 만들면, 프론트엔드에서 fetch('/api/graph')로 호출할 수 있어. 별도의 Express 서버를 띄울 필요가 없는 거지."
"여기서 질문 하나. export async function GET() -- 이게 뭔지 알아? 전통적인 Express에서는 app.get('/api/graph', (req, res) => { ... }) 이렇게 쓰잖아. App Router에서는 함수 이름 자체가 HTTP 메서드야. GET 함수를 export하면 GET 요청을 처리하고, POST 함수를 export하면 POST 요청을 처리하는 거지. 한 파일에 둘 다 있을 수도 있어."
"그다음이 캐시 문제야. buildGraph(1000)을 호출하면 51개 파일을 읽고, 필터링하고, 링크 생성까지 전부 해. 이 결과를 버리고 다음 요청에서 또 처음부터 하면 자원 낭비잖아. 같은 minVolume으로 요청하면 이전 결과를 그대로 돌려주면 되지 않을까?"
"근데 캐시를 영원히 들고 있으면 안 돼. 왜? 크롤러가 새 데이터를 수집하면 JSON 파일 내용이 바뀌잖아. 그때는 캐시를 지우고 새로 빌드해야 해. 이걸 위해 두 가지 전략을 쓸 거야:"
- TTL(Time To Live) -- 캐시가 일정 시간이 지나면 자동으로 만료. 5분으로 설정
- 수동 무효화 -- POST 요청으로 캐시를 강제로 비우고 리빌드
"캐시 저장소로는 Map을 쓸 거야. Redis 같은 외부 저장소를 쓸 수도 있지만, 이 프로젝트는 단일 서버에서 돌아가니까 인메모리로 충분해. 자, 그러면 코드로 들어가자."
3. 구현
3.1 API Route 전체 구조
먼저 전체 코드를 보고, 이후 각 부분을 상세히 분석합니다.
// src/app/api/graph/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { buildGraph } from '@/lib/buildGraph';
import type { GraphData } from '@/types/graph';
// 인메모리 캐시
const cache = new Map<number, { data: GraphData; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5분
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const minVolume = Math.max(0, Math.min(50000,
parseInt(searchParams.get('minVolume') ?? '1000', 10) || 1000
));
// 캐시 확인
const cached = cache.get(minVolume);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return NextResponse.json(cached.data);
}
// 그래프 빌드
const data = buildGraph(minVolume);
cache.set(minVolume, { data, timestamp: Date.now() });
return NextResponse.json(data);
}
// POST /api/graph — 캐시 클리어 후 리빌드
export async function POST(request: NextRequest) {
let body: { minVolume?: number };
try {
body = await request.json();
} catch {
body = {};
}
const minVolume = Math.max(0, Math.min(50000, body.minVolume ?? 1000));
// 캐시 전체 클리어
cache.clear();
// 그래프 리빌드
const data = buildGraph(minVolume);
cache.set(minVolume, { data, timestamp: Date.now() });
return NextResponse.json(data);
}47줄. 이 한 파일이 백엔드(buildGraph)와 프론트엔드(page.tsx)를 잇는 다리 역할을 합니다. 파일 경로가 곧 URL 경로라는 점을 기억하세요: src/app/api/graph/route.ts -> GET /api/graph, POST /api/graph.
3.2 import와 모듈 수준 캐시 선언
// src/app/api/graph/route.ts (상단부)
import { NextRequest, NextResponse } from 'next/server';
import { buildGraph } from '@/lib/buildGraph';
import type { GraphData } from '@/types/graph';
// 인메모리 캐시
const cache = new Map<number, { data: GraphData; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5분이준혁: "import부터 보자.
NextRequest랑NextResponse-- 이게 App Router의 HTTP 요청/응답 객체야. Express의req,res랑 비슷한 역할인데, Web API 표준(Request,Response)을 확장한 거라서 훨씬 현대적이야."
NextRequest vs 전통적인 req:
| 비교 항목 | Express req | NextRequest |
|---|---|---|
| 쿼리 파라미터 | req.query.minVolume | new URL(request.url).searchParams.get('minVolume') |
| 요청 바디 | req.body (미들웨어 필요) | await request.json() (내장) |
| 기반 표준 | Node.js HTTP 모듈 | Web API Request 확장 |
| 타입 안전성 | @types/express 필요 | Next.js에 내장 |
NextResponse vs 전통적인 res:
| 비교 항목 | Express res | NextResponse |
|---|---|---|
| JSON 응답 | res.json(data) | NextResponse.json(data) |
| 상태 코드 | res.status(404).json(...) | NextResponse.json(..., { status: 404 }) |
| 헤더 설정 | res.set('X-Custom', 'value') | 생성자 옵션 또는 headers 메서드 |
이준혁: "그다음으로 중요한 게
cache와CACHE_TTL이 함수 바깥 모듈 스코프에 선언돼 있다는 거야. 왜 함수 안에 넣지 않았을까?"
"API Route 핸들러(GET, POST)는 요청이 올 때마다 호출돼. 만약 cache를 함수 안에 선언하면 매 요청마다 새 Map이 만들어져서 캐시 역할을 못 해. 모듈 스코프에 두면 서버 프로세스가 살아 있는 동안 유지되니까, 여러 요청에 걸쳐 캐시를 공유할 수 있는 거지."
Map의 제네릭 타입을 뜯어보면:
Map<number, { data: GraphData; timestamp: number }>
// ^키 ^값
// minVolume { 그래프 데이터, 캐시 생성 시각 }- 키(
number):minVolume값.1000,5000같은 필터 조건이 키가 돼 - 값(
{ data, timestamp }): 그래프 빌드 결과와 캐시된 시점의 타임스탬프
이준혁: "
CACHE_TTL = 5 * 60 * 1000-- 5분이야. 왜 5분이냐고? 너무 짧으면(예: 10초) 캐시 히트율이 떨어져서 캐시 효과가 없고, 너무 길면(예: 1시간) 크롤러가 새 데이터를 가져와도 한참 동안 옛날 데이터를 보여주게 돼. 5분은 '사용자가 페이지를 둘러보는 동안은 캐시가 유지되되, 신선도가 크게 떨어지지 않는 타협점'이야. 물론 정답은 없어. 서비스 특성에 따라 조정하면 돼."
3.3 GET 핸들러 -- 읽기 요청과 캐시 활용
// src/app/api/graph/route.ts (GET 핸들러)
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const minVolume = Math.max(0, Math.min(50000,
parseInt(searchParams.get('minVolume') ?? '1000', 10) || 1000
));
// 캐시 확인
const cached = cache.get(minVolume);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return NextResponse.json(cached.data);
}
// 그래프 빌드
const data = buildGraph(minVolume);
cache.set(minVolume, { data, timestamp: Date.now() });
return NextResponse.json(data);
}이 함수가 처리하는 HTTP 요청과 응답의 흐름을 구체적으로 살펴봅시다.
프론트엔드 요청:
GET /api/graph?minVolume=3000
응답 (JSON):
{
"nodes": [
{ "id": "감자빵", "keyword": "감자빵", "totalVolume": 21820, ... },
{ "id": "강황", "keyword": "강황", "totalVolume": 15400, ... },
...
],
"links": [
{ "source": "감자빵", "target": "감자빵 택배", "strength": 0.75 },
...
]
}1단계: 파라미터 추출과 클램핑(Clamping)
const { searchParams } = new URL(request.url);
const minVolume = Math.max(0, Math.min(50000,
parseInt(searchParams.get('minVolume') ?? '1000', 10) || 1000
));이준혁: "이 한 줄이 방어적 프로그래밍의 교과서야. 안에서 바깥으로 뜯어볼게."
바깥에서 안으로 순서대로 분해합니다:
searchParams.get('minVolume') // 쿼리 파라미터 추출 (string | null)
?? '1000' // null이면 '1000' 기본값
parseInt(..., 10) // 문자열 -> 정수 변환 (10진수)
|| 1000 // NaN이면 1000 기본값
Math.min(50000, ...) // 상한선 50000
Math.max(0, ...) // 하한선 0이 패턴을 **클램핑(Clamping)**이라고 합니다. 값을 특정 범위 안에 가두는 거죠.
입력값 -> 처리 결과
?minVolume=3000 -> 3000 (정상)
?minVolume=-500 -> 0 (하한선)
?minVolume=999999 -> 50000 (상한선)
?minVolume=abc -> 1000 (NaN 방어)
(파라미터 없음) -> 1000 (null 방어)이준혁: "왜 이렇게까지 방어하느냐고? API는 누구나 호출할 수 있어. 브라우저 주소창에 직접
/api/graph?minVolume=-999999를 입력할 수도 있고, curl로 아무 값이나 보낼 수도 있거든. 외부 입력은 절대 신뢰하지 않는다 -- 이게 서버 코드의 기본 원칙이야."
2단계: 캐시 확인 (Cache Hit/Miss)
const cached = cache.get(minVolume);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return NextResponse.json(cached.data);
}cache.get(minVolume) -- Map에서 해당 minVolume을 키로 조회합니다. 결과는 두 가지:
- Cache Hit:
cached가 존재하고, 생성 시점으로부터 5분이 안 지났으면 캐시된 데이터를 바로 반환.buildGraph()호출 없이 즉시 응답. - Cache Miss:
cached가 없거나 (undefined), 5분이 지났으면 (stale) 아래 코드로 넘어가서 새로 빌드.
Cache Hit 시나리오:
1. GET /api/graph?minVolume=1000 -> buildGraph 실행, 캐시 저장, 응답 (느림)
2. GET /api/graph?minVolume=1000 -> 캐시 히트, 바로 응답 (빠름)
3. GET /api/graph?minVolume=1000 -> 캐시 히트, 바로 응답 (빠름)
4. (5분 경과)
5. GET /api/graph?minVolume=1000 -> 캐시 만료, buildGraph 재실행, 캐시 갱신
Cache Miss 시나리오 (다른 minVolume):
1. GET /api/graph?minVolume=1000 -> buildGraph(1000) 실행, 캐시 저장
2. GET /api/graph?minVolume=5000 -> 키가 다르므로 Miss, buildGraph(5000) 실행, 별도 캐시 저장3단계: 그래프 빌드와 캐시 저장
const data = buildGraph(minVolume);
cache.set(minVolume, { data, timestamp: Date.now() });
return NextResponse.json(data);캐시 미스일 때 실행됩니다. buildGraph()가 51개 JSON 파일을 읽고, 필터링하고, 그래프를 조립해서 GraphData를 반환합니다. 이 결과를 cache에 저장하고, 동시에 클라이언트에게 JSON으로 응답합니다.
이준혁: "
cache.set()의 값을 보면{ data, timestamp: Date.now() }-- 데이터뿐 아니라 '언제 캐시됐는지'도 함께 저장해. 이 타임스탬프가 있어야 나중에 TTL 만료 여부를 판단할 수 있으니까."
3.4 POST 핸들러 -- 캐시 무효화와 리빌드
// src/app/api/graph/route.ts (POST 핸들러)
export async function POST(request: NextRequest) {
let body: { minVolume?: number };
try {
body = await request.json();
} catch {
body = {};
}
const minVolume = Math.max(0, Math.min(50000, body.minVolume ?? 1000));
// 캐시 전체 클리어
cache.clear();
// 그래프 리빌드
const data = buildGraph(minVolume);
cache.set(minVolume, { data, timestamp: Date.now() });
return NextResponse.json(data);
}POST 핸들러는 "데이터가 변경됐으니 캐시를 비우고 새로 만들어라"는 명령입니다.
크롤러가 새 데이터를 수집한 후:
POST /api/graph
Content-Type: application/json
{ "minVolume": 1000 }
동작:
1. 캐시 전체 클리어 (모든 minVolume에 대한 캐시가 사라짐)
2. buildGraph(1000) 실행
3. 새 결과를 캐시에 저장
4. 새 결과를 응답으로 반환이준혁: "GET과 POST의 역할을 RESTful 관점에서 비교해볼까?"
| 구분 | GET /api/graph | POST /api/graph |
|---|---|---|
| 의미 | 데이터 읽기(Read) | 캐시 무효화 + 리빌드(Invalidate & Rebuild) |
| 파라미터 전달 | URL 쿼리스트링 (?minVolume=3000) | 요청 바디 (JSON) |
| 캐시 동작 | 캐시 히트 시 즉시 반환 | 캐시 전체 클리어 후 새로 빌드 |
| 멱등성 | 멱등 (같은 요청 = 같은 결과) | 비멱등 (캐시 상태가 변함) |
| 호출 주체 | 프론트엔드 (페이지 로드 시) | 크롤러 (데이터 수집 완료 후) |
요청 바디 파싱과 방어적 처리:
let body: { minVolume?: number };
try {
body = await request.json();
} catch {
body = {};
}이준혁: "
try/catch로 감싼 이유가 뭘까?Content-Type이application/json이 아니거나, 바디가 비어 있거나, JSON 형식이 아닌 문자열이 오면request.json()이 에러를 던져. 이런 비정상적인 요청에도 서버가 죽지 않고, 빈 객체를 기본값으로 사용해서 정상 처리하는 거야."
정상 요청: POST /api/graph {"minVolume": 5000} -> body.minVolume = 5000
빈 바디: POST /api/graph -> catch -> body = {} -> 기본값 1000
잘못된 JSON: POST /api/graph "not json" -> catch -> body = {} -> 기본값 1000cache.clear() vs 특정 키만 삭제:
cache.clear(); // 모든 엔트리 삭제이준혁: "
cache.delete(minVolume)로 특정 키만 지울 수도 있는데, 왜clear()로 전부 날렸을까? 크롤러가 새 데이터를 가져오면 JSON 파일 자체가 바뀌잖아. 그러면minVolume이 1000이든 5000이든, 모든 조건에 대한 캐시가 stale 해진 거야. 부분 삭제를 하면 다른minVolume값에 대한 옛날 캐시가 남아서 잘못된 데이터를 보여줄 수 있어."
3.5 GET과 POST의 협력 -- 전체 흐름
두 핸들러가 함께 작동하는 전체 시나리오를 시간 순서대로 추적합니다.
[시각 0분] 서버 시작
cache = {} (비어 있음)
[시각 1분] 프론트엔드: GET /api/graph?minVolume=1000
-> cache.get(1000) = undefined (Cache Miss)
-> buildGraph(1000) 실행 (51개 파일 읽기)
-> cache.set(1000, { data, timestamp: T1 })
-> 응답 반환
[시각 2분] 프론트엔드: GET /api/graph?minVolume=1000
-> cache.get(1000) 존재, T1으로부터 1분 경과 < 5분 (Cache Hit)
-> 캐시된 data 바로 반환 (buildGraph 호출 안 함)
[시각 3분] 프론트엔드: GET /api/graph?minVolume=5000
-> cache.get(5000) = undefined (Cache Miss, 다른 키)
-> buildGraph(5000) 실행
-> cache.set(5000, { data, timestamp: T3 })
-> 응답 반환
[시각 4분] 크롤러: POST /api/graph { "minVolume": 1000 }
-> cache.clear() (1000과 5000 캐시 모두 삭제)
-> buildGraph(1000) 실행
-> cache.set(1000, { data, timestamp: T4 })
-> 새 데이터로 응답 반환
[시각 4.5분] 프론트엔드: GET /api/graph?minVolume=5000
-> cache.get(5000) = undefined (POST에서 clear했으므로 Miss)
-> buildGraph(5000) 실행 (새 데이터 기반)
-> 새 결과 캐시 및 응답4. 핵심 포인트 정리
-
Next.js App Router에서 API Route는 파일 경로가 곧 URL이다.
src/app/api/graph/route.ts는/api/graph엔드포인트가 되며,export async function GET/POST라는 함수 이름이 HTTP 메서드를 결정한다. 별도의 라우터 설정이 필요 없다. -
NextRequest/NextResponse는 Web API 표준(Request/Response)의 확장이다. Express의req/res와 달리 미들웨어 없이도request.json()으로 바디를 파싱하고,NextResponse.json()으로 응답을 생성할 수 있다. -
인메모리 캐시는 모듈 스코프에 선언해야 요청 간에 공유된다.
Map을 함수 안에 선언하면 매 요청마다 초기화되어 캐시 역할을 못 한다. 모듈 스코프의 변수는 서버 프로세스가 살아 있는 동안 유지된다. -
TTL(Time To Live) 설정은 캐시 효율과 데이터 신선도 사이의 트레이드오프다. 너무 짧으면 캐시 히트율이 낮아 빌드가 반복되고, 너무 길면 오래된 데이터를 보여주게 된다. 5분은 사용자의 일반적인 세션 시간을 고려한 기본값이다.
-
외부 입력에 대한 클램핑(Clamping) 패턴
Math.max(하한, Math.min(상한, 입력))은 API의 기본 방어 장치다. URL 쿼리 파라미터나 요청 바디는 누구나 임의로 조작할 수 있으므로, 유효 범위를 강제해서buildGraph()에 비정상 값이 전달되는 것을 차단한다. -
GET은 읽기(캐시 활용), POST는 상태 변경(캐시 무효화)으로 HTTP 시맨틱을 지킨다. 이것이 RESTful 설계의 기본이다. 프론트엔드는 GET으로 데이터를 가져오고, 크롤러는 POST로 캐시를 갱신한다.
-
인메모리 캐시의 한계를 인지해야 한다. 서버가 재시작되면 캐시가 사라지고, 서버가 여러 대일 때 인스턴스 간 캐시가 공유되지 않는다. 단일 서버 환경에서는 충분하지만, 스케일아웃 시 Redis 같은 외부 캐시로 전환을 고려해야 한다.
5. 다음 Step 예고
Step 7: 프론트엔드 기초와 데이터 페칭
API Route가 완성됐으니, 이제 프론트엔드 page.tsx에서 fetch('/api/graph')를 호출해 그래프 데이터를 가져오고, React 상태로 관리하는 방법을 다룹니다. 서버 컴포넌트와 클라이언트 컴포넌트의 구분, useEffect를 통한 데이터 페칭 패턴, 그리고 로딩/에러 상태 처리까지 살펴볼 예정입니다.