Skip to Content
Dev Environment & ToolsZero Trust 환경에서 로컬 HTTPS를 맞추는 방법
🛠️ Dev Environment & Tools2023년 1월 12일

Zero Trust 환경에서 로컬 HTTPS를 맞추는 방법

#dev-environment-tools#https#mkcert#zero-trust#local-development#proxy

왜 localhost HTTP로는 충분하지 않았는가

로컬 개발 환경에서는 보통 http://localhost:3000 정도로도 충분하다. 하지만 사내 Zero Trust 환경에서는 그 가정이 자주 깨진다. 인증 리디렉션, CORS, 쿠키 정책, 그리고 프록시 구성까지 실제 운영 경로가 모두 HTTPS를 전제로 설계되어 있기 때문이다.

이 글에서 다루는 문제도 그랬다. 당시 환경은 MSA로 나뉜 서비스가 많아서, 무엇 하나 확인하려 해도 프런트엔드 개발 서버 하나만 띄워서는 끝나지 않았다. 상황에 따라 여러 로컬 애플리케이션을 함께 올려야 했고, 어떤 경우에는 로컬 서비스와 이미 떠 있는 개발 서버를 섞어서 붙여야 했다.

물론 모든 것을 컨테이너로 로컬에서만 재현하는 것도 가능하다. 하지만 실제 팀 환경은 개발 서버와 연동해서 테스트할 수 있게도 구성되어 있었고, 이 경로를 써야만 재현되는 인증·세션 이슈도 적지 않았다. 문제는 여기서부터였다. 개발 서버 쪽은 HTTPS가 기본 전제였고, Zero Trust 환경 때문에 사내 Root CA까지 신뢰해야 했기 때문에 “로컬은 대충 HTTP로, 나머지는 개발 서버에서” 같은 방식이 오래 버티지 못했다.

결국 브라우저가 신뢰하는 로컬 인증서를 만들고, 로컬 도메인도 실제 서비스에 가까운 형태로 맞춰야 했다.

이 글에서는 로컬 도메인을 임의로 localhost.nike.com이라고 적겠다.

어떤 제약이 로컬 테스트를 막았는가

문제는 단순히 “브라우저 경고가 뜬다” 수준이 아니었다. 인증과 세션 흐름 전체가 HTTPS를 기준으로 짜여 있었기 때문에, 로컬에서도 그 전제를 맞춰야 했다. 특히 여러 서비스가 나뉜 구조에서는 일부는 로컬에서, 일부는 개발 서버에서 붙여 테스트하는 일이 흔했고, 이때 인증서 신뢰 체계가 조금만 어긋나도 흐름 전체가 깨졌다.

제약로컬 HTTP에서 생기는 문제왜 HTTPS가 필요했나
SSO 리디렉션허용된 도메인/리디렉션 패턴과 어긋날 수 있음운영과 유사한 도메인 검증 필요
Secure 쿠키브라우저가 쿠키를 보내지 않음로그인 후 세션 유지 실패
CORS / SameSite로컬 환경에서 정책 차이가 크게 남실제 브라우저 동작을 재현해야 함
로컬 프록시서비스별 SSL 구성이 제각각이 됨앞단에서 일관된 HTTPS 종단 필요

즉, 이 문제는 인증서 하나의 문제가 아니라 운영 경로를 로컬에서 얼마나 비슷하게 재현할 수 있는가의 문제였다.

로컬 서비스만 단독으로 띄워서 테스트할 때는 HTTP로도 어느 정도 우회할 수 있었다. 하지만 실제로는 프런트엔드 개발 서버, 일부 백엔드, 그리고 이미 떠 있는 개발 서버를 함께 붙여 검증해야 하는 경우가 많았다. 이때는 다시 도메인 기반 쿠키와 HTTPS 전제가 살아났다.

예를 들어 로컬 화면이 개발 서버 API와 함께 동작해야 하거나, 로컬 백엔드가 개발 환경의 다른 서비스와 세션을 공유해야 하는 경우가 있다. 이런 흐름에서는 “로컬은 HTTP, 나머지는 HTTPS” 같은 혼합 구성이 금방 어긋난다. 쿠키 스코프와 Secure 속성, 브라우저의 cross-site 처리 방식이 개발 서버와 로컬 서버를 다르게 취급하기 때문이다.

