아키텍처 설계 리팩토링 및 개발 생산성 향상
복잡한 마이크로서비스에서 tRPC로: 개발 생산성을 2배 향상시킨 아키텍처 전환기
🎯 들어가며
“또 빌드 에러야? 이번엔 뭐가 문제지?”
개발팀에서 자주 들리던 한숨 섞인 말이었습니다. 간단한 API 하나 추가하는데도 여러 서비스를 거쳐야 하고, 한 곳을 수정하면 다른 프로젝트들이 연쇄적으로 영향을 받는 상황. 이런 복잡한 아키텍처 속에서 우리는 tRPC라는 해답을 찾게 되었습니다.
🏗️ 기존 아키텍처의 딜레마
복잡함의 극치: 5단계 레이어
우리의 기존 아키텍처는 다음과 같았습니다:
[Client] → [BFF] → [IDL] → [gRPC] → [Go Server]
언뜻 보면 잘 설계된 마이크로서비스 아키텍처 같지만, 실제 개발 현실은 달랐습니다.
🚨 직면한 문제들
1. 간단한 기능 하나도 복잡한 여정
프론트엔드에서 사용자 정보 하나를 가져오려면:
- Go Server에서 비즈니스 로직 구현
- gRPC 서비스 정의
- IDL 파일 업데이트
- BFF에서 GraphQL 스키마 매핑
- 클라이언트에서 GraphQL 쿼리 작성
2. GraphQL Code Generation의 덫
모든 프로젝트가 동일한 GraphQL 스키마에서 생성된 코드를 바라보고 있었습니다.
// 모든 프로젝트에서 공통으로 사용
import { GetUserQuery, User } from "@shared/graphql-generated";
문제는 여기서 시작되었습니다:
- A 프로젝트에서 User 타입 수정 → B, C, D 프로젝트 모두 영향
- 한 팀의 수정사항으로 다른 팀의 빌드 실패
- 수정 전 모든 프로젝트 빌드 테스트 필수
- 놓치는 케이스 발생 시 프로덕션 장애
3. 협업 비용의 급증
# 매번 반복되는 불안한 루틴
$ git push feature/user-profile-update
$ # 5분 후...
$ "아, A팀 빌드 깨졌네요. 되돌려주세요 😅"
💡 해결책 모색 과정
첫 번째 시도: RESTful API 분리
// 도메인별 REST API
GET / api / user / profile;
GET / api / book / list;
GET / api / payment / history;
하지만 이 방법은 타입 안정성이라는 GraphQL의 핵심 장점을 포기해야 했습니다.
두 번째 제안: tRPC 도입
“타입 안정성은 유지하면서 의존성은 격리할 수 없을까?”
NestJS와 tRPC의 조합을 연구한 결과, 이상적인 해답을 발견했습니다.
🚀 tRPC 아키텍처 설계
새로운 아키텍처 구조
[Client Apps] ← HTTP → [NestJS BFF] ← Direct DB → [Database]
↓ ↓
[Domain-specific] [Domain-specific]
[tRPC Clients] [tRPC Routers]
도메인별 격리 전략
1. 백엔드: 도메인별 tRPC 라우터 분리
// apps/apigateway/src/v2/solve-books/solve-books.trpc.router.ts
@Injectable()
export class SolveBooksRouter {
constructor(private readonly trpc: TrpcService) {}
get solveBooksRouter() {
return this.trpc.router({
store: this.storeRouter.router,
user: this.userRouter.router,
});
}
}
// apps/apigateway/src/v2/admin/admin.trpc.router.ts
@Injectable()
export class AdminRouter {
constructor(private readonly trpc: TrpcService) {}
get adminRouter() {
return this.trpc.router({
dashboard: this.dashboardRouter.router,
settings: this.settingsRouter.router,
});
}
}
2. 타입 격리: 도메인별 타입 익스포트
// apps/apigateway/exports.ts
export type { SolveBooksAppRouter } from "./src/v2/solve-books/solve-books.trpc.router";
export type { AdminAppRouter } from "./src/v2/admin/admin.trpc.router";
export type { ConverterAppRouter } from "./src/v2/converter/converter.trpc.router";
3. 클라이언트: 독립적인 tRPC 클라이언트
// apps/solve-books/src/utils/trpc.ts
import { createTRPCClient } from "@trpc/client";
import type { SolveBooksAppRouter } from "@apigateway/types";
const trpcClient = createTRPCClient<SolveBooksAppRouter>({
links: [
httpBatchLink({
url: "http://localhost:5500/trpc/solve-books",
}),
],
});
// apps/admin/src/utils/trpc.ts
import type { AdminAppRouter } from "@apigateway/types";
const trpcClient = createTRPCClient<AdminAppRouter>({
links: [
httpBatchLink({
url: "http://localhost:5500/trpc/admin",
}),
],
});
TanStack Query 통합으로 완성
// 캐싱, 리트라이, 에러 핸들링이 모두 자동화
export const trpc = createTRPCOptionsProxy<SolveBooksAppRouter>({
client: trpcClient,
queryClient,
});
// 컴포넌트에서의 사용
function ProductList() {
const { data, isLoading, error } = useQuery(
trpc.store.product.list.queryOptions({
category: "textbook",
limit: 10,
})
);
if (isLoading) return <Loading />;
if (error) return <Error message={error.message} />;
return <ProductGrid products={data} />;
}
📊 마이그레이션 성과
🎯 정량적 성과
지표 | 이전 | 이후 | 개선율 |
---|---|---|---|
API 개발 시간 | 2-3일 | 4-6시간 | 75% 단축 |
빌드 에러 발생 | 주 5-7회 | 주 0-1회 | 85% 감소 |
프로젝트 간 의존성 | 100% 공유 | 0% 격리 | 완전 분리 |
타입 안정성 | GraphQL Generated | Runtime Safe | 향상 |
🚀 정성적 성과
1. 개발자 경험의 혁신
// Before: 복잡한 GraphQL 쿼리 작성
const GET_USER_PROFILE = gql`
query GetUserProfile($userId: ID!) {
user(id: $userId) {
id
name
email
profile {
avatar
bio
}
}
}
`;
// After: 직관적인 tRPC 호출
const userProfile = await trpc.user.getProfile.query({ userId });
2. 실시간 타입 동기화
백엔드 API 변경 → 즉시 프론트엔드 타입 반영 → IDE에서 실시간 에러 표시
3. 도메인별 자율성 확보
- 각 팀이 자신의 API를 독립적으로 관리
- 다른 팀에 영향 없이 자유로운 개발
- 배포 주기의 독립성 확보
🔍 기술적 심화: 구현 디테일
1. NestJS 모듈 구조
// v2.module.ts - 모든 도메인 모듈의 통합점
@Module({
imports: [
TrpcModule,
SolveBooksModule,
AdminModule,
ConverterModule,
ScmModule,
],
exports: [TrpcModule, SolveBooksModule, AdminModule],
})
export class V2Module {}
2. tRPC 서비스 설정
// trpc-setup.service.ts - 도메인별 라우터 등록
@Injectable()
export class TrpcSetupService {
setupAllTrpcRoutes(app: INestApplication): void {
this.setupSolveBooksRoutes(app);
this.setupAdminRoutes(app);
this.setupConverterRoutes(app);
}
private setupSolveBooksRoutes(app: INestApplication): void {
const solveBooksRouter = app.get(SolveBooksRouter);
app.use(
"/trpc/solve-books",
trpcExpress.createExpressMiddleware({
router: solveBooksRouter.solveBooksRouter,
createContext: () => ({}),
})
);
}
}
3. 타입 안전성 보장
// Zod 스키마로 런타임 검증
export const SearchProductSchema = z.object({
category: z.string().optional(),
minPrice: z.number().optional(),
maxPrice: z.number().optional(),
limit: z.number().min(1).max(100).default(10),
});
export type SearchProductInput = z.infer<typeof SearchProductSchema>;
// tRPC 프로시저에서 사용
export const productRouter = router({
search: procedure.input(SearchProductSchema).query(async ({ input }) => {
// input은 자동으로 타입 추론됨
return await productService.search(input);
}),
});
🤔 도입 과정의 고민과 해결
1. 기존 GraphQL 코드와의 공존
점진적 마이그레이션을 위해 GraphQL과 tRPC를 병행 운영:
// 기존 GraphQL은 유지하면서
app.use("/graphql", graphqlUploadExpress(), graphqlMiddleware);
// 새로운 tRPC 엔드포인트 추가
app.use("/trpc", trpcMiddleware);
2. 팀 설득과 학습 곡선
- 소규모 프로젝트부터 tRPC 적용하여 효과 입증
- 개발팀 워크샵을 통한 지식 공유
- 마이그레이션 가이드 문서 작성
3. 모니터링과 디버깅
// tRPC Panel을 통한 API 문서화 및 테스트
app.use("/panel", (_, res) => {
return res.send(
renderTrpcPanel(appRouter, {
url: "http://localhost:5500/trpc",
})
);
});
🎉 마무리
복잡한 마이크로서비스 아키텍처에서 시작된 여정이 tRPC로 완전히 새로운 개발 경험을 만들어냈습니다.
핵심은 “기술을 위한 기술이 아닌, 문제 해결을 위한 기술”이었습니다. GraphQL의 타입 안정성은 유지하면서도 의존성 지옥에서 벗어날 수 있었고, 개발팀의 생산성은 눈에 띄게 향상되었습니다.
혹시 비슷한 아키텍처 고민을 하고 계신다면, tRPC는 정말 좋은 선택지가 될 수 있습니다. 특히 TypeScript 기반 풀스택 개발 환경에서는 그 진가를 발휘할 것입니다.
“복잡함을 단순함으로, 의존성을 독립성으로”
이것이 우리가 tRPC를 통해 얻은 가장 큰 교훈입니다.