콘텐츠로 이동

HTTP Basics

분류: Layer 1 - 백엔드 기초 | 작성일: 2026-03-21

HTTP(HyperText Transfer Protocol)는 클라이언트와 서버가 데이터를 주고받기 위한 요청-응답 기반의 통신 규약이다.

BackOps에서 다루는 거의 모든 서비스는 HTTP 위에서 동작한다. API 호출이 실패했을 때, 배포 후 서비스가 안 될 때, 외부 연동이 안 될 때 — 원인을 파악하려면 HTTP가 어떻게 동작하는지 알아야 한다. 이걸 모르면 에러 로그를 봐도 뭐가 문제인지 판단이 안 된다.

요청(Request)과 응답(Response)

클라이언트가 요청을 보내면 서버가 응답을 돌려준다. 항상 이 순서. 요청에는 메서드, URL, 헤더, 바디가 있고 응답에는 상태 코드, 헤더, 바디가 있다.

HTTP 요청이 실제로 어떻게 도달하는가 — 내부 동작 원리

“브라우저에서 URL을 치면 서버가 응답한다”는 사실 뒤에 이런 단계가 숨어 있다:

  1. DNS 조회: api.example.com203.0.113.5 IP 주소로 변환 (전화번호부를 찾는 것과 같다)
  2. TCP 3-way Handshake: 클라이언트 SYN → 서버 SYN-ACK → 클라이언트 ACK. 연결 수립 (실제로 악수하는 것과 같다)
  3. TLS Handshake (HTTPS인 경우): 인증서 검증 + 암호화 키 교환
  4. HTTP 요청 전송: 메서드 + URL + 헤더 + 바디를 텍스트로 전송
  5. 서버 처리: 라우팅 → 비즈니스 로직 → DB 조회 → 응답 생성
  6. HTTP 응답 수신: 상태 코드 + 헤더 + 바디
URL 입력부터 HTTP 응답까지
sequenceDiagram
participant Client as Browser or Client
participant DNS
participant Edge as LB or Web Server
participant App as API Server
participant DB

Client->>DNS: hostname 조회
DNS-->>Client: IP 주소 반환
Client->>Edge: TCP 3-way handshake
Client->>Edge: TLS handshake
Client->>App: HTTP request
App->>DB: 필요 시 데이터 조회
DB-->>App: rows
App-->>Client: HTTP status + headers + body

📖 더 보기: Life Cycle of an HTTP Request — 위 1~6단계 흐름을 다이어그램과 함께 설명하는 입문 가이드 (입문)

curl로 실제 HTTP 요청 확인하기

Terminal window
# -v 옵션으로 전체 요청/응답 헤더를 볼 수 있다
curl -v https://httpbin.org/get
# 예상 출력 (핵심 부분만)
* Trying 54.91.xxx.xxx:443...
* Connected to httpbin.org (54.91.xxx.xxx) port 443
* SSL handshake done
> GET /get HTTP/1.1 ← 요청 메서드 + 경로 + 버전
> Host: httpbin.org ← 요청 헤더
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 200 OK ← 응답 상태 코드
< Content-Type: application/json
< Content-Length: 340
<
{
"headers": { "Host": "httpbin.org", "User-Agent": "curl/8.5.0" },
"url": "https://httpbin.org/get"
}

NestJS에서 HTTP 상태 코드 제어

// 기본값: @Get()은 200, @Post()는 201을 자동으로 반환
@Controller("users")
export class UsersController {
@Post()
@HttpCode(201) // 명시적으로 설정 가능
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
@Delete(":id")
@HttpCode(204) // 삭제 성공: 바디 없이 204만 반환
remove(@Param("id") id: string) {
return this.usersService.remove(id);
}
}
# 예상 출력 (DELETE 요청 시)
HTTP/1.1 204 No Content

HTTP 메서드

메서드는 요청의 의도를 서버에 전달한다. 실무에서는 “조회인지, 생성인지, 전체 교체인지, 일부 변경인지, 삭제인지”를 먼저 나누고 캐싱·재시도 가능성을 함께 본다.

HTTP 메서드 선택 기준

GET

서버 상태를 바꾸지 않고 리소스를 조회한다. 캐싱과 재시도에 가장 친화적이다.

목록 조회, 상세 조회, 검색 조건이 URL로 표현될 때

POST

서버에 새 처리를 요청한다. 생성뿐 아니라 명령 실행처럼 결과가 매번 달라질 수 있는 작업에도 쓴다.

회원가입, 결제 요청, 복잡한 검색 body가 필요할 때

PUT

리소스 전체 표현을 교체한다. 같은 요청을 여러 번 보내도 최종 상태가 같아야 한다.

프로필 전체 수정처럼 클라이언트가 완전한 새 상태를 알고 있을 때

PATCH

리소스 일부 필드만 변경한다. 전체 상태를 모를 때도 작은 변경을 표현할 수 있다.

이름만 변경, 알림 설정 하나만 토글할 때

DELETE

리소스 삭제를 요청한다. 성공 시 바디 없이 204 No Content를 돌려주는 패턴이 흔하다.

사용자 탈퇴, 세션 제거, 임시 리소스 삭제처럼 제거 의도가 명확할 때

