티스토리 뷰
비밀번호를 그대로 저장하면 왜 위험할까? 해시, 검증, 인증의 첫 단추 제대로 끼우기 — FastAPI · Spring Boot · Node.js
octo54 2026. 4. 8. 17:04비밀번호를 그대로 저장하면 왜 위험할까? 해시, 검증, 인증의 첫 단추 제대로 끼우기 — FastAPI · Spring Boot · Node.js
여기까지 시리즈를 따라오면서 회원가입 구조, 서비스 계층, repository, 트랜잭션까지 왔잖아요.
이쯤 되면 진짜 자연스럽게 붙는 질문이 하나 있어요.
“비밀번호는 이제 어떻게 저장하지?”
이 질문이 은근히 무섭습니다.
왜냐하면 여기서 대충 넘어가면, 앞에서 그렇게 열심히 구조를 잘 나눠놔도 한 번에 보안 구멍이 생기거든요.
저도 아주 초반엔 이런 생각을 한 적이 있었어요.
- 그냥 DB에 저장하면 안 되나?
- SHA-256 한 번 돌리면 되는 거 아닌가?
- 어차피 내 서비스 작은데, 나중에 바꾸면 되지 않을까?
근데 이게 진짜 위험한 생각이더라고요.
비밀번호는 “그냥 저장하면 안 되는 데이터” 의 대표격입니다.
OWASP는 비밀번호는 거의 모든 경우에 암호화(encryption)가 아니라 해시(hash) 해야 한다고 설명합니다. 해시는 일방향이라 원문으로 되돌릴 수 없고, 비밀번호 검증에 더 적합하다고 안내합니다. 또한 최신 비밀번호 해시 알고리즘은 공격자가 훔친 해시를 오프라인으로 대입 공격할 때 최대한 느리게 만들도록 설계되어야 한다고 설명합니다. (OWASP Cheat Sheet Series)
이번 글에서는 이걸
FastAPI / Spring Boot / Node.js 각각으로 구현해보겠습니다.
그리고 이번 글에서는 일부러 아주 중요한 얘기를 먼저 할게요.
먼저 하나 바로잡고 갈 것
앞선 글들에서 설명용으로 SHA-256 예제를 썼는데,
그건 어디까지나 서비스/repository/트랜잭션 구조를 설명하기 위한 간단한 데모에 가까웠습니다.
실서비스 비밀번호 저장용으로는 그 방식으로 가면 안 됩니다.
OWASP는 비밀번호 저장에 대해 빠른 범용 해시가 아니라, 비밀번호 저장용으로 설계된 느리고 조정 가능한(adaptive) 알고리즘을 써야 한다고 설명합니다. 그리고 새 애플리케이션에서는 Argon2id를 우선 추천하고, bcrypt는 주로 레거시 환경에서 대안으로 언급합니다. (OWASP Cheat Sheet Series)
즉 오늘 글의 핵심은 이겁니다.
비밀번호는 sha256(password)로 저장하는 게 아니라,
비밀번호 전용 해시 라이브러리로 해시하고, 검증도 그 라이브러리로 해야 합니다.
왜 비밀번호를 그대로 저장하면 안 될까
이건 사실 설명이 길 필요도 없어요.
DB가 털리면 끝이니까요.
비밀번호를 평문으로 저장하면,
유출 순간 사용자의 실제 비밀번호가 그대로 노출됩니다.
그리고 대부분의 사용자는 여러 사이트에서 비밀번호를 재사용하죠.
그래서 피해가 우리 서비스 하나로 안 끝날 수도 있어요.
OWASP는 비밀번호를 저장할 때 원문이 아니라 해시를 사용해야 하며, 비밀번호를 되돌릴 필요가 있는 극히 예외적인 경우가 아니라면 암호화로 저장하는 구조도 피하라고 설명합니다. (OWASP Cheat Sheet Series)
그럼 SHA-256 같은 해시는 왜 안 될까
이 질문도 진짜 많이 나와요.
“해시잖아요. 일방향이잖아요. 그럼 괜찮은 거 아닌가요?”
겉으로는 맞는 말처럼 보이는데, 비밀번호 저장에선 부족합니다.
문제는 SHA-256 같은 일반 해시 함수는 너무 빠르다는 거예요.
빠른 해시는 파일 무결성 체크나 일반적인 해시 계산엔 좋지만, 비밀번호 저장에는 오히려 불리합니다. 공격자가 해시를 훔쳤을 때 엄청난 속도로 대입을 돌릴 수 있으니까요.
OWASP는 비밀번호 저장용 알고리즘은 의도적으로 느리고, 작업 강도(work factor)를 높일 수 있어야 한다고 설명합니다. Spring Security 문서도 bcrypt, Argon2, PBKDF2 같은 adaptive one-way function은 비밀번호 검증이 시스템 기준 약 1초 정도 걸리도록 튜닝하라고 설명합니다. (OWASP Cheat Sheet Series)
Node.js 공식 문서의 crypto.createHash() 예제는 일반적인 SHA-256 해시 사용법을 보여주는 것이지, 비밀번호 저장 전용 API가 아닙니다. 즉 쓸 수는 있지만, 비밀번호 저장 용도엔 적합한 선택이 아닙니다. (Node.js)
지금 기준으로 어떤 알고리즘을 써야 할까
가장 현실적으로 정리하면 이렇게 보면 됩니다.
1. 새 서비스면 Argon2id 우선
OWASP는 새 애플리케이션에 Argon2id를 우선 추천합니다. 최소 구성 예시도 제시하고 있고, GPU 공격과 메모리 기반 공격에 균형 있게 대응하는 변형이라고 설명합니다. (OWASP Cheat Sheet Series)
2. Argon2id가 어려우면 bcrypt
OWASP는 Argon2나 scrypt를 쓸 수 없을 때 레거시/호환성 측면에서 bcrypt를 언급하며, 최소 work factor 10 이상과 72바이트 입력 제한을 주의하라고 설명합니다. Spring Security도 BCryptPasswordEncoder 기본 구현이 strength 10을 사용한다고 안내합니다. (OWASP Cheat Sheet Series)
3. Spring은 PasswordEncoder 계층을 활용
Spring Security는 DelegatingPasswordEncoder를 통해 여러 인코더를 다룰 수 있고, bcrypt/argon2/scrypt/pbkdf2 등을 함께 매핑할 수 있습니다. 저장 포맷도 {id}encodedPassword 형태를 사용한다고 설명합니다. (Home)
4. FastAPI 쪽은 현재 공식 튜토리얼에서 pwdlib 사용
FastAPI 보안 튜토리얼은 pwdlib와 PasswordHash.recommended()를 사용하며, pwdlib[argon2] 설치를 보여주고 Argon2를 권장 알고리즘으로 설명합니다. (FastAPI)
5. Node.js는 bcrypt나 argon2 패키지를 보통 사용
npm 패키지 검색 기준으로 bcrypt는 비밀번호 해시용 라이브러리이고, argon2 패키지도 Node용 Argon2 라이브러리로 배포되고 있습니다. (Npm)
이번 글에서 맞출 공통 흐름
이번에도 회원가입/로그인 흐름으로 통일해보겠습니다.
회원가입
- 이메일, 비밀번호, 닉네임을 받는다
- 비밀번호를 해시한다
- 해시된 값만 저장한다
로그인
- 이메일로 사용자를 찾는다
- 입력한 비밀번호와 저장된 해시를 비교한다
- 맞으면 로그인 성공
여기서 진짜 중요한 건 딱 하나예요.
비밀번호는 “다시 해시해서 문자열 비교” 하는 게 아니라,
반드시 라이브러리의 verify/matches 함수를 사용해야 한다는 점입니다.
1) FastAPI에서 비밀번호 해시와 검증 붙이기
FastAPI 공식 보안 튜토리얼은 pwdlib의 PasswordHash.recommended()를 사용해 해시와 검증을 수행하는 예제를 제공합니다. 문서에서 pwdlib[argon2] 설치를 보여주고, 권장 알고리즘이 Argon2라고 설명합니다. (FastAPI)
추천 구조
fastapi-backend/
├── app/
│ ├── api/
│ │ └── auth.py
│ ├── repositories/
│ │ └── user_repository.py
│ ├── schemas/
│ │ └── auth.py
│ ├── security/
│ │ └── password.py
│ ├── services/
│ │ └── auth_service.py
│ ├── dependencies.py
│ └── main.py
└── requirements.txt
requirements.txt
fastapi
uvicorn[standard]
pydantic
email-validator
pwdlib[argon2]
app/security/password.py
from pwdlib import PasswordHash
password_hash = PasswordHash.recommended()
def hash_password(raw_password: str) -> str:
return password_hash.hash(raw_password)
def verify_password(raw_password: str, hashed_password: str) -> bool:
return password_hash.verify(raw_password, hashed_password)
app/schemas/auth.py
from pydantic import BaseModel, EmailStr, Field
class SignupRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=100)
nickname: str = Field(min_length=2, max_length=20)
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=100)
class UserRecord(BaseModel):
id: int
email: EmailStr
password_hash: str
nickname: str
class UserResponse(BaseModel):
id: int
email: EmailStr
nickname: str
app/repositories/user_repository.py
from typing import Optional
from app.schemas.auth import UserRecord
class UserRepository:
def __init__(self) -> None:
self._users: list[UserRecord] = []
self._next_id = 1
def find_by_email(self, email: str) -> Optional[UserRecord]:
return next((u for u in self._users if str(u.email) == email), None)
def save(self, email: str, password_hash: str, nickname: str) -> UserRecord:
user = UserRecord(
id=self._next_id,
email=email,
password_hash=password_hash,
nickname=nickname,
)
self._users.append(user)
self._next_id += 1
return user
app/services/auth_service.py
from app.repositories.user_repository import UserRepository
from app.schemas.auth import LoginRequest, SignupRequest, UserResponse
from app.security.password import hash_password, verify_password
class AuthService:
def __init__(self, user_repository: UserRepository) -> None:
self.user_repository = user_repository
def signup(self, request: SignupRequest) -> UserResponse:
if self.user_repository.find_by_email(str(request.email)) is not None:
raise ValueError("이미 가입된 이메일입니다.")
hashed = hash_password(request.password)
saved = self.user_repository.save(
email=str(request.email),
password_hash=hashed,
nickname=request.nickname,
)
return UserResponse(
id=saved.id,
email=saved.email,
nickname=saved.nickname,
)
def login(self, request: LoginRequest) -> UserResponse:
user = self.user_repository.find_by_email(str(request.email))
if user is None:
raise ValueError("이메일 또는 비밀번호가 올바르지 않습니다.")
if not verify_password(request.password, user.password_hash):
raise ValueError("이메일 또는 비밀번호가 올바르지 않습니다.")
return UserResponse(
id=user.id,
email=user.email,
nickname=user.nickname,
)
app/dependencies.py
from app.repositories.user_repository import UserRepository
from app.services.auth_service import AuthService
user_repository = UserRepository()
def get_auth_service() -> AuthService:
return AuthService(user_repository)
app/api/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from app.dependencies import get_auth_service
from app.schemas.auth import LoginRequest, SignupRequest, UserResponse
from app.services.auth_service import AuthService
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def signup(
request: SignupRequest,
auth_service: AuthService = Depends(get_auth_service),
) -> UserResponse:
try:
return auth_service.signup(request)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/login", response_model=UserResponse)
def login(
request: LoginRequest,
auth_service: AuthService = Depends(get_auth_service),
) -> UserResponse:
try:
return auth_service.login(request)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
app/main.py
from fastapi import FastAPI
from app.api.auth import router as auth_router
app = FastAPI(title="backend-series-fastapi-password")
app.include_router(auth_router)
실행
uvicorn app.main:app --reload
FastAPI에서 기억할 포인트
FastAPI 쪽은 지금 공식 튜토리얼이 꽤 명확합니다.
pwdlib와 PasswordHash.recommended()를 쓰고, 해시/검증을 별도 함수로 빼는 구조가 자연스러워요. (FastAPI)
개인적으로도 이게 좋다고 느끼는 이유가 있어요.
컨트롤러나 서비스 안에서 해시 로직을 직접 쓰기 시작하면 나중에 알고리즘 교체가 너무 번거롭거든요.
security/password.py 같은 파일로 한 번 감싸두면 훨씬 편합니다.
2) Spring Boot에서 PasswordEncoder로 비밀번호 다루기
Spring Security는 비밀번호 저장을 위한 PasswordEncoder 계층을 공식적으로 제공합니다. BCryptPasswordEncoder, Argon2PasswordEncoder, Pbkdf2PasswordEncoder, SCryptPasswordEncoder 등을 지원하고, DelegatingPasswordEncoder로 여러 포맷을 함께 다룰 수 있습니다. 저장 포맷은 {id}encodedPassword 형태입니다. (Home)
추천 구조
springboot-backend/
├── src/main/java/com/example/backend/
│ ├── BackendApplication.java
│ ├── config/
│ │ └── SecurityBeansConfig.java
│ ├── controller/
│ │ └── AuthController.java
│ ├── dto/
│ │ ├── LoginRequest.java
│ │ ├── SignupRequest.java
│ │ ├── UserRecord.java
│ │ └── UserResponse.java
│ ├── repository/
│ │ └── UserRepository.java
│ └── service/
│ └── AuthService.java
└── build.gradle
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.3'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
config/SecurityBeansConfig.java
package com.example.backend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityBeansConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
PasswordEncoderFactories.createDelegatingPasswordEncoder()는 DelegatingPasswordEncoder를 만들고, 기본 encode id는 bcrypt입니다. Spring Security 문서의 예시에서도 idForEncode = "bcrypt"로 보여줍니다. (Home)
dto/SignupRequest.java
package com.example.backend.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record SignupRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8, max = 100) String password,
@NotBlank @Size(min = 2, max = 20) String nickname
) {
}
dto/LoginRequest.java
package com.example.backend.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record LoginRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8, max = 100) String password
) {
}
dto/UserRecord.java
package com.example.backend.dto;
public record UserRecord(
Long id,
String email,
String passwordHash,
String nickname
) {
}
dto/UserResponse.java
package com.example.backend.dto;
public record UserResponse(
Long id,
String email,
String nickname
) {
}
repository/UserRepository.java
package com.example.backend.repository;
import com.example.backend.dto.UserRecord;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Repository
public class UserRepository {
private final List<UserRecord> users = new ArrayList<>();
private long nextId = 1L;
public Optional<UserRecord> findByEmail(String email) {
return users.stream()
.filter(user -> user.email().equals(email))
.findFirst();
}
public UserRecord save(String email, String passwordHash, String nickname) {
UserRecord user = new UserRecord(nextId++, email, passwordHash, nickname);
users.add(user);
return user;
}
}
service/AuthService.java
package com.example.backend.service;
import com.example.backend.dto.LoginRequest;
import com.example.backend.dto.SignupRequest;
import com.example.backend.dto.UserRecord;
import com.example.backend.dto.UserResponse;
import com.example.backend.repository.UserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public UserResponse signup(SignupRequest request) {
userRepository.findByEmail(request.email()).ifPresent(user -> {
throw new IllegalArgumentException("이미 가입된 이메일입니다.");
});
String encodedPassword = passwordEncoder.encode(request.password());
UserRecord saved = userRepository.save(
request.email(),
encodedPassword,
request.nickname()
);
return new UserResponse(saved.id(), saved.email(), saved.nickname());
}
public UserResponse login(LoginRequest request) {
UserRecord user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다."));
if (!passwordEncoder.matches(request.password(), user.passwordHash())) {
throw new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다.");
}
return new UserResponse(user.id(), user.email(), user.nickname());
}
}
controller/AuthController.java
package com.example.backend.controller;
import com.example.backend.dto.LoginRequest;
import com.example.backend.dto.SignupRequest;
import com.example.backend.dto.UserResponse;
import com.example.backend.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/signup")
@ResponseStatus(HttpStatus.CREATED)
public UserResponse signup(@Valid @RequestBody SignupRequest request) {
return authService.signup(request);
}
@PostMapping("/login")
public UserResponse login(@Valid @RequestBody LoginRequest request) {
return authService.login(request);
}
}
Spring Boot에서 기억할 포인트
Spring은 진짜 이 부분이 잘 되어 있어요.
PasswordEncoder 인터페이스가 있어서 서비스는 encode() 와 matches()만 알면 되고, 내부 알고리즘 교체는 설정 계층에서 할 수 있습니다. (Home)
그리고 하나 더 중요한 점.
Spring Security 문서는 bcrypt, Argon2, PBKDF2 같은 adaptive one-way function을 설명하면서, 검증 시간이 시스템 기준 약 1초 정도가 되도록 조정하라고 말합니다. 즉 “빠르면 좋은 것”이 아니라, 적당히 느려야 좋은 것이 비밀번호 해시입니다. (Home)
실무에서 무난한 시작점으로는 DelegatingPasswordEncoder를 많이 씁니다.
지금 당장은 bcrypt로 시작하더라도 저장 포맷에 {bcrypt} 같은 id가 붙기 때문에 나중에 알고리즘 전환 전략을 가져가기 편해요. (Home)
3) Node.js에서 bcrypt로 비밀번호 해시 붙이기
Node.js/Express는 프레임워크 차원 비밀번호 인코더가 없어서 라이브러리를 직접 고르는 편입니다. npm 기준 bcrypt는 비밀번호 해시용 라이브러리로 배포되고 있고, argon2 패키지도 Node용 Argon2 라이브러리로 제공됩니다. (Npm)
초반엔 호환성과 생태계 때문에 bcrypt로 시작하는 경우가 많고, 새 프로젝트에서 Argon2를 택하는 팀도 있습니다. 다만 OWASP 기준으로 새 애플리케이션의 우선순위는 Argon2id 쪽입니다. (OWASP Cheat Sheet Series)
여기서는 가장 익숙한 bcrypt 기준으로 보여드릴게요.
추천 구조
node-backend/
├── src/
│ ├── repositories/
│ │ └── user.repository.js
│ ├── routes/
│ │ └── auth.route.js
│ ├── security/
│ │ └── password.js
│ ├── services/
│ │ └── auth.service.js
│ └── server.js
└── package.json
package.json
{
"name": "backend-series-node-password",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node src/server.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
"express": "^5.1.0"
}
}
src/security/password.js
import bcrypt from "bcrypt";
const SALT_ROUNDS = 10;
export async function hashPassword(rawPassword) {
return bcrypt.hash(rawPassword, SALT_ROUNDS);
}
export async function verifyPassword(rawPassword, hashedPassword) {
return bcrypt.compare(rawPassword, hashedPassword);
}
OWASP는 bcrypt를 쓸 경우 work factor 10 이상을 권장하고, bcrypt 입력 길이 제한이 72바이트라는 점을 주의하라고 설명합니다. Spring Security도 기본 bcrypt strength 10을 언급합니다. (OWASP Cheat Sheet Series)
src/repositories/user.repository.js
export class UserRepository {
constructor() {
this.users = [];
this.nextId = 1;
}
findByEmail(email) {
return this.users.find((user) => user.email === email) ?? null;
}
save(email, passwordHash, nickname) {
const user = {
id: this.nextId++,
email,
passwordHash,
nickname,
};
this.users.push(user);
return user;
}
}
src/services/auth.service.js
import { hashPassword, verifyPassword } from "../security/password.js";
export class AuthService {
constructor(userRepository) {
this.userRepository = userRepository;
}
async signup({ email, password, nickname }) {
const existingUser = this.userRepository.findByEmail(email);
if (existingUser) {
throw new Error("이미 가입된 이메일입니다.");
}
const passwordHash = await hashPassword(password);
const savedUser = this.userRepository.save(email, passwordHash, nickname);
return {
id: savedUser.id,
email: savedUser.email,
nickname: savedUser.nickname,
};
}
async login({ email, password }) {
const user = this.userRepository.findByEmail(email);
if (!user) {
throw new Error("이메일 또는 비밀번호가 올바르지 않습니다.");
}
const matched = await verifyPassword(password, user.passwordHash);
if (!matched) {
throw new Error("이메일 또는 비밀번호가 올바르지 않습니다.");
}
return {
id: user.id,
email: user.email,
nickname: user.nickname,
};
}
}
src/routes/auth.route.js
import { Router } from "express";
export function createAuthRouter(authService) {
const router = Router();
router.post("/api/auth/signup", async (req, res) => {
const { email, password, nickname } = req.body;
if (!email || !password || !nickname) {
return res.status(400).json({ message: "필수값이 누락되었습니다." });
}
if (password.length < 8) {
return res.status(400).json({ message: "비밀번호는 8자 이상이어야 합니다." });
}
try {
const result = await authService.signup({ email, password, nickname });
return res.status(201).json(result);
} catch (error) {
return res.status(400).json({ message: error.message });
}
});
router.post("/api/auth/login", async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: "필수값이 누락되었습니다." });
}
try {
const result = await authService.login({ email, password });
return res.json(result);
} catch (error) {
return res.status(400).json({ message: error.message });
}
});
return router;
}
src/server.js
import express from "express";
import { UserRepository } from "./repositories/user.repository.js";
import { createAuthRouter } from "./routes/auth.route.js";
import { AuthService } from "./services/auth.service.js";
const app = express();
const PORT = 3000;
const userRepository = new UserRepository();
const authService = new AuthService(userRepository);
app.use(express.json());
app.use(createAuthRouter(authService));
app.listen(PORT, () => {
console.log(`server running on http://localhost:${PORT}`);
});
Node.js에서 기억할 포인트
Node.js는 여기서 진짜 실수하기 쉬워요.
내장 crypto.createHash("sha256") 예제가 너무 쉽게 보이거든요. 하지만 그건 일반 해시 API일 뿐이고, 비밀번호 저장엔 bcrypt나 argon2 같은 라이브러리를 쓰는 편이 맞습니다. (Node.js)
그리고 한 가지 더.
bcrypt는 입력 제한이 72바이트라는 점을 꼭 알아야 해요.
문자 수가 아니라 바이트 기준이라, 한글/이모지 같은 멀티바이트 문자가 섞이면 생각보다 빨리 걸릴 수 있습니다. OWASP도 이 제한을 따로 경고합니다. (OWASP Cheat Sheet Series)
세 스택을 나란히 놓고 보면
FastAPI
- 현재 공식 튜토리얼이 pwdlib + PasswordHash.recommended() 흐름을 보여줌
- Argon2 기반 시작이 자연스러움
- 해시/검증 함수를 security 모듈로 분리하기 좋음 (FastAPI)
Spring Boot
- PasswordEncoder 계층이 너무 잘 되어 있음
- bcrypt/argon2/pbkdf2/scrypt를 하나의 인터페이스로 다룰 수 있음
- DelegatingPasswordEncoder 덕분에 알고리즘 전환 전략이 편함 (Home)
Node.js
- 프레임워크 기본 제공은 없고 라이브러리를 직접 선택
- bcrypt가 여전히 흔하고, Argon2 라이브러리도 사용 가능
- crypto.createHash 대신 비밀번호 전용 라이브러리를 쓰는 감각이 중요 (Npm)
여기서 꼭 같이 기억해야 할 것들
1. 해시 문자열은 그대로 저장해도 된다
이건 헷갈리는 분들이 많아요.
bcrypt나 Argon2 결과 문자열에는 보통 salt와 알고리즘 관련 정보가 함께 들어갑니다. Spring의 {id}encodedPassword 포맷도 비슷한 맥락에서 어떤 인코더로 만든 값인지 식별자를 앞에 둡니다. (Home)
즉 “해시값을 DB에 저장하는 것” 자체는 정상입니다.
2. 비밀번호 비교는 직접 문자열 비교로 하지 않는다
절대 hash(inputPassword) == savedHash 같은 식으로 단순 비교 흐름을 직접 구현하지 마세요.
반드시 라이브러리의 verify, matches, compare를 써야 합니다.
알고리즘마다 salt 처리와 포맷이 다르기 때문이에요. FastAPI와 Spring 예제도 그렇게 구성되어 있습니다. (FastAPI)
3. “느린 해시”가 정상이다
이건 진짜 감각을 바꿔야 하는 부분이에요.
보통 개발에서는 빠른 게 좋은데, 비밀번호 저장에서는 너무 빠르면 오히려 공격자에게 유리합니다. OWASP와 Spring Security 둘 다 느리고 조정 가능한 알고리즘을 권장합니다. (OWASP Cheat Sheet Series)
4. bcrypt를 쓰면 입력 길이 정책도 같이 정하자
OWASP는 bcrypt가 대부분 구현에서 72바이트 제한이 있다고 설명합니다. 그래서 최대 길이 정책을 문서화해두는 게 좋습니다. (OWASP Cheat Sheet Series)
5. 해시 알고리즘 교체 가능성을 열어두자
이건 나중에 진짜 중요해요.
Spring의 DelegatingPasswordEncoder가 좋은 예고, FastAPI/Node도 해시 함수를 별도 모듈로 빼두면 나중에 Argon2 ↔ bcrypt 전환이 훨씬 편합니다. (Home)
주니어 때 자주 하는 실수
이건 정말 많이 봤습니다.
1. 평문 비밀번호 저장
이건 말할 것도 없습니다. 바로 아웃이에요. (OWASP Cheat Sheet Series)
2. SHA-256 한 번만 해서 저장
해시라는 단어에 속기 쉬운데, 비밀번호 저장용으론 부족합니다. OWASP는 현대적인 비밀번호 해시 알고리즘을 쓰라고 분명히 말합니다. (OWASP Cheat Sheet Series)
3. 로그인 때 다시 해시해서 직접 비교
bcrypt/argon2는 salt와 포맷을 고려해야 해서 라이브러리 verify 함수를 써야 합니다. (FastAPI)
4. 서비스 코드 곳곳에서 해시 로직 중복
해시 알고리즘 변경이 어려워집니다.
security/password.* 같은 모듈로 한 번 감싸두는 게 좋아요.
5. 비밀번호 해시와 JWT 발급을 한 덩어리로 생각
비밀번호 해시는 “사용자 인증 자격을 안전하게 저장하는 문제”이고, JWT는 “로그인 성공 후 세션/토큰을 어떻게 줄 것인가”의 문제예요. 순서상 해시가 먼저입니다.
이번 글 핵심 정리
이번 글의 핵심은 딱 하나입니다.
비밀번호는 저장하는 데이터가 아니라, 검증 가능한 형태로 변환해서 보관해야 하는 데이터다.
그래서 핵심 흐름은 항상 비슷합니다.
- 회원가입: hash(password) 후 저장
- 로그인: verify(rawPassword, savedHash) 로 검증
- 응답/로그/토큰에 비밀번호나 해시를 절대 노출하지 않기
그리고 스택별로 감각을 정리하면 이렇습니다.
- FastAPI: pwdlib + PasswordHash.recommended() 흐름이 현재 가장 자연스럽다. (FastAPI)
- Spring Boot: PasswordEncoder와 DelegatingPasswordEncoder를 쓰면 안정적이다. (Home)
- Node.js: crypto.createHash 대신 bcrypt 또는 argon2 같은 전용 라이브러리를 써야 한다. (Node.js)
다음 글 예고
다음 글에서는 자연스럽게
로그인 이후, 세션과 JWT 중 무엇을 선택할 것인가 로 넘어가겠습니다.
이제 비밀번호 검증까지 붙였으니 다음 질문이 바로 나오거든요.
- 로그인 성공 후 사용자를 어떻게 기억하지?
- 세션 기반이 나을까, JWT가 나을까?
- 백엔드 API 서버에서는 왜 JWT를 많이 쓸까?
- 그런데 또 왜 세션이 더 안전하다는 말도 나올까?
다음 글에서는 FastAPI, Spring Boot, Node.js 각각에서
세션 vs JWT의 감각을 실무 기준으로 정리해보겠습니다.
출처
- OWASP Password Storage Cheat Sheet (OWASP Cheat Sheet Series)
- FastAPI 공식 문서 — OAuth2 with Password (and hashing), Bearer with JWT tokens (FastAPI)
- Spring Security 공식 문서 — Password Storage (Home)
- Node.js 공식 문서 — crypto.createHash() examples (Node.js)
- npm package search — bcrypt, argon2 (Npm)
백엔드개발, FastAPI, SpringBoot, Nodejs, Express, 비밀번호해시, bcrypt, Argon2, PasswordEncoder, 백엔드시리즈
'study > 백엔드' 카테고리의 다른 글
- Total
- Today
- Yesterday
- JAX
- nextJS
- seo 최적화 10개
- Next.js
- CI/CD
- llm
- 웹개발
- fastapi
- 생성형AI
- node.js
- JWT
- rag
- REACT
- NestJS
- LangChain
- Python
- flax
- Redis
- Express
- kotlin
- 백엔드개발
- ai철학
- 쿠버네티스
- DevOps
- 딥러닝
- Docker
- SEO최적화
- Prisma
- PostgreSQL
- 개발블로그
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |

