기사 원문: The JSON Web Token Handbook: Learn to Use JWTs for Web Authentication
JWT는 JSON Web Token의 약자이며, 웹 개발을 하다 보면 정말 자주 보게 되는 용어입니다.
기본적으로 JWT는 두 주체 간에 특정한 정보(claim)를 안전하게 표현하고 전달하기 위한 JSON 기반의 개방형 표준입니다. 특히 마이크로서비스 아키텍처나 최신 인증 시스템에서 매우 널리 사용된다는 점이 인상적인 부분입니다.
이 글에서는 JWT가 실제로 무엇인지, 어떻게 구성되어 있는지, 그리고 웹 애플리케이션을 어떻게 안전하게 만드는지 자세히 살펴보겠습니다. 글을 다 읽고 나면 왜 개발자들이 매일같이 JWT를 사용하는지 이해할 수 있게 될 것입니다.
이 글에서 다룰 내용
- 사전 지식
- JWT란 무엇인가?
- 왜 토큰이 필요한가?
- JWT의 구조: 헤더, 페이로드, 시그니처
- 예제: JWT 디코딩하기
- JWT가 보안을 보장하는 방식: 시그니처
- 보안 고려사항 및 토큰 관리 방법
- 다양한 언어에서 JWT 생성 방법
- 실전 구현: Express + MongoDB로 만드는 JWT 인증
- 요약
- 마지막 한마디
사전 지식
이 가이드를 따라가며 최대한 많은 것을 얻으려면 다음과 같은 내용이 준비되어 있으면 좋습니다:
- JavaScript / Node.js에 대한 기초적인 이해
- 로컬 환경에 설치된 Node.js와 npm
- HTTP와 REST API의 기본 개념
- JSON 구조와 역직렬화 / 직렬화 방법
- Express에 대한 기초 지식 (혹은 튜토리얼을 따라갈 수 있는 정도의 지식)
- 실행 중인 MongoDB (로컬 또는 원격)
- 비동기 코드 / Promise / Async–Await 사용 경험
- 환경 변수 및
.env설정에 대한 이해
이 글과 함께 볼 수 있는 동영상도 준비되어 있습니다. 텍스트와 함께 영상으로 학습하는 것을 좋아한다면 같이 참고해 보면 좋겠습니다.
JWT란 무엇인가?
JWT는 오늘날 가장 흔히 인증 용도로 사용되지만, 사실 원래 목적은 조금 달랐습니다. 처음에는 두 주체 간에 정보를 안전하게 교환하기 위한 표준 방식을 제공하기 위해 만들어졌습니다. 이를 위해 RFC 7519라는 산업 표준 사양이 정의되었으며, JWT가 어떻게 구조화되고, 어떤 방식으로 데이터 교환에 사용되어야 하는지 명시하였습니다. JavaScript를 위한 표준을 정의하는 ECMAScript (혹은 ES)와 비슷한 개념이라고 보면 되겠습니다.

실제 애플리케이션에서는 JWT가 주로 인증 용도로 사용되고 있고, 이 글에서도 그 관점에서 집중하여 설명할 것입니다.
하지만 기억해야 할 점은, JWT가 단순히 인증만을 위해 디자인된 것은 아니라는 것입니다. 인증을 구현하는 방법은 JWT 말고도 여러 가지가 있고, 그 중 가장 대표적인 대안이 바로 세션 토큰(session token)입니다.
왜 토큰이 필요한가?
우리가 어떤 인증 전략을 사용하든지 간에, 그게 세션 토큰이든 혹은 JWT든 간에, 그 바탕에는 토큰이 필요한 공통된 이유가 있습니다. 바로 HTTP 프로토콜이 상태를 유지하지 않는(stateless) 특성을 갖고 있기 때문입니다.
브라우저에서 서버로, 혹은 서버끼리 HTTP를 사용해 요청과 응답을 주고받을 때, HTTP 자체는 아무런 정보를 저장하지 않습니다.
Stateless라는 말은, 클라이언트와 서버가 상호작용할 때 HTTP가 이전 요청이나 데이터를 기억하지 않는다는 의미입니다. 다시 말해, 모든 요청은 필요한 정보를 스스로 담아서 보내야만 합니다. HTTP는 자체적으로 데이터를 저장하지 않습니다. 정보를 받으면 바로 “잊어버리는” 구조입니다. 그래서 HTTP를 stateless 프로토콜이라고 부릅니다.
이를 다른 식으로 생각해 보면 이렇습니다. 어떤 서버에 있는 웹페이지에 접속할 때, 우리는 서버에 어떤 정보를 보낼까요? 단순한 정적(static) 웹사이트라면, 사실 보내는 정보는 많지 않습니다. 그냥 페이지의 URL을 서버에 보내면, 서버는 그에 대응하는 HTML 페이지를 응답으로 돌려줍니다. 이 경우 서버는 별도의 정보를 기억할 필요가 없고, 상태를 유지할 필요도 없습니다. 이것이 바로 HTTP가 디자인된 방식이며, HTTP가 stateless 프로토콜인 이유입니다.

하지만 웹 애플리케이션이 유저마다 다른 응답을 제공하는 동적(dynamic) 웹사이트라면 이야기가 달라집니다. 이때는 URL만 보내서는 충분하지 않습니다. 유저는 URL과 함께 자신의 신원 정보(identity)도 서버에 전달해야 합니다.
예를 들어, 어떤 유저가 page-1에 접근하고 싶다면 서버에 이렇게 말해야 합니다. “저는 A 유저입니다. page-1을 보여 주세요.” 그러면 서버는 해당 유저를 위한 page-1을 응답으로 돌려줍니다. 이후 유저가 다시 “이번에는 page-2를 주세요.”라고 요청하면 어떻게 될까요? HTTP는 stateless이기 때문에, 이 요청에 유저의 신원 정보가 포함되어 있지 않으면, 서버는 누가 요청했는지 알 수 없고 어떤 응답을 보내야 할지 판단할 수 없습니다. 그래서 매 요청마다 유저는 자신의 신원 정보를 함께 보내야 합니다.
그런데 실제 우리가 사용하는 웹사이트들을 보면, 정말 매번 신원 정보를 보내고 있나요? 예를 들어 Facebook을 생각해 보겠습니다. 한 번 로그인을 하고 나면, 이후에는 다시 로그인 인증을 하지 않아도 홈 화면이나 프로필 페이지에 접근할 수 있습니다.
그렇다면 질문이 생깁니다. HTTP가 stateless 프로토콜이라면, 어떻게 이런 일이 가능한가요? 웹 애플리케이션은 우리의 브라우징 세션을 어떻게 기억하는 걸까요? 그 답은 웹 애플리케이션이 세션을 여러 방식으로 유지할 수 있기 때문입니다. 그중 가장 흔한 방법 중 하나가 토큰을 사용하는 방식입니다.

