로컬 개발환경에 HTTPS 적용하기

sejin kim·2023년 6월 10일
8
post-thumbnail

localhost as secure contexts

일반적으로 웹 개발에서 로컬 개발환경은 http://localhost인 경우가 많습니다. 대개는 별다른 문제 없이 대부분의 기능을 사용하며 개발할 수 있는데, 이는 브라우저가 localhost인 경우 보안 정책에 예외를 두는 등, TLS 통신과 동일한 보안 특성을 간주하고 HTTPS처럼 동작하기 때문입니다.

정확하게는 localhost.localhost에 속하는 모든 도메인을 '잠재적으로 신뢰할 수 있는 출처(Potentially Trustworthy Origin)'으로 취급하는 것인데, 가령 Service Worker 라거나 Web Authentication API, Sensor API 같은 것들은 반드시 보안 컨텍스트(HTTPS)에서만 동작한다는 제약사항이 존재하지만, localhost에서는 예외적으로 정상적인 사용이 가능합니다.


5. 보안 고려 사항
...
5.2. localhost
RFC6761의 섹션 6.3은 localhost의 해석과 .localhost에 속하는 도메인을 특별하게 다루어야 함을 설명하며, 로컬 리졸버가 이를 특수하게 처리하도록 권고합니다. 그러나 리졸버들은 종종 이러한 제안을 무시하고, localhost를 네트워크로 전송하여 해석하는 경우가 많습니다.
따라서, 사용자 에이전트는 let-localhost-be-localhost에 명시된 이름 해석의 규칙을 준수하는 경우에만 잠재적으로 localhost를 신뢰할 수 있는 출처로 취급할 수 있습니다. 이는 결국 localhost가 다른 루프백 주소가 아닌 주소로 해석되지 않도록 보장하는 내용입니다.


- W3C, Secure Contexts


그러나, 막상 개발을 하다 보면 로컬에서도 HTTPS여야만 하는 경우가 왕왕 있습니다. 흔하게는 Secure 속성을 사용한 쿠키, 즉 보안 쿠키(Secure cookie)를 사용하려는 때가 그렇습니다. 결국엔 로컬 개발 환경에서도 HTTPS를 적용하는 편이 아무래도 더 편리한 셈인데, 이외에도 아래와 같은 경우가 해당될 수 있습니다.


  • Mixed content 이슈를 디버깅해야 할 때
  • HTTP/2 이상의 프로토콜을 사용하고자 할 때
  • HTTPS가 요구되는 라이브러리, API 등을 사용할 때
  • 호스트네임을 커스터마이즈했을 때

하지만 HTTPS를 적용하려면 TLS/SSL 인증서가 필요합니다. 또한 인증서는 공인된 실제 인증 기관(Certificate Authority, CA)으로부터 서명된 것이어야 하며, 그래야만 브라우저가 유효한 인증서로 간주하게 됩니다.

로컬 개발환경에서 이런 실제 인증서를 사용하는 것은 설정부터가 다소 복잡한 데다, 유효한 도메인 이름을 사용해야만 하는 등(가령 localhost나 예약된 도메인 같은 것은 사용할 수 없습니다) 제약이 있어 여러모로 번거로울 수 있습니다.

아니면 그냥 개인적으로 자체 서명한 인증서를 생성하고, 그걸 브라우저가 허용하게 할 수도 있습니다만, 아래와 같이 브라우저에서 별도의 플래그 설정이 필요하고, 거슬리는 경고도 발생하게 됩니다.



때문에 결국엔 인증서를 직접 생성하되, 디바이스와 브라우저가 로컬에서 신뢰하는 CA를 사용하여 서명하는 방법이 가장 간단하고 깔끔합니다. 이는 무료로 인증서를 제공해주는 비영리 CA 기관으로 유명한 Let's Encrypt에서도 권장하는 방법입니다.

이러한 목적으로 인증서를 생성할 때 가장 많이 사용되는 도구로는 mkcert가 있는데, 자세한 사용 방법은 아래에서 이어서 살펴보겠습니다.






Installation

mkcert 설치는 매우 간단합니다만, OS에 따라 방법이 약간 다릅니다.

