복잡한 마이크로서비스에서 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를 통해 얻은 가장 큰 교훈입니다.

Updated: