이미지 로딩 중...

REST API 개념과 설계 철학 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 25. · 5 Views

REST API 개념과 설계 철학 완벽 가이드

RESTful API의 핵심 개념과 6가지 제약 조건을 실무 중심으로 배웁니다. 리소스 중심 설계, 무상태성, 캐싱 등 REST의 철학을 이해하고 실전에 적용하는 방법을 알아봅니다.


목차

  1. REST란 무엇인가
  2. REST 6가지 제약 조건
  3. 리소스 중심 설계 사고방식
  4. 무상태성(Stateless)의 중요성
  5. 캐싱 가능성(Cacheable)
  6. 계층화 시스템(Layered System)

1. REST란 무엇인가

시작하며

여러분이 서버와 클라이언트 간에 데이터를 주고받는 API를 설계할 때 이런 고민을 해본 적 있나요? "URL을 어떻게 만들어야 할까?", "GET을 써야 할까 POST를 써야 할까?", "어떻게 하면 일관성 있는 API를 만들 수 있을까?" 같은 질문들 말이죠.

이런 고민 없이 API를 만들다 보면 /getUserData, /createNewUser, /deleteUserById 같은 URL이 마구잡이로 만들어집니다. 팀원마다 다른 스타일로 API를 만들고, 나중에는 어떤 API가 무슨 일을 하는지 헷갈리게 되죠.

바로 이럴 때 필요한 것이 REST(Representational State Transfer)입니다. REST는 웹의 창시자 중 한 명인 로이 필딩이 2000년에 제안한 아키텍처 스타일로, API를 설계할 때 따라야 할 명확한 원칙을 제시합니다.

개요

간단히 말해서, REST는 웹의 장점을 최대한 활용할 수 있는 네트워크 아키텍처 원칙입니다. HTTP를 기반으로 하며, 모든 것을 "리소스"라는 개념으로 표현합니다.

REST가 필요한 이유는 명확합니다. 일관된 인터페이스를 제공하여 클라이언트와 서버가 독립적으로 진화할 수 있게 하고, 확장성과 성능을 높일 수 있기 때문입니다.

예를 들어, 모바일 앱, 웹 브라우저, IoT 디바이스가 모두 같은 REST API를 사용할 수 있습니다. 전통적인 RPC(Remote Procedure Call) 방식에서는 함수를 호출하듯이 API를 설계했다면, REST에서는 리소스를 중심으로 생각합니다.

/getUser가 아니라 /users라는 리소스에 GET 요청을 보내는 방식으로 바뀐 것이죠. REST의 핵심 특징은 크게 세 가지입니다.

첫째, 리소스 기반 (모든 것은 리소스), 둘째, HTTP 메서드 활용 (GET, POST, PUT, DELETE), 셋째, 상태를 서버에 저장하지 않음 (무상태성). 이러한 특징들이 API를 단순하면서도 강력하게 만들어줍니다.

코드 예제

// REST API의 기본 구조 예시
// 사용자 리소스를 다루는 RESTful 엔드포인트

// 1. 모든 사용자 조회 (Read Collection)
GET /api/users

// 2. 특정 사용자 조회 (Read Single)
GET /api/users/123

// 3. 새 사용자 생성 (Create)
POST /api/users
Body: { "name": "김철수", "email": "kim@example.com" }

// 4. 사용자 정보 수정 (Update)
PUT /api/users/123
Body: { "name": "김영희", "email": "kim@example.com" }

// 5. 사용자 삭제 (Delete)
DELETE /api/users/123

// 리소스의 계층 구조 표현
GET /api/users/123/orders  // 특정 사용자의 주문 목록
GET /api/users/123/orders/456  // 특정 사용자의 특정 주문

설명

이것이 하는 일: REST는 네트워크 상의 모든 것을 리소스로 표현하고, HTTP 프로토콜을 통해 이 리소스를 조작하는 방법을 정의합니다. 첫 번째로, URL은 동사가 아닌 명사로 리소스를 표현합니다.

/getUsers가 아니라 /users처럼 말이죠. 이렇게 하는 이유는 리소스 자체에 집중하고, 그 리소스에 대한 행위는 HTTP 메서드로 표현하기 위해서입니다.

그 다음으로, HTTP 메서드가 실행되면서 리소스에 대한 CRUD(Create, Read, Update, Delete) 작업을 수행합니다. GET은 조회, POST는 생성, PUT은 수정, DELETE는 삭제를 담당합니다.

이는 SQL의 SELECT, INSERT, UPDATE, DELETE와 비슷한 개념이죠. 리소스의 계층 구조도 URL로 표현할 수 있습니다.

/users/123/orders는 "123번 사용자의 주문들"이라는 의미로, 사용자와 주문의 관계를 명확히 보여줍니다. 이런 방식은 API를 보는 것만으로도 데이터 구조를 이해할 수 있게 해줍니다.

여러분이 이 원칙을 사용하면 팀 전체가 동일한 스타일로 API를 작성할 수 있고, API 문서 없이도 직관적으로 사용법을 추측할 수 있습니다. 또한 캐싱, 로드밸런싱, 보안 등 HTTP 인프라의 이점을 그대로 활용할 수 있습니다.

실전 팁