게다가 Zero Trust 환경에서는 사내 Root CA를 신뢰하지 않으면 개발 서버 쪽 HTTPS 요청도 정상적으로 붙지 않았다. 결국 필요한 것은 “로컬 인증서 하나”가 아니라, 로컬 도메인용 인증서와 사내 인증서를 함께 다룰 수 있는 신뢰 체계였다.

결국 선택지는 둘 중 하나였다.

  • 로컬 테스트를 할 때마다 HTTP 전용 우회 설정을 따로 만든다.
  • 아예 로컬도 처음부터 HTTPS와 도메인 기반으로 맞춘다.

실무에서는 두 번째가 더 덜 고통스러웠다. 한 번 세팅이 번거롭더라도, 이후에는 “운영과 비슷한 조건”에서 계속 테스트할 수 있기 때문이다.

mkcert가 해결한 것은 무엇이었나

mkcert는 로컬에서 신뢰 가능한 인증서를 만드는 도구다. 핵심은 자체 서명 인증서를 무작정 쓰는 것이 아니라, 로컬 CA를 만든 뒤 그 CA를 운영체제의 trust store에 등록하는 데 있다.

그러면 브라우저는 “내가 신뢰하는 루트 인증서가 서명한 인증서”로 인식하고 경고 없이 HTTPS를 받아들인다. 결국 로컬에서도 실제 서비스처럼 도메인 기반 HTTPS 흐름을 만들 수 있다.

적용 흐름

로컬 CA를 설치한다

mkcert -install로 로컬 CA를 만들고 시스템 trust store에 등록한다.

테스트용 도메인 인증서를 발급한다

localhost.nike.com 같은 로컬 테스트 도메인에 맞는 인증서를 생성한다.

로컬 DNS 해석을 맞춘다

/etc/hosts 등에 테스트 도메인이 로컬 머신을 가리키도록 설정한다.

프록시나 로컬 서버에 인증서를 연결한다

프런트 도어가 되는 프록시 또는 개발 서버가 HTTPS를 종료하도록 붙인다.

실제로 가장 헷갈렸던 지점

설치 자체는 금방 끝난다. 헷갈리는 건 “어디까지가 브라우저 신뢰 저장소의 문제이고, 어디부터가 애플리케이션 런타임 문제인가”를 구분하는 일이다.

브라우저는 되는데 Java는 안 되는 경우

브라우저는 운영체제의 trust store를 그대로 쓰기 때문에 mkcert -install만으로 해결되는 경우가 많다. 하지만 Java 애플리케이션은 자체 trust store를 사용하므로, 같은 인증서를 두고도 브라우저는 통과하는데 서버 간 호출은 실패할 수 있다.

대표적으로 이런 식의 에러를 보게 된다.

text
sample/tls-error.txt
sun.security.validator.ValidatorException: PKIX path building failed

이 지점에서 배운 건 명확했다. “로컬 HTTPS가 된다”는 말은 브라우저 하나만 기준으로 판단하면 안 된다. 브라우저, 프록시, 백엔드 런타임이 각각 어떤 trust store를 보는지까지 함께 확인해야 한다.

특히 로컬에서 여러 언어/런타임을 함께 쓸 때 이 차이가 더 두드러진다.

  • Node.js는 시스템 인증서만으로 충분할 때도 있지만, 실행 환경에 따라 별도 CA 설정이 필요할 수 있다.
  • Python은 라이브러리나 가상환경에 따라 참조하는 CA 번들이 달라질 수 있다.
  • Java는 브라우저와 별도로 trust store를 보므로 가장 자주 막힌다.

그래서 인증서를 한 번 만든 뒤 끝내지 않고, macOS에서 쓰는 로컬 인증서와 사내 Zero Trust 환경의 내부 인증서를 여러 런타임이 함께 읽을 수 있게 맞추는 스크립트를 따로 만들었다.

trust store를 왜 스크립트로 자동화했는가

처음에는 브라우저만 통과하면 된다고 생각하기 쉽다. 하지만 실제 개발 환경에서는 브라우저, Node 개발 서버, Python 스크립트, Java 애플리케이션, Gradle 빌드까지 모두 HTTPS 경로를 건드린다. 이 중 하나라도 다른 trust store를 보면 다시 TLS 에러가 난다.

