CS + 인프라

[인프라] 미니PC 서버 구축 2 - 접속부터 응답까지 : Cloudflare 에서 도메인 구매와 내 미니 PC 연결하기 (feat. HTTPS통신과 HAProxy설치)

공대키메라 2026. 9. 18. 17:04

한창 미니 PC를 연결하기 위해 필자는 쌩 난리(?)를 치고 있다.

 

확실히 머릿속으로 개념이 그려지지 않고 또한 무료 도메인 사이트를 사용하려다가

 

여러가지를 고려한 결과 Cloudfare를 쓰는게 낫겠다는 판단이 들었다.

 

 

해당 글은 이 과정을 이해하기 위해 정리한 내용이다.

 

결론적으로 정리하면 다음과 같다.

 

(1) 무료 도메인으로 막힘 → (2) Cloudflare로 전환 →

(3) 두 구간 HTTPS 완성 → (4) 우회 접속 차단 →

(5) 테스트 서버로 경로 확인 → (6) API를 내부 전용으로 정리

 

이 과정이 생각보다 손도 많이 가고 흐름이 머릿속에 그려지지를 않아서 이를 정리하는데 정말 애먹었다.

 


목표

1. Cloudfare가 무엇인지, 왜 필요한지 이해한다.

2. 사용자 요청이 내 미니 PC에 들어와서 응답까지의 과정을 이해한다.

3. 실제 내 PC까지 연결 작업을 세팅한다.


 

0. 들어가기 전에

0.1) 포워드 프록시 vs 리버스 프록시

프록시는 우선 '대리자' 라는 뜻이다.

 

무엇을 대리해주는가? 코드에 이를 적용하면 '프록시 패턴'이라고 하고

 

서버에 적용하면 '프록시 서버'라고 한다. 즉, 어떤것을 기점으로 대리자의 역할을 하는지에 따라 용도가 좀 나뉠 뿐이다.

 

이를 간단하게 그려보면 다음과 같다.

 

둘 다 프록시인데 인터넷의 앞에 있냐, 뒤에 있냐에 따라 말 그대로 foward proxy, reverse proxy이다.

 

직접 목적지에 연결해주지 않고 '중개인 혹은 대리자'를 통해 특정한 목적을 달성할 수 있다.

 

구분 위치 숨기는 대상 주체 주요기능 및 효과 대표적인 예
포워드 프록시
(Forward Proxy)
클라이언트 ↔ 인터넷 클라이언트
(Client IP)
클라이언트 • 아웃바운드 트래픽 제어: 사내망 등에서 특정 사이트 접근 차단

• 익명성 보장: 외부 서버에 클라이언트의 실제 IP 노출 방지

• 대역폭 절약: 자주 찾는 리소스 캐싱을 통한 네트워크 성능 향상
사내망 통제 서버, VPN, AWS NAT Gateway
리버스 프록시
(Reverse Proxy)
인터넷 ↔ 내부 서버 오리진 서버
(Server IP)
서버 제공자 • 인바운드 로드 밸런싱: 대규모 트래픽을 여러 서버로 분산 (Scale-out 지원)

• 보안 강화: 비즈니스 로직을 수행하는 내부망 구조 및 IP 은닉

• 서버 부하 감소: SSL 오프로딩(TLS 연산 대행) 및 정적 리소스 캐싱
Cloudflare, Nginx, AWS ALB, CloudFront

 

현재 필자가 cloudfare를 사용하려고 하는데, 

 

내 서버를 숨기는 역할이니 cloudfare는 reverse proxy 이다.

 

어떻게 숨기냐고? 내 서버 IP를 직접 노출하지 않으니까!

 

0.2) SSL 오프로딩 (TLS 연산 대행)

위의 표에서 리버스 프록시의 주요기능 및 효과를 잘 보면 서버부하 감소를 장점으로 들 수 있다.

 

그런데 필자는 이게 어떻게 해주는지 잘 몰라서 찾아보니 이미 내가 이를 정리했다고 한다. (정말?)

 

해당 내용은 다음 글에 있다.([Security 기초] HTTPS와 SSL/TLS이해와 실습 (feat. 암호화 기초))

 

SSL/TLS 발급 및 HTTPS 통신 프로세스

https://tech-monster.tistory.com/324

 

상세한 내용이 궁금하면 이를 열어서 보길 추천한다.

더보기

[1단계] 사전 준비: 인증서 발급받기

사용자가 접속하기 훨씬 전에, 서비스를 준비하면서 한 번만 하는 과정입니다. (인증서 만료 시 갱신)

1. 서버 준비

서비스를 운영할 서버를 마련합니다.

 

2. 서버의 장기 키 쌍 생성

서버가 공개키와 개인키 한 쌍을 만듭니다.

  • 공개키: 누구에게 보여줘도 되는 키. 인증서에 담겨 전 세계에 공개됩니다.
  • 개인키: 서버만 가지고 있어야 하는 키. 절대 밖으로 나가지 않습니다. 이 키가 유출되면 누구든 이 서버인 척할 수 있습니다.

비유: 공개키는 내 도장의 인영(찍힌 모양), 개인키는 도장 그 자체입니다. 인영은 누구나 봐도 되지만, 도장은 나만 가지고 있어야 합니다.

 

3. CSR(인증서 발급 요청서) 작성 및 제출

서버가 공개키 + 도메인 정보(예: kimera.com) 등을 묶어 CSR을 만들고 CA에 제출합니다.

"이 공개키의 주인은 kimera.com입니다. 확인해 주세요"라는 신청서입니다.

 

CSR에는 개인키가 들어가지 않습니다.

대신 서버가 자기 개인키로 CSR에 서명해서, "이 공개키의 짝인 개인키를 내가 실제로 갖고 있다"는 것을 함께 증명합니다.

 

4. CA의 심사: 도메인 주인이 맞는가?

CA는 신청자가 정말 그 도메인을 통제하는지 확인합니다. 대표적인 방법은 다음과 같습니다.

  • DNS 확인: "이 도메인의 DNS에 우리가 준 값을 TXT 레코드로 등록해 보세요"
  • HTTP 확인: "이 도메인의 특정 URL에 우리가 준 파일을 올려 보세요"

도메인을 실제로 관리하는 사람만 이 요구를 수행할 수 있으므로, 통과하면 주인으로 인정됩니다.

 

5. SSL 인증서 발급

심사를 통과하면 CA가 인증서를 만들어 줍니다.

인증서 안에는 다음 내용이 들어 있습니다.

  • 서버의 공개키
  • 도메인 이름
  • 유효기간
  • 발급자(어느 CA가 발급했는지)
  • 위 내용 전체에 대한 CA의 전자서명

인증서 = "이 공개키는 kimera.com의 것이 맞다"라는 내용이 적힌 문서 + CA의 도장

 

참고로 실제로는 Root CA가 직접 도장을 찍지 않고, Root CA가 인정한 중간 CA가 찍는 경우가 대부분입니다.

그래서 서버는 자기 인증서와 함께 중간 CA의 인증서도 같이 가지고 있다가 브라우저에게 보냅니다.

이 묶음을 인증서 체인이라고 합니다.


[2단계] 접속 시작과 인증서 확인

여기서부터는 사용자가 접속할 때마다 매번 일어나는 과정입니다.

 

6. 사용자의 접속 시도

사용자가 브라우저 주소창에 https://kimera.com을 입력합니다. (그 전에 DNS 조회로 kimera.com의 IP 주소를 알아내고, TCP 연결을 맺습니다.)

 

7. 브라우저의 첫 인사 (ClientHello)

브라우저가 서버에게 "HTTPS로 대화하자"며 첫 메시지를 보냅니다. 안에는 다음이 들어 있습니다.

  • 난수 A: 이번 접속만을 위한 무작위 값
  • 지원하는 암호 방식 목록: "나는 이런 암호 알고리즘을 쓸 줄 알아"
  • 접속하려는 도메인 이름(SNI): 한 서버가 여러 사이트를 운영할 수 있어서 알려줍니다
  • 브라우저의 키 공유값: 이번 접속용 일회용 키 쌍을 새로 만들고, 그중 공개 부분을 보냅니다

키 공유값이 뭔지 물감으로 이해하기

  1. 브라우저와 서버는 모두가 아는 공통 색(기준 색) 을 정해 둡니다.
  2. 브라우저는 비밀 색 a를 몰래 고르고, 기준 색 + a를 섞어서 보냅니다. ← 이게 브라우저의 키 공유값
  3. 서버도 비밀 색 b를 몰래 고르고, 기준 색 + b를 섞어서 보냅니다. ← 서버의 키 공유값 (8번)
  4. 받은 쪽은 거기에 자기 비밀 색을 한 번 더 섞습니다.
    • 브라우저: (기준 + b) + a
    • 서버: (기준 + a) + b
  5. 결과적으로 양쪽 모두 기준 + a + b라는 같은 색에 도달합니다.

중간에서 엿본 해커는 기준 + a와 기준 + b만 봤습니다. 섞인 물감에서 원래 색을 분리해낼 수 없기 때문에 기준 + a + b를 만들 수 없습니다.