💡 URL에는 동사를 넣지 말고 명사만 사용하세요. /createUser 대신 POST /users를 사용하면 더 RESTful합니다.

💡 복수형 명사를 사용하는 것이 일관성 있습니다. /user/123보다는 /users/123이 컬렉션과 단일 리소스를 명확히 구분합니다.

💡 버전 관리는 URL에 포함시키세요. /api/v1/users 형태로 하면 API가 변경되어도 기존 클라이언트가 영향받지 않습니다.

💡 에러 응답에는 적절한 HTTP 상태 코드를 사용하세요. 200(성공), 201(생성됨), 400(잘못된 요청), 404(없음), 500(서버 오류) 등을 구분해서 사용하면 클라이언트가 에러를 처리하기 쉽습니다.

💡 응답 데이터는 일관된 형식으로 제공하세요. 항상 JSON을 사용하고 { "data": {...}, "error": null } 같은 일관된 구조를 유지하면 클라이언트 개발이 편해집니다.


2. REST 6가지 제약 조건

시작하며

여러분이 "우리 API는 RESTful하다"고 말할 때, 정확히 무엇을 의미하는 걸까요? 단순히 JSON을 반환하고 HTTP를 사용한다고 해서 REST API라고 할 수 있을까요?

사실 많은 개발자들이 "REST 스타일" API를 만든다고 생각하지만, 실제로는 REST의 제약 조건을 제대로 따르지 않는 경우가 많습니다. 로이 필딩이 정의한 REST는 6가지 명확한 제약 조건을 모두 만족해야 진정한 REST라고 할 수 있습니다.

바로 이럴 때 필요한 것이 REST의 6가지 제약 조건에 대한 이해입니다. 이 원칙들을 알면 왜 특정 설계가 더 나은지, 어떤 부분을 개선해야 하는지 명확해집니다.

개요

간단히 말해서, REST의 6가지 제약 조건은 Client-Server, Stateless, Cacheable, Uniform Interface, Layered System, Code on Demand(선택적)입니다. 이 제약 조건들이 필요한 이유는 확장 가능하고 유지보수하기 쉬운 분산 시스템을 만들기 위해서입니다.

각 제약 조건은 특정 품질 속성(성능, 확장성, 단순성 등)을 향상시키는 목적을 가지고 있습니다. 예를 들어, 무상태성은 서버의 확장성을 높이고, 캐시 가능성은 네트워크 효율을 높입니다.

전통적인 모놀리식 아키텍처에서는 모든 것이 하나로 묶여있었다면, REST는 각 컴포넌트를 독립적으로 만들어 개별적으로 발전할 수 있게 합니다. 클라이언트는 서버의 내부 구현을 몰라도 되고, 서버는 클라이언트가 무엇인지 신경 쓰지 않아도 됩니다.

이 제약 조건들의 핵심 특징은 상호보완적이라는 것입니다. 하나만 따르는 것이 아니라 모두를 함께 적용할 때 시너지가 발생합니다.

Stateless가 Cacheable을 쉽게 만들고, Uniform Interface가 Layered System을 가능하게 하는 식이죠.

코드 예제

// REST 제약 조건을 따르는 Express 서버 예시

const express = require('express');
const app = express();

// 1. Client-Server: 클라이언트와 서버를 명확히 분리
// 2. Stateless: 각 요청은 독립적, 세션 정보를 서버에 저장하지 않음
app.get('/api/users/:id', (req, res) => {
  // 인증 정보를 매 요청마다 받음 (JWT 토큰 등)
  const token = req.headers.authorization;

  // 토큰에서 사용자 정보 추출 (서버는 세션을 저장하지 않음)
  const userId = verifyToken(token);

  // 3. Cacheable: 캐시 헤더 설정
  res.set('Cache-Control', 'public, max-age=300'); // 5분간 캐시
  res.set('ETag', generateETag(userData)); // 조건부 요청 지원

  // 4. Uniform Interface: 표준 HTTP 메서드와 상태 코드 사용
  const user = getUserById(req.params.id);

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  // 5. Layered System: 응답은 중간 레이어(프록시, 게이트웨이)를 거칠 수 있음
  res.status(200).json({ data: user });
});

// 6. Code on Demand (선택적): 필요시 클라이언트에 코드 전달
app.get('/api/widget', (req, res) => {
  res.json({
    data: { /* 데이터 */ },
    script: 'function render() { /* 렌더링 로직 */ }' // 선택적
  });
});

설명

이것이 하는 일: 6가지 제약 조건은 API가 확장 가능하고, 성능이 좋으며, 수정하기 쉽도록 보장하는 설계 원칙입니다. 첫 번째로, Client-Server 제약은 사용자 인터페이스와 데이터 저장소를 분리합니다.

이렇게 하는 이유는 클라이언트와 서버가 독립적으로 개발되고 배포될 수 있게 하기 위해서입니다. React 앱을 업데이트해도 백엔드 API는 그대로 유지될 수 있죠.

그 다음으로, Stateless(무상태성)는 서버가 클라이언트의 상태를 저장하지 않는다는 의미입니다. 매 요청에는 필요한 모든 정보가 포함되어야 합니다.

이는 서버를 수평 확장하기 쉽게 만듭니다. 어떤 서버가 요청을 처리해도 상관없으니까요.

