HTTP 프로토콜 심화와 RESTful API 설계 완벽 가이드

HTTP 프로토콜 심화와 RESTful API 설계 완벽 가이드

이전 네트워크 기초 글에서 HTTP의 버전별 차이와 기본 메서드를 다루었다. 이 글에서는 한 단계 더 들어가, HTTP 메시지의 내부 구조, 헤더의 역할과 활용, 캐싱 메커니즘, 인증 방식, 그리고 REST 아키텍처의 본질과 실무적인 API 설계 원칙을 깊이 있게 정리한다.


1. HTTP 메시지 구조

HTTP는 클라이언트-서버 모델의 요청/응답 프로토콜이다. 모든 HTTP 통신은 요청(Request)응답(Response) 메시지의 교환으로 이루어진다.

1.1 요청 메시지 (Request)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────────────────────┐
│ 요청 라인 (Request Line)                                  │
│   메서드  요청대상(URI)  HTTP버전                           │
│   GET /api/users?page=1 HTTP/1.1                        │
├─────────────────────────────────────────────────────────┤
│ 요청 헤더 (Request Headers)                               │
│   Host: api.example.com                                 │
│   Accept: application/json                              │
│   Authorization: Bearer eyJhbGciOiJIUzI1...             │
│   Accept-Language: ko-KR,ko;q=0.9,en;q=0.8             │
│   Cache-Control: no-cache                               │
│   Connection: keep-alive                                │
├─────────────────────────────────────────────────────────┤
│ 빈 줄 (CRLF)                                            │
├─────────────────────────────────────────────────────────┤
│ 요청 본문 (Request Body) - 선택적                         │
│   {"name": "홍길동", "email": "hong@example.com"}        │
└─────────────────────────────────────────────────────────┘

1.2 응답 메시지 (Response)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌─────────────────────────────────────────────────────────┐
│ 상태 라인 (Status Line)                                   │
│   HTTP버전  상태코드  상태문구                               │
│   HTTP/1.1 200 OK                                       │
├─────────────────────────────────────────────────────────┤
│ 응답 헤더 (Response Headers)                              │
│   Content-Type: application/json; charset=UTF-8         │
│   Content-Length: 256                                    │
│   Cache-Control: max-age=3600                           │
│   ETag: "abc123"                                        │
│   Set-Cookie: sessionId=xyz; HttpOnly; Secure           │
├─────────────────────────────────────────────────────────┤
│ 빈 줄 (CRLF)                                            │
├─────────────────────────────────────────────────────────┤
│ 응답 본문 (Response Body)                                 │
│   {"id": 1, "name": "홍길동", ...}                       │
└─────────────────────────────────────────────────────────┘

1.3 주요 요청 헤더 상세

헤더 역할 예시
Host 요청 대상 서버 (HTTP/1.1 필수) Host: api.example.com
Accept 클라이언트가 원하는 응답 미디어 타입 Accept: application/json
Content-Type 요청 본문의 미디어 타입 Content-Type: application/json
Authorization 인증 정보 Authorization: Bearer token
Accept-Encoding 지원하는 압축 방식 Accept-Encoding: gzip, deflate, br
Accept-Language 선호하는 언어 Accept-Language: ko-KR,ko;q=0.9
If-None-Match 캐시 검증 (ETag) If-None-Match: "abc123"
If-Modified-Since 캐시 검증 (날짜) If-Modified-Since: Tue, 01 Jan 2026...
Cookie 서버에서 받은 쿠키 전송 Cookie: sessionId=abc
User-Agent 클라이언트 소프트웨어 정보 User-Agent: Mozilla/5.0...

1.4 주요 응답 헤더 상세

헤더 역할 예시
Content-Type 응답 본문의 미디어 타입 Content-Type: application/json
Content-Length 응답 본문의 바이트 크기 Content-Length: 1024
Cache-Control 캐싱 정책 Cache-Control: max-age=3600
ETag 리소스 버전 식별자 ETag: "abc123"
Last-Modified 리소스 최종 수정 시간 Last-Modified: Tue, 01 Jan...
Set-Cookie 클라이언트에 쿠키 저장 지시 Set-Cookie: id=abc; HttpOnly
Location 리다이렉트 대상 URI Location: /api/users/1
Access-Control-Allow-Origin CORS 허용 출처 Access-Control-Allow-Origin: *

2. HTTP 메서드 심화

2.1 안전성(Safety)과 멱등성(Idempotency)

HTTP 메서드를 이해할 때 가장 중요한 두 가지 개념이 안전성멱등성이다.

안전성(Safe): 서버의 상태를 변경하지 않는 메서드. GET, HEAD, OPTIONS가 안전한 메서드다. 안전한 메서드는 캐싱이 가능하고, 브라우저가 자유롭게 prefetch 할 수 있다.

멱등성(Idempotent): 같은 요청을 1번 보내든 100번 보내든 서버의 최종 상태가 동일한 메서드. GET, PUT, DELETE, HEAD, OPTIONS가 멱등하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────┬────────┬────────┬────────────────────────────────┐
│  메서드   │ 안전성  │ 멱등성  │            설명                 │
├──────────┼────────┼────────┼────────────────────────────────┤
│  GET     │   O    │   O    │ 리소스 조회                     │
│  HEAD    │   O    │   O    │ GET과 동일하나 본문 없이 헤더만    │
│  OPTIONS │   O    │   O    │ 지원하는 메서드 확인 (CORS 등)    │
├──────────┼────────┼────────┼────────────────────────────────┤
│  PUT     │   X    │   O    │ 리소스 전체 교체 (없으면 생성)     │
│  DELETE  │   X    │   O    │ 리소스 삭제                     │
├──────────┼────────┼────────┼────────────────────────────────┤
│  POST    │   X    │   X    │ 리소스 생성 / 프로세스 실행       │
│  PATCH   │   X    │   X    │ 리소스 부분 수정                 │
└──────────┴────────┴────────┴────────────────────────────────┘

왜 멱등성이 중요한가? 네트워크 장애 시 재시도(retry) 전략과 직결된다. 멱등한 메서드는 응답을 받지 못했을 때 안전하게 재전송할 수 있다. POST는 멱등하지 않으므로 재전송하면 중복 생성이 발생할 수 있다. 이 때문에 결제 같은 POST 요청에는 멱등키(Idempotency Key)를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 멱등키를 활용한 결제 API - 중복 결제 방지
@PostMapping("/api/payments")
public ResponseEntity<Payment> processPayment(
        @RequestHeader("Idempotency-Key") String idempotencyKey,
        @RequestBody PaymentRequest request) {

    // 이미 처리된 요청인지 확인
    Optional<Payment> existing = paymentService.findByIdempotencyKey(idempotencyKey);
    if (existing.isPresent()) {
        return ResponseEntity.ok(existing.get());  // 이전 결과 반환
    }

    Payment payment = paymentService.process(request, idempotencyKey);
    return ResponseEntity.status(HttpStatus.CREATED).body(payment);
}

2.2 PUT vs PATCH: 정확한 차이

PUT과 PATCH를 혼동하는 경우가 많다. 핵심 차이는 전체 교체부분 수정이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
기존 리소스:
{
    "id": 1,
    "name": "홍길동",
    "email": "hong@example.com",
    "age": 25
}

PUT /api/users/1  →  전체 교체 (보내지 않은 필드는 null/기본값)
Body: { "name": "홍길동", "email": "new@example.com" }
결과: { "id": 1, "name": "홍길동", "email": "new@example.com", "age": null }
                                                                 ↑ 사라짐!

PATCH /api/users/1  →  부분 수정 (보내지 않은 필드는 유지)
Body: { "email": "new@example.com" }
결과: { "id": 1, "name": "홍길동", "email": "new@example.com", "age": 25 }
                                                                 ↑ 유지됨
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Spring에서의 구현 차이
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id,
                                        @RequestBody UserDto dto) {
    // 전체 필드를 새 값으로 교체
    User user = userService.findById(id)
        .orElseThrow(() -> new ResourceNotFoundException("User", id));

    user.setName(dto.getName());
    user.setEmail(dto.getEmail());
    user.setAge(dto.getAge());       // null이면 null로 세팅
    user.setPhone(dto.getPhone());   // null이면 null로 세팅

    return ResponseEntity.ok(userService.save(user));
}

@PatchMapping("/{id}")
public ResponseEntity<User> patchUser(@PathVariable Long id,
                                       @RequestBody Map<String, Object> updates) {
    // 전달된 필드만 수정
    User user = userService.findById(id)
        .orElseThrow(() -> new ResourceNotFoundException("User", id));

    updates.forEach((key, value) -> {
        switch (key) {
            case "name"  -> user.setName((String) value);
            case "email" -> user.setEmail((String) value);
            case "age"   -> user.setAge((Integer) value);
            case "phone" -> user.setPhone((String) value);
        }
    });

    return ResponseEntity.ok(userService.save(user));
}

2.3 POST vs PUT: 리소스 생성에서의 차이

POST와 PUT 모두 리소스를 생성할 수 있지만, 차이점이 명확하다.

1
2
3
4
5
POST /api/users          →  서버가 URI를 결정 (응답: Location: /api/users/42)
PUT  /api/users/42       →  클라이언트가 URI를 결정 (해당 URI에 생성)

POST: "이 컬렉션에 새 리소스를 만들어줘" (서버가 ID 부여)
PUT:  "이 URI에 이 리소스를 놓아줘" (클라이언트가 ID 지정)

3. HTTP 상태 코드 심화

3.1 상태 코드 분류와 설계 원칙

상태 코드를 올바르게 사용하는 것은 API 품질의 핵심이다.

1
2
3
4
5
1xx (정보)        →  요청 수신, 처리 계속
2xx (성공)        →  요청 성공적으로 처리
3xx (리다이렉션)   →  추가 동작 필요
4xx (클라이언트 오류) →  요청 자체에 문제
5xx (서버 오류)     →  서버 처리 중 문제

3.2 실무에서 자주 혼동되는 상태 코드

401 Unauthorized vs 403 Forbidden

이름이 혼란을 일으키지만, 실제 의미는 명확하다.

1
2
3
4
5
6
7
8
9
401 Unauthorized (인증 실패)
  → "너 누군지 모르겠다" (로그인 안 됨, 토큰 만료)
  → WWW-Authenticate 헤더와 함께 사용
  → 올바른 인증 정보를 제공하면 접근 가능

403 Forbidden (인가 실패)
  → "너 누군지 알지만, 권한이 없다" (일반 사용자가 관리자 페이지 접근)
  → 재인증해도 접근 불가
  → 리소스의 존재를 숨기고 싶으면 404로 응답하기도 함

301 vs 302 vs 307 vs 308

1
2
3
4
5
6
7
8
9
10
11
┌─────┬────────────────────┬──────────────┬───────────────────────┐
│코드 │       의미          │  메서드 변경  │         용도           │
├─────┼────────────────────┼──────────────┼───────────────────────┤
│ 301 │ Moved Permanently  │ GET으로 변경  │ 영구 이동 (SEO에 영향)  │
│ 302 │ Found              │ GET으로 변경  │ 임시 이동 (레거시)      │
│ 307 │ Temporary Redirect │ 메서드 유지   │ 임시 이동 (메서드 보존)  │
│ 308 │ Permanent Redirect │ 메서드 유지   │ 영구 이동 (메서드 보존)  │
└─────┴────────────────────┴──────────────┴───────────────────────┘

예: POST /old-endpoint → 301 리다이렉트 → 브라우저가 GET /new-endpoint로 변경
    POST /old-endpoint → 307 리다이렉트 → 브라우저가 POST /new-endpoint로 유지

200 vs 201 vs 204

1
2
3
200 OK          →  일반적인 성공 응답 (조회, 수정 결과 반환)
201 Created     →  리소스 생성 성공 (Location 헤더에 새 리소스 URI 포함)
204 No Content  →  성공했지만 응답 본문 없음 (DELETE 후, 또는 PUT으로 수정 후)

3.3 Spring에서의 상태 코드 설계

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@RestController
@RequestMapping("/api/articles")
public class ArticleController {

    @GetMapping("/{id}")
    public ResponseEntity<Article> getArticle(@PathVariable Long id) {
        return articleService.findById(id)
            .map(ResponseEntity::ok)                    // 200
            .orElse(ResponseEntity.notFound().build());  // 404
    }