실제로는 물감 대신 타원곡선 수학을 쓰며, 이 방식을 ECDHE라고 부릅니다. 이렇게 만든 기준 + a + b를 공유 비밀이라고 하고, 이것이 세션 키의 핵심 재료가 됩니다.

핵심: 난수 A, B는 평문으로 오가기 때문에 해커도 볼 수 있습니다. 난수만으로 세션 키를 만들면 해커도 똑같이 만들 수 있습니다. 해커가 절대 알 수 없는 재료, 즉 공유 비밀이 반드시 필요합니다.

 

8. 서버의 응답 (ServerHello + 인증서)

서버가 답장을 보냅니다.

  • 난수 B
  • 선택한 암호 방식: 브라우저가 보낸 목록 중 하나를 고릅니다
  • 서버의 키 공유값: 서버도 일회용 키 쌍을 새로 만들어 공개 부분을 보냅니다
  • 인증서 체인: 5번에서 받은 인증서 + 중간 CA 인증서

TLS 1.3의 특징: 서로 키 공유값을 교환한 직후부터 양쪽 모두 공유 비밀을 계산할 수 있습니다. 그래서 인증서부터는 이미 임시 키로 암호화된 상태로 전송됩니다. 누가 어느 사이트에 접속하는지 인증서 내용으로 엿보기 어려워집니다.

 

9. 1차 검증: 이 인증서는 진짜인가?

브라우저가 받은 인증서를 꼼꼼히 검사합니다.

  1. 도메인 확인: 인증서에 적힌 도메인이 내가 접속하려는 kimera.com과 같은가?
  2. 유효기간 확인: 만료되지 않았는가?
  3. 서명 확인 (체인 따라 올라가기):
    • 서버 인증서의 서명을 중간 CA의 공개키로 검증
    • 중간 CA 인증서의 서명을 Root CA의 공개키로 검증
    • 그 Root CA가 브라우저에 미리 설치된 Root 인증서 목록에 있는지 확인
  4. 폐기 여부 확인: 유출 등의 이유로 취소된 인증서가 아닌가?

모두 통과하면 브라우저는 확신합니다.

"이 인증서는 믿을 수 있는 기관이 발급한 진짜이고, 여기 적힌 공개키는 kimera.com의 것이 맞다."

 

그런데 아직 끝이 아닙니다. 인증서는 누구에게나 공개되는 파일이라서, 해커도 kimera.com의 인증서를 그대로 복사해서 내밀 수 있습니다. 9번은 "인증서가 진짜"라는 것만 증명할 뿐, "지금 이 인증서를 내민 상대가 진짜 주인" 이라는 것은 증명하지 못합니다. 그래서 다음 단계가 필요합니다.

 

[3단계] 2차 검증: 인증서의 진짜 주인인가?

10. 서버의 신원 증명 서명 (CertificateVerify)

서버는 2번에서 숨겨둔 장기 개인키를 꺼내서, 지금까지 주고받은 대화 전체(난수 A와 B, 양쪽 키 공유값, 인증서 등)의 요약값에 서명해서 보냅니다.

비유: 신분증(인증서)을 보여준 사람에게 "그럼 여기 오늘 날짜와 우리 대화 내용이 적힌 종이에 도장 찍어 보세요"라고 요구하는 것과 같습니다. 신분증은 복사할 수 있어도, 도장(개인키)은 진짜 주인만 가지고 있습니다.

 

서명 대상에 대화 전체가 들어가는 이유는 두 가지입니다.

  • 재사용 방지: 매 접속마다 난수가 다르므로, 과거의 서명을 훔쳐서 다시 쓸 수 없습니다.
  • 바꿔치기 방지: 해커가 중간에서 서버의 키 공유값을 자기 것으로 바꿨다면, 브라우저가 가진 대화 기록과 서버가 서명한 대화 기록이 달라져서 검증에 실패합니다.

 

11. 브라우저의 2차 검증

브라우저는 인증서 안에 들어 있던 서버 공개키로 10번 서명을 검증합니다. 브라우저도 같은 대화 기록을 갖고 있으니, 직접 요약값을 만들어 비교할 수 있습니다.

서명이 맞으면 브라우저는 두 가지를 확신합니다.

"상대는 인증서만 복사한 해커가 아니라, 개인키를 실제로 가진 진짜 주인이다." "우리가 주고받은 키 공유값은 중간에 바꿔치기되지 않았다."

 

 

[4단계] 세션 키 완성과 HTTPS 통신

12. 세션 키 조립과 완료 신호 (Finished)

세션 키 만들기

브라우저와 서버는 네트워크로 키를 주고받지 않고, 각자 자기 컴퓨터 안에서 계산합니다.

공유 비밀 (7~8번에서 만든 기준 + a + b) + 대화 기록 → 키 유도 함수(HKDF) → 세션 키

 

같은 재료를 같은 공식에 넣었으니 양쪽에 똑같은 세션 키가 나옵니다.

세션 키는 네트워크에 한 번도 실린 적이 없으므로 해커는 알 수 없습니다.

(실제로는 "브라우저→서버"용과 "서버→브라우저"용으로 방향별 키가 따로 만들어집니다.)

 

완료 신호 주고받기 (Finished)

양쪽은 "대화 기록 전체를 내 키로 계산한 확인값" 을 서로 보냅니다.

 

  • 서버의 확인값과 브라우저가 계산한 값이 일치하면 → 양쪽이 같은 키를 가졌고, 대화가 처음부터 끝까지 조작되지 않았다는 뜻입니다.

 

서버와 브라우저의 Finished가 모두 확인되는 순간, TLS 핸드셰이크가 완료됩니다.

 

13. 안전한 HTTPS 통신 시작

이제부터 주고받는 모든 데이터(HTML, 로그인 비밀번호, 쿠키, API 응답 등)는 세션 키로 암호화됩니다. (AES-GCM 같은 빠른 대칭키 알고리즘 사용)

 

  • 무거운 비대칭키 연산은 핸드셰이크에서 역할을 마쳤습니다.
  • 가벼운 대칭키로 빠르게 통신합니다.
  • 암호화와 함께 변조 검사도 같이 이뤄져서, 중간에서 데이터를 건드리면 받는 쪽이 바로 알아챕니다.

 

이렇게 공공 인터넷 위에 제3자가 내용을 볼 수도, 바꿀 수도 없는 논리적 암호화 터널이 만들어진 상태가 바로 HTTPS입니다.

 

접속이 끝나면?

세션 키와 일회용 키 쌍은 버려집니다.

 

그래서 나중에 서버의 장기 개인키가 유출되더라도, 과거에 녹화해 둔 대화는 풀 수 없습니다.

 

장기 개인키는 "서명"에만 쓰였을 뿐, 세션 키 재료를 만드는 데는 쓰이지 않았기 때문입니다. 이 성질을 전방 보안(Forward Secrecy) 이라고 합니다.

 

요악하면 다음과 같다.

 

발급 (한 번만)

  1. 서버 준비: 서비스할 서버를 만든다
  2. 키 쌍 생성: 도장(개인키)과 도장 모양(공개키)을 만든다
  3. CSR 제출: "이 공개키 주인은 나야" 신청서를 CA에 낸다
  4. CA 심사: 도메인 주인이 맞는지 확인한다
  5. 인증서 발급: CA가 신청서에 자기 도장을 찍어 돌려준다

 

핸드셰이크 (접속마다)
  6. 접속 시도: 사용자가 주소를 입력한다
  7. 브라우저 인사: 난수 A와 브라우저 키 공유값을 보낸다
  8. 서버 답장: 난수 B, 서버 키 공유값, 인증서를 보낸다
  9. 1차 검증: 인증서가 진짜인지 CA 도장을 확인한다
  10. 서버 서명: 서버가 개인키로 대화 전체에 도장을 찍는다
  11. 2차 검증: 인증서 속 공개키로 그 도장을 확인한다
  12. 세션 키 조립: 양쪽이 각자 같은 키를 계산하고 완료 신호를 주고받는다

 

통신
  13. HTTPS 시작: 세션 키로 모든 데이터를 암호화한다

 

CIDR이란?

CIDR는 클래스 개념 없이 IP 주소를 유연하게 할당하고 라우팅 효율성을 높이는 주소 지정 방식인

Classless Inter-Domain Routing의 약자

 

CGNAT이란?

CGNAT(Carrier-Grade NAT, 대규모 네트워크 주소 변환)인터넷 서비스 제공업체(ISP)가 부족한 공용 IPv4 주소를 여러 사용자가 함께 공유하여 사용할 수 있도록 통신사 망에서 제공하는 주소 변환 기술

 

 

1. Cloudflare란?

https://www.cloudflare.com/ko-kr/learning/what-is-cloudflare/

 

전 세계 웹사이트의 보안과 성능을 향상시키기 위해 트래픽을 중계하고 보호하는 글로벌 클라우드 플랫폼을 말한다.

 

필자는 DDos공격과 IP접속 제한을 편리하게 하기 위해 Cloudflare 를 사용했다.

 

Cloudflare에서는 많은 것을 지원한다.

 