Cacheable 제약은 응답이 캐시 가능한지 명시해야 한다는 것입니다. HTTP의 Cache-Control, ETag 같은 헤더를 사용하면 네트워크 요청을 줄이고 성능을 크게 향상시킬 수 있습니다.

같은 데이터를 매번 서버에서 가져올 필요가 없어지는 거죠. Uniform Interface는 REST의 가장 중요한 제약으로, 모든 리소스에 일관된 방식으로 접근할 수 있어야 합니다.

이는 리소스 식별(URI), 표현을 통한 리소스 조작(HTTP 메서드), 자기 서술적 메시지(Content-Type), HATEOAS(Hypermedia) 등을 포함합니다. 여러분이 이 제약 조건들을 따르면 API가 더 예측 가능해지고, 확장하기 쉬워지며, 성능도 개선됩니다.

새로운 개발자가 팀에 합류해도 일관된 패턴으로 빠르게 적응할 수 있습니다.

실전 팁

💡 JWT 토큰을 사용하면 Stateless를 쉽게 구현할 수 있습니다. 세션을 서버에 저장하는 대신 토큰에 필요한 정보를 담아 클라이언트가 들고 다니게 하세요.

💡 GET 요청에는 항상 적절한 캐시 헤더를 추가하세요. Cache-Control: public, max-age=3600만 추가해도 서버 부하가 크게 줄어듭니다.

💡 API 응답에 X-Response-Time 헤더를 추가하면 성능 모니터링이 쉬워집니다. Layered System의 각 레이어가 얼마나 시간을 소비하는지 추적할 수 있습니다.

💡 Code on Demand는 선택적이므로 꼭 구현할 필요는 없습니다. 대부분의 REST API는 이를 생략하고도 훌륭하게 작동합니다.

💡 제약 조건을 너무 완벽하게 따르려 하지 마세요. 실용적인 REST API는 "REST-like" 혹은 "RESTish"라고 불리기도 하는데, 중요한 것은 이 원칙들이 왜 존재하는지 이해하고 적절히 적용하는 것입니다.


3. 리소스 중심 설계 사고방식

시작하며

여러분이 새로운 기능을 만들 때 "어떤 API를 만들까?"라고 고민하며 함수 이름부터 생각하지 않나요? loginUser(), getUserProfile(), updateUserPassword() 같은 동사 중심의 이름 말이죠.

이런 방식으로 API를 만들다 보면 기능이 추가될 때마다 새로운 엔드포인트가 폭발적으로 늘어납니다. /api/login, /api/logout, /api/changePassword, /api/resetPassword 등등...

결국 API 문서는 방대해지고 일관성은 사라집니다. 바로 이럴 때 필요한 것이 리소스 중심 설계 사고방식입니다.

동사가 아닌 명사로, 행위가 아닌 대상으로 생각하는 패러다임 전환이 필요합니다.

개요

간단히 말해서, 리소스 중심 설계는 "무엇을 할까?"가 아니라 "무엇을 다룰까?"라는 질문에서 시작합니다. 시스템의 모든 데이터와 기능을 리소스라는 개체로 모델링하는 것이죠.

이 접근법이 필요한 이유는 API의 복잡도를 극적으로 낮출 수 있기 때문입니다. 수십 개의 동사 기반 엔드포인트 대신, 몇 개의 리소스와 표준 HTTP 메서드 조합으로 대부분의 기능을 표현할 수 있습니다.

예를 들어, 사용자 관련 20개 기능을 /users 리소스 하나로 통합할 수 있습니다. 전통적인 RPC 방식에서는 executePayment(), cancelPayment(), refundPayment() 같은 함수를 만들었다면, 리소스 중심 설계에서는 /payments라는 리소스에 대해 POST(생성), DELETE(취소), PATCH(환불 상태로 변경) 같은 표준 작업을 수행합니다.

리소스 중심 설계의 핵심 특징은 세 가지입니다. 첫째, 리소스는 명사로 표현됩니다.

둘째, 리소스는 고유한 식별자(URI)를 가집니다. 셋째, 리소스에 대한 모든 작업은 제한된 HTTP 메서드로 표현됩니다.

이러한 제약이 오히려 설계를 단순하고 일관되게 만듭니다.

코드 예제

// 동사 중심 설계 (나쁜 예)
POST /api/loginUser
POST /api/logoutUser
POST /api/changeUserPassword
POST /api/sendPasswordResetEmail
GET /api/getUserProfile
POST /api/updateUserProfile

// 리소스 중심 설계 (좋은 예)
// 리소스: 사용자(users), 세션(sessions), 프로필(profiles)

// 1. 로그인: 세션 리소스 생성
POST /api/sessions
Body: { "email": "user@example.com", "password": "***" }

// 2. 로그아웃: 세션 리소스 삭제
DELETE /api/sessions/current

// 3. 프로필 조회: 프로필 리소스 읽기
GET /api/users/123/profile

// 4. 프로필 수정: 프로필 리소스 업데이트
PATCH /api/users/123/profile
Body: { "bio": "새로운 소개" }

// 5. 비밀번호 재설정: 비밀번호 재설정 토큰 리소스 생성
POST /api/password-reset-tokens
Body: { "email": "user@example.com" }