    @PostMapping
    public ResponseEntity<Article> createArticle(@Valid @RequestBody ArticleDto dto) {
        Article article = articleService.create(dto);
        URI location = URI.create("/api/articles/" + article.getId());
        return ResponseEntity.created(location).body(article);  // 201 + Location 헤더
    }

    @PutMapping("/{id}")
    public ResponseEntity<Article> updateArticle(@PathVariable Long id,
                                                  @Valid @RequestBody ArticleDto dto) {
        Article article = articleService.update(id, dto);
        return ResponseEntity.ok(article);  // 200
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteArticle(@PathVariable Long id) {
        articleService.delete(id);
        return ResponseEntity.noContent().build();  // 204
    }
}

// 전역 예외 처리 - 일관된 에러 응답
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
            HttpStatus.NOT_FOUND.value(),
            ex.getMessage(),
            LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);  // 404
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .toList();

        ErrorResponse error = new ErrorResponse(
            HttpStatus.BAD_REQUEST.value(),
            "입력값 검증 실패",
            errors,
            LocalDateTime.now()
        );
        return ResponseEntity.badRequest().body(error);  // 400
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleForbidden(AccessDeniedException ex) {
        ErrorResponse error = new ErrorResponse(
            HttpStatus.FORBIDDEN.value(),
            "접근 권한이 없습니다",
            LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);  // 403
    }
}

4. Content Negotiation (콘텐츠 협상)

콘텐츠 협상은 클라이언트와 서버가 같은 URI에 대해 최적의 표현 형식을 합의하는 메커니즘이다. 하나의 리소스가 JSON, XML, HTML 등 다양한 형식으로 제공될 수 있다.

4.1 서버 주도 협상 (Server-driven)

클라이언트가 Accept 관련 헤더를 보내면, 서버가 가장 적합한 표현을 선택한다.

1
2
3
4
5
6
7
8
9
10
클라이언트 요청:
GET /api/users/1 HTTP/1.1
Accept: application/json, application/xml;q=0.9, text/html;q=0.5
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Accept-Encoding: gzip, deflate, br

q값 (Quality Value): 0~1 사이의 선호도 (기본값 1.0)
  application/json  → q=1.0 (가장 선호)
  application/xml   → q=0.9
  text/html         → q=0.5 (가장 낮은 선호)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Spring에서 Content Negotiation
@GetMapping(value = "/{id}",
    produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
public ResponseEntity<User> getUser(@PathVariable Long id) {
    User user = userService.findById(id)
        .orElseThrow(() -> new ResourceNotFoundException("User", id));
    return ResponseEntity.ok(user);
    // Accept 헤더에 따라 JSON 또는 XML로 자동 직렬화
}

// Content Negotiation 전역 설정
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        configurer
            .defaultContentType(MediaType.APPLICATION_JSON)
            .favorParameter(true)          // ?format=json 파라미터 지원
            .parameterName("format")
            .mediaType("json", MediaType.APPLICATION_JSON)
            .mediaType("xml", MediaType.APPLICATION_XML);
    }
}

4.2 응답의 Content-Type과 Vary 헤더

1
2
3
4
5
6
7
8
9
서버 응답:
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Content-Encoding: gzip
Content-Language: ko-KR
Vary: Accept, Accept-Encoding, Accept-Language

Vary 헤더는 캐시 서버에게 "이 응답은 Accept, Accept-Encoding, Accept-Language
헤더에 따라 달라질 수 있으니, 이 헤더들을 캐시 키에 포함시켜라"는 의미다.

5. HTTP 캐싱 메커니즘

캐싱은 웹 성능 최적화의 핵심이다. 동일한 리소스를 반복 요청할 때 네트워크 전송을 줄이고 응답 속도를 높인다.

5.1 캐시 제어 모델

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌──────────────────────────────────────────────────────────────────┐
│                      HTTP 캐시 흐름                               │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  클라이언트 ──→ [프록시 캐시] ──→ [CDN] ──→ 원본 서버              │
│       ↑              ↑            ↑                              │
│   브라우저 캐시   공유 캐시    공유 캐시                             │
│   (private)    (shared)    (shared)                               │
│                                                                  │
│  Cache-Control: private   → 브라우저만 캐시 (개인 정보 등)          │
│  Cache-Control: public    → 프록시/CDN도 캐시 가능                 │
│  Cache-Control: no-store  → 캐시 금지 (민감 정보)                  │
│  Cache-Control: no-cache  → 캐시하되, 매번 서버에 검증 필요          │
│  Cache-Control: max-age=N → N초 동안 캐시 유효 (검증 없이 사용)     │
└──────────────────────────────────────────────────────────────────┘

no-cache vs no-store 차이 - 가장 많이 혼동되는 부분이다.

1
2
3
4
5
6
7
no-cache: "캐시에 저장은 해도 되지만, 사용하기 전에 반드시 서버에 확인해"
           → 매 요청마다 조건부 요청(If-None-Match)을 보내 검증
           → 변경되지 않았으면 304 Not Modified (본문 전송 안 함 = 빠름)

no-store: "아예 캐시에 저장하지 마"
           → 매 요청마다 전체 응답을 다시 받아야 함
           → 개인정보, 결제 정보 등 민감한 데이터에 사용

5.2 조건부 요청과 검증

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
< 첫 번째 요청 >

클라이언트                           서버
    │  GET /api/articles/1            │
    │ ──────────────────────────────→ │
    │                                 │
    │  200 OK                         │
    │  ETag: "v1-abc123"              │
    │  Cache-Control: no-cache        │
    │  Body: {"title": "제목"...}      │
    │ ←────────────────────────────── │
    │                                 │
    │  (브라우저 캐시에 저장)            │

< 두 번째 요청 - 캐시 검증 >

클라이언트                           서버
    │  GET /api/articles/1            │
    │  If-None-Match: "v1-abc123"     │  ← 캐시된 ETag를 보냄
    │ ──────────────────────────────→ │
    │                                 │
    │  [변경 없는 경우]                 │
    │  304 Not Modified               │  ← 본문 없음 (대역폭 절약)
    │ ←────────────────────────────── │
    │                                 │
    │  [변경된 경우]                    │
    │  200 OK                         │
    │  ETag: "v2-def456"              │  ← 새로운 ETag
    │  Body: {"title": "수정된 제목"}   │
    │ ←────────────────────────────── │

ETag 생성 전략:

  • Strong ETag: 바이트 단위로 완전히 동일해야 함. "abc123"
  • Weak ETag: 의미적으로 동일하면 됨. W/"abc123" (포맷 변경 등은 무시)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Spring에서의 ETag 활용
@GetMapping("/{id}")
public ResponseEntity<Article> getArticle(@PathVariable Long id) {
    Article article = articleService.findById(id)
        .orElseThrow(() -> new ResourceNotFoundException("Article", id));

    // ETag 생성 (버전 또는 해시 기반)
    String etag = "\"" + article.getVersion() + "-" + article.getUpdatedAt().hashCode() + "\"";

    return ResponseEntity.ok()
        .eTag(etag)
        .cacheControl(CacheControl.noCache())  // 매번 검증
        .body(article);
}

// ShallowEtagHeaderFilter를 사용한 자동 ETag 처리
@Bean
public FilterRegistrationBean<ShallowEtagHeaderFilter> etagFilter() {
    FilterRegistrationBean<ShallowEtagHeaderFilter> registration
        = new FilterRegistrationBean<>();
    registration.setFilter(new ShallowEtagHeaderFilter());
    registration.addUrlPatterns("/api/*");
    return registration;
}

5.3 캐시 전략별 사용 사례

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌──────────────────────────┬────────────────────────────────────┐
│       리소스 유형          │         캐시 전략                   │
├──────────────────────────┼────────────────────────────────────┤
│ 정적 파일 (JS, CSS, 이미지)│ Cache-Control: public, max-age=   │
│ (파일명에 해시 포함)       │ 31536000 (1년)                     │
│                          │ → 파일 변경 시 URL이 바뀌므로 안전   │
├──────────────────────────┼────────────────────────────────────┤
│ API 응답 (자주 변경)       │ Cache-Control: no-cache            │
│                          │ ETag 사용하여 조건부 요청             │
├──────────────────────────┼────────────────────────────────────┤
│ 사용자 프로필 등 개인 정보  │ Cache-Control: private, no-cache   │
│                          │ → CDN/프록시 캐시 금지              │
├──────────────────────────┼────────────────────────────────────┤
│ 결제/인증 토큰 등 민감 정보 │ Cache-Control: no-store            │
│                          │ → 캐시 자체를 금지                   │
├──────────────────────────┼────────────────────────────────────┤
│ 뉴스 목록 등 준실시간      │ Cache-Control: public,             │
│                          │ max-age=60, stale-while-revalidate │
│                          │ =30 → 60초간 캐시, 이후 30초간 stale│
│                          │ 응답 허용하며 백그라운드 갱신          │
└──────────────────────────┴────────────────────────────────────┘

5.4 CDN 캐싱과 Cache-Control 전략 실무

실무에서 캐싱을 설계할 때 단순히 Cache-Control 헤더 하나만 설정하는 것으로는 충분하지 않다. 대규모 서비스에서는 CDN(Content Delivery Network) 엣지 서버와 오리진 서버 사이의 캐싱 계층 구조를 이해하고, 각 계층에 맞는 전략을 수립해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
< CDN 엣지 캐싱 vs 오리진 캐싱 아키텍처 >

클라이언트 (브라우저)
  │
  │  ① 요청: GET /api/products/123
  ▼
┌──────────────────────────────────────────────────┐
│              CDN 엣지 서버 (Edge)                   │
│                                                    │
│  ┌────────────────────────────────────────┐        │
│  │          Edge Cache                    │        │
│  │  Key: scheme+host+path+vary_headers    │        │
│  │  TTL: s-maxage 기반                     │        │
│  │  stale-while-revalidate 지원            │        │
│  └────────────────────────────────────────┘        │
│                                                    │
│  Cache HIT → ② 즉시 응답 (수 ms)                   │
│  Cache MISS ↓                                      │
└──────────────────────────────────────────────────┘
  │
  │  ③ 오리진으로 요청 전달 (Cache MISS 시)
  ▼
┌──────────────────────────────────────────────────┐
│            리버스 프록시 / 로드밸런서                  │
│            (Nginx, HAProxy 등)                     │
│                                                    │
│  ┌────────────────────────────────────────┐        │
│  │       Proxy Cache (선택적)              │        │
│  │  프록시 레벨에서도 캐싱 가능              │        │
│  └────────────────────────────────────────┘        │
└──────────────────────────────────────────────────┘
  │
  │  ④ 애플리케이션 서버로 전달
  ▼
┌──────────────────────────────────────────────────┐
│              오리진 서버 (Origin)                    │
│                                                    │
│  ┌────────────────────────────────────────┐        │
│  │     Application Cache (Redis 등)       │        │
│  │  DB 쿼리 결과, 연산 결과 캐싱            │        │
│  └────────────────────────────────────────┘        │
│                                                    │
│  ⑤ 응답 생성 + Cache-Control 헤더 설정              │
│     Cache-Control: public, s-maxage=300,           │
│     max-age=60, stale-while-revalidate=30          │
└──────────────────────────────────────────────────┘

위 아키텍처에서 핵심적인 구분은 max-ages-maxage의 차이다. max-age는 브라우저(사설 캐시)에 적용되고, s-maxage는 CDN/프록시(공유 캐시)에 적용된다. 즉, 같은 응답이라도 CDN에는 5분간 캐싱하고, 브라우저에는 1분만 캐싱하도록 분리 설정할 수 있다.

Cache-Key의 구성

CDN이 캐시된 응답을 식별하는 키(Cache-Key)는 단순히 URL만으로 구성되지 않는다. 정확한 Cache-Key는 scheme + host + path + Vary 헤더에 명시된 요청 헤더 값의 조합이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
< Cache-Key 구성 예시 >

요청:
  GET /api/products/123 HTTP/1.1
  Host: api.example.com
  Accept-Language: ko-KR
  Accept-Encoding: gzip

응답 헤더:
  Vary: Accept-Language, Accept-Encoding

이 경우 Cache-Key는:
  https://api.example.com/api/products/123
    + Accept-Language=ko-KR
    + Accept-Encoding=gzip

→ 같은 URL이라도 Accept-Language가 en-US인 요청은
  별도의 캐시 엔트리로 저장된다.