그래서 인증서 설정을 수동 문서로 남기는 대신, 맥북에서 한 번에 실행할 수 있는 셸 스크립트로 자동화했다. 역할은 단순했다.

  • mkcert가 만든 로컬 CA를 읽는다.
  • 사내 Zero Trust 환경에서 필요한 내부 인증서도 함께 모은다.
  • macOS trust store와 각 런타임이 읽는 경로를 맞춘다.
  • 새 노트북이나 런타임 업데이트 후에도 다시 실행할 수 있게 만든다.

아래는 실제 구현을 그대로 옮긴 것이 아니라, 당시 구조를 설명하기 위한 개념 예시다.

bash
sample/setup-local-ca.sh
#!/usr/bin/env bash set -euo pipefail LOCAL_CA="${HOME}/Library/Application Support/mkcert/rootCA.pem" COMPANY_CA="./certs/company-root-ca.pem" JAVA_CA_BUNDLE="${HOME}/.config/dev-certs/java-cacerts" security add-trusted-cert -d -r trustRoot -k ~/Library/Keychains/login.keychain-db "$LOCAL_CA" security add-trusted-cert -d -r trustRoot -k ~/Library/Keychains/login.keychain-db "$COMPANY_CA" mkdir -p "$(dirname "$JAVA_CA_BUNDLE")" cp "$JAVA_HOME/lib/security/cacerts" "$JAVA_CA_BUNDLE" keytool -importcert -noprompt -storepass changeit \ -alias local-mkcert-root -file "$LOCAL_CA" -keystore "$JAVA_CA_BUNDLE" keytool -importcert -noprompt -storepass changeit \ -alias company-root-ca -file "$COMPANY_CA" -keystore "$JAVA_CA_BUNDLE"

핵심은 “인증서를 어디에 넣을까”보다 “새 맥북에서도 다시 실행 가능하게 만들까”였다. 수동 설정은 한 번 맞춘 뒤 잊어버리기 쉽고, 몇 달 뒤 자바 버전이나 개발 도구가 바뀌면 다시 같은 문제를 겪게 된다.

Java 버전이 바뀌어도 계속 읽게 만든 방법

가장 귀찮았던 건 Java였다. JDK를 바꿀 때마다 각 버전의 cacerts를 직접 수정하는 방식은 오래 유지하기 어렵다. Gradle 빌드까지 생각하면 더 번거로워진다. 애플리케이션 실행은 되는데 Gradle dependency resolve 단계에서 다시 TLS가 깨지는 식의 문제가 생기기 때문이다.

그래서 JDK 내부 cacerts를 매번 수정하기보다, 별도의 trust store 파일을 만들고 전역 환경 변수로 읽게 하는 방식을 택했다. 이 trust store에는 mkcert가 만든 로컬 인증서와 사내 Zero Trust 환경에서 쓰는 Root CA를 함께 넣었다. 이렇게 해두면 JDK 버전이 바뀌어도 같은 trust store를 계속 재사용할 수 있다.

예를 들면 이런 식이다.

bash
sample/java-trust-store.sh
export JAVA_TOOL_OPTIONS=\"-Djavax.net.ssl.trustStore=$HOME/.config/dev-certs/java-cacerts -Djavax.net.ssl.trustStorePassword=changeit\"

JAVA_TOOL_OPTIONS를 써 두면 애플리케이션 실행뿐 아니라 Gradle 같은 Java 기반 도구도 같은 trust store를 보게 할 수 있다. 이 설정 덕분에 “앱은 되는데 빌드는 안 된다” 같은 불일치를 크게 줄일 수 있었다.

도메인을 맞춰야 쿠키와 리디렉션이 맞는다

로컬에서 localhost만 쓰면 대충 화면은 뜰 수 있어도, 실제 인증 흐름과 완전히 같아지지는 않는다. 이 글의 목적은 단순히 자물쇠 아이콘을 띄우는 것이 아니라, 실제 서비스와 최대한 비슷한 브라우저 조건을 재현하는 것이었다.