// 6. 복잡한 리소스 관계 표현
GET /api/users/123/orders  // 사용자의 주문 목록
GET /api/orders/456/items  // 주문의 상품 목록
POST /api/users/123/orders/456/reviews  // 주문에 대한 리뷰 생성

설명

이것이 하는 일: 리소스 중심 설계는 시스템의 핵심 개체를 식별하고, 이들에 대한 CRUD 작업을 표준화된 방식으로 제공합니다. 첫 번째로, 도메인 모델에서 명사를 찾아냅니다.

전자상거래라면 "사용자", "상품", "주문", "리뷰" 같은 개체들이죠. 이렇게 하는 이유는 이 명사들이 곧 URI의 기본 구조가 되기 때문입니다.

/users, /products, /orders, /reviews처럼 말이죠. 그 다음으로, 각 리소스가 지원해야 할 작업을 HTTP 메서드로 매핑합니다.

GET은 조회, POST는 생성, PUT/PATCH는 수정, DELETE는 삭제입니다. 예를 들어, "주문을 취소한다"는 것은 DELETE /orders/123 또는 PATCH /orders/123 (상태를 'cancelled'로 변경)로 표현할 수 있습니다.

리소스 간의 관계도 URI 구조로 표현됩니다. /users/123/orders는 "123번 사용자의 주문들"이라는 의미입니다.

이는 리소스 간의 소유 관계나 연관 관계를 명확하게 보여줍니다. 클라이언트는 URL만 봐도 데이터 구조를 이해할 수 있죠.

특별한 작업이 필요할 때도 리소스로 모델링할 수 있습니다. "로그인"은 "세션 생성"으로, "결제"는 "결제 트랜잭션 생성"으로 생각하면 됩니다.

이렇게 하면 모든 것이 일관된 패턴으로 통일됩니다. 여러분이 이 사고방식을 적용하면 API 수가 줄어들고, 문서가 간결해지며, 클라이언트 개발자가 API를 학습하는 시간이 크게 단축됩니다.

새로운 리소스를 추가할 때도 기존 패턴을 따르면 되므로 설계 고민이 줄어듭니다.

실전 팁

💡 "이것이 진짜 리소스인가?" 테스트: CRUD 작업이 의미 있다면 리소스입니다. 만약 생성이나 조회가 이상하다면 다른 리소스의 작업으로 표현하는 게 나을 수 있습니다.

💡 동사를 명사로 바꾸는 연습을 하세요. "검색한다" → "검색 결과(search-results)", "내보낸다" → "내보내기(exports)" 처럼 명사화하면 리소스가 보입니다.

💡 URL 깊이는 3단계를 넘지 않도록 하세요. /users/123/orders/456/items/789/reviews는 너무 복잡합니다. 대신 /order-items/789/reviews처럼 단축하세요.

💡 복수형을 일관되게 사용하세요. /user/123/users/123을 섞어 쓰지 말고 항상 /users/123처럼 복수형으로 통일하면 혼란이 줄어듭니다.

💡 리소스 이름은 케밥 케이스(kebab-case)를 사용하세요. /passwordResetTokens보다 /password-reset-tokens가 URL에서 더 읽기 쉽습니다.


4. 무상태성(Stateless)의 중요성

시작하며

여러분이 쇼핑몰 API를 운영하는데 사용자가 갑자기 10배로 늘어났다고 상상해보세요. 서버를 10대로 늘렸는데도 사용자들이 로그인이 풀린다거나 장바구니가 사라진다고 불평합니다.

왜 그럴까요? 이런 문제는 서버가 사용자의 상태(세션, 장바구니 등)를 메모리에 저장하고 있을 때 발생합니다.

사용자가 서버 A에 로그인했는데 다음 요청이 서버 B로 가면 서버 B는 그 사용자를 모르는 거죠. 이를 해결하려면 "고정 세션(sticky session)"이라는 복잡한 설정이 필요합니다.

바로 이럴 때 필요한 것이 무상태성(Stateless) 원칙입니다. 서버가 클라이언트의 상태를 저장하지 않으면 어떤 서버가 요청을 처리하든 상관없어집니다.

개요

간단히 말해서, 무상태성은 서버가 클라이언트의 이전 요청을 기억하지 않는다는 의미입니다. 각 요청은 그 자체로 완전해야 하며, 요청을 처리하는 데 필요한 모든 정보를 포함해야 합니다.

무상태성이 필요한 이유는 확장성 때문입니다. 서버가 상태를 저장하지 않으면 로드밸런서가 요청을 아무 서버에나 보낼 수 있고, 서버를 추가하거나 제거하기도 쉬워집니다.

예를 들어, Netflix는 수백만 사용자를 처리하기 위해 수천 대의 서버를 사용하는데, 무상태 API 덕분에 가능한 것입니다. 전통적인 상태 유지(stateful) 방식에서는 서버 메모리에 세션을 저장했다면, 무상태 방식에서는 JWT 토큰처럼 클라이언트가 상태를 들고 다닙니다.

서버는 그저 토큰을 검증하기만 하면 되죠. 무상태성의 핵심 특징은 세 가지입니다.

첫째, 각 요청은 독립적입니다. 둘째, 인증 정보는 매 요청마다 전달됩니다.

셋째, 서버를 재시작해도 클라이언트에 영향이 없습니다. 이러한 특징이 시스템을 더 견고하고 예측 가능하게 만듭니다.

