Next.js API Routes 및 SSR 데이터 타입 완벽 가이드

Next.js 프로젝트에서 가장 흔히 발생하는 버그 중 하나는 서버에서 보내주는 데이터의 구조와 클라이언트에서 기대하는 구조가 일치하지 않을 때 발생합니다. TypeScript를 활용하여 서버와 클라이언트 사이의 '타입 계약(Type Contract)'을 완벽하게 체결하는 방법을 정리했습니다.
1. API Routes: 서버리스 함수의 타입 안전성
Next.js의 API Routes는 NextApiRequest와 NextApiResponse를 통해 강력한 타입 지원을 제공합니다.
1.1. 응답 데이터 구조 정의
먼저 성공 응답과 에러 응답의 구조를 인터페이스로 정의합니다.
// types/api.ts
export interface User {
id: string;
name: string;
email: string;
}
export interface ApiError {
message: string;
code?: string;
}
1.2. 핸들러 타입 적용
res.status().json()에 제네릭을 사용하여 전달할 데이터의 타입을 강제할 수 있습니다.
// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { User, ApiError } from '@/types/api';
export default function handler(
req: NextApiRequest,
res: NextApiResponse<User | ApiError> // 성공 또는 에러 타입 지정
) {
const { id } = req.query;
if (req.method === 'GET') {
// DB에서 유저를 찾는 로직 (가정)
const user = { id: String(id), name: '홍길동', email: 'hong@example.com' };
if (!user) {
return res.status(404).json({ message: '유저를 찾을 수 없습니다.' });
}
return res.status(200).json(user);
}
res.status(405).json({ message: '허용되지 않은 메서드입니다.' });
}
2. SSR (Server-Side Rendering): Props 추론의 기술
getServerSideProps를 사용할 때 가장 큰 실수는 서버 함수에서 반환한 Props의 타입을 컴포넌트에서 수동으로 한 번 더 정의하는 것입니다. 이는 중복이며 실수의 원인이 됩니다.
2.1. GetServerSideProps와 Context 타입
서버 사이드 로직에서는 GetServerSideProps 타입을 사용하여 리턴 타입을 보장합니다.
import { GetServerSideProps } from 'next';
interface Post {
id: number;
title: string;
content: string;
}
export const getServerSideProps: GetServerSideProps<{
post: Post;
serverTime: string;
}> = async (context) => {
const { id } = context.params!; // URL 파라미터 접근
const res = await fetch(`https://api.example.com/posts/${id}`);
const post: Post = await res.json();
return {
props: {
post,
serverTime: new Date().toISOString(),
},
};
};
2.2. InferGetServerSidePropsType으로 자동 추론
컴포넌트에서는 InferGetServerSidePropsType을 사용하여 서버 함수에서 정의한 props 타입을 그대로 가져옵니다.
import { InferGetServerSidePropsType } from 'next';
// 별도의 Props 인터페이스를 만들 필요가 없습니다!
export default function PostPage({
post,
serverTime,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<footer>로드된 시간: {serverTime}</footer>
</article>
);
}
3. 고급 기술: 공유 타입 전략 (Shared Types)
프론트엔드와 백엔드가 같은 리포지토리에 있는 Next.js의 특성을 활용하여 타입을 공유하는 것이 핵심입니다.
3.1. DTO (Data Transfer Object) 정의
API 응답 전용 타입을 따로 관리하여 클라이언트와 서버 양측에서 import 하여 사용합니다.
// types/dto.ts
export type CreateUserRequest = {
name: string;
email: string;
};
export type UserResponse = {
id: string;
createdAt: string;
} & CreateUserRequest;
3.2. 클라이언트 사이드 Fetcher와 연동
useSWR이나 TanStack Query를 사용할 때 API Route에서 정의한 타입을 제네릭으로 넘겨주면 완벽한 자동 완성을 경험할 수 있습니다.
import useSWR from 'swr';
import { UserResponse } from '@/types/dto';
const fetcher = (url: string) => fetch(url).then(res => res.json());
function UserProfile({ userId }: { userId: string }) {
// 제네릭을 통해 data가 UserResponse 타입임을 보장
const { data, error } = useSWR<UserResponse>(`/api/users/${userId}`, fetcher);
if (!data) return <div>로딩 중...</div>;
return <div>사용자 이름: {data.name}</div>;
}
4. 런타임 검증: Zod와의 결합
TypeScript는 컴파일 타임의 타입만 체크합니다. 실제 API로 들어오는 데이터가 올바른지 확인하려면 Zod와 같은 라이브러리를 API Routes에서 사용하는 것을 권장합니다.
import { z } from 'zod';
const UserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
// 런타임에 데이터 구조 검증
const validatedData = UserSchema.parse(req.body);
// 검증 성공 시 validatedData는 자동으로 타입이 추론됨
res.status(200).json({ success: true, data: validatedData });
} catch (error) {
res.status(400).json({ message: '잘못된 데이터 형식입니다.' });
}
}
요약
구분핵심 도구 / 기술효과
| API 응답 | NextApiResponse<T> | 서버에서 응답하는 JSON 구조 강제 |
| SSR Props | InferGetServerSidePropsType | 서버 데이터와 컴포넌트 Props 타입 동기화 |
| 타입 공유 | Common Types / DTO | 중복 선언 방지 및 API 변경 대응 용이 |
| 데이터 검증 | Zod / Yup | 런타임 환경에서의 타입 안정성 확보 |
서버에서 클라이언트로 흐르는 모든 데이터에 타입을 입히는 작업은 초기 비용이 들지만, 결과적으로 **"배포 후 에러"**를 90% 이상 줄여주는 최고의 투자입니다.