Vary 헤더를 잘못 설정하면 발생하는 문제:
  - Vary: * → 모든 요청이 다른 캐시 키 → 캐시 무효화와 동일
  - Vary 누락 → 한국어 사용자에게 영어 응답이 내려갈 수 있음
  - Vary: Cookie → 사용자마다 캐시 분리 → CDN 캐시 적중률 급락

stale-while-revalidate와 stale-if-error

현대 캐싱 전략에서 가장 실용적인 지시어 두 가지가 stale-while-revalidatestale-if-error다. 이 두 지시어는 사용자 경험과 시스템 안정성을 동시에 확보하는 데 핵심적인 역할을 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
< stale-while-revalidate 동작 과정 >

Cache-Control: max-age=60, stale-while-revalidate=30

시간축:
0초          60초         90초
├────────────┼────────────┤
│   FRESH    │   STALE    │  EXPIRED
│  (신선)     │ (허용 구간) │  (완전 만료)
│            │            │

① 0~60초 사이 요청:
   → 캐시에서 즉시 응답 (신선한 상태)

② 60~90초 사이 요청:
   → 캐시에서 즉시 stale 응답 반환 (사용자는 기다리지 않음)
   → 동시에 백그라운드에서 오리진에 재검증 요청
   → 새 응답이 오면 캐시 갱신

③ 90초 이후 요청:
   → 캐시 완전 만료, 오리진에서 새 응답을 받을 때까지 대기

stale-while-revalidate의 가장 큰 장점은 사용자가 캐시 갱신을 기다리지 않는다는 점이다. 캐시가 만료되어도 즉시 이전 응답을 내려주고, 백그라운드에서 조용히 캐시를 갱신한다. 뉴스 피드, 상품 목록, 추천 리스트 등 약간의 데이터 지연이 허용되는 API에 적합하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
< stale-if-error 동작 과정 >

Cache-Control: max-age=300, stale-if-error=86400

시나리오: 오리진 서버 장애 발생

① 정상 상태:
   → max-age=300에 따라 5분마다 오리진에서 갱신

② 오리진 서버 다운 (5xx 응답 또는 네트워크 오류):
   → stale-if-error=86400 (24시간) 동안은
     만료된 캐시 응답을 계속 제공
   → 사용자는 서버 장애를 인지하지 못함

③ 오리진 복구 후:
   → 다음 재검증 시 정상 응답을 받으면 캐시 갱신
   → 서비스 자연스럽게 정상화

실무 활용:
  - 마이크로서비스 간 통신에서 의존 서비스 장애 시 graceful degradation
  - CDN 단에서 오리진 장애 대비 안전망 역할
  - 결제/인증 등 실시간성이 중요한 API에는 사용하면 안 됨

Strong ETag vs Weak ETag

ETag는 리소스의 버전을 식별하는 검증자(validator)인데, 강한 검증과 약한 검증 두 가지 방식이 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
< Strong ETag vs Weak ETag >

Strong ETag (강한 검증):
  ETag: "abc123def456"
  → 바이트 단위로 완전히 동일함을 보장
  → Range 요청(부분 다운로드)에 사용 가능
  → 리소스의 어떤 부분이라도 변경되면 ETag가 바뀜
  → 파일 해시(MD5, SHA-256)로 생성하는 경우가 많음

Weak ETag (약한 검증):
  ETag: W/"abc123"
  → "의미적으로(semantically)" 동일함만 보장
  → 공백이나 포맷 변경 등 사소한 차이는 무시
  → Range 요청에 사용 불가
  → API 응답에서 핵심 데이터만 비교할 때 유용

비교:
┌──────────────┬────────────────────┬────────────────────┐
│     항목      │    Strong ETag     │    Weak ETag       │
├──────────────┼────────────────────┼────────────────────┤
│ 형식          │ "abc123"           │ W/"abc123"         │
│ 비교 범위     │ 바이트 단위 완전 일치│ 의미적 동등성       │
│ Range 요청    │ 사용 가능           │ 사용 불가          │
│ 생성 비용     │ 높음 (전체 해시)     │ 낮음 (일부 필드)    │
│ 적합한 상황   │ 정적 파일, 이미지    │ API 응답, HTML     │
│ If-Match      │ 지원               │ 지원 (약한 비교)    │
│ If-None-Match │ 지원               │ 지원 (약한 비교)    │
└──────────────┴────────────────────┴────────────────────┘

실무에서 API 응답에 Strong ETag를 사용하면 응답 직렬화 방식의 사소한 차이(JSON 키 순서 변경, 공백 차이 등)에도 ETag가 바뀌어 캐시 적중률이 떨어질 수 있다. 이런 경우 Weak ETag를 사용하면 핵심 데이터가 같을 때 캐시를 재활용할 수 있어 효율적이다.

캐시 무효화 전략

CDN 캐싱에서 가장 어려운 문제는 캐시 무효화(Cache Invalidation)다. Phil Karlton의 유명한 말처럼 “컴퓨터 과학에서 어려운 것은 캐시 무효화와 이름 짓기” 두 가지뿐이다. 실무에서 사용하는 주요 무효화 전략을 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
< 캐시 무효화 전략 비교 >

1. Purge (퍼지)
   → 특정 URL의 캐시를 즉시 삭제
   → 가장 정밀한 무효화 방식
   → 예: POST /purge/api/products/123
   → 삭제할 URL을 정확히 알아야 함

2. Ban (밴)
   → 패턴 매칭으로 다수의 캐시를 한번에 무효화
   → 예: /api/products/.* 패턴으로 모든 상품 캐시 무효화
   → Varnish의 ban 기능이 대표적
   → 즉시 삭제가 아니라, 다음 요청 시 무효화 검사

3. Soft Purge (소프트 퍼지)
   → 캐시를 삭제하지 않고 "stale" 상태로 전환
   → stale-while-revalidate와 결합하면 효과적
   → 사용자에게 즉시 stale 응답을 주면서 백그라운드 갱신
   → Fastly CDN에서 지원하는 방식

4. Tag 기반 무효화 (Surrogate-Key)
   → 캐시에 태그를 부여하고, 태그 단위로 무효화
   → 예: 상품 페이지에 "product-123", "category-books" 태그
   → "category-books" 태그 무효화 → 해당 카테고리 모든 캐시 제거
   → CDN이 Surrogate-Key 헤더를 지원해야 함

실무 조합 전략:
  - 긴급 수정: Purge로 특정 URL 즉시 제거
  - 대량 업데이트: Tag 기반으로 카테고리 단위 무효화
  - 점진적 갱신: Soft Purge + stale-while-revalidate

Spring Boot 캐시 헤더 설정 실무

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// Spring Boot에서 캐시 전략을 리소스 유형별로 설정하는 예제

@Configuration
public class CacheControlConfig implements WebMvcConfigurer {

    // 정적 리소스 캐시 설정 (해시가 포함된 파일명 전제)
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**")
            .addResourceLocations("classpath:/static/")
            .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS)
                .cachePublic());  // public, max-age=31536000
    }
}

@RestController
@RequestMapping("/api")
public class ProductController {

    // 상품 목록: CDN 5분, 브라우저 1분, stale 허용 30초
    @GetMapping("/products")
    public ResponseEntity<List<Product>> getProducts() {
        List<Product> products = productService.findAll();

        return ResponseEntity.ok()
            .cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS)  // 브라우저 1분
                .cachePublic()
                .sMaxAge(300, TimeUnit.SECONDS)        // CDN 5분
                .staleWhileRevalidate(30, TimeUnit.SECONDS)  // 만료 후 30초간 stale 허용
                .staleIfError(3600, TimeUnit.SECONDS))       // 오류 시 1시간 stale 허용
            .body(products);
    }

    // 개별 상품: Weak ETag 활용한 조건부 요청
    @GetMapping("/products/{id}")
    public ResponseEntity<Product> getProduct(
            @PathVariable Long id,
            WebRequest request) {

        Product product = productService.findById(id);

        // Weak ETag 생성 (핵심 필드 기반 해시)
        String etagValue = "W/\"" + product.getName().hashCode()
            + "-" + product.getPrice().hashCode()
            + "-" + product.getUpdatedAt().toEpochMilli() + "\"";

        // 조건부 요청 검사: ETag가 같으면 304 Not Modified 반환
        if (request.checkNotModified(etagValue)) {
            return null;  // Spring이 자동으로 304 응답 생성
        }

        return ResponseEntity.ok()
            .eTag(etagValue)
            .cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS)
                .cachePrivate()
                .mustRevalidate())  // 매번 서버에 검증 요청
            .body(product);
    }

    // 민감한 데이터: 캐시 완전 금지
    @GetMapping("/users/me/payment-methods")
    public ResponseEntity<List<PaymentMethod>> getPaymentMethods(
            @AuthenticationPrincipal User user) {

        return ResponseEntity.ok()
            .cacheControl(CacheControl.noStore())  // 캐시 자체를 금지
            .body(paymentService.findByUser(user));
    }
}

위 코드에서 주목할 점은 리소스의 성격에 따라 캐시 전략이 완전히 다르다는 것이다. 상품 목록은 CDN에서 적극적으로 캐싱하되 stale-while-revalidate로 사용자 경험을 보장하고, 개별 상품은 Weak ETag로 조건부 요청을 활용하며, 결제 정보 같은 민감한 데이터는 no-store로 캐시 자체를 금지한다.


6. 쿠키, 세션, 토큰 기반 인증

HTTP는 무상태(Stateless) 프로토콜이다. 각 요청은 독립적이며, 서버는 이전 요청을 기억하지 않는다. 그러나 현실의 웹 애플리케이션에서는 로그인 상태를 유지해야 한다. 이를 해결하는 세 가지 방식을 살펴보자.

쿠키는 서버가 클라이언트(브라우저)에 저장시키는 작은 데이터 조각이다. 이후 요청마다 자동으로 전송된다.

1
2
3
4
5
6
7
8
9
10
11
< 로그인 요청 >
POST /login HTTP/1.1
Body: {"username": "hong", "password": "1234"}

< 서버 응답 >
HTTP/1.1 200 OK
Set-Cookie: sessionId=abc123; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600

< 이후 요청 (브라우저가 자동으로 쿠키 포함) >
GET /api/profile HTTP/1.1
Cookie: sessionId=abc123

쿠키의 보안 속성:

속성 역할
HttpOnly JavaScript에서 접근 불가 (XSS 방어)
Secure HTTPS에서만 전송
SameSite=Strict 같은 사이트 요청에서만 전송 (CSRF 방어)
SameSite=Lax 외부 사이트에서의 GET 요청까지 허용
SameSite=None 모든 크로스 사이트 요청에 전송 (Secure 필수)
Domain 쿠키가 전송될 도메인 지정
Path 쿠키가 전송될 경로 지정
Max-Age / Expires 쿠키 만료 시간 (없으면 세션 쿠키 → 브라우저 종료 시 삭제)

6.2 세션 기반 인증 (Session)

서버 측에서 사용자 상태를 저장하고, 클라이언트에는 세션 ID만 전달하는 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌───────────┐                          ┌───────────┐
│  클라이언트  │                          │   서버     │
└─────┬─────┘                          └─────┬─────┘
      │  1. POST /login                     │
      │  (username, password)               │
      │ ───────────────────────────────────→ │
      │                                     │  2. 인증 확인
      │                                     │  3. 세션 생성 (메모리/Redis)
      │                                     │     sessionId=abc → {userId: 1, role: "USER"}
      │  4. Set-Cookie: JSESSIONID=abc      │
      │ ←─────────────────────────────────── │
      │                                     │
      │  5. GET /api/profile                │
      │  Cookie: JSESSIONID=abc             │
      │ ───────────────────────────────────→ │
      │                                     │  6. 세션 저장소에서 abc 조회
      │                                     │     → {userId: 1, role: "USER"}
      │  7. 200 OK (사용자 정보)              │
      │ ←─────────────────────────────────── │
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Spring Security 세션 기반 인증 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                .maximumSessions(1)                    // 동시 세션 1개 제한
                .maxSessionsPreventsLogin(false)        // 기존 세션 만료 (새 로그인 허용)
            )
            .formLogin(form -> form
                .loginProcessingUrl("/login")
                .successHandler((req, res, auth) -> {
                    res.setStatus(200);
                    res.getWriter().write("{\"message\": \"로그인 성공\"}");
                })
            );
        return http.build();
    }
}

