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 로그인이 성공하지만 세션이 유지되지 않습니다:

  1. GitHub/Google "로그인" 클릭
  2. OAuth 인증 성공 (GitHub/Google에서 코드 수신)
  3. 콜백 처리 완료 (서버 로그에서 사용자 정보 확인)
  4. 그런데 로그인 페이지로 다시 돌아옴

/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 }

signInjwt 콜백 모두 정상 호출됩니다. 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_URLhttps://로 변경
  • [ ] 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에서 secureCookiecookieName 동기화

프론트엔드

  • [ ] <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의 getTokenAUTH_SECRET 환경변수를 자동으로 찾지 못할 수 있으므로, 명시적으로 전달하는 것이 안전합니다.

13. 참고 자료