세션 토큰(Session Token): 고전적인 방식
토큰 방식에는 크게 두 가지 옵션이 있습니다. 하나는 세션 토큰이 있고, 다른 하나는 JSON Web Token (JWT)가 있습니다. 두 방식을 이해해야 JWT가 무엇인지, 왜 쓰이는지 더 분명해집니다.
어떤 회사의 고객센터를 예로 들어보겠습니다. 고객이 전화를 걸어 불만을 제기합니다. 고객센터 상담사가 제기된 문제를 듣고 여러 가지 해결 방법을 시도하지만, 바로 해결하지 못합니다.
이때 상담사는 상위 관리자 팀으로 이관하고, 고객에 대한 케이스 파일을 만듭니다. 이 파일에는 고객과의 대화 내용과 여태 시도한 해결 방법들이 기록됩니다. 그리고 고객에게 케이스 번호를 알려줍니다. 그렇게 함으로써 고객이 다음에 전화했을 때, 처음부터 모든 이야기를 반복할 필요가 없어집니다.

다음 날 고객이 다시 전화를 걸어 케이스 번호를 말하면, 상담사는 해당 번호를 가지고 이력과 정보를 확인한 뒤 바로 응대를 할 수 있습니다.

이 시나리오는 웹 애플리케이션에서 세션 토큰을 이용한 인증 방식과 유사합니다. 유저가 인증에 성공하면, 서버는 세션을 생성하고 이를 추적하기 시작합니다. 그리고 해당 세션을 식별하기 위한 세션 ID를 새로 생성해 유저에게 전달합니다. 이는 앞서 예시로 든 고객 케이스 번호와 유사합니다. 이후부터는 유저가 서버에 요청을 보낼 때마다, 세션 ID 또는 토큰이 함께 전달되고, 서버는 전달받은 ID를 기반으로 세션을 조회해 요청을 보낸 클라이언트를 식별합니다. 서버는 동시에 여러 클라이언트를 처리해야 하므로, 이러한 세션 토큰 방식은 인증을 구현하는 데 있어 효과적이고 널리 사용되는 전략으로 자리 잡았습니다.
클라이언트가 이 세션 ID를 서버로 어떻게 보내느냐는 구현에 따라 달라질 수 있지만, 가장 흔한 방식은 브라우저의 쿠키(cookie)에 세션 ID를 저장하는 것입니다. 이 방법의 장점은, 브라우저가 동일한 서버로 요청을 보낼 때마다 자동으로 쿠키 정보를 헤더에 실어 보내준다는 점입니다. 이는 브라우저의 기본 동작이기 때문에, 별도의 처리 없이도 자연스럽게 동작합니다.

유저가 인증에 성공하면 서버는 브라우저 쿠키에 세션 ID를 저장하고, 이후부터는 모든 요청에 쿠키가 자동으로 포함되어 서버가 유저를 식별할 수 있게 됩니다. 이 방식은 과거에 매우 널리 사용되었지만, 현대적인 애플리케이션 환경에서는 다소 구식이 된 측면도 있습니다.
이 방식에는 몇 가지 문제가 있습니다. 가장 큰 문제는 서버가 하나뿐이라는 전제를 바탕에 두고 있다는 점입니다. 요즘의 웹 애플리케이션은 보통 여러 대의 서버를 사용합니다. 이런 경우, 유저 앞단에는 로드 밸런서(load balancer)가 있고, 이 로드 밸런서가 어떤 서버에 요청을 보낼건지 결정합니다.
세션 토큰을 사용하는 상황을 가정해 보겠습니다. 유저가 첫 번째 요청을 보냈고, 로드 밸런서가 이 요청을 Server-1으로 보냅니다. Server-1은 세션 ID를 생성해 클라이언트에게 돌려줍니다. 이후 유저가 또 다른 요청을 보내고, 이번에는 로드 밸런서가 이 요청을 Server-2로 보냅니다. 그런데 Server-2에는 해당 세션 ID에 대한 정보가 없습니다. 그렇다면 이 요청이 누구의 것인지 어떻게 알 수 있을까요?
일반적인 해결책은 세션 ID를 각 서버가 아니라 공유된 Redis 데이터베이스에 저장하는 것입니다. 이렇게 하면 어느 서버든 Redis에서 세션 ID를 조회할 수 있습니다. 이를 흔히 Redis cache라고 부릅니다. 하지만 마이크로서비스 아키텍처에서는 이 방식에 허점이 있습니다. 만약 어떤 이유로 Redis cache가 다운된다면, 서버들은 정상적으로 작동하고 있더라도 인증 메커니즘이 실패할 수 있습니다. 이 지점에서 JSON Web Token이 다른 접근 방식을 제시합니다.