상태 코드 (외워야 할 것들)

  • 200 OK: 성공
  • 201 Created: 생성 성공
  • 204 No Content: 성공이지만 응답 바디 없음 (DELETE 성공 시 자주 사용)
  • 304 Not Modified: 캐시된 리소스가 아직 유효함 (ETag 검증 통과)
  • 400 Bad Request: 요청이 잘못됨 (클라이언트 실수)
  • 401 Unauthorized: 인증 안 됨 (로그인 안 함)
  • 403 Forbidden: 권한 없음 (로그인은 했지만 접근 불가)
  • 404 Not Found: 리소스 없음
  • 429 Too Many Requests: 요청 횟수 초과 (Rate Limiting)
  • 500 Internal Server Error: 서버 내부 오류
  • 502 Bad Gateway: 중간 서버(프록시/로드밸런서)가 뒤에 있는 서버로부터 잘못된 응답을 받음
  • 503 Service Unavailable: 서버 과부하 또는 점검 중
  • 504 Gateway Timeout: 중간 서버가 뒤에 있는 서버의 응답을 기다리다 타임아웃

퀴즈

401과 403을 같은 인증 실패로 처리하면 무엇을 놓치는가?

힌트: HTTP 상태 코드는 디버깅 출발점을 좁히는 신호다.

정답 보기

401은 신원을 증명하지 못한 상태라 토큰 존재와 만료를 먼저 보고, 403은 신원은 확인됐지만 권한이 부족한 상태라 역할과 정책을 봐야 한다.

헤더(Header)

요청/응답에 대한 메타데이터. Content-Type(데이터 형식), Authorization(인증 정보), Cache-Control(캐싱 규칙) 등이 자주 쓰인다.

Stateless(무상태)

HTTP는 각 요청이 독립적이다. 서버는 이전 요청을 기억하지 않는다.

그래서 인증이 필요한 모든 요청마다 Authorization 헤더를 포함해야 한다.

Session/Cookie가 필요한 이유도 이 stateless 성질 때문이다.

용어

Stateless

HTTP 서버가 이전 요청을 기억하지 않는다는 뜻이다. 그래서 인증, 추적 ID, 멱등성 키처럼 다음 판단에 필요한 정보는 매 요청마다 다시 전달해야 한다.

실무에서는 Authorization 헤더, Cookie, Idempotency-Key, X-Request-Id가 이 한계를 보완한다.

HTTPS

HTTP + TLS 암호화. 데이터가 중간에 탈취당해도 읽을 수 없게 한다. 현재 거의 모든 서비스가 HTTPS를 사용한다.

HTTP/2 — 왜 HTTP/1.1보다 빠른가

비유하자면, HTTP/1.1은 “한 줄짜리 창구”이다. 요청 하나가 끝나야 다음 요청을 할 수 있다. HTTP/2는 “여러 창구를 동시에 열어두는 것”이다.

HTTP/1.1의 문제점: 하나의 TCP 연결에서 요청을 직렬로 처리한다. 브라우저는 이를 우회하려고 보통 6~8개의 TCP 연결을 병렬로 열지만, 이것은 서버 리소스를 낭비한다.

HTTP/2가 이를 해결하는 방법:

  1. 멀티플렉싱(Multiplexing): 하나의 TCP 연결에서 여러 요청·응답을 동시에 주고받는다. 요청 순서가 달라도 각 요청에 스트림 ID를 붙여서 구분한다.
  2. 헤더 압축(HPACK): HTTP/1.1은 요청마다 동일한 헤더(User-Agent, Cookie 등)를 반복 전송한다. HTTP/2는 HPACK 알고리즘으로 이전에 보낸 헤더를 인덱스로 참조해 중복을 제거한다.
  3. 바이너리 프레이밍: 텍스트 대신 0/1 이진 형식으로 데이터를 전송해 파싱이 빠르고 에러에 강하다.
HTTP/1.1 연결 흐름 (직렬):
클라이언트 ──요청1──► 서버
클라이언트 ◄──응답1─── 서버
클라이언트 ──요청2──► 서버 ← 응답1 올 때까지 기다림
클라이언트 ◄──응답2─── 서버
HTTP/2 연결 흐름 (멀티플렉싱):
클라이언트 ──요청1(stream1)──► 서버
클라이언트 ──요청2(stream2)──► 서버 ← 동시에 보냄
클라이언트 ──요청3(stream3)──► 서버
클라이언트 ◄──응답2(stream2)─── 서버 ← 먼저 처리된 것 먼저 옴
클라이언트 ◄──응답1(stream1)─── 서버

NestJS는 기본적으로 HTTP/1.1로 동작하며, AWS ALB(Application Load Balancer)가 클라이언트와 HTTP/2로 통신하고 내부적으로 HTTP/1.1로 NestJS에 전달하는 방식이 일반적이다.

📖 더 보기: HTTP/2 vs HTTP/1.1 — Cloudflare — 멀티플렉싱과 헤더 압축을 다이어그램으로 설명하는 공식 가이드 (입문)

HTTP Keep-Alive — 연결을 재사용해서 성능을 높이는 방법

비유하자면, Keep-Alive는 “전화 통화를 끊지 않고 여러 용건을 처리하는 것”이다. 매번 전화를 새로 거는(TCP 연결을 새로 맺는) 비용을 절약한다.

TCP 연결을 새로 맺을 때마다 3-way handshake + TLS handshake가 일어나고, 이것이 수십~수백 ms의 지연을 만든다. Keep-Alive를 사용하면 한 번 맺은 연결을 여러 요청에 재사용할 수 있다.

NestJS에서 마이크로서비스 간 HTTP 호출을 반복할 때 Keep-Alive를 사용하면 응답 속도가 크게 개선된다:

// NestJS HttpModule에서 Keep-Alive 설정
import * as http from "http";
import * as https from "https";
@Module({
imports: [
HttpModule.register({
httpsAgent: new https.Agent({ keepAlive: true }),
httpAgent: new http.Agent({ keepAlive: true }),
timeout: 5000,
}),
],
})
export class AppModule {}
# Keep-Alive 비교 (같은 서버에 10회 연속 요청)
Without Keep-Alive: 각 요청마다 TCP+TLS 핸드셰이크
→ 평균 응답 시간: ~250ms
With Keep-Alive: 연결 재사용
→ 평균 응답 시간: ~50ms (첫 요청 이후 80% 단축)

Node.js 서버의 기본 Keep-Alive 타임아웃은 5초로, AWS ALB의 기본 60초보다 짧다. ALB가 연결을 유지하려는데 NestJS 서버가 먼저 끊으면 502가 발생할 수 있다. NestJS 서버 타임아웃을 ALB보다 길게 설정해야 한다:

// main.ts — NestJS Keep-Alive 타임아웃을 ALB보다 길게 설정
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const server = app.getHttpServer();
server.keepAliveTimeout = 65000; // ALB 60초보다 길게 (65초)
server.headersTimeout = 66000; // keepAliveTimeout보다 1초 더 길게
await app.listen(3000);
}

HTTP 캐싱 — 왜 304 응답이 중요한가

서버가 동일한 데이터를 계속 보내지 않도록 하는 메커니즘이다. 클라이언트와 서버가 합의한 기준으로 “이미 가진 데이터를 재사용해도 된다”고 판단하면 네트워크 전송을 건너뛸 수 있다.

  • Cache-Control: max-age=300: 300초(5분)간 캐시된 응답을 사용해도 됨
  • ETag: 리소스 버전을 나타내는 식별자. 서버가 응답에 ETag: "abc123" 포함 → 클라이언트가 다음 요청에 If-None-Match: "abc123" 포함 → 서버가 변경이 없으면 304 Not Modified 반환 (바디 없음)
ETag 기반 304 판단 흐름
flowchart TD
A["첫 GET /users/1"] --> B["200 OK + ETag v1"]
B --> C["다음 요청에 If-None-Match v1 포함"]
C --> D{"서버 리소스가 바뀌었나?"}
D -->|아니오| E["304 Not Modified - 바디 없음"]
D -->|예| F["200 OK + 새 ETag + 새 바디"]

HTTP/3(QUIC) 프로덕션 현황 — 2025년 35% 채택률의 의미

HTTP/3는 더 이상 미래 기술이 아니다. 2025년 10월 Cloudflare 데이터 기준으로 전 세계 트래픽의 35%가 HTTP/3로 전송된다. AWS CloudFront와 Cloudflare는 이미 HTTP/3를 기본 지원하므로, NestJS 백엔드 개발자도 이 계층의 동작을 이해해야 한다.

HTTP/2에 남아있던 문제: TCP 계층에서 하나의 패킷이 손실되면 모든 스트림이 기다려야 한다(TCP 수준의 Head-of-Line Blocking). HTTP/3의 QUIC는 각 스트림이 독립적이라서 하나의 패킷 손실이 다른 스트림에 전혀 영향을 주지 않는다.

HTTP/3 성능 이점 (2025년 실측 데이터):
- TTFB(첫 바이트 수신 시간) 중앙값 41.8% 감소
- 고패킷 손실(5%) 환경에서 로드 시간 47% 개선
- 모바일 → Wi-Fi 전환 시 연결 유지 (Connection ID 기반)
적합한 환경:
✅ 모바일 사용자가 많은 서비스 (네트워크 전환 잦음)
✅ 불안정한 네트워크 환경 (이동 중 사용)
✅ 고지연 국제 트래픽
효과 제한적인 환경:
⚠️ 안정적인 내부망 마이크로서비스 간 통신
⚠️ 동일 리전 내 서버-서버 통신

BackOps에서 실용적인 시사점: NestJS 앱 자체는 여전히 HTTP/1.1 또는 HTTP/2로 동작하고, 엣지(ALB, CloudFront)가 클라이언트와 HTTP/3로 통신한다. 백엔드 코드 변경 없이 ALB/CloudFront 설정만으로 HTTP/3 혜택을 받을 수 있다.

Terminal window
# CloudFront에서 HTTP/3 지원 여부 확인
curl -I --http3 https://your-cloudfront-domain.cloudfront.net/
# 응답 헤더에 다음이 있으면 HTTP/3 지원 중:
# alt-svc: h3=":443"; ma=86400
# ALB가 HTTP/2를 지원하는지 확인
curl -I --http2 https://api.example.com/health
# 응답에 HTTP/2 200 이면 HTTP/2 활성화됨

3.5. HTTP 원리의 전이 가능성 — 다른 기술에 어떻게 적용되는가

섹션 제목: “3.5. HTTP 원리의 전이 가능성 — 다른 기술에 어떻게 적용되는가”

HTTP에서 배운 원리는 gRPC, WebSocket, 메시지 큐 등 다른 기술에도 그대로 적용된다. 원리를 이해하면 새 기술을 접할 때 “이게 왜 이렇게 동작하는가”를 빠르게 파악할 수 있다.

HTTP 원리HTTP에서의 역할다른 기술에서의 적용
Stateless각 요청이 독립적, 서버가 이전 요청 기억 안 함gRPC Unary: 각 RPC 호출이 독립적 (Stateless RPC). 메시지 큐(SQS/Kafka): 각 메시지가 자기완결적, 브로커가 상태 보관 안 함. 이벤트 소싱: 이벤트마다 충분한 컨텍스트를 포함
멀티플렉싱HTTP/2: 하나의 TCP 연결에서 여러 스트림 동시 처리gRPC (HTTP/2 기반): 하나의 gRPC 채널에서 수백 개 동시 RPC 호출 처리. HTTP/3 QUIC: UDP 기반 독립 스트림으로 HOL 블로킹 완전 제거
연결 재사용 (Keep-Alive)TCP+TLS 핸드셰이크 비용을 여러 요청에 분산DB 커넥션 풀: 연결 수립 비용 분산 (동일 원리). gRPC 채널 재사용: 채널 1개로 수백 동시 호출 처리, 채널 생성마다 TCP+TLS 비용 발생하므로 재사용 필수 (출처: gRPC Performance — Microsoft Learn)
헤더 기반 메타데이터Content-Type, Authorization 등으로 메타데이터 전달gRPC 메타데이터(Metadata): HTTP/2 헤더와 동일 메커니즘. Kafka 레코드 헤더: 메시지 본문 외 메타데이터 전달

핵심 통찰: WebSocket은 HTTP 업그레이드 핸드셰이크로 시작하지만, 연결 후에는 Stateful 양방향 채널이 된다. HTTP/2 멀티플렉싱과 달리 WebSocket은 자체 멀티플렉싱이 없어 연결당 하나의 스트림만 처리한다. (출처: Communication Protocols — GetStream)

새 통신 기술을 분석하는 4질문 (HTTP에서 추출한 일반 공식)

위 표의 4축은 단순 매핑이 아니라 새 프로토콜을 만났을 때 가장 먼저 던져야 할 질문이다.

  1. 상태 관리는 어디서? — 호출자, 수신자, 브로커, 클라이언트 중 어디가 상태를 기억하는가
  2. 다중화 단위는? — 연결당 1 스트림인가, 연결당 N 스트림인가, 연결-스트림이 아예 분리되어 있는가
  3. 연결 비용은 어떻게 분산하는가? — 매 호출 새 연결인가, 풀/채널을 재사용하는가
  4. 메타데이터는 본문과 분리되는가? — 헤더 같은 별도 채널이 있는가, 본문에 인라인되는가

이 4질문을 gRPC에 그대로 적용해 보면: ① Unary RPC는 stateless ② 채널 1개에 동시 RPC N개 (HTTP/2 멀티플렉싱) ③ 채널 재사용 필수 (연결 비용 분산) ④ Metadata API가 헤더 역할. 그래서 “gRPC 채널을 매 호출 새로 만들면 안 된다”는 운영 규칙이 HTTP Keep-Alive 운영 규칙과 같은 원리에서 나온다. WebSocket에 적용해 보면 ①에서 끊긴다 — 연결 후 Stateful이라 재연결 시 상태 복원 책임이 애플리케이션으로 넘어가고, 이것이 HTTP API와 가장 큰 운영 차이를 만든다.


3.6. HTTP/2 도입 Trade-off — 언제 ALB에 위임하고 언제 직접 적용하는가

섹션 제목: “3.6. HTTP/2 도입 Trade-off — 언제 ALB에 위임하고 언제 직접 적용하는가”

HTTP/2를 도입할 때 “NestJS 서버에서 직접 HTTP/2를 활성화할 것인가, ALB에 위임할 것인가”를 선택해야 한다.

ALB HTTP/2 위임이 적합한 경우 (대부분의 백엔드 서비스)

  • 서비스 규모에 무관하게, 클라이언트(브라우저/앱) ↔ ALB 구간만 HTTP/2 혜택이 필요한 경우
  • NestJS는 HTTP/1.1 유지, ALB가 HTTP/2를 종료하고 HTTP/1.1로 내부 전달
  • 이점: 앱 코드 변경 없이 HTTP/2 혜택, ALB가 연결 관리 전담
  • 한계: ALB ↔ NestJS 구간은 여전히 HTTP/1.1 (내부 마이크로서비스 트래픽 많을 때 병목 가능)

NestJS에서 HTTP/2 직접 활성화가 고려되는 경우

  • 서비스 간 gRPC 통신이 주가 되어 ALB를 통한 gRPC 라우팅이 필요한 경우
  • AWS ALB는 2020년부터 end-to-end HTTP/2 및 gRPC 라우팅을 공식 지원 (출처: AWS Blog — ALB HTTP/2 and gRPC)

측정 데이터로 본 결정 기준 — HTTP/3 도입을 켜야 할까

Cloudflare가 2024년 자사 트래픽으로 HTTP/3 vs HTTP/2를 비교 측정한 결과는 다음과 같다 (출처: Comparing HTTP/3 vs HTTP/2 — Cloudflare):

  • TTFB(첫 바이트 수신 시간) 평균: HTTP/3 176ms vs HTTP/2 201ms → 약 12.4% 개선
  • 작은 페이지(15KB) 로드 시간: HTTP/3 443ms vs HTTP/2 458ms (소폭 우위)
  • 큰 페이지(1MB) 로드 시간: HTTP/3 2.33s vs HTTP/2 2.30s (사실상 동일, HTTP/2가 미세하게 빠름)

이 데이터로 결정한 사례: “주 트래픽이 1MB 이상 이미지·동영상 페이로드인 미디어 API”라면 HTTP/3 도입의 TTFB 개선 효과가 큰 페이로드의 전송 시간에 묻혀 사용자 체감 차이가 거의 없다. 따라서 CloudFront에서 HTTP/3 활성화는 작은 응답이 많은 API용 디스트리뷰션에 우선 적용하고, 대용량 미디어용 디스트리뷰션은 HTTP/2 유지가 합리적이다. 반대로 모바일 비중이 높은 헤드리스 커머스 API(응답 평균 5~50KB)는 TTFB 12% 개선이 직접 전환율 지표로 이어지므로 HTTP/3 활성화가 우선순위 높다.

