본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 11. 1. · 13 Views
tRPC 타입 세이프 API 설계 완벽 가이드
tRPC를 활용한 타입 안전한 API 설계 방법을 초급 개발자도 쉽게 이해할 수 있도록 설명합니다. 실무에서 바로 적용 가능한 패턴과 예제 코드를 통해 엔드-투-엔드 타입 안전성을 구현하는 방법을 배워보세요.
목차
- tRPC란_무엇인가
- Router_생성하기
- Query_vs_Mutation
- Input_Validation
- Context_활용
- Middleware_패턴
- Error_Handling
- Client_설정
- React_Hooks_통합
- Subscriptions
1. tRPC란 무엇인가
시작하며
여러분이 React나 Next.js로 프론트엔드를 개발하고, Node.js로 백엔드를 만들 때 이런 상황을 겪어본 적 있나요? API 엔드포인트의 응답 타입이 변경되었는데, 프론트엔드 코드에서는 이를 전혀 알 수 없어서 런타임 에러가 발생하는 상황 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. REST API나 GraphQL을 사용하면 프론트엔드와 백엔드 사이의 타입 안전성이 보장되지 않습니다.
타입 정의 파일을 수동으로 동기화해야 하고, API 스펙이 변경될 때마다 문서를 확인하고 코드를 수정해야 합니다. 바로 이럴 때 필요한 것이 tRPC입니다.
tRPC는 TypeScript의 타입 추론을 활용하여 백엔드에서 정의한 API 타입이 자동으로 프론트엔드에 전달되어, 컴파일 타임에 타입 안전성을 보장합니다.
개요
간단히 말해서, tRPC는 TypeScript를 사용하는 풀스택 애플리케이션에서 타입 안전한 API를 만들 수 있게 해주는 라이브러리입니다. 여러분이 Next.js나 React 애플리케이션을 만들 때, 백엔드 API의 타입을 프론트엔드에서 자동으로 추론할 수 있다면 얼마나 편할까요?
tRPC는 바로 이것을 가능하게 합니다. 예를 들어, 백엔드에서 User 객체의 필드를 추가하거나 제거하면, 프론트엔드에서도 즉시 타입 에러가 발생하여 변경사항을 놓치지 않을 수 있습니다.
기존 REST API에서는 OpenAPI 스펙을 작성하고 코드 생성 도구를 사용해야 했다면, tRPC에서는 별도의 스펙 정의 없이 TypeScript 코드만으로 모든 것이 해결됩니다. tRPC의 핵심 특징은 엔드-투-엔드 타입 안전성, 자동 완성 지원, 그리고 최소한의 보일러플레이트입니다.
이러한 특징들이 개발 생산성을 크게 향상시키고, 런타임 에러를 사전에 방지할 수 있게 해줍니다.
코드 예제
// 백엔드: API 라우터 정의
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
// 사용자 조회 프로시저
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
return { id: input.id, name: '홍길동', email: 'hong@example.com' };
}),
});
export type AppRouter = typeof appRouter;
설명
이것이 하는 일: tRPC는 백엔드에서 정의한 API 프로시저의 타입 정보를 프론트엔드가 직접 참조할 수 있게 하여, 별도의 타입 정의나 코드 생성 없이 완벽한 타입 안전성을 제공합니다. 첫 번째로, initTRPC.create()로 tRPC 인스턴스를 생성합니다.
이것은 여러분의 API를 구성하는 빌더 역할을 합니다. 여기서 context 타입이나 메타 정보를 설정할 수 있습니다.
그 다음으로, t.router()를 사용하여 API 라우터를 정의합니다. 각 프로시저(엔드포인트)는 .input()으로 입력 스키마를 정의하고, .query() 또는 .mutation()으로 실행 로직을 구현합니다.
이때 Zod 스키마를 사용하여 런타임 검증도 함께 수행됩니다. 마지막으로, AppRouter 타입을 export하면 프론트엔드에서 이 타입을 import하여 완벽한 타입 추론을 받을 수 있습니다.
백엔드 코드가 변경되면 프론트엔드에서 즉시 타입 에러가 발생하여, API 변경사항을 놓치는 일이 없습니다. 여러분이 이 코드를 사용하면 API 문서를 별도로 작성할 필요가 없고, Postman 같은 도구 없이도 프론트엔드 코드에서 자동 완성을 통해 API를 사용할 수 있습니다.
또한 타입 불일치로 인한 런타임 에러가 사전에 방지되어 버그가 크게 줄어듭니다.
실전 팁
💡 tRPC는 모노레포 구조에서 가장 강력합니다. 백엔드와 프론트엔드가 같은 저장소에 있어야 타입을 직접 import할 수 있기 때문입니다.
💡 REST API를 완전히 대체하는 것이 아닙니다. 외부 API를 제공해야 한다면 여전히 REST나 GraphQL이 필요할 수 있습니다.
💡 Zod 스키마는 런타임 검증뿐만 아니라 타입 추론에도 사용되므로, 정확하게 정의하는 것이 중요합니다.
💡 프로덕션 환경에서는 tRPC 서버를 별도의 Express나 Fastify 서버로 래핑하여 사용하는 것이 일반적입니다.
2. Router 생성하기
시작하며
여러분이 백엔드 API를 만들 때 각 기능별로 엔드포인트를 어떻게 구조화해야 할지 고민해본 적 있나요? 사용자 관리, 게시물 관리, 댓글 관리 등 도메인별로 API를 깔끔하게 분리하고 싶지만, 코드가 점점 복잡해지는 상황 말이죠.
tRPC의 Router는 이런 문제를 해결하는 핵심 개념입니다. Router를 사용하면 API를 논리적인 단위로 그룹화하고, 필요에 따라 중첩시켜 계층적 구조를 만들 수 있습니다.
실제 프로젝트에서는 단일 Router가 아닌 여러 개의 작은 Router를 만들고 이를 조합하는 방식이 훨씬 유지보수하기 좋습니다. 지금부터 Router를 효과적으로 구성하는 방법을 배워보겠습니다.
개요
간단히 말해서, Router는 여러 개의 프로시저(API 엔드포인트)를 그룹화하는 컨테이너입니다. Router를 사용하면 관련된 기능들을 하나로 묶어 코드를 체계적으로 관리할 수 있습니다.
예를 들어, users 관련 모든 API(생성, 조회, 수정, 삭제)를 userRouter에 모아두고, posts 관련 API는 postRouter에 분리하는 식입니다. 기존 Express나 Fastify에서는 각 라우트를 개별 파일로 분리하고 수동으로 import해야 했다면, tRPC에서는 Router를 중첩시켜 자동으로 타입이 병합됩니다.
Router의 핵심 특징은 중첩 가능성, 타입 안전한 병합, 그리고 네임스페이스 지원입니다. 이를 통해 대규모 애플리케이션에서도 API 구조를 명확하게 유지할 수 있습니다.
코드 예제
// 사용자 관련 Router
const userRouter = t.router({
list: t.procedure.query(() => [{ id: '1', name: '사용자1' }]),
getById: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => ({ id: input.id, name: '홍길동' })),
});
// 게시물 관련 Router
const postRouter = t.router({
list: t.procedure.query(() => [{ id: '1', title: '첫 게시물' }]),
});
// 메인 Router: 여러 Router를 병합
export const appRouter = t.router({
user: userRouter,
post: postRouter,
});
설명
이것이 하는 일: Router는 여러 프로시저를 논리적 단위로 묶고, 다른 Router와 조합하여 전체 API 구조를 구성합니다. 첫 번째로, 도메인별로 작은 Router를 만듭니다.
userRouter는 사용자 관련 모든 API를 포함하고, postRouter는 게시물 관련 API를 포함합니다. 각 Router는 독립적으로 개발하고 테스트할 수 있어 모듈화가 쉽습니다.
그 다음으로, 메인 appRouter에서 이 작은 Router들을 병합합니다. user: userRouter 형태로 추가하면, 프론트엔드에서는 trpc.user.list() 처럼 네임스페이스로 접근할 수 있습니다.
이렇게 하면 API 이름 충돌을 방지하고 구조를 명확하게 만들 수 있습니다. 타입 추론도 자동으로 작동합니다.
userRouter의 프로시저들이 appRouter에 병합될 때, TypeScript는 전체 Router의 타입을 정확하게 추론합니다. 프론트엔드에서 AppRouter 타입을 import하면 모든 중첩된 프로시저의 타입 정보를 얻을 수 있습니다.
여러분이 이 패턴을 사용하면 대규모 프로젝트에서도 코드를 파일별로 분리하여 관리할 수 있고, 팀원들이 각자 다른 Router를 개발해도 충돌 없이 병합할 수 있습니다. 또한 특정 도메인의 API만 필요한 경우 해당 Router만 재사용할 수도 있습니다.
실전 팁
💡 Router는 무한히 중첩할 수 있지만, 일반적으로 2-3단계 정도가 적절합니다. 너무 깊으면 프론트엔드에서 호출 경로가 길어져 불편합니다.
💡 각 Router는 별도 파일로 분리하고, index.ts에서 병합하는 구조가 일반적입니다. 예: routers/user.ts, routers/post.ts, routers/index.ts
💡 공통 로직(인증, 로깅 등)은 Middleware로 만들어 여러 Router에서 재사용하세요. 각 Router마다 중복 코드를 작성하지 마세요.
💡 Router 이름은 프론트엔드 호출 경로에 그대로 반영되므로, 명확하고 간결한 이름을 사용하세요.
💡 개발 중에는 Router 구조를 자주 변경하게 되는데, 타입 추론 덕분에 변경 영향을 IDE에서 즉시 확인할 수 있습니다.
3. Query vs Mutation
시작하며
여러분이 API를 설계할 때 GET 요청과 POST 요청을 어떻게 구분하시나요? REST에서는 HTTP 메서드로 구분하지만, tRPC에서는 Query와 Mutation이라는 개념을 사용합니다.
처음에는 이 둘의 차이가 단순히 이름만 다른 것처럼 보일 수 있습니다. 하지만 Query와 Mutation을 올바르게 구분하면 캐싱, 에러 처리, 재시도 로직 등에서 큰 차이를 만들어냅니다.
특히 프론트엔드에서 React Query와 통합할 때, Query와 Mutation의 구분은 매우 중요해집니다. 잘못 선택하면 불필요한 네트워크 요청이 발생하거나, 데이터 일관성 문제가 생길 수 있습니다.
개요
간단히 말해서, Query는 데이터를 읽기만 하는 작업이고, Mutation은 데이터를 변경하는 작업입니다. Query는 조회, 검색, 목록 가져오기 같은 읽기 전용 작업에 사용합니다.
예를 들어, 사용자 목록 조회, 게시물 검색, 댓글 가져오기 등이 Query에 해당합니다. Query는 멱등성을 가져야 합니다.
즉, 같은 입력으로 여러 번 호출해도 같은 결과를 반환해야 합니다. 반면 Mutation은 생성, 수정, 삭제 같은 쓰기 작업에 사용합니다.
기존에 POST, PUT, PATCH, DELETE 요청으로 처리하던 모든 작업이 Mutation입니다. 이 구분의 핵심 특징은 캐싱 전략, HTTP 메서드 매핑, 그리고 에러 재시도 정책입니다.
Query는 자동으로 캐시되고 백그라운드에서 재실행될 수 있지만, Mutation은 명시적으로 실행해야 하며 실패 시 신중한 재시도가 필요합니다.
코드 예제
// Query: 데이터 읽기 (멱등성)
const userRouter = t.router({
// 사용자 목록 조회
list: t.procedure.query(async () => {
return await db.user.findMany();
}),
// 특정 사용자 조회
getById: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.user.findUnique({ where: { id: input.id } });
}),
});
// Mutation: 데이터 변경 (비멱등성)
const userMutationRouter = t.router({
// 사용자 생성
create: t.procedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ input }) => {
return await db.user.create({ data: input });
}),
// 사용자 수정
update: t.procedure
.input(z.object({ id: z.string(), name: z.string() }))
.mutation(async ({ input }) => {
return await db.user.update({
where: { id: input.id },
data: { name: input.name },
});
}),
});
설명
이것이 하는 일: Query와 Mutation은 API의 의도를 명확하게 표현하고, 클라이언트가 적절한 캐싱과 재시도 전략을 사용할 수 있게 합니다. 첫 번째로, Query는 .query() 메서드를 사용하여 정의합니다.
Query는 GET 요청으로 매핑되며, URL 파라미터로 입력값이 전달됩니다. 프론트엔드에서는 useQuery 훅을 사용하여 자동 캐싱, 백그라운드 리프레시, stale 데이터 관리 등의 이점을 얻습니다.
그 다음으로, Mutation은 .mutation() 메서드를 사용합니다. Mutation은 POST 요청으로 매핑되며, 요청 본문으로 입력값이 전달됩니다.
프론트엔드에서는 useMutation 훅을 사용하여 로딩 상태, 에러 처리, 성공 콜백 등을 관리합니다. 왜 이렇게 구분해야 할까요?
예를 들어, 사용자 목록 조회를 Mutation으로 만들면 매번 새로운 요청이 발생하여 성능이 떨어집니다. 반대로 사용자 생성을 Query로 만들면 의도치 않게 중복 실행되어 같은 사용자가 여러 번 생성될 수 있습니다.
여러분이 이 구분을 올바르게 사용하면 프론트엔드에서 최적화된 캐싱 전략을 자동으로 적용받고, 사용자 경험이 크게 개선됩니다. 또한 코드만 보고도 어떤 작업이 데이터를 변경하는지 명확하게 알 수 있어 유지보수가 쉬워집니다.
실전 팁
💡 Query는 부작용(side effect)이 없어야 합니다. 조회 API에서 로그를 남기는 것은 괜찮지만, 데이터를 수정하면 안 됩니다.
💡 검색이나 필터링은 항상 Query로 만드세요. 입력값이 복잡해도 Query를 사용하는 것이 맞습니다.
💡 파일 업로드, 결제 처리 같은 중요한 작업은 반드시 Mutation으로 만들어야 합니다. 재시도가 안전하게 처리되어야 하기 때문입니다.
💡 Mutation 후에는 관련된 Query를 무효화(invalidate)하여 캐시를 업데이트해야 합니다. 그렇지 않으면 화면에 오래된 데이터가 표시됩니다.
💡 HTTP 메서드를 직접 지정할 수는 없습니다. Query는 항상 GET, Mutation은 항상 POST입니다. RESTful 스타일이 필요하다면 tRPC가 아닌 다른 방법을 고려하세요.
4. Input Validation
시작하며
여러분이 API를 만들 때 가장 신경 써야 할 부분 중 하나가 바로 입력값 검증입니다. 사용자가 이상한 값을 보내면 어떻게 되나요?
데이터베이스에 잘못된 데이터가 저장되거나, 심한 경우 보안 취약점이 될 수 있습니다. 전통적인 방법으로는 각 엔드포인트마다 if문으로 검증 로직을 작성했습니다.
하지만 이는 반복적이고, 실수하기 쉽고, 타입 안전성도 보장되지 않습니다. tRPC는 Zod라는 강력한 스키마 검증 라이브러리와 통합되어 있습니다.
Zod 스키마는 런타임 검증과 동시에 TypeScript 타입 추론까지 제공하여, 한 번의 정의로 두 가지 이점을 모두 얻을 수 있습니다.
개요
간단히 말해서, Input Validation은 Zod 스키마를 사용하여 API 입력값의 형태와 제약 조건을 정의하고 자동으로 검증하는 것입니다. .input() 메서드에 Zod 스키마를 전달하면, tRPC가 자동으로 입력값을 검증합니다.
예를 들어, 이메일 형식이 올바른지, 문자열 길이가 적절한지, 필수 필드가 있는지 등을 자동으로 확인합니다. 검증에 실패하면 클라이언트에게 명확한 에러 메시지가 반환됩니다.
기존에는 express-validator나 joi 같은 별도 라이브러리를 사용하고, 타입 정의는 따로 작성해야 했다면, Zod는 하나의 스키마로 모든 것을 해결합니다. Zod의 핵심 특징은 타입 추론, 체이닝 가능한 API, 그리고 커스텀 검증 로직입니다.
이를 통해 복잡한 비즈니스 규칙도 깔끔하게 표현할 수 있습니다.
코드 예제
import { z } from 'zod';
// 사용자 생성 스키마
const createUserSchema = z.object({
name: z.string().min(2, '이름은 2글자 이상이어야 합니다'),
email: z.string().email('올바른 이메일 형식이 아닙니다'),
age: z.number().int().min(18, '만 18세 이상만 가입 가능합니다').optional(),
role: z.enum(['user', 'admin']).default('user'),
});
export const userRouter = t.router({
create: t.procedure
.input(createUserSchema)
.mutation(async ({ input }) => {
// input은 자동으로 타입 추론됨
// input.name: string, input.email: string, input.age?: number
return await db.user.create({ data: input });
}),
});
설명
이것이 하는 일: Zod 스키마는 입력값의 구조, 타입, 제약 조건을 선언적으로 정의하고, tRPC가 이를 자동으로 검증하여 타입 안전성을 보장합니다. 첫 번째로, z.object()로 객체 스키마를 정의합니다.
각 필드는 z.string(), z.number() 같은 기본 타입으로 시작하여, .min(), .max(), .email() 같은 검증 규칙을 체이닝으로 추가합니다. 이 과정에서 에러 메시지도 함께 정의할 수 있어, 사용자에게 친절한 피드백을 제공할 수 있습니다.
그 다음으로, .optional()이나 .default()로 선택적 필드를 정의합니다. optional()은 필드가 없어도 되고, default()는 값이 없을 때 기본값을 사용합니다.
이러한 정보는 TypeScript 타입에도 정확하게 반영되어, 프로시저 내부에서도 안전하게 사용할 수 있습니다. z.enum()으로 열거형을 정의하면 허용된 값만 받을 수 있습니다.
role 필드는 'user'나 'admin'만 허용되며, 다른 값이 오면 자동으로 거부됩니다. 이는 문자열 리터럴 타입으로 추론되어 타입 안전성이 극대화됩니다.
여러분이 이 방식을 사용하면 if문을 수십 개 작성할 필요 없이, 선언적인 스키마만으로 모든 검증을 처리할 수 있습니다. 또한 스키마를 재사용하여 여러 프로시저에서 같은 검증 로직을 공유할 수 있고, 테스트하기도 훨씬 쉬워집니다.
실전 팁
💡 복잡한 검증 로직은 .refine()이나 .superRefine()을 사용하세요. 예를 들어, 두 필드를 비교하거나 비동기 검증이 필요할 때 유용합니다.
💡 스키마는 별도 파일로 분리하여 관리하세요. 예: schemas/user.ts. 프론트엔드에서도 같은 스키마를 재사용할 수 있습니다.
💡 에러 메시지는 한국어로 작성하되, 일관된 톤을 유지하세요. "~이어야 합니다" 스타일로 통일하면 좋습니다.
💡 배열이나 중첩 객체도 검증 가능합니다. z.array(z.string())나 z.object({ user: z.object({ name: z.string() }) }) 같은 형태로 작성하세요.
💡 개발 환경에서는 .safeParse()로 스키마를 테스트해보세요. 어떤 에러가 발생하는지 미리 확인할 수 있습니다.
5. Context 활용
시작하며
여러분이 API를 만들 때 모든 엔드포인트에서 공통으로 필요한 정보가 있지 않나요? 예를 들어, 현재 로그인한 사용자 정보, 데이터베이스 연결, 로거 인스턴스 같은 것들 말이죠.
이런 공통 정보를 각 프로시저마다 전달하려면 코드가 중복되고 관리하기 어려워집니다. 또한 요청별로 다른 정보(예: 인증된 사용자)를 어떻게 안전하게 전달할 수 있을까요?
tRPC의 Context는 바로 이런 문제를 해결합니다. Context는 모든 프로시저에서 접근할 수 있는 공유 객체로, 요청마다 동적으로 생성되어 요청별 정보를 안전하게 관리할 수 있습니다.
개요
간단히 말해서, Context는 모든 프로시저에서 접근 가능한 공유 데이터 저장소로, 데이터베이스, 인증 정보, 로거 등을 담습니다. Context는 각 요청마다 생성되는 객체입니다.
예를 들어, 요청 헤더에서 인증 토큰을 추출하여 사용자를 식별하고, 그 정보를 Context에 담으면 모든 프로시저에서 ctx.user로 접근할 수 있습니다. 이렇게 하면 인증 로직을 한 곳에서만 작성하면 됩니다.
기존에는 Express 미들웨어로 req 객체에 정보를 추가했다면, tRPC에서는 createContext 함수에서 정보를 생성하고 타입 안전하게 전달합니다. Context의 핵심 특징은 요청별 격리, 타입 안전성, 그리고 의존성 주입입니다.
이를 통해 테스트하기 쉽고, 확장 가능한 API 구조를 만들 수 있습니다.
코드 예제
import { inferAsyncReturnType } from '@trpc/server';
import * as trpcNext from '@trpc/server/adapters/next';
// Context 생성 함수
export async function createContext({ req, res }: trpcNext.CreateNextContextOptions) {
// 인증 토큰에서 사용자 추출
const token = req.headers.authorization?.replace('Bearer ', '');
const user = token ? await getUserFromToken(token) : null;
return {
req,
res,
db: prisma, // 데이터베이스 인스턴스
user, // 인증된 사용자
};
}
export type Context = inferAsyncReturnType<typeof createContext>;
// tRPC 인스턴스에 Context 타입 지정
const t = initTRPC.context<Context>().create();
// 프로시저에서 Context 사용
export const protectedRouter = t.router({
getMyProfile: t.procedure.query(({ ctx }) => {
// ctx.user는 타입 안전하게 접근 가능
if (!ctx.user) throw new Error('인증 필요');
return ctx.user;
}),
});
설명
이것이 하는 일: Context는 요청별 공통 정보를 중앙 집중식으로 관리하고, 모든 프로시저에 타입 안전하게 전달하여 코드 중복을 제거합니다. 첫 번째로, createContext 함수를 정의합니다.
이 함수는 각 요청마다 실행되며, req와 res 객체를 받아서 필요한 정보를 추출합니다. 예를 들어, Authorization 헤더에서 JWT 토큰을 파싱하고, 토큰을 검증하여 사용자 정보를 가져옵니다.
비동기 작업도 가능하므로 데이터베이스 조회도 할 수 있습니다. 그 다음으로, inferAsyncReturnType으로 Context 타입을 추론합니다.
이렇게 하면 createContext 함수가 반환하는 객체의 타입이 자동으로 Context 타입이 되어, 별도로 인터페이스를 작성할 필요가 없습니다. 함수 시그니처를 변경하면 타입도 자동으로 업데이트됩니다.
initTRPC.context<Context>()로 tRPC 인스턴스에 Context 타입을 지정하면, 모든 프로시저의 ({ ctx }) 매개변수가 정확한 타입을 가지게 됩니다. IDE에서 ctx.user를 입력하면 자동 완성이 제공되고, 타입 체크도 정확하게 작동합니다.
여러분이 이 패턴을 사용하면 인증, 로깅, 데이터베이스 접근 같은 공통 로직을 한 곳에서 관리할 수 있습니다. 또한 테스트할 때 Context를 목(mock) 객체로 교체하기 쉬워져, 유닛 테스트가 훨씬 간단해집니다.
실전 팁
💡 Context에는 요청별로 다른 정보만 담으세요. 모든 요청에서 동일한 정보(예: 환경 변수)는 Context 외부에 전역 변수로 관리하는 것이 효율적입니다.
💡 인증 실패 시에도 Context를 생성해야 합니다. user를 null로 설정하고, 보호된 프로시저에서 검증하는 방식이 일반적입니다.
💡 Context 생성 중 에러가 발생하면 모든 프로시저가 실행되지 않습니다. 따라서 에러 처리를 신중하게 해야 합니다.
💡 Next.js 외에도 Express, Fastify, Standalone 어댑터마다 createContext 시그니처가 다릅니다. 공식 문서를 확인하세요.
💡 Context에 너무 많은 로직을 넣지 마세요. 복잡한 작업은 Middleware로 분리하는 것이 좋습니다.
6. Middleware 패턴
시작하며
여러분이 여러 API에서 공통적으로 실행해야 하는 로직이 있을 때 어떻게 하시나요? 예를 들어, 관리자 권한 확인, 로깅, 성능 측정 같은 작업은 많은 엔드포인트에서 반복됩니다.
각 프로시저마다 같은 코드를 복사-붙여넣기하면 유지보수가 어렵고, 실수하기 쉽습니다. 한 곳을 수정하면 다른 모든 곳도 수정해야 하는 번거로움이 있죠.
tRPC의 Middleware는 이런 반복 로직을 재사용 가능한 단위로 분리하는 강력한 패턴입니다. Middleware를 사용하면 Context를 확장하거나, 실행 전후에 로직을 추가하거나, 에러를 처리하는 등 다양한 작업을 깔끔하게 구현할 수 있습니다.
개요
간단히 말해서, Middleware는 프로시저 실행 전후에 실행되는 재사용 가능한 로직으로, Context 확장, 검증, 로깅 등에 사용됩니다. Middleware는 t.procedure.use()로 연결되며, 체이닝 방식으로 여러 개를 조합할 수 있습니다.
예를 들어, 먼저 인증 Middleware로 사용자를 확인하고, 그 다음 권한 Middleware로 관리자인지 검증하는 식으로 구성할 수 있습니다. 기존 Express 미들웨어와 비슷하지만, tRPC Middleware는 타입 안전성을 제공하고 Context를 안전하게 확장할 수 있다는 점에서 더 강력합니다.
Middleware의 핵심 특징은 Context 확장, 체이닝 가능성, 그리고 타입 추론입니다. 이를 통해 복잡한 비즈니스 로직을 모듈화하고, 재사용성을 극대화할 수 있습니다.
코드 예제
// 인증 Middleware
const isAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '로그인이 필요합니다' });
}
// Context에 user가 반드시 있음을 타입으로 보장
return next({
ctx: {
...ctx,
user: ctx.user, // 이제 user는 non-null 타입
},
});
});
// 관리자 권한 확인 Middleware
const isAdmin = t.middleware(async ({ ctx, next }) => {
if (!ctx.user || ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN', message: '관리자 권한이 필요합니다' });
}
return next({ ctx });
});
// 보호된 프로시저 생성
const protectedProcedure = t.procedure.use(isAuthed);
const adminProcedure = t.procedure.use(isAuthed).use(isAdmin);
// 사용 예시
export const adminRouter = t.router({
deleteUser: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(({ ctx, input }) => {
// ctx.user는 타입상 반드시 존재하며, admin임이 보장됨
return db.user.delete({ where: { id: input.id } });
}),
});
설명
이것이 하는 일: Middleware는 프로시저가 실행되기 전에 공통 로직을 수행하고, 필요시 Context를 확장하거나 에러를 발생시켜 실행을 중단합니다. 첫 번째로, t.middleware()로 Middleware 함수를 정의합니다.
함수는 { ctx, next } 매개변수를 받는데, ctx는 현재 Context이고, next()는 다음 단계(다음 Middleware 또는 프로시저)를 실행하는 함수입니다. next()를 호출하지 않으면 프로시저가 실행되지 않습니다.
그 다음으로, 인증 체크를 수행합니다. ctx.user가 없으면 UNAUTHORIZED 에러를 발생시켜 즉시 요청을 거부합니다.
만약 인증에 성공하면 next()를 호출하되, Context를 확장하여 user 필드가 non-null 타입임을 보장합니다. 이렇게 하면 이후 프로시저에서 ctx.user가 undefined일 수 없다는 것을 타입 시스템이 알게 됩니다.
Middleware를 체이닝하면 여러 검증을 순차적으로 수행할 수 있습니다. adminProcedure는 먼저 isAuthed로 인증을 확인하고, 그 다음 isAdmin으로 관리자 권한을 확인합니다.
각 단계에서 조건을 만족하지 않으면 즉시 에러가 발생하여 다음 단계로 진행되지 않습니다. 여러분이 이 패턴을 사용하면 인증이 필요한 모든 프로시저에서 같은 Middleware를 재사용할 수 있고, 인증 로직이 변경되어도 한 곳만 수정하면 됩니다.
또한 타입 추론 덕분에 프로시저 내부에서 ctx.user를 안전하게 사용할 수 있어 null 체크를 반복할 필요가 없습니다.
실전 팁
💡 Middleware는 프로시저별로 적용하지 말고, protectedProcedure 같은 프리셋을 만들어 재사용하세요. 일관성 있고 관리하기 쉬워집니다.
💡 로깅이나 성능 측정은 모든 프로시저에 적용할 수 있도록 전역 Middleware로 만드세요. initTRPC.create()에서 설정할 수 있습니다.
💡 next() 호출 전후로 코드를 작성하여 실행 전후 작업을 처리할 수 있습니다. 예: const start = Date.now(); const result = await next(); console.log(Date.now() - start);
💡 Context 확장 시 spread 연산자를 사용하여 기존 Context를 보존하세요. 그렇지 않으면 다른 필드가 사라집니다.
💡 에러 코드는 tRPC가 제공하는 표준 코드를 사용하세요. UNAUTHORIZED, FORBIDDEN, BAD_REQUEST 등이 있으며, HTTP 상태 코드로 자동 매핑됩니다.
7. Error Handling
시작하며
여러분이 API를 만들 때 에러 처리는 항상 신경 쓰이는 부분입니다. 데이터베이스 연결 실패, 잘못된 입력값, 권한 부족 등 다양한 에러가 발생할 수 있는데, 이를 어떻게 일관되게 처리할까요?
전통적인 방법으로는 각 에러마다 HTTP 상태 코드와 메시지를 설정하고, try-catch를 중첩해야 했습니다. 하지만 이는 코드를 복잡하게 만들고, 에러 형식이 일관되지 않아 프론트엔드에서 처리하기 어렵습니다.
tRPC는 표준화된 에러 처리 메커니즘을 제공합니다. TRPCError를 사용하면 타입 안전한 에러 코드와 함께 명확한 에러 메시지를 클라이언트에 전달할 수 있습니다.
개요
간단히 말해서, Error Handling은 TRPCError를 사용하여 표준화된 형식으로 에러를 발생시키고, 프론트엔드에서 일관되게 처리하는 것입니다. TRPCError는 code, message, cause 필드를 가지며, code는 HTTP 상태 코드로 자동 변환됩니다.
예를 들어, UNAUTHORIZED는 401, FORBIDDEN은 403, NOT_FOUND는 404로 매핑됩니다. 이를 통해 RESTful 규칙을 따르면서도 타입 안전성을 유지할 수 있습니다.
기존에는 res.status(404).json({ error: 'Not found' }) 같은 코드를 직접 작성했다면, tRPC에서는 throw new TRPCError({ code: 'NOT_FOUND' })만으로 같은 결과를 얻습니다. 에러 처리의 핵심 특징은 표준화된 에러 코드, 자동 HTTP 매핑, 그리고 타입 안전한 에러 정보입니다.
이를 통해 프론트엔드에서 에러를 예측 가능하게 처리할 수 있습니다.
코드 예제
import { TRPCError } from '@trpc/server';
export const userRouter = t.router({
getById: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
// 사용자가 없으면 NOT_FOUND 에러 발생
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '사용자를 찾을 수 없습니다',
});
}
return user;
}),
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
// 자기 자신을 삭제하려는 경우
if (input.id === ctx.user.id) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '자기 자신은 삭제할 수 없습니다',
});
}
try {
return await db.user.delete({ where: { id: input.id } });
} catch (error) {
// 데이터베이스 에러를 tRPC 에러로 변환
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '사용자 삭제 중 오류가 발생했습니다',
cause: error, // 원본 에러 보존
});
}
}),
});
설명
이것이 하는 일: TRPCError는 서버에서 발생한 다양한 에러를 표준화된 형식으로 클라이언트에 전달하고, 적절한 HTTP 상태 코드로 변환합니다. 첫 번째로, 비즈니스 로직 에러를 처리합니다.
예를 들어, 존재하지 않는 리소스를 조회하면 NOT_FOUND 에러를 발생시킵니다. code 필드는 에러의 종류를 나타내며, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, INTERNAL_SERVER_ERROR 등 다양한 표준 코드가 있습니다.
그 다음으로, message 필드에 사용자에게 보여줄 친절한 메시지를 작성합니다. 이 메시지는 프론트엔드에서 그대로 표시될 수 있으므로, 기술적인 세부사항보다는 사용자가 이해할 수 있는 설명을 담아야 합니다.
"사용자를 찾을 수 없습니다"처럼 명확하고 구체적으로 작성하세요. 외부 시스템(데이터베이스, API 등)에서 발생한 에러는 try-catch로 잡아서 TRPCError로 변환합니다.
cause 필드에 원본 에러를 담으면 서버 로그에서 상세한 디버깅 정보를 확인할 수 있습니다. 하지만 cause는 프로덕션 환경에서 클라이언트에 노출되지 않으므로 보안상 안전합니다.
여러분이 이 패턴을 사용하면 프론트엔드에서 error.data.code로 에러 종류를 확인하고, error.message로 사용자에게 메시지를 보여줄 수 있습니다. 또한 같은 에러 코드는 항상 같은 HTTP 상태 코드로 변환되어, 브라우저나 프록시에서도 올바르게 처리됩니다.
실전 팁
💡 프로덕션 환경에서는 민감한 정보를 에러 메시지에 포함하지 마세요. "데이터베이스 연결 실패: host=xxx" 대신 "일시적인 오류가 발생했습니다"처럼 추상화하세요.
💡 커스텀 에러 코드가 필요하면 initTRPC.create()에서 errorFormatter를 사용하여 확장할 수 있습니다.
💡 Zod 검증 실패는 자동으로 BAD_REQUEST 에러로 변환되며, 어떤 필드가 잘못되었는지 정보가 포함됩니다.
💡 프론트엔드에서는 error.data?.code로 에러 종류를 확인하세요. undefined 체크를 잊지 마세요.
💡 전역 에러 핸들러를 만들어 모든 예외를 잡고, 로깅 시스템에 전송하는 것이 좋습니다. Sentry 같은 도구와 통합할 수 있습니다.
8. Client 설정
시작하며
여러분이 백엔드에서 멋진 tRPC API를 만들었다면, 이제 프론트엔드에서 어떻게 사용할까요? REST API였다면 fetch()나 axios로 호출했겠지만, tRPC는 다릅니다.
tRPC 클라이언트를 설정하면 백엔드 API를 마치 로컬 함수처럼 호출할 수 있습니다. 타입 추론 덕분에 자동 완성이 제공되고, 잘못된 파라미터를 전달하면 컴파일 에러가 발생합니다.
설정은 간단합니다. 백엔드에서 export한 AppRouter 타입을 import하고, tRPC 클라이언트를 생성하면 됩니다.
이제 모든 준비가 끝났습니다.
개요
간단히 말해서, Client 설정은 tRPC 클라이언트를 생성하여 백엔드 API를 타입 안전하게 호출할 수 있게 만드는 과정입니다. createTRPCProxyClient()나 createTRPCNext()를 사용하여 클라이언트를 생성합니다.
여기에 AppRouter 타입을 제네릭으로 전달하면, 클라이언트는 백엔드의 모든 프로시저를 알게 되고, 타입 안전한 호출이 가능해집니다. 기존에는 API 엔드포인트 URL을 문자열로 작성하고, 응답 타입을 수동으로 정의해야 했다면, tRPC 클라이언트는 모든 것을 자동으로 처리합니다.
클라이언트 설정의 핵심 특징은 타입 추론, HTTP 링크 설정, 그리고 React Query 통합입니다. 이를 통해 프론트엔드 개발 경험이 크게 향상됩니다.
코드 예제
// utils/trpc.ts (프론트엔드)
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router'; // 백엔드에서 export한 타입
// Vanilla 클라이언트 (React 없이 사용)
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
// 인증 헤더 추가
headers() {
return {
authorization: `Bearer ${localStorage.getItem('token')}`,
};
},
}),
],
});
// React Query 통합 (Next.js 예시)
import { createTRPCNext } from '@trpc/next';
export const trpcNext = createTRPCNext<AppRouter>({
config() {
return {
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
};
},
ssr: false, // SSR 사용 여부
});
설명
이것이 하는 일: tRPC 클라이언트는 백엔드 Router의 타입 정보를 가져와서, 프론트엔드에서 API를 로컬 함수처럼 호출할 수 있게 합니다. 첫 번째로, AppRouter 타입을 import합니다.
이것은 백엔드에서 export한 타입으로, 모든 프로시저의 입력과 출력 타입을 포함합니다. 모노레포 구조에서는 직접 import하고, 별도 저장소라면 npm 패키지로 공유해야 합니다.
그 다음으로, createTRPCProxyClient()에 AppRouter 타입을 제네릭으로 전달합니다. 이렇게 하면 trpc 객체가 백엔드 API 구조를 완벽하게 반영하게 됩니다.
trpc.user.list()를 입력하면 IDE가 자동 완성을 제공하고, 반환 타입도 정확하게 추론됩니다. httpBatchLink는 여러 요청을 하나의 HTTP 요청으로 묶어 성능을 최적화합니다.
예를 들어, 여러 Query를 동시에 실행하면 자동으로 배치 처리되어 네트워크 왕복을 줄일 수 있습니다. headers() 함수에서 인증 토큰 같은 공통 헤더를 추가할 수 있습니다.
React를 사용한다면 createTRPCNext()나 createTRPCReact()를 사용하여 React Query와 통합할 수 있습니다. 이렇게 하면 useQuery, useMutation 훅을 사용하여 선언적으로 API를 호출하고, 로딩 상태와 에러를 쉽게 관리할 수 있습니다.
여러분이 이 설정을 완료하면 프론트엔드에서 API 호출이 마치 로컬 함수를 호출하는 것처럼 느껴집니다. 타입 에러가 있으면 즉시 IDE에서 알려주고, 리팩토링도 안전하게 할 수 있습니다.
실전 팁
💡 개발 환경과 프로덕션 환경에서 다른 URL을 사용해야 한다면 환경 변수를 활용하세요. url: process.env.NEXT_PUBLIC_API_URL
💡 httpBatchLink는 GET 요청도 배치 처리하므로, 브라우저 캐싱이 제한될 수 있습니다. 필요하다면 splitLink로 Query와 Mutation을 분리하세요.
💡 SSR을 사용한다면 ssr: true로 설정하고, getServerSideProps에서 사용할 수 있도록 추가 설정이 필요합니다.
💡 WebSocket을 사용한 실시간 통신이 필요하다면 wsLink를 사용하세요. Subscription을 지원합니다.
💡 클라이언트는 한 번만 생성하고 재사용하세요. 매번 생성하면 성능이 떨어지고, 캐시도 제대로 작동하지 않습니다.
9. React Hooks 통합
시작하며
여러분이 React로 UI를 만들 때 가장 많이 하는 작업이 무엇인가요? 바로 데이터를 가져오고, 로딩 상태를 관리하고, 에러를 처리하는 것입니다.
이런 작업은 반복적이고 보일러플레이트가 많습니다. tRPC는 React Query와 완벽하게 통합되어, useQuery와 useMutation 훅으로 API를 간편하게 호출할 수 있습니다.
로딩, 에러, 성공 상태가 자동으로 관리되고, 캐싱과 재검증도 알아서 처리됩니다. 무엇보다 좋은 점은 모든 것이 타입 안전하다는 것입니다.
잘못된 파라미터를 전달하면 컴파일 에러가 발생하고, 응답 데이터의 타입도 정확하게 추론됩니다.
개요
간단히 말해서, React Hooks 통합은 tRPC 클라이언트를 React Query 훅으로 감싸서, 선언적이고 타입 안전한 방식으로 API를 사용하는 것입니다. trpc.user.list.useQuery()처럼 각 프로시저에 .useQuery() 또는 .useMutation()을 붙이면 React Hook이 됩니다.
이 훅은 data, isLoading, error 같은 상태를 반환하여, 컴포넌트에서 쉽게 사용할 수 있습니다. 기존에는 useState, useEffect로 API 호출을 직접 관리했다면, tRPC + React Query는 선언적으로 데이터를 구독하는 방식입니다.
React Hooks 통합의 핵심 특징은 자동 캐싱, 백그라운드 리프레시, 그리고 낙관적 업데이트입니다. 이를 통해 사용자 경험을 크게 개선할 수 있습니다.
코드 예제
import { trpcNext } from '../utils/trpc';
export default function UserList() {
// Query: 사용자 목록 조회
const { data, isLoading, error } = trpcNext.user.list.useQuery();
// Mutation: 사용자 생성
const createUser = trpcNext.user.create.useMutation({
onSuccess: () => {
// 성공 시 목록 다시 불러오기
utils.user.list.invalidate();
},
});
const utils = trpcNext.useContext();
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error.message}</div>;
return (
<div>
{data?.map(user => (
<div key={user.id}>{user.name}</div>
))}
<button onClick={() => createUser.mutate({ name: '새 사용자', email: 'new@example.com' })}>
사용자 추가
</button>
</div>
);
}
설명
이것이 하는 일: React Hooks 통합은 tRPC 프로시저를 React Query 훅으로 변환하여, 컴포넌트에서 데이터를 선언적으로 구독하고 관리합니다. 첫 번째로, .useQuery() 훅으로 데이터를 조회합니다.
컴포넌트가 마운트되면 자동으로 API가 호출되고, data에 결과가 담깁니다. isLoading은 요청 중일 때 true이고, error는 에러 발생 시 에러 객체를 담습니다.
이 상태들을 사용하여 UI를 조건부로 렌더링할 수 있습니다. 그 다음으로, .useMutation() 훅으로 데이터를 변경합니다.
Query와 달리 Mutation은 자동 실행되지 않고, mutate() 함수를 호출해야 실행됩니다. 버튼 클릭이나 폼 제출 시 mutate()를 호출하면 됩니다.
onSuccess 콜백에서 성공 후 작업(예: 캐시 무효화, 알림 표시)을 처리할 수 있습니다. utils.user.list.invalidate()는 캐시를 무효화하여, 다음에 해당 Query를 사용하는 컴포넌트가 렌더링될 때 자동으로 데이터를 다시 가져오게 합니다.
이렇게 하면 Mutation 후에 목록이 자동으로 업데이트되어, 사용자가 최신 데이터를 볼 수 있습니다. React Query의 모든 기능(staleTime, cacheTime, refetchOnWindowFocus 등)을 그대로 사용할 수 있습니다.
useQuery의 두 번째 인자로 옵션을 전달하면 됩니다. 예: useQuery(undefined, { staleTime: 60000 }) 여러분이 이 패턴을 사용하면 데이터 페칭 로직이 컴포넌트에서 분리되어 코드가 깔끔해지고, 자동 캐싱으로 불필요한 네트워크 요청이 줄어듭니다.
또한 백그라운드에서 데이터를 자동으로 업데이트하여 항상 최신 상태를 유지할 수 있습니다.
실전 팁
💡 enabled 옵션으로 조건부 Query를 만들 수 있습니다. useQuery({ id }, { enabled: !!id })처럼 사용하면 id가 있을 때만 실행됩니다.
💡 여러 Mutation 후 한 번에 invalidate하려면 utils.invalidate()를 사용하세요. 개별 Query마다 invalidate하는 것보다 효율적입니다.
💡 낙관적 업데이트는 onMutate에서 캐시를 직접 수정하여 구현합니다. 응답을 기다리지 않고 즉시 UI를 업데이트할 수 있습니다.
💡 무한 스크롤은 useInfiniteQuery를 사용하세요. tRPC도 이를 지원하며, cursor 기반 페이지네이션과 잘 어울립니다.
💡 개발자 도구를 설치하면 React Query의 캐시 상태를 시각적으로 확인할 수 있습니다. @tanstack/react-query-devtools 패키지를 추가하세요.
10. Subscriptions
시작하며
여러분이 실시간 채팅, 알림, 주식 가격 같은 실시간 데이터를 다루고 싶다면 어떻게 해야 할까요? 일반적인 Query나 Mutation으로는 불가능합니다.
폴링(주기적으로 요청)하는 방법도 있지만, 비효율적이고 지연이 발생합니다. WebSocket을 직접 구현할 수도 있지만, 연결 관리, 재연결, 에러 처리 등을 모두 직접 해야 하고, 타입 안전성도 보장되지 않습니다.
tRPC의 Subscription은 WebSocket 기반 실시간 데이터 스트리밍을 타입 안전하게 구현할 수 있게 합니다. Observable 패턴을 사용하여 서버에서 클라이언트로 데이터를 푸시하고, 프론트엔드에서는 useSubscription 훅으로 간편하게 구독할 수 있습니다.
개요
간단히 말해서, Subscription은 서버에서 클라이언트로 실시간으로 데이터를 푸시하는 WebSocket 기반 통신 방식입니다. .subscription() 메서드로 정의하며, Observable을 반환하여 데이터 스트림을 생성합니다.
예를 들어, 새 메시지가 생성될 때마다 연결된 모든 클라이언트에게 자동으로 전송할 수 있습니다. 기존에는 Socket.IO나 직접 WebSocket을 구현해야 했다면, tRPC는 기존 API와 동일한 방식으로 Subscription을 정의하고 사용할 수 있습니다.
Subscription의 핵심 특징은 실시간 양방향 통신, 타입 안전성, 그리고 자동 재연결입니다. 이를 통해 실시간 기능을 빠르고 안전하게 구현할 수 있습니다.
코드 예제
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';
// 이벤트 에미터 (실제로는 Redis Pub/Sub 등 사용)
const ee = new EventEmitter();
// 백엔드: Subscription 정의
export const messageRouter = t.router({
onNewMessage: t.procedure.subscription(() => {
return observable<{ id: string; text: string }>((emit) => {
// 새 메시지 이벤트 리스너
const onMessage = (data: { id: string; text: string }) => {
emit.next(data); // 클라이언트에 데이터 푸시
};
ee.on('newMessage', onMessage);
// 구독 종료 시 리스너 제거
return () => {
ee.off('newMessage', onMessage);
};
});
}),
});
// 프론트엔드: Subscription 사용
export default function Chat() {
trpcNext.message.onNewMessage.useSubscription(undefined, {
onData(message) {
console.log('새 메시지:', message);
// UI 업데이트 로직
},
onError(err) {
console.error('구독 에러:', err);
},
});
return <div>실시간 채팅</div>;
}
설명
이것이 하는 일: Subscription은 Observable 패턴으로 데이터 스트림을 생성하고, WebSocket을 통해 클라이언트에 실시간으로 전송합니다. 첫 번째로, .subscription() 메서드로 Subscription을 정의합니다.
observable() 함수는 emit 객체를 제공하는데, emit.next(data)를 호출하면 연결된 모든 클라이언트에게 data가 전송됩니다. 이벤트 기반 시스템(EventEmitter, Redis Pub/Sub, Kafka 등)과 연결하여 실시간 이벤트를 구독할 수 있습니다.
그 다음으로, cleanup 함수를 반환하여 구독 종료 시 리소스를 정리합니다. 클라이언트가 연결을 끊거나 컴포넌트가 언마운트되면 이 함수가 실행되어 이벤트 리스너를 제거합니다.
이렇게 하지 않으면 메모리 누수가 발생할 수 있습니다. 프론트엔드에서는 useSubscription 훅으로 구독합니다.
onData 콜백은 새 데이터가 도착할 때마다 실행되며, 여기서 상태를 업데이트하거나 UI를 변경할 수 있습니다. onError 콜백은 연결 에러나 서버 에러 발생 시 실행됩니다.
WebSocket 연결은 자동으로 관리됩니다. 연결이 끊기면 자동으로 재연결을 시도하고, 클라이언트는 연결 상태를 신경 쓰지 않고 데이터만 처리하면 됩니다.
여러분이 이 기능을 사용하면 실시간 채팅, 라이브 알림, 협업 도구 같은 실시간 기능을 빠르게 구현할 수 있습니다. 또한 타입 안전성 덕분에 서버에서 보낸 데이터 형식을 클라이언트가 정확하게 알 수 있어, 런타임 에러를 방지할 수 있습니다.
실전 팁
💡 Subscription을 사용하려면 wsLink를 설정해야 합니다. httpLink와 splitLink로 분리하여, Subscription은 WebSocket으로, 나머지는 HTTP로 처리하세요.
💡 프로덕션 환경에서는 Redis Pub/Sub이나 Kafka 같은 메시지 브로커를 사용하세요. EventEmitter는 단일 서버에서만 작동합니다.
💡 데이터베이스 변경을 실시간으로 전달하려면 PostgreSQL의 LISTEN/NOTIFY나 MongoDB의 Change Streams를 사용할 수 있습니다.
💡 너무 많은 데이터를 푸시하면 클라이언트가 느려질 수 있습니다. 필요한 데이터만 전송하고, throttle이나 debounce를 적용하세요.
💡 Subscription은 Query나 Mutation보다 리소스를 많이 사용합니다. 꼭 필요한 경우에만 사용하고, 폴링으로 충분하다면 폴링을 선택하세요.
댓글 (0)
함께 보면 좋은 카드 뉴스
마이크로서비스 배포 완벽 가이드
Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.
Application Load Balancer 완벽 가이드
AWS의 Application Load Balancer를 처음 배우는 개발자를 위한 실전 가이드입니다. ALB 생성부터 ECS 연동, 헬스 체크, HTTPS 설정까지 실무에 필요한 모든 내용을 다룹니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.
고객 상담 AI 시스템 완벽 구축 가이드
AWS Bedrock Agent와 Knowledge Base를 활용하여 실시간 고객 상담 AI 시스템을 구축하는 방법을 단계별로 학습합니다. RAG 기반 지식 검색부터 Guardrails 안전 장치, 프론트엔드 연동까지 실무에 바로 적용 가능한 완전한 시스템을 만들어봅니다.
에러 처리와 폴백 완벽 가이드
AWS API 호출 시 발생하는 에러를 처리하고 폴백 전략을 구현하는 방법을 다룹니다. ThrottlingException부터 서킷 브레이커 패턴까지, 실전에서 바로 활용할 수 있는 안정적인 에러 처리 기법을 배웁니다.
외부 API 연동 에이전트 완벽 가이드
Lambda를 활용한 외부 API 연동 에이전트 구현부터 에러 처리, 타임아웃 관리, 테스트까지 실무에 바로 적용 가능한 완벽 가이드입니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.