v3
★ MILESTONE · BOARD

안전한 회원가입/로그인

v2 의 두 문제를 해결한다

학습 목표

  • v2 의 평문 비밀번호를 BCrypt 해시로 교체한다
  • 로그인 성공 시 세션에 사용자 정보를 보관한다
  • 로그아웃 시 세션을 invalidate 한다
  • v2 와 v3 의 코드 차이가 「두 줄」 임을 본다

⚠️ v2 의 두 가지 문제

전 차시(v2) 에서 의도적으로 만든 위험
  • ① 평문 저장 — DB 가 유출되면 모든 사용자의 비밀번호가 그대로 노출
  • ② 무상태 — 로그인 성공해도 다음 페이지 가면 바로 풀림

이 두 문제를 풀어야 실용적인 인증이 됩니다. 오늘이 그 작업.

⚠️ 그리고 또 하나 — 컬럼 길이

v2 의 pwd VARCHAR(20) 으로는 BCrypt 가 안 들어간다

-- v2 의 스키마
CREATE TABLE mymember (
    id  VARCHAR(50)  PRIMARY KEY,
    pwd VARCHAR(20)  NOT NULL    -- ← 평문 기준
);

BCrypt 해시는 항상 60자. VARCHAR(20) 에 60자를 넣으면 잘려서 들어가거나 에러.

→ 도구 도입 전에 스키마부터 늘려야 합니다.

🛠️ 두 가지 해결책

BCrypt + HttpSession

v2 의 코드에 두 가지 변경:

  • 회원가입: 평문 → BCrypt 해시로 변환 후 저장
  • 로그인: 비교는 BCrypt 가 / 성공 시 세션에 저장
  • 로그아웃: 세션 폐기

BCrypt 는 단방향 자물쇠 — 한 번 잠그면 서버도 원본 비밀번호를 모릅니다.

단방향 자물쇠

BCrypt — 단방향 해시

원본 비밀번호: "pw1234" ↓ BCrypt 해시 (단방향) 저장값: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy 특징: - 같은 입력도 매번 다른 해시 (salt 자동) - 해시 → 원본은 절대 불가능 (단방향) - 비교는 BCrypt 가 알아서 (encoder.matches) - 강력 (의도적으로 느림 — brute force 방어)

① 스키마 마이그레이션 — ALTER TABLE


-- v2 → v3: pwd 컬럼 길이 확장
ALTER TABLE mymember MODIFY COLUMN pwd VARCHAR(100) NOT NULL;
기존 평문 데이터는?
  • 학습 단계라 회원 테이블을 비우고 다시 가입 — 가장 단순
  • 실서비스라면 「전체 사용자에게 비밀번호 재설정 메일 발송」 정책
  • 평문 → 해시 변환은 한 줄로 가능하지만, 그 시점 이후로 사용자가 입력한 평문을 절대 보관하면 안 됨

BCrypt 사용 — pom.xml


<!-- Spring Security Crypto (BCrypt 만 가벼이) -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-crypto</artifactId>
    <version>5.8.5</version>
</dependency>

👉 Spring Security 전체가 아닌 crypto 모듈만. 가볍게 BCrypt 만 쓰기 위함.

BCrypt 사용 — 회원가입


package com.smhrd.service;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Service
public class MemberService {
    @Autowired
    private MemberMapper mapper;

    private final BCryptPasswordEncoder encoder
            = new BCryptPasswordEncoder();

    // 회원가입 — 해시해서 저장
    public void signup(Member m) {
        String hashed = encoder.encode(m.getPwd());
        m.setPwd(hashed);
        mapper.insert(m);
    }
}

👉 encoder.encode 한 줄로 평문이 단방향 해시로 변환됩니다.

BCrypt 사용 — 로그인


// MemberService 안에 이어서

// 로그인 — 해시 비교
public Member login(String id, String rawPw) {
    Member m = mapper.selectOne(id);
    if (m == null) return null;
    if (encoder.matches(rawPw, m.getPwd())) {
        return m;
    }
    return null;
}

👉 비교는 encoder.matches(평문, 해시) — 직접 해시 비교 금지.

BCrypt — 동작 한눈에

회원가입 시 raw = "pw1234" hashed = encoder.encode(raw) = "$2a$10$..." mapper.insert(m) // DB 에는 hashed 저장 로그인 시 raw = "pw1234" // 사용자가 입력한 평문 stored = mapper.selectOne(id).getPwd() // DB 의 해시 encoder.matches(raw, stored) ↓ BCrypt 가 내부적으로: 1) stored 의 salt 추출 2) raw 를 같은 salt 로 다시 해시 3) 둘이 같은가 비교 → true / false

HttpSession — 로그인 처리


package com.smhrd.controller;

@Controller
public class LoginController {
    @Autowired private MemberService service;

    @GetMapping("/login")
    public String form() { return "login"; }

    @PostMapping("/login")
    public String login(@RequestParam String id,
                        @RequestParam String pwd,
                        HttpSession session) {
        Member m = service.login(id, pwd);
        if (m == null) return "redirect:/login?error";
        session.setAttribute("loginUser", m);  // ← 핵심
        return "redirect:/";
    }
}

👉 session.setAttribute 한 줄이 「페이지 이동해도 풀리지 않는」 인증을 만듭니다.

HttpSession — 로그아웃


// LoginController 안에 이어서

@GetMapping("/logout")
public String logout(HttpSession session) {
    session.invalidate();
    return "redirect:/";
}