Keep-Alive 부작용 — 서버 리소스 고갈 조건

Keep-Alive는 성능을 높이지만 과도하게 열린 연결이 서버 파일 디스크립터와 메모리를 점유한다.

위험 조건:
- 짧은 요청이 많은 API + 긴 Keep-Alive 타임아웃
→ 연결이 빨리 닫히지 않아 열린 연결 수가 급증
- OS 기본 파일 디스크립터 한도(1024)에 근접하면 새 연결 거부
- Node.js keepAliveTimeout &lt; ALB idle timeout 이면 ALB가 재사용하려는 순간 앱이 이미 연결을 닫아 502 발생
안전한 설정 (AWS ALB 환경):
- ALB idle timeout: 60초 (기본값)
- NestJS keepAliveTimeout: 65,000ms (ALB보다 5초 길게)
- NestJS headersTimeout: 66,000ms (keepAliveTimeout보다 1초 길게)

시나리오

간헐적 502가 배포 직후 늘어난 상황

ALB 앞단의 NestJS API에서 트래픽이 늘 때만 502가 보인다. 앱 로그에는 명확한 예외가 없고, 요청 재시도는 대부분 성공한다.

먼저 HTTPCode_ELB_502_Count와 NestJS keepAliveTimeout 값을 확인하고, Node.js keepAliveTimeout이 ALB idle timeout보다 짧은지 판단한다.

명령 단위 진단 → 복구 절차 (간헐적 502 발생 시)

Terminal window
# 1. ALB 측에서 502가 어느 쪽 원인인지 먼저 확인
aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB \
--metric-name HTTPCode_ELB_502_Count \
--dimensions Name=LoadBalancer,Value=app/my-alb/abc123 \
--start-time 2026-05-01T00:00:00Z --end-time 2026-05-01T01:00:00Z \
--period 300 --statistics Sum
# 예상 출력: Sum > 0 이면 ALB 측에서 502 카운트됨 → 백엔드 timeout 의심
# 2. 현재 NestJS 컨테이너의 keepAliveTimeout 실제 값 확인
kubectl exec -n production deploy/api-server -- \
node -e "console.log(require('http').globalAgent.keepAliveMsecs)"
# 예상 출력: 5000 이면 기본값 그대로 (수정 필요), 65000 이면 적용됨
# 3. 복구: 환경변수 또는 main.ts에서 server.keepAliveTimeout = 65000 설정 후 롤링 재시작
kubectl rollout restart deploy/api-server -n production
kubectl rollout status deploy/api-server -n production --timeout=180s
# 예상 출력: deployment "api-server" successfully rolled out
# 4. 검증: 5분간 502 카운트가 0으로 떨어지는지 watch
watch -n 30 'aws cloudwatch get-metric-statistics ... --start-time $(date -u -v-5M +%FT%TZ) ...'
# 다음 단계: 5분간 502 카운트 0이면 종료. 여전히 발생하면 ALB idle timeout(60초)이 변경되었거나 idle_timeout > 65초인지 의심

(출처: Reverse Proxy Keep-Alive 502 분석 — iximiuz, Check your server.keepAliveTimeout — Shuhei Kagawa)

CORS preflight 비용과 캐싱 전략

OPTIONS preflight 요청은 실제 API 요청보다 먼저 발생해 레이턴시를 추가한다.

preflight 캐싱 전략:
- Access-Control-Max-Age: 86400 → Firefox 최대 24시간 캐시
- Access-Control-Max-Age: 7200 → Chrome/Chromium 최대 2시간 캐시
- 기본값(설정 안 하면): 5초 → 매 10초 polling API는 preflight를 2배로 보냄
NestJS 권장 설정:
app.enableCors({
origin: ['https://app.example.com'],
maxAge: 86400, // 24시간 (Firefox 기준 최대)
});

(출처: Cache your CORS — httptoolkit.com, MDN CORS)


  • API 서버 간 통신 (내부 마이크로서비스 호출)
  • 프론트엔드 ↔ 백엔드 통신
  • 외부 서비스 연동 (결제, 알림, 외부 API)
  • 배포 후 헬스체크 (서버가 살아있는지 확인)
  • 모니터링 (HTTP 상태 코드 기반 알림)
  • API 호출 실패 시 로그에서 상태 코드를 보고 원인을 파악해야 함
  • 배포 후 502/503 에러가 발생하면 서버 상태를 판단해야 함
  • 외부 서비스 연동 이슈 디버깅 시 요청/응답 구조를 이해해야 함
  • CI/CD 파이프라인에서 헬스체크 엔드포인트의 동작을 이해해야 함
개념 A개념 B차이점
401 Unauthorized403 Forbidden401은 “너 누구야” (인증 실패), 403은 “너인 건 아는데 안 돼” (권한 부족)
PUTPATCHPUT은 리소스 전체를 교체, PATCH는 일부만 수정
HTTPHTTPSHTTPS = HTTP + TLS 암호화. 데이터 전송 시 암호화 여부의 차이
500502500은 서버 자체 오류, 502는 중간 프록시가 뒷단 서버로부터 비정상 응답을 받은 것
HTTP/1.1HTTP/2HTTP/1.1은 직렬 요청, HTTP/2는 멀티플렉싱으로 동시 요청 처리
200 OK304 Not Modified200은 새 데이터 포함, 304는 캐시가 유효해서 바디 없이 “그대로 써도 됨”
502504502는 뒷단 서버가 비정상 응답, 504는 뒷단 서버가 응답 시간 초과