Linux

linux에서는 먼저 인증서 관련 유틸리티 certutil 설치가 필요하므로, 아래의 패키지를 먼저 설치합니다. (배포판에 따라 구체적인 방법은 달라집니다)


sudo apt install libnss3-tools

OR

sudo yum install nss-tools

OR

sudo pacman -S nss

그런 다음 미리 빌드된 바이너리 패키지를 다운받습니다. mkcert 저장소를 클론하고 로컬에서 직접 빌드하는 방법도 있습니다만, Golang 세팅이 필요하고 구태여 번거로운 방법이므로 생략하겠습니다.


curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
chmod +x mkcert-v*-linux-amd64
sudo mv mkcert-v*-linux-amd64 /usr/local/bin/mkcert

macOS

brew install mkcert
brew install nss

nss 패키지는 Firefox를 사용하는 경우에 설치합니다.


Windows

Windows에서는 아래와 같이 패키지 매니저인 chocolately를 사용하여 설치할 수 있습니다.


choco install mkcert

아니면 릴리즈 페이지에서 그냥 exe 파일을 다운받는 것이 더 간단할 수 있습니다.






Setting

설치가 완료되었다면, 다음의 명령어로 로컬 CA를 만들고 Root CA 인증서를 생성합니다. Windows에서는 루트 인증서 설치에 대한 보안 경고 다이얼로그가 나타나는데, 확인을 선택해주면 됩니다.


mkcert -install

생성된 루트 인증서(rootCA.pem, rootCA-key.pem)는 아래의 명령어로 위치를 확인할 수 있습니다. 가령 모바일 디바이스에서는 인증서 파일을 직접 옮겨서 설치해야 할 수 있으므로, 이런 경우 필요할 수 있습니다.


mkcert -CAROOT

⛔ 이렇게 생성한 루트 인증서는 공개된 저장소에 업로드하는 등 타인과 절대 공유해서는 안 됩니다! 요청을 가로채는 보안 공격의 수단으로 사용될 수 있기 때문입니다. 만약 해당 작업을 팀 단위로 진행하고 있는 경우라면, 팀원들 각자가 개별적으로 실행해야 합니다.


그런 다음 아래와 같이 지정한 호스트 도메인들에 대한 인증서를 생성합니다. 호스트는 각각 공백으로 구분하여 인수로 전달할 수 있고, 와일드카드도 가능합니다. 이 과정에서 mkcert도 해당 인증서에 서명하게 됩니다.


mkcert "*.example.dev" localhost 127.0.0.1 ::1

별다른 추가 옵션을 명시하지 않았다면, 현재 명령어를 실행하고 있는 경로에 두 개의 .pem 파일(cert, key)이 생성되었을 것입니다.

이제 이 인증서를 적당한 디렉토리에 옮기고, 각 개발환경에 따라 웹 서버에서 해당 인증서를 사용하여 HTTPS를 활성화하도록 설정해주어야 합니다. 모든 케이스를 예로 들기는 어렵지만, 일반적인 설정의 샘플을 몇 가지 나열해 보자면 아래와 같습니다. (경로는 모두 임의로 설정된 것이며, 기타 여러 설정들이 생략되어 있습니다)


Apache httpd

# httpd-ssl.conf

<VirtualHost _default_:443>
    DocumentRoot "/var/www/example"
    ServerName example.dev:443

    SSLEngine on
    SSLCertificateFile "/usr/local/apache2/ssl/example.dev.pem"
    SSLCertificateKeyFile "/usr/local/apache2/ssl/example.dev-key.pem"

    ...
</VirtualHost>


Nginx

# nginx.conf

http {
    server {
        listen 443 ssl;
        listen [::]:443 ssl;
        
        server_name example.dev www.example.dev;
        root /var/www/example;
        
        ssl_certificate ~/example.dev.pem;
        ssl_certificate_key ~/example.dev-key.pem;

        ...
    }
}


Node.js

// server.js

const https = require('https');
const fs = require('fs');

const options = {
    key: fs.readFileSync('./example.dev-key.pem'),
    cert: fs.readFileSync('./example.dev.pem'),
};
 
https.createServer(options, (req, res) => {
    // ...
}).listen(3000);

http-server를 사용하고 있다면

http-server -S -C example.dev.pem -K example.dev-key.pem


Next.js

Next의 경우, 공식 문서의 custom server 섹션에서 설명하는 것처럼 서버 설정을 직접 커스터마이즈하는 방식으로 인증서를 사용하도록 설정할 수 있습니다. 프로젝트 루트에 server.js를 생성한 다음, 위에서 먼저 예시로 들었던 Node 서버와 동일한 형태로 createServer()에서 option으로 인증서 파일을 전달하여 재정의하는 것입니다.

하지만 이는 공식적으로 권장되지 않는 방법인데, 이렇게 서버를 변경하면 HMR(Hot Module Refresh) 기능에서 문제가 발생한다거나 자동 정적 최적화 같은 기능을 사용할 수 없게 되는 등 오히려 문제가 복잡해지기 때문입니다.


사용자 정의 서버를 사용하기로 결정하기 전에, Next.js의 통합 라우터가 애플리케이션의 요구 사항을 충족할 수 없는 경우에만 사용해야 한다는 점을 명심하세요. 사용자 정의 서버는 자동 정적 최적화와 같은 중요한 성능 최적화를 제거합니다.

Next.js 문서의 'Custom Server' 섹션 중


때문에 이런 케이스에서는 통상적인 사용례와 같이 앞단에 Nginx를 세팅하거나, local-ssl-proxy 같은 도구를 사용하는 식으로 (리버스) 프록시를 통해 SSL을 상위 계층으로 올려 HTTPS를 적용하는 편이 훨씬 간단할 수 있습니다. 그 편이 관심사 분리의 측면에서도 적절하고, 더 효율적인 것은 물론입니다.


next dev -p 3001 | local-ssl-proxy --key example.dev-key.pem --cert example.dev.pem --source 3000 --target 3001

다만 이렇게 프록시를 적용하는 경우에는, WebSocket 연결을 정상적으로 처리할 수 있는지 확인하여 필요한 경우 추가로 설정을 해줘야 할 수 있습니다. 개발 과정에서 중요하게 활용되는 기능 중 하나인 HMR이 WebSocket으로 구현되기 때문입니다(Next 12부터).


공식 문서에서는 NginxApache로 프록시하는 경우 WebSocket Upgrade 요청을 설정하는 방법을 가이드하고 있습니다.



Nuxt.js

Nuxt 역시 Next와 마찬가지로, 굳이 서버를 변경하기보단 프록시를 세팅하는 편이 더 일반적이고 실무적인 측면에서도 바람직합니다. 다만 그럼에도 직접 설정하고자 한다면, 아래와 같이 할 수는 있습니다. Nuxt(3 기준)에는 자체적으로 구동되는 서버 엔진(웹 서버 프레임워크)인 Nitro의 설정을 아래와 같이 변경해주면 됩니다.


// nuxt.config.js

export default defineNuxtConfig({
    devServer: {
        https: {
            key: './example.dev-key.pem',
            cert: './example.dev.pem'
        },
        host: 'example.dev',
        port: 3000
    }
 
    // ...
});

그러나 역시 WebSocket 연결을 고려해야 합니다. Nuxt에서 기본적으로 채택하고 있는 번들러인 Vite 역시 웹소켓으로 HMR을 구현하므로, 만약 연결에 실패하는 등의 이슈가 있는 경우 HMR 연결 설정과 관련하여 프로토콜, 포트 등의 설정을 직접 명시하거나 변경해야 할 수 있습니다.


// nuxt.config.js

export default defineNuxtConfig({
    vite: {
        server: {
            hmr: {
                protocol: 'wss',
                host: 'example.dev'
            }
        }
    }
 
    // ...
});





Finish

설정이 완료되었다면, 웹 서버를 재기동한 다음 브라우저에서 확인해 봅니다. 아래와 같이 보인다면 정상적으로 적용된 것입니다.




📖 참고 문서

profile
퇴고를 좋아하는 주니어 웹 개발자입니다.

0개의 댓글