코드 예제

// Stateful 방식 (나쁜 예)
const sessions = {}; // 서버 메모리에 세션 저장

app.post('/api/login', (req, res) => {
  const user = authenticateUser(req.body);
  const sessionId = generateSessionId();
  sessions[sessionId] = { userId: user.id }; // 상태 저장!
  res.json({ sessionId });
});

app.get('/api/cart', (req, res) => {
  const session = sessions[req.headers.sessionId]; // 이전 상태 의존
  if (!session) return res.status(401).send('Not logged in');
  // 문제: 이 요청이 다른 서버로 가면 세션을 찾을 수 없음
});

// Stateless 방식 (좋은 예)
const jwt = require('jsonwebtoken');

app.post('/api/login', (req, res) => {
  const user = authenticateUser(req.body);

  // 상태를 토큰에 담아 클라이언트에게 전달
  const token = jwt.sign(
    { userId: user.id, email: user.email },
    'secret-key',
    { expiresIn: '1h' }
  );

  res.json({ token }); // 서버는 아무것도 저장하지 않음
});

app.get('/api/cart', (req, res) => {
  // 매 요청마다 토큰을 받아서 검증
  const token = req.headers.authorization?.split(' ')[1];

  try {
    const decoded = jwt.verify(token, 'secret-key');
    // 어떤 서버가 이 요청을 처리해도 상관없음!
    const cart = getCartByUserId(decoded.userId);
    res.json({ data: cart });
  } catch (err) {
    res.status(401).json({ error: 'Invalid token' });
  }
});

설명

이것이 하는 일: 무상태성은 서버의 메모리 부담을 없애고, 수평 확장을 가능하게 하며, 시스템의 신뢰성을 높입니다. 첫 번째로, 클라이언트는 로그인할 때 JWT 토큰을 받습니다.

이 토큰에는 사용자 ID, 권한, 만료 시간 등 서버가 알아야 할 모든 정보가 암호화되어 들어있습니다. 이렇게 하는 이유는 서버가 "이 사용자가 누구지?" 같은 정보를 메모리에 저장할 필요가 없어지기 때문입니다.

그 다음으로, 클라이언트는 매 API 요청마다 이 토큰을 Authorization 헤더에 담아 보냅니다. 서버는 토큰을 받아서 서명을 검증하고, 유효하면 토큰 안의 정보를 믿고 사용합니다.

데이터베이스나 메모리에서 세션을 찾을 필요가 없어 응답 속도가 빨라집니다. 로드밸런서가 요청을 서버 A, B, C 중 아무 곳으로 보내도 상관없습니다.

모든 서버가 같은 비밀 키로 토큰을 검증할 수 있으니까요. 심지어 서버를 재시작하거나 새로 추가해도 클라이언트는 아무런 영향을 받지 않습니다.

단점도 있습니다. 토큰이 탈취되면 만료될 때까지 악용될 수 있고, 토큰 크기가 커지면 네트워크 오버헤드가 증가합니다.

하지만 HTTPS를 사용하고 짧은 만료 시간을 설정하면 이 위험을 크게 줄일 수 있습니다. 여러분이 무상태 API를 만들면 AWS Auto Scaling 같은 기능을 쉽게 사용할 수 있고, 서버 장애가 발생해도 다른 서버가 즉시 대체할 수 있습니다.

시스템의 전체적인 견고함이 크게 향상됩니다.

실전 팁

💡 JWT 토큰의 만료 시간은 짧게(15-60분), Refresh Token은 길게(2주-1개월) 설정하세요. 보안과 사용자 경험의 균형을 맞출 수 있습니다.

💡 민감한 정보는 토큰에 넣지 마세요. 토큰은 Base64 디코딩만으로 내용을 볼 수 있습니다. 사용자 ID 정도만 넣고, 상세 정보는 필요할 때 DB에서 가져오세요.

💡 토큰을 블랙리스트로 관리하고 싶다면 Redis를 사용하세요. 완전한 무상태는 아니지만, 세션 저장소보다 훨씬 가볍고 빠릅니다.

💡 API Gateway나 로드밸런서에서 토큰 검증을 하면 백엔드 서버의 부담이 줄어듭니다. AWS API Gateway의 Lambda Authorizer가 좋은 예입니다.

💡 토큰 갱신(refresh) 로직을 잘 구현하세요. 만료된 Access Token과 유효한 Refresh Token을 보내면 새 Access Token을 발급하는 /api/token/refresh 엔드포인트를 만드세요.


5. 캐싱 가능성(Cacheable)

시작하며

여러분의 API 서버가 매일 같은 상품 목록을 조회하는 요청을 수만 번 받는다고 생각해보세요. 상품 정보는 하루에 한두 번만 바뀌는데, 매번 데이터베이스를 조회하고 JSON을 만들어서 응답합니다.

CPU도 낭비되고 응답도 느립니다. 이런 문제는 많은 API에서 발생합니다.

특히 조회가 많고 변경이 적은 데이터일수록 심각합니다. 뉴스 기사, 상품 정보, 공지사항 같은 것들은 한번 생성되면 거의 바뀌지 않는데도 매번 새로 만들어집니다.