👉 invalidate() 는 서버 메모리의 보관함을 통째로 비웁니다. 브라우저의 JSESSIONID 쿠키는 남아 있어도 의미 없는 값이 됩니다.

HttpSession 의 동작

① 로그인 성공 session.setAttribute("loginUser", member) ↓ 서버 메모리: JSESSIONID=abc123 → { "loginUser" : Member(...) } ↓ Set-Cookie: JSESSIONID=abc123 ↓ 브라우저에 쿠키 저장 ② 다음 요청 Cookie: JSESSIONID=abc123 (자동 동봉) ↓ 서버: abc123 보관함에서 loginUser 꺼냄 ↓ "환영합니다 OO님" ③ 로그아웃 session.invalidate() ↓ 서버 메모리에서 abc123 보관함 폐기 ↓ 브라우저의 JSESSIONID 도 의미 없는 값이 됨

v2 → v3 비교 ① 회원가입

v2 — 평문 저장

public void signup(Member m) {
    // 평문 그대로!
    mapper.insert(m);
}
v3 — 해시 저장

public void signup(Member m) {
    m.setPwd(
      encoder.encode(m.getPwd())); // ⭐
    mapper.insert(m);
}

👉 변경량: 한 줄. encode 호출만 끼워 넣음.

v2 → v3 비교 ② 로그인 검증

v2 — equals 비교

public Member login(String id, String pwd) {
    Member m = mapper.selectOne(id);
    if (m != null
        && m.getPwd().equals(pwd))
        return m;
    return null;
}
v3 — matches 비교

public Member login(String id, String pwd) {
    Member m = mapper.selectOne(id);
    if (m != null
        && encoder.matches(pwd,
                m.getPwd()))      // ⭐
        return m;
    return null;
}

👉 변경량: 한 줄. equalsencoder.matches.

v2 → v3 비교 ③ 세션 저장

v2 — 세션 저장 없음

// Controller
public String login(...) {
    Member m = service.login(...);
    if (m != null) return "redirect:/";
    return "redirect:/login?error";
    // 세션 저장 X
}
v3 — HttpSession 보관

public String login(..., HttpSession s) {
    Member m = service.login(...);
    if (m != null) {
        s.setAttribute("loginUser", m); // ⭐
        return "redirect:/";
    }
    return "redirect:/login?error";
}

👉 변경량: 한 줄. 합계 3 줄로 v2 → v3.

JSP 에서 로그인 상태 표시


<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

<c:choose>
    <c:when test="${not empty sessionScope.loginUser}">
        <span>${sessionScope.loginUser.id}님</span>
        <a href="/logout">로그아웃</a>
    </c:when>
    <c:otherwise>
        <a href="/login">로그인</a>
        <a href="/signup">회원가입</a>
    </c:otherwise>
</c:choose>

👉 sessionScope.loginUser 로 JSP 의 EL 에서 세션 접근. (v4 에서 nick 추가 후엔 표시명을 loginUser.nick 으로.)

로그인 결과 직접 확인

  1. 회원가입 후 DB 의 pwd 컬럼을 직접 봄
    SELECT id, pwd FROM mymember;
    값이 $2a$10$... 형태로 저장됐는지 확인 (60자 길이)
  2. 로그인 후 F12 → Application → Cookies 에서 JSESSIONID 보임
  3. 다른 페이지로 이동해도 로그인 상태 유지
  4. 로그아웃 → 쿠키 그대로지만 서버 보관함은 비워짐

실험 — 일부러 깨뜨려보기

실험증상
encoder.encode 빼기평문 저장 → v2 의 함정 재현
encoder.matchesequals로그인 항상 실패 (해시는 평문과 다름)
session.setAttribute 빼기로그인 페이지에선 성공해도 다른 페이지에선 풀림
invalidate() 빼기로그아웃해도 사용자 정보가 안 사라짐

지금 v3 의 한계

아직 남은 문제
  • 각 컨트롤러마다 if (session.getAttribute("loginUser") == null) redirect 를 직접 써야 함
  • 이게 게시판 작성·수정·삭제·마이페이지 등 여기저기 반복됨
  • 코드가 지저분

→ 다음 차시 v4 깔끔한 로그인 — 인터셉터로 자동화.

v3 의 의미

오늘이 우리 프로젝트의 다섯 번째 마일스톤:

  • v0 — Hello Servlet
  • v0.5 — DB 없이 종단간
  • v1 — 첫 DB 종단간
  • v2 — 원시 인증 (의도적으로 위험)
  • v3 — 안전한 인증 (오늘)
  • v4 — 깔끔한 로그인 (다음)

v3 부터 우리 사이트는 실제로 사용 가능한 인증을 갖춥니다.

🔄 Before / After

v2 끝

평문 저장 + 무상태. 보안적으로 위험하고, 페이지 이동 시 로그인 풀림.

v3 끝

BCrypt 단방향 해시 + HttpSession. 평문은 어디에도 안 남고, 페이지 이동 시 로그인 유지.

이번 차시의 데이터 흐름

로그인 폼
Controller
Service
BCrypt 비교
HttpSession
다음 페이지
(상태 유지)
HttpSession 박스가 등장 — 페이지 사이의 「기억」을 담당

정리

오늘 들고 가는 것

  • BCrypt = 단방향 해시. 서버도 진짜 비밀번호를 모름
  • encoder.encode, encoder.matches 두 메서드
  • session.setAttribute / invalidate — 로그인 유지·해제
  • v2 → v3 의 변경량: 3 줄
  • v3 — 우리 프로젝트의 실용 인증

다음: v4 깔끔한 로그인 — 인터셉터로 가드를 자동화.