🔧 CORS 에러 — “Access to fetch has been blocked by CORS policy”

섹션 제목: “🔧 CORS 에러 — “Access to fetch has been blocked by CORS policy””

증상: 프론트엔드에서 API 호출 시 브라우저 콘솔에 CORS 에러가 뜨고 요청이 막힘

원인: 서버가 다른 출처(Origin)에서 오는 요청을 허용하지 않도록 설정되어 있음. HTTP 응답 헤더에 Access-Control-Allow-Origin이 없거나 잘못된 값

해결:

// main.ts — NestJS에서 CORS 허용
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: "http://localhost:3000", // 개발 환경
credentials: true,
});
await app.listen(8080);
}

주의: 프로덕션에서 origin: '*'은 보안 위험. 허용할 도메인을 명시적으로 지정할 것.


🔧 502 Bad Gateway — 배포 후 갑자기 502

섹션 제목: “🔧 502 Bad Gateway — 배포 후 갑자기 502”

증상: 배포 직후 또는 서버 재시작 시 502 응답이 옴. 로드밸런서/리버스 프록시(nginx, ALB)는 살아있는데 앱 서버가 응답 안 함

원인: 앱 컨테이너가 아직 기동 중이거나, 헬스체크 포트가 잘못 설정되어 있거나, 앱이 크래시 후 재시작 중

해결:

  1. ECS/EC2의 앱 로그 확인 — 앱 서버 자체 에러인지 파악
  2. 헬스체크 엔드포인트(/health)가 정상 응답하는지 확인
  3. 배포 전략이 Rolling이라면 구버전 컨테이너가 내려가기 전에 신버전이 완전히 뜨는지 확인

🔧 401 vs 403 — 디버깅 방향이 다르다

섹션 제목: “🔧 401 vs 403 — 디버깅 방향이 다르다”

증상: API 호출 시 401 또는 403 응답

원인과 해결:

  • 401 Unauthorized → 인증 토큰이 없거나 만료됨
    • Authorization 헤더가 포함되었는지 확인
    • 토큰 만료 여부 확인 (jwt.io에서 디코딩해서 exp 필드 확인)
    • 토큰 재발급 로직(Refresh Token) 동작 여부 확인
  • 403 Forbidden → 토큰은 유효하지만 해당 리소스 접근 권한 없음
    • 해당 사용자의 역할(Role)이 맞는지 확인
    • RBAC 정책 또는 AWS IAM 정책 확인

🔧 요청 타임아웃 — 응답이 안 오고 연결이 끊김

섹션 제목: “🔧 요청 타임아웃 — 응답이 안 오고 연결이 끊김”

증상: API 호출 시 응답이 오지 않고 일정 시간 후 ETIMEDOUT 또는 504 Gateway Timeout 발생

원인: 서버 처리 시간이 너무 길거나(DB 쿼리 슬로우), 서버가 다운되었거나, 방화벽/보안 그룹이 요청을 막고 있음

해결:

Terminal window
# curl로 응답 시간 측정
curl -o /dev/null -s -w "Total: %{time_total}s\nHTTP Code: %{http_code}\n" \
https://api.example.com/users
# 예상 출력 (정상):
# Total: 0.234s
# HTTP Code: 200
# 예상 출력 (슬로우 쿼리):
# Total: 30.001s
# HTTP Code: 504

AWS 환경에서는 ALB의 기본 idle timeout이 60초이고, ECS 태스크가 그 안에 응답하지 않으면 504가 발생한다. 슬로우 쿼리는 CloudWatch Logs에서 DB 실행 시간을 확인해야 한다.


🔧 NestJS 서버에서 간헐적 502 — Keep-Alive 타임아웃 불일치

섹션 제목: “🔧 NestJS 서버에서 간헐적 502 — Keep-Alive 타임아웃 불일치”

증상: 트래픽이 적을 때나 배포 직후 간헐적으로 502가 발생. 재시도하면 정상. ALB 로그에 502 Target closed the connection while we were reading the response

원인: NestJS(Node.js)의 기본 keepAliveTimeout(5초)이 AWS ALB의 idle timeout(기본 60초)보다 짧다. ALB가 연결을 재사용하려는 시점에 NestJS가 이미 연결을 끊어버림

해결:

// main.ts — NestJS Keep-Alive 타임아웃을 ALB보다 길게 설정
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const server = app.getHttpServer();
// ALB idle timeout(60초)보다 서버 타임아웃을 길게 설정
server.keepAliveTimeout = 65000; // 65초
server.headersTimeout = 66000; // keepAliveTimeout보다 반드시 길게
await app.listen(3000);
}
// 예상 결과: 간헐적 502 사라짐

🔧 NestJS 요청이 처리되지 않고 Controller까지 도달하지 않음

섹션 제목: “🔧 NestJS 요청이 처리되지 않고 Controller까지 도달하지 않음”

증상: 분명히 Controller에 핸들러를 정의했는데 요청이 도달하지 않음. 로그가 찍히지 않거나 404가 아닌 엉뚱한 응답이 옴.

원인: NestJS 요청 처리 순서를 모르면 어느 단계에서 막히는지 파악하기 어렵다.

NestJS 요청 처리 순서 (Request Lifecycle):

HTTP 요청 수신
[1] Middleware (전역 → 모듈별 순서)
↓ (next()를 호출하지 않으면 여기서 멈춤)
[2] Guards (전역 → 컨트롤러 → 핸들러 순서)
↓ (canActivate()가 false를 반환하면 403)
[3] Interceptors (전역 → 컨트롤러 → 핸들러) — 요청 측
[4] Pipes (전역 → 핸들러 → 파라미터 순서)
↓ (유효성 검증 실패 시 400)
[5] Controller 핸들러 실행
[6] Interceptors — 응답 측 (역순)
[7] Exception Filters (예외 발생 시)

해결 디버깅 체크리스트:

// 1. Guard가 막고 있는지 확인 — 임시로 Guard 제거 후 테스트
// @UseGuards(JwtAuthGuard) ← 주석 처리 후 요청이 오면 Guard 문제
@Get(':id')
findOne(@Param('id') id: string) { ... }
// 2. Pipe 오류인지 확인 — ValidationPipe 로그 확인
app.useGlobalPipes(new ValidationPipe({
exceptionFactory: (errors) => {
console.log('Validation errors:', JSON.stringify(errors)); // 로그 추가
return new BadRequestException(errors);
}
}));
// 3. Middleware에서 next()를 호출하는지 확인
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log(`Request: ${req.method} ${req.url}`);
next(); // ← 이게 없으면 요청이 여기서 멈춤!
}
}

🔧 Silent Failure — 200 OK인데 잘못 동작하는 경우

섹션 제목: “🔧 Silent Failure — 200 OK인데 잘못 동작하는 경우”

상태 코드만 보는 모니터링이 가장 놓치기 쉬운 실패 카테고리다. RFC 9111도 “재무 트랜잭션이 조용히 미실행되는 것을 막기 위해” must-revalidate 같은 directive를 정의한다 (출처: RFC 9111 — HTTP Caching). 아래 3가지가 BackOps에서 자주 만나는 silent failure다.

Silent failure 1 — Stale ETag 캐시: 304 와야 할 때 200을 보내며 옛 데이터 반환