바로 이럴 때 필요한 것이 캐싱 가능성(Cacheable) 원칙입니다. HTTP의 강력한 캐싱 메커니즘을 활용하면 서버 부하는 줄이고 응답 속도는 높일 수 있습니다.

개요

간단히 말해서, 캐싱 가능성은 API 응답이 재사용될 수 있는지를 명시적으로 표시하는 것입니다. HTTP 헤더를 통해 "이 데이터는 5분간 캐시해도 된다" 또는 "이 데이터는 캐시하지 마라"를 알려주는 거죠.

캐싱이 필요한 이유는 명확합니다. 같은 요청을 반복 처리하는 것은 자원 낭비입니다.

캐시를 사용하면 서버는 트래픽이 10배 증가해도 실제로는 2배만 처리하고, 사용자는 밀리초 단위로 빠른 응답을 받을 수 있습니다. 예를 들어, Instagram의 피드는 짧은 시간 동안 캐시되어 수억 명의 사용자를 지원합니다.

전통적인 방식에서는 애플리케이션 레벨에서 직접 캐시를 구현했다면, REST에서는 HTTP의 표준 캐싱 메커니즘을 활용합니다. 브라우저, CDN, 프록시 서버가 자동으로 캐싱을 처리해주니까요.

캐싱의 핵심 특징은 세 가지입니다. 첫째, Cache-Control 헤더로 캐시 정책을 지정합니다.

둘째, ETag로 데이터 변경 여부를 효율적으로 확인합니다. 셋째, 조건부 요청으로 네트워크 대역폭을 절약합니다.

이러한 메커니즘이 자동으로 작동하여 성능을 향상시킵니다.

코드 예제

// Express에서 캐싱 구현하기
const express = require('express');
const crypto = require('crypto');
const app = express();

// 캐시 미들웨어
function cacheMiddleware(duration) {
  return (req, res, next) => {
    // GET 요청만 캐시 가능
    if (req.method !== 'GET') {
      return next();
    }

    // Cache-Control 헤더 설정
    res.set('Cache-Control', `public, max-age=${duration}`);
    next();
  };
}

// ETag를 사용한 조건부 요청 처리
app.get('/api/products', cacheMiddleware(300), (req, res) => {
  const products = getProducts(); // DB 조회

  // 응답 데이터의 해시값을 ETag로 사용
  const etag = crypto
    .createHash('md5')
    .update(JSON.stringify(products))
    .digest('hex');

  // 클라이언트의 If-None-Match 헤더 확인
  if (req.headers['if-none-match'] === etag) {
    // 데이터가 변경되지 않았으면 304 응답 (body 없음)
    return res.status(304).end();
  }

  // 데이터가 변경되었으면 ETag와 함께 응답
  res.set('ETag', etag);
  res.json({ data: products });
});

// 캐시하면 안 되는 데이터 (개인정보, 실시간 데이터)
app.get('/api/users/me', (req, res) => {
  res.set('Cache-Control', 'private, no-cache, no-store, must-revalidate');
  res.set('Expires', '0');

  const user = getCurrentUser(req.headers.authorization);
  res.json({ data: user });
});

// Last-Modified를 사용한 캐싱
app.get('/api/articles/:id', (req, res) => {
  const article = getArticle(req.params.id);
  const lastModified = new Date(article.updatedAt).toUTCString();

  // 클라이언트의 If-Modified-Since 헤더 확인
  if (req.headers['if-modified-since'] === lastModified) {
    return res.status(304).end();
  }

  res.set('Last-Modified', lastModified);
  res.set('Cache-Control', 'public, max-age=600');
  res.json({ data: article });
});

설명

이것이 하는 일: 캐싱 메커니즘은 서버, CDN, 브라우저 등 여러 레벨에서 데이터를 저장하여 불필요한 네트워크 요청과 서버 처리를 줄입니다. 첫 번째로, Cache-Control 헤더가 캐시 정책을 결정합니다.

max-age=300은 "300초 동안은 캐시된 데이터를 사용해도 된다"는 의미입니다. 이렇게 하는 이유는 클라이언트와 중간 프록시가 자동으로 캐싱을 처리할 수 있게 하기 위해서입니다.

그 다음으로, ETag(Entity Tag)는 리소스의 특정 버전을 식별하는 문자열입니다. 서버는 응답 데이터의 해시값을 ETag로 보내고, 클라이언트는 다음 요청 시 If-None-Match 헤더에 이 ETag를 담아 보냅니다.

데이터가 변경되지 않았다면 서버는 304 Not Modified를 응답하고, body는 보내지 않습니다. 네트워크 대역폭이 극적으로 절약되는 거죠.

Last-ModifiedIf-Modified-Since도 비슷한 원리로 작동합니다. 리소스의 마지막 수정 시간을 기준으로 변경 여부를 판단합니다.

이는 파일 시스템 기반의 정적 리소스에 특히 유용합니다. 캐시 전략은 데이터 특성에 따라 달라집니다.

공개 데이터는 public으로 CDN 캐시를 허용하고, 개인 데이터는 private로 브라우저만 캐시하게 하며, 민감한 데이터는 no-store로 아예 캐시하지 못하게 합니다. 여러분이 적절한 캐싱 전략을 사용하면 서버 비용이 크게 줄어듭니다.

