7강. 비밀번호를 해시로 저장하고 로그인 검증하기

0. 학습 목표
→ 비밀번호를 평문으로 저장하지 않고, 해시값으로 저장한 뒤 로그인할 때 안전하게 비교하는 구조를 학습합니다.
이번 글에서 다룰 내용
이번 글에서는 로그인 회원 정보 중 비밀번호를 그대로 저장하지 않는 방법을 학습합니다.
6강에서는 회원 정보를 member.json 파일에 저장하고 다시 불러오는 구조를 만들었습니다.
하지만 비밀번호가 1234처럼 그대로 파일에 저장되면 누구나 파일을 열어 비밀번호를 확인할 수 있습니다.
그래서 이번 강의에서는 비밀번호를 Hash해시 값으로 바꾼 뒤 저장합니다.
| 구분 | 내용 |
| 핵심 개념 | 비밀번호를 평문으로 저장하지 않고 해시값으로 저장합니다. |
| 실습 준비 | Python의 hashlib 모듈, 기존 member.json 저장 구조, PySide6 로그인 화면 |
| 최종 목표 | member.json에는 pw 대신 password_hash를 저장하고, 로그인할 때 입력 비밀번호를 해시로 바꿔 비교합니다. |

이번 단계의 핵심: 비밀번호는 다시 꺼내서 보여주는 값이 아니라, 입력값과 비교하기 위한 해시값으로 저장해야 합니다.
1. 비밀번호를 그대로 저장하면 왜 위험할까?
→ member.json 파일에 비밀번호가 그대로 보이는 문제를 확인합니다.
1.1 6강에서 만든 member.json 구조
6강에서는 회원 정보를 member.json 파일에 저장했습니다.
그런데 현재 구조에서는 비밀번호가 아래처럼 그대로 보일 수 있습니다.
# 기존 member.json
{
"id": "test",
"pw": "1234",
"name": "홍길동",
"email": "test@test.com",
"phone": "010-1234-5678"
}
이렇게 저장된 비밀번호를 평문 비밀번호라고 합니다.
평문은 사람이 바로 읽을 수 있는 원래 문장이나 값을 의미합니다.
| 저장 방식 | 문제점 |
| "pw": "1234" | 파일을 열면 비밀번호가 그대로 보입니다. |
| 코드 안에 비밀번호 작성 | 코드를 보는 사람이 비밀번호를 알 수 있습니다. |
| 파일에 비밀번호 그대로 저장 | 파일이 노출되면 비밀번호도 함께 노출됩니다. |
1.2 암호화와 해시의 차이
비밀번호를 안전하게 저장한다고 할 때 흔히 “암호화”라는 말을 사용합니다.
하지만 로그인 비밀번호 저장에서는 보통 Encryption암호화보다 해시 방식을 사용합니다.
| 구분 | 설명 |
| 암호화 | 비밀번호를 암호문으로 바꾸고, 필요하면 다시 원래 비밀번호로 복호화할 수 있습니다. |
| 해시 | 비밀번호를 해시값으로 바꾸고, 원래 비밀번호로 되돌리기 어렵습니다. |
# 암호화 개념
비밀번호
↓
암호문
↓
다시 원래 비밀번호로 복호화 가능
# 해시 개념
비밀번호
↓
해시값
↓
원래 비밀번호로 되돌리기 어려움
1.3 이번 강의에서 바꿀 저장 구조
이번 강의에서는 member.json에 pw를 그대로 저장하지 않습니다.
대신 password_hash라는 이름으로 해시값을 저장합니다.
# 새 member.json 구조 예시
{
"id": "test",
"password_hash": "03ac674216f3e15c...",
"name": "홍길동",
"email": "test@test.com",
"phone": "010-1234-5678"
}
로그인할 때는 사용자가 입력한 비밀번호를 다시 해시값으로 바꿉니다.
그리고 저장된 password_hash와 비교합니다.
문제의 핵심: 비밀번호 원문을 저장하지 않고, 비밀번호를 변환한 해시값만 저장해야 합니다.
2. 비밀번호를 해시로 바꾸는 함수 만들기
→ hashlib를 사용해 입력 비밀번호를 해시 문자열로 변환합니다.
2.1 프로젝트 구조 수정하기
6강에서 만든 파일 저장 구조를 그대로 사용합니다.
이번 강의에서는 비밀번호 해시 처리를 담당하는 password_utils.py 파일만 추가합니다.
# 프로젝트 구조
tab_login_file_storage/
├── main.py
├── member_model.py
├── member_storage.py
├── password_utils.py
└── tab_window.py
| 파일 | 역할 |
| password_utils.py | 비밀번호를 해시값으로 바꾸는 함수를 담당합니다. |
| member_storage.py | member.json 파일 저장과 불러오기를 담당합니다. |
| member_model.py | 로그인 검사와 프로필 수정을 담당합니다. |
| tab_window.py | 화면과 버튼 동작을 담당합니다. |
2.2 password_utils.py 작성하기
먼저 비밀번호를 해시값으로 바꾸는 함수를 만듭니다.
Python의 hashlib 모듈을 사용하면 문자열을 해시값으로 바꿀 수 있습니다.
import hashlib
def hash_password(password):
"""입력한 비밀번호를 SHA-256 해시 문자열로 변환합니다."""
return hashlib.sha256(password.encode("utf-8")).hexdigest()
이 함수는 사용자가 입력한 비밀번호를 UTF-8 바이트로 바꾼 뒤 SHA-256 해시값으로 변환합니다.
마지막의 hexdigest()는 해시 결과를 사람이 읽을 수 있는 16진수 문자열로 바꿉니다.
| 코드 | 역할 |
| password.encode("utf-8") | 문자열 비밀번호를 바이트 데이터로 바꿉니다. |
| hashlib.sha256(...) | SHA-256 해시 계산을 수행합니다. |
| hexdigest() | 해시 결과를 16진수 문자열로 반환합니다. |
2.3 해시 결과 확인하기
예를 들어 비밀번호가 1234라면 아래처럼 해시값으로 바뀝니다.
from password_utils import hash_password
print(hash_password("1234"))
실행 결과는 아래처럼 긴 문자열입니다.
03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4
이제 member.json에는 1234를 그대로 저장하지 않고, 위와 같은 해시 문자열을 저장할 수 있습니다.
핵심 확인: 같은 비밀번호는 같은 해시값으로 바뀝니다. 그래서 로그인할 때 입력한 비밀번호를 다시 해시로 바꿔 저장된 해시값과 비교할 수 있습니다.
3. member.json에 password_hash 저장하기
→ 기존 pw 저장 구조를 password_hash 저장 구조로 변경합니다.
3.1 member_storage.py 수정하기
이제 기본 회원 정보를 만들 때 pw 대신 password_hash를 저장하도록 바꿉니다.
또한 6강에서 만든 member.json 파일에 pw가 남아 있을 수도 있으므로, 기존 pw 값을 password_hash로 바꿔주는 처리도 함께 넣습니다.
import json
from PySide6.QtCore import QFile, QIODevice, QTextStream
from password_utils import hash_password
class MemberStorage:
"""회원 정보를 JSON 파일에 저장하고 불러오는 클래스"""
def __init__(self, file_path="member.json"):
self.file_path = file_path
def default_member(self):
return {
"id": "test",
"password_hash": hash_password("1234"),
"name": "홍길동",
"email": "test@test.com",
"phone": "010-1234-5678",
}
def load_member(self):
if not QFile.exists(self.file_path):
member = self.default_member()
self.save_member(member)
return member
file = QFile(self.file_path)
if not file.open(QIODevice.OpenModeFlag.ReadOnly | QIODevice.OpenModeFlag.Text):
return self.default_member()
stream = QTextStream(file)
json_text = stream.readAll()
file.close()
try:
member = json.loads(json_text)
except json.JSONDecodeError:
member = self.default_member()
self.save_member(member)
return member
member = self.convert_plain_password(member)
self.save_member(member)
return member
def convert_plain_password(self, member):
if "pw" in member and "password_hash" not in member:
member["password_hash"] = hash_password(member["pw"])
del member["pw"]
return member
def save_member(self, member):
file = QFile(self.file_path)
if not file.open(QIODevice.OpenModeFlag.WriteOnly | QIODevice.OpenModeFlag.Text):
return False
stream = QTextStream(file)
stream << json.dumps(member, ensure_ascii=False, indent=4)
file.close()
return True
3.2 default_member() 변경 확인
기존에는 기본 회원 정보에 pw가 들어 있었습니다.
# 기존 방식
"pw": "1234"
이제는 password_hash에 해시값을 저장합니다.
# 새 방식
"password_hash": hash_password("1234")
따라서 member.json 파일에는 비밀번호 1234가 직접 저장되지 않습니다.
3.3 기존 pw를 password_hash로 바꾸는 이유
이미 6강을 실행했다면 프로젝트 폴더에 기존 member.json 파일이 있을 수 있습니다.
그 파일에는 아직 pw가 남아 있을 수 있습니다.
# 기존 파일에 남아 있을 수 있는 구조
{
"id": "test",
"pw": "1234",
"name": "홍길동",
"email": "test@test.com",
"phone": "010-1234-5678"
}
그래서 load_member()에서 파일을 읽은 뒤 convert_plain_password()를 호출합니다.
member = self.convert_plain_password(member)
self.save_member(member)
이 코드는 기존 pw 값을 password_hash로 바꾸고, 다시 member.json 파일에 저장합니다.
주의할 점: 기존 member.json 파일이 남아 있으면 새 코드만 작성해도 파일 내용이 바로 바뀌지 않을 수 있습니다. 그래서 기존 pw를 password_hash로 변환하는 처리를 넣어 줍니다.
4. 로그인할 때 해시값으로 비교하기
→ 입력한 비밀번호를 해시로 바꾼 뒤 저장된 password_hash와 비교합니다.
4.1 member_model.py 수정하기
이제 MemberModel의 로그인 검사 방식을 바꿉니다.
입력한 비밀번호를 그대로 비교하지 않고, 해시값으로 바꾼 뒤 비교합니다.
from member_storage import MemberStorage
from password_utils import hash_password
class MemberModel:
"""회원 정보 관리, 로그인 검사, 프로필 수정을 담당하는 클래스"""
def __init__(self):
self.storage = MemberStorage()
self.member = self.storage.load_member()
self.is_logged_in = False
def check_login(self, user_id, user_pw):
input_password_hash = hash_password(user_pw)
if user_id == self.member["id"] and input_password_hash == self.member["password_hash"]:
self.is_logged_in = True
return True
return False
def get_member_info(self):
if not self.is_logged_in:
return None
return {
"id": self.member["id"],
"name": self.member["name"],
"email": self.member["email"],
"phone": self.member["phone"],
}
def update_profile(self, name, email, phone):
if not self.is_logged_in:
return False
self.member["name"] = name
self.member["email"] = email
self.member["phone"] = phone
return self.storage.save_member(self.member)
4.2 check_login() 코드 흐름
로그인 검사는 아래 순서로 진행됩니다.
# 로그인 검증 흐름
사용자가 ID, PW 입력
↓
입력한 PW를 hash_password()로 해시 변환
↓
입력 ID와 저장 ID 비교
↓
입력 PW 해시값과 저장 password_hash 비교
↓
둘 다 같으면 로그인 성공
핵심 코드는 아래 부분입니다.
input_password_hash = hash_password(user_pw)
이 코드는 사용자가 입력한 비밀번호를 해시값으로 바꿉니다.
input_password_hash == self.member["password_hash"]
이 코드는 입력 비밀번호의 해시값과 파일에 저장된 해시값이 같은지 비교합니다.
4.3 tab_window.py는 거의 그대로 사용하기
화면 코드인 tab_window.py는 거의 수정하지 않아도 됩니다.
LoginView는 여전히 사용자가 입력한 ID와 PW를 MemberModel에 전달합니다.
if self.member_model.check_login(user_id, user_pw):
self.message_label.setText("로그인 성공!")
self.main_view.refresh()
self.profile_view.refresh()
self.tab_widget.setCurrentIndex(1)
else:
self.message_label.setText("로그인 실패! 아이디 또는 비밀번호를 확인하세요.")
LoginView는 비밀번호가 해시로 저장되는지 직접 알 필요가 없습니다.
비밀번호 해시 비교는 MemberModel이 담당합니다.
핵심 확인: View는 입력만 받고, 비밀번호를 어떻게 비교할지는 Model이 처리합니다.
5. 실행 결과 확인하기
→ member.json에 pw가 사라지고 password_hash가 저장되는지 확인합니다.
5.1 실행하기
터미널에서 프로젝트 폴더로 이동한 뒤 아래 명령어를 실행합니다.
python main.py
기존 member.json에 pw가 있었다면, 프로그램 실행 후 password_hash로 변환되어 다시 저장됩니다.
5.2 member.json 확인하기
프로그램 실행 후 member.json 파일을 열어 봅니다.
이제 pw가 없어지고 password_hash가 저장되어 있어야 합니다.
# 변경 후 member.json 예시
{
"id": "test",
"name": "홍길동",
"email": "test@test.com",
"phone": "010-1234-5678",
"password_hash": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4"
}
이제 파일을 열어도 원래 비밀번호인 1234는 보이지 않습니다.
5.3 로그인 테스트하기
로그인 화면에서 기존과 동일하게 입력합니다.
| 항목 | 입력값 |
| 아이디 | test |
| 비밀번호 | 1234 |
member.json에는 1234가 없지만 로그인은 성공해야 합니다.
왜냐하면 입력한 1234를 다시 해시로 바꾼 뒤, 저장된 password_hash와 비교하기 때문입니다.
# 로그인 성공 흐름
입력 비밀번호: 1234
↓
hash_password("1234")
↓
03ac674216f3e15c...
↓
member.json의 password_hash와 비교
↓
같으면 로그인 성공
5.4 확인 체크리스트
아래 항목을 직접 확인합니다.
| 확인 항목 | 완료 |
| password_utils.py 파일을 만들었는가? | □ |
| hash_password() 함수가 비밀번호를 해시 문자열로 바꾸는가? | □ |
| member.json에서 pw가 사라지고 password_hash가 저장되는가? | □ |
| 로그인할 때 입력 비밀번호를 해시로 바꿔 비교하는가? | □ |
| 비밀번호 1234로 정상 로그인되는가? | □ |
| 틀린 비밀번호를 입력하면 로그인에 실패하는가? | □ |
6. 평문 저장 방식과 해시 저장 방식 비교하기
→ 비밀번호를 그대로 저장하는 방식과 해시값으로 저장하는 방식을 비교하며 정리합니다.
6.1 기존 방식
기존 방식은 비밀번호를 그대로 저장하는 구조입니다.
# 기존 방식
member.json
└── "pw": "1234"
이 방식은 구현은 쉽지만 파일을 열면 비밀번호가 바로 보입니다.
6.2 새 방식
새 방식은 비밀번호를 해시값으로 바꿔 저장하는 구조입니다.
# 새 방식
입력 비밀번호
↓
hash_password()
↓
password_hash
↓
member.json에 저장
이 방식에서는 member.json을 열어도 원래 비밀번호가 바로 보이지 않습니다.
| 구분 | 평문 저장 방식 | 해시 저장 방식 |
| 저장 값 | "pw": "1234" | "password_hash": "03ac..." |
| 파일 확인 | 비밀번호가 그대로 보입니다. | 원래 비밀번호가 바로 보이지 않습니다. |
| 로그인 비교 | 입력 비밀번호와 저장 비밀번호를 직접 비교합니다. | 입력 비밀번호를 해시로 바꾼 뒤 password_hash와 비교합니다. |
| 학습 목적 | 처음 구조 이해에는 쉽습니다. | 더 안전한 저장 구조를 이해할 수 있습니다. |
6.3 실제 서비스에서는 더 안전한 방식이 필요합니다
이번 강의에서는 학습을 위해 SHA-256을 사용했습니다.
하지만 실제 서비스에서는 단순 SHA-256만으로 비밀번호를 저장하는 것은 충분하지 않습니다.
실제 서비스에서는 Salt솔트를 추가하고, 비밀번호 저장에 특화된 더 느린 해시 알고리즘을 사용하는 것이 일반적입니다.
| 구분 | 설명 |
| 이번 강의 | 개념 학습을 위해 hashlib.sha256()을 사용합니다. |
| 실제 서비스 | salt, bcrypt, scrypt, argon2 같은 비밀번호 저장 전용 방식을 사용하는 것이 좋습니다. |
6.4 최종 정리
이번 글에서는 비밀번호를 평문으로 저장하지 않고 해시값으로 저장하는 구조를 만들었습니다.
| 핵심 내용 | 정리 |
| 문제 파악 | member.json에 pw가 그대로 저장되면 비밀번호가 노출될 수 있습니다. |
| 문제 해결 | hash_password() 함수를 만들어 비밀번호를 해시값으로 바꿨습니다. |
| 저장 구조 변경 | pw 대신 password_hash를 member.json에 저장했습니다. |
| 로그인 검증 | 입력 비밀번호를 해시로 바꾼 뒤 저장된 password_hash와 비교했습니다. |
| 주의점 | 실제 서비스에서는 salt와 비밀번호 저장 전용 해시 알고리즘이 필요합니다. |
기억할 문장: 비밀번호는 그대로 저장하지 않고, 입력값을 해시로 바꿔 저장된 해시값과 비교해야 합니다.
6.4 프로젝트 파일
참고. 공식 문서로 확인하기
→ hashlib와 JSON 저장 방식의 자세한 내용은 공식 문서에서 확인합니다.
참고 문서
이번 글에서 사용한 hashlib, json 모듈은 아래 문서에서 자세히 확인할 수 있습니다.
참고: 이번 강의의 SHA-256 방식은 해시 개념을 이해하기 위한 학습용입니다. 실제 서비스에서는 salt와 bcrypt, scrypt, argon2 같은 비밀번호 저장 전용 방식을 검토해야 합니다.