아래 코드는 실제 내부 설정을 그대로 옮긴 것이 아니라, 당시 구조를 설명하기 위한 개념 예시다.

bash
sample/hosts-setup.sh
sudo sh -c 'echo "127.0.0.1 localhost.nike.com" >> /etc/hosts' mkcert localhost.nike.com

이렇게 해두면 브라우저는 https://localhost.nike.com을 실제 도메인처럼 다루고, 이후 프록시 앞단 구성도 훨씬 자연스러워진다.

프록시가 왜 같이 필요했는가

로컬 HTTPS를 맞추는 작업은 결국 프록시 구성으로 이어졌다. 서비스가 하나면 개발 서버에 직접 인증서를 붙여도 되지만, 여러 로컬 서비스와 API를 함께 붙이기 시작하면 SSL 종료 지점을 한 군데로 모으는 편이 훨씬 관리하기 쉽다.

여기에는 인증서 관리 편의만 있는 것이 아니다. 브라우저는 도메인뿐 아니라 포트까지 포함해서 origin을 판단하기 때문에, 포트가 다르면 사실상 다른 서비스로 취급한다. 그러면 쿠키 범위, 인증 리디렉션, CORS 판단이 다시 꼬이기 쉽다.

즉 로컬 서비스들이 제각각 다른 포트에 떠 있으면 화면은 보여도 “같은 서비스처럼” 테스트하기는 어려워진다. 반대로 프록시를 앞단에 두고 하나의 HTTPS 진입점으로 모으면, 브라우저 입장에서는 더 일관된 origin으로 다룰 수 있고 쿠키나 세션 흐름도 실제 환경과 비슷하게 검증할 수 있다.

그래서 mkcert는 끝이 아니라 시작에 가까웠다.

  • mkcert는 신뢰 가능한 로컬 인증서를 만든다.
  • 프록시는 그 인증서를 받아 여러 로컬 서비스 앞단에서 HTTPS를 종료한다.
  • 그 위에서야 비로소 로그인, 쿠키, CORS, 리디렉션을 실제처럼 검증할 수 있다.

다음 글에서는 이 흐름을 이어서, Nginx Proxy Manager로 로컬 멀티서비스 프록시를 어떻게 붙였는지 정리할 수 있다.

이 방식의 한계는 무엇이었나

이 접근은 강력했지만 완전히 공짜는 아니다.

  • 로컬 trust store를 건드려야 하므로 초기 세팅이 번거롭다.
  • 브라우저와 Java 런타임의 인증서 신뢰 경로가 달라서 문제를 한 번 더 추적해야 한다.
  • 테스트 도메인, hosts 설정, 프록시 설정까지 엮이기 시작하면 신규 합류자가 따라오기 어렵다.
  • 런타임별 trust store 자동화까지 들어가면 편해지는 대신 스크립트 유지보수 책임이 생긴다.

특히 이런 종류의 개발 환경은 “한 번 맞춰 놓은 사람”에게는 쉬워 보이지만, 처음 세팅하는 사람에게는 어디서 막혔는지 파악하기가 어렵다. 그래서 단순 설치법보다 왜 이런 구성이 필요했는지를 함께 남기는 것이 중요했다.

로컬 CA의 개인 키는 개발 편의용이라도 민감하다. 로컬 인증서 자동화 스크립트를 공유하더라도 개인 키 자체를 배포하는 식으로 운영하면 안 된다.

로컬 HTTPS를 맞춘 뒤 남은 것

결국 이 작업의 핵심은 mkcert 사용법 자체가 아니었다. Zero Trust 환경에서는 로컬 개발도 운영 경로를 얼마나 비슷하게 재현하느냐가 중요했고, HTTPS는 그 출발점이었다.

이 과정을 거치고 나서야 로그인 흐름, Secure 쿠키, 프록시 라우팅을 운영과 가까운 조건에서 확인할 수 있었다. 다시 말해 mkcert는 “로컬에서도 진짜 같은 조건으로 테스트할 수 있게 만드는 기반 공사”에 가까웠다.

다음 단계는 이 인증서를 앞단 프록시에 붙여서 여러 로컬 서비스를 하나의 HTTPS 진입점으로 묶는 일이었다.

Last updated on