세션의 한계: 서버 메모리에 상태를 저장하므로, 서버가 여러 대(Scale-out)일 때 세션 공유 문제가 발생한다. 이를 해결하는 방법으로 Sticky Session, Session Clustering, 외부 세션 저장소(Redis)가 있다.

1
2
3
4
5
6
7
8
9
< Scale-out 시 세션 문제 >

요청 1: 클라이언트 → [로드밸런서] → 서버 A (세션 생성: abc)
요청 2: 클라이언트 → [로드밸런서] → 서버 B (세션 abc가 없음! → 인증 실패)

< 해결: Redis 기반 세션 공유 >

요청 1: 클라이언트 → [LB] → 서버 A → Redis에 세션 저장
요청 2: 클라이언트 → [LB] → 서버 B → Redis에서 세션 조회 → 인증 성공

6.3 JWT 기반 인증 (Token)

JWT(JSON Web Token)는 인증 정보를 토큰 자체에 포함시켜, 서버가 상태를 저장할 필요 없이 토큰만으로 인증을 처리하는 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
JWT 구조:

Header.Payload.Signature

┌──────────────────────────────────────────────────┐
│ Header (Base64 인코딩)                             │
│ {"alg": "HS256", "typ": "JWT"}                   │
├──────────────────────────────────────────────────┤
│ Payload (Base64 인코딩) - 클레임(Claims) 포함       │
│ {                                                │
│   "sub": "1234567890",     ← 사용자 ID            │
│   "name": "홍길동",         ← 사용자 정보           │
│   "role": "ADMIN",         ← 권한                 │
│   "iat": 1672531200,       ← 발급 시간             │
│   "exp": 1672534800        ← 만료 시간             │
│ }                                                │
├──────────────────────────────────────────────────┤
│ Signature                                        │
│ HMACSHA256(                                      │
│   base64UrlEncode(header) + "." +                │
│   base64UrlEncode(payload),                      │
│   secret                   ← 서버만 아는 비밀키     │
│ )                                                │
└──────────────────────────────────────────────────┘

주의: Payload는 암호화가 아닌 Base64 인코딩이다.
      누구나 디코딩하여 내용을 볼 수 있으므로, 비밀번호 같은 민감 정보를 넣으면 안 된다.
      Signature는 위변조 방지 용도이지, 보안 용도가 아니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
< JWT 인증 흐름 >

클라이언트                              서버
    │  1. POST /login                   │
    │  (username, password)             │
    │ ────────────────────────────────→  │
    │                                   │  2. 인증 확인
    │                                   │  3. JWT 생성 (서버 비밀키로 서명)
    │  4. 200 OK                        │
    │  { accessToken: "eyJ...",         │
    │    refreshToken: "eyJ..." }       │
    │ ←──────────────────────────────── │
    │                                   │
    │  5. GET /api/profile              │
    │  Authorization: Bearer eyJ...     │
    │ ────────────────────────────────→  │
    │                                   │  6. JWT 서명 검증 + 만료 확인
    │                                   │     → 별도 DB/Redis 조회 불필요!
    │  7. 200 OK                        │
    │ ←──────────────────────────────── │

Access Token + Refresh Token 전략:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Access Token:  유효기간 짧음 (15분~1시간)
               → 탈취 시 피해 범위 제한

Refresh Token: 유효기간 김 (7일~30일)
               → Access Token 갱신용
               → 서버에 저장 (Redis 등) → 강제 만료 가능

┌──────────┐                              ┌───────────┐
│ 클라이언트 │                              │   서버     │
└────┬─────┘                              └─────┬─────┘
     │  Access Token 만료됨                      │
     │  GET /api/data → 401 Unauthorized         │
     │ ←──────────────────────────────────────── │
     │                                           │
     │  POST /auth/refresh                       │
     │  { refreshToken: "eyJ..." }               │
     │ ─────────────────────────────────────────→ │
     │                                           │  Refresh Token 검증
     │                                           │  새 Access Token 발급
     │  { accessToken: "new-eyJ..." }            │
     │ ←──────────────────────────────────────── │

6.4 세션 vs JWT 비교

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌───────────────┬─────────────────────┬─────────────────────┐
│     항목       │     세션 기반         │     JWT 기반         │
├───────────────┼─────────────────────┼─────────────────────┤
│ 상태 저장      │ 서버 (Stateful)      │ 토큰 자체 (Stateless)│
│ 확장성        │ 세션 공유 필요        │ 서버 확장 용이        │
│ 보안          │ 서버에서 세션 즉시 무효화│ 토큰 만료까지 유효    │
│               │ 가능                 │ (강제 만료 어려움)    │
│ 네트워크 비용  │ 쿠키(수십 바이트)      │ 토큰(수백~수천 바이트) │
│ 서버 부하      │ 매 요청마다 세션 조회   │ 서명 검증만 (CPU)     │
│ CSRF          │ 취약 (별도 방어 필요)   │ 헤더 전송 시 안전     │
│ XSS           │ HttpOnly 쿠키로 방어   │ localStorage 저장 시  │
│               │                      │ 취약 (HttpOnly 쿠키   │
│               │                      │ 권장)                │
│ 적합한 환경    │ 전통적 웹 애플리케이션   │ SPA, 모바일 앱,      │
│               │ SSR 기반 서비스        │ 마이크로서비스         │
└───────────────┴─────────────────────┴─────────────────────┘

7. CORS (Cross-Origin Resource Sharing) 심화

7.1 동일 출처 정책 (Same-Origin Policy)

브라우저는 보안을 위해 다른 출처(Origin)의 리소스 접근을 기본적으로 차단한다. 출처(Origin)는 프로토콜 + 호스트 + 포트의 조합이다.

1
2
3
4
5
6
7
기준: https://example.com:443

https://example.com:443/page    → 같은 출처 (경로만 다름)
https://example.com:443/api     → 같은 출처
http://example.com:443          → 다른 출처 (프로토콜 다름)
https://api.example.com:443     → 다른 출처 (호스트 다름)
https://example.com:8080        → 다른 출처 (포트 다름)

7.2 CORS 동작 메커니즘

CORS 요청은 단순 요청(Simple Request)사전 요청(Preflight Request)으로 나뉜다.

단순 요청의 조건: 메서드가 GET/HEAD/POST 중 하나이고, Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나이고, 커스텀 헤더가 없는 경우.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
< 단순 요청 >

클라이언트 (https://app.com)               서버 (https://api.com)
    │  GET /api/data                        │
    │  Origin: https://app.com              │
    │ ────────────────────────────────────→  │
    │                                       │
    │  200 OK                               │
    │  Access-Control-Allow-Origin:          │
    │    https://app.com                    │
    │ ←──────────────────────────────────── │

< 사전 요청 (Preflight) - JSON API 등 >

클라이언트                                  서버
    │  [1단계: Preflight]                    │
    │  OPTIONS /api/data                    │
    │  Origin: https://app.com              │
    │  Access-Control-Request-Method: POST  │
    │  Access-Control-Request-Headers:      │
    │    Content-Type, Authorization        │
    │ ────────────────────────────────────→  │
    │                                       │
    │  204 No Content                       │
    │  Access-Control-Allow-Origin:          │
    │    https://app.com                    │
    │  Access-Control-Allow-Methods:        │
    │    GET, POST, PUT, DELETE             │
    │  Access-Control-Allow-Headers:        │
    │    Content-Type, Authorization        │
    │  Access-Control-Max-Age: 3600         │  ← 1시간 캐시
    │ ←──────────────────────────────────── │
    │                                       │
    │  [2단계: 실제 요청]                     │
    │  POST /api/data                       │
    │  Origin: https://app.com              │
    │  Content-Type: application/json       │
    │  Authorization: Bearer eyJ...         │
    │ ────────────────────────────────────→  │
    │                                       │
    │  200 OK                               │
    │ ←──────────────────────────────────── │

7.3 Spring에서의 CORS 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 방법 1: 전역 설정 (가장 권장)
@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://app.example.com", "https://admin.example.com")
            .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
            .allowedHeaders("*")
            .exposedHeaders("Location", "X-Total-Count")  // 클라이언트가 읽을 수 있는 응답 헤더
            .allowCredentials(true)   // 쿠키 포함 허용
            .maxAge(3600);            // Preflight 캐시 1시간
    }
}

// 방법 2: 컨트롤러 단위 (특정 엔드포인트만)
@RestController
@CrossOrigin(origins = "https://app.example.com", maxAge = 3600)
@RequestMapping("/api/users")
public class UserController {
    // ...
}

// 방법 3: Spring Security와 함께 사용 시
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.cors(cors -> cors.configurationSource(corsConfigurationSource()));
    return http.build();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.example.com"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

8. REST 아키텍처 심화

8.1 REST의 6가지 제약 조건

Roy Fielding이 2000년 박사 논문에서 정의한 REST(Representational State Transfer)는 단순한 API 스타일이 아닌, 아키텍처 제약 조건의 집합이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
┌────────────────────────────────────────────────────────────────┐
│                    REST의 6가지 제약 조건                        │
├────────────────────────────────────────────────────────────────┤
│                                                                │
│  1. Client-Server (클라이언트-서버 분리)                         │
│     → UI와 데이터 저장 관심사를 분리                              │
│     → 독립적으로 발전 가능                                       │
│                                                                │
│  2. Stateless (무상태)                                         │
│     → 각 요청은 독립적, 서버는 클라이언트 상태를 저장하지 않음       │
│     → 요청에 필요한 모든 정보가 요청 자체에 포함                    │
│     → Scale-out 용이                                           │
│                                                                │
│  3. Cacheable (캐시 가능)                                      │
│     → 응답은 캐시 가능/불가능 여부를 명시해야 함                    │
│     → 네트워크 효율성 향상                                       │
│                                                                │
│  4. Uniform Interface (균일한 인터페이스) ★ 핵심                  │
│     → 리소스 식별 (URI)                                         │
│     → 표현을 통한 리소스 조작                                     │
│     → 자기 서술적 메시지 (Self-descriptive)                      │
│     → HATEOAS (Hypermedia as the Engine of Application State)  │
│                                                                │
│  5. Layered System (계층화 시스템)                               │
│     → 클라이언트는 직접 서버인지, 중간 서버인지 알 수 없음           │
│     → 로드밸런서, 프록시, 게이트웨이 등 중간 계층 추가 가능          │
│                                                                │
│  6. Code-on-Demand (선택적)                                    │
│     → 서버가 클라이언트에 실행 가능한 코드를 전송 (예: JavaScript)   │
│     → 유일한 선택적 제약 조건                                     │
│                                                                │
└────────────────────────────────────────────────────────────────┘

8.2 Richardson Maturity Model (성숙도 모델)

Leonard Richardson이 제안한 REST API의 성숙도를 4단계로 분류하는 모델이다.

1
2
3
4
5
6
7
Level 3: Hypermedia Controls (HATEOAS)    ← 진정한 REST
  ▲
Level 2: HTTP Verbs (HTTP 메서드 활용)     ← 대부분의 API가 이 수준
  ▲
Level 1: Resources (리소스 URI)
  ▲
Level 0: The Swamp of POX (Plain Old XML) ← RPC 스타일

Level 0 - RPC 스타일: 하나의 엔드포인트에 모든 요청을 POST로 보낸다.

1
2
3
4
5
6
7
8
POST /api
Body: {"action": "getUser", "userId": 1}

POST /api
Body: {"action": "createUser", "name": "홍길동"}

POST /api
Body: {"action": "deleteUser", "userId": 1}

Level 1 - 리소스 도입: URI로 리소스를 식별하지만, 여전히 POST만 사용한다.

1
2
3
POST /api/users/1         Body: {"action": "get"}
POST /api/users           Body: {"action": "create", "name": "홍길동"}
POST /api/users/1/delete  Body: {}

Level 2 - HTTP 메서드 활용: URI + HTTP 메서드로 리소스를 조작한다. 대부분의 현대 API가 이 수준이다.

1
2
3
4
GET    /api/users/1          →  사용자 조회
POST   /api/users            →  사용자 생성
PUT    /api/users/1          →  사용자 수정
DELETE /api/users/1          →  사용자 삭제

Level 3 - HATEOAS: 응답에 다음 가능한 행동(링크)을 포함한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GET /api/users/1

