JWT(JSON Web Token)란?

JWT는 로그인이 성공한 이후에 "이 사람은 인증된 사람이다"라는 것을 증명하기 위해 서버가 발행해주는 '출입증'입니다.
서버가 로그인 정보를 암호화해서 티켓(Token)으로 만들어 사용자에게 줍니다. 로그인 정보 자체를 암호화해서 사용자에게 줍니다. 서버는 장부를 확인할 필요 없이 도장만 확인하면 됩니다.
만약 사용자가 로그인을 성공했다 하더라도 우리가 사용하는 웹 프로토콜(HTTP)은 기본적으로 무상태(Stateless)기 때문에 서버는 1초 전에 로그인을 시켰다는 사실을 즉시 까먹습니다. 그래서 서버는 로그인 직후 "이 사람은 인증된 사람이다"라는 증명서(JWT)를 써서 사용자에게 줍니다.
JWT 외에도 실무에서 병행하는 보안 기법들이 있습니다.
- OAuth 2.0: 직접 비밀번호를 관리하지 않고 구글, 카카오 등의 인증을 빌려 쓰는 방식입니다.
- 2FA (2단계 인증): 로그인 후 이메일이나 OTP로 한 번 더 인증하는 방식입니다.

쿠키(또는 저장소)에는 아이디/비번 대신 서버가 준 JWT(토큰)만 저장합니다. JWT가 서버가 써준 내용물(편지)이라면, 쿠키는 그 편지를 담아두는 편지봉투이자 전용 주머니라고 이해하면 쉽습니다.
한 번 로그인을 해서 쿠키에 JWT를 넣어두면, 이후에 "마이페이지 보여줘", "게시글 작성해줘"라고 요청할 때마다 여러분이 일일이 토큰을 꺼내지 않아도 브라우저가 알아서 쿠키를 동봉해 서버로 보냅니다.
다음으로 사용자의 비밀번호를 안전하게 보관하는 방법 중 하나인 해시에 대해 알아봅시다.

해시 함수는 어떤 길의 데이터(문장, 파일, 숫자 등)를 입력해도 항상 똑같은 길이의 불규칙한 문자열을 만들어내는 '마법의 상자'와 같습니다. 이 결과물을 해시값(Hash Value) 또는 다이제스트(Digest)라고 부릅니다.
DB에 비밀번호를 그대로(Plain Text) 저장하면, 만약 DB가 해킹당했을 때 모든 사용자의 비밀번호가 유출됩니다. 하지만 해시를 사용하면 보안이 강력해집니다.
해시의 3가지 핵심 특징
- 단방향성 (One-way): 결과값(해시)을 보고 원래의 입력값을 알아내는 것이 수학적으로 거의 불가능합니다. (비가역적)
- 결정론적 (Deterministic): 입력값이 완전히 같다면, 결과값도 항상 100% 일치합니다.
- 눈사태 효과 (Avalanche Effect): 입력값이 아주 조금만 달라져도(예: 마침표 하나 추가), 결과값은 완전히 판이하게 바뀝니다.
마지막으로 어떤 비밀번호를 만든 해시값이 매번 다른데 서버가 어떻게 알아볼까요? 그 비밀은 해시값의 구조에 있습니다.
Bcrypt가 생성한 문자열(예: $2b$12$K7...) 안에는 솔트(Salt) 정보가 포함되어 있습니다. 서버는 솔트 + 해시결과가 합쳐진 긴 문자열을 DB에 저장합니다. 로그인을 할 때 서버는 DB에서 꺼낸 해시값의 앞부분을 보고 당시에 사용했던 솔트를 알아냅니다.