Cloudflare 제품 메뉴.

 

그 중에서 필자가 가장 중요하게 봤던 것으로 DDos, 저렴한 DNS 비용, WAF에 대해서만 알아보겠다.

 

디도스(DDoS, 분산 서비스 거부 공격) 방어

여러 대의 기기를 동원해 특정 서버에 과도한 트래픽을 보내 마비시키는 사이버 공격을 말한다.

 

디도스는 사실 너무 흔히 접하는 문제이긴 한다.

 

구글에서 '디도스 뉴스' 검색 시 이미지

 

 

Cloudflare에서는 이것을 알아서 해준다.

 

https://www.cloudflare.com/ko-kr/ddos

 

WAF(Web Application Firewall) 지원

 

 

DNS 기능

 

DNS도 설정하기 쉬우면서 합리적인 가격을 제공한다.

 

이 외에도 캐싱도 하고 TLS처리도 해준다.

 

 

간단하게 어떤 기능과 장점이 뭔지 표로 요약했다.

 

Cloudflare 기능 요약!

기능 무슨 역할 장점 (홈서버 관점)
CDN 전 세계 330개 이상 도시의 엣지에서 콘텐츠 캐싱·전송 정적 파일이 사용자와 가까운 노드에서 응답 → 빠름, 원본 서버(미니 PC) 부하 감소
DDos 방어 대규모 트래픽 공격을 엣지에서 흡수·차단 무제한 DDoS 방어를 무료 플랜에서도 제공. 가정용 회선으로는 절대 못 막는 공격을 대신 막아줌
WAF (웹 방화벽) SQL Injection, XSS 등 악성 요청을 룰 기반으로 차단 무료는 기본 관리형 룰셋, 유료(Pro)는 OWASP 룰까지. 애플리케이션 레벨 공격 1차 필터
Universal SSL HTTPS 인증서 자동 발급·갱신 무료로 자동 처리. Let's Encrypt 갱신·80번 포트 이슈를 신경 쓸 필요가 없어짐 (지금 상황에 딱 맞음)
DNS 권威 DNS 호스팅, 빠른 응답 무제한 쿼리, 관리 콘솔에서 레코드 즉시 수정
프록시(오렌지 구름) 실제 서버 IP를 숨기고 Cloudflare IP만 노출 미니 PC의 공인 IP가 외부에 안 드러남 → 직접 공격 표적이 되지 않음
방화벽 규칙 / IP지오차단 국가·IP·ASN 단위로 허용·차단 "국내 IP만 허용" 요구사항을 여기서 룰 한 줄로 처리 가능
Bot Flight Mode 자동화 봇 트래픽 탐지·차단 무료는 기본, 유료는 Super Bot Fight Mode. 스캐닝 봇 감소
Workers 엣지에서 실행되는 서버리스 코드 무료는 하루 10만 요청 제한. 간단한 리다이렉트·인증 로직을 엣지에서 처리
Registrar 도메인 등록 (원가 판매) 이미 movieseat.site 구매하신 그 기능. 마크업 없이 도가 원가로 판매

 

2. 내 미니 PC 와 연결하기 1: 무엇이 문제인가?

필자는 초기에는 무료 도메인으로 연결하려고 했다.

 

https://내도메인.한국 화면

 

그런데 가장 큰 문제가 발생했다.

 

이렇게 진행하면 HTTPS인증서를 받을 수 없었다. 

 

이유는 2개다. CA가 요구하는 2가지 사항을 충족하지 못하기 때문이다.

 

검증 방식 CA가 요구하는 것 막힌 이유
DNS-01 _acme-challenge.도메인에 TXT 레코드 등록 내도메인.한국이 밑줄(_)이 들어간 이름을 거부
HTTP-01 80번 포트로 접속해 인증 파일 확인 집 공유기가 외부 80번 포트포워딩을 차단

 

 

SK 브로드밴트 공유기의 고급 설정에서 포트포워딩으로 필자의 미니 PC로 연결하려고 했는데 80번대는 안된다고 한다.

 

DNS-01방식으로 하려니 밑줄이 들어가면 안됀다. 그래서 HTTP-01로 하려고 하니 80번대는 설정이 안된다고 한다.

 

인증서 발급을 위해서 써야하는 두가지 방법이 다 막힌 상황이다. 그렇다면? 결국에는 다른 방식으로 접근해야한다.

 

 

그 외에도 직접 PC에서 관리를 하면 어려운 난관에 봉착한다.

 

(1) 국내 IP로 제한하는것도 직접 해야하고 그걸 해도

(2) DDos공격에 취약하며

(3) 내 집의 공인 IP가 그대로 노출되는 불상사가 생기기 때문이다.

 

가장 무서운것은 내 IP가 노출되어서 내가 거주하는 건물 전체를 좀비화하는 일은 없어야하니... 

 

여러 문제와 편리성을 위해서 Cloudflare 도입을 결정했다.

 

 

2. 내 미니 PC 와 연결하기 2: cloudflare연결하기(DNS Records와 SSL/TLS설정)

 

우선 흐름을 다시 정리하겠다.

 

[사용자]
   │  HTTPS
   ▼
[Cloudflare 엣지]   - TLS 종단 1 (Universal SSL), WAF(KR만), DDoS 방어, 집 IP 숨김
   │  HTTPS  (Full strict, Origin CA 인증서 검증)
   ▼
[집 공유기]   - 외부 443 → 192.168.45.46:443 포워딩 (80, 22 미개방)
   │
   ▼
[미니 PC 192.168.x.x]
   │
   ▼
[ufw]   - 443은 Cloudflare 대역만 통과 (나머지 차단)
   │
   ▼
