1 / 2

Step 1. 프로젝트 초기화 및 타입 시스템 설계

예상 시간: 7분

Step 1. 프로젝트 초기화 및 타입 시스템 설계

이 문서에서 다루는 내용: Next.js 프로젝트를 생성하고, TypeScript 엄격 모드와 경로 별칭을 설정한 뒤, 네이버 키워드 데이터를 시각화하기 위한 도메인 타입(RawKeywordEntry, KeywordNode, KeywordLink, GraphData)을 설계합니다.

안녕

하세요

프로젝트 전체 개요

이 프로젝트는 네이버 검색 키워드 데이터를 Force-Directed Graph로 시각화하는 웹 애플리케이션입니다. 네이버 광고 API에서 수집한 키워드별 검색량, 경쟁 강도, 클릭률 등의 원시 데이터를 받아서, 키워드 간 연관 관계를 그래프 형태로 표현합니다.

전체 10단계에 걸쳐 다음과 같은 기술 스택을 다룹니다:

  • 프론트엔드: Next.js 16 + React 19 + TypeScript
  • 시각화: react-force-graph-2d + d3-force
  • 스타일링: Tailwind CSS 4
  • 데이터: 네이버 키워드 API JSON 파일

1. 요구사항

PM 김도연:

"이준혁 님, 새 프로젝트 하나 시작해야 합니다. 네이버 키워드 데이터를 시각화하는 도구인데요, 마케팅팀에서 키워드 간 연관 관계를 한눈에 파악하고 싶다고 합니다."

"데이터는 네이버 광고 API에서 JSON으로 수집해둔 상태예요. 키워드마다 PC/모바일 검색량, 경쟁 강도, 클릭률 같은 지표가 들어 있고요. 이걸 Force-Directed Graph 형태로 보여주면 됩니다."

"기술 스택은 Next.js와 TypeScript 기반으로 가주시고, 시각화는 react-force-graph-2d를 쓰면 될 것 같아요. 첫 단계에서는 프로젝트 세팅이랑 데이터 구조부터 잡아주세요. 나중에 그래프 빌드, API 라우트, UI 인터랙션까지 순차적으로 확장할 거니까 타입 설계를 처음부터 탄탄하게 가져가면 좋겠습니다."


2. 시니어의 접근 방식

시니어 이준혁 (8년차):

"오케이, 키워드 시각화 프로젝트네. 먼저 생각해볼 게 있어."

"데이터 흐름을 먼저 그려보자. 원본 JSON이 있고, 이걸 그래프 구조로 변환해야 하고, 최종적으로 화면에 렌더링해야 하잖아. 이 세 단계에서 데이터의 모양이 전부 달라. 그러면 타입도 세 겹으로 나눠야 하는 거 아닐까?"

"첫 번째는 Raw 데이터 타입 -- API에서 내려온 JSON 그대로의 형태. 두 번째는 Node 타입 -- 그래프 시각화에 필요한 속성을 가진 노드. 세 번째는 Link 타입 -- 노드 간 연결 정보. 그리고 이것들을 묶는 GraphData 타입."

"여기서 질문 하나. Raw 데이터에는 monthlyPcQcCntmonthlyMobileQcCnt가 따로 있는데, 그래프 노드에서는 totalVolume이라는 필드가 있거든. 왜 합산 필드를 별도로 두는 게 좋을까? 원본 필드 두 개를 그때그때 더해서 쓰면 안 되나?"

"답은 간단해. 시각화 레이어에서 매번 계산하면 코드가 지저분해지고, 정렬이나 필터링할 때도 번거롭거든. 변환 단계에서 한 번 계산해놓으면 이후 로직이 훨씬 깔끔해져."

"또 하나 중요한 건, compIdx 같은 필드. 이게 '높음' | '중간' | '낮음'인데, 이걸 string으로 퉁치면 나중에 오타 하나 때문에 버그 잡느라 시간 날려. 유니온 리터럴 타입으로 확실하게 잡아두는 게 핵심이야."

"자, 그러면 프로젝트 초기화부터 시작하자."


3. 구현

3.1 Next.js 프로젝트 생성 및 의존성 구성

프로젝트는 create-next-app으로 생성한 뒤, 시각화에 필요한 라이브러리를 추가로 설치합니다.

