큰 그림: 요청 하나가 겪는 일
브라우저에 주소를 치고 화면이 뜨기까지. 이후 모든 챕터는 이 여정의 한 구간이에요.
요청은 결국 네 가지 질문을 통과해요
| 질문 | 담당 | 실패하면 보이는 것 |
|---|---|---|
| 어디로? | DNS | DNS_PROBE_FINISHED_NXDOMAIN, dig 결과 없음 |
| 닿나? | IP · TCP · 방화벽 | timeout, connection refused |
| 믿을 수 있나? | TLS · 인증서 | NET::ERR_CERT_*, 525/526 |
| 들어가도 되나? | WAF · 인증 · 인가 · CORS | 403, 401, 콘솔의 CORS 에러 |
도메인: 인터넷 주소의 "이름"
IP 주소는 외우기 어려우니 사람이 읽는 이름을 붙인 것. 그 이름을 누가, 어떻게 관리할까요?
도메인을 둘러싼 세 주체
| 주체 | 역할 | 이번 경우 |
|---|---|---|
| 레지스트리 (Registry) | TLD 전체를 관리하는 기관. 장부의 원본을 가짐 | .ua 레지스트리 |
| 레지스트라 (Registrar) | 레지스트리에 대신 등록해주는 판매 대리점 | nic.ua |
| 등록자 (Registrant) | 도메인 주인 | 나 |
nic.ua에서 한 일은 레지스트리 장부에 "이 도메인의 네임서버는 Cloudflare야"라고 적은 것뿐이에요. 도메인 소유권은 여전히 nic.ua를 통해 관리돼요.
서브도메인은 공짜
내가 산 건 ssobbs13.pp.ua 하나예요. 그 아래 api., www., admin.은 DNS 레코드 한 줄로 무한히, 무료로 만들 수 있어요. 서브도메인에는 "서버"가 필요 없어요. 그냥 이름표예요.
Public Suffix List (PSL)
pp.ua, co.kr, github.io, pages.dev처럼 "이 아래 이름은 각자 다른 주인"이라고 정한 공개 목록이에요. 브라우저는 이 목록으로 쿠키를 격리하고, Cloudflare는 ssobbs13.pp.ua를 독립 도메인으로 인정해요.
도메인의 일생: 만료되면 바로 사라질까?
아니에요. 만료일이 지나도 며칠~수십 일 동안 되찾을 기회가 있어요. 대신 뒤로 갈수록 비싸지고, 마지막엔 아무도 못 막아요.
WHOIS와 RDAP: 도메인 등기부 등본
whois ssobbs13.pp.ua # 레지스트라, 만료일, 네임서버, 상태 코드
curl -s https://rdap.org/domain/example.com | jq # WHOIS의 후속 표준 (JSON)
| 상태 코드 (EPP) | 뜻 |
|---|---|
clientTransferProhibited | 이전 잠금. 남이 몰래 다른 레지스트라로 옮기지 못하게. 켜두는 게 정상 |
clientHold / serverHold | DNS에서 빠짐. 만료, 미납, 신원 확인 미완료 때 걸려요. 사이트가 통째로 사라져요 |
redemptionPeriod | 만료 후 복구 기간. 비싼 수수료를 내야 되찾을 수 있어요 |
pendingDelete | 삭제 대기. 이 상태가 되면 누구도 못 되돌려요 |
admin.ssobbs13.pp.ua를 쓰고 싶다. 무엇을 해야 할까?DNS: 이름을 주소로 바꾸는 전화번호부
브라우저가 가장 먼저 하는 일은 "이 이름의 IP가 뭐지?"를 묻는 것.
두 종류의 DNS 서버
- 권한 있는 네임서버 (Authoritative): 정답 원본을 가진 서버. 지금은
cash / virginia.ns.cloudflare.com - 리졸버 (Recursive Resolver): 대신 물어보고 캐시하는 서버. 통신사 DNS,
8.8.8.8,1.1.1.1
TTL과 "전파"의 정체
TTL(Time To Live)은 리졸버가 답을 캐시하는 시간(초)이에요. "DNS 전파에 24시간 걸린다"는 말의 정체는 전 세계 리졸버의 캐시가 만료되기를 기다리는 시간이에요. 뭔가 퍼져나가는 게 아니에요.
권한 네임서버는 어떻게 답할까: zone 조회 시뮬레이터
아래는 ssobbs13.pp.ua zone의 예시 레코드예요. 이름과 타입을 넣으면 Cloudflare 네임서버가 어떻게 답하는지 보여줘요. 와일드카드, CNAME 따라가기, Cloudflare 프록시의 원본 숨김, NXDOMAIN과 NODATA의 차이를 직접 확인해보세요.
캐시는 한 군데가 아니에요
"레코드를 바꿨는데 나만 옛날 IP가 나와요"의 원인은 대부분 아래 캐시 중 하나예요. 위에서부터 차례로 확인해요.
| 순서 | 캐시 위치 | 비우는 법 |
|---|---|---|
| 1 | 브라우저 DNS 캐시 (Chrome은 약 1분) | chrome://net-internals/#dns → Clear host cache |
| 2 | OS 캐시 + /etc/hosts (hosts 파일이 DNS보다 우선) | macOS sudo dscacheutil -flushcache, Windows ipconfig /flushdns |
| 3 | 공유기 DNS 프록시 | 재부팅하거나 리졸버를 1.1.1.1로 직접 지정 |
| 4 | 리졸버 (통신사, 8.8.8.8, 1.1.1.1) | 못 비워요. TTL 만료를 기다리거나 dig @1.1.1.1로 다른 리졸버에 물어봐요. Cloudflare는 1.1.1.1 캐시 삭제 페이지를 제공해요 |
@(= ssobbs13.pp.ua 자체)는 원래 규칙상 CNAME을 못 가져요. Cloudflare는 CNAME Flattening으로 몰래 A 레코드처럼 바꿔줘서 가능하게 해줘요.api 레코드를 xxx.cfargotunnel.com으로 연결하려면 어떤 타입을 써야 할까?api CNAME 레코드를 dig +short api.ssobbs13.pp.ua로 조회하면?Zone: Cloudflare에서 도메인을 다루는 단위
"Zone을 추가한다" = "Cloudflare야, 이 도메인의 DNS를 네가 맡아줘"
Zone 안에 들어있는 것 (대시보드 왼쪽 메뉴)
| 메뉴 | 하는 일 |
|---|---|
| DNS | 레코드 관리 (A, CNAME…), Proxied / DNS only 전환 |
| SSL/TLS | 암호화 모드(Flexible/Full/Strict), 인증서 |
| Security | WAF, 봇 차단, Rate limit |
| Caching | 엣지 캐시 규칙 |
| Rules | 리다이렉트, 헤더 변경 |
| Workers Routes | 이 도메인 경로에 Worker 연결 |
네임서버를 옮길 때 가장 무서운 함정: DNSSEC
DNSSEC은 DNS 답에 전자서명을 붙여 위조를 막는 기술이에요. 상위 zone(레지스트리)에 DS 레코드("이 도메인은 이 키로 서명해요")가 등록돼 있으면, 검증하는 리졸버는 서명이 그 키와 맞는지 확인해요.
nic.ua 기본 설정에서 DNSSEC이 꺼져 있었다면 이번 이전에는 해당 없어요. 하지만 다른 도메인을 옮길 땐 레지스트라에서 DNSSEC(DS) 상태를 먼저 확인하세요. Active가 된 뒤 Cloudflare의 DNS → Settings에서 DNSSEC을 켜고, 알려주는 DS 값을 레지스트라에 등록하면 돼요.
ssobbs13.pp.ua가 안 보인다. 가장 유력한 이유는?IP, 포트, 방화벽: "어디로, 몇 번 문으로"
IP는 건물 주소, 포트는 호실 번호, 방화벽은 경비원.
IP 주소의 종류
localhost는 그 컨테이너 자신이에요. 호스트 서버가 아니에요. cloudflared 컨테이너가 localhost:8080으로 Spring에 닿으려면 같은 네트워크 네임스페이스를 공유하거나(network_mode), 서비스 이름(http://be:8080)을 써야 해요.CIDR: IP 범위를 한 줄로 쓰는 법
10.0.0.0/24에서 /24는 "앞의 24비트는 고정, 나머지 8비트는 자유"라는 뜻이에요. 방화벽 규칙, VCN 서브넷, Docker 네트워크가 전부 이 표기를 써요. 0.0.0.0/0은 "모든 IP", /32는 "딱 이 IP 하나"예요.
NAT: 사설 IP가 인터넷에 나가는 방법
집 공유기 안의 노트북(192.168.0.10)도, OCI VCN 안의 서버(10.0.0.5)도 사설 IP예요. 밖으로 나갈 때 공유기나 인터넷 게이트웨이가 출발 주소를 공인 IP로 바꿔서 내보내고, 돌아오는 응답을 기억해뒀다가 다시 안쪽으로 돌려줘요. 이게 NAT예요.
- 그래서 밖에서 먼저 사설 IP로 연결을 걸 수는 없어요. 받으려면 포트 포워딩이 필요해요.
- cloudflared가 NAT 뒤에서도 잘 되는 이유: 안에서 먼저 나가는 연결이라 NAT가 응답 경로를 기억해줘요.
- OCI 인스턴스의 "공인 IP"도 실제로는 VCN의 인터넷 게이트웨이가 1:1 NAT로 붙여준 거예요. 서버 안에서
ip addr를 치면 사설 IP만 보여요.
자주 쓰는 포트
| 포트 | 용도 |
|---|---|
| 22 | SSH (서버 접속) |
| 80 / 443 | HTTP / HTTPS |
| 5432 / 3306 | PostgreSQL / MySQL |
| 8080 | Spring Boot 기본값 |
인바운드 vs 아웃바운드
OCI의 방화벽은 두 겹
인터넷 → [OCI Security List / NSG] → [서버 OS: iptables/ufw] → [Docker] → 앱
클라우드 콘솔에서 설정 서버 안에서 설정
"포트 열었는데 안 돼요"의 90%는 OS 방화벽을 안 열어서예요. 터널을 쓰는 지금은 둘 다 닫아두는 게 정답. SSH(22)만 열어두세요.
HTTPS, TLS, 인증서: nginx SSL이 어려웠던 이유
HTTPS = HTTP + TLS. TLS는 암호화와 신원 확인을 해줘요.
- 인증서: 믿을 수 있는 기관(CA)이 "이 서버는 이 도메인 주인 맞음"이라고 서명한 파일
- Let's Encrypt: 무료 자동 CA. 90일마다 갱신 필요
인증서 체인: 브라우저는 누구를 믿나
브라우저와 OS에는 루트 CA 약 150개의 공개키가 미리 들어있어요(신뢰 저장소). 루트는 금고에 보관하고, 실제 발급은 중간 CA가 해요. 서버는 "내 인증서 + 중간 인증서"를 함께 보내고, 브라우저가 루트까지 서명을 거슬러 올라가며 확인해요.
이 인증서로 이 도메인을 쓸 수 있나?
인증서에는 쓸 수 있는 이름 목록(SAN, Subject Alternative Name)이 적혀 있어요. 와일드카드 *는 딱 한 단계만 대신해요. Cloudflare 무료 인증서(Universal SSL)는 ssobbs13.pp.ua와 *.ssobbs13.pp.ua만 담고 있어요.
nginx로 직접 하면 해야 하는 일
Cloudflare를 쓰면: TLS가 두 구간으로 나뉨
http://localhost:8080처럼 평문으로 적어도 안전해요. 평문 구간은 서버 내부뿐이에요. nginx도, certbot도, 인증서 파일도 필요 없어요.터널 없이 직접 오픈한다면: Origin CA 인증서
Full (strict)를 쓰려면 오리진에도 믿을 만한 인증서가 있어야 해요. Let's Encrypt 대신 Cloudflare가 무료로 주는 Origin CA 인증서(최대 15년)를 쓸 수 있어요. 단, 이 인증서는 Cloudflare 엣지만 믿어요. 브라우저가 오리진에 직접 접속하면(DNS only) 경고가 떠요.
HSTS: "이 사이트는 앞으로 무조건 HTTPS"
Strict-Transport-Security: max-age=31536000; includeSubDomains
이 헤더를 한 번 받은 브라우저는 max-age 동안 http://로 접속하려 해도 스스로 https://로 바꿔요. 처음 한 번의 평문 요청을 가로채는 공격(SSL stripping)을 막아요. Cloudflare의 SSL/TLS → Edge Certificates에서 켤 수 있어요.
includeSubDomains를 켜면 모든 서브도메인이 HTTPS를 강제당해요. HTTPS가 안 되는 서브도메인이 하나라도 있으면 그곳은 max-age 동안 접속 불가예요. max-age=300처럼 짧게 시작해서 늘리세요. preload는 브라우저에 하드코딩되는 거라 빼는 데 몇 달 걸려요.v1.api.ssobbs13.pp.ua를 만들었더니 인증서 오류가 난다. 이유는?*.api.ssobbs13.pp.ua를 따로 발급하거나, v1-api처럼 한 단계로 이름을 지어요.프록시, 리버스 프록시, CDN, 엣지
Cloudflare의 정체는 "전 세계에 깔린 거대한 리버스 프록시"예요.
| 용어 | 뜻 |
|---|---|
| 포워드 프록시 | 클라이언트 쪽 대리인 (회사 프록시, VPN) |
| 리버스 프록시 | 서버 쪽 대리인. 요청을 대신 받아 뒤로 넘김 (nginx, Caddy, Cloudflare) |
| CDN | 전 세계에 서버를 두고 가까운 곳에서 응답하는 네트워크 |
| 엣지 | CDN의 각 서버. 커넥터가 붙어있던 icn(인천)이 그 예 |
| 오리진 | 진짜 원본 서버. OCI의 Spring 앱 |
| Anycast | 여러 서버가 같은 IP를 쓰고, 가장 가까운 곳으로 자동 연결 |
Cloudflare 프록시(Proxied) vs DNS only
캐시
정적 파일(JS, CSS, 이미지)은 엣지 캐시로 빨라져요(Pages가 자동으로 해줘요). API 응답은 기본적으로 캐시 안 돼요. 캐시 규칙을 잘못 만들면 남의 일기가 보이는 사고가 날 수 있어요. Spring에서 Cache-Control 헤더로 제어하세요.
CF-Cache-Status | 뜻 |
|---|---|
| HIT | 엣지 캐시에서 바로 응답. 오리진은 몰라요 |
| MISS | 캐시에 없어서 오리진에 갔다 옴. 다음 요청부터 HIT |
| EXPIRED / REVALIDATED | 캐시가 만료돼서 오리진에 다시 받음 / 오리진이 304로 "그대로 써"라고 함 |
| BYPASS | 캐시 대상이지만 오리진 헤더(private, no-store, Set-Cookie) 때문에 건너뜀 |
| DYNAMIC | 애초에 캐시 대상이 아님 (HTML, API 기본값). 가장 흔한 값 |
브라우저 개발자 도구 → Network → 응답 헤더에서 바로 볼 수 있어요. curl -sI https://…/app.js | grep -i cf-cache로도 확인해요.
리버스 프록시가 덧붙이는 헤더
프록시를 거치면 오리진이 보는 "접속자"는 프록시 자신이에요. 그래서 프록시는 원래 정보를 헤더에 적어서 넘겨줘요.
| 헤더 | 담긴 정보 | Spring에서 |
|---|---|---|
X-Forwarded-For | 원래 클라이언트 IP (프록시를 지날 때마다 뒤에 추가) | request.remoteAddr (설정 후) |
X-Forwarded-Proto | 원래 스킴 (https) | request.isSecure, 리다이렉트 URL |
X-Forwarded-Host | 원래 Host | 절대 URL 생성 |
CF-Connecting-IP | Cloudflare가 확인한 사용자 IP 하나 | 직접 꺼내 쓰기 가장 쉬움 |
X-Forwarded-For: 1.2.3.4를 붙여 보낼 수 있어요. 믿어도 되는 건 내가 신뢰하는 프록시가 붙인 값뿐이에요. 터널 구조에선 오리진에 들어오는 모든 요청이 Cloudflare를 거치니 안전하지만, 포트를 직접 열었다면 Cloudflare IP 대역에서 온 요청만 믿어야 해요.api CNAME 레코드의 프록시 상태는?/diaries에 걸었다. 사용자 A의 일기 목록이 B에게 보이는 사고를 막으려면 Spring이 뭘 보내야 할까?no-cache, no-store를 붙여줘요.브라우저 보안 모델: CORS와 쿠키
FE와 API 도메인이 달라지는 순간 반드시 만나는 것.
Origin과 Site
- Origin = 스킴 + 호스트 + 포트. 하나라도 다르면 다른 origin
- Site = 등록 가능한 도메인(PSL 기준).
api.ssobbs13.pp.ua와ssobbs13.pp.ua는 같은 site
CORS
브라우저는 기본적으로 다른 origin의 응답을 JS가 못 읽게 막아요. CORS는 서버가 "이 origin은 읽어도 돼"라고 허락하는 헤더예요.
Access-Control-Allow-Origin: https://ssobbs13.pp.ua
Access-Control-Allow-Credentials: true
PUT/DELETE, JSON 본문, Authorization 헤더를 쓰면 브라우저가 먼저 OPTIONS 요청(preflight)을 보내요. Spring Security가 OPTIONS를 막으면 CORS 에러가 나요.
Spring Security에서 CORS 켜기 (Kotlin)
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val config = CorsConfiguration().apply {
allowedOrigins = listOf("https://ssobbs13.pp.ua", "http://localhost:5173")
allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE")
allowedHeaders = listOf("Authorization", "Content-Type")
allowCredentials = true // 쿠키를 쓴다면
maxAge = 3600 // preflight 결과를 1시간 캐시
}
return UrlBasedCorsConfigurationSource().apply { registerCorsConfiguration("/**", config) }
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain = http
.cors { } // 위 Bean을 사용. 인증 필터보다 먼저 OPTIONS를 처리해요
.build()
allowCredentials = true인데 allowedOrigins = listOf("*") → Spring이 시작할 때 막아요. 와일드카드가 필요하면 allowedOriginPatterns. ② origin 끝에 /를 붙임(https://ssobbs13.pp.ua/) → 불일치. ③ @CrossOrigin만 붙이고 Security에 .cors { }를 안 켬 → preflight가 401로 막혀요.쿠키 속성
| 속성 | 뜻 |
|---|---|
Domain=ssobbs13.pp.ua | 서브도메인끼리 공유 |
SameSite=Lax | 같은 site 요청 + 링크 이동 시 전송 (기본값) |
SameSite=None; Secure | 다른 site에도 전송 (서드파티) |
HttpOnly | JS에서 못 읽음 (XSS 방어) |
Secure | HTTPS에서만 전송 |
pages.dev가 아니라 ssobbs13.pp.ua 커스텀 도메인에 두면, API와 같은 site가 되어 쿠키 인증이 훨씬 쉬워져요.CSRF: 쿠키가 자동으로 따라가서 생기는 문제
쿠키는 요청을 누가 시켰든 브라우저가 알아서 붙여요. 악성 사이트가 <form action="https://api.ssobbs13.pp.ua/diaries/7" method="POST">를 몰래 제출하면, 로그인된 사용자의 쿠키가 함께 가요. 이게 CSRF예요. CORS는 응답을 읽는 것만 막고 요청 전송은 막지 않아서 CSRF를 못 막아요.
| 인증 방식 | CSRF 위험 | 대책 |
|---|---|---|
| 쿠키 세션 / 쿠키에 JWT | 있음 | SameSite=Lax(cross-site POST에 쿠키 안 보냄) + Spring Security CSRF 토큰 |
Authorization: Bearer 헤더 | 없음 | 브라우저가 자동으로 붙이지 않아서 안전. 대신 토큰 보관 위치가 XSS에 노출돼요 (암호학 챕터 참고) |
주의: api.ssobbs13.pp.ua와 evil.ssobbs13.pp.ua는 same-site예요. SameSite는 같은 site 안의 다른 서브도메인은 막지 못해요. 서브도메인을 남에게 내주지 마세요.
https://ssobbs13.pp.ua 페이지에서 https://api.ssobbs13.pp.ua를 fetch하면?fetch(url, {method:"POST", headers:{"Content-Type":"application/json"}, body}). 콘솔에 CORS 에러가 났는데 Network 탭엔 OPTIONS 401이 보인다. 원인은?.cors { }를 켜세요.부하 분산과 가용성
커넥터 2개가 이미 레플리카 구조예요.
- 헬스체크:
/actuator/health를 주기적으로 찔러 죽은 레플리카를 빼요 - SPOF: 그게 죽으면 전체가 죽는 지점. 커넥터 2개가 같은 서버라면 서버가 SPOF, DB가 1개면 DB가 SPOF
- 무상태(stateless): 레플리카가 여럿이면 세션을 메모리에 두면 안 돼요 → JWT 또는 Redis 세션
로드밸런싱 알고리즘
| 알고리즘 | 방식 | 잘 맞는 경우 |
|---|---|---|
| 라운드 로빈 | 차례대로 하나씩 | 요청 처리 시간이 비슷할 때 (기본값) |
| 가중 라운드 로빈 | 성능 좋은 서버에 더 많이 | 서버 사양이 다를 때 |
| 최소 연결 | 지금 연결이 가장 적은 서버로 | 요청마다 처리 시간이 크게 다를 때 (파일 업로드, 긴 쿼리) |
| IP 해시 / 스티키 세션 | 같은 사용자는 같은 서버로 | 세션을 메모리에 둔 레거시. 새로 만든다면 stateless로 가세요 |
헬스체크 두 종류와 우아한 종료
- Liveness (
/actuator/health/liveness): "프로세스가 살아있나?" 실패하면 재시작 - Readiness (
/actuator/health/readiness): "지금 요청 받을 준비됐나?" 실패하면 트래픽에서 잠시 뺌 (기동 중, DB 연결 끊김) - Graceful shutdown: 배포할 때 처리 중인 요청을 끝내고 종료. Spring Boot 3.4부터
server.shutdown=graceful이 기본값이에요. 레플리카를 하나씩 교체하면 무중단 배포가 돼요.
가용성은 곱셈이에요
요청 경로에 있는 부품은 전부 살아있어야 해요(직렬: 가용성을 곱함). 같은 역할을 하는 부품을 둘 두면 둘 다 죽어야 멈춰요(병렬: 1 − 둘 다 죽을 확률). 어디를 이중화해야 효과가 큰지 직접 켜보세요.
보안 계층: 누가 무엇을 막나
여러 겹의 방어. 한 겹이 뚫려도 다음 겹이 막아요.
인증 vs 인가
- 인증 (Authentication): 너 누구야? → 로그인, JWT
- 인가 (Authorization): 너 이거 해도 돼? → RBAC (
ROLE_USER,ROLE_ADMIN) - 객체 단위 권한: 이 일기가 네 거야? ← 가장 많이 놓치는 곳
GET /diaries/123에서 123만 바꾸면 남의 일기가 보이는 취약점. 역할(RBAC)만으로는 못 막아요. 매 요청마다 "이 리소스 주인 == 현재 사용자"를 확인하세요.// 나쁜 예: 인증만 확인
fun get(id: Long) = diaryRepository.findById(id).orElseThrow()
// 좋은 예: 조회 조건에 주인을 넣으면 남의 것은 "없음"(404)이 돼요
fun get(id: Long, @LoginUser user: User) =
diaryRepository.findByIdAndOwnerId(id, user.id) ?: throw NotFoundException()
Rate Limiting: 토큰 버킷
가장 흔한 요청 제한 방식이에요. 버킷에 토큰이 일정 속도로 차고, 요청 하나가 토큰 하나를 써요. 토큰이 없으면 429 Too Many Requests. 버킷 크기만큼은 한꺼번에 몰려도 허용해요(burst).
보안 헤더: 브라우저에게 시키는 방어
http://라서, 설정 없이는 request.isSecure == false예요. 그러면 Spring Security가 HSTS 헤더를 안 붙이고, 리다이렉트 URL이 http://로 만들어져요. server.forward-headers-strategy=native를 켜면 X-Forwarded-Proto: https를 믿고 제대로 동작해요.Cloudflare Access는?
엣지에서 동작하는 문지기예요(터널과 별개 제품). BE에 Spring Security로 인증과 권한이 제대로 있으면 필수가 아니라 추가 방어층이에요. 이번 프로젝트는 제거 예정이에요.
/diaries/999(B의 일기)를 조회할 수 있었다. 무엇이 빠졌나?Cloudflare 제품 지도 (풀스택 관점)
제품을 눌러 상세를 보세요.
터널 vs ngrok vs 직접 오픈
| Cloudflare Tunnel | ngrok | 직접 오픈 (Caddy) | |
|---|---|---|---|
| 서버 인바운드 | 안 엶 | 안 엶 | 80/443 엶 |
| DNS | Cloudflare 필수 | 아무 데나 (CNAME) | 아무 데나 (A) |
| 커스텀 도메인 | 무료 | 유료 | 무료 |
| 서버 IP | 숨김 | 숨김 | 노출 |
| DDoS 방어 | 강함 | 기본 | 없음 |
| BE 코드 변경 | 셋 다 없음. BE는 localhost:8080만 알면 됨 | ||
터널 ingress 규칙: 위에서부터 첫 번째로 맞는 것
cloudflared는 들어온 요청의 호스트명과 경로를 규칙 목록과 위에서부터 비교해서, 처음 맞는 규칙의 서비스로 보내요. 마지막엔 반드시 아무것에나 맞는 catch-all 규칙이 있어야 해요. 대시보드의 "Published application routes"도 같은 규칙이에요.
/actuator/env, /actuator/heapdump는 설정값과 메모리 속 비밀을 그대로 보여줄 수 있어요. 위 예시처럼 ingress에서 http_status:404로 막거나, management.server.port를 따로 두고 터널에 연결하지 마세요.내 프로젝트: 전환 전과 후
diary 프로젝트의 실제 구성 (대시보드와 저장소에서 확인한 내용).
전환 작업 체크리스트
최종 구조의 서버 구성 (예시)
# docker-compose.yml (OCI 서버)
services:
be:
image: diary-be:latest
environment:
SERVER_FORWARD_HEADERS_STRATEGY: native # X-Forwarded-* 신뢰
# ports 없음! 밖으로 포트를 공개하지 않아요
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${TUNNEL_TOKEN} # .env 파일, 깃에 올리지 않기
network_mode: "service:be" # be와 네트워크 공유 → localhost:8080으로 닿음
depends_on:
be:
condition: service_healthy
restart: unless-stopped
왜 network_mode를 쓰는지, ports:를 왜 빼는지는 "Docker 네트워크" 챕터에서 자세히 다뤄요.
문제 생겼을 때: 도구와 에러 코드
명령어 (터미널에서 실행)
dig +short api.ssobbs13.pp.ua
DNS가 어떤 IP를 주는지 봐요. Cloudflare IP(104.x, 172.67.x)면 정상이에요.dig +short NS ssobbs13.pp.ua
네임서버가 누구인지 봐요. cash / virginia.ns.cloudflare.com이 나와야 해요.curl -v https://api.ssobbs13.pp.ua/actuator/health
연결, TLS, 헤더, 상태 코드를 한 번에 볼 수 있어요. 가장 많이 쓰는 도구예요.curl -I -X OPTIONS https://api.ssobbs13.pp.ua/diaries -H "Origin: https://ssobbs13.pp.ua" -H "Access-Control-Request-Method: POST"
CORS preflight 응답에 Access-Control-Allow-Origin이 있는지 확인해요.dig @1.1.1.1 api.ssobbs13.pp.ua vs dig @cash.ns.cloudflare.com api.ssobbs13.pp.ua
리졸버 답(캐시일 수 있음)과 권한 네임서버의 원본 답을 비교해요. 다르면 아직 캐시가 안 풀린 거예요. +trace를 붙이면 루트부터 따라가는 과정이 다 보여요.openssl s_client -connect api.ssobbs13.pp.ua:443 -servername api.ssobbs13.pp.ua </dev/null | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
실제로 받은 인증서의 이름(SAN), 발급자, 만료일을 봐요. -servername(SNI)을 빼면 다른 인증서가 올 수 있어요.curl -w "dns:%{time_namelookup} tcp:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n" -o /dev/null -s https://api.ssobbs13.pp.ua/actuator/health
느린 게 DNS인지, 연결인지, TLS인지, 서버 처리(TTFB)인지 구간별로 쪼개서 봐요. 각 값은 시작부터 누적된 초예요.ss -ltnp (서버에서)
어떤 프로세스가 어떤 포트를 듣고 있는지. 127.0.0.1:8080이면 서버 안에서만, 0.0.0.0:8080이면 모든 인터페이스에서 받아요.docker compose logs -f cloudflared / docker compose ps
터널이 엣지에 붙었는지(Registered tunnel connection), 컨테이너가 healthy인지 확인해요.브라우저 개발자 도구 → Network
실패한 요청을 누르면 상태 코드, 응답 헤더(cf-ray, cf-cache-status), preflight 여부가 보여요. CORS 에러는 콘솔에만 이유가 나와요. 요청을 우클릭하면 "Copy as cURL"로 터미널에서 그대로 재현할 수 있어요.진단 흐름도: 질문에 답하면서 원인 좁히기
curl로 API를 부르면 200이 잘 오는데, 브라우저 화면에서만 실패한다. 가장 먼저 볼 곳은?OSI 계층 모델: 네트워크의 지도
지금까지 배운 것 대부분이 L7(HTTP)이었어요. 이 챕터는 그 전체를 한 장의 지도로 정리해요.
실무에서는 L5/L6를 따로 나누지 않고 TCP/IP 4계층(응용 · 전송 · 인터넷 · 네트워크 접근)으로 보는 경우가 많아요. 대화에서 가장 자주 나오는 건 L3(IP) · L4(포트/TCP) · L7(HTTP) 세 개예요.
캡슐화: 요청이 계층을 내려갔다 올라오는 과정
L4와 L7: 무엇을 볼 수 있나
L4 장비가 보는 것: 203.0.113.7:51234 → 104.21.5.9:443 (내용은 암호화된 바이트 덩어리) L7 장비가 보는 것: GET /diaries/123 Host: api.ssobbs13.pp.ua Authorization: Bearer …
| 구성 요소 | 계층 | 이유 |
|---|---|---|
| OCI Security List / iptables | L3/L4 | IP와 포트로만 허용/차단 |
| Cloudflare DDoS 방어 | L3/L4 L7 | 패킷 폭탄(L3/4)과 HTTP 요청 폭탄(L7) 둘 다 |
| Cloudflare 엣지 (Proxied 레코드) | L7 | TLS를 풀고 HTTP를 읽어서 캐시·규칙 적용 |
| WAF · Rate Limiting · Access | L7 | URL, 헤더, 본문을 봐야 판단 가능 |
| Tunnel 호스트명 라우팅 | L7 | Host 헤더로 ingress 규칙 매칭 |
| Tunnel 연결 자체 | L4 | cloudflared ↔ 엣지 QUIC(UDP 7844) 연결 |
| Spring Security · CORS | L7 | 전부 HTTP 헤더 기반 |
127.0.0.1이에요. 진짜 사용자 IP는 CF-Connecting-IP 또는 X-Forwarded-For 헤더에서 꺼내야 해요. IP 기준 Rate Limit이나 로그에 꼭 필요해요. Spring Boot에서는 server.forward-headers-strategy=native 설정을 알아두세요.TCP, UDP, HTTP 버전, 그리고 지연시간
"왜 엣지가 가까우면 빠를까?"를 숫자로 이해하는 챕터.
TCP vs UDP
| TCP | UDP | |
|---|---|---|
| 연결 | 3-way handshake로 연결 후 전송 | 연결 없이 바로 전송 |
| 보장 | 순서 보장, 유실 시 재전송 | 보장 없음 (앱이 알아서) |
| 속도 | 느림 (확인 절차) | 빠름 |
| 용도 | HTTP/1.1, HTTP/2, DB, SSH | DNS, 영상통화, 게임, QUIC(HTTP/3) |
RTT: 지연시간의 기본 단위
RTT(Round Trip Time)는 패킷이 갔다 오는 시간이에요. 서울↔서울 엣지는 수 ms, 서울↔미국은 150ms 이상이에요. 빛의 속도는 못 이기니까, 왕복 횟수를 줄이거나 거리를 줄이는 것이 성능 최적화의 핵심이에요.
HTTP 버전
| 버전 | 전송 | 특징 |
|---|---|---|
| HTTP/1.1 | TCP | 연결당 요청 하나씩 (keep-alive로 재사용). 앞 요청이 막히면 뒤도 대기 (HOL blocking) |
| HTTP/2 | TCP | 한 연결에 여러 요청 동시 전송(멀티플렉싱), 헤더 압축. 하지만 TCP 패킷 하나가 유실되면 전부 대기 |
| HTTP/3 | QUIC (UDP) | TCP의 HOL 문제 해결, 핸드셰이크와 TLS를 합쳐 더 빠름. 모바일 네트워크 전환에도 강함 |
연결 재사용과 타임아웃 (백엔드 입장)
핸드셰이크가 비싸니까 연결은 재사용하는 게 기본이에요. Spring이 다른 API나 DB를 부를 때도 같아요. DB는 HikariCP가 연결을 풀(pool)로 들고 있고, HTTP 클라이언트도 커넥션 풀을 써요.
| 타임아웃 | 무엇을 기다리나 | 안 걸면 생기는 일 |
|---|---|---|
| connect timeout | TCP(+TLS) 연결이 맺어지기까지 | 상대 서버가 방화벽에 막혀 조용하면 OS 기본값(수십 초~2분)만큼 멈춤 |
| read (socket) timeout | 연결 후 응답 바이트가 오기까지 | 상대가 느리면 스레드가 무한정 묶임 → 스레드 풀 고갈 → 내 서버도 죽음 |
| pool acquire timeout | 풀에서 빈 연결을 받기까지 (Hikari connectionTimeout, 기본 30초) | 느린 쿼리가 연결을 다 잡고 있으면 모든 요청이 줄 서서 대기 |
HTTP 의미론: 메서드, 상태 코드, 헤더
L7의 언어. API 설계와 캐시, 재시도, 에러 처리가 전부 여기서 나와요.
- 안전(Safe): 서버 상태를 안 바꿈 → 크롤러, 프리페치가 마음대로 호출해도 됨
- 멱등(Idempotent): 여러 번 해도 결과가 같음 → 네트워크 오류 시 자동 재시도해도 안전
- 캐시 가능: 엣지나 브라우저가 응답을 저장해도 됨
Idempotency-Key 헤더로 중복을 막아요.꼭 알아야 할 헤더
| 헤더 | 방향 | 역할 |
|---|---|---|
Host | 요청 | 어느 도메인에 대한 요청인지. 터널 ingress 매칭의 기준 |
Authorization | 요청 | Bearer <JWT> 형태의 인증 정보 |
Cookie / Set-Cookie | 요청 / 응답 | 세션 쿠키 주고받기 |
Content-Type | 양쪽 | 본문 형식 (application/json) |
Cache-Control | 응답 | 캐시 가능 여부와 기간. API는 no-store나 private |
Origin | 요청 | CORS 판단 기준. 브라우저가 자동으로 붙임 |
CF-Connecting-IP / X-Forwarded-For | 요청 | 프록시 뒤에서 진짜 사용자 IP |
CF-Ray | 응답 | Cloudflare 요청 추적 ID. 문제 신고와 로그 검색에 사용 |
HTTP 요청은 그냥 텍스트예요
HTTP/1.1 요청은 사람이 읽을 수 있는 텍스트예요(HTTP/2, 3는 같은 내용을 바이너리로 압축할 뿐 의미는 같아요). 옵션을 바꾸면서 실제로 어떤 텍스트가 전송되는지 보세요.
조건부 요청: ETag와 304
| Cache-Control 값 | 뜻 | 어디에 쓰나 |
|---|---|---|
no-store | 어디에도 저장하지 마 | 개인정보, 일기 본문 |
no-cache | 저장은 해도 되지만 쓸 때마다 서버에 확인(ETag) | 자주 바뀌는 공개 데이터, index.html |
private | 브라우저만 저장, 엣지·프록시는 금지 | 로그인 사용자별 응답 |
public, max-age=31536000, immutable | 1년간 확인 없이 사용 | 파일명에 해시가 붙은 JS/CSS (app.3f9a.js) |
s-maxage=60 | 공유 캐시(엣지)용 max-age | 엣지에서만 잠깐 캐시하고 싶을 때 |
no-store예요.HTTP는 무상태(stateless)
HTTP 요청은 서로를 기억하지 못해요. 로그인 상태를 유지하려면 매 요청마다 쿠키(세션 ID)나 토큰(JWT)을 들고 와야 해요. 이게 8장의 레플리카와 세션 문제, 7장의 쿠키 이야기로 이어져요.
암호학 기초: TLS와 JWT를 이해하기 위한 최소한
수학은 몰라도 돼요. 도구 네 가지가 각각 무엇을 보장하는지만 알면 돼요.
| 도구 | 보장하는 것 | 비유 | 우리 프로젝트에서 |
|---|---|---|---|
| 해시 | 무결성 (바뀌었는지 확인), 되돌릴 수 없음 | 지문 | 비밀번호 저장 (BCrypt) |
| 대칭키 암호 | 기밀성. 같은 키로 잠그고 품 | 집 열쇠 | TLS의 실제 데이터 암호화 (AES) |
| 비대칭키 암호 | 공개키로 잠그면 개인키로만 풀림 | 우체통 (누구나 넣고, 주인만 꺼냄) | TLS 키 교환 |
| 전자서명 | 진위 (누가 썼는지), 위변조 방지 | 도장 | 인증서, JWT 서명 |
BCryptPasswordEncoder처럼 일부러 느리고 솔트(salt)를 섞는 전용 해시를 쓰세요. 같은 비밀번호라도 사용자마다 다른 결과가 나와요.TLS 핸드셰이크 (단순화)
비대칭키는 느려서 키 교환에만 쓰고, 실제 데이터는 빠른 대칭키로 암호화해요. 인증서의 서명이 "이 공개키는 진짜 api.ssobbs13.pp.ua의 것"임을 보증해요.
JWT: 서명된 출입증
JWT는 헤더.페이로드.서명 세 부분을 점으로 이은 문자열이에요. 앞의 두 부분은 암호화가 아니라 Base64 인코딩이라 누구나 읽을 수 있어요. 서명은 "서버가 발급했고 내용이 안 바뀌었다"를 보장할 뿐이에요.
exp(만료)를 짧게. ④ 실제 JWT를 온라인 디코더 사이트에 붙여넣지 마세요.토큰은 어디에 보관할까
Access Token + Refresh Token
JWT는 서버가 기억하지 않아서 중간에 취소하기 어려워요. 그래서 수명을 짧게(5~15분) 하고, 새 토큰을 받는 데 쓰는 긴 수명의 Refresh Token을 따로 둬요. Refresh Token은 서버 DB에 저장해서 취소할 수 있게 해요.
"role":"USER"가 있다. 사용자가 이걸 "ADMIN"으로 바꿔서 보내면?Docker 네트워크: 컨테이너 안의 localhost
"로컬에선 되는데 컨테이너에 넣으니 안 돼요"의 대부분이 이 챕터 내용이에요.
localhost)를 가져요. 그래서 컨테이너 A의 localhost:8080은 컨테이너 B의 8080과 전혀 상관없어요. 호스트 서버의 8080과도 상관없어요.네트워크 모드
| 모드 | 동작 | 다른 컨테이너 부르기 |
|---|---|---|
기본 bridge (docker run) | 컨테이너마다 사설 IP(172.17.x.x) | IP로만 가능. 이름으로는 안 됨 |
| 사용자 정의 bridge (compose 기본) | compose 프로젝트마다 네트워크 하나를 자동 생성 | 서비스 이름이 DNS 이름: http://be:8080 |
| host | 호스트의 네트워크를 그대로 사용 (격리 없음) | localhost:8080이 호스트의 8080 |
service:be | 다른 컨테이너의 네트워크를 공유 (같은 localhost) | localhost:8080이 be의 8080 |
| none | 네트워크 없음 | 불가 |
포트 공개(ports:)는 누구에게 열리나
ports: "8080:8080"은 "호스트의 8080으로 들어온 걸 컨테이너 8080으로 넘겨"라는 뜻이에요. 앞의 호스트 쪽에 IP를 안 쓰면 0.0.0.0, 즉 모든 네트워크 인터페이스에 열려요.
ufw deny 8080을 해도 ports: "8080:8080"이면 밖에서 접속돼요. 마지막 방어선은 OCI Security List뿐이에요. 터널 구조에서는 ports를 아예 쓰지 않는 게 정답이에요.자주 겪는 증상
| 증상 | 원인 | 해결 |
|---|---|---|
Spring이 localhost:5432로 DB에 연결 실패 | DB가 다른 컨테이너 | jdbc:postgresql://db:5432/diary (서비스 이름) |
| 컨테이너에서 호스트의 서비스에 닿아야 함 | 컨테이너 localhost ≠ 호스트 | host.docker.internal (Linux는 extra_hosts: ["host.docker.internal:host-gateway"]) |
| 앱이 떴는데 포트 매핑으로도 접속 안 됨 | 앱이 컨테이너 안에서 127.0.0.1에만 바인딩 | 앱을 0.0.0.0에 바인딩 (Spring은 기본이 0.0.0.0) |
cloudflared 로그에 connection refused | Spring이 아직 기동 중이거나 주소가 틀림 | depends_on: condition: service_healthy, 주소 재확인 |
be와 db 서비스가 있다. be의 DB URL로 알맞은 것은?ports: 설정은?"127.0.0.1:8080:8080"까지만 허용하세요.