Next.js 로컬 개발 환경 HTTP → HTTPS 전환 완전 가이드: mkcert, NextAuth v5 쿠키 문제 해결까지
로컬 개발 환경에서 HTTPS가 필요할 때 — mkcert 인증서 생성부터 NextAuth v5의 쿠키 프리픽스 문제 해결까지, 실전에서 마주치는 모든 함정과 해결법을 정리합니다.
1. 왜 로컬에서 HTTPS가 필요한가?
로컬 개발은 보통 http://localhost:3000으로 충분합니다. 하지만 다음 상황에서는 HTTPS가 필수입니다:
- OAuth 콜백 URL: Meta(Threads, Instagram), Apple 등은 HTTPS 콜백 URL만 허용합니다
- Secure 쿠키:
__Host-,__Secure-프리픽스 쿠키는 HTTPS에서만 동작합니다 - Service Worker: HTTPS 환경에서만 등록 가능합니다
- Mixed Content 방지: 프로덕션과 동일한 환경에서 테스트할 수 있습니다
이번 프로젝트에서는 Meta Threads API의 OAuth가 HTTPS redirect URI만 허용하면서 전환이 필요해졌습니다.
2. HTTPS 전환 전 구성 (Before)
전환 전 개발 환경 구성입니다:
// package.json
{
"scripts": {
"dev": "next dev"
}
}
# .env.local
AUTH_URL=http://localhost:3000
THREADS_REDIRECT_URI=http://localhost:3000/api/threads/callback
// src/lib/auth.ts
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [GitHub({...}), Google({...})],
adapter: PrismaAdapter(prisma),
session: { strategy: 'jwt' },
// ...
})
이 상태에서 Meta Developer Console에 redirect URI를 등록하면 HTTPS가 아니라는 에러가 발생합니다.
3. 1단계: mkcert로 로컬 인증서 생성
mkcert 설치 및 CA 등록
mkcert는 로컬 개발용 신뢰 인증서를 생성하는 도구입니다. 브라우저가 신뢰하는 로컬 CA(Certificate Authority)를 설치하여, self-signed 인증서의 보안 경고를 제거합니다.
# macOS
brew install mkcert
# CA 설치 (시스템 키체인에 등록)
mkcert -install
# → The local CA is already installed in the system trust store! 👍
mkcert -install은 시스템의 신뢰 저장소에 로컬 CA를 등록합니다. 이후 이 CA로 서명한 인증서는 Chrome, Firefox 등에서 신뢰됩니다.
인증서 생성
# 프로젝트 루트에서
mkdir -p .cert
cd .cert
mkcert localhost 127.0.0.1 ::1
# → localhost+2.pem (인증서)
# → localhost+2-key.pem (개인키)
생성된 파일:
localhost+2.pem: localhost, 127.0.0.1, ::1 에 대한 인증서localhost+2-key.pem: 개인키
# .gitignore에 추가
.cert/
중요: .cert/ 디렉토리는 반드시 .gitignore에 추가하세요. 인증서 파일이 원격 저장소에 올라가면 보안 위험이 됩니다.
4. 2단계: Next.js HTTPS 개발 서버 설정
Next.js 16의 --experimental-https
Next.js는 --experimental-https 플래그로 HTTPS 개발 서버를 지원합니다. mkcert 인증서를 직접 지정할 수도 있습니다:
// package.json
{
"scripts": {
"dev": "next dev --experimental-https --experimental-https-key .cert/localhost+2-key.pem --experimental-https-cert .cert/localhost+2.pem",
"dev:http": "next dev"
}
}
--experimental-https: HTTPS 모드 활성화--experimental-https-key: 개인키 경로--experimental-https-cert: 인증서 경로
dev:http는 HTTP 모드가 필요할 때를 위한 폴백 스크립트입니다.
환경변수 업데이트
# .env.local - 프로토콜 변경
AUTH_URL=https://localhost:3000 # ← http → https
THREADS_REDIRECT_URI=https://localhost:3000/api/threads/callback # ← http → https
서버 실행 확인
pnpm dev
# 출력:
# ⚠ Self-signed certificates are currently an experimental feature
# ▲ Next.js 16.1.6 (Turbopack)
# - Local: https://localhost:3000
여기까지는 순조롭습니다. 하지만 이제부터 문제가 시작됩니다.
5. 문제 1: OAuth 제공자 Redirect URI 불일치
증상
GitHub, Google 로그인 시 "Invalid Redirect URI" 에러가 발생합니다.
https://github.com/login/oauth/authorize?
redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fcallback%2Fgithub
→ Invalid Redirect URI
원인
기존에 http://localhost:3000/api/auth/callback/github으로 등록했지만, 이제 https://로 요청하고 있어서 불일치가 발생합니다.
해결
GitHub OAuth (github.com/settings/developers):
Authorization callback URL:
Before: http://localhost:3000/api/auth/callback/github
After: https://localhost:3000/api/auth/callback/github ← https로 변경
Google OAuth (console.cloud.google.com/auth/clients):
승인된 JavaScript 원본과 리디렉션 URI 모두 업데이트해야 합니다:
승인된 JavaScript 원본:
https://localhost:3000
승인된 리디렉션 URI:
https://localhost:3000/api/auth/callback/google
주의: Google OAuth는 클라이언트 유형이 "웹 애플리케이션"이어야 redirect URI를 설정할 수 있습니다. "데스크톱" 유형은 redirect URI 섹션이 없습니다.
Meta Threads OAuth (developers.facebook.com):
Redirect callback URLs:
https://localhost:3000/api/threads/callback
Deauthorize Callback URL:
https://localhost:3000/api/threads/deauthorize
Data Deletion Request URL:
https://localhost:3000/api/threads/data-deletion
6. 문제 2: next-themes Hydration Mismatch
증상
페이지 로드 시 콘솔에 다음 에러가 나타납니다:
A tree hydrated but some attributes of the server rendered HTML
didn't match the client properties.
<html
lang="en"
- className="dark"
- style={{color-scheme:"dark"}}
>
이어서 history.replaceState() 과다 호출 에러도 발생합니다:
SecurityError: Attempt to use history.replaceState()
more than 100 times per 10 seconds
원인
next-themes의 ThemeProvider가 클라이언트에서 <html> 태그에 dark 클래스를 주입합니다. 서버에서는 테마 정보가 없으므로 렌더링 결과가 다릅니다. 이 불일치가 무한 리렌더링을 유발합니다.
해결
<html> 태그에 suppressHydrationWarning을 추가합니다. 이것은 next-themes 공식 권장 방식입니다:
// src/app/layout.tsx
// Before
<html lang="en">
// After
<html lang="en" suppressHydrationWarning> // ← 핵심 변경점
suppressHydrationWarning은 해당 요소의 속성 불일치만 무시합니다. 자식 요소의 구조적 불일치는 여전히 감지됩니다. next-themes는 이 패턴을 의도적으로 사용하므로 안전합니다.
7. 문제 3: NextAuth v5 세션 쿠키 미저장 (핵심 문제)
증상
OAuth 로그인이 성공하지만 세션이 유지되지 않습니다:
- GitHub/Google "로그인" 클릭
- OAuth 인증 성공 (GitHub/Google에서 코드 수신)
- 콜백 처리 완료 (서버 로그에서 사용자 정보 확인)
- 그런데 로그인 페이지로 다시 돌아옴
/api/auth/session 호출 시 null 반환:
curl -sk https://localhost:3000/api/auth/session
# → null
디버깅 과정
서버 로그에 디버그 콜백을 추가하여 어디서 실패하는지 확인했습니다:
// src/lib/auth.ts
callbacks: {
async signIn({ user, account }) {
console.log('[auth] signIn callback:', { userId: user.id, provider: account?.provider })
return true
},
async jwt({ token, user }) {
console.log('[auth] jwt callback:', { tokenSub: token.sub, userId: user?.id })
if (user) token.id = user.id
return token
},
}
서버 로그 결과:
[auth] signIn callback: { userId: '65e08a0e-...', provider: 'github' }
[auth] jwt callback: { tokenSub: '65e08a0e-...', userId: undefined }
[auth] jwt callback: { tokenSub: '65e08a0e-...', userId: undefined }
signIn과 jwt 콜백 모두 정상 호출됩니다. JWT 토큰도 생성됩니다. 그런데 브라우저에 쿠키가 저장되지 않습니다.
원인 분석
NextAuth v5는 HTTPS를 감지하면 자동으로 쿠키 이름에 보안 프리픽스를 추가합니다:
| 프로토콜 | 쿠키 이름 | 요구 사항 |
|---|---|---|
| HTTP | authjs.session-token |
없음 |
| HTTPS | __Secure-authjs.session-token |
Secure 플래그, HTTPS |
| HTTPS | __Host-authjs.csrf-token |
Secure 플래그, HTTPS, Path=/, Domain 없음 |
__Host- 프리픽스는 __Secure-보다 더 엄격한 보안 제약을 가집니다. 브라우저가 인증서를 완전히 신뢰하지 않으면 이 쿠키를 조용히 거부합니다.
mkcert로 만든 인증서를 사용하더라도, Chrome이 간헐적으로 __Host- 프리픽스 쿠키를 거부하는 경우가 있습니다. 특히:
- Chrome 브라우저 캐시에 이전 HTTP 세션의 쿠키가 남아있을 때
- 시스템 키체인의 CA 인증서 상태가 불안정할 때
- Chrome의 보안 정책 업데이트로 localhost 예외 처리가 변경되었을 때
실제로 Auth.js 소스 코드에서 이 동작을 확인할 수 있습니다:
// @auth/core/lib/utils/cookie.js
export function defaultCookies(useSecureCookies) {
const cookiePrefix = useSecureCookies ? "__Secure-" : ""
return {
sessionToken: {
name: `${cookiePrefix}authjs.session-token`, // ← 프리픽스 추가
options: { secure: useSecureCookies, ... }
},
csrfToken: {
name: `${useSecureCookies ? "__Host-" : ""}authjs.csrf-token`, // ← 더 엄격
options: { secure: useSecureCookies, ... }
},
}
}
그리고 useSecureCookies의 기본값은:
// @auth/core/lib/utils/assert.js
const { callbackUrl } = defaultCookies(
options.useSecureCookies ?? url.protocol === "https:" // ← 프로토콜 기반 자동 결정
)
즉, **HTTPS인데 useSecureCookies를 명시하지 않으면 자동으로 true**가 됩니다.
해결: useSecureCookies 분리
개발 환경에서는 useSecureCookies: false로 설정하여 프리픽스 없는 일반 쿠키를 사용합니다:
// src/lib/auth.ts
const useSecureCookies = process.env.NODE_ENV === 'production' // ← 핵심 변경점
export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true,
useSecureCookies, // ← 명시적 설정
providers: [GitHub({...}), Google({...})],
adapter: PrismaAdapter(prisma),
session: { strategy: 'jwt' },
// ...
})
middleware도 동일하게 맞춰야 합니다. middleware의 getToken은 쿠키 이름으로 JWT를 찾기 때문입니다:
// src/middleware.ts
export async function middleware(request: NextRequest) {
const useSecureCookies = process.env.NODE_ENV === 'production' // ← auth.ts와 동일
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
secureCookie: useSecureCookies, // ← 쿠키 보안 설정
cookieName: useSecureCookies // ← 쿠키 이름 동기화
? '__Secure-authjs.session-token'
: 'authjs.session-token',
})
const isLoggedIn = !!token
// ...
}
핵심은 auth.ts의 useSecureCookies와 middleware의 secureCookie/cookieName이 반드시 같은 로직을 사용해야 한다는 것입니다.
8. 환경별 설정 비교 요약
| 항목 | 개발 (HTTP) | 개발 (HTTPS) | 프로덕션 |
|---|---|---|---|
| dev 스크립트 | next dev |
next dev --experimental-https ... |
N/A |
| AUTH_URL | http://localhost:3000 |
https://localhost:3000 |
https://example.com |
| useSecureCookies | false |
false |
true |
| 세션 쿠키 이름 | authjs.session-token |
authjs.session-token |
__Secure-authjs.session-token |
| CSRF 쿠키 이름 | authjs.csrf-token |
authjs.csrf-token |
__Host-authjs.csrf-token |
| 인증서 | 없음 | mkcert 인증서 | 프로덕션 인증서 |
| OAuth 콜백 | http://... |
https://... |
https://... |
핵심 패턴: 프로토콜(HTTP/HTTPS)과 쿠키 보안 설정을 분리합니다. 개발 환경에서는 HTTPS를 사용하더라도 useSecureCookies: false로 설정하여 브라우저 호환성 문제를 회피합니다.
9. 추가: custom server.js의 함정
처음에는 Node.js의 https.createServer로 커스텀 서버를 만들어 시도했습니다:
// server.js (사용하지 마세요!)
const { createServer } = require('https')
const next = require('next')
const fs = require('fs')
const app = next({ dev: true })
const handle = app.getRequestHandler()
app.prepare().then(() => {
createServer({
key: fs.readFileSync('.cert/localhost+2-key.pem'),
cert: fs.readFileSync('.cert/localhost+2.pem'),
}, (req, res) => handle(req, res))
.listen(3000)
})
이 방식은 Next.js 16에서 history.replaceState() 과다 호출 에러를 발생시킵니다. Next.js의 내부 라우터가 커스텀 서버와 충돌하기 때문입니다. --experimental-https 플래그를 사용하는 것이 올바른 방법입니다.
10. 전체 마이그레이션 체크리스트
HTTPS 전환 시 놓치기 쉬운 항목들을 체크리스트로 정리했습니다:
인증서 설정
- [ ]
mkcert -install로 로컬 CA 등록 - [ ]
mkcert localhost 127.0.0.1 ::1로 인증서 생성 - [ ]
.cert/디렉토리를.gitignore에 추가
Next.js 설정
- [ ]
package.json의 dev 스크립트에--experimental-https플래그 추가 - [ ] HTTP 폴백 스크립트(
dev:http) 추가
환경변수
- [ ]
AUTH_URL을https://로 변경 - [ ] OAuth 관련 redirect URI를
https://로 변경
OAuth 제공자 (외부 콘솔)
- [ ] GitHub: Authorization callback URL 업데이트
- [ ] Google: 승인된 JavaScript 원본 + 리디렉션 URI 업데이트
- [ ] Meta/Threads: Redirect callback URLs 업데이트
- [ ] 기타 OAuth 제공자의 redirect URI 업데이트
NextAuth / Auth.js
- [ ]
useSecureCookies를 환경별로 분리 설정 - [ ]
trustHost: true추가 - [ ] middleware의
getToken에서secureCookie와cookieName동기화
프론트엔드
- [ ]
<html>태그에suppressHydrationWarning추가 (next-themes 사용 시)
11. 베스트 프랙티스
프로토콜과 쿠키 보안을 분리하세요
// ✅ 좋은 패턴: 환경 변수로 분리
const useSecureCookies = process.env.NODE_ENV === 'production'
// ❌ 나쁜 패턴: 프로토콜로 판단
const useSecureCookies = request.nextUrl.protocol === 'https:'
프로토콜로 판단하면 로컬 HTTPS에서 __Host- 쿠키 문제가 재발합니다.
커스텀 서버보다 내장 기능을 사용하세요
# ✅ Next.js 내장 HTTPS
next dev --experimental-https --experimental-https-key ... --experimental-https-cert ...
# ❌ 커스텀 Node.js HTTPS 서버
node server.js # → history.replaceState 충돌
auth.ts와 middleware.ts의 쿠키 설정을 동기화하세요
// ✅ 동일한 로직 사용
// auth.ts: useSecureCookies = process.env.NODE_ENV === 'production'
// middleware.ts: useSecureCookies = process.env.NODE_ENV === 'production'
// ❌ 각각 다른 로직
// auth.ts: useSecureCookies = false
// middleware.ts: secureCookie = request.nextUrl.protocol === 'https:'
12. FAQ
Q: mkcert 없이 --experimental-https만 사용하면 안 되나요?
next dev --experimental-https를 플래그만 사용하면 Next.js가 자동으로 mkcert를 호출하여 인증서를 생성합니다. 하지만 이 경우 매번 새 인증서가 생성될 수 있어서, OAuth 제공자에 등록한 도메인과 일치하지 않을 수 있습니다. 직접 인증서를 관리하는 것이 안정적입니다.
Q: __Host-와 __Secure- 쿠키의 차이는 무엇인가요?
__Secure- 프리픽스는 HTTPS + Secure 플래그를 요구합니다. __Host- 프리픽스는 여기에 더해 Path=/이고 Domain 속성이 없어야 합니다. __Host-가 더 엄격하며, 주로 CSRF 토큰에 사용됩니다.
Q: 프로덕션에서도 useSecureCookies: false로 하면 안 되나요?
절대 안 됩니다. 프로덕션에서 useSecureCookies: false를 설정하면 세션 쿠키가 HTTP로도 전송되어 중간자 공격(MITM)에 취약해집니다. 반드시 프로덕션에서는 true로 설정하세요.
Q: Chrome에서 "이 연결은 비공개가 아닙니다" 경고가 뜨면?
mkcert -install이 정상적으로 실행되었는지 확인하세요. Chrome을 완전히 종료 후 재시작하면 해결되는 경우가 많습니다. 그래도 안 되면 chrome://flags/#allow-insecure-localhost를 활성화하는 방법도 있습니다.
Q: middleware에서 getToken의 secret을 왜 명시해야 하나요?
Next.js 16의 middleware는 Edge Runtime에서 실행되며, process.env가 제한적으로 노출됩니다. NextAuth의 getToken이 AUTH_SECRET 환경변수를 자동으로 찾지 못할 수 있으므로, 명시적으로 전달하는 것이 안전합니다.
13. 참고 자료
- Next.js CLI: HTTPS 개발 서버 설정 —
--experimental-https공식 문서 - mkcert GitHub — 로컬 개발용 신뢰 인증서 도구
- Auth.js 배포 가이드 — NextAuth v5 배포 설정
- NextAuth 쿠키 프리픽스 이슈 #8702 —
__Secure-프리픽스 관련 GitHub 이슈 - Vercel: Next.js HTTPS 로컬 개발 — Vercel 공식 가이드