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

sejin kim·2023년 6월 10일
7
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

그런 다음 미리 빌드된 바이너리 패키지를 다운받습니다. 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 서버와 같은 형태로 재정의하는 방법이 있습니다만, 이 경우 HMR(Hot Module Refresh)에서 이슈가 발생한다거나 일부 기능을 사용할 수 없게 되는 등 오히려 문제가 복잡해지므로 그다지 권장할 만한 방법은 아닙니다.

때문에 통상적인 사용례와 같이 앞단에 Nginx를 세팅하거나, local-ssl-proxy 같은 도구를 사용하는 식으로 (리버스) 프록시를 통해 HTTPS를 적용하는 편이 더 깔끔하고 간단할 수 있습니다.


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

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

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



Nuxt.js

Nuxt 역시 Next와 마찬가지로 프록시를 세팅하는 편이 더 간단합니다만, 자체 웹 서버에 설정하고자 한다면 아래와 같이 할 수는 있습니다. Nuxt(v3 기준)에는 자체적으로 구동되는 서버 엔진(웹 서버 프레임워크)인 Nitro가 있으므로, 아래와 같이 설정을 변경해주면 됩니다.


// nuxt.config.js

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

그러나 역시 웹소켓 연결을 고려해야 합니다. Nuxt에서 기본적으로 채택하고 있는 번들러인 Vite 역시 웹소켓으로 HMR을 구현하므로, HMR 연결 설정과 관련하여 보안 프로토콜(wss:)을 명시해야 할 수 있습니다.


// nuxt.config.js

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





Finish

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




📖 참고 문서

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

0개의 댓글