실제로 잘 설계된 캐싱은 서버 대수를 절반으로 줄일 수 있고, 사용자는 거의 즉각적인 응답을 경험합니다. CDN을 함께 사용하면 전 세계 어디서든 빠른 응답을 보장할 수 있습니다.

실전 팁

💡 GET 요청만 캐시하세요. POST, PUT, DELETE는 캐시하면 안 됩니다. 이들은 서버 상태를 변경하는 요청이기 때문입니다.

💡 max-age 값은 데이터 특성에 맞게 설정하세요. 뉴스 피드는 60초, 상품 정보는 300초, 정적 이미지는 86400초(1일)처럼 차등 적용하면 효율적입니다.

💡 API 응답이 변경되면 ETag도 반드시 변경되어야 합니다. 그렇지 않으면 클라이언트가 오래된 데이터를 계속 사용하게 됩니다.

💡 Vary 헤더를 사용하여 헤더 기반 캐싱을 제어하세요. Vary: Accept-Language를 설정하면 언어별로 다른 캐시를 유지할 수 있습니다.

💡 캐시 무효화 전략을 미리 계획하세요. 데이터가 변경되었을 때 캐시를 어떻게 갱신할지 (time-based vs event-based) 결정해야 합니다.


6. 계층화 시스템(Layered System)

시작하며

여러분의 작은 스타트업이 성공해서 사용자가 폭발적으로 늘어났다고 상상해보세요. 처음에는 단순한 서버 하나였지만, 이제는 로드밸런서, CDN, API 게이트웨이, 캐시 서버, 보안 방화벽 등 여러 시스템을 추가해야 합니다.

이런 상황에서 클라이언트 코드를 매번 수정해야 한다면 악몽일 겁니다. "이제 CDN을 거쳐야 하니까 앱을 업데이트하세요", "API 게이트웨이 주소가 바뀌었으니 모든 클라이언트를 수정하세요" 같은 공지는 하고 싶지 않으시죠?

바로 이럴 때 필요한 것이 계층화 시스템(Layered System) 원칙입니다. 클라이언트는 직접 연결된 레이어만 알면 되고, 그 뒤에 몇 개의 레이어가 있는지 알 필요가 없습니다.

개요

간단히 말해서, 계층화 시스템은 클라이언트와 서버 사이에 여러 중간 레이어(프록시, 게이트웨이, 캐시 등)를 투명하게 배치할 수 있다는 원칙입니다. 이 원칙이 필요한 이유는 시스템이 성장하면서 단일 서버로는 해결할 수 없는 문제들이 생기기 때문입니다.

보안을 위해 방화벽이 필요하고, 성능을 위해 캐시가 필요하고, 확장을 위해 로드밸런서가 필요합니다. 예를 들어, Netflix는 클라이언트 요청이 실제 서버에 도달하기까지 수십 개의 레이어를 거치지만, 앱 개발자는 이를 전혀 몰라도 됩니다.

전통적인 직접 연결 방식에서는 클라이언트가 서버의 IP 주소를 알아야 했다면, 계층화 시스템에서는 클라이언트는 단일 엔드포인트(api.example.com)만 알고, 뒤의 복잡한 인프라는 숨겨집니다. 계층화 시스템의 핵심 특징은 세 가지입니다.

첫째, 각 레이어는 인접한 레이어만 알면 됩니다. 둘째, 중간 레이어를 추가하거나 제거해도 클라이언트는 영향을 받지 않습니다.

셋째, 각 레이어는 독립적으로 진화하고 확장할 수 있습니다. 이러한 특징이 시스템을 유연하고 확장 가능하게 만듭니다.

코드 예제

// 계층화 시스템 아키텍처 예시

// 레이어 1: 클라이언트 (React App)
// 클라이언트는 단일 API 엔드포인트만 알면 됨
const API_BASE_URL = 'https://api.example.com';

async function fetchUserProfile() {
  const response = await fetch(`${API_BASE_URL}/users/me`, {
    headers: { 'Authorization': `Bearer ${token}` }
  });
  return response.json();
  // 클라이언트는 뒤에 몇 개의 레이어가 있는지 모름
}

// 레이어 2: CDN / Edge (CloudFlare, CloudFront)
// - 정적 리소스 캐싱
// - DDoS 방어
// - SSL/TLS 종료

// 레이어 3: API Gateway (AWS API Gateway, Kong)
// - 인증/인가 (JWT 검증)
// - Rate Limiting (요청 제한)
// - 요청 로깅 및 모니터링
router.use('/api/*', (req, res, next) => {
  // JWT 토큰 검증
  const token = req.headers.authorization;
  if (!verifyToken(token)) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // Rate Limiting 체크
  if (isRateLimited(req.ip)) {
    return res.status(429).json({ error: 'Too many requests' });
  }

  next(); // 다음 레이어로 전달
});

// 레이어 4: Load Balancer (NGINX, HAProxy)
// - 여러 백엔드 서버로 트래픽 분산
// - 헬스 체크
// - 세션 유지 (필요시)

// 레이어 5: Application Server (Node.js, Express)
app.get('/api/users/me', async (req, res) => {
  // 실제 비즈니스 로직 처리
  const userId = req.user.id; // API Gateway에서 검증됨
  const user = await db.users.findById(userId);
  res.json({ data: user });
});