JWT: 현대적인 해결책
앞에서 살펴본 고객센터 예시로 다시 돌아가 보죠. 이번에는 전화나 시스템이 전혀 없다고 가정해 보겠습니다. 고객이 직접 회사 사무실로 찾아와 상담사와 대면으로 이야기합니다. 이번에는 상담사가 시스템에 정보를 저장할 수 없으니, 대신 종이에 모든 내용을 적은 뒤 그 종이를 고객에게 건네주며 이렇게 말합니다. “다음에 올 때 이걸 가져오세요.”
이 방식은 앞서 봤던 개념과 조금 다르죠? 하지만, 이 방식도 여전히 다른 문제가 있습니다. 바로 유효성(validity) 문제입니다. 만약 그 고객이 진짜 고객이 아니고 악의적인 의도를 품고 있다면, 상담사는 어떻게 그 고객을 신뢰할 수 있을까요? 다음 날 그 고객이 빈 종이에 같은 정보를 적어 들고 온다면, 상담사는 그 고객의 신원이 유효한지 어떻게 확인할 수 있을까요?
이때 가능한 해결 방법은, 상담사가 종이를 건네줄 때 직접 서명을 해 두는 것입니다. 그러면 고객이 다음에 그 종이를 가져왔을 때, 상담사는 그 서명을 확인함으로써 해당 종이를 신뢰할 수 있는지 판단할 수 있습니다.
JSON Web Token은 이와 유사한 방식으로 동작합니다. 여기서는 클라이언트가 인증되었을 때, 서버가 모든 정보를 저장하는 대신 유저의 정보를 서명(signature)과 함께 JSON 토큰 형태로 클라이언트에게 전달합니다. 이후 클라이언트는 다음 요청부터 매번 이 토큰을 함께 전송합니다. 이 토큰에는 이 유저가 어떤 유저인지, 이름이 무엇인지, 그리고 그 외 필요한 정보들이 포함되어 있습니다.
이 경우 서버는 아무것도 저장하지 않고, 모든 정보는 클라이언트에 유지됩니다. 클라이언트가 이 토큰과 함께 요청을 보낼 때마다, 서버는 토큰을 읽고 어떤 유저가 요청을 보냈는지 식별하고 필요한 데이터를 제공합니다.
이 토큰은 단순한 ID가 아닙니다. 모든 정보를 담고 있는 JSON 객체이며, 이것이 바로 JSON Web Token입니다. 이 JWT를 어디에 저장할지는 전적으로 클라이언트의 선택입니다. 가장 일반적인 방식은 브라우저의 쿠키나 로컬 스토리지(local storage)에 저장하는 것입니다.

JWT의 구조: 헤더, 페이로드, 시그니처
앞서 언급한 것 처럼, 서버는 JSON 객체를 전달받지만, JWT는 일반적인 JSON처럼 보이지는 않습니다.

위 이미지에서는 JWT가 다소 특이하게 보일 수 있습니다. 실제로 이것은 JSON 객체를 인코딩한 형태로, 일종의 복잡하게 압축된 표현 방식입니다. 자세히 살펴보면 JWT는 점(.)으로 구분된 세 부분으로 나뉘어 있다는 것을 알 수 있습니다. 첫 번째 부분은 헤더(header), 두 번째 부분은 본질적으로 데이터를 담고 있는 JSON 페이로드(payload), 그리고 세 번째 부분은 시그니처(signature)입니다.
각 부분을 개별적으로 살펴보면 다음과 같습니다.
- 헤더는 하나의 독립적인 JSON 객체입니다.
- 페이로드 역시 데이터를 포함하고 있는 별도의 JSON 객체입니다.
- 세 번째 부분은 시그니처입니다.
그렇다면 시그니처는 무엇일까요? 간단히 말해 토큰의 내용은 시크릿 키(secret key)와 함께 해시(hash)되는데, 그게 바로 시그니처입니다. 여기서 이 시크릿 키는 서버 내부에만 저장되고, JSON Web Token이 서버로 전달되면, 서버는 해당 시크릿 키를 사용해 시그니처가 여전히 유효한지, 중간에 변조되지는 않았는지 등을 확인할 수 있습니다.
번역자 코멘트: 해시(hash)는 데이터를 고정된 길이의 값으로 변환해 주는 계산 방식입니다. 입력 내용이 조금이라도 달라지면 결과 값은 완전히 달라지게 됩니다. 특히 JWT는 시크릿 키(secret key)와 함께 해시를 하기에, 결과 값을 뒤로 되돌려 원래의 데이터를 얻는 건 불가능에 가깝습니다. 누군가 중간에 JWT를 가로채 데이터를 변조했다면, 서버는 바로 알아챌 수 있습니다.
예제: JWT 디코딩하기
예제를 하나 살펴보겠습니다. JWT를 다루고 그 구조를 이해하기에 가장 좋은 웹사이트는 jwt.io입니다. JWT 하나를 이 사이트에 붙여 넣으면 헤더, 페이로드, 시그니처라는 세 개의 영역이 표시됩니다. 페이로드는 “Decoded Payload” 부분에 나타나며, 실제 내용과 데이터가 들어 있습니다. 이 안에는 ID, 이름을 담고 있는 JSON 객체, 그리고 만료일 등이 포함되어 있는 것을 확인할 수 있습니다.

"Decoded Header" 부분 역시 완전히 유효한 JSON 객체이며, JWT를 생성하거나 검증할 때 사용된 알고리즘과 토큰의 타입을 명시합니다.
주요 데이터는 “Decoded Payload” 부분에 들어 있습니다. 세 번째 부분은 시그니처입니다. 여기서 한 가지 중요한 점이 있습니다. 이처럼 복잡하게 섞여보이는 토큰이 어디에서 오는지 궁금할 수 있는데, 그건 사실 매우 단순합니다. “Decoded Payload”에 있는 데이터가 Base64로 인코딩되어 있으며, 이것이 바로 이처럼 난해하게 보이는 토큰의 형태를 만듭니다.
이 JWT의 해당 부분을 복사해 온라인에서 찾을 수 있는 Base64 디코더에 일단 붙여 넣어 보면, 즉시 원래의 데이터를 확인할 수 있습니다.