# 프로젝트 생성 (TypeScript, Tailwind CSS, App Router 선택)
npx create-next-app@latest test --typescript --tailwind --app
 
# 시각화 라이브러리 설치
cd test
npm install d3-force react-force-graph-2d
npm install -D @types/d3-force

설치가 완료되면 package.json은 다음과 같은 형태가 됩니다.

// package.json
{
  "name": "test",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "d3-force": "^3.0.0",
    "next": "16.1.4",
    "react": "19.2.3",
    "react-dom": "19.2.3",
    "react-force-graph-2d": "^1.29.0"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/d3-force": "^3.0.10",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.1.4",
    "tailwindcss": "^4",
    "typescript": "5.9.3"
  }
}

각 의존성이 왜 필요한지 짚어보겠습니다:

패키지역할
next 16.1.4App Router 기반의 React 프레임워크. API Route, 서버 컴포넌트 등을 제공
react / react-dom 19.2.3React 19 -- 최신 Concurrent 기능 활용
d3-forceForce-Directed 시뮬레이션 엔진. 노드 간 물리 연산 담당
react-force-graph-2dd3-force를 React 컴포넌트로 감싼 래퍼. Canvas 기반 2D 그래프 렌더링
@types/d3-forced3-force의 TypeScript 타입 정의
tailwindcss ^4유틸리티 퍼스트 CSS 프레임워크
typescript 5.9.3타입 시스템의 근간

이준혁: "react-force-graph-2d가 내부적으로 d3-force를 쓰는데 왜 d3-force를 직접 설치하느냐고? 나중에 그래프 빌드 로직에서 시뮬레이션 파라미터를 직접 제어할 일이 있거든. 래퍼만으로는 세밀한 조정이 안 돼."

3.2 TypeScript 설정 (tsconfig.json)

TypeScript 컴파일러 설정은 프로젝트 전체의 타입 안전성 수준을 결정합니다.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts",
    "**/*.mts"
  ],
  "exclude": ["node_modules"]
}

핵심 설정 항목을 하나씩 살펴봅니다:

"strict": true

이 한 줄이 TypeScript 엄격 모드를 활성화합니다. 내부적으로 다음 옵션들이 전부 켜집니다:

  • strictNullChecks -- nullundefined를 명시적으로 처리해야 함
  • strictFunctionTypes -- 함수 매개변수 타입을 엄격하게 검사
  • strictPropertyInitialization -- 클래스 프로퍼티 초기화 강제
  • noImplicitAny -- 타입을 추론할 수 없으면 에러 발생
  • noImplicitThis -- this의 타입이 불명확하면 에러

이준혁: "strict: true 없이 TypeScript 쓰는 건 안전벨트 안 매고 운전하는 거랑 같아. 처음에는 귀찮아도 나중에 런타임 에러로 고생하는 것보다 백 배 나아."

"paths": { "@/*": ["./src/*"] }

경로 별칭(path alias) 설정입니다. ../../../types/graph 같은 상대경로 대신 @/types/graph로 깔끔하게 임포트할 수 있습니다.

// 경로 별칭 없이 (지옥)
import { GraphData } from '../../../types/graph';
 
// 경로 별칭 사용 (천국)
import { GraphData } from '@/types/graph';

"resolveJsonModule": true

이 옵션이 있어야 .json 파일을 직접 import할 수 있습니다. 이 프로젝트에서는 키워드 원시 데이터가 JSON 파일로 저장되어 있기 때문에 필수적인 설정입니다.

"moduleResolution": "bundler"

Next.js 같은 번들러 환경에 최적화된 모듈 해석 전략입니다. node보다 번들러의 동작 방식에 더 가까워서 exports 필드 등을 올바르게 처리합니다.

3.3 Next.js 설정 (next.config.ts)

// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  /* config options here */
};
 
export default nextConfig;

현재는 기본 설정 그대로입니다. 주목할 점은 설정 파일의 확장자가 .ts라는 것입니다. Next.js 15+부터는 설정 파일도 TypeScript로 작성할 수 있어서, NextConfig 타입의 자동완성과 타입 검사를 받을 수 있습니다.