보안을 고려한 로그인 방식을 구현하는 절차는 다음과 같습니다.
1. 자격 증명 전송 (Credential Transmission via HTTPS)
사용자가 아이디와 비밀번호를 입력하고 로그인 버튼을 누르면, 데이터는 서버의 특정 엔드포인트(예: POST /api/v1/login)로 전송됩니다.
- 보안 포인트: 애플리케이션 레벨에서 아이디와 비밀번호는 평문(Plaintext)으로 취급되지만, 네트워크 전송 단계에서는 HTTPS(TLS/SSL) 암호화 통로를 통해 보호됩니다. 이를 통해 중간자 공격(MITM)에 의한 데이터 탈취를 방지합니다.
2. 서버 사이드 비밀번호 검증 (Server-side Verification)
서버는 전달받은 자격 증명을 데이터베이스(DB)에 저장된 정보와 비교 대조합니다.
- 해시 조회: DB에는 보안을 위해 비밀번호 원문이 아닌, Bcrypt 등의 알고리즘으로 단방향 해시화된 값이 저장되어 있습니다. 서버는 사용자가 입력한 ID를 조건으로 해당 해시값을 조회합니다.
- 해시 대조: 서버는 bcrypt.verify(입력한_비밀번호, DB_저장_해시값) 함수를 사용합니다. 해시 함수는 동일한 입력에 대해 동일한 결과를 내놓는 특성을 이용하며, 솔팅(Salting) 기법이 적용되어 있어 레인보우 테이블 공격에 대비합니다. 대조 결과가 일치할 때만 다음 단계로 진행합니다.
3. JWT(JSON Web Token) 생성 및 발급 (Token Issuance)
사용자 검증이 완료되면 서버는 해당 사용자의 신원을 보증하는 JWT(Access Token)를 발행합니다.
- 구성: 토큰의 페이로드(Payload)에는 사용자의 식별 정보(ID, 병원명, 권한 등)가 포함됩니다.
- 서명(Signature): 서버만 알고 있는 Secret Key를 사용하여 토큰을 디지털 서명합니다. 이 서명은 나중에 토큰이 변조되지 않았음을 증명하는 핵심 수단이 됩니다.
- 응답: 서버는 생성된 토큰을 JSON 형태로 클라이언트에게 반환합니다. 이때 비밀번호와 같은 민감 정보는 응답 데이터에서 제외합니다.
4. 클라이언트 사이드 토큰 관리 (Client-side Storage)
클라이언트는 서버로부터 받은 JWT를 브라우저 내부에 보관합니다.
- 저장 위치: 일반적으로 LocalStorage 또는 Cookie(HttpOnly 옵션 권장)에 저장합니다.
- 보안 규칙: 브라우저 저장소에는 절대로 사용자의 비밀번호를 저장하지 않습니다. 오직 서버로부터 발급받은 '기간 제한형' 토큰만 보관하여, 유출 시 발생할 수 있는 피해 범위를 최소화합니다.
5. 토큰 기반 API 인증 (Stateless Authorization)
로그인 이후의 모든 API 요청은 발급받은 토큰을 통해 인증을 수행합니다.
- 헤더 동봉: 클라이언트는 HTTP 요청 헤더의 Authorization 필드에 Bearer <JWT> 형식을 갖추어 요청을 보냅니다.
- 서버 측 검증: 서버는 매 요청마다 DB를 조회하지 않습니다. 대신 전달받은 JWT의 서명(Signature)을 검증하여 해당 토큰이 서버에서 발급한 것인지, 위변조되지 않았는지만 확인합니다.
- 무상태성(Stateless): 서버는 사용자의 로그인 세션을 메모리에 유지할 필요가 없습니다. 이는 서버 확장(Scale-out) 시 별도의 세션 클러스터링 없이도 모든 서버 노드에서 동일한 인증 처리가 가능하게 합니다.
코드 구현
우선 /api/v1/login 으로 로그인 정보를 POST하는 login.py를 작성합니다.
아래의 코드는 FastAPI를 사용하여 로그인할 때 비밀번호를 검증하고, 보안이 강화된 JWT를 생성하여 쿠키에 저장하는 로그인 로직입니다.
import bcrypt
import os
from datetime import datetime, timedelta, timezone
from jose import jwt
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from database import get_db_connection
# 로그인 보안설정
SECRET_KEY = os.getenv("SECRET_KEY", "fallback_secret_for-dev")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
router = APIRouter(
prefix="/api/v1/login",
tags=["Login"]
)
@router.post("")
async def login(request: dict):
hpid = request.get("hpid")
password = request.get("password")
if not hpid or not password:
raise HTTPException(status_code=400, detail="HPID and Password are required")
connection = get_db_connection()
cursor = connection.cursor()
try:
sql = """
SELECT L.PASSWORD, M.HOSPITAL_NM, L.HPID
FROM HOSPITAL_LOGIN L
JOIN HOSPITAL_MASTER M ON L.HPID = M.HPID
WHERE L.HPID = :hpid
"""
cursor.execute(sql, hpid=hpid)
result = cursor.fetchone()
if not result:
raise HTTPException(status_code=401, detail="Invalid HPID or Password")
db_hashed_password = result[0].strip() # 공백 제거 필수!
hospital_name = result[1]
print(f"--- DEBUG START ---")
print(f"가져온 해시값: '{db_hashed_password}'") # 따옴표 사이에 공백이 있는지 확인
print(f"글자 수: {len(db_hashed_password)}") # 이 숫자가 정확히 60이어야 함
print(f"데이터 타입: {type(db_hashed_password)}")
print(f"--- DEBUG END ---")
# bcrypt로 비밀번호 검증 (입력받은 비번과 DB의 해시값을 대조)
if not bcrypt.checkpw(password.encode('utf-8'), db_hashed_password.encode('utf-8')):
raise HTTPException(status_code=401, detail="Invalid HPID or Password")
# 로그인 성공시 JWT 토큰 생성 (토큰에 담을 정보 : HPID, 병원명, 만료시간)
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
"sub": hpid,
"name": hospital_name,
"exp": expire
}
# 페이로드와 비밀키를 조합하여 디지털 서명된 토큰 발행
access_token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
content = {
"message": "Login successful",
"hospital_name": hospital_name
}
# 쿠리를 수동으로 설정
response = JSONResponse(content=content)
# 쿠키 설정
response.set_cookie(
key="access_token",
value=access_token,
httponly=True, # 브라우저 스크립트 접근 차단 (XSS 해킹 방지)
secure=True, # HTTPS 환경에서만 전송
samesite="Lax", # 타 사이트에서 쿠키 전송 제한
max_age=3600 # 쿠키 유효시간
)
return response
except Exception as e:
print(f"Login error: {e}")
raise HTTPException(status_code=500, detail="Internal Server Error")
finally:
cursor.close()
connection.close()
bcrypt는 가장 신뢰받는 비밀번호 해싱 라이브러리 중 하나입니다.
datetime은 로그인 토큰(JWT)이나 보안 쿠키의 만료 시간을 계산할 때 사용합니다.
jose는 python-jose 라이브러리로, 웹 표준 인증 방식인 JWT(JSON Web Token)를 다룹니다.
import bcrypt
import os
from datetime import datetime, timedelta, timezone
from jose import jwt
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from database import get_db_connection
os.getenv를 이용하여 SECRET_KEY를 외부 환경 변수에서 가져오도록 설계합니다.
사용자가 로그인에 성공하면, 이 코드는 SECRET_KEY를 사용해 토큰에 디지털 서명을 찍는 것입니다.
# SECRET_KEY: 서버만 알고 있는 암호 키. 토큰의 위조 여부를 확인하는 '인증 도장' 역할
SECRET_KEY = os.getenv("SECRET_KEY", "fallback_secret_for-dev")
ALGORITHM = "HS256" # JWT 서명에 사용할 알고리즘 (표준 방식)
ACCESS_TOKEN_EXPIRE_MINUTES = 60 # 토큰의 유효 기간 (1시간)
login 함수를 정의합니다. 인자로 요청의 request를 받는데 dict 형태인 아이디와 비밀번호를 받습니다.
각각 아이디(hpid, 병원 아이디)와 비밀번호를 hpid와 password 변수에 담습니다.
HTTPException을 통해 아이디와 비밀번호가 비어 있는지 가장 먼저 체크합니다.
@router.post("")
async def login(request: dict):
hpid = request.get("hpid") # 프론트엔드에서 보낸 아이디
password = request.get("password") # 프론트엔드에서 보낸 평문 비밀번호
if not hpid or not password:
raise HTTPException(status_code=400, detail="HPID and Password are required")
아이디와 패스워드 그리고 아이디에 일치하는 병원 이름을 쿼리 JOIN을 통해 가져옵니다.
sql = """
SELECT L.PASSWORD, M.HOSPITAL_NM, L.HPID
FROM HOSPITAL_LOGIN L
JOIN HOSPITAL_MASTER M ON L.HPID = M.HPID
WHERE L.HPID = :hpid
"""
cursor.execute(sql, hpid=hpid)
result = cursor.fetchone()
DB(특히 Oracle의 CHAR 타입)에서 가져온 문자열은 뒤에 의미 없는 공백이 붙어 있을 수 있습니다. Bcrypt는 단 하나의 공백만 추가되어도 다른 값으로 인식하기 때문에, .strip()으로 양 끝의 공백을 제거하여 순수한 해시값만 남깁니다.
db_hashed_password = result[0].strip() # 공백 제거 필수!
hospital_name = result[1] # 받아온 병원 이름 저장
bcrypt.checkpw( ) 함수는 사용자가 입력한 평문 비번(password)과 DB에 저장된 해시값(db_hashed_password)을 대조합니다. 인자에서 Bcrypt는 문자열이 아닌 바이트(Bytes) 데이터를 처리하기 때문에, 컴퓨터가 이해할 수 있는 0과 1의 형태로 변환해 주어야 합니다.
if not bcrypt.checkpw(password.encode('utf-8'), db_hashed_password.encode('utf-8')):
raise HTTPException(status_code=401, detail="Invalid HPID or Password")
토큰이 영원히 유효하면 해킹 위험이 크므로, 현재 시간으로부터 60분 뒤에 만료되도록 유통기한을 설정합니다.
payload는 토큰 안에 담을 정보 주머니입니다.
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
"sub": hpid, # 토큰의 주인(ID)
"name": hospital_name, # 화면에 표시할 이름
"exp": expire # 만료 시간 (1시간 뒤)
}
위에서 만든 정보 payload를 서버만 아는 비밀키(SECRET_KEY)로 꽁꽁 묶어서 암호화된 도장을 찍습니다. 이 결과물이 access_token 문자열입니다.
access_token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
단순한 딕셔너리 리턴이 아니라, 응답 객체를 직접 만듭니다. 그래야 아래에 나오는 set_cookie를 실행할 수 있기 때문입니다. 응답 객체를 만들어 그 속에 쿠키를 집어 넣습니다.
content = {
"message": "Login successful",
"hospital_name": hospital_name
}
response = JSONResponse(content=content)
인증 티켓인 access_token을 브라우저의 일반 저장소가 아닌 보안 쿠키에 넣습니다. httponly=True 덕분에 해커가 자바스크립트로 이 토큰을 훔쳐갈 수 없습니다.
response.set_cookie(
key="access_token",
value=access_token,
httponly=True, # 자바스크립트로 접근 불가 (XSS 공격 방어)
secure=True, # HTTPS 환경에서만 전송 (도청 방지)
samesite="Lax", # 다른 사이트에서 보낸 링크를 통한 가짜 요청 방어 (CSRF 방어)
max_age=3600 # 쿠키 수명 (1시간)
)
다음으로 시스템에 접근할 수 있도록 가입 가능한 병원 목록을 불러오고, 선택한 병원의 정보를 바탕으로 비밀번호를 안전하게 암호화하여 저장하는 회원가입 signup.py 코드입니다.
import bcrypt
from fastapi import APIRouter, HTTPException
from database import get_db_connection
router = APIRouter(
prefix="/api/v1/signup",
tags=["Signup"]
)
# 1. 프론트엔드 선택창을 위한 병원 목록 조회
@router.get("/list")
async def get_hospitals():
connection = get_db_connection()
cursor = connection.cursor()
try:
# 이미 가입된 병원은 제외한 목록
sql = """
SELECT HPID, HOSPITAL_NM
FROM HOSPITAL_MASTER
WHERE HPID NOT IN (SELECT HPID FROM HOSPITAL_LOGIN)
ORDER BY HOSPITAL_NM
"""
cursor.execute(sql)
hospitals = [{"hpid": row[0], "name": row[1]} for row in cursor.fetchall()]
return hospitals
finally:
cursor.close()
connection.close()
# 2. 회원가입 처리 (비밀번호 해싱 및 저장)
@router.post("")
async def signup(request: dict):
hpid = request.get("hpid")
password = request.get("password")
if not hpid or not password:
raise HTTPException(status_code=400, detail="HPID와 비밀번호를 모두 입력해주세요.")
# [보안] 비밀번호 해싱 (앞서 사용한 방식과 동일)
salt = bcrypt.gensalt()
hashed_password = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
connection = get_db_connection()
cursor = connection.cursor()
try:
# 중복 가입 체크
check_sql = "SELECT COUNT(*) FROM HOSPITAL_LOGIN WHERE HPID = :hpid"
cursor.execute(check_sql, hpid=hpid)
if cursor.fetchone()[0] > 0:
raise HTTPException(status_code=400, detail="이미 가입된 병원입니다.")
# 데이터 삽입
insert_sql = "INSERT INTO HOSPITAL_LOGIN (HPID, PASSWORD) VALUES (:hpid, :password)"
cursor.execute(insert_sql, hpid=hpid, password=hashed_password)
connection.commit() # INSERT 후에는 반드시 commit
return {"message": "회원가입이 완료되었습니다."}
except Exception as e:
connection.rollback()
print(f"Signup Error: {e}")
raise HTTPException(status_code=500, detail="서버 오류가 발생했습니다.")
finally:
cursor.close()
connection.close()
가장 먼저 필요한 라이브러리들을 import 해줍니다.
import bcrypt # 비밀번호를 안전하게 암호화(해싱)하기 위한 라이브러리
from fastapi import APIRouter, HTTPException # API 경로 설정 및 에러 처리를 위한 FastAPI 도구
from database import get_db_connection # 미리 정의된 데이터베이스 연결 함수 가
get 메서드를 활용해서 프론트엔드가 서버로부터 정보를 가져올 때 사용하는 표준 규약을 사용합니다.
async def는 파이썬의 비동기 기능으로써 DB 응답을 기다리는 동안 서버가 멈추지 않고 다른 일을 할 수 있게 합니다.
@router.get("/list") # HTTP GET 방식을 사용하여 데이터를 '조회'하겠다고 선언합니다.
async def get_hospitals(): # 비동기(async) 함수로 정의하여 여러 요청을 효율적으로 처리합니다.
connection = get_db_connection() # 데이터베이스에 접속하는 '통로'를 엽니다.
cursor는 실제 SQL 쿼리를 실행하고, 그 결과 데이터 위를 돌아다니며 한 줄씩 읽어오는 역할을 합니다.
cursor = connection.cursor() # SQL 명령어를 전달하고 결과를 받아올 '심부름꾼'을 만듭니다.
try: # 코드 실행 중 예외 상황이 발생해도 안전하게 처리하기 위한 블록입니다.
쿼리문을 작성합니다. 가입 시 사용자의 화면에 DB에 등록된 가입할 수 있는 병원 목록을 보여주는 기능을 위해 정보를 가져옵니다. WHERE 절을 통해 이미 가입된 병원은 목록에서 제외합니다.
sql = """
SELECT HPID, HOSPITAL_NM FROM HOSPITAL_MASTER
WHERE HPID NOT IN (SELECT HPID FROM HOSPITAL_LOGIN)
ORDER BY HOSPITAL_NM
"""
쿼리를 실행하고 실행한 결과를 fetchall( ) 로 가져옵니다.
가져오는 쿼리 결과인 row[0]은 병원 아이디인 HPID 이고 row[1]은 두 번째 값인 병원이름 HOSPITAL_NM입니다.
cursor.execute(sql) # 작성한 SQL을 DB에 전달하여 실행합니다.
hospitals = [{"hpid": row[0], "name": row[1]} for row in cursor.fetchall()]
return hospitals # 최종 결과를 JSON 형태로 사용자에게 전달합니다.
첫 번째 /signup/list 엔드포인트에서 아이디 정보를 받아왔다면 /signup 에서는 POST 방식으로 프론트엔드가 보낸 아이디와 비밀번호를 JSPN 데이터 딕셔너리 형태로 받습니다.
@router.post("") # 데이터를 서버에 '생성'하거나 저장할 때 쓰는 POST 방식을 선언합니다.
async def signup(request: dict): # 사용자로부터 HPID와 비밀번호가 담긴 딕셔너리를 받습니다.
hpid = request.get("hpid") # 전달받은 데이터에서 병원 식별자를 꺼냅니다.
password = request.get("password") # 전달받은 데이터에서 비밀번호를 꺼냅니다.
다음으로 보안의 핵심인 비밀번호를 암호화하는 코드입니다.
salt를 이용하여 동일한 비밀번호라도 DB에 저장되는 형태를 다르게 만들어, 해커의 사전 공격을 무력화합니다.
salt = bcrypt.gensalt() # 암호문을 매번 다르게 만들기 위한 무작위 '소금' 값을 생성합니다.
hashed_password = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
데이터의 무결성을 지키기 위해 다시 한 번 이미 가입한 정보가 있는지 조회하는 쿼리입니다.
check_sql = "SELECT COUNT(*) FROM HOSPITAL_LOGIN WHERE HPID = :hpid"
cursor.execute(check_sql, hpid=hpid) # 해당 HPID로 가입된 이력이 있는지 조회합니다.
if cursor.fetchone()[0] > 0: # 결과값이 0보다 크면 이미 가입된 병원입니다.
가입된 정보가 없다면 병원 로그인 정보 테이블에 새로운 행을 추가합니다.
insert_sql = "INSERT INTO HOSPITAL_LOGIN (HPID, PASSWORD) VALUES (:hpid, :password)"
cursor.execute(insert_sql, hpid=hpid, password=hashed_password) # 암호화된 비밀번호를 저장합니다.
connection.commit() # 이 명령을 내려야 비로소 데이터가 실제 하드디스크에 기록됩니다.
다음의 코드가 잘 구현되었다면 main.py에 라우터를 추가해주고 프론트엔드에서 회원가입을 진행합니다.
회원가입이 잘 진행되었다면 아래와 같이 DB에 회원가입 아이디와 해싱이 적용된 비밀번호가 잘 INSERT 됨을 확인할 수 있습니다.

'FastAPI' 카테고리의 다른 글
| [FastAPI] Redis를 이용한 검색 속도 향상/로그인 세션 관리 (0) | 2025.12.24 |
|---|---|
| [FastAPI] Nginx 사용하기 (0) | 2025.12.18 |
| [FastAPI] Azure Service Bus와 Function App을 활용한 데이터 전송 (2) (0) | 2025.12.18 |
| [FastAPI] Azure Service Bus와 Function App을 활용한 데이터 전송 (1) (0) | 2025.12.18 |
| [FastAPI] Naver API를 사용한 거리 및 최단경로 시간 탐색 (0) | 2025.12.17 |