주니어 개발자가 회원가입·로그인 직접 설계하며 배운 것들 (GS인증 / KISA 기준)
인증(회원가입·로그인) 기능을 처음부터 설계하면서 계속 막히고 헷갈렸다. 비밀번호는 어떻게 저장하지? 이메일은? SHA-256이면 되나? 소셜이랑 로컬 계정이 겹치면? 탈퇴는 어떻게 처리하지? 하나씩 파고들면서 알게 된 것들을 내가 헷갈렸던 순서 그대로 정리했다. 나 같은 주니어에게 도움이 되길.
1. 계정 통합 — "1 이메일 = 1 계정"
우리 서비스는 이메일/비밀번호(로컬) 로도, 구글·카카오·네이버(소셜) 로도 로그인할 수 있다. 그러면 이런 의문이 생긴다.
🤔 헷갈린 점: 로컬로 가입한 사람이 나중에 같은 이메일로 소셜 로그인하면? 계정이 2개 생기나?
정답은 "이메일 하나 = 계정 하나". 같은 이메일이면 계정(User)은 하나로 두고, 로그인 수단(Provider)만 추가로 연결한다.
User (1) ──< (N) Provider
User = 신원 (이메일·이름·사진)
Provider = 로그인 수단 1개 (LOCAL / GOOGLE / KAKAO / NAVER)
이미 가입된 이메일이면?
🤔 헷갈린 점: 이미 가입돼 있으면 그냥 막아? 아니면 물어봐?
조용히 합치면 안 되고(계정 탈취 위험), 그렇다고 막다른 길로 막아도 안 된다. "이미 가입된 계정이 있어요. 통합할까요?"라고 물어보고, 예 하면 기존 계정에 새 수단을 연결하는 게 자연스럽다.
단, "통합할까요?"는 아무 때나 띄우는 게 아니라 "다른(미연결) 수단을 새로 붙일 때만" 띄운다.
| 상황 | 처리 |
|---|---|
| 새 이메일 | 그냥 가입 / 소셜은 자동가입 |
| 기존 계정 + 새 수단 추가 | "연결할까요?" → 연결 후 로그인 |
| 기존 계정 + 같은 수단 재시도 | 그냥 로그인 ("이미 가입됨, 로그인하세요") |
🤔 헷갈린 점: "이 이메일은 구글로 가입됐어요"처럼 어떤 걸로 가입했는지 알려줘야 하나?
→ 로그인 화면에선 알려줘도 되지만, 가입/이메일 입력 단계에선 숨기는 게 안전하다. 이메일만 넣어보고 "얘는 구글 쓰네"를 알아낼 수 있으면 정보 노출이니까. 우리는 "소셜 로그인으로 가입돼 비밀번호가 없어요" 정도로만 안내하기로 했다.
핵심: 통합의 기준은 이메일이다. 소셜이 넘겨주는 이메일이 기존 이메일과 같으면 같은 사람으로 보고 연결한다. (소셜의 고유 id인 sub는 매칭 키가 아니라, 그냥 그 수단을 식별하려고 저장하는 값)
2. 회원 탈퇴와 soft delete — FK 함정
탈퇴는 데이터를 진짜 지우지 않고 IsDeleted = true로 표시만 하는 soft delete로 처리한다. (복구·감사·재가입 때문에)
🤔 헷갈린 점: 회원(User) 지우면 그 사람의 로그인 수단(Provider)도 같이 지워져야 하잖아. FK에
ON DELETE CASCADE걸면 되는 거 아냐?
여기서 크게 한 번 헤맸다. 결론부터:
ON DELETE CASCADE는 진짜 DELETE 문에만 작동한다. soft delete는 사실 UPDATE(IsDeleted=true)라서 CASCADE가 안 터진다.
UPDATE "User" SET "IsDeleted"=true WHERE ... -- DELETE가 아니라 UPDATE!
🤔 그럼
ON UPDATE CASCADE로 바꾸면?
→ 그것도 안 된다.ON UPDATE CASCADE는 부모의 키 값(ID)이 바뀔 때 따라가는 거지,IsDeleted같은 다른 컬럼은 안 본다. FK는 키 컬럼만 쫓아간다.
정답: soft delete는 DB가 아니라 코드(서비스)에서 처리한다. 탈퇴할 때 User와 그 사람의 Provider들을 코드로 같이 IsDeleted=true 해줘야 한다. (안 그러면 살아남은 소셜 수단 때문에 나중에 같은 소셜로 재가입이 안 된다 — UNIQUE 제약에 걸림)
🤔 헷갈린 점: "Provider를 탈퇴처리한다"는 게 다른 회원까지 영향 주는 거야?
→ 아니다. 탈퇴는 항상WHERE UserID = 그 사람으로 스코프된다. UserID가 울타리라서, 1번 회원의 일이 2번 회원에게 절대 안 넘어간다. "회원 탈퇴 = 그 회원 + 그 회원의 수단들"만.
회원 탈퇴 ≠ 소셜 연결 해제도 구분해야 한다.
- 회원 탈퇴 = User + 그 사람의 모든 수단 정리
- 소셜 연결 해제 = 그 수단 1개만 정리 (계정·다른 수단은 유지)
그럼 FK CASCADE는 왜 걸어둠?
물리 삭제(개인정보 파기) 때 쓰려고. 평소 탈퇴(soft)는 코드가, 나중에 진짜 DELETE 할 때는 CASCADE가 Provider를 자동 정리한다. 두 층이 각자 담당.
참고 —
ON DELETE SET NULL: 부모 삭제 시 자식 행은 남기고 FK 칸만 NULL로 비운다. 감사 로그처럼 "보존은 하되 사람 연결만 끊고 싶을 때" 쓴다.
왜 코드에서
cascade=REMOVE(엔티티) 대신 벌크 UPDATE? 이 프로젝트는 이미 "전용 벌크 soft-delete" 패턴으로 통일돼 있다. 엔티티 cascade는 자식을 다 로딩해 하나씩 UPDATE(N번)하고 동작이 숨겨지는 반면, 벌크는UPDATE ... WHERE UserID=?한 방이고 서비스 코드에 명시적으로 보인다. → 컨벤션과 성능·가독성 모두 벌크가 낫다.
3. 비밀번호는 어떻게 저장할까 (제일 많이 헷갈린 파트)
3-1. 평문 저장은 당연히 X. 그럼 SHA-256으로 해시하면?
🤔 헷갈린 점: 데이터를 SHA-256으로 해시해서 저장하면 남이 풀 수 있어?
"되돌리기(복호화)"는 불가능하다. SHA-256은 단방향이라 해시 → 원본으로 가는 역함수가 없다. 근데 함정이 있다.
"되돌리기"는 못 해도 "맞춰보기"는 된다.
공격자: "1234" → SHA-256 → 저장된 해시랑 같나?
"password" → SHA-256 → 같나?
(흔한 값 수십억 개 대입…) ← 하나 맞으면 원본 알아냄
SHA-256은 빠르고 + 같은 입력이면 항상 같은 해시라서, 비밀번호처럼 예측 가능한 값은 이 무차별 대입(또는 미리 계산해둔 표 = 레인보우 테이블)으로 뚫린다.
3-2. salt — 왜 필요하고, 왜 사용자마다 달라야 하나
salt = 비밀번호에 섞는 사용자별 랜덤값.
- salt가 없으면: 같은 비밀번호 쓰는 사람들의 해시가 전부 동일 → 하나 뚫리면 전원 뚫림 + 레인보우 테이블에 취약.
- salt가 사용자마다 다르면: 같은
password123이라도 해시가 전부 달라짐 → 레인보우 테이블 무력화.
🤔 헷갈린 점: salt를 저장 안 하고 그냥
SHA-256(salt+password)하면 안 돼?
→ 안 된다. 랜덤 salt를 저장 안 하면 로그인 검증 자체가 불가능하다(그 salt를 다시 못 구하니까). salt는 어떤 형태로든 재현 가능해야 한다.
- salt가 매번 랜덤 + 저장 안 함 → 검증 불가
- salt가 앱 고정 전역값(pepper) → 되지만 전원 같은 salt라 약함
- salt가 사용자별 → 저장해야 함 (또는 이미 있는 값에서 유도)
3-3. bcrypt는 이 문제를 어떻게? (이전 프로젝트 확인)
사내 이전 프로젝트를 열어보니 bcrypt를 쓰고 있었다.
return new BCryptPasswordEncoder();
// 가입: encode(password) / 검증: matches(입력, 저장값)
bcrypt는 별도 salt 컬럼이 없는데도 사용자별 랜덤 salt를 쓴다. 비밀! — salt를 해시 문자열 안에 같이 넣어 저장하기 때문.
$2a$10$N9qo8uLOickgx2ZMRZoMy.Mrq4t... (60자)
│ │ └── 22자 salt ──┘└──── 31자 hash ────┘
알고리즘 반복 강도
💡 깨달음: "salt를 저장 안 함"의 진짜 의미는 "별도 컬럼 없이 해시 문자열에 같이 저장"이었다.
3-4. 빠른 해시의 약점 → 반복(key stretching)
SHA-256은 너무 빨라서 salt를 붙여도 무차별 대입에 약하다. 그래서 일부러 수십만 번 반복해서 느리게 만든다. 정상 로그인은 1번뿐이라 체감 안 되지만, 공격자는 후보마다 그 반복을 다 돌아야 해서 수십만 배 느려진다.
"SHA-256 + salt + 반복" = 그게 바로 PBKDF2다.
3-5. PBKDF2-HMAC-SHA256
🤔 헷갈린 점: 이름이
PBKDF2-salt-SHA256인가?
→ 아니다. 정확한 이름은PBKDF2-HMAC-SHA256. salt는 이름이 아니라 입력값이다.
PBKDF2( PRF=HMAC-SHA256, password, salt, 반복횟수 ) → 해시
│ │ 입력 입력 입력
알고리즘 내부 해시함수
- PBKDF2가 사용자별 랜덤 salt(≥16바이트) 를 만들어 넣고, 수십만 번 반복하고, salt·반복 정보를 결과 문자열에 같이 인코딩한다 → 별도 salt 컬럼 불필요.
- 검증은 저장값에서 salt를 꺼내 같은 방식으로 재계산해 비교. Spring 내장
Pbkdf2PasswordEncoder를 쓰면encode()/matches()두 줄로 끝.
🤔 헷갈린 점: 그럼 사용자마다 salt가 달라?
→ 그렇다. 비밀번호 저장할 때마다 새 랜덤 salt를 만든다. 그래서 같은 비번이어도 해시가 전부 다르다. 그 salt는PasswordHash에 같이 저장돼서 검증도 문제없다.
3-6. crypto는 직접 짜지 마라
🤔 헷갈린 점: crypto가 뭐야?
→ cryptography(암호학)의 줄임말. "커스텀 crypto 짜지 마라" = salt 생성·해시·비교 같은 보안 계산을 직접 구현하지 마라는 뜻.
암호 코드는 사소한 실수(예측 가능한 난수, == 비교로 인한 timing attack, 반복 처리 실수)가 그대로 보안 구멍이 된다. 검증된 라이브러리(PBKDF2/bcrypt 인코더)를 쓰는 게 원칙.
3-7. 근데 GS인증은 SHA-256을 쓰라던데? (KISA)
우리 제품 문서엔 "SHA-256+salt"라고 적혀 있었고, bcrypt로 갔다가 다시 SHA-256으로 돌아온 이력이 있었다. 알고 보니 이유가 있었다.
- GS인증의 핵심 = "제품이 문서대로 동작하는가" (문서 == 구현).
- bcrypt는 KISA/KCMVP 승인 알고리즘 목록에 없다 → 국내 인증엔 부적합. (이전 프로젝트 bcrypt는 이 관점에선 아쉬운 선택이었던 것)
- KISA 가이드가 권장하는 정석 =
PBKDF2-HMAC-SHA256(salt ≥16B, 반복 ~10만+, 개인정보는 AES-256-GCM).
💡 결정적 깨달음: PBKDF2-HMAC-SHA256은 "SHA-256 기반" 구현의 표준 방식이다. 즉 PBKDF2를 쓰는 게 SHA-256 요구에서 벗어나는 게 아니라, 그걸 제대로 구현하는 것. 제품설명서에 "SHA-256 기반 PBKDF2-HMAC-SHA256"이라고만 적으면 GS 문서와도 완전 정합.
최종 결정: 비밀번호 = PBKDF2-HMAC-SHA256 (Spring 내장 인코더).
4. 이메일은 어떻게 저장할까 — 암호화 vs 해시
이메일은 개인정보라 평문 저장이 곤란하다. 그런데 요구사항이 상충한다.
- 표시하려면 원본을 복원할 수 있어야 한다 (마이페이지에 이메일 보여주기).
- 검색·중복확인하려면 같은 이메일이 항상 같은 값이어야 한다.
🤔 헷갈린 점: 그래서 이메일을 컬럼 2개에 저장한다고?
맞다. 둘을 나눠서 저장한다.
| 컬럼 | 방식 | 방향 | 용도 |
|---|---|---|---|
EmailEnc |
AES-256-GCM | 양방향(복원 O) | 화면 표시 |
EmailHash |
HMAC-SHA256(비밀키, 이메일) | 단방향(복원 X) | 검색·중복확인 (블라인드 인덱스) |
(저장 전에 정규화 — 소문자화 + 공백 제거 — 를 먼저 한다. 대소문자 다르다고 별개 계정 되면 안 되니까.)
5. 해시 개념 총정리 (계속 헷갈렸던 것들)
5-1. SHA-256 vs HMAC-SHA256 — 아예 다른 거야?
🤔 헷갈린 점: 둘이 완전 다른 알고리즘이야?
아니다. HMAC-SHA256이 SHA-256을 "안에서 사용"한다.
SHA-256 = 기본 해시 함수 (부품)
HMAC-SHA256 = 그 SHA-256을 비밀키와 함께 감싼 것 (조립품)
차이는 딱 하나 — 비밀키. 출력 길이는 둘 다 256비트(64 hex)로 같다.
5-2. 키가 있으면 무조건 HMAC이야?
🤔 헷갈린 점:
SHA-256(키+데이터)도 키가 있으니 HMAC 아냐?
아니다. 키를 붙였다고 HMAC이 되는 게 아니라, 키를 "HMAC 방식으로" 써야 HMAC이다.
① SHA-256(데이터) ← 키 없음 = 그냥 SHA-256
② SHA-256(키 + 데이터) ← 키 있음, 그냥 붙임 = HMAC 아님 (❌ 취약)
③ HMAC-SHA256(키, 데이터) ← 키 있음 + 올바른 구조 = HMAC ⭕
②의 "키"도 비밀키로 쓰려던 것이고 작동은 한다. 다만 구조적 허점(length-extension) 이 있어서 안 쓰고, 같은 목적을 안전하게 만든 ③(HMAC)을 쓴다.
5-3. HMAC의 키 = 비밀키
🤔 헷갈린 점: HMAC의 "키"가 비밀키 맞아?
맞다. 서버 설정/KMS에만 보관하고 DB엔 저장 안 한다. 키가 유출되면 HMAC 보호 효과는 0.
salt와 HMAC 키는 성격이 정반대라 헷갈리면 안 된다:
| 비밀번호 salt | HMAC 키 | |
|---|---|---|
| 공개 여부 | 공개 (DB에 같이 저장) | 비밀 (DB 저장 X) |
| 개수 | 사용자마다 다름 (랜덤) | 전역 1개 (고정) |
| 같은 입력 → | 매번 다른 해시 | 항상 같은 해시 |
| 목적 | 숨기기 (검색 불필요) | 검색되게 (중복확인) |
💡 이메일에 salt 대신 HMAC(고정 비밀키)을 쓰는 이유가 여기 있다. 같은 이메일 → 항상 같은 EmailHash여야 검색이 되니까. 그러면서 키가 비밀이라 DB만 털려선 역추적 불가.
5-4. length-extension attack이 뭔데?
🤔 헷갈린 점: ②가 왜 취약해?
length-extension = "해시값만 보고, 원본 뒤에 내용을 몰래 이어붙인 새 해시를 (키 없이) 만들어내는" 공격.
SHA-256은 데이터를 블록 단위로 이어 처리하고 최종 출력 = 마지막 내부 상태다. 그래서 그 출력(해시)을 이어받아 계산을 계속할 수 있다.
서버: 해시 = SHA-256(비밀키 + "user=guest")
공격자: 그 해시를 "중간 상태"로 삼아 이어감
→ SHA-256(비밀키 + "user=guest" + 패딩 + "&admin=true") 위조!
(비밀키를 몰라도 유효한 새 해시 생성 → 권한 위조)
HMAC은 키로 한 번 더 바깥을 감싸서 이 이어붙이기를 원천 차단한다.
HMAC = SHA-256( 키2 + SHA-256(키1 + 데이터) )
└ 이어붙여도 여기까지 ┘
└ 바깥 해시가 덮어서 차단 ┘
(참고: 우리 EmailHash 용도에선 위조가 목적이 아니라 실제 위협은 작지만, 어차피 HMAC 함수 쓰면 공짜로 안전해지니 그냥 HMAC을 쓴다.)
6. 아키텍처에서 빠뜨렸던 것들
설계 트리를 그려놓고 냉정하게 다시 보니 꼭 필요한데 빠진 게 여럿이었다.
백엔드에서 놓쳤던 것:
- JWKS 엔드포인트 — 외부 서버가 우리 JWT를 검증하려면 공개키를 내줘야 함
- OAuth2 로그인 성공 핸들러 — 소셜 인증 성공 후 우리 JWT 발급·쿠키 세팅하는 지점
- 인증 진입점(EntryPoint) — 보안 필터 단계에서 터지는 401은
@ControllerAdvice가 못 잡음 → 따로 필요 - 스케줄러 — 감사 로그 2년 후 purge
프론트에서 놓쳤던 것:
- 라우팅 자체 (파일 기반 라우터의 라우트 정의)
- 인증 전용 레이아웃 (로그인/가입 공통 카드 셸)
- 토큰 저장소(store) 와 인증 가드
💡 교훈: "핵심 흐름"만 그리면 외부 검증·성공 핸들러·예외 응답·스케줄러 같은 "이음새"를 빼먹기 쉽다. API 목록 ↔ 컴포넌트 ↔ 보안 요소를 서로 대조하면 구멍이 보인다.
7. 최종 설계 요약
| 항목 | 결정 |
|---|---|
| 계정 모델 | 1 이메일 = 1 계정 (User 1 : Provider N), 통합은 "물어보고 연결" |
| 비밀번호 | PBKDF2-HMAC-SHA256 (사용자별 salt≥16B + 반복, Spring 내장 인코더) |
| 이메일 | EmailEnc = AES-256-GCM(표시) + EmailHash = HMAC-SHA256(검색) |
| 탈퇴 | soft delete, 코드에서 User+Provider 벌크 UPDATE (FK CASCADE는 물리파기용) |
| 토큰 | JWT RS256 + refresh 회전 + access 블랙리스트 |
| 인증 기준 | KISA/GS 정석 준수 (bcrypt ❌ 미승인) |
마치며 — 내가 얻은 교훈
- 해시는 "풀리는" 게 아니라 "찍어서 맞히는" 게 문제다. 그래서 salt·반복·비밀키로 그 맞추기를 막는다.
- "저장 안 함"의 진짜 의미는 대개 "해시 문자열에 같이 저장"이었다 (bcrypt·PBKDF2).
- 암호는 직접 짜지 말고 검증된 라이브러리를 쓴다.
- soft delete는 UPDATE라 FK CASCADE가 안 먹는다. DB 규칙과 앱 로직의 역할을 구분하자.
- 인증 요건은 "무엇이 더 안전하냐"가 아니라 "문서·가이드(KISA/GS)와 맞느냐"도 같이 봐야 한다.
- 모르는 개념(salt, HMAC, PBKDF2, length-extension)은 "왜 이게 필요한가" 를 물으면 결국 다 연결돼 있었다.
처음엔 "그냥 비밀번호 해시해서 저장하면 되는 거 아냐?"였는데, 파고들수록 salt → 반복 → PBKDF2 → HMAC → 인증 가이드까지 줄줄이 이어졌다. 하나씩 "왜?"를 던진 게 제일 도움이 됐다.
참고
- 관련 설계 문서:
01_file/03_auth/01_obsidian/auth-design.md - 참고 글: KISA 가이드 정리 (Medium, sungwon-eni) — "왜 한국 SW 인증엔 bcrypt보다 SHA-256+salt일까"
- 관련 노트: [[auth-client-structure]], [[tanstack-router-라우트트리]], [[zod-resolver]]
'공부 > Spring' 카테고리의 다른 글
| 비밀번호 저장, 그냥 암호화하면 되는 거 아닌가요? — KISA 가이드라인 앞에서 멘붕한 이야기 (0) | 2026.07.01 |
|---|---|
| AOP (0) | 2025.05.09 |
| 구글 로그인 구현 (1) | 2025.01.16 |
| 서버사이드 렌더링, 클라이언트 사이드 렌더링 (0) | 2024.11.25 |
| 웹 어플리케이션의 의해 (1) | 2024.11.25 |