{
    "id": 1,
    "name": "홍길동",
    "email": "hong@example.com",
    "_links": {
        "self": { "href": "/api/users/1" },
        "update": { "href": "/api/users/1", "method": "PUT" },
        "delete": { "href": "/api/users/1", "method": "DELETE" },
        "orders": { "href": "/api/users/1/orders" },
        "friends": { "href": "/api/users/1/friends" }
    }
}

HATEOAS의 핵심 가치는 클라이언트가 URI를 하드코딩할 필요가 없다는 것이다. API의 시작점(Entry Point)만 알면, 이후 탐색은 응답에 포함된 링크를 따라가면 된다. API 변경 시 클라이언트 코드 수정이 최소화된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Spring HATEOAS 활용
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")
    public EntityModel<User> getUser(@PathVariable Long id) {
        User user = userService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("User", id));

        return EntityModel.of(user,
            linkTo(methodOn(UserController.class).getUser(id)).withSelfRel(),
            linkTo(methodOn(UserController.class).getAllUsers()).withRel("users"),
            linkTo(methodOn(OrderController.class).getUserOrders(id)).withRel("orders")
        );
    }

    @GetMapping
    public CollectionModel<EntityModel<User>> getAllUsers() {
        List<EntityModel<User>> users = userService.findAll().stream()
            .map(user -> EntityModel.of(user,
                linkTo(methodOn(UserController.class).getUser(user.getId())).withSelfRel()
            ))
            .toList();

        return CollectionModel.of(users,
            linkTo(methodOn(UserController.class).getAllUsers()).withSelfRel()
        );
    }
}

9. RESTful API 설계 원칙

9.1 URI 설계 규칙

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
좋은 URI 설계 원칙:

1. 리소스는 명사, 복수형 사용
   ✅ GET /api/users
   ✅ GET /api/users/1
   ❌ GET /api/getUser
   ❌ GET /api/user/list

2. 계층 관계는 슬래시(/)로 표현
   ✅ GET /api/users/1/orders           ← 사용자 1의 주문 목록
   ✅ GET /api/users/1/orders/5         ← 사용자 1의 주문 5번
   ❌ GET /api/getUserOrders?userId=1

3. 행위는 HTTP 메서드로 표현
   ✅ DELETE /api/users/1
   ❌ POST /api/users/1/delete
   ❌ GET /api/deleteUser/1

4. 소문자 사용, 하이픈(-) 사용 (언더스코어 X)
   ✅ /api/user-profiles
   ❌ /api/user_profiles
   ❌ /api/UserProfiles

5. 파일 확장자는 URI에 포함하지 않음
   ✅ Accept: application/json 헤더 사용
   ❌ /api/users.json

6. 컬렉션 필터링은 쿼리 파라미터
   ✅ GET /api/users?role=admin&status=active
   ✅ GET /api/users?sort=name,asc&page=0&size=20

9.2 페이지네이션 설계

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
< Offset 기반 (전통적) >
GET /api/articles?page=0&size=20&sort=createdAt,desc

응답:
{
    "content": [...],
    "page": 0,
    "size": 20,
    "totalElements": 150,
    "totalPages": 8,
    "first": true,
    "last": false
}

장점: 특정 페이지로 바로 이동 가능
단점: 데이터 삽입/삭제 시 중복/누락 발생, 대용량에서 성능 저하 (OFFSET 스캔)


< Cursor 기반 (대용량/실시간) >
GET /api/articles?cursor=eyJpZCI6MTAwfQ&size=20

응답:
{
    "content": [...],
    "nextCursor": "eyJpZCI6ODB9",
    "hasNext": true
}

장점: 대용량에서도 일관된 성능, 데이터 변경에 안정적
단점: 특정 페이지로 바로 이동 불가, 총 개수 알기 어려움
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Spring Data JPA - Offset 기반 페이지네이션
@GetMapping
public ResponseEntity<Page<ArticleDto>> getArticles(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(defaultValue = "createdAt,desc") String sort) {

    String[] sortParams = sort.split(",");
    Sort sortObj = Sort.by(Sort.Direction.fromString(sortParams[1]), sortParams[0]);
    Pageable pageable = PageRequest.of(page, size, sortObj);

    Page<ArticleDto> articles = articleService.findAll(pageable)
        .map(ArticleDto::from);

    return ResponseEntity.ok(articles);
}

// Cursor 기반 페이지네이션
@GetMapping
public ResponseEntity<CursorPage<ArticleDto>> getArticles(
        @RequestParam(required = false) Long cursor,
        @RequestParam(defaultValue = "20") int size) {

    List<Article> articles;
    if (cursor == null) {
        articles = articleRepository.findTopByOrderByIdDesc(PageRequest.of(0, size + 1));
    } else {
        articles = articleRepository.findByIdLessThanOrderByIdDesc(cursor, PageRequest.of(0, size + 1));
    }

    boolean hasNext = articles.size() > size;
    if (hasNext) articles = articles.subList(0, size);

    Long nextCursor = hasNext ? articles.get(articles.size() - 1).getId() : null;

    return ResponseEntity.ok(new CursorPage<>(
        articles.stream().map(ArticleDto::from).toList(),
        nextCursor,
        hasNext
    ));
}

9.3 API 버전 관리

API를 변경할 때 기존 클라이언트를 깨뜨리지 않기 위해 버전 관리가 필요하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌────────────────────┬──────────────────────┬────────────────────┐
│     방식            │       예시            │     특징            │
├────────────────────┼──────────────────────┼────────────────────┤
│ URI Path           │ /api/v1/users        │ 가장 직관적          │
│ (가장 널리 사용)     │ /api/v2/users        │ 캐시 친화적          │
│                    │                      │ URI가 지저분해질 수   │
│                    │                      │ 있음                │
├────────────────────┼──────────────────────┼────────────────────┤
│ Query Parameter    │ /api/users?v=1       │ 선택적 버전 지정      │
│                    │ /api/users?v=2       │ 기본 버전 설정 가능   │
├────────────────────┼──────────────────────┼────────────────────┤
│ Custom Header      │ X-API-Version: 1     │ URI를 깔끔하게 유지  │
│                    │ X-API-Version: 2     │ 브라우저 테스트 어려움│
├────────────────────┼──────────────────────┼────────────────────┤
│ Accept Header      │ Accept: application/ │ HTTP 표준에 가장     │
│ (Content           │ vnd.myapi.v1+json    │ 부합                │
│  Negotiation)      │                      │ 복잡                │
└────────────────────┴──────────────────────┴────────────────────┘

9.4 에러 응답 설계

일관된 에러 응답 형식은 API 품질의 핵심이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 좋은 에러 응답 예시
{
    "status": 400,
    "error": "BAD_REQUEST",
    "message": "입력값 검증에 실패했습니다",
    "timestamp": "2026-03-10T10:30:00",
    "path": "/api/users",
    "details": [
        {
            "field": "email",
            "value": "invalid-email",
            "reason": "올바른 이메일 형식이 아닙니다"
        },
        {
            "field": "age",
            "value": -1,
            "reason": "나이는 0 이상이어야 합니다"
        }
    ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 일관된 에러 응답 구조
public record ErrorResponse(
    int status,
    String error,
    String message,
    LocalDateTime timestamp,
    String path,
    List<FieldError> details
) {
    public record FieldError(String field, Object value, String reason) {}

    public static ErrorResponse of(HttpStatus status, String message, String path) {
        return new ErrorResponse(
            status.value(), status.name(), message,
            LocalDateTime.now(), path, List.of()
        );
    }

    public static ErrorResponse of(HttpStatus status, String message,
                                     String path, List<FieldError> details) {
        return new ErrorResponse(
            status.value(), status.name(), message,
            LocalDateTime.now(), path, details
        );
    }
}

9.5 콘텐츠 협상 동작 과정 심층

1) 클라이언트는 Accept, Accept-Language, Accept-Encoding을 보낸다. 2) 서버는 각 헤더의 품질지수(q값)를 기준으로 우선순위를 매긴다. 예: text/html;q=0.9, application/json;q=0.8 → HTML 우선. 3) 서버가 생산 가능한 표현이 없으면 406, 있다면 가장 우선 표현으로 응답하고 Content-Language, Content-Type, Content-Encoding, Vary를 설정한다. 4) Vary는 캐시 키를 바꾸므로, 협상이 많은 API는 불필요한 Vary를 남발하지 않는다.

9.6 캐시 검증 알고리즘 예시 (의사코드)

if (If-None-Match present) {
    if (etag matches current) return 304;
}
if (If-Modified-Since present) {
    if (last_modified <= header_value) return 304;
}
return 200 with new ETag/Last-Modified;
  • 둘 다 있을 때는 ETag가 우선. 시계가 뒤로 가 있는 환경을 방어한다.
  • 강력 일관성이 필요하면 Cache-Control: must-revalidate를 함께 사용한다.

9.7 TCP/TLS 연결 최적화 심층

  • TLS 1.3: 첫 연결 1-RTT, 세션 재개/0-RTT로 핸드셰이크를 줄일 수 있다. 단 0-RTT는 재전송 공격에 취약하므로 멱등 요청만 허용.
  • 연결 재사용: HTTP/1.1 keep-alive, HTTP/2 커넥션 하나 멀티플렉싱, HTTP/3는 CID(Connection ID)로 IP 변경에도 연결을 유지한다.
  • 프론트엔드 최적화: preconnect로 DNS+TCP+TLS 선행, origin coalescing(H2)으로 동일 인증서 도메인을 하나의 커넥션으로 공유.

9.8 오류 응답의 모범 설계

  • 4xx에는 클라이언트가 수정 가능한 정보를, 5xx에는 재시도 가능/불가능 여부를 담는다.
  • 공통 포맷 예:
    1
    2
    3
    4
    5
    6
    
    {
    "error": "validation_failed",
    "message": "email is invalid",
    "fields": {"email": "invalid format"},
    "trace_id": "9f0c..."
    }
    
  • trace_id를 응답/로그에 함께 남겨야 운영·모니터링과 연결된다.

10. HTTP/2 Server Push와 실시간 통신

10.1 Server-Sent Events (SSE)

SSE는 서버에서 클라이언트로의 단방향 실시간 데이터 스트리밍을 위한 HTTP 기반 기술이다. WebSocket보다 간단하고, HTTP 인프라(프록시, 로드밸런서)와 호환성이 좋다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
< SSE 동작 >

클라이언트                              서버
    │  GET /api/notifications/stream    │
    │  Accept: text/event-stream        │
    │ ────────────────────────────────→  │
    │                                   │
    │  200 OK                           │
    │  Content-Type: text/event-stream  │
    │  Connection: keep-alive           │
    │                                   │
    │  data: {"message": "알림1"}        │  ← 이벤트 1
    │ ←──────────────────────────────── │
    │         (시간 경과)                 │
    │  data: {"message": "알림2"}        │  ← 이벤트 2
    │ ←──────────────────────────────── │
    │         (연결 유지...)              │
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Spring WebFlux SSE
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<Notification>> streamNotifications(
        @AuthenticationPrincipal UserDetails user) {

    return notificationService.subscribe(user.getUsername())
        .map(notification -> ServerSentEvent.<Notification>builder()
            .id(String.valueOf(notification.getId()))
            .event("notification")
            .data(notification)
            .retry(Duration.ofSeconds(5))  // 재연결 대기 시간
            .build()
        );
}

// Spring MVC SseEmitter
@GetMapping("/stream")
public SseEmitter streamNotifications(@AuthenticationPrincipal UserDetails user) {
    SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);

    notificationService.addEmitter(user.getUsername(), emitter);

    emitter.onCompletion(() -> notificationService.removeEmitter(user.getUsername()));
    emitter.onTimeout(() -> notificationService.removeEmitter(user.getUsername()));

    return emitter;
}

10.2 실시간 통신 방식 비교

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────────┬───────────────┬───────────────┬───────────────┐
│      방식         │   Polling     │     SSE       │  WebSocket    │
├──────────────────┼───────────────┼───────────────┼───────────────┤
│ 프로토콜          │ HTTP          │ HTTP          │ WS (TCP 위)   │
│ 통신 방향         │ 클라이언트→서버 │ 서버→클라이언트 │ 양방향         │
│ 연결 유지         │ 매번 새 연결   │ 유지           │ 유지           │
│ 실시간성          │ 낮음 (주기적)  │ 높음           │ 높음           │
│ 서버 부하         │ 높음          │ 중간           │ 낮음           │
│ 인프라 호환성     │ 완벽          │ 좋음           │ 프록시 설정 필요│
│ 자동 재연결       │ N/A          │ 브라우저 내장   │ 직접 구현      │
│ 적합한 사례       │ 간단한 상태   │ 알림, 피드,    │ 채팅, 게임,    │
│                  │ 확인          │ 실시간 로그    │ 실시간 협업    │
└──────────────────┴───────────────┴───────────────┴───────────────┘