이준혁: "설정 파일이 비어 있다고 쓸모없는 게 아니야. 나중에 이미지 도메인 허용, webpack 커스텀, 리다이렉트 설정 같은 게 들어갈 자리거든. 처음부터 타입이 걸려 있으니 설정 추가할 때 오타를 못 내."

3.4 도메인 타입 설계 (graph.ts)

이 프로젝트의 핵심이 되는 타입 파일입니다. 데이터의 전체 생명주기를 네 개의 인터페이스로 정의합니다.

// src/types/graph.ts
 
// raw JSON 파일의 각 엔트리 형태
export interface RawKeywordEntry {
  relKeyword: string;
  monthlyPcQcCnt: number;
  monthlyMobileQcCnt: number;
  plAvgDepth: number;
  compIdx: '높음' | '중간' | '낮음';
  monthlyAvePcClkCnt: number;
  monthlyAveMobileClkCnt: number;
  monthlyAvePcCtr: number;
  monthlyAveMobileCtr: number;
}
 
// 그래프 노드
export interface KeywordNode {
  id: string;
  keyword: string;
  totalVolume: number;
  monthlyPcQcCnt: number;
  monthlyMobileQcCnt: number;
  compIdx: '높음' | '중간' | '낮음';
  monthlyAvePcCtr: number;
  monthlyAveMobileCtr: number;
  isSeed: boolean;
  seedCount: number;
  group: number;
}
 
// 그래프 엣지
export interface KeywordLink {
  source: string;
  target: string;
  strength: number;
}
 
// 전체 그래프 데이터
export interface GraphData {
  nodes: KeywordNode[];
  links: KeywordLink[];
}

타입별 설계 의도를 상세히 살펴봅니다.

RawKeywordEntry -- 원시 데이터의 계약서

이 인터페이스는 네이버 키워드 API에서 수집한 JSON 데이터의 형태를 그대로 반영합니다. 실제 원시 데이터 파일(예: data/raw/감자빵.json)의 구조는 다음과 같습니다:

[
  {
    "relKeyword": "감자빵",
    "monthlyPcQcCnt": 2820,
    "monthlyMobileQcCnt": 19000,
    "plAvgDepth": 8,
    "compIdx": "높음",
    "monthlyAvePcClkCnt": 42,
    "monthlyAveMobileClkCnt": 491.4,
    "monthlyAvePcCtr": 1.53,
    "monthlyAveMobileCtr": 2.86
  }
]

각 필드의 의미:

필드의미
relKeyword연관 키워드명
monthlyPcQcCnt월간 PC 검색량
monthlyMobileQcCnt월간 모바일 검색량
plAvgDepth월간 평균 노출 광고수
compIdx경쟁 강도 (높음 / 중간 / 낮음)
monthlyAvePcClkCnt월간 평균 PC 클릭수
monthlyAveMobileClkCnt월간 평균 모바일 클릭수
monthlyAvePcCtr월간 평균 PC 클릭률(%)
monthlyAveMobileCtr월간 평균 모바일 클릭률(%)

이준혁: "compIdx를 왜 string이 아니라 '높음' | '중간' | '낮음'으로 했는지 알겠지? 이렇게 하면 '높은'이라고 오타 내는 순간 컴파일 에러가 나거든. 런타임 버그를 컴파일 타임으로 끌어오는 거야. TypeScript의 가장 강력한 무기 중 하나지."

KeywordNode -- 시각화를 위한 변환된 노드

RawKeywordEntry에서 KeywordNode로 변환할 때 다음과 같은 변화가 생깁니다:

  1. id 추가 -- 그래프 라이브러리가 노드를 식별하는 고유 키
  2. keyword 추가 -- relKeyword를 좀 더 범용적인 이름으로 매핑
  3. totalVolume 추가 -- PC + 모바일 검색량의 합산값. 노드 크기를 결정하는 핵심 지표
  4. isSeed 추가 -- 시드 키워드(검색의 출발점) 여부. 그래프에서 중심 노드를 구분
  5. seedCount 추가 -- 이 키워드가 몇 개의 시드 키워드와 연관되는지. 교차 연관도 파악
  6. group 추가 -- 클러스터링을 위한 그룹 번호. 색상 구분에 사용
  7. 불필요한 필드 제거 -- plAvgDepth, monthlyAvePcClkCnt, monthlyAveMobileClkCnt는 시각화에 직접 쓰이지 않으므로 제외