그렇다면 이것이 의미하는 바는 무엇일까요? 이는 이 데이터를 다시 Base64로 인코딩하면 동일한 토큰이 생성된다는 뜻입니다. 헤더 역시 동일한 방식으로 동작합니다.
마지막으로, 이 인코딩된 부분에 대해 짚고 넘어가야 할 점이 있습니다. 이것이 보안을 위한 것일까요? 그렇지 않습니다. 이는 오직 편의를 위한 것입니다. JSON 객체는 상당히 길어질 수 있고, 모든 프로그래밍 언어가 이를 동일한 방식으로 처리하지는 않습니다. JavaScript에서는 비교적 쉽지만, 다른 언어에서는 문제가 되는 경우도 있습니다. 그래서 데이터를 다루기 쉽게 만들기 위해 Base64 인코딩을 사용하는 것입니다. 이는 보안을 위한 것이 아니며, 이런 방식의 인코딩은 데이터를 안전하게 만들어 주지 않습니다. 정보는 여전히 공개적으로 확인할 수 있습니다.
위 그림에서 볼 수 있듯이, 이 Base64 디코더 사이트에 토큰을 입력하는 순간 데이터는 즉시 표시됩니다. 이는 여기에 민감한 정보를 저장해서는 안 된다는 의미입니다. 유저 ID와 같은 유저 식별 정보나 기타 공개 가능한 정보만 담아야 합니다. 비밀번호나 시크릿 키와 같은 정보는 쉽게 읽힐 수 있기 때문에 절대로 토큰에 저장해서는 안 됩니다. 겉보기에는 인코딩되어 보이지만, 실제로는 공개된 데이터입니다.
JWT가 보안을 보장하는 방식: 시그니처
이제 시그니처(signature)를 통해 보안에 대한 보장이 이루어지는 부분으로 넘어가 보겠습니다. 앞서 고객센터 종이 예시에서는 사람이 직접 손으로 서명을 추가할 수 있었습니다.
하지만 데이터의 경우, 시그니처를 생성하는 과정은 다릅니다. 데이터에서는 실제 서명 역할을 하는 시크릿 키를 사용해 암호학적으로 시그니처를 생성합니다. 시그니처를 만드는 과정은 다음과 같습니다:
- 데이터는 Base64로 인코딩됩니다.
- 인코딩된 데이터에 시크릿 키를 결합(concatenate) 합니다.
- 이를 다시 Base64로 인코딩합니다.
사용할 알고리즘은 변경할 수 있지만, 한 번 선택된 알고리즘은 토큰을 생성할 때와 검증할 때 동일하게 사용되어야 합니다. 생성과 검증 과정에서 서로 다른 알고리즘을 사용할 수는 없습니다.
마지막으로 데이터는 시크릿 키를 사용해 해시됩니다. 이 시크릿 키는 외부에 공개되지 않으며, 오직 서버에만 보관됩니다. 보통은 서버의 보안 저장소(server vault)에 안전하게 저장됩니다. 이 JWT가 서버에 도착하면, 서버는 해당 시크릿 키를 사용해 토큰이 유효한지 검증합니다. 만약 값이 올바르게 일치하지 않으면 “invalid signature”라는 결과가 표시됩니다. 이를 통해 서버는 토큰이 중간에 변조되었는지 여부를 확인할 수 있습니다.

예를 들어, 시그니처로 love-you-all-from-logicbaselabs를 사용하고 서버에서 이를 검증했을 때 “signature verified”라는 결과가 나온다면, 이는 시크릿 키가 서버에만 존재한다는 것을 보여줍니다. 즉, 공개된 정보가 포함되어 있더라도 토큰의 유효성은 검증될 수 있습니다.
다만 JSON Web Token은 비밀번호와 같은 개념은 아닙니다. JWT는 주로 유저를 식별하는 역할을 합니다. 서버는 JWT를 확인해 이것이 유효한 유저에게 속한 토큰인지 판단할 수 있습니다. 다시 말해 JWT는 유저의 신원을 나타내며, 시그니처와 함께 보안 요소를 포함하고 있는 매우 중요한 토큰입니다.