[HAProxy :443]   - TLS 종단 2 (Origin CA), 경로 분기, 호스트에 직접 설치
        │  HTTP
        ├─ /api/**   → be_was   - 라운드로빈 + 헬스체크
        │                ├─ was1      127.0.0.1:8081 ─┐
        │                └─ was2      127.0.0.1:8082 ─┤
        │                                             └─ MySQL   (Docker 내부망 전용, 포트 비공개)
        │
        └─ default   → be_web
                         └─ Next.js   127.0.0.1:3000

[외부 PC] ── Tailscale ──▶ [ufw: 22 허용] ──▶ [미니 PC sshd :22]   - SSH 관리 경로 (웹 트래픽과 별개)

 

HTTPS연결을 위한 여정을 그럼 시작해보자.

 

2.1) Domain 구매와 설정

cloudflare 사이트에 들어가서 Domains -> Registrations 를 선택하고 우측 상단의 Buy domain을 선택한다.

 

 

그리고 구매하고자 하는 domain 을 검색하고 사면 된다. 필자는 .site로 선택을 했다.

 

movieseat.site이다. 1년에 4.5불 이라고 한다. (엄청싸네?) 

 

 

그리고 구매를 하고 내 공유기의 IP와 연결해준다.

 

 

그러면 내 미니 PC의 ip주소를 알아야겠지?

 

# 현재 접속 중인 환경(서버 또는 로컬)의 공인 IPv4 주소를 확인하는 명령어
curl -4 ifconfig.me

 

내 miniPC의 정보를 알면 구매한 도메인에 Add record를 해줘서 가상의 IP 에서 내 집의 공유기로 오도록 설정한다.

 

위의 그림에서 보면 admin과 www그리고 맨 사이트를 등록해놨다.

 

 

공유기의 정보는 집에서 확인했고, 필자는 SK 브로드밴드를 사용했다.

 

SK 브로드밴드 공유기 설정 방법은 인터넷에 찾아보니 잘 나온다. 한 번 직접 해보자.(별거없다! 진짜다!)

 

 

내부에 들어가서 고급설정 > 포트포워딩에서 필자는 설정을 해놓은 상태이다.

 

프로토콜 외부 포트 시작 외부 포트 끝 내부 IP 주소 내부 포트 시작 내부 포트 끝
TCP 443 443 192.168.x.x 443 443

 

sk 브로드밴드에서 포트포워딩을 하고 window의 cmd 창에서 nslookup movieseat.site를 확인한 결과 다음과 같이 나왔다.

 

nslookup movieseat.site 결과

nslookup movieseat.site
서버:    dns1.fortiguard.net
Address:  96.45.45.45

권한 없는 응답:
이름:    movieseat.site
Addresses:  2606:4700:3033::ac43:c684
          2606:4700:3030::6815:2c60
          104.21.44.96
          172.67.198.132

 

우선 addrsss가 올바르게 나온다. 해당 address는 cloudflare에서 설정해준 것이니 공개해도 상관이 없다.

 

2.2) SSL/TLS설정

 

이제 내 미니PC에서 올바르게 https 통신을 할 수 있도록 설정해야 한다.

 

Cloudflare의 기능을 활용하면 이마저도 간단해진다. 직접 openssl로 만들어서 뭐 할 필요가 없다는 말이다.

 

 

2.2.1) 원본 서버용 인증서 발급(Origin CA)

 

SSL/TLS > Origin Server를 선택한다.

 

우선 Domains 섹션에서 원하는 도메인을 선택하면 SSL/TLS가 나온다. 

 

RSA(2048) 버전으로 Create하면 public key 랑 private key를 발급해준다.

 

그리고 발급된 key값을 내 미니 PC에 넣어야 하는데... HAProxy를 통해서 부하분산 및 TLS 처리를 해줄 것이다.

 

그렇기에 HAProxy를 설치하도록 하겠다.

 

2.2.2) HAProxy 설치

# [미니 PC] 패키지 목록 갱신
sudo apt update

# [미니 PC] HAProxy 설치 (systemd 서비스로 등록됨)
sudo apt install -y haproxy

# [미니 PC] 설치된 버전 확인
haproxy -v

# [미니 PC] 부팅 시 자동 시작 설정
sudo systemctl enable haproxy

# [미니 PC] 패키지 기본 설정 파일 백업 (나중에 비교용)
sudo cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.orig

 

2.2.3) 미니 PC에 발급받은 key저장

# [미니 PC] 인증서 디렉터리 생성 (이미 있으면 그대로 둠), root만 접근 가능하게
sudo mkdir -p /etc/haproxy/certs
sudo chmod 700 /etc/haproxy/certs

# [미니 PC] Origin Certificate 붙여넣고 저장 (Ctrl+O, Enter, Ctrl+X)
sudo nano /etc/haproxy/certs/origin.crt

# [미니 PC] Private Key 붙여넣고 저장
sudo nano /etc/haproxy/certs/origin.key

# [미니 PC] 인증서 + 개인키를 한 파일로 합침 (HAProxy가 읽는 파일)
sudo sh -c 'cat /etc/haproxy/certs/origin.crt /etc/haproxy/certs/origin.key > /etc/haproxy/certs/movieseat.site.pem'

# [미니 PC] 모든 인증서 파일을 root 소유, root만 읽기로 제한
sudo chown root:root /etc/haproxy/certs/*
sudo chmod 600 /etc/haproxy/certs/*

 

필자의 경우에는 이미 만들어놨기 때문에, 이를 검증하기 위해 해당 스크립크를 실행하고 보았다.

 

# [미니 PC] pem 안에 인증서 블록과 개인키 블록이 둘 다 있는지 확인 (각각 1 이상이어야 정상)
sudo grep -c "BEGIN CERTIFICATE" /etc/haproxy/certs/movieseat.site.pem
sudo grep -c "BEGIN PRIVATE KEY" /etc/haproxy/certs/movieseat.site.pem

# [미니 PC] 인증서 발급자·대상·유효기간 확인 (Issuer에 CloudFlare Origin 이 보여야 함)
sudo openssl x509 -in /etc/haproxy/certs/movieseat.site.pem -noout -issuer -subject -dates

# [미니 PC] 인증서와 개인키가 짝이 맞는지 확인 (두 줄의 해시값이 같아야 정상)
sudo openssl x509 -in /etc/haproxy/certs/movieseat.site.pem -noout -pubkey | openssl sha256
sudo openssl pkey -in /etc/haproxy/certs/movieseat.site.pem -pubout | openssl sha256

# [미니 PC] 권한 확인 (-rw------- root root 이면 정상)
sudo ls -l /etc/haproxy/certs/

 

다음은 실행시 보이는 모습이다.

 

# 1. PEM 파일 내에 인증서(Certificate) 블록이 몇 개 존재하는지 카운트합니다. (결과: 1개)
$ sudo grep -c "BEGIN CERTIFICATE" /etc/haproxy/certs/movieseat.site.pem
1

# 2. PEM 파일 내에 개인키(Private Key) 블록이 몇 개 존재하는지 카운트합니다. (결과: 1개)
# HAProxy는 인증서와 개인키가 하나의 PEM 파일에 병합된 구조를 요구하므로 이를 확인하는 과정입니다.
$ sudo grep -c "BEGIN PRIVATE KEY" /etc/haproxy/certs/movieseat.site.pem
1

# 3. 인증서의 발급자(Issuer), 대상(Subject), 유효기간(Dates)을 출력하여 정보를 검증합니다.
# Cloudflare Origin CA에서 발급한 15년(2026~2041) 유효기간의 인증서임을 확인합니다.
$ sudo openssl x509 -in /etc/haproxy/certs/movieseat.site.pem -noout -issuer -subject -dates
issuer=C=US, O=CloudFlare, Inc., OU=CloudFlare Origin SSL Certificate Authority, L=San Francisco, ST=California
subject=O=CloudFlare, Inc., OU=CloudFlare Origin CA, CN=CloudFlare Origin Certificate
notBefore=Sep 15 15:58:00 2026 GMT
notAfter=Sep 11 15:58:00 2041 GMT

# 4. 인증서(Certificate) 객체에서 공개키를 추출한 뒤 SHA256 해시값을 계산합니다.
$ sudo openssl x509 -in /etc/haproxy/certs/movieseat.site.pem -noout -pubkey | openssl sha256
SHA2-256(stdin)= 3cffa57aㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌ8078

# 5. 개인키(Private Key) 객체에서 공개키를 유도하여 추출한 뒤 SHA256 해시값을 계산합니다.
# 4번 명령어의 해시값과 5번 명령어의 해시값이 일치하므로, 두 키가 올바른 한 쌍(Pair)임을 암호학적으로 증명합니다.
$ sudo openssl pkey -in /etc/haproxy/certs/movieseat.site.pem -pubout | openssl sha256
SHA2-256(stdin)= 3cffa57aㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌㅌ8078

# 6. PEM 파일의 권한을 확인합니다.
# 파일이 root 소유이며 권한이 600(-rw-------)으로 설정되어, 외부 노출 없이 안전하게 보호되고 있음을 확인합니다.
$ sudo ls -l /etc/haproxy/certs/
total 4
-rw------- 1 root root 3380 Sep 15 16:05 movieseat.site.pem
$

 

흠.... 잘 나오는거 같은데?

 

그러면 이제 들어오자마자 HAProxy가 작동해서 내부 was 에 분산처리하도록 설정해야한다.

 

필자는 이번에 HAProxy를 직접 설정하는게 처음이다.

 

그래서 AI를 최대한 활용해서 기본 내용을 정리했다.

 

섹션 무엇을 정하나?
global HAProxy 프로세스 자체의 설정.
로그를 어디로 보낼지, 어떤 권한으로 실행할지, TLS 기본 정책 등. 파일에 한 번만 나옴
defaults 아래에 나오는 모든 frontend/backend에 공통으로 적용할 기본값.
각 섹션에서 같은 항목을 다시 쓰면 그 섹션만 덮어씀
frontend 요청을 받는 입구.
어느 포트에서 받고, 받은 요청을 검사·수정한 뒤 어느 backend로 보낼지
backend 요청을 넘겨줄 뒷단 서버 묶음.
서버 목록, 분배 방식, 헬스체크

 

기존에 파일 내용을 변경하기 전에 다 내리고 새롭게 넣도록 하겠다.

 

HAProxy 설정 넣기

#기존 파일 내용 싹 비우기
sudo sh -c '> /etc/haproxy/haproxy.cfg'

# 다시 열기
sudo nano /etc/haproxy/haproxy.cfg

# 내용 복사하기
# Ctrl + V -> Ctrl + O 후 Enter -> Ctrl + X

 

HAProxy.cfg 설정 내용

#=====================================================================
# global
#   HAProxy 프로세스 자체의 설정 (파일에 한 번만 존재)
#   로그 대상, 실행 권한, 관리용 소켓, TLS 기본 정책을 정한다
#=====================================================================
global
    # 로그를 로컬 syslog 소켓(/dev/log)으로 보낸다. facility는 local0 (모든 레벨)
    # Ubuntu에서는 rsyslog가 이걸 받아 /var/log/haproxy.log 에 기록한다
    log /dev/log local0

    # 같은 소켓으로 facility local1 에도 보내되, notice 이상(중요한 것)만 보낸다
    log /dev/log local1 notice

    # 시작 후 프로세스가 볼 수 있는 파일 시스템을 /var/lib/haproxy 안으로 가둔다
    # HAProxy가 뚫려도 공격자가 /etc, /home 등 다른 경로에 접근하지 못하게 하는 격리 장치
    chroot /var/lib/haproxy

    # 실행 중인 HAProxy를 제어하는 관리용 유닉스 소켓
    # (서버 상태 조회, 특정 서버 일시 제외 등). mode 660: 소유자/그룹만 읽기·쓰기
    # level admin: 이 소켓으로 모든 관리 명령 허용
    stats socket /run/haproxy/admin.sock mode 660 level admin

    # 관리 소켓에 접속한 뒤 30초 동안 아무 명령이 없으면 연결을 끊는다
    stats timeout 30s

    # root로 시작해 443 포트 바인딩과 인증서 읽기를 끝낸 뒤,
    # 권한이 낮은 haproxy 사용자/그룹으로 전환해서 동작한다
    user haproxy
    group haproxy

    # 백그라운드(데몬)로 실행
    # Ubuntu의 systemd 서비스는 자체 옵션으로 실행하므로 실제로는 systemd가 관리한다
    daemon

    # [TLS 1.2 이하] 허용할 암호 방식 목록 (Ubuntu 패키지 기본값 유지)
    # 모두 ECDHE/DHE 키 교환(전방 비밀성) + GCM/CHACHA20 인증 암호화 조합
    ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384

    # [TLS 1.3] 허용할 암호 방식 목록 (TLS 1.3은 이 항목을 따로 쓴다)
    ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256

    # ssl-min-ver TLSv1.2 : TLS 1.0, 1.1 접속은 거부
    # no-tls-tickets      : 세션 티켓(재접속 시 핸드셰이크 생략 기능)을 끈다
    #                       티켓 암호화 키가 노출되면 과거 통신까지 풀릴 수 있어 끄는 것
    ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets


#=====================================================================
# defaults
#   아래 모든 frontend/backend에 공통으로 적용되는 기본값
#   각 섹션에서 같은 항목을 다시 쓰면 그 섹션만 덮어쓴다
#=====================================================================
defaults
    # 로그 설정은 global에 적은 것을 그대로 사용
    log     global

    # HTTP(L7) 모드. 요청의 경로, 헤더를 읽고 수정할 수 있다
    # (tcp 모드면 내용을 보지 않고 바이트만 전달 → 경로 분기 불가)
    mode    http

    # HTTP 요청 단위의 상세 로그 형식 사용 (메서드, 경로, 상태코드, 응답시간, 어느 서버로 갔는지)
    option  httplog

    # 데이터를 한 바이트도 보내지 않고 끊긴 연결은 로그에 남기지 않는다
    # (포트 스캐너, 단순 연결 확인 등으로 로그가 지저분해지는 것 방지)
    option  dontlognull

    # HAProxy가 뒷단 서버에 TCP 연결을 맺을 때 최대 5초 대기
    timeout connect 5s

    # 클라이언트(여기서는 Cloudflare) 쪽에서 30초 동안 데이터가 없으면 연결 종료
    timeout client  30s

    # 뒷단 서버가 30초 동안 응답 데이터를 보내지 않으면 연결 종료 (504 응답)
    timeout server  30s


#=====================================================================
# frontend fe_https
#   외부 요청이 들어오는 입구
#   받기 → 출발지 검사 → TLS 해제 → 헤더 정리 → 경로 보고 backend 선택
#=====================================================================
frontend fe_https
    # 443 포트에서 받고, 이 인증서(Origin 인증서 + 개인키)로 TLS를 해제(종단)한다
    # ':443' 은 모든 IPv4 주소에서 받는다는 뜻
    bind :443 ssl crt /etc/haproxy/certs/movieseat.site.pem

    # TCP 연결이 맺어지는 즉시(TLS 핸드셰이크 전에) 출발지 IP를 검사
    # 파일에 적힌 Cloudflare 대역에 속하지 않으면 바로 끊는다
    # → 원본 IP로 직접 들어와 Cloudflare(WAF, 국가 제한)를 우회하는 것을 차단
    tcp-request connection reject if !{ src -f /etc/haproxy/cloudflare-ips.lst }

    # 뒷단 입장에서는 모든 요청이 127.0.0.1(HAProxy)에서 온 것으로 보인다
    # Cloudflare가 넣어준 실제 사용자 IP(CF-Connecting-IP)를 X-Forwarded-For에 넣어 전달
    # set-header는 '추가'가 아니라 '덮어쓰기'라서, 사용자가 위조해 보낸 값은 지워진다
    http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)]

    # HAProxy가 TLS를 풀었으므로 뒷단은 평문 HTTP로 받는다
    # 원래 요청이 https였다는 사실을 알려준다 (Spring이 리다이렉트 URL을 https로 만들도록)
    http-request set-header X-Forwarded-Proto https

    # 실제 사용자 IP를 HAProxy 로그에도 남긴다 (로그의 {} 안에 표시됨)
    # len 45 : IPv6 주소 최대 길이
    http-request capture req.hdr(CF-Connecting-IP) len 45

    # 경로가 /api/ 로 시작하면 is_api 조건이 참
    acl is_api path_beg /api/

    # is_api 가 참이면 be_was 로 보낸다
    use_backend be_was if is_api

    # 위 조건에 해당하지 않는 나머지 요청은 전부 be_web 으로 보낸다
    default_backend be_web


#=====================================================================
# backend be_web
#   프론트엔드(Next.js) 서버 묶음
#=====================================================================
backend be_web
    # web1 이라는 이름의 서버, 127.0.0.1:3000 으로 전달
    # check : 주기적으로 TCP 연결을 시도해 살아있는지 확인 (기본 2초 간격)
    server web1 127.0.0.1:3000 check


#=====================================================================
# backend be_was
#   API 서버(Spring) 묶음, 2대로 부하 분산
#=====================================================================
backend be_was
    # 요청을 was1 → was2 → was1 ... 순서대로 번갈아 보낸다
    balance roundrobin

    # 헬스체크를 TCP 연결 확인이 아니라 HTTP 요청으로 한다
    option httpchk

    # 헬스체크 요청 내용: GET /actuator/health
    http-check send meth GET uri /actuator/health

    # 응답이 200이어야 정상으로 판단
    http-check expect status 200

    # 1초마다 헬스체크. 연속 실패하면 분배 대상에서 빠지고, 연속 성공하면 다시 들어온다
    server was1 127.0.0.1:8081 check inter 1s
    server was2 127.0.0.1:8082 check inter 1s


#=====================================================================
# frontend stats
#   서버 상태를 보는 웹 대시보드
#=====================================================================
frontend stats
    # 127.0.0.1 에서만 받으므로 미니 PC 안에서만 접속 가능 (외부 노출 없음)
    bind 127.0.0.1:8404

    # 이 frontend를 통계 페이지로 사용
    stats enable

    # 루트 경로(/)에서 통계 페이지를 보여준다
    stats uri /

    # 페이지를 5초마다 자동 새로고침
    stats refresh 5s

 

2.2.4) cloudflare -> 미니PC간 https 통신 설정을 위한 부가 설정

 

현재 cloudflare 에서 미니 PC까지 설정을 하는데 HTTPS를 위해 고생중이다.

 

[사용자 브라우저] ───(1)───▶ [Cloudflare] ───(2)───▶ [미니 PC의 HAProxy]

 

여태 우리는 (2) 작업을 위해 고생중이다.

 

우선 (1) 구간에서는 DNS 설정에서 프록시(주황 구름)을 키면 Cloudflare에서 자기 인증서를 자동으로 발급해 붙여준다.

 

1차적으로 사용자과 Cloudflare간의 HTTPS통신이 가능하다. 

 

그런데 문제는 Cloudflare에서 미니 PC로의 작업이 완료되지 않았다.

 

필요사항은 2개이다.

 

 

  • (a) 미니 PC가 HTTPS를 받을 수 있어야 한다 → 인증서가 있어야 함
  • (b) Cloudflare가 미니 PC에 HTTPS로 접속하고, 그 인증서를 검증하도록 설정되어 있어야 한다

 

 

(a)는 Haproxy작업으로 TLS 종단처리를 완료했다. 위의 HAProxy.cfg 를 설정해줬기 때문이다.

 

인증서를 Cloudflare에서 받급해서 이를 /etc/haproxy/certs/movieseat.site.pem에 저장했다.

 

 

이제는 Cloudflare가 2구간에서 HTTPS(443)로 접속하고 미니 PC 인증서를 검증하도록 해야 한다.

 

이를 위해서는 Cloudflare 사이트에서 SSL/TLS -> Overview 에 접속해서

암호화 모드를 Full (Strict)로 지정하고 저장하는 것이 필요하다. 

 

 

각각을 설명하도록 하겠다.

 

요약 정리 표

모드 2구간 연결 인증서검증 내 미니 PC 상태에 이 모드를 적용?
Off HTTP 없음 쓰면 안됌
Flexible HTTP(80) 없음 80차단이라 연결 실패
Full HTTPS(443) 안함 동작은 하지만 원본 서버 확인 불가
Full(Strict) HTTPS(443) 암호화 + 원본 서버 확인
Automatic 스캔 결과에 따라 결정 결과에 따라 현재 Strict로 동작 중

 

 

  • Off: 사용자 → Cloudflare, Cloudflare → 미니 PC 모두 암호화하지 않는다.
  • Flexible: 사용자 → Cloudflare만 암호화하고, Cloudflare → 미니 PC는 HTTP(80)로 평문 접속한다.
  • Full: Cloudflare → 미니 PC도 HTTPS(443)로 암호화하지만, 미니 PC 인증서는 검증하지 않는다.
  • Full (Strict): Cloudflare → 미니 PC를 HTTPS(443)로 암호화하고, 미니 PC 인증서의 발급자·유효기간·도메인까지 검증한다.
  • Automatic: Cloudflare가 미니 PC를 스캔해서 위 모드 중 통과 가능한 가장 안전한 것을 알아서 적용한다.

 

 

필자는 Full(Strict)를 선택했다. 

 

 

그리고 국내에서만 들어올 수 있도록 Security rules 에서 Create rule 을 클릭해서 custom rule을 추가했다.

'

 

 

3. 미니 PC 에 Cloudflare 접속만 허용하기 + 테스트용 도커 서버 띄우기

 

3.1) Cloudflare접속만! 

그런데 문제가 또 있다.

 

DNS 프록시로 집 공인 IP를 숨겼지만, IP가 알려지면(예전 무료 도메인의 A 레코드, DNS 이력 사이트 등) Cloudflare를 거치지 않고 미니 PC 443에 직접 접속할 수 있다. 이 경우 Cloudflare의 WAF와 DDoS 방어가 모두 우회된다.

 

그래서 Cloudflare에서 온 요청만 미니 PC에 들어올 수 있도록 설정한다. Cloudflare가 공개한 IP 대역 목록을 파일로 받아서, ufw와 HAProxy 두 곳에 적용한다.

 

  • ufw: 목록에 없는 IP의 443 접속을 TCP 연결 단계에서 거부
  • HAProxy: ufw를 통과하더라도 목록에 없는 IP면 연결 즉시 끊음 (2중 확인)

 

직접 들어온 요청은 연결 단계에서 끊기므로, 미니 PC에서 국가 차단 같은 WAF 설정을 따로 할 필요는 없다.

 

국가 차단은 Cloudflare, 우회 차단은 미니 PC가 맡는 구조다.

 

 

이전 글에서는 fail2ban으로 뭘 막았다고 주저리 써놧엇다. 이전 글에서 fail2ban은 SSH 전용이다.

 

웹 요청은 모두 Cloudflare IP에서 들어오기 때문에 fail2ban으로 ufw 차단을 걸면 Cloudflare 자체가 막혀 사이트 전체가 접속 불가가 된다. 웹 쪽 차단은 Cloudflare에서 한다

 

하여간 Cloudflare 우회요청을 막기 위한 작업을 하겠다.

 

3.2) Cloudflare IP 목록 준비

# [미니 PC] Cloudflare 공식 IPv4 대역 저장
sudo sh -c 'curl -s https://www.cloudflare.com/ips-v4 > /etc/haproxy/cloudflare-ips.lst'

# [미니 PC] 파일 끝 줄바꿈 보정 후 IPv6 대역 이어붙임
sudo sh -c 'echo >> /etc/haproxy/cloudflare-ips.lst; curl -s https://www.cloudflare.com/ips-v6 >> /etc/haproxy/cloudflare-ips.lst'

# [미니 PC] 빈 줄 제거
sudo sed -i '/^$/d' /etc/haproxy/cloudflare-ips.lst

# [미니 PC] 내용 확인 (CIDR 대역이 한 줄에 하나씩)
cat /etc/haproxy/cloudflare-ips.lst

 

 

실제 확인 결과(정상)

173.245.48.0/20
103.21.244.0/22
...
131.0.72.0/22        ← IPv4 15개
2400:cb00::/32
...
2c0f:f248::/32       ← IPv6 7개

 

이제 방화벽을 설정하자.

 

3.3) 방화벽 설정

 

우선 방화벽 상태를 보자.

$sudo ufw status numbered
Status: active

     To                         Action      From
     --                         ------      ----
[ 1] 22/tcp on tailscale0       ALLOW IN    Anywhere
[ 2] 22/tcp                     ALLOW IN    192.168.45.0/24
[ 3] 80/tcp                     ALLOW IN    Anywhere
[ 4] 443/tcp                    ALLOW IN    173.245.48.0/20            # cloudflare
[ 5] 443/tcp                    ALLOW IN    103.21.244.0/22            # cloudflare
[ 6] 443/tcp                    ALLOW IN    103.22.200.0/22            # cloudflare
[ 7] 443/tcp                    ALLOW IN    103.31.4.0/22              # cloudflare
[ 8] 443/tcp                    ALLOW IN    141.101.64.0/18            # cloudflare
[ 9] 443/tcp                    ALLOW IN    108.162.192.0/18           # cloudflare
[10] 443/tcp                    ALLOW IN    190.93.240.0/20            # cloudflare
[11] 443/tcp                    ALLOW IN    188.114.96.0/20            # cloudflare
[12] 443/tcp                    ALLOW IN    197.234.240.0/22           # cloudflare
[13] 443/tcp                    ALLOW IN    198.41.128.0/17            # cloudflare
[14] 443/tcp                    ALLOW IN    162.158.0.0/15             # cloudflare
[15] 443/tcp                    ALLOW IN    104.16.0.0/13              # cloudflare
[16] 443/tcp                    ALLOW IN    104.24.0.0/14              # cloudflare
[17] 443/tcp                    ALLOW IN    172.64.0.0/13              # cloudflare
[18] 443/tcp                    ALLOW IN    131.0.72.0/22              # cloudflare
[19] 22/tcp (v6) on tailscale0  ALLOW IN    Anywhere (v6)
[20] 80/tcp (v6)                ALLOW IN    Anywhere (v6)
[21] 443/tcp                    ALLOW IN    2400:cb00::/32             # cloudflare
[22] 443/tcp                    ALLOW IN    2606:4700::/32             # cloudflare
[23] 443/tcp                    ALLOW IN    2803:f800::/32             # cloudflare
[24] 443/tcp                    ALLOW IN    2405:b500::/32             # cloudflare
[25] 443/tcp                    ALLOW IN    2405:8100::/32             # cloudflare
[26] 443/tcp                    ALLOW IN    2a06:98c0::/29             # cloudflare
[27] 443/tcp                    ALLOW IN    2c0f:f248::/32             # cloudflare

 

이전에 사실 필자는 tailscale은 등록을 해놨다. 작업용으로 접속은 해야하니까

 

# [미니 PC] 기본 정책: 들어오는 것은 차단, 나가는 것은 허용
sudo ufw default deny incoming
sudo ufw default allow outgoing

# [미니 PC] SSH는 Tailscale 인터페이스에서만 허용
sudo ufw allow in on tailscale0 to any port 22 proto tcp

# [미니 PC] 집 내부망에서 SSH 허용 (내부에서 직접 접속용, 필요 시)
sudo ufw allow from 192.168.45.0/24 to any port 22 proto tcp

# [미니 PC] Cloudflare 대역마다 443 허용 규칙 추가 (전체 개방인 'ufw allow 443'은 쓰지 않음)
for ip in $(cat /etc/haproxy/cloudflare-ips.lst); do sudo ufw allow from "$ip" to any port 443 proto tcp comment 'cloudflare'; done

 

그런데 어디가 잘못된건지 anywhere가 있다.

 

우리는 현재 clourflare를 통해서만, 그것도 https 통신으로 미니 PC에 접근하도록 해야한다.

 

그런데 80포트로 tcp가 anywhere이라면 뚫여서 안된다.

 

 

80/tcp Anywhere 규칙은 예전에 python http.server로 외부 접속 테스트를 할 때 추가했던 것이 남아 있던 것이다.

 

지금은 80에서 듣는 프로그램도 없고 공유기도 80을 막고 있어 당장 뚫리지는 않지만, 쓰지 않는 포트를 열어두면 나중에 무언가 80을 쓰게 됐을 때 그대로 노출되니 필요 없는 규칙은 지운다.

 

 

고로! 다음 명령어를 수행한다.

$sudo ufw delete allow 80/tcp
$ sudo ufw status numbered
Status: active

     To                         Action      From
     --                         ------      ----
[ 1] 22/tcp on tailscale0       ALLOW IN    Anywhere
[ 2] 22/tcp                     ALLOW IN    192.168.45.0/24
[ 3] 443/tcp                    ALLOW IN    173.245.48.0/20            # cloudflare
[ 4] 443/tcp                    ALLOW IN    103.21.244.0/22            # cloudflare
[ 5] 443/tcp                    ALLOW IN    103.22.200.0/22            # cloudflare
[ 6] 443/tcp                    ALLOW IN    103.31.4.0/22              # cloudflare
[ 7] 443/tcp                    ALLOW IN    141.101.64.0/18            # cloudflare
[ 8] 443/tcp                    ALLOW IN    108.162.192.0/18           # cloudflare
[ 9] 443/tcp                    ALLOW IN    190.93.240.0/20            # cloudflare
[10] 443/tcp                    ALLOW IN    188.114.96.0/20            # cloudflare
[11] 443/tcp                    ALLOW IN    197.234.240.0/22           # cloudflare
[12] 443/tcp                    ALLOW IN    198.41.128.0/17            # cloudflare
[13] 443/tcp                    ALLOW IN    162.158.0.0/15             # cloudflare
[14] 443/tcp                    ALLOW IN    104.16.0.0/13              # cloudflare
[15] 443/tcp                    ALLOW IN    104.24.0.0/14              # cloudflare
[16] 443/tcp                    ALLOW IN    172.64.0.0/13              # cloudflare
[17] 443/tcp                    ALLOW IN    131.0.72.0/22              # cloudflare
[18] 22/tcp (v6) on tailscale0  ALLOW IN    Anywhere (v6)
[19] 443/tcp                    ALLOW IN    2400:cb00::/32             # cloudflare
[20] 443/tcp                    ALLOW IN    2606:4700::/32             # cloudflare
[21] 443/tcp                    ALLOW IN    2803:f800::/32             # cloudflare
[22] 443/tcp                    ALLOW IN    2405:b500::/32             # cloudflare
[23] 443/tcp                    ALLOW IN    2405:8100::/32             # cloudflare
[24] 443/tcp                    ALLOW IN    2a06:98c0::/29             # cloudflare
[25] 443/tcp                    ALLOW IN    2c0f:f248::/32             # cloudflare

$

 

다시 확인하면 SSH(22)는 Tailscale과 집 내부망에서만, HTTPS(443)는 Cloudflare 대역에서만 허용되고, 그 외 Anywhere 규칙은 모두 사라졌다.

 

$ sudo ufw show added | grep 22
ufw allow in on tailscale0 to any port 22 proto tcp
ufw allow from 192.168.45.0/24 to any port 22 proto tcp
ufw allow from 103.21.244.0/22 to any port 443 proto tcp comment 'cloudflare'
ufw allow from 103.22.200.0/22 to any port 443 proto tcp comment 'cloudflare'
ufw allow from 103.31.4.0/22 to any port 443 proto tcp comment 'cloudflare'
ufw allow from 197.234.240.0/22 to any port 443 proto tcp comment 'cloudflare'
ufw allow from 131.0.72.0/22 to any port 443 proto tcp comment 'cloudflare'
$ sudo ufw enable
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup
$ sudo ufw status numbered
Status: active

     To                         Action      From
     --                         ------      ----
[ 1] 22/tcp on tailscale0       ALLOW IN    Anywhere
[ 2] 22/tcp                     ALLOW IN    192.168.45.0/24
[ 3] 443/tcp                    ALLOW IN    173.245.48.0/20            # cloudflare
[ 4] 443/tcp                    ALLOW IN    103.21.244.0/22            # cloudflare
[ 5] 443/tcp                    ALLOW IN    103.22.200.0/22            # cloudflare
[ 6] 443/tcp                    ALLOW IN    103.31.4.0/22              # cloudflare
[ 7] 443/tcp                    ALLOW IN    141.101.64.0/18            # cloudflare
[ 8] 443/tcp                    ALLOW IN    108.162.192.0/18           # cloudflare
[ 9] 443/tcp                    ALLOW IN    190.93.240.0/20            # cloudflare
[10] 443/tcp                    ALLOW IN    188.114.96.0/20            # cloudflare
[11] 443/tcp                    ALLOW IN    197.234.240.0/22           # cloudflare
[12] 443/tcp                    ALLOW IN    198.41.128.0/17            # cloudflare
[13] 443/tcp                    ALLOW IN    162.158.0.0/15             # cloudflare
[14] 443/tcp                    ALLOW IN    104.16.0.0/13              # cloudflare
[15] 443/tcp                    ALLOW IN    104.24.0.0/14              # cloudflare
[16] 443/tcp                    ALLOW IN    172.64.0.0/13              # cloudflare
[17] 443/tcp                    ALLOW IN    131.0.72.0/22              # cloudflare
[18] 22/tcp (v6) on tailscale0  ALLOW IN    Anywhere (v6)
[19] 443/tcp                    ALLOW IN    2400:cb00::/32             # cloudflare
[20] 443/tcp                    ALLOW IN    2606:4700::/32             # cloudflare
[21] 443/tcp                    ALLOW IN    2803:f800::/32             # cloudflare
[22] 443/tcp                    ALLOW IN    2405:b500::/32             # cloudflare
[23] 443/tcp                    ALLOW IN    2405:8100::/32             # cloudflare
[24] 443/tcp                    ALLOW IN    2a06:98c0::/29             # cloudflare
[25] 443/tcp                    ALLOW IN    2c0f:f248::/32             # cloudflare

 

3.4) 테스트용 도커 서버 띄우기

 

다시 이쯤에서 흐름을 확인하자.

 

HAProxy는 요청을 127.0.0.1:3000(web), 127.0.0.1:8081(was1), 127.0.0.1:8082(was2)로 넘기도록 설정되어 있다.

그런데 아직 실제 Next.js, Spring 서버가 없다.

 

실제 앱을 올리기 전에, 사용자 → Cloudflare → 미니 PC → HAProxy → 뒷단까지 전체 경로가 뚫렸는지 먼저 확인하고 싶다.

그래서 hashicorp/http-echo로 임시 서버 3개를 띄운다.

 

http-echo는 어떤 경로로 요청하든 지정한 문자열을 200으로 응답하는 작은 서버다. 그래서 HAProxy의 헬스체크(/actuator/health)도 통과하고, 응답 문자열로 어느 서버가 응답했는지 구분할 수 있다.

 

compose.yml 내용

# 테스트용 임시 뒷단: 어떤 경로로 요청해도 지정한 문자열을 200으로 응답
# 포트는 모두 127.0.0.1에만 바인딩 (외부에서 직접 접근 불가)
services:
  web:
    # Next.js 자리 (be_web → 127.0.0.1:3000)
    image: hashicorp/http-echo
    command: ["-listen=:5678", "-text=hello"]
    ports:
      - "127.0.0.1:3000:5678"
    restart: unless-stopped

  was1:
    # Spring was1 자리 (be_was → 127.0.0.1:8081)
    image: hashicorp/http-echo
    command: ["-listen=:5678", "-text=hello from was1"]
    ports:
      - "127.0.0.1:8081:5678"
    restart: unless-stopped

  was2:
    # Spring was2 자리 (be_was → 127.0.0.1:8082)
    image: hashicorp/http-echo
    command: ["-listen=:5678", "-text=hello from was2"]
    ports:
      - "127.0.0.1:8082:5678"
    restart: unless-stopped

 

 

앞에서 ufw로 443을 Cloudflare 대역에만 열었다. 그런데 Docker는 ufw를 우회한다.

 

Docker는 ports:로 공개한 포트에 대해 iptables 규칙을 직접 추가하는데, 이 규칙이 ufw 규칙보다 먼저 적용된다.

 

그래서 "3000:5678"처럼 쓰면 ufw에 규칙이 없어도 외부에서 3000번으로 접속할 수 있게 된다.

 

"127.0.0.1:3000:5678"처럼 앞에 127.0.0.1을 붙이면 미니 PC 내부에서만 접근할 수 있다.

외부 요청은 반드시 HAProxy를 거쳐야 한다.

 

HAProxy를 Docker가 아닌 apt로 직접 설치한 것도 같은 이유다. 외부 요청을 받는 입구에는 ufw가 반드시 적용되어야 한다.

 

docker 실행 명령어

# [미니 PC] 백그라운드로 실행
docker compose up -d

# [미니 PC] 세 컨테이너가 Up 이고, PORTS 가 127.0.0.1:... 로 나오는지 확인
docker compose ps

# [미니 PC] 컨테이너에 직접 요청 → hello / hello from was1 / hello from was2
curl http://127.0.0.1:3000; echo
curl http://127.0.0.1:8081; echo
curl http://127.0.0.1:8082; echo

 

실행하면 다음과 같다.

 

 

내부에서 직접 찔러보니 되기는 한다.

 

 

4. 내가 생각한 문제점 해결 : API 요청은 frontend에서만 가능하게

 

그런데 생각을 해보니 외부에서 api를 요청하는게 적절하지 않은거 같다.

 

내가 구축한 frontend 서버에서만 이를 사용하겟지, 다른데서 사용할 일은 없지 않나? 현재 시점에서 봣을 때 말이다.

 

그래서 흐름을 다음과 같이 변경하기로 했다.

 

브라우저 ──▶ Cloudflare ──▶ HAProxy fe_https(:443) ──▶ Next.js (127.0.0.1:3000)
                                                        │
                                                        │ Next.js 서버가 내부에서 호출
                                                        ▼
                                  HAProxy fe_internal_api(127.0.0.1:8090)
                                                        │ 라운드로빈 + 헬스체크
                                                        ├─▶ was1 127.0.0.1:8081
                                                        └─▶ was2 127.0.0.1:8082

 

 

haproxy.cfg 수정본

#=====================================================================
# global
#   HAProxy 프로세스 자체의 설정 (파일에 한 번만 존재)
#   로그 대상, 실행 권한, 관리용 소켓, TLS 기본 정책을 정한다
#=====================================================================
global
    # 로그를 로컬 syslog 소켓(/dev/log)으로 보낸다. facility는 local0 (모든 레벨)
    # Ubuntu에서는 rsyslog가 이걸 받아 /var/log/haproxy.log 에 기록한다
    log /dev/log local0

    # 같은 소켓으로 facility local1 에도 보내되, notice 이상(중요한 것)만 보낸다
    log /dev/log local1 notice

    # 시작 후 프로세스가 볼 수 있는 파일 시스템을 /var/lib/haproxy 안으로 가둔다
    # HAProxy가 뚫려도 공격자가 /etc, /home 등 다른 경로에 접근하지 못하게 하는 격리 장치
    chroot /var/lib/haproxy

    # 실행 중인 HAProxy를 제어하는 관리용 유닉스 소켓
    # (서버 상태 조회, 특정 서버 일시 제외 등). mode 660: 소유자/그룹만 읽기·쓰기
    # level admin: 이 소켓으로 모든 관리 명령 허용
    stats socket /run/haproxy/admin.sock mode 660 level admin

    # 관리 소켓에 접속한 뒤 30초 동안 아무 명령이 없으면 연결을 끊는다
    stats timeout 30s

    # root로 시작해 443 포트 바인딩과 인증서 읽기를 끝낸 뒤,
    # 권한이 낮은 haproxy 사용자/그룹으로 전환해서 동작한다
    user haproxy
    group haproxy

    # 백그라운드(데몬)로 실행
    # Ubuntu의 systemd 서비스는 자체 옵션으로 실행하므로 실제로는 systemd가 관리한다
    daemon

    # [TLS 1.2 이하] 허용할 암호 방식 목록 (Ubuntu 패키지 기본값 유지)
    # 모두 ECDHE/DHE 키 교환(전방 비밀성) + GCM/CHACHA20 인증 암호화 조합
    ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384

    # [TLS 1.3] 허용할 암호 방식 목록 (TLS 1.3은 이 항목을 따로 쓴다)
    ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256

    # ssl-min-ver TLSv1.2 : TLS 1.0, 1.1 접속은 거부
    # no-tls-tickets      : 세션 티켓(재접속 시 핸드셰이크 생략 기능)을 끈다
    #                       티켓 암호화 키가 노출되면 과거 통신까지 풀릴 수 있어 끄는 것
    ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets


#=====================================================================
# defaults
#   아래 모든 frontend/backend에 공통으로 적용되는 기본값
#   각 섹션에서 같은 항목을 다시 쓰면 그 섹션만 덮어쓴다
#=====================================================================
defaults
    # 로그 설정은 global에 적은 것을 그대로 사용
    log     global

    # HTTP(L7) 모드. 요청의 경로, 헤더를 읽고 수정할 수 있다
    # (tcp 모드면 내용을 보지 않고 바이트만 전달)
    mode    http

    # HTTP 요청 단위의 상세 로그 형식 사용 (메서드, 경로, 상태코드, 응답시간, 어느 서버로 갔는지)
    option  httplog

    # 데이터를 한 바이트도 보내지 않고 끊긴 연결은 로그에 남기지 않는다
    # (포트 스캐너, 단순 연결 확인 등으로 로그가 지저분해지는 것 방지)
    option  dontlognull

    # HAProxy가 뒷단 서버에 TCP 연결을 맺을 때 최대 5초 대기
    timeout connect 5s

    # 클라이언트(fe_https에서는 Cloudflare, fe_internal_api에서는 Next.js) 쪽에서
    # 30초 동안 데이터가 없으면 연결 종료
    timeout client  30s

    # 뒷단 서버가 30초 동안 응답 데이터를 보내지 않으면 연결 종료 (504 응답)
    timeout server  30s


#=====================================================================
# frontend fe_https
#   외부 요청이 들어오는 유일한 입구
#   받기 → 출발지 검사 → TLS 해제 → 헤더 정리 → 전부 Next.js로 전달
#   Spring(be_was)으로 가는 경로는 없다. 외부에서 API를 직접 호출할 수 없다
#=====================================================================
frontend fe_https
    # 443 포트에서 받고, 이 인증서(Origin 인증서 + 개인키)로 TLS를 해제(종단)한다
    # ':443' 은 모든 IPv4 주소에서 받는다는 뜻
    bind :443 ssl crt /etc/haproxy/certs/movieseat.site.pem

    # TCP 연결이 맺어지는 즉시(TLS 핸드셰이크 전에) 출발지 IP를 검사
    # 파일에 적힌 Cloudflare 대역에 속하지 않으면 바로 끊는다
    # → 원본 IP로 직접 들어와 Cloudflare(WAF, 국가 제한)를 우회하는 것을 차단
    tcp-request connection reject if !{ src -f /etc/haproxy/cloudflare-ips.lst }

    # 뒷단 입장에서는 모든 요청이 127.0.0.1(HAProxy)에서 온 것으로 보인다
    # Cloudflare가 넣어준 실제 사용자 IP(CF-Connecting-IP)를 X-Forwarded-For에 넣어 전달
    # set-header는 '추가'가 아니라 '덮어쓰기'라서, 사용자가 위조해 보낸 값은 지워진다
    http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)]

    # HAProxy가 TLS를 풀었으므로 뒷단은 평문 HTTP로 받는다
    # 원래 요청이 https였다는 사실을 알려준다 (Next.js가 리다이렉트 URL을 https로 만들도록)
    http-request set-header X-Forwarded-Proto https

    # 실제 사용자 IP를 HAProxy 로그에도 남긴다 (로그의 {} 안에 표시됨)
    # len 45 : IPv6 주소 최대 길이
    http-request capture req.hdr(CF-Connecting-IP) len 45

    # 외부 요청은 경로와 관계없이 전부 Next.js(be_web)로 보낸다
    # (/api/ 도 Next.js로 가므로, Next.js의 app/api/.../route.ts 를 그대로 쓸 수 있다)
    default_backend be_web


#=====================================================================
# frontend fe_internal_api
#   미니 PC 내부 전용 API 입구
#   Next.js 서버가 Spring을 호출할 때만 사용한다
#=====================================================================
frontend fe_internal_api
    # 127.0.0.1 에만 열리므로 미니 PC 밖에서는 접근할 수 없다
    # (공유기 포트포워딩도, ufw 허용 규칙도 없음)
    bind 127.0.0.1:8090

    # 모든 요청을 WAS 묶음으로 보낸다 (라운드로빈 + 헬스체크는 be_was 설정 그대로)
    default_backend be_was


#=====================================================================
# backend be_web
#   프론트엔드(Next.js) 서버 묶음 — fe_https 에서만 사용
#=====================================================================
backend be_web
    # web1 이라는 이름의 서버, 127.0.0.1:3000 으로 전달
    # check : 주기적으로 TCP 연결을 시도해 살아있는지 확인 (기본 2초 간격)
    server web1 127.0.0.1:3000 check


#=====================================================================
# backend be_was
#   API 서버(Spring) 묶음, 2대로 부하 분산 — fe_internal_api 에서만 사용
#=====================================================================
backend be_was
    # 요청을 was1 → was2 → was1 ... 순서대로 번갈아 보낸다
    balance roundrobin

    # 헬스체크를 TCP 연결 확인이 아니라 HTTP 요청으로 한다
    option httpchk

    # 헬스체크 요청 내용: GET /actuator/health
    http-check send meth GET uri /actuator/health

    # 응답이 200이어야 정상으로 판단
    http-check expect status 200

    # 1초마다 헬스체크. 연속 실패하면 분배 대상에서 빠지고, 연속 성공하면 다시 들어온다
    server was1 127.0.0.1:8081 check inter 1s
    server was2 127.0.0.1:8082 check inter 1s


#=====================================================================
# frontend stats
#   HAProxy 내장 상태 페이지 (서버별 UP/DOWN, 분배 건수, 에러 수)
#=====================================================================
frontend stats
    # 127.0.0.1 에서만 받으므로 미니 PC 안에서만 접속 가능 (외부 노출 없음)
    bind 127.0.0.1:8404

    # 이 frontend를 통계 페이지로 사용
    stats enable

    # 루트 경로(/)에서 통계 페이지를 보여준다
    stats uri /

    # 페이지를 5초마다 자동 새로고침
    stats refresh 5s

 

실행 결과

$ sudo nano haproxy.cfg
[sudo: authenticate] Password:
$ sudo nano haproxy.cfg
$ sudo sh -c '> /etc/haproxy/haproxy.cfg'


$ sudo haproxy -c -V -f /etc/haproxy/haproxy.cfg
'Configuration file is valid
$ sudo systemctl restart haproxy
sudo systemctl restart haproxy
$ sudo ss -tlnp | grep -E ':443|:8090'sudo ss -tlnp | grep -E ':443|:8090'
grep: invalid option -- 't'
Usage: grep [OPTION]... PATTERNS [FILE]...
Try 'grep --help' for more information.
$ sudo ss -tlnp | grep -E ':443|:8090'
LISTEN 0      4096                     127.0.0.1:8090       0.0.0.0:*    users:(("haproxy",pid=12549,fd=6))
LISTEN 0      4096                       0.0.0.0:443        0.0.0.0:*    users:(("haproxy",pid=12549,fd=5))
$ for i in 1 2 3 4; do curl -s http://127.0.0.1:8090/api/test; echo; done
hello from was1

hello from was2

hello from was1

hello from was2

$

 


 

으아... 정말 쉽지 않았다. 요즘같은 AI 시대에는 코딩은 빠르게 해줄지언정 보안적으로 고려는 사실 사람이 아직도 해야할 것 같다.

 

물론 그마저도 조언을 받을 수 있고, 필자도 많은 작업을 그렇게 진행했다.

 

허나 직접 쳐봐서 맞는지 안맞는지 검증을 하고 물어봐야하니 그렇다. 다 뚤리면 누구탓? 바로 내탓! 

 

참고:

클라우드페어 : 리버스 프록시란? | 프록시 서버 설명