이준혁: "Raw 타입과 Node 타입을 왜 분리하느냐고? 단일 책임 원칙이야. Raw 타입은 외부 API와의 계약이고, Node 타입은 시각화 레이어의 요구사항을 반영한 거야. API 응답 형태가 바뀌어도 Node 타입은 영향을 안 받고, 반대도 마찬가지지."

KeywordLink -- 노드 간 연결

export interface KeywordLink {
  source: string;  // 출발 노드 id
  target: string;  // 도착 노드 id
  strength: number; // 연결 강도 (0~1)
}

strength 필드는 두 키워드가 얼마나 강하게 연관되어 있는지를 나타냅니다. 이 값에 따라 그래프에서 엣지의 굵기와 노드 간 거리가 결정됩니다. 같은 원시 데이터 파일에 함께 등장하는 키워드들이 링크로 연결되며, 검색량 비율 등을 기반으로 강도가 계산됩니다.

GraphData -- 최종 조립

export interface GraphData {
  nodes: KeywordNode[];
  links: KeywordLink[];
}

react-force-graph-2d가 요구하는 데이터 형태와 일치합니다. nodes 배열과 links 배열로 구성된 단순한 구조이지만, 이 인터페이스가 있음으로써 컴포넌트에 전달되는 데이터의 형태가 항상 보장됩니다.

3.5 프로젝트 디렉토리 구조

현재까지의 프로젝트 구조는 다음과 같습니다:

test/
├── data/
│   └── raw/            # 네이버 API 원시 JSON 파일들
│       ├── 감자빵.json
│       ├── 강황.json
│       └── ...
├── src/
│   ├── app/            # Next.js App Router
│   ├── lib/            # 유틸리티 및 비즈니스 로직
│   └── types/
│       └── graph.ts    # 도메인 타입 정의
├── package.json
├── tsconfig.json
└── next.config.ts

이준혁: "타입 파일을 src/types/에 따로 빼놓은 이유가 있어. 컴포넌트 파일 안에 인터페이스를 같이 넣으면 처음엔 편한데, 다른 파일에서 같은 타입을 쓰려면 결국 옮겨야 하거든. 공유 타입은 처음부터 독립된 위치에 두는 습관을 들여야 해."


4. 핵심 포인트 정리

  • TypeScript strict: true는 선택이 아니라 기본값이다. 엄격 모드가 꺼져 있으면 null 참조, 암시적 any 같은 런타임 오류를 컴파일 타임에 잡을 수 없다.

  • 경로 별칭(@/*)은 코드 가독성과 리팩토링 용이성을 동시에 확보한다. 디렉토리 구조가 바뀌어도 별칭 매핑만 수정하면 된다.

  • 데이터 흐름의 각 단계에 맞는 타입을 분리 설계한다. Raw(외부 API 계약) -> Node/Link(내부 시각화 요구사항) -> GraphData(컴포넌트 인터페이스) 순서로 변환되며, 각 타입은 독립적인 책임을 가진다.

  • 유니온 리터럴 타입('높음' | '중간' | '낮음')으로 도메인 제약 조건을 타입 레벨에서 강제한다. 런타임 유효성 검사 없이도 잘못된 값이 들어오는 것을 방지한다.

  • 합산 필드(totalVolume)는 변환 단계에서 미리 계산해둔다. 시각화 레이어에서 반복 계산을 피하고, 정렬 및 필터링 로직을 단순화한다.

  • 공유 타입은 src/types/ 디렉토리에 독립적으로 관리한다. 여러 레이어(API, 비즈니스 로직, 컴포넌트)에서 참조하는 타입이 한 곳에 모여 있으면 변경 추적이 쉽다.


5. 다음 Step 예고

Step 2: 토큰 버킷 Rate Limiter 구현

네이버 키워드 API에 요청을 보낼 때, 무제한으로 호출하면 API 서버에서 차단당합니다. 다음 단계에서는 토큰 버킷(Token Bucket) 알고리즘을 사용한 Rate Limiter를 직접 구현하여, API 호출 빈도를 안전하게 제어하는 방법을 다룹니다. 비동기 큐 관리와 함께 TypeScript의 제네릭을 활용한 범용적인 설계까지 살펴볼 예정입니다.