// 레이어 6: Database (PostgreSQL, MongoDB)
// - 데이터 영속성
// - 트랜잭션 관리

설명

이것이 하는 일: 계층화 시스템은 관심사의 분리를 통해 각 레이어가 특정 책임만 담당하게 하고, 전체 시스템의 복잡도를 관리 가능한 수준으로 유지합니다. 첫 번째로, 클라이언트는 단일 API 엔드포인트로 요청을 보냅니다.

https://api.example.com/users/me 같은 URL이죠. 이렇게 하는 이유는 클라이언트가 뒤의 복잡한 인프라를 알 필요가 없도록 하기 위해서입니다.

도메인 하나만 알면 충분합니다. 그 다음으로, 요청은 여러 레이어를 거칩니다.

먼저 CDN이 캐시를 확인하고, 캐시 미스면 API Gateway로 전달됩니다. API Gateway는 인증을 확인하고 rate limiting을 적용한 후, 로드밸런서로 보냅니다.

로드밸런서는 가용한 서버 중 하나를 선택하여 요청을 전달합니다. 각 레이어는 독립적으로 작동합니다.

CDN을 Cloudflare에서 AWS CloudFront로 바꿔도 다른 레이어는 영향받지 않습니다. API Gateway를 추가해도 클라이언트 코드는 수정할 필요가 없습니다.

로드밸런서 뒤의 서버를 10대에서 100대로 늘려도 API Gateway는 신경 쓰지 않습니다. 각 레이어는 특정 책임을 담당합니다.

CDN은 성능과 가용성, API Gateway는 보안과 정책, 로드밸런서는 확장성과 장애 대응, 애플리케이션 서버는 비즈니스 로직, 데이터베이스는 데이터 영속성을 담당하죠. 이런 분리가 시스템을 이해하고 유지보수하기 쉽게 만듭니다.

여러분이 계층화 시스템을 설계하면 트래픽이 증가할 때 필요한 레이어만 확장하면 됩니다. CPU가 병목이면 애플리케이션 서버를 늘리고, 네트워크가 병목이면 CDN을 강화하는 식으로요.

또한 보안 취약점이 발견되면 API Gateway만 업데이트하면 되므로 대응이 빠릅니다.

실전 팁

💡 각 레이어는 다음 레이어에 커스텀 헤더를 추가할 수 있습니다. X-Request-ID로 요청을 추적하고, X-Response-Time으로 각 레이어의 처리 시간을 측정하면 성능 병목을 찾기 쉽습니다.

💡 API Gateway를 사용하면 백엔드 서버를 private 네트워크에 숨길 수 있어 보안이 향상됩니다. 외부에서는 API Gateway만 접근 가능하게 만드세요.

💡 각 레이어의 로그를 중앙 집중식으로 수집하세요. ELK Stack이나 CloudWatch를 사용하면 전체 요청 흐름을 추적할 수 있습니다.

💡 Circuit Breaker 패턴을 구현하여 하위 레이어의 장애가 전체 시스템으로 전파되지 않도록 하세요. 한 서버가 다운되어도 다른 서버가 계속 서비스할 수 있습니다.

💡 레이어가 너무 많아지면 오히려 복잡도가 증가할 수 있습니다. 꼭 필요한 레이어만 추가하고, 각 레이어의 역할을 명확히 문서화하세요. "이 레이어는 왜 있는가?"에 답할 수 있어야 합니다.


#REST#API Design#HTTP#Stateless#Resource-Oriented#API,REST,아키텍처

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

WebSocket과 Server-Sent Events 실시간 통신 완벽 가이드

웹 애플리케이션에서 실시간 데이터 통신을 구현하는 핵심 기술인 WebSocket과 Server-Sent Events를 다룹니다. 채팅, 알림, 실시간 업데이트 등 현대 웹 서비스의 필수 기능을 구현하는 방법을 배워봅니다.

API 테스트 전략과 자동화 완벽 가이드

API 개발에서 필수적인 테스트 전략을 단계별로 알아봅니다. 단위 테스트부터 부하 테스트까지, 실무에서 바로 적용할 수 있는 자동화 기법을 익혀보세요.

효과적인 API 문서 작성법 완벽 가이드

API 문서는 개발자와 개발자 사이의 가장 중요한 소통 수단입니다. 이 가이드에서는 좋은 API 문서가 갖춰야 할 조건부터 Getting Started, 엔드포인트 설명, 에러 코드 문서화, 인증 가이드, 변경 이력 관리까지 체계적으로 배워봅니다.

API 캐싱과 성능 최적화 완벽 가이드

웹 서비스의 응답 속도를 획기적으로 개선하는 캐싱 전략과 성능 최적화 기법을 다룹니다. HTTP 캐싱부터 Redis, 데이터베이스 최적화, CDN까지 실무에서 바로 적용할 수 있는 핵심 기술을 초급자 눈높이에서 설명합니다.

OAuth 2.0과 소셜 로그인 완벽 가이드

OAuth 2.0의 핵심 개념부터 구글, 카카오 소셜 로그인 구현까지 초급 개발자를 위해 쉽게 설명합니다. 인증과 인가의 차이점, 다양한 Flow의 특징, 그리고 보안 고려사항까지 실무에 바로 적용할 수 있는 내용을 다룹니다.