Terminal window
# 증상: 클라이언트가 옛 데이터를 본다고 보고했는데 서버 로그·5xx 알림은 깨끗
# 진단: 같은 ETag로 두 번 요청해 304가 와야 정상
ETAG=$(curl -sI https://api.example.com/users/1 | awk '/ETag/ {print $2}' | tr -d '\r')
curl -sI -H "If-None-Match: $ETAG" https://api.example.com/users/1 | head -1
# 예상 출력 (정상): HTTP/1.1 304 Not Modified
# silent failure 신호: HTTP/1.1 200 OK ← ETag 비교가 깨졌거나 프록시가 헤더 제거
# 복구: CloudFront/ALB가 If-None-Match 헤더를 origin으로 전달하는지 확인
aws cloudfront get-distribution-config --id E1ABCDEF \
| jq '.DistributionConfig.DefaultCacheBehavior.ForwardedValues.Headers'
# 예상 출력: "If-None-Match"가 Items 배열에 있어야 함
# 다음 단계: 누락이면 update-distribution-config로 헤더 forward 추가 후 캐시 무효화

Silent failure 2 — Cache-Control 누락으로 의도하지 않은 CDN 캐싱

Terminal window
# 증상: 사용자별 응답이 다른 사람에게 노출 (개인 데이터 유출 가능성)
curl -I https://api.example.com/users/me -H "Authorization: Bearer $TOKEN"
# 예상 출력 (정상): Cache-Control: private, no-store
# silent failure 신호: Cache-Control 헤더 자체 부재 또는 public ← CDN이 사용자 응답을 공유 캐싱
# 복구 (NestJS 글로벌 인터셉터에서 Auth 응답에 no-store 강제):
# main.ts: app.use((req, res, next) => { if (req.headers.authorization) res.setHeader('Cache-Control', 'private, no-store'); next(); });
# 검증: CDN 캐시 즉시 무효화
aws cloudfront create-invalidation --distribution-id E1ABCDEF --paths "/users/me*"
# 예상 출력: { "Invalidation": { "Status": "InProgress" ... } }

Silent failure 3 — preflight 실패가 실제 요청 실패로 보고되는 CORS 케이스

Terminal window
# 증상: 프론트엔드에서 "API 응답 안 옴"으로 보고. 백엔드 로그에는 OPTIONS만 보이고 GET 흔적 없음
curl -X OPTIONS https://api.example.com/users \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: GET" -i
# 예상 출력 (정상): HTTP/1.1 204 + Access-Control-Allow-Origin 헤더
# silent failure 신호: 200이지만 Access-Control-Allow-Origin 헤더 부재
# → 브라우저가 실제 GET을 보내지 않아 백엔드는 "요청 자체가 없었던" 것처럼 보임
# 복구: NestJS enableCors에 origin 추가 후 재배포 (rollback이 필요하면 이전 이미지 태그로 되돌림)
kubectl set image deploy/api-server api=myrepo/api:previous-tag -n production
kubectl rollout status deploy/api-server -n production --timeout=120s

HTTP Basics 복습 체크

  • GET과 POST의 차이를 설명할 수 있다
  • 400, 401, 403, 500, 502의 차이를 디버깅 방향과 연결할 수 있다
  • 요청의 구성요소인 메서드, URL, 헤더, 바디를 읽을 수 있다
  • HTTP가 stateless라서 매 요청마다 인증 정보를 보내야 하는 이유를 설명할 수 있다
  • HTTPS가 왜 필요한지 설명할 수 있다
  • curl로 API를 호출하고 상태 코드와 헤더를 읽을 수 있다
  • HTTP/2가 HTTP/1.1보다 빠른 이유를 멀티플렉싱 관점에서 설명할 수 있다
  • Keep-Alive와 NestJS 타임아웃 설정이 ALB 502와 어떻게 연결되는지 설명할 수 있다

REST, HTTP/3(QUIC), Keep-Alive, CORS, Content Negotiation, Cookie vs Session, Stateless, WebSocket, ETag, Cache-Control

  • 터미널에서 curl -v https://httpbin.org/get 실행해서 요청/응답 헤더 확인
Terminal window
curl -v https://httpbin.org/get
# 예상 출력:
# * SSL handshake done
# > GET /get HTTP/1.1
# > Host: httpbin.org
# < HTTP/1.1 200 OK
# < Content-Type: application/json
# {
# "headers": { "Host": "httpbin.org", "User-Agent": "curl/8.5.0" },
# "url": "https://httpbin.org/get"
# }
  • POST 요청 보내보기
Terminal window
curl -X POST https://httpbin.org/post \
-d '{"name":"test"}' \
-H 'Content-Type: application/json'
# 예상 출력: 요청한 바디가 "json" 키 아래에 그대로 반환됨
# { "json": { "name": "test" }, ... }
  • 현재 팀 서비스의 API를 하나 골라서 curl로 호출해보고, 응답 상태 코드와 헤더 확인
Terminal window
# 응답 시간 측정 포함
curl -o /dev/null -s -w "HTTP Code: %{http_code}\nTotal: %{time_total}s\n" \
https://api.example.com/health
# 예상 출력:
# HTTP Code: 200
# Total: 0.087s
Terminal window
# ETag 캐싱 동작 확인
# 첫 번째 요청
curl -I https://api.example.com/users/1
# 응답 헤더에 ETag가 있으면: ETag: "v1-abc123"
# ETag를 포함한 두 번째 요청
curl -I -H 'If-None-Match: "v1-abc123"' https://api.example.com/users/1
# 예상 출력 (데이터 변경 없으면): HTTP/1.1 304 Not Modified
# 예상 출력 (데이터 변경됐으면): HTTP/1.1 200 OK, ETag: "v1-xyz789"
  • 브라우저 개발자 도구 → Network 탭에서 요청/응답 확인해보기

HTTP/3는 HTTP/2의 TCP 기반 한계를 극복하기 위해 UDP 위에 구축된 QUIC 프로토콜을 사용한다.

HTTP/2의 남은 문제점: TCP는 패킷 하나가 손실되면 모든 스트림이 기다려야 한다(Head-of-Line Blocking). HTTP/3는 각 스트림이 독립적이라서 하나의 패킷 손실이 다른 스트림에 영향을 주지 않는다.

HTTP 버전 비교:
HTTP/1.1 → HTTP/2 → HTTP/3
직렬 요청 멀티플렉싱 QUIC(UDP) 기반 멀티플렉싱
TCP TCP UDP + 독립 스트림
헤더 압축(HPACK) 헤더 압축(QPACK)

HTTP/3의 또 다른 장점은 연결 마이그레이션이다. QUIC는 4계층(전송) 연결을 3계층(IP) 흐름과 분리하므로, 모바일 기기가 Wi-Fi에서 셀룰러로 네트워크를 전환해도 연결이 끊기지 않는다. 2024년 기준 상위 1천만 웹사이트의 34%가 HTTP/3를 지원하며, 주요 브라우저의 95% 이상이 호환된다. AWS CloudFront, Cloudflare는 이미 HTTP/3를 지원한다. NestJS 백엔드 자체는 여전히 HTTP/1.1 또는 HTTP/2로 동작하고, 엣지(CDN/로드밸런서)에서 HTTP/3를 처리하는 방식이 일반적이다.

📖 더 보기: HTTP/3 and QUIC — Cloudflare — HTTP/3가 TCP 대신 QUIC를 사용하는 이유와 0-RTT 연결 복원을 설명 (중급)

10. 요약 — 이것만 기억해도 된다

섹션 제목: “10. 요약 — 이것만 기억해도 된다”
요청이 실패했다
├── 4xx → 클라이언트 문제
│ ├── 400 → 요청 형식/데이터 잘못
│ ├── 401 → 로그인 안 함 (토큰 없거나 만료)
│ ├── 403 → 로그인은 했지만 권한 없음
│ ├── 404 → 리소스가 존재하지 않음
│ └── 429 → 너무 많이 요청함 (Rate Limit)
└── 5xx → 서버 문제
├── 500 → 서버 내부 코드 에러
├── 502 → 뒷단 서버가 이상한 응답 (앱 크래시/재시작 중)
├── 503 → 서버 과부하 또는 점검
└── 504 → 뒷단 서버가 너무 오래 걸림 (슬로우 쿼리 의심)
  1. HTTP는 요청-응답 구조의 통신 규약이다
  2. 메서드(GET/POST/PUT/DELETE)로 의도를 표현하고, 상태 코드로 결과를 전달한다
  3. 상태 코드의 첫 자리 의미: 2xx 성공, 4xx 클라이언트 잘못, 5xx 서버 잘못
  4. 헤더로 메타데이터를 주고받고, HTTPS로 암호화하며, HTTP/2는 멀티플렉싱으로 성능을 높인다
  5. API 디버깅의 출발점은 항상 HTTP 요청/응답을 읽는 것이다