보안 고려사항 및 토큰 관리 방법
한 가지 반드시 기억해야 할 점이 있습니다. 누군가가 당신의 JWT를 그대로 손에 넣었다면, 즉 완전히 동일한 토큰을 가지고 있다면, 그 사람은 해당 유저로 쉽게 로그인할 수 있습니다. 그저 그 토큰을 포함해 요청을 보내기만 하면 필요한 접근 권한을 얻을 수 있습니다.
이를 이렇게 생각해 볼 수 있습니다. 누군가가 당신의 Facebook 비밀번호를 알아내면, 당신의 Facebook 계정에 그대로 로그인할 수 있습니다. 마찬가지로 누군가가 PayPal 계정의 PIN을 획득했다면, 그 계정에 쉽게 접근할 수 있습니다. 즉, 가장 중요한 보안 정보를 누군가가 손에 넣는 순간, 이를 보호할 방법은 사실상 없습니다.
JWT도 동일합니다. 토큰을 클라이언트 측에서 얼마나 안전하게 보관하느냐가 절대적으로 중요합니다. 이런 점에서 우리는 어느 정도 취약할 수밖에 없습니다.
다만 세션 토큰과의 가장 큰 차이점이 있는데, 세션 토큰의 경우, 계정이 침해되었다고 판단되면 서버에서 해당 세션을 무효화할 수 있습니다. 다시 말해, 그 세션 ID로는 더 이상 누구도 로그인할 수 없게 됩니다.
하지만 JWT의 경우에는 만료일이 지나기 전까지 토큰이 계속 유효합니다. 따라서 이를 즉시 무효화할 수 있는 직접적인 방법이 없습니다. JWT는 암호학적으로 자기완결적인(self-contained) 구조를 가지며, 서버의 시크릿 키로 서명되어 있기 때문에, 한 번 생성되면 서버에서 바로 폐기할 수 없습니다.
이를 처리하는 유일한 방법은 웹에서 일반적으로 사용하는 방식인 토큰 차단 목록(denylist)을 관리하는 것입니다. 즉, 서버는 차단된 JWT 토큰들을 별도의 데이터베이스에 저장해 관리합니다. 요청이 들어오면 서버는 먼저 토큰이 형식적으로 유효한지 검증하고, 그 다음 미들웨어를 통해 해당 토큰이 denylist에 포함되어 있는지 확인합니다. 이 목록에 포함되어 있지 않은 경우에만 유저의 접근이 허용됩니다.
이것이 JSON Web Token을 사용할 때 지켜야 할 기본적인 규칙들입니다. JWT는 어떤 프로그래밍 언어에서도 사용할 수 있으며, 특히 REST API 환경에서 매우 널리 활용됩니다. 또한 마이크로서비스 아키텍처에서도 매우 보편적이고 널리 퍼진 방식입니다.
다양한 언어에서 JWT 생성 방법
JWT를 생성하는 방법은 사용하는 프로그래밍 언어에 따라 달라집니다. 예를 들어 Node.js에서는 jsonwebtoken과 같은 전용 라이브러리가 제공되기 때문에 비교적 쉽게 JWT를 만들 수 있습니다. PHP 역시 JWT를 생성하기 위한 사용하기 쉬운 라이브러리들이 존재합니다. 이처럼 JWT는 특정 프로그래밍 언어에 국한된 기술이 아니라, 어디에서나 사용할 수 있는 범용적인 도구입니다. 많은 사람들이 JWT가 JavaScript 전용이라고 생각하지만, 이는 사실이 아닙니다.
또한 JWT는 인증 용도로만 사용되는 것이 아니라는 점도 기억해야 합니다. JWT는 어떠한 형태로든 신원을 표현하는 데 사용할 수 있습니다. 예를 들어 콘서트에 입장할 때 일반적인 종이 티켓 대신 JWT를 사용해 접근 권한을 부여할 수도 있습니다. 이때 클라이언트가 해당 JWT를 사용하면, 게이트웨이(gateway)나 서버는 토큰을 읽어 필요한 정보에 대한 접근을 허용하고, 시그니처를 통해 토큰의 유효성을 검증할 수 있습니다.
실전 구현: Express + MongoDB로 만드는 JWT 인증
이번에는 지금까지 배운 모든 개념을 실제로 적용해 보겠습니다. Express.js와 MongoDB를 사용해 JWT 인증 시스템을 단계별로 구축할 것입니다.
처음에는 다소 부담스럽게 느껴질 수도 있지만 차근차근 따라오면 문제없습니다. 한 단계씩 차근차근 진행할 것이며, 마지막에는 실제로 동작하는 프로젝트를 완성하게 될 것입니다. 건물에 층층이 들어가 보듯이, 각 구간을 충분히 살펴보면서 확실한 이해를 얻고 갈 것입니다.
1. 프로젝트 세팅 & 패키지 설치
코드를 작성하기 전에 먼저 Node.js 프로젝트를 세팅하고 필요한 패키지들을 설치해야 합니다.
Node.js 프로젝트 초기화
터미널을 열어 다음과 같이 실행해보세요:
mkdir jwt-auth-demo
cd jwt-auth-demo
npm init -y이 명령을 실행하면 기본 세팅이 된 package.json 파일이 생성됩니다.
패키지 설치
JWT 인증 시스템을 만들기 위해 다음 패키지들을 설치합니다.
npm install express mongoose bcryptjs jsonwebtoken dotenvexpress: API 를 만들기 위한 빠르고 최소한의 Node.js 웹 프레임워크입니다.mongoose: MongoDB를 더 쉽게 다루기 위한 ODM(Object Data Modeling) 라이브러리입니다.bcryptjs: 비밀번호를 해시하기 위한 라이브러리입니다.jsonwebtoken: JWT 토큰을 생성하고 검증하는 라이브러리입니다.dotenv:.env파일에 있는 환경 변수를 불러와서, 비밀 정보들을 안전하게 관리할 수 있게 해줍니다.
개발용 패키지 설치 (선택 사항)
개발을 편하게 하기 위해 파일이 변경될 때마다 서버를 자동으로 재시작해 주는 nodemon을 설치해 보세요.
npm install --save-dev nodemonpackage.json의 scripts를 다음과 같이 수정합니다.
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}npm start는 서버를 일반 모드로 실행합니다.npm run dev는 nodemon을 사용해 코드 변경 시 자동으로 서버를 재시작합니다.
2. 프로젝트 폴더 구조
jwt-auth-demo/
│
├── config/
│ └── db.js
│
├── controllers/
│ └── authController.js
│
├── middlewares/
│ └── authMiddleware.js
│
├── models/
│ └── User.js
│
├── routes/
│ └── auth.js
│
├── services/
│ ├── hashService.js
│ └── jwtService.js
│
├── .env
├── server.js
├── package.json각 디렉터리의 역할은 다음과 같습니다.
config/: 데이터베이스 연결 및 환경 설정 관련 코드controllers/: 각 엔드포인트(Endpoint)의 핵심 비즈니스 로직middlewares/: 컨트롤러 실행 전에 동작하는 미들웨어(예: 인증 체크)models/: Mongoose 스키마routes/: API 엔드포인트의 정의services/: 비밀번호 해시, JWT 생성/검증 같은 재사용 가능한 로직.env: 시크릿이나 환경 변수server.js: 애플리케이션의 엔트리 포인트
3. 단계별 구현하기
Express 서버 초기화
먼저 가장 기본이 되는 Express 서버를 설정합니다. 이 서버가 유저 회원가입, 로그인과 같은 요청을 받고 응답을 돌려주는 역할을 합니다.
파일: server.js
// server.js
// Import the express library to build our server
const express = require("express");
// Create an instance of express
const app = express();
// Middleware to parse JSON request bodies (important for APIs)
app.use(express.json());
// Default route to test server
app.get("/", (req, res) => {
res.send("Hello World! Your server is working 🚀");
});
// Start the server on port 5000
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});- Express를 불러와 app인스턴스를 생성합니다.
- JSON 형태의 요청을 역직렬화(parsing)하기 위해 미들웨어를 사용합니다.
- 서버가 잘 동작하는지 테스트하기 위해 간단한
/경로의 라우트를 만듭니다. - 마지막으로 5000번 포트에서 서버를 시작합니다.
이제 테스트를 해봅시다:
node server.js혹은npm run dev를 실행해 봅니다.- 브라우저를 열어 다음과 같은 주소로 가보세요.
http://localhost:5000. - 다음과 같은 메세지가 떠야 합니다:
Hello World! Your server is working 🚀
MongoDB를 Mongoose로 연결하기
이제 유저 정보를 저장할 데이터베이스가 필요합니다. 여기서는 MongoDB를 사용하고, Node.js에서 쉽게 접근하기 위해 ODM 라이브러리인 Mongoose를 사용합니다.
파일: config/db.js
// config/db.js
// Import mongoose
const mongoose = require("mongoose");
// Connect to MongoDB using environment variable
const connectDB = async () => {
try {
await mongoose.connect(process.env.MONGO_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
console.log("✅ MongoDB Connected");
} catch (err) {
console.error("❌ MongoDB Connection Error:", err.message);
process.exit(1); // Stop server if DB fails
}
};
module.exports = connectDB;이제 서버가 MongoDB와 연결될 준비가 되었습니다. 데이터를 삽입/수정/조회하는 모든 작업은 이 데이터베이스를 통해 이루어집니다.
File: .env
PORT=5000
MONGO_URI=mongodb://127.0.0.1:27017/jwt-auth-demo
JWT_SECRET=your_super_secret_key
.env파일에는 데이터베이스의 URI, JWT 시크릿 키, 서버 포트와 같은 민감한 정보가 저장됩니다. 환경 변수를 사용하면 이러한 비밀 정보를 코드에서 분리해 관리할 수 있고, 소스 파일을 수정하지 않고도 설정을 쉽게 변경할 수 있습니다. 자격 증명(credential)을 보호하기 위해 .env파일은 절대 공개 저장소에 기록(commit)해서는 안 됩니다.
User 모델 만들기
이제 데이터베이스 안에서 User가 어떤 구조를 가질지 정의해야 합니다. 각 유저는 name, email, password를 갖게 됩니다.
파일: models/User.js
// models/User.js
const mongoose = require("mongoose");
// Define a schema (blueprint of user data)
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
});
// Create and export the model
module.exports = mongoose.model("User", userSchema);이제 각 유저는 name, email, hashed password 필드를 갖는 구조로 저장됩니다.
해시(Hash) 및 JWT 서비스
이번에는 비밀번호 해시와 JWT 관리 로직을 각각 별도의 서비스 파일로 분리합니다. 이렇게 하면 코드가 깔끔해지고 재사용성이 높아집니다.
파일: services/hashService.js
//services/hashService.js
const bcrypt = require("bcryptjs");
// Function to hash a plain password
exports.hashPassword = async (plainPassword) => {
// bcrypt.hash generates a hashed version of the password
// The number 10 is the salt rounds, which affects the hashing complexity
return await bcrypt.hash(plainPassword, 10);
};
// Function to compare a plain password with a hashed password
exports.comparePassword = async (plainPassword, hashedPassword) => {
// bcrypt.compare checks if the plain password matches the hashed one
return await bcrypt.compare(plainPassword, hashedPassword);
};
hashPassword(plainPassword):비밀번호를 받아 bcrypt로 해시한 값을 반환합니다. 비밀번호는 절대 평문으로 저장하면 안 됩니다comparePassword(plainPassword, hashedPassword): 유저가 입력한 비밀번호와 데이터베이스에 저장된 해시 값을 비교해 일치 여부를 알려줍니다. 일치하면true를 반환합니다.
파일: services/jwtService.js
// services/jwtService.js
const jwt = require("jsonwebtoken");
// Function to generate a JWT
exports.generateToken = (payload) => {
// jwt.sign creates a signed token using our secret key from environment variables
// expiresIn defines how long the token is valid (1 hour here)
return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: "1h" });
};
// Function to verify a JWT
exports.verifyToken = (token) => {
// jwt.verify checks if the token is valid and not expired
return jwt.verify(token, process.env.JWT_SECRET);
};generateToken(payload): JWT를 생성합니다. 보통payload가 유저를 식별할 수 있는 ID나 이메일을 담고 있습니다.verifyToken(token): JWT의 유효성을 검증하고 디코딩된 payload를 반환합니다.- 이처럼 JWT 관련 로직을 한 곳에 모아 두면 관리와 유지보수가 훨씬 편해집니다.
인증 컨트롤러(Auth Controller)
이 단계에서는 인증과 관련된 모든 로직을 별도의 컨트롤러에서 처리합니다. 이렇게 하면 라우트 정의를 깔끔하게 유지할 수 있고, 비즈니스 로직을 엔드포인트 정의와 분리할 수 있습니다.
파일: controllers/authController.js
// controllers/authController.js
const User = require("../models/User");
const { hashPassword, comparePassword } = require("../services/hashService");
const { generateToken } = require("../services/jwtService");
// Register new user
exports.register = async (req, res) => {
try {
const { name, email, password } = req.body; // Get user input
// Step 1: Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser)
return res.status(400).json({ message: "User already exists!" });
// Step 2: Hash password using hashService
const hashedPassword = await hashPassword(password);
// Step 3: Save user to database
const user = new User({ name, email, password: hashedPassword });
await user.save();
// Step 4: Send success response
res.status(201).json({ message: "User registered successfully!" });
} catch (err) {
// Handle errors gracefully
res.status(500).json({ error: err.message });
}
};
// Login user
exports.login = async (req, res) => {
try {
const { email, password } = req.body; // Get user input
// Step 1: Find user by email
const user = await User.findOne({ email });
if (!user)
return res.status(400).json({ message: "Invalid email or password" });
// Step 2: Compare provided password with hashed password
const isMatch = await comparePassword(password, user.password);
if (!isMatch)
return res.status(400).json({ message: "Invalid email or password" });
// Step 3: Generate JWT using jwtService
const token = generateToken({ id: user._id, email: user.email });
// Step 4: Send success response with token
res.json({ message: "Login successful!", token });
} catch (err) {
res.status(500).json({ error: err.message });
}
};
// Protected profile route
exports.profile = (req, res) => {
// req.user is set by auth middleware after token verification
res.json({
message: "Welcome to your profile!",
user: req.user,
});
};- 파일:
controllers/authController.js은 인증 관련 로직을 담고 있습니다. exports.register는 회원가입 로직입니다. 이미 같은 이메일의 유저가 있는지 확인하고,hashService를 이용하여 비밀번호를 해시한 뒤, 새 유저를 MongoDB에 저장하고, 성공 메세지를 반환합니다.exports.login은 로그인 로직입니다. 이메일로 유저를 찾고,hashService.comparePassword를 이용하여 비밀번호를 비교 검증하고, 유효하다면 JWT를 생성해, 토큰을 반환합니다.exports.profile은 보호된(protected) 프로필 라우트입니다.req.user에 저장된 유저 정보를 응답으로 돌려줍니다.- 컨트롤러를 사용하면 라우트 정의와 실제 비즈니스 로직을 깔끔하게 분리할 수 있습니다.
인증 미들웨어(Auth Middleware)
이 단계에서는 JWT를 검증함으로써 라우트를 보호하는 미들웨어를 생성합니다. 인증된 유저만 보호된 엔드포인트에 접근할 수 있습니다.
파일: middlewares/authMiddleware.js
// middlewares/authMiddleware.js
const { verifyToken } = require("../services/jwtService");
// Middleware to protect routes
module.exports = (req, res, next) => {
// Step 1: Get Authorization header
const authHeader = req.headers["authorization"];
if (!authHeader)
return res.status(401).json({ message: "No token provided" });
// Step 2: Extract token from format 'Bearer <token>'
const token = authHeader.split(" ")[1];
if (!token) return res.status(401).json({ message: "Malformed token" });
try {
// Step 3: Verify token using jwtService
const decoded = verifyToken(token);
// Step 4: Attach decoded user info to request object
req.user = decoded;
// Proceed to next middleware or route handler
next();
} catch (err) {
// If token is invalid or expired
res.status(401).json({ message: "Invalid or expired token" });
}
};- 파일:
middlewares/authMiddleware.js은 라우트를 보호하기 위한 미들웨어입니다. - 1단계:
Authorization헤더가 존재하는지 확인합니다. - 2단계:
Bearer <token>형식에서 실제 토큰을 추출합니다. - 3단계:
jwtService.verifyToken을 사용해 토큰을 검증합니다. - 4단계: 디코딩된 유저 정보를
req.user에 추가해 이후 라우트 핸들러에서 사용할 수 있도록 합니다. - 토큰이 없거나, 형식이 올바르지 않거나, 유효하지 않거나, 만료된 경우 미들웨어는 401 Unauthorized 응답을 반환합니다. 이를 통해 인증된 유저만 보호된 라우트에 접근할 수 있도록 보장합니다.
인증 라우트 (Auth Routes)
이 단계에서는 인증과 관련된 라우트를 정의하고, 이를 컨트롤러와 미들웨어에 연결합니다.
파일: routes/auth.js
// routes/auth.js
const express = require("express");
const router = express.Router();
const authController = require("../controllers/authController");
const authMiddleware = require("../middlewares/authMiddleware");
// Step 1: Register route
// Users send their name, email, and password to this endpoint
router.post("/register", authController.register);
// Step 2: Login route
// Users send email and password to receive JWT
router.post("/login", authController.login);
// Step 3: Protected profile route
// Only accessible to authenticated users with a valid JWT
router.get("/profile", authMiddleware, authController.profile);
module.exports = router;- 파일:
routes/auth.js- 인증 엔드포인트를 정의하는 핵심 파일입니다. router.post("/register", authController.register): 유저 회원가입을 처리합니다.router.post("/login", authController.login): 유저 로그인을 처리하고 JWT 토큰을 생성합니다.router.get("/profile", authMiddleware, authController.profile): JWT가 필요한 보호된 라우트입니다.authMiddleware를 통해 인증된 유저만 접근할 수 있도록 보장합니다.- 이처럼 라우트, 컨트롤러, 미들웨어를 함께 사용하면 애플리케이션 구조를 체계적으로 유지할 수 있고, 코드도 보다 전문적으로 관리할 수 있습니다.
메인 서버 파일
이 파일은 애플리케이션의 메인 엔트리 포인트입니다. 서버를 설정하고, 데이터베이스에 연결하며, 모든 라우트를 등록하는 역할을 합니다.
파일: server.js
// server.js
require("dotenv").config(); // Step 1: Load environment variables from .env
const express = require("express");
const connectDB = require("./config/db");
const app = express();
// Step 2: Connect to MongoDB
connectDB();
// Step 3: Middleware to parse JSON request bodies
app.use(express.json());
// Step 4: Mount auth routes
// All auth-related routes will start with /api/auth
app.use("/api/auth", require("./routes/auth"));
// Step 5: Default route to test server
app.get("/", (req, res) => {
res.send("Hello World! Your server is working 🚀");
});
// Step 6: Start server on PORT from .env or default 5000
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});- 환경 변수 불러오기:
dotenv를 사용해 비밀 정보와 설정 값을 코드와 분리해 관리합니다. - MongoDB 연결:
config/db.js에 정의된connectDB()를 호출해 데이터베이스에 연결합니다. - 미들웨어:
express.json()을 사용해 Express가 JSON 요청을 파싱할 수 있도록 합니다. - 라우트 등록:
app.use("/api/auth", ...)를 통해 모든 인증 관련 라우트를 등록합니다. - 기본 라우트: 서버가 정상적으로 실행 중인지 확인하기 위한 간단한 GET 엔드포인트입니다.
- 서버 시작:
app.listen을 호출해 설정된 포트에서 서버를 실행합니다.
API 테스트 하기
이번에는 Postman이나 기타 HTTP 클라이언트와 같은 도구를 사용해 JWT 인증 API를 테스트하는 방법을 살펴봅니다.
테스트를 시작하기 전에 서버가 실행 중인지 반드시 확인해야 합니다. 서버가 실행되고 있지 않다면, 터미널을 열고 다음 명령을 실행합니다.
npm run dev혹은
node server.js이 명령을 실행하면 .env 파일에 정의된 포트(기본값은 5000)에서 서버가 시작됩니다.
MongoDB가 실행 중인지도 확인해 보세요. 로컬 MongoDB를 사용하고 있다면 다음 명령으로 실행할 수 있습니다:
mongod
로컬 MongoDB를 사용하지 않는 경우에는, 연결된 MongoDB 서비스가 정상적으로 활성화되어 있는지 확인합니다.
항상 터미널에 오류 메시지가 있는지 확인해 보세요. 서버나 데이터베이스가 정상적으로 시작되지 않으면 API 요청은 동작하지 않습니다.
회원가입 하기
요청:
POST http://localhost:5000/api/auth/register
Content-Type: application/json
{
"name": "sumit",
"email": "sumit@example.com",
"password": "mypassword"
}응답:
{
"message": "User registered successfully!"
}이 요청은 유저 정보를 포함해 http://localhost:5000/api/auth/register로 POST 요청을 전송합니다. 요청이 성공하면 확인 메시지를 응답으로 받게 됩니다.
로그인 하기
요청:
POST http://localhost:5000/api/auth/login
Content-Type: application/json
{
"email": "sumit@example.com",
"password": "mypassword"
}응답:
{
"message": "Login successful!",
"token": "<JWT_TOKEN>"
}이 요청은 이메일과 비밀번호를 포함해 http://localhost:5000/api/auth/login으로 POST 요청을 전송합니다. 자격 증명이 올바른 경우, 보호된 라우트에 접근하기 위한 JWT를 응답으로 받게 됩니다.
보호된 라우트에 접근하기
요청:
GET http://localhost:5000/api/auth/profile
Authorization: Bearer <JWT_TOKEN>응답:
{
"message": "Welcome to your profile!",
"user": {
"id": "...",
"email": "sumit@example.com",
"iat": ...,
"exp": ...
}
}이 요청은 Bearer 방식으로 Authorization 헤더에 JWT를 포함하여 전송합니다.
- 유효한 토큰만 이 보호된 라우트에 접근할 수 있습니다.
iat와exp는 각각 토큰이 발급된 시점(issued at)과 만료 시점(expiry time)을 의미합니다.
참고: 보호된 라우트에 접근할 때는 항상 Authorization: Bearer <token> 헤더를 포함해야 합니다.
요약(Summary)
이 글에서는 JSON Web Token(JWT)이 무엇인지와 웹 인증에서 어떤 역할을 하는지를 전반적으로 살펴보았습니다. HTTP가 상태를 유지하지 않는(stateless) 프로토콜이라는 점과 그로 인해 토큰이 필요한 이유를 설명했으며, 전통적인 세션 토큰 방식과 JWT를 비교했습니다.
또한 JWT의 구조와 보안 메커니즘을 다루고, Node.js, Express, MongoDB를 사용한 실제 구현 방법을 단계별로 살펴보았습니다. 이와 함께 보안 고려사항, 토큰 관리 방식, 그리고 JWT 인증 API를 테스트하는 방법도 함께 설명했습니다.
핵심 포인트 정리:
- JWT란 무엇인가?
- JWT는 RFC 7519에 정의된, 두 주체 간의 정보를 안전하게 표현하기 위한 JSON 기반 개방형 표준입니다.
- 현대적인 웹 애플리케이션과 마이크로서비스 아키텍처에서 인증 용도로 널리 사용됩니다.
- 세션 토큰의 대안으로 활용됩니다.
2. HTTP의 Stateless 특성
- HTTP는 요청 간 상태를 유지하지 않기 때문에, 각 요청은 필요한 모든 정보를 포함해야 합니다.
- 동적인 웹 애플리케이션에서는 유저 세션을 유지하기 위해 세션 토큰이나 JWT와 같은 토큰을 사용합니다.
3. 세션 토큰
- 서버가 세션 ID를 생성해 저장하고, 보통 쿠키를 통해 클라이언트에 전달하는 방식입니다.
- 단일 서버 환경에서는 효과적으로 동작하지만, 여러 서버 환경에서는 Redis와 같은 공유된 저장소가 필요합니다.
- 해당 캐시가 다운되면 인증 시스템이 영향을 받을 수 있습니다.
4. JWT: 현대적인 해결책
- 서버는 서명된 JSON 토큰을 클라이언트에 전달하고, 클라이언트는 이를 매 요청마다 전송합니다.
- 서버 측에 별도의 세션 저장소가 필요 없으며, 모든 유저 정보는 토큰 안에 포함됩니다.
- 시그니처를 통해 토큰의 유효성과 무결성이 보장됩니다.
5. JWT 구조
- JWT는 헤더, 페이로드, 시그니처 세 부분으로 구성됩니다.
- 헤더와 페이로드는 Base64로 인코딩된 JSON 객체이며, 시그니처는 시크릿 키를 사용한 해시 값입니다.
- Base64 인코딩은 보안을 위한 것이 아니라, 데이터를 다루기 쉽게 하기 위한 표현 방식입니다.
6. JWT 디코딩
- jwt.io와 같은 도구를 사용하면 JWT의 헤더, 페이로드, 시그니처를 쉽게 확인할 수 있습니다.
- 페이로드는 누구나 읽을 수 있으므로, 민감한 정보는 JWT에 저장해서는 안 됩니다.
7. JWT 보안
- 시그니처는 시크릿 키와 암호학적 알고리즘을 사용해 생성됩니다.
- 서버는 시크릿 키를 이용해 토큰의 무결성을 검증합니다.
- JWT는 유저를 식별하기 위한 수단이며, 비밀번호와 같은 역할을 하지는 않습니다.
8. 보안 고려사항 및 토큰 관리
- JWT가 탈취되면, 만료 시점까지 공격자가 해당 유저로 위장할 수 있습니다.
- JWT는 직접적인 무효화가 어렵기 때문에, 차단 목록을 통해 관리합니다.
- 세션 토큰은 서버에서 직접 무효화할 수 있다는 차이가 있습니다.
9. 다양한 언어에서의 JWT
- JWT는 언어에 독립적인 표준으로, Node.js, PHP 등 다양한 언어에서 구현할 수 있습니다.
- 인증뿐 아니라 다양한 형태의 신원을 표현하는 데도 활용할 수 있습니다.
10. 실전 구현: Express + MongoDB로 JWT 인증 만들기
- 프로젝트 세팅 및 패키지 설치
- 폴더 구조 설계
- Express 서버 초기화
- MongoDB 연결
- 유저 모델 생성
- 비밀번호 해시 및 JWT 서비스 구현
- 인증 컨트롤러와 미들웨어 작성
- 인증 라우트 구성
- 메인 서버 파일 구성
- API 테스트 방법 안내
11. API 테스트
- Postman과 같은 도구를 사용해 회원가입, 로그인, 보호된 라우트 접근 방법을 설명했습니다.
- 요청 및 응답 예시를 통해 전체 흐름을 확인할 수 있습니다.
12. 요약
- JWT는 안전하고, stateless하며, 널리 사용되는 인증 방식입니다.
- 다만 보안은 토큰을 얼마나 안전하게 보관하고 관리하느냐에 크게 좌우된다는 점을 항상 염두에 두어야 합니다.
마지막 한마디
이 튜토리얼에서 사용된 모든 소스 코드는 해당 GitHub 저장소에서 확인할 수 있습니다. 내용이 조금이라도 도움이 되었다면, 별을 눌러 응원의 뜻을 전해 주셔도 좋습니다.
또한 이 글이 유익하다고 느껴지셨다면, 도움이 될 만한 다른 분들과 자유롭게 공유해 주세요. 의견이나 피드백이 있다면 X에서 @sumit_analyzen, Facebook의 @sumit.analyzen으로 저를 언급해 주시거나, 코딩 튜토리얼을 시청하거나 LinkedIn에서 연결해 주셔도 감사하겠습니다.