11. 브라우저에 URL을 입력하면 일어나는 일 — 심화

이 질문은 네트워크 면접의 단골 질문이지만, 깊이에 따라 답변의 수준이 완전히 달라진다. 단순히 “DNS 조회 → TCP 연결 → HTTP 요청”으로 끝내는 것이 아니라, 각 단계의 내부 메커니즘까지 이해해야 한다.

사용자가 https://www.example.com/products?category=books 를 입력했다고 가정하자.

11.1 Step 1: URL 파싱 및 유효성 검사

브라우저는 입력을 먼저 파싱한다. URL인지, 검색어인지 판별하는 것부터 시작된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
입력: https://www.example.com/products?category=books#section1

URL 파싱 결과:
┌──────────┬───────────────────────────────────────┐
│ 스킴      │ https                                 │
│ 호스트    │ www.example.com                        │
│ 포트      │ 443 (HTTPS 기본 포트, 생략됨)            │
│ 경로      │ /products                              │
│ 쿼리      │ category=books                         │
│ 프래그먼트 │ section1 (서버에 전송되지 않음)            │
└──────────┴───────────────────────────────────────┘

판별 과정:
1. "https://" 스킴이 있는가? → URL로 처리
2. 스킴이 없으면 → 도메인 형식인지 확인 (example.com 등)
3. 둘 다 아니면 → 기본 검색 엔진으로 검색 쿼리 전송

11.2 Step 2: HSTS 확인

HSTS(HTTP Strict Transport Security)는 해당 도메인이 반드시 HTTPS로만 접속해야 함을 브라우저에 알리는 보안 메커니즘이다. 사용자가 http://로 접속을 시도해도 브라우저가 자체적으로 https://로 변환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
HSTS 동작 과정:

1. 첫 접속 시:
   클라이언트 → http://example.com → 서버 응답:
   HTTP/1.1 301 Moved Permanently
   Location: https://example.com
   Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

2. 이후 접속 시:
   사용자가 http://example.com 입력
   → 브라우저가 HSTS 목록 확인
   → 네트워크 요청 없이 내부적으로 https://로 변환 (307 Internal Redirect)
   → 중간자 공격(MITM)으로 HTTP 응답을 가로채는 것 불가능

HSTS Preload List:
  → 브라우저에 하드코딩된 HSTS 도메인 목록
  → 첫 접속부터 HTTPS를 강제할 수 있음
  → Chrome, Firefox, Safari, Edge 등이 공유

11.3 Step 3: 브라우저 캐시 확인

네트워크 요청 전에 먼저 로컬 캐시를 확인한다. 캐시에 유효한 응답이 있으면 네트워크 요청 없이 즉시 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
캐시 확인 순서:

1. Service Worker 캐시 (있는 경우)
   → PWA의 Service Worker가 요청을 가로채서 캐시된 응답 반환 가능
   → 오프라인 지원의 핵심

2. 메모리 캐시 (Memory Cache)
   → 현재 탭에서 이미 로드한 리소스 (가장 빠름)
   → 탭을 닫으면 사라짐
   → 주로 preload된 리소스, 같은 페이지의 이미지 등

3. 디스크 캐시 (Disk Cache / HTTP Cache)
   → 디스크에 저장된 응답 (탭/브라우저 종료 후에도 유지)
   → Cache-Control 헤더에 따라 유효성 판단

   Cache-Control: max-age=3600 이고 1시간 미경과?
   → 캐시 히트! 서버에 요청하지 않고 즉시 사용 (200 from cache)

   만료되었거나 no-cache?
   → 조건부 요청 전송 (If-None-Match / If-Modified-Since)
   → 서버가 304 Not Modified 응답 → 캐시 재사용 (본문 전송 안 함)
   → 서버가 200 OK + 새 데이터 응답 → 캐시 갱신

4. Push Cache (HTTP/2)
   → 서버가 Push한 리소스의 캐시
   → 연결(세션) 단위로 존재, 한 번 사용하면 삭제됨

11.4 Step 4: DNS 조회 (Domain Name Resolution)

캐시에 없으면 서버에 요청해야 하고, 그러려면 도메인의 IP 주소를 알아야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
DNS 조회 순서 (상세):

┌──────────────────────────────────────────────────────────────┐
│ 1. 브라우저 DNS 캐시                                          │
│    → 이전에 조회한 도메인의 IP가 캐시되어 있는지 확인            │
│    → Chrome: chrome://net-internals/#dns 에서 확인 가능        │
│    → TTL(Time To Live) 기간 동안 유효                         │
├──────────────────────────────────────────────────────────────┤
│ 2. OS DNS 캐시                                                │
│    → OS가 관리하는 DNS 캐시 확인                                │
│    → Linux: systemd-resolved, macOS: mDNSResponder            │
├──────────────────────────────────────────────────────────────┤
│ 3. hosts 파일 확인                                            │
│    → /etc/hosts (Linux/Mac) 또는 C:\Windows\...\hosts         │
│    → 로컬 개발 시 도메인 매핑에 활용 (127.0.0.1 local.dev)     │
├──────────────────────────────────────────────────────────────┤
│ 4. 로컬 DNS 리졸버 (ISP DNS 서버)                              │
│    → OS가 설정된 DNS 서버에 질의                                │
│    → /etc/resolv.conf 또는 네트워크 설정의 DNS 서버              │
│    → 8.8.8.8 (Google), 1.1.1.1 (Cloudflare) 등               │
│                                                              │
│    리졸버의 캐시에 있으면 바로 응답                               │
│    없으면 재귀적 질의(Recursive Query) 시작 ↓                   │
├──────────────────────────────────────────────────────────────┤
│ 5. 루트 DNS 서버 (13개 그룹, Anycast로 수백 대)                │
│    → "www.example.com의 IP는?"                                │
│    → ".com TLD 서버 주소는 이것이다" (NS 레코드 반환)            │
├──────────────────────────────────────────────────────────────┤
│ 6. TLD DNS 서버 (.com)                                        │
│    → "example.com의 네임서버는 이것이다"                        │
│    → example.com의 권한 있는 네임서버 주소 반환                  │
├──────────────────────────────────────────────────────────────┤
│ 7. 권한 있는 네임서버 (Authoritative NS)                       │
│    → "www.example.com의 IP는 93.184.216.34이다"               │
│    → A 레코드(IPv4) 또는 AAAA 레코드(IPv6) 반환                │
│    → TTL 값과 함께 반환 → 각 단계에서 캐싱                      │
└──────────────────────────────────────────────────────────────┘

DNS 최적화 기법:
- dns-prefetch: <link rel="dns-prefetch" href="//api.example.com">
  → 페이지 로드 전에 외부 도메인의 DNS를 미리 조회
- preconnect: <link rel="preconnect" href="https://api.example.com">
  → DNS + TCP + TLS까지 미리 연결

11.5 Step 5: TCP 연결 (3-Way Handshake)

IP 주소를 얻었으면 서버와 TCP 연결을 수립한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
소켓 생성 → TCP 3-Way Handshake

클라이언트 (임시 포트: 52431)          서버 (포트: 443)
    │                                   │
    │  1. SYN                           │
    │  seq=1000, 윈도우=65535           │
    │  MSS=1460, SACK 허용             │  → SYN_SENT 상태
    │ ────────────────────────────────→ │  → SYN_RECEIVED 상태
    │                                   │
    │  2. SYN+ACK                       │
    │  seq=5000, ack=1001              │
    │  윈도우=14480, MSS=1460           │
    │ ←──────────────────────────────── │
    │                                   │
    │  3. ACK                           │
    │  ack=5001                        │  → ESTABLISHED (양쪽 모두)
    │ ────────────────────────────────→ │
    │                                   │
    │        연결 수립 완료               │
    │        소요 시간: 1 RTT            │

RTT(Round Trip Time) 영향:
서울 → 서울 서버: ~1ms
서울 → 도쿄 서버: ~30ms
서울 → 미국 서버: ~150ms
→ TCP + TLS를 합치면 지연이 크므로, CDN이 중요한 이유

Connection: keep-alive:
  → HTTP/1.1부터 기본 활성화
  → TCP 연결을 재사용하여 매 요청마다 Handshake 반복 방지
  → HTTP/2는 하나의 TCP 연결로 여러 스트림 처리 (멀티플렉싱)

11.6 Step 6: TLS 핸드셰이크 (HTTPS)

HTTPS이므로 TCP 연결 후 TLS 핸드셰이크를 수행한다. TLS 1.3 기준으로 설명한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
TLS 1.3 핸드셰이크 (1-RTT):

클라이언트                                      서버
    │                                            │
    │  ClientHello                               │
    │  - 지원하는 암호 스위트 목록                    │
    │  - 클라이언트 랜덤 값                         │
    │  - key_share (ECDHE 공개키)                  │  ← TLS 1.3: 키 교환을
    │  - supported_versions: TLS 1.3              │     첫 메시지에 포함
    │ ─────────────────────────────────────────→  │
    │                                            │
    │  ServerHello                               │
    │  - 선택한 암호 스위트                          │
    │  - 서버 랜덤 값                               │
    │  - key_share (서버 ECDHE 공개키)              │
    │  {EncryptedExtensions}                     │  ← 여기서부터 암호화됨
    │  {Certificate}                             │  ← 서버 인증서
    │  {CertificateVerify}                       │  ← 인증서 소유 증명
    │  {Finished}                                │  ← 핸드셰이크 완료 확인
    │ ←───────────────────────────────────────── │
    │                                            │
    │  양쪽 모두 세션 키 생성 완료                    │
    │  (ECDHE 공유 비밀 + 랜덤 값 → HKDF로 키 파생)  │
    │                                            │
    │  {Finished}                                │
    │  [Application Data: HTTP 요청]              │  ← 핸드셰이크와 동시에
    │ ─────────────────────────────────────────→  │     데이터 전송 가능!
    │                                            │

TLS 1.2 vs TLS 1.3:
  TLS 1.2: 2-RTT (ClientHello→ServerHello→키교환→Finished)
  TLS 1.3: 1-RTT (키 교환을 ClientHello에 포함)
  TLS 1.3 0-RTT: 이전 연결의 PSK(Pre-Shared Key)로 첫 메시지에 데이터 포함
                  → 재연결 시 지연 최소화 (단, 재전송 공격 위험)

인증서 검증 과정:
  1. 서버 인증서의 유효 기간 확인
  2. 인증서가 신뢰할 수 있는 CA(인증 기관)가 발급한 것인지 확인
     → 인증서 체인(서버 인증서 → 중간 CA → 루트 CA) 검증
     → 브라우저에 내장된 루트 CA 인증서 목록과 대조
  3. 인증서의 도메인이 접속하는 도메인과 일치하는지 확인
     → SAN(Subject Alternative Name) 또는 CN(Common Name) 확인
  4. 인증서가 폐기(Revoke)되지 않았는지 확인
     → CRL(Certificate Revocation List) 또는 OCSP(Online Certificate Status Protocol)
  5. 모든 검증 통과 → 주소창에 자물쇠 아이콘 표시
     실패 → "이 사이트는 안전하지 않습니다" 경고

11.7 Step 7: HTTP 요청 전송

TLS 연결이 수립되면 암호화된 채널을 통해 HTTP 요청을 전송한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
브라우저가 생성하는 실제 HTTP 요청:

