본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 11. 25. · 7 Views
API 아키텍처 패턴과 설계 완벽 가이드
모놀리식부터 마이크로서비스까지, 현대 백엔드 아키텍처의 핵심 패턴들을 초급 개발자도 이해할 수 있도록 실무 예제와 함께 설명합니다. API Gateway, BFF, 이벤트 기반 아키텍처 등 실전에서 바로 적용할 수 있는 설계 전략을 다룹니다.
목차
- 모놀리식 vs 마이크로서비스
- API Gateway 패턴
- BFF (Backend for Frontend) 패턴
- 서비스 간 통신 (REST, gRPC)
- 이벤트 기반 아키텍처
- API 버전 관리 전략
1. 모놀리식 vs 마이크로서비스
스타트업에서 일하는 김개발 씨는 최근 고민에 빠졌습니다. 서비스가 성장하면서 배포할 때마다 전체 시스템이 멈추고, 작은 버그 하나가 전체 서비스를 다운시키는 일이 반복되었습니다.
선배 박시니어 씨에게 조언을 구하자, "우리 아키텍처를 한번 살펴볼 때가 됐네요"라는 답이 돌아왔습니다.
모놀리식 아키텍처는 모든 기능이 하나의 애플리케이션에 통합된 구조입니다. 마치 모든 부서가 한 건물에 있는 회사와 같습니다.
반면 마이크로서비스 아키텍처는 기능별로 독립된 서비스로 분리한 구조로, 각 부서가 별도 건물에서 일하면서 필요할 때 소통하는 방식입니다.
다음 코드를 살펴봅시다.
// 모놀리식: 모든 기능이 한 곳에
class MonolithicApp {
async processOrder(userId: string, items: Item[]) {
// 사용자 검증, 재고 확인, 결제, 배송 모두 여기서
const user = await this.userService.validate(userId);
const stock = await this.inventoryService.check(items);
const payment = await this.paymentService.process(user, items);
const shipping = await this.shippingService.create(user, items);
return { orderId: payment.orderId, trackingNo: shipping.trackingNo };
}
}
// 마이크로서비스: 각 기능이 독립된 서비스
// order-service/src/order.controller.ts
async createOrder(userId: string, items: Item[]) {
// 다른 서비스들과 API로 통신
const user = await fetch('http://user-service/validate/' + userId);
const stock = await fetch('http://inventory-service/check', { body: items });
return await this.orderRepository.save({ userId, items, status: 'pending' });
}
김개발 씨가 입사했을 때 회사의 서비스는 아주 단순했습니다. 회원 가입, 상품 조회, 주문 처리까지 모든 기능이 하나의 애플리케이션 안에 들어 있었습니다.
배포도 간단했고, 개발도 수월했습니다. 이것이 바로 모놀리식 아키텍처입니다.
쉽게 비유하자면, 모놀리식은 마치 원룸에서 생활하는 것과 같습니다. 침실, 거실, 주방이 모두 한 공간에 있어서 이동이 편하고 관리가 쉽습니다.
혼자 살기에는 더없이 좋은 구조입니다. 하지만 시간이 지나면서 문제가 생기기 시작했습니다.
서비스 사용자가 늘어나면서 주문 처리 부분만 부하가 심해졌는데, 전체 애플리케이션을 늘릴 수밖에 없었습니다. 결제 모듈을 수정하려면 전체 시스템을 다시 배포해야 했고, 그때마다 회원 서비스까지 잠시 중단되었습니다.
더 큰 문제는 개발팀이 커지면서 나타났습니다. 여러 팀이 하나의 코드베이스에서 작업하다 보니 충돌이 빈번했습니다.
A팀이 배포하려는데 B팀의 코드가 아직 테스트 중이라 기다려야 하는 상황이 반복되었습니다. 박시니어 씨가 말했습니다.
"이제 마이크로서비스를 고민해볼 때가 됐어요." 마이크로서비스는 마치 아파트 단지와 같습니다. 각 기능이 독립된 건물처럼 분리되어 있어서, 한 건물을 수리해도 다른 건물 주민들은 영향을 받지 않습니다.
주문 서비스만 확장하고 싶으면 그 서비스만 늘리면 됩니다. 위 코드를 살펴보면, 모놀리식에서는 하나의 메서드 안에서 모든 처리가 이루어집니다.
반면 마이크로서비스에서는 각 기능이 별도의 서비스로 분리되어 HTTP 통신으로 연결됩니다. 그렇다면 무조건 마이크로서비스가 좋을까요?
그렇지 않습니다. 마이크로서비스는 네트워크 통신 비용, 서비스 간 데이터 일관성 유지, 분산 시스템의 복잡성 등 새로운 도전 과제를 가져옵니다.
실무에서는 서비스 초기에는 모놀리식으로 빠르게 개발하고, 특정 기능에 병목이 생기거나 팀이 커지면 그 부분부터 점진적으로 분리하는 전략을 많이 사용합니다. 이를 모놀리식 우선 접근법이라고 합니다.
김개발 씨는 고개를 끄덕였습니다. "무조건 최신 기술이 좋은 게 아니라, 상황에 맞는 선택이 중요하군요."
실전 팁
💡 - 스타트업 초기에는 모놀리식으로 빠르게 검증하고, 성장하면서 점진적으로 분리하세요
- 마이크로서비스 전환 시 가장 독립적이고 부하가 높은 기능부터 분리를 시작하세요
- 서비스 분리 전에 반드시 도메인 경계를 명확히 정의하세요
2. API Gateway 패턴
마이크로서비스로 전환을 시작한 김개발 씨 팀은 새로운 문제에 직면했습니다. 클라이언트가 여러 서비스에 각각 요청을 보내야 하고, 서비스마다 인증을 따로 처리해야 했습니다.
"이거 클라이언트 개발자분들이 힘들어하시겠는데요?" 김개발 씨의 걱정에 박시니어 씨가 해결책을 제시했습니다.
API Gateway는 모든 클라이언트 요청의 단일 진입점 역할을 합니다. 마치 호텔의 프런트 데스크처럼, 고객이 각 부서를 직접 찾아다니지 않아도 프런트에서 모든 요청을 접수하고 적절한 곳으로 연결해줍니다.
인증, 라우팅, 로드밸런싱, 속도 제한 등을 한 곳에서 처리할 수 있습니다.
다음 코드를 살펴봅시다.
// api-gateway/src/gateway.ts
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
const app = express();
// 공통 인증 미들웨어
app.use(async (req, res, next) => {
const token = req.headers.authorization;
const isValid = await authService.validateToken(token);
if (!isValid) return res.status(401).json({ error: 'Unauthorized' });
next();
});
// 서비스별 라우팅
app.use('/api/users', createProxyMiddleware({ target: 'http://user-service:3001' }));
app.use('/api/orders', createProxyMiddleware({ target: 'http://order-service:3002' }));
app.use('/api/products', createProxyMiddleware({ target: 'http://product-service:3003' }));
// 속도 제한
app.use(rateLimit({ windowMs: 60000, max: 100 }));
마이크로서비스 아키텍처를 도입한 회사들이 공통적으로 겪는 문제가 있습니다. 클라이언트 입장에서 보면, 하나의 화면을 그리기 위해 여러 서비스에 각각 요청을 보내야 합니다.
김개발 씨 팀의 경우를 봅시다. 마이페이지를 로드하려면 회원 서비스에서 프로필을, 주문 서비스에서 주문 내역을, 포인트 서비스에서 적립금 정보를 각각 가져와야 했습니다.
모바일 앱 개발자는 세 번의 API 호출을 해야 했고, 각 서비스의 주소와 인증 방식을 모두 알아야 했습니다. 이 상황은 마치 큰 병원에서 환자가 각 과를 직접 찾아다니는 것과 같습니다.
내과는 2층, 외과는 5층, 검사실은 지하 1층을 직접 돌아다녀야 한다면 얼마나 불편할까요? API Gateway는 병원의 원스톱 창구와 같습니다.
환자는 창구에만 가면 되고, 창구에서 각 과에 연락하여 필요한 서비스를 조율해줍니다. 위 코드를 보면, 모든 요청이 먼저 인증 미들웨어를 거칩니다.
인증이 통과되면 URL 경로에 따라 적절한 내부 서비스로 라우팅됩니다. 클라이언트는 Gateway의 주소만 알면 되고, 내부 서비스들의 존재를 알 필요가 없습니다.
API Gateway의 주요 기능들을 살펴봅시다. 첫째, 인증과 인가를 중앙에서 처리합니다.
각 서비스가 개별적으로 토큰을 검증할 필요가 없습니다. 둘째, 요청 라우팅으로 클라이언트 요청을 적절한 서비스로 전달합니다.
셋째, 속도 제한으로 특정 클라이언트가 과도한 요청을 보내는 것을 막습니다. 실무에서 많이 사용되는 API Gateway 솔루션으로는 Kong, AWS API Gateway, Netflix Zuul, 그리고 최근에는 Envoy가 있습니다.
직접 구현할 수도 있지만, 프로덕션 환경에서는 검증된 솔루션을 사용하는 것이 안전합니다. 다만 주의할 점도 있습니다.
API Gateway가 **단일 장애점(Single Point of Failure)**이 될 수 있습니다. Gateway가 다운되면 모든 서비스에 접근할 수 없게 됩니다.
따라서 Gateway의 고가용성 구성은 필수입니다. 김개발 씨는 API Gateway를 도입한 후, 모바일 개발자로부터 "이제 API 연동이 훨씬 깔끔해졌어요"라는 피드백을 받았습니다.
실전 팁
💡 - API Gateway는 비즈니스 로직을 넣지 말고, 횡단 관심사(인증, 로깅, 모니터링)만 처리하세요
- Gateway의 고가용성을 위해 최소 2대 이상으로 구성하고 로드밸런서를 앞에 두세요
- 서비스 디스커버리와 연동하면 서비스 주소 변경에 유연하게 대응할 수 있습니다
3. BFF (Backend for Frontend) 패턴
API Gateway를 도입하고 얼마 지나지 않아 새로운 요구사항이 생겼습니다. 웹과 모바일 앱이 필요로 하는 데이터가 달랐던 것입니다.
웹에서는 상세한 정보가 필요했지만, 모바일에서는 배터리와 데이터 절약을 위해 꼭 필요한 정보만 원했습니다. 하나의 API로 모두를 만족시키려니 어느 쪽도 만족스럽지 못했습니다.
**BFF(Backend for Frontend)**는 각 클라이언트 유형에 맞는 전용 백엔드를 두는 패턴입니다. 마치 같은 레스토랑이라도 홀 서비스와 배달 서비스의 메뉴 구성이 다른 것처럼, 웹용 BFF, 모바일용 BFF를 따로 두어 각 플랫폼에 최적화된 응답을 제공합니다.
다음 코드를 살펴봅시다.
// bff-web/src/controllers/product.controller.ts
// 웹용 BFF: 상세한 정보 제공
async getProductDetail(productId: string) {
const [product, reviews, related, seller] = await Promise.all([
this.productService.getById(productId),
this.reviewService.getByProduct(productId, { limit: 20 }),
this.recommendService.getRelated(productId, { limit: 10 }),
this.sellerService.getInfo(product.sellerId)
]);
return { product, reviews, relatedProducts: related, sellerInfo: seller };
}
// bff-mobile/src/controllers/product.controller.ts
// 모바일용 BFF: 필수 정보만 압축하여 제공
async getProductDetail(productId: string) {
const product = await this.productService.getById(productId);
return {
id: product.id,
name: product.name,
price: product.price,
thumbnailUrl: product.images[0]?.thumbnail, // 작은 이미지만
reviewCount: product.reviewCount,
rating: product.averageRating
};
}
김개발 씨 팀은 처음에 하나의 범용 API를 만들었습니다. 웹에서 필요한 모든 필드를 포함했더니 모바일 앱에서는 불필요한 데이터까지 받아야 했습니다.
반대로 모바일에 맞추면 웹에서 추가 API 호출이 필요했습니다. 이 딜레마는 마치 옷 가게에서 프리사이즈 옷만 파는 것과 같습니다.
누구에게나 맞는 옷을 만들려다 보니 정작 누구에게도 딱 맞지 않게 되었습니다. BFF 패턴은 이 문제를 해결합니다.
S, M, L 사이즈를 따로 만들듯이, 웹용, 모바일용, 때로는 iOS용과 Android용까지 전용 백엔드를 둡니다. 위 코드에서 웹용 BFF는 상품 상세 페이지에 필요한 모든 정보를 한 번에 조합하여 반환합니다.
리뷰 20개, 연관 상품 10개, 판매자 정보까지 포함합니다. 반면 모바일용 BFF는 핵심 정보만 압축하여 전달합니다.
이미지도 썸네일만, 리뷰는 개수만 보여줍니다. BFF의 핵심 가치는 클라이언트 요구사항의 변경이 다른 클라이언트에 영향을 주지 않는다는 점입니다.
웹팀이 새로운 필드를 추가해도 모바일 앱은 전혀 영향받지 않습니다. 넷플릭스가 이 패턴을 적극적으로 활용하는 것으로 유명합니다.
TV, 게임 콘솔, 모바일, 웹 등 수많은 디바이스를 지원하는데, 각각의 화면 크기와 성능이 다르기 때문에 디바이스별로 최적화된 BFF를 운영합니다. 하지만 BFF에도 트레이드오프가 있습니다.
코드 중복이 발생할 수 있고, 관리해야 할 서비스가 늘어납니다. 따라서 클라이언트 간 요구사항 차이가 크고, 각 플랫폼 전담 팀이 있을 때 도입을 고려하는 것이 좋습니다.
실무에서는 프론트엔드 팀이 자신의 BFF를 직접 관리하는 경우도 많습니다. 프론트엔드 개발자가 필요한 형태로 데이터를 조합할 수 있어서 백엔드 팀과의 커뮤니케이션 비용이 줄어듭니다.
실전 팁
💡 - BFF는 데이터 조합과 변환만 담당하고, 비즈니스 로직은 백엔드 서비스에 두세요
- 클라이언트 팀이 자신의 BFF를 관리하면 개발 속도가 빨라집니다
- 공통 로직은 라이브러리로 추출하여 BFF 간 코드 중복을 최소화하세요
4. 서비스 간 통신 (REST, gRPC)
마이크로서비스 환경에서 김개발 씨는 새로운 고민에 빠졌습니다. 주문 서비스가 재고 서비스를 호출하고, 재고 서비스가 다시 창고 서비스를 호출하는데, API 응답이 점점 느려지고 있었습니다.
박시니어 씨가 물었습니다. "서비스 간 통신 방식은 뭘 쓰고 있어요?"
마이크로서비스에서 서비스 간 통신은 크게 REST와 gRPC 두 가지 방식이 있습니다. REST는 HTTP와 JSON을 사용하는 익숙하고 범용적인 방식이고, gRPC는 Protocol Buffers와 HTTP/2를 사용하여 더 빠르고 효율적인 통신을 제공합니다.
각각의 장단점을 이해하고 상황에 맞게 선택해야 합니다.
다음 코드를 살펴봅시다.
// REST 방식: HTTP + JSON
// order-service/src/services/inventory.client.ts
async checkStock(productId: string): Promise<StockInfo> {
const response = await fetch(`http://inventory-service/api/stock/${productId}`, {
headers: { 'Content-Type': 'application/json' }
});
return response.json(); // JSON 파싱 필요
}
// gRPC 방식: Protocol Buffers + HTTP/2
// proto/inventory.proto 정의 후 자동 생성된 클라이언트 사용
import { InventoryClient } from './generated/inventory_grpc_pb';
const client = new InventoryClient('inventory-service:50051', grpc.credentials.createInsecure());
async checkStock(productId: string): Promise<StockInfo> {
return new Promise((resolve, reject) => {
const request = new StockRequest();
request.setProductId(productId);
client.checkStock(request, (err, response) => {
if (err) reject(err);
else resolve(response.toObject()); // 바이너리에서 객체로 변환
});
});
}
김개발 씨 팀은 모든 서비스 간 통신에 REST API를 사용하고 있었습니다. REST는 HTTP와 JSON을 사용하기 때문에 브라우저에서 바로 테스트할 수 있고, curl로 간단히 호출할 수 있어서 개발이 편했습니다.
하지만 문제가 있었습니다. 하나의 요청이 내부적으로 여러 서비스를 거치면서 JSON 직렬화와 역직렬화가 반복되었습니다.
텍스트 기반의 JSON은 크기도 컸습니다. 트래픽이 늘어나자 이 오버헤드가 무시할 수 없는 수준이 되었습니다.
박시니어 씨가 제안했습니다. "내부 서비스 간 통신에는 gRPC를 고려해보는 게 어때요?" gRPC는 구글에서 만든 원격 프로시저 호출 프레임워크입니다.
마치 같은 내용을 편지로 보내느냐 전화로 하느냐의 차이와 같습니다. REST가 편지라면, gRPC는 전화입니다.
전화가 더 빠르고 직접적이지만, 편지는 기록이 남고 누구나 읽을 수 있다는 장점이 있습니다. gRPC의 핵심은 Protocol Buffers입니다.
JSON이 사람이 읽을 수 있는 텍스트라면, Protocol Buffers는 기계가 효율적으로 처리할 수 있는 바이너리입니다. 같은 데이터를 훨씬 작은 크기로 전송할 수 있습니다.
또한 gRPC는 HTTP/2를 사용합니다. HTTP/1.1에서는 하나의 연결에 하나의 요청만 처리할 수 있지만, HTTP/2는 하나의 연결에서 여러 요청을 동시에 처리하는 멀티플렉싱을 지원합니다.
위 코드를 비교해보면, REST는 직관적이고 익숙합니다. 반면 gRPC는 .proto 파일로 인터페이스를 먼저 정의하고, 거기서 클라이언트 코드를 자동 생성합니다.
초기 설정이 번거롭지만, 타입 안정성이 보장되고 자동 완성이 잘 됩니다. 실무에서의 선택 기준은 명확합니다.
외부에 공개하는 API는 REST를 사용합니다. 개발자들이 익숙하고, 웹 브라우저에서 바로 사용할 수 있기 때문입니다.
반면 내부 서비스 간 통신, 특히 지연시간에 민감한 경우에는 gRPC가 좋은 선택입니다. 넷플릭스, 트위터 등 대규모 서비스를 운영하는 회사들은 내부적으로 gRPC를 적극 활용합니다.
하지만 작은 규모의 서비스라면 REST로도 충분한 경우가 많습니다.
실전 팁
💡 - 외부 공개 API는 REST, 내부 서비스 간 고성능 통신에는 gRPC를 사용하세요
- gRPC 도입 시 .proto 파일 관리와 버전 호환성에 신경 쓰세요
- REST와 gRPC를 혼용할 때는 API Gateway에서 프로토콜 변환을 처리할 수 있습니다
5. 이벤트 기반 아키텍처
주문 시스템을 개선하던 김개발 씨는 또 다른 문제를 발견했습니다. 주문이 완료되면 재고 차감, 포인트 적립, 알림 발송, 통계 업데이트 등 여러 작업이 동시에 일어나야 했습니다.
동기 방식으로 모든 서비스를 순차 호출하다 보니, 하나라도 느려지면 전체 주문 처리가 지연되었습니다.
이벤트 기반 아키텍처는 서비스들이 직접 통신하는 대신, 이벤트를 발행하고 구독하는 방식으로 소통합니다. 마치 라디오 방송처럼, 방송국은 전파를 쏘고 청취자들은 각자 듣고 싶은 채널을 선택합니다.
서비스 간 결합도를 낮추고, 비동기 처리로 응답 속도를 개선할 수 있습니다.
다음 코드를 살펴봅시다.
// 이벤트 발행: 주문 서비스
// order-service/src/services/order.service.ts
async createOrder(orderData: CreateOrderDto) {
const order = await this.orderRepository.save(orderData);
// 동기 호출 대신 이벤트 발행
await this.eventBus.publish('order.created', {
orderId: order.id,
userId: order.userId,
items: order.items,
totalAmount: order.totalAmount,
createdAt: new Date()
});
return order; // 이벤트 처리 완료를 기다리지 않고 즉시 반환
}
// 이벤트 구독: 각 서비스가 독립적으로 처리
// inventory-service/src/handlers/order.handler.ts
@EventHandler('order.created')
async handleOrderCreated(event: OrderCreatedEvent) {
await this.inventoryService.decreaseStock(event.items);
}
// notification-service/src/handlers/order.handler.ts
@EventHandler('order.created')
async handleOrderCreated(event: OrderCreatedEvent) {
await this.notificationService.sendOrderConfirmation(event.userId, event.orderId);
}
김개발 씨가 처음 설계한 주문 처리 흐름은 이랬습니다. 주문 생성 후 재고 서비스 호출, 성공하면 포인트 서비스 호출, 성공하면 알림 서비스 호출, 마지막으로 통계 서비스 호출.
모든 호출이 성공해야 주문 완료 응답을 보낼 수 있었습니다. 문제는 명확했습니다.
통계 서비스가 2초 걸리면 사용자는 주문 확인까지 2초를 기다려야 했습니다. 알림 서비스에 장애가 나면 주문 자체가 실패했습니다.
박시니어 씨가 말했습니다. "사용자 입장에서 생각해보세요.
주문이 접수되었다는 확인만 빨리 받으면 되지, 알림이 언제 오는지는 중요하지 않잖아요." 이벤트 기반 아키텍처는 이런 상황에 딱 맞는 해결책입니다. 라디오 방송에 비유하면 이해가 쉽습니다.
DJ가 노래를 틀 때, 청취자 한 명 한 명에게 전화해서 "지금 이 노래 들으세요"라고 하지 않습니다. 그냥 전파를 쏘면, 듣고 싶은 사람이 알아서 채널을 맞춥니다.
위 코드에서 주문 서비스는 주문을 저장한 후, order.created라는 이벤트를 발행하고 바로 응답합니다. 재고 서비스, 알림 서비스, 통계 서비스는 이 이벤트를 구독하고 있다가, 이벤트가 오면 각자의 속도로 처리합니다.
이 방식의 장점은 명확합니다. 첫째, 응답 속도가 빨라집니다.
주문 서비스는 이벤트 발행 후 바로 응답하므로, 다른 서비스의 처리 시간에 영향받지 않습니다. 둘째, 장애 격리가 됩니다.
알림 서비스가 다운되어도 주문은 정상 처리됩니다. 셋째, 확장이 쉽습니다.
새로운 기능을 추가할 때 기존 코드를 수정하지 않고, 이벤트를 구독하는 새 서비스만 만들면 됩니다. 실무에서 이벤트 기반 아키텍처를 구현할 때는 메시지 브로커가 필요합니다.
Apache Kafka, RabbitMQ, AWS SNS/SQS 등이 대표적입니다. Kafka는 대용량 이벤트 스트리밍에 강하고, RabbitMQ는 복잡한 라우팅이 필요할 때 유용합니다.
다만 주의할 점도 있습니다. 이벤트 순서 보장, 중복 처리, 실패한 이벤트의 재처리 등 고려해야 할 것이 많습니다.
**최종 일관성(Eventual Consistency)**을 받아들여야 하며, 즉시 일관성이 필요한 경우에는 적합하지 않습니다. 김개발 씨는 이벤트 기반으로 전환한 후, 주문 응답 시간이 2초에서 200ms로 단축된 것을 보고 감탄했습니다.
실전 팁
💡 - 이벤트 핸들러는 멱등성을 보장하도록 설계하세요 (같은 이벤트를 여러 번 처리해도 결과가 같아야 함)
- 중요한 비즈니스 이벤트는 이벤트 저장소에 기록하여 추적 가능하게 하세요
- 처음부터 모든 것을 이벤트 기반으로 하지 말고, 비동기 처리가 적합한 부분부터 점진적으로 도입하세요
6. API 버전 관리 전략
서비스가 안정화된 후, 김개발 씨 팀은 API를 대폭 개선하기로 했습니다. 응답 구조를 바꾸고, 일부 필드명을 더 직관적으로 변경하려 했습니다.
그런데 문제가 생겼습니다. 이미 수만 명의 사용자가 기존 API를 사용하고 있었습니다.
"그냥 바꾸면 앱이 다 깨질 텐데요..."
API 버전 관리는 기존 클라이언트를 깨뜨리지 않으면서 API를 발전시키는 전략입니다. URL 경로에 버전을 넣는 방식, HTTP 헤더로 버전을 지정하는 방식, 쿼리 파라미터를 사용하는 방식 등이 있습니다.
각 방식의 장단점을 이해하고 프로젝트에 맞는 전략을 선택해야 합니다.
다음 코드를 살펴봅시다.
// 1. URL 경로 방식 (가장 명시적)
// GET /api/v1/users/123
// GET /api/v2/users/123
app.get('/api/v1/users/:id', userControllerV1.getUser);
app.get('/api/v2/users/:id', userControllerV2.getUser);
// 2. HTTP 헤더 방식 (URL이 깔끔)
// GET /api/users/123
// Header: Accept: application/vnd.myapi.v2+json
app.get('/api/users/:id', (req, res) => {
const version = req.headers['accept']?.match(/vnd\.myapi\.v(\d+)/)?.[1] || '1';
if (version === '2') return userControllerV2.getUser(req, res);
return userControllerV1.getUser(req, res);
});
// 3. 버전별 응답 변환 (내부 로직은 공유, 응답만 다르게)
// user.transformer.ts
const transformUserResponse = (user: User, version: string) => {
if (version === '2') {
return { userId: user.id, fullName: user.name, emailAddress: user.email };
}
// v1: 기존 응답 유지
return { id: user.id, name: user.name, email: user.email };
};
김개발 씨 팀은 1년 전 출시한 API의 문제점들을 발견했습니다. 응답에서 사용자 ID를 id라고 했는데, 다른 리소스의 ID와 구분하기 위해 userId로 바꾸고 싶었습니다.
name 필드도 fullName으로 바꾸면 firstName, lastName과 구분이 명확해질 것 같았습니다. 하지만 이미 수만 개의 모바일 앱이 기존 API를 사용하고 있었습니다.
필드명을 그냥 바꿔버리면, 앱 업데이트를 하지 않은 사용자들은 모두 오류를 경험하게 됩니다. 이 상황은 마치 도로 체계를 바꾸는 것과 같습니다.
기존 도로를 갑자기 폐쇄하면 혼란이 생기지만, 새 도로를 만들고 점진적으로 이전하면 문제없습니다. API 버전 관리가 바로 이 새 도로를 만드는 방법입니다.
가장 널리 사용되는 방식은 URL 경로에 버전을 포함하는 것입니다. /api/v1/users, /api/v2/users처럼 말이죠.
이 방식의 장점은 매우 명시적이라는 것입니다. URL만 봐도 어떤 버전을 사용하는지 바로 알 수 있습니다.
캐싱도 쉽고, 로드밸런서에서 버전별로 다른 서버로 라우팅하기도 편합니다. HTTP 헤더 방식도 있습니다.
URL은 그대로 두고, Accept 헤더에 버전 정보를 넣습니다. URL이 깔끔하게 유지되지만, 브라우저에서 테스트하기 어렵고 디버깅이 불편하다는 단점이 있습니다.
위 코드의 세 번째 예제처럼, 내부 로직은 공유하고 응답만 버전별로 변환하는 방법도 있습니다. 비즈니스 로직 중복을 피하면서 다양한 버전을 지원할 수 있습니다.
실무에서 중요한 것은 지원 정책입니다. 모든 버전을 영원히 유지할 수는 없습니다.
일반적으로 최신 버전과 바로 이전 버전만 지원하고, 오래된 버전은 충분한 유예 기간을 두고 폐기합니다. 이를 클라이언트에게 미리 공지하고, Deprecation 헤더로 알려주는 것이 좋습니다.
또 하나 중요한 원칙이 있습니다. 하위 호환성을 깨는 변경은 새 버전으로라는 것입니다.
필드 추가는 기존 버전에서도 가능하지만, 필드 삭제나 이름 변경은 새 버전에서 해야 합니다. 김개발 씨 팀은 v2 API를 새로 만들고, v1 사용자들에게 6개월의 마이그레이션 기간을 주었습니다.
API 문서에 변경 사항을 명시하고, v1 응답에 deprecation 경고를 추가했습니다.
실전 팁
💡 - URL 경로 방식이 가장 명시적이고 디버깅이 쉬워서 대부분의 경우 추천됩니다
- 새 버전 출시 전에 클라이언트 개발자들과 충분히 소통하고, 마이그레이션 가이드를 제공하세요
- 버전별 API 사용량을 모니터링하여 오래된 버전의 폐기 시점을 결정하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
메일 서버 아키텍처 설계 완벽 가이드
메일 서버를 직접 구축하고 운영하기 위한 아키텍처 설계 방법을 다룹니다. MTA, MDA부터 고가용성 설계까지 실무에서 필요한 모든 것을 초급자도 이해할 수 있도록 쉽게 설명합니다.
Docker 실전 프로젝트 완벽 가이드
프론트엔드, 백엔드, 데이터베이스를 Docker로 컨테이너화하고 Nginx 리버스 프록시를 구성하여 실제 프로덕션 환경에 배포하는 전 과정을 다룹니다. 실무에서 바로 적용할 수 있는 Docker Compose 기반 멀티 컨테이너 애플리케이션 구축법을 배웁니다.
Docker Compose 실전 활용 완벽 가이드
Docker Compose를 활용하여 복잡한 멀티 컨테이너 환경을 손쉽게 구축하고 관리하는 방법을 배웁니다. 개발 환경부터 운영 환경까지, 실무에서 바로 적용할 수 있는 핵심 기법들을 단계별로 알아봅니다.
API 표준과 규정 준수 완벽 가이드
현대 웹 서비스에서 필수적인 개인정보 보호와 API 보안에 대해 알아봅니다. GDPR부터 데이터 암호화, 감사 로그까지 실무에서 반드시 알아야 할 규정 준수 방법을 초보자도 이해할 수 있게 설명합니다.
OpenAPI/Swagger로 API 문서화 완벽 가이드
API 문서화의 표준인 OpenAPI와 Swagger를 활용하여 프론트엔드 개발자와 원활하게 협업하는 방법을 배웁니다. 스펙 작성부터 자동 문서 생성, 버전 관리까지 실무에서 바로 적용할 수 있는 내용을 다룹니다.