GET /products?category=books HTTP/1.1
Host: www.example.com
Connection: keep-alive
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,
        image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
            (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
Cookie: sessionId=abc123; _ga=GA1.2.1234567890
Cache-Control: max-age=0
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1

주요 헤더 역할:
- Host: 같은 서버에서 여러 도메인 호스팅 시 구분 (가상 호스트)
- Accept: 클라이언트가 처리 가능한 미디어 타입 (콘텐츠 협상)
- Accept-Encoding: 서버 응답 압축 방식 (gzip, br = Brotli)
- Cookie: 이전에 서버가 Set-Cookie로 저장시킨 쿠키들
- Sec-Fetch-*: 요청의 출처와 목적을 서버에 알려주는 메타데이터 (보안)

11.8 Step 8: 서버 처리 과정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
요청이 서버에 도달하면 여러 계층을 거쳐 처리된다.

┌────────────────────────────────────────────────────────────┐
│                    서버 처리 파이프라인                       │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  1. 로드 밸런서 (L4/L7)                                     │
│     → 여러 서버 중 하나를 선택 (라운드 로빈, 최소 연결 등)      │
│     → 헬스 체크로 죽은 서버 제외                               │
│     → L7: URL/헤더 기반 라우팅 (/api → WAS, /static → CDN)  │
│                                                            │
│  2. 웹 서버 / 리버스 프록시 (Nginx, Apache)                   │
│     → 정적 파일이면 직접 응답 (빠름)                           │
│     → 동적 요청이면 WAS로 프록시                              │
│     → SSL 종료 (TLS 복호화를 여기서 처리)                     │
│     → 요청/응답 압축 (gzip/Brotli)                          │
│     → 접근 로그 기록                                         │
│                                                            │
│  3. WAS (Tomcat, Undertow 등)                               │
│     → 서블릿 컨테이너가 요청을 받음                            │
│     → 스레드 풀에서 워커 스레드 할당                           │
│                                                            │
│  4. Spring MVC / 프레임워크 처리                              │
│     → DispatcherServlet이 요청 수신                          │
│     → Filter Chain 실행 (인코딩, CORS, 보안 등)              │
│     → HandlerMapping: URL → Controller 메서드 매핑           │
│     → Interceptor preHandle 실행                            │
│     → Controller 메서드 실행                                 │
│       → Service 계층: 비즈니스 로직                           │
│       → Repository 계층: DB 조회                             │
│     → Interceptor postHandle 실행                           │
│     → ViewResolver 또는 MessageConverter로 응답 생성          │
│     → Filter Chain 응답 처리                                 │
│                                                            │
│  5. 데이터베이스                                             │
│     → 커넥션 풀에서 DB 연결 획득 (HikariCP 등)                │
│     → SQL 실행 (쿼리 파서 → 옵티마이저 → 실행 엔진)            │
│     → 인덱스 활용하여 데이터 조회                              │
│     → 결과를 WAS에 반환                                      │
│     → 커넥션 풀에 연결 반환                                   │
│                                                            │
│  6. 응답 생성                                                │
│     → 비즈니스 로직 결과를 HTML/JSON으로 직렬화                 │
│     → 응답 헤더 설정 (Content-Type, Cache-Control, ETag 등)  │
│     → 응답 압축 (gzip/Brotli)                               │
│     → 클라이언트에 전송                                       │
└────────────────────────────────────────────────────────────┘

11.9 Step 9: HTTP 응답 수신 및 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
서버 응답 예시:

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Encoding: br
Transfer-Encoding: chunked
Cache-Control: no-cache
ETag: "w/abc123"
Set-Cookie: _tracking=xyz; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Content-Security-Policy: default-src 'self'; script-src 'self' cdn.example.com

[HTML 본문...]

보안 관련 응답 헤더:
- HSTS: 이후 접속은 반드시 HTTPS로 (위에서 설명)
- X-Content-Type-Options: nosniff → MIME 타입 스니핑 방지
- X-Frame-Options: DENY → iframe 삽입 방지 (클릭재킹 방어)
- CSP: 로드 가능한 리소스의 출처를 제한 (XSS 방어)

11.10 Step 10: 브라우저 렌더링 파이프라인

브라우저가 HTML을 받으면, 화면에 표시하기까지 정교한 렌더링 파이프라인을 거친다. 이 과정을 Critical Rendering Path라 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌──────────────────────────────────────────────────────────────┐
│              브라우저 렌더링 파이프라인 (Critical Rendering Path)│
│                                                              │
│  HTML 바이트 수신                                             │
│      ↓                                                       │
│  ① HTML 파싱 → DOM 트리 생성                                  │
│      ↓ (CSS 링크 발견 시 병렬 다운로드)                         │
│  ② CSS 파싱 → CSSOM 트리 생성                                 │
│      ↓ (script 태그 발견 시 → 파싱 중단 또는 async/defer 처리)  │
│  ③ JavaScript 실행 (DOM/CSSOM 수정 가능)                       │
│      ↓                                                       │
│  ④ DOM + CSSOM → 렌더 트리(Render Tree) 생성                  │
│      ↓                                                       │
│  ⑤ 레이아웃(Layout/Reflow): 각 요소의 크기와 위치 계산          │
│      ↓                                                       │
│  ⑥ 페인트(Paint): 각 요소를 픽셀로 변환하여 레이어에 그림        │
│      ↓                                                       │
│  ⑦ 컴포지팅(Compositing): 레이어를 합성하여 최종 화면 생성      │
│      ↓                                                       │
│  화면에 표시!                                                 │
└──────────────────────────────────────────────────────────────┘

각 단계의 상세 설명:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
① HTML 파싱 → DOM 트리:
  - 바이트를 문자로 변환 (UTF-8 디코딩)
  - 토큰화 (Tokenization): <html>, <head>, <body> 등의 토큰 생성
  - 노드 생성: 토큰을 DOM 노드로 변환
  - DOM 트리 구축: 노드들의 부모-자식 관계 설정

  <html>
  ├── <head>
  │   ├── <title>페이지 제목</title>
  │   └── <link rel="stylesheet" href="style.css">  ← CSS 다운로드 시작
  └── <body>
      ├── <h1>상품 목록</h1>
      ├── <div class="product-list">
      │   └── ...
      └── <script src="app.js"></script>  ← JS 발견 → 파싱 중단!

② CSS 파싱 → CSSOM 트리:
  - CSS 파일/style 태그의 CSS를 파싱
  - 선택자 매칭, 상속, 캐스케이딩 규칙 적용
  - CSS는 렌더링 차단 리소스: CSSOM 완성까지 렌더 트리를 만들 수 없음

③ JavaScript와 파싱 차단:
  <script src="app.js">          → 파싱 중단, 다운로드+실행 후 재개
  <script src="app.js" async>   → 파싱과 병렬 다운로드, 다운로드 완료 시 파싱 중단 후 실행
  <script src="app.js" defer>   → 파싱과 병렬 다운로드, DOM 파싱 완료 후 실행 (권장)

  ┌────────────────────────────────────────────────────┐
  │ 일반:  HTML ──■■■■ (중단) ■■■■── HTML ──           │
  │              ↓ JS 다운로드+실행 ↑                    │
  │                                                    │
  │ async: HTML ─────────────■■■ (중단) ──             │
  │              ↓ JS 다운로드  ↑실행↑                   │
  │                                                    │
  │ defer: HTML ──────────────────── DOM 파싱 완료      │
  │              ↓ JS 다운로드         ↑ JS 실행         │
  └────────────────────────────────────────────────────┘

④ 렌더 트리 생성:
  - DOM 트리 + CSSOM 트리를 결합
  - display: none인 요소는 렌더 트리에 포함되지 않음
  - <head>, <script> 등 비시각적 요소도 제외
  - visibility: hidden인 요소는 포함됨 (공간은 차지)

⑤ 레이아웃 (Layout / Reflow):
  - 뷰포트 크기 기준으로 각 요소의 정확한 위치와 크기 계산
  - %, em, vh 등 상대 단위를 픽셀로 변환
  - Box Model 적용 (margin, border, padding, content)
  - Reflow 트리거: 윈도우 리사이즈, DOM 추가/삭제, 크기/위치 변경 쿼리

⑥ 페인트 (Paint):
  - 렌더 트리의 각 노드를 화면의 실제 픽셀로 변환
  - 배경색, 텍스트, 이미지, 테두리, 그림자 등을 그림
  - 페인트 순서: 배경색 → 배경이미지 → 테두리 → 자식 → 아웃라인
  - 레이어(Layer) 단위로 페인트 (will-change, transform 등으로 레이어 분리)

⑦ 컴포지팅 (Compositing):
  - GPU가 각 레이어를 합성하여 최종 화면 생성
  - transform, opacity 등은 컴포지팅 단계에서 처리 → Reflow/Repaint 없이 빠름
  - 이것이 CSS 애니메이션에서 transform을 권장하는 이유

11.11 Step 11: 추가 리소스 로딩

HTML 파싱 중 발견된 외부 리소스들을 병렬로 요청한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
HTML 파싱 중 발견되는 리소스들:

<link rel="stylesheet" href="/css/style.css">      → CSS (렌더링 차단)
<script src="/js/app.js" defer></script>            → JS (defer)
<img src="/images/hero.jpg" alt="..." loading="lazy"> → 이미지 (지연 로딩)
<link rel="preload" href="/fonts/main.woff2" as="font"> → 폰트 (우선 로딩)

리소스 로딩 우선순위 (Chrome 기준):
  Highest:  메인 HTML, CSS
  High:     프리로드된 폰트, import된 JS, 뷰포트 내 이미지
  Medium:   CSS에서 참조하는 폰트
  Low:      일반 JS (async), 뷰포트 밖 이미지
  Lowest:   prefetch 리소스

브라우저의 동시 연결 제한:
  HTTP/1.1: 도메인당 6개 TCP 연결 (Chrome 기준)
  HTTP/2:   도메인당 1개 TCP 연결 (멀티플렉싱으로 병렬 처리)
  → 이것이 HTTP/2가 더 효율적인 이유

최적화 힌트:
  <link rel="preconnect" href="https://cdn.example.com">  → 미리 TCP+TLS 연결
  <link rel="dns-prefetch" href="//analytics.example.com"> → 미리 DNS 조회
  <link rel="preload" href="/critical.css" as="style">     → 중요 리소스 우선 로드
  <link rel="prefetch" href="/next-page.html">             → 다음 페이지 미리 로드

11.12 전체 과정 타임라인 요약

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
시간 ──────────────────────────────────────────────────→

[URL 입력]
  │
  ├─ URL 파싱 + HSTS 확인            (~0ms)
  ├─ 브라우저 캐시 확인                (~0-1ms)
  │
  ├─ DNS 조회                         (~1-100ms, 캐시 미스 시)
  │
  ├─ TCP 3-Way Handshake              (~1 RTT = 1-150ms)
  │
  ├─ TLS 1.3 Handshake               (~1 RTT = 1-150ms)
  │
  ├─ HTTP 요청 전송                    (~수 ms)
  │
  ├─ 서버 처리                         (~10-500ms, 로직에 따라)
  │   ├─ LB → 리버스프록시 → WAS
  │   ├─ 스프링 필터/인터셉터
  │   ├─ 컨트롤러 → 서비스 → DB
  │   └─ 응답 직렬화 + 압축
  │
  ├─ HTTP 응답 수신                    (~수 ms ~ 수백 ms, 크기에 따라)
  │
  ├─ HTML 파싱 + DOM 구축              (~수십 ms)
  │   ├─ CSS 병렬 다운로드 → CSSOM
  │   ├─ JS 다운로드 (async/defer)
  │   └─ 이미지/폰트 등 추가 리소스 요청
  │
  ├─ 렌더 트리 생성                    (~수 ms)
  ├─ 레이아웃 (Reflow)                 (~수 ms)
  ├─ 페인트 (Paint)                    (~수 ms)
  └─ 컴포지팅 → 화면 표시 (FCP)         (~수 ms)

총 소요 시간: 수백 ms ~ 수 초 (네트워크, 서버, 페이지 복잡도에 따라)

핵심 성능 지표:
  TTFB (Time To First Byte): 요청 → 첫 번째 바이트 수신
  FCP (First Contentful Paint): 첫 번째 콘텐츠가 화면에 그려지는 시점
  LCP (Largest Contentful Paint): 가장 큰 콘텐츠가 화면에 그려지는 시점
  TTI (Time To Interactive): 사용자 상호작용이 가능한 시점

11.13 면접에서 특히 좋아하는 보충 포인트

면접에서는 단순히 “DNS를 조회하고 TCP 연결 후 HTTP 요청을 보낸다” 수준으로 답하면 보통 중간 점수다. 아래 포인트를 함께 말하면 깊이가 확 달라진다.

  • 주소창 입력 직후 브라우저는 단순 문자열이 아니라 URL 파싱, 스킴 판별, HSTS 강제 HTTPS 전환 가능 여부를 먼저 확인한다.
  • DNS 조회도 운영체제 캐시, 브라우저 캐시, 로컬 hosts 파일, 재귀 DNS 서버 순서로 이어지며, 실제 병목인지 캐시 히트인지 구분해서 말해야 한다.
  • TCP 연결 이후 HTTPS라면 TLS 핸드셰이크에서 서버 인증서 검증, 대칭키 교환, ALPN을 통한 HTTP/2 협상이 일어난다.
  • 브라우저 렌더링 단계에서는 HTML 파싱만이 아니라 CSSOM 생성, render tree 생성, layout, paint, compositing까지 이어진다.
  • 서버는 단순히 “WAS가 응답한다”가 아니라 로드밸런서, 리버스 프록시, 애플리케이션 서버, DB/캐시를 거쳐 응답을 만든다.
  • 성능 면접에서는 TTFB, FCP, LCP를 같이 언급하면 네트워크 지식과 프론트엔드 렌더링 지식을 연결해 설명할 수 있다.

11.14 브라우저 URL 입력 질문에 대한 면접형 답변 예시

사용자가 브라우저 주소창에 URL을 입력하면, 먼저 브라우저가 URL을 파싱하고 스킴이 HTTP인지 HTTPS인지 확인합니다. HTTPS라면 HSTS 정책도 확인해서 강제로 HTTPS로 전환할 수 있습니다. 이후 브라우저/OS/로컬 DNS 캐시를 확인하고, 없으면 DNS 서버에 질의해 IP를 얻습니다. 그 다음 서버와 TCP 연결을 맺고, HTTPS면 TLS 핸드셰이크를 통해 인증서 검증과 세션 키 협상을 수행합니다. 연결이 성립되면 HTTP 요청을 전송하고, 서버는 로드밸런서-웹서버-WAS-DB 같은 계층을 거쳐 응답을 생성합니다. 브라우저는 받은 HTML을 파싱해 DOM을 만들고 CSS는 CSSOM을 만든 뒤 render tree를 구성합니다. 이후 layout, paint, compositing 단계를 거쳐 화면에 표시되고, 추가 JS/CSS/이미지 리소스는 우선순위에 따라 병렬 로딩됩니다. 성능 관점에서는 DNS lookup, TCP/TLS handshake, TTFB, 렌더링 비용이 주요 지연 요소입니다.

11.15 자주 나오는 꼬리 질문

Q: DNS 조회가 느리면 어떻게 최적화하나요?

DNS prefetch, CDN 활용, 재귀 DNS 응답 캐시 최적화, 연결 재사용을 함께 고려한다. 다만 실제 병목이 DNS인지 TCP/TLS인지 구분 없이 “CDN 쓰면 된다”고 답하면 깊이가 떨어진다.

Q: TCP 대신 QUIC/HTTP3는 무엇이 다른가요?

HTTP/3는 TCP가 아니라 UDP 기반 QUIC 위에서 동작한다. 연결 설정과 암호화가 통합되어 핸드셰이크 지연을 줄이고, TCP HOL Blocking 문제를 완화한다. 다만 면접에서는 먼저 HTTP/1.1, HTTP/2, TLS까지 안정적으로 설명한 뒤 확장 포인트로 언급하는 편이 낫다.

Q: 브라우저 렌더링에서 CSS가 왜 렌더링 차단 리소스인가요?

스타일 정보가 없으면 정확한 render tree를 만들 수 없기 때문이다. 반면 defer 스크립트는 DOM 파싱 완료 후 실행되므로 렌더링 경로에 미치는 영향이 다르다.

12. 면접에서 자주 나오는 HTTP/REST 질문과 답변

Q1: HTTP의 Stateless 특성이란 무엇이고, 왜 중요한가요?

HTTP는 각 요청이 독립적이고 서버가 이전 요청의 상태를 기억하지 않는 무상태 프로토콜입니다. 요청에 필요한 모든 정보(인증 토큰, 요청 데이터 등)가 매 요청에 포함되어야 합니다. Stateless의 장점은 서버 확장이 용이하다는 것입니다. 어떤 서버든 요청을 처리할 수 있으므로 로드밸런싱과 Scale-out이 쉬워집니다. 로그인 상태 유지 같은 상태 관리는 세션이나 JWT로 보완합니다.

Q2: PUT과 PATCH의 차이를 설명하세요.

PUT은 리소스의 전체 교체(full replacement)로, 요청에 포함되지 않은 필드는 null이나 기본값으로 초기화됩니다. 멱등성을 가집니다. PATCH는 리소스의 부분 수정(partial update)으로, 요청에 포함된 필드만 변경하고 나머지는 기존 값을 유지합니다. 엄밀히 말하면 PATCH는 멱등하지 않을 수 있습니다. 예를 들어 “나이를 1 증가”라는 PATCH 연산은 매번 결과가 달라집니다.

Q3: REST API에서 멱등성이 중요한 이유는 무엇인가요?

네트워크 장애 시 클라이언트가 응답을 받지 못하면 요청을 재전송해야 합니다. 멱등한 메서드(GET, PUT, DELETE)는 재전송해도 결과가 동일하므로 안전합니다. POST는 멱등하지 않아 재전송 시 중복 생성이 발생할 수 있습니다. 이를 해결하기 위해 Idempotency Key 패턴을 사용하거나, 서버에서 중복 체크 로직을 구현합니다.

Q4: 세션과 JWT 중 어떤 것을 선택해야 하나요?

세션은 서버에 상태를 저장하므로 강제 로그아웃 같은 즉각적인 세션 무효화가 가능하지만, 서버 Scale-out 시 세션 공유 전략(Redis 등)이 필요합니다. JWT는 토큰 자체에 정보를 포함하므로 서버가 상태를 저장할 필요 없어 확장성이 좋지만, 토큰이 발급되면 만료 전까지 강제 무효화가 어렵습니다. 전통적 웹 애플리케이션에는 세션이, SPA/모바일 앱/마이크로서비스 환경에는 JWT가 적합합니다. 실무에서는 JWT + Refresh Token + Redis 블랙리스트 조합을 많이 사용합니다.

Q5: CORS 에러가 발생하는 이유와 해결 방법은?

브라우저의 동일 출처 정책(SOP)으로 인해 다른 출처의 리소스 요청이 차단되어 발생합니다. 프론트엔드(localhost:3000)가 백엔드(localhost:8080)에 API 요청할 때 흔히 발생합니다. 서버에서 Access-Control-Allow-Origin 등의 CORS 헤더를 응답에 포함시켜 해결합니다. Spring에서는 @CrossOrigin 어노테이션이나 WebMvcConfigurer의 addCorsMappings으로 설정합니다. 주의할 점은 CORS는 브라우저의 보안 메커니즘이므로, 서버 간 통신에서는 발생하지 않습니다.

Q6: ETag는 무엇이고, 어떻게 캐싱에 활용되나요?

ETag(Entity Tag)는 리소스의 특정 버전을 식별하는 고유 문자열입니다. 서버가 응답에 ETag 헤더를 포함하면, 클라이언트는 이후 요청 시 If-None-Match 헤더에 ETag 값을 보냅니다. 서버는 리소스가 변경되지 않았으면 304 Not Modified를 본문 없이 응답하여 대역폭을 절약합니다. 변경되었으면 200 OK와 새 ETag를 반환합니다. Spring에서는 ShallowEtagHeaderFilter를 사용하면 응답 본문의 MD5 해시로 자동 ETag를 생성할 수 있습니다.

Q7: Richardson Maturity Model의 각 레벨을 설명하세요.

Level 0은 하나의 엔드포인트에 모든 요청을 POST로 보내는 RPC 스타일입니다. Level 1은 리소스를 URI로 식별하지만 여전히 하나의 메서드만 사용합니다. Level 2는 HTTP 메서드(GET, POST, PUT, DELETE)를 올바르게 활용하여 리소스를 조작하는 수준으로, 현재 대부분의 REST API가 이 수준입니다. Level 3은 HATEOAS를 적용하여 응답에 다음 가능한 행동의 링크를 포함하는 수준으로, 클라이언트가 API 문서 없이도 API를 탐색할 수 있게 합니다.

Q8: API 설계 시 HTTP 상태 코드를 어떻게 활용해야 하나요?

200은 일반 성공, 201은 리소스 생성(Location 헤더 포함), 204는 성공했지만 응답 본문 없음(DELETE), 400은 요청 데이터 검증 실패, 401은 인증 실패(로그인 필요), 403은 인가 실패(권한 없음), 404는 리소스 없음, 409는 충돌(이미 존재하는 리소스), 500은 서버 내부 오류입니다. 모든 응답을 200으로 보내고 body에 에러 코드를 넣는 것은 안티패턴입니다.

Q9: REST와 RPC의 차이는 무엇인가요?

RPC는 “무엇을 실행할 것인가”에 초점이 있고, REST는 “어떤 리소스를 어떤 상태로 표현하고 조작할 것인가”에 초점이 있습니다. 그래서 RPC는 /createOrder, /cancelOrder 같은 액션 중심 URI가 많고, REST는 /orders/{id} 같은 리소스 중심 URI를 사용합니다. 다만 실무 API는 순수 REST보다 REST 스타일 + RPC 엔드포인트를 혼합하는 경우가 많습니다. 면접에서는 “REST 제약을 지키는 것이 이상적이지만, 도메인 액션이 강한 경우 예외적 RPC 엔드포인트도 실용적으로 허용된다”고 답하면 균형감이 좋습니다.

Q10: GET 요청에도 Body를 넣을 수 있나요?

HTTP 스펙상 GET Body를 명시적으로 금지하지는 않지만, 의미가 표준화되어 있지 않고 프록시, 캐시, 프레임워크가 무시하거나 비정상 처리할 수 있습니다. 그래서 실무에서는 조회 조건은 쿼리 파라미터나 POST 검색 API로 분리하는 것이 안전합니다.

Q11: URI와 URL, URN의 차이를 설명해보세요.

URI는 리소스를 식별하는 상위 개념이고, URL은 그중 위치(location)로 리소스를 식별하는 방식입니다. URN은 이름(name)으로 식별하는 방식입니다. 실무 면접에서는 흔히 URL이라고 부르지만, 엄밀히 말하면 REST에서 리소스 식별자는 URI라는 표현이 더 정확합니다.

Q12: REST API 버저닝은 어떻게 하나요?

가장 흔한 방법은 URI 버저닝(/v1/orders)이고, 헤더 기반 버저닝(Accept: application/vnd.company.v2+json)도 있습니다. URI 버저닝은 직관적이고 운영이 쉽지만 URI가 지저분해질 수 있고, 헤더 버저닝은 HTTP스럽지만 클라이언트와 문서화 난도가 올라갑니다. 면접에서는 “대부분의 공개 API는 운영 편의상 URI 버저닝을 많이 택한다”고 답하면 현실적입니다.

Q13: no-cache와 no-store의 차이를 설명하세요.

no-store는 아예 저장하지 말라는 뜻이고, no-cache는 저장은 가능하지만 재사용 전에 반드시 원 서버 검증을 하라는 뜻입니다. 이 차이를 반대로 말하는 경우가 많아서 면접에서 자주 확인합니다.


정리

HTTP 프로토콜과 REST API의 핵심을 정리하면 다음과 같다.

HTTP 메시지는 요청 라인/상태 라인, 헤더, 빈 줄, 본문으로 구성되며, 헤더를 통해 콘텐츠 협상, 캐싱, 인증 등 다양한 메타 정보를 교환한다. 메서드의 안전성과 멱등성을 이해하면 네트워크 장애 시 재시도 전략을 올바르게 설계할 수 있다.

캐싱은 Cache-Control 헤더로 정책을 정의하고, ETag/Last-Modified로 조건부 요청을 통해 검증한다. no-cache(매번 검증)와 no-store(캐시 금지)의 차이를 정확히 구분해야 한다.

인증 방식으로 세션은 Stateful하고 즉시 무효화가 가능한 반면, JWT는 Stateless로 확장성이 좋지만 강제 만료가 어렵다. 실무에서는 Access Token + Refresh Token + Redis 조합이 일반적이다.

REST는 단순히 “URL에 명사를 쓰고 HTTP 메서드를 쓰는 것”이 아니라, Fielding이 정의한 6가지 아키텍처 제약 조건의 집합이다. Richardson Maturity Model을 통해 API의 REST 성숙도를 평가할 수 있으며, Level 2(HTTP 메서드 활용)가 실무의 표준이고 Level 3(HATEOAS)가 이론적 완성형이다.