개주 훈련일지/🏋️ 전집중 호흡 훈련

비밀번호 보안 개선기: bcrypt 해시 저장/검증 구조 (Spring Boot + JDBC)

lshfood2 2026. 2. 22. 14:18

[ 비밀번호 평문 저장 문제 개선 ]

이번 작업에서는 회원 기능 중에서도

가장 기본이지만 나중으로 미루기 쉬운

비밀번호 처리 구조를 정리했다.

 

초기 구현에서는 회원가입, 로그인,

비밀번호 변경 기능이 먼저 동작하는 데

집중하다 보니 비밀번호를 평문으로 저장하고,

SQL에서 직접 비교하는 구조로 되어 있었다.

 

기능은 잘 돌아갔지만 이 상태는

보안 관점에서 오래 가져가기 어려웠다.

 

특히 비밀번호는 회원가입만

고친다고 끝나는 게 아니라

  • 로그인
  • 현재 비밀번호 확인
  • 비밀번호 변경
  • 비밀번호 찾기 후 재설정

까지 함께 점검해야 실제로 의미 있는 개선이 된다.

이번 개선의 핵심은 아래 3가지다.

  1. 저장은 encode()로 해시 처리
  2. 검증은 SQL 비교 대신 matches()로 처리
  3. DAO와 Service 역할을 분리해서
    유지보수 가능한 구조로 정리

일반 비밀번호(평문)와 해시가 적용되어 달라진 비밀번호의 차이

 

결과적으로 이제는 비밀번호가

DB에 평문으로 저장되지 않고

 

로그인/비밀번호 변경/재설정 흐름에서도

해시 기반 검증이 동작하는 구조가 됐다.


문제 상황

기존 회원 비밀번호 처리 흐름은 대략 이랬다.

  1. 회원가입 시 입력 비밀번호를 DB에 그대로 저장
  2. 로그인 시 DAO SQL에서
    member_name = ? AND
    member_password = ? 로 비교
  3. 비밀번호 변경 시 새 비밀번호도 그대로 저장
  4. 현재 비밀번호 확인도 SQL 평문 비교로 처리

이 구조는 구현은 단순하지만 다음 문제가 있었다.

  • DB 유출 시 비밀번호가 그대로 노출될 수 있음
  • 해시 전환 시 SQL 비교 구조가 오히려 발목을 잡음
  • 회원가입만 수정하면 비밀번호 변경/재설정 경로는
    그대로 남는 반쪽짜리 개선이 됨

즉, 이번 작업은 단순히 회원가입 로직만

손보는 작업이 아니라 비밀번호가 오가는

주요 흐름 전체를 정리하는 작업이었다.


원인 정리

문제의 핵심은 단순히 비밀번호를

평문으로 저장했다에서 끝나지 않았다.

 

초기 구조에서는 비밀번호 비교 자체가 DAO

(SQL) 안에 들어가 있었고, 이 구조 때문에

해시 기반 검증으로 바꾸기 어려운 상태였다.

 

정리하면 원인은 두 가지였다.

1) 백엔드 구조

  • DAO가 비밀번호 비교까지 담당하고 있었음

2) 역할 분리 부족

  • 비밀번호 정책(해시 생성/검증)이
    Service에 모이지 않고 SQL에 섞여 있었음

해시 기반 검증은 SQL 문자열 비교가 아니라

애플리케이션 로직에서 matches()로 처리해야 한다.

 

그래서 이번에는 단순 치환이 아니라,

비밀번호 검증 책임 자체를

Service로 옮기는 방향으로 수정했다.


개선 목표

이번 개선의 목표는 명확했다.

  • 비밀번호 저장은 해시로만 저장
  • 로그인/현재 비밀번호 확인은 matches()로 검증
  • DAO는 조회/업데이트만 담당
  • Service는 비밀번호 정책 처리 담당
  • 기존 평문 계정은 임시 호환으로
    바로 로그인 불가 상태가 되지 않게 유지

이렇게 역할을 분리하면 기능 동작을 유지하면서도

보안 구조를 한 단계 끌어올릴 수 있다.


먼저 확인한 것: DB 컬럼 길이

해시 적용 전에 가장 먼저 확인한 건

member_password 컬럼 길이였다.

 

bcrypt 해시 문자열은 길기 때문에

컬럼 길이가 짧으면 저장 중 잘려서

이후 matches() 검증이 전부 실패할 수 있다.

 

내 DDL에서는 이미 아래처럼 되어 있어서

추가 수정 없이 진행 가능했다.

member_password VARCHAR(255) NOT NULL
  • VARCHAR(255)면 bcrypt 해시 저장에 충분하다.

적용 전/후 흐름 비교

> 적용 전

1) 회원가입

Controller -> Service -> DAO -> DB 평문 저장

 

2) 로그인

Controller -> Service -> DAO
DAO SQL에서 member_password = ? 직접 비교

 

3) 비밀번호 변경/재설정

Controller -> Service -> DAO -> DB 평문 저장

 

> 적용 후

1) 회원가입

Controller -> Service(encode) -> DAO -> DB 해시 저장

 

2) 로그인

Controller -> Service -> DAO(사용자 조회만)
Service에서 matches(raw, encoded) 검증

 

3) 비밀번호 변경/재설정

Controller -> Service(encode) -> DAO -> DB 해시 저장

 

4)  현재 비밀번호 확인

Controller -> Service -> DAO(저장된 비밀번호 조회만)
Service에서 matches() 검증

수정한 파일 목록

이번 작업에서 실제로 손댄 파일은 크게 4개다.

  1. pom.xml
  2. PasswordConfig.java
  3. MemberServiceImpl.java
  4. MemberDAO.java

아래부터 핵심 코드 예시와 함께 정리한다.


백엔드 개선 1: crypto 의존성 추가

전체 시큐리티 스타터를 붙이지 않고,

비밀번호 해시 기능만 쓰기 위해

spring-security-crypto만 추가했다.

 

이렇게 하면 기존 로그인/세션 구조를

크게 건드리지 않고 PasswordEncoder만 사용할 수 있다.

<!-- 변경: 비밀번호 해시 기능(PasswordEncoder) 사용 -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-crypto</artifactId>
</dependency>

포인트

  • spring-boot-starter-security가 아니라
    spring-security-crypto만 추가
  • 기존 프로젝트에 보안 필터 체인
    영향 없이 해시 기능만 도입 가능

백엔드 개선 2: PasswordEncoder Bean 등록

해시 생성/검증은

서비스 여러 곳에서 쓸 수 있으므로

Bean으로 등록해두는 편이 깔끔하다.

 

파일 위치 예시

src/main/java/fourcheetah/animale/web/config/PasswordConfig.java
package fourcheetah.animale.web.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

/*
 비밀번호 해시/검증용 PasswordEncoder Bean 등록 
 역할
 - 저장 시: encode(rawPassword)
 - 검증 시: matches(rawPassword, encodedPassword)
 */
@Configuration
public class PasswordConfig {

    /*
     변경: PasswordEncoder Bean 등록
     반환 타입은 인터페이스(PasswordEncoder)로 두고,
     실제 구현체는 BCryptPasswordEncoder 사용
     - 서비스가 구현체에 직접 의존하지 않음
     - 나중에 교체/테스트가 쉬움
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        // bcrypt 사용 (기본 cost = 10)
        return new BCryptPasswordEncoder();
    }
}

백엔드 개선 3: Service에서 저장 시 encode() 처리

이번 작업에서 가장 먼저

적용한 건 저장 경로 해시 처리였다.

  • 회원가입
    : MEMBER_JOIN
  • 비밀번호 변경/재설정
    : MEMBER_PASSWORD_UPDATE

이 두 경로에서만 encode()가 실행되도록

조건 분기로 제한했다.

 

1) MemberServiceImpl 생성자 주입 + PasswordEncoder 주입

package fourcheetah.animale.web.repository.member;

import java.util.List;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import fourcheetah.animale.web.dto.member.MemberDTO;
import fourcheetah.animale.web.dto.member.MemberWarningDTO;
import fourcheetah.animale.web.service.member.MemberService;

@Service
public class MemberServiceImpl implements MemberService {

    // 변경: 생성자 주입으로 의존성 명확화
    private final MemberDAO memberDAO;

    // 변경: PasswordConfig에서 등록한 Bean 주입
    private final PasswordEncoder passwordEncoder;

    public MemberServiceImpl(MemberDAO memberDAO, PasswordEncoder passwordEncoder) {
        this.memberDAO = memberDAO;
        this.passwordEncoder = passwordEncoder;
    }

    // ...
}

 

2) 회원가입 시 해시 저장

@Override
public boolean insert(MemberDTO dto) {
    // 변경: 회원가입(MEMBER_JOIN)일 때만 해시 처리
    // - 다른 insert 조건까지 건드리지 않도록 condition으로 제한
    if (dto != null && "MEMBER_JOIN".equals(dto.getCondition())) {
        String rawPassword = dto.getMemberPassword();

        // null/공백 방어
        // - 컨트롤러 validation과 별개로 서비스에서도 한 번 더 체크
        if (rawPassword != null && !rawPassword.trim().isEmpty()) {

            // 이중 해시 방지
            // - 현재는 보통 평문 입력이 들어오지만,
            //   추후 재호출/구조 변경 실수 방지용 안전장치
            if (!isBcryptHash(rawPassword)) {
                dto.setMemberPassword(passwordEncoder.encode(rawPassword));
            }
        }
    }
    return memberDAO.insert(dto);
}

 

3) 비밀번호 변경/재설정 시 해시 저장

@Override
public boolean update(MemberDTO dto) {
    // 변경: 비밀번호 변경/재설정(MEMBER_PASSWORD_UPDATE)만 해시 처리
    // - 닉네임/프로필/캐시 등 다른 update는 건드리지 않음
    if (dto != null && "MEMBER_PASSWORD_UPDATE".equals(dto.getCondition())) {
        String rawPassword = dto.getMemberPassword();

        // null/공백 방어
        if (rawPassword != null && !rawPassword.trim().isEmpty()) {

            // 이중 해시 방지
            if (!isBcryptHash(rawPassword)) {
                dto.setMemberPassword(passwordEncoder.encode(rawPassword));
            }
        }
    }
    return memberDAO.update(dto);
}

백엔드 개선 4: DAO SQL 비밀번호 비교 제거

해시 기반 검증으로 바꾸려면

DAO에서 비밀번호 비교를 제거해야 한다.

 

핵심은 다음 두 가지다.

  • 로그인 SQL에서 AND member_password = ? 제거
  • 현재 비밀번호 확인 SQL도
    저장된 비밀번호 조회만 하도록 변경

1) 로그인 SQL 수정

// 변경 후: 로그인 SQL에서 비밀번호 조건 제거
// - DAO는 사용자 조회만 담당
// - 실제 비밀번호 검증은 Service에서 matches() 처리
private static final String SELECT_MEMBER_LOGIN =
        "SELECT "
      + " member_id             AS MEMBER_ID, "
      + " member_name           AS MEMBER_NAME, "
      + " member_password       AS MEMBER_PASSWORD, "
      + " member_nickname       AS MEMBER_NICKNAME, "
      + " member_cash           AS MEMBER_CASH, "
      + " member_role           AS MEMBER_ROLE, "
      + " member_profile_image  AS MEMBER_PROFILE_IMAGE, "
      + " member_email          AS MEMBER_EMAIL, "
      + " valid_report_count    AS VALID_REPORT_COUNT, "
      + " last_warning_at       AS LAST_WARNING_AT, "
      + " notice_pending        AS NOTICE_PENDING, "
      + " notice_message        AS NOTICE_MESSAGE, "
      + " member_profile_color  AS MEMBER_PROFILE_COLOR, "
      + " member_nickname_color AS MEMBER_NICKNAME_COLOR "
      + "FROM MEMBER "
      + "WHERE member_name = ? "
      + "AND member_role IN ('ACTIVE','ADMIN')";

 

2) 현재 비밀번호 확인용 SQL 수정

// 변경 후: 현재 비밀번호 확인 SQL은 저장값 조회만 수행
// - Service에서 matches()로 비교하기 위해 member_password를 함께 조회
private static final String SELECT_PASSWORD_CHECK =
        "SELECT member_id AS MEMBER_ID, member_password AS MEMBER_PASSWORD "
      + "FROM MEMBER "
      + "WHERE member_id = ?";

 

3) 비밀번호 조회용 RowMapper 추가

// 변경: 비밀번호 검증용 RowMapper
// - 현재 비밀번호 확인/로그인 검증에서 저장된 password 값을 읽기 위해 사용
private static final RowMapper<MemberDTO> ID_PASSWORD_ROW_MAPPER = (rs, rowNum) -> {
    MemberDTO data = new MemberDTO();
    data.setMemberId(rs.getInt("MEMBER_ID"));
    data.setMemberPassword(rs.getString("MEMBER_PASSWORD"));
    return data;
};

 

4) DAO selectOne 분기 수정

if ("MEMBER_LOGIN".equals(condition)) {
    // 변경: 비밀번호 비교 제거
    // DAO는 사용자 조회만 수행
    return jdbcTemplate.queryForObject(SELECT_MEMBER_LOGIN, FULL_ROW_MAPPER, dto.getMemberName());
}

if ("MEMBER_PASSWORD_CHECK".equals(condition)) {
    // 변경: 저장된 비밀번호(해시) 조회만 수행
    // 실제 비교는 Service에서 matches()
    return jdbcTemplate.queryForObject(SELECT_PASSWORD_CHECK, ID_PASSWORD_ROW_MAPPER, dto.getMemberId());
}

백엔드 개선 5: Service에서 로그인/현재 비밀번호 확인을 matches()로 검증

이 부분이 이번 작업의 핵심이다.

이제 DAO는 비밀번호를 비교하지 않고,

Service에서만 비밀번호 일치 여부를 판단한다.

 

1) selectOne()에서 로그인/현재 비밀번호 확인 분기 처리

@Override
public MemberDTO selectOne(MemberDTO dto) {
    if (dto == null) {
        return null;
    }

    String condition = dto.getCondition();

    // 변경: 로그인 / 현재비밀번호확인만 Service에서 비밀번호 검증 처리
    if ("MEMBER_LOGIN".equals(condition) || "MEMBER_PASSWORD_CHECK".equals(condition)) {
        String rawPassword = dto.getMemberPassword();

        // null/공백 방어
        if (rawPassword == null || rawPassword.trim().isEmpty()) {
            return null;
        }

        // DAO는 이제 사용자 조회만 수행 (비밀번호 비교 안 함)
        MemberDTO target = memberDAO.selectOne(dto);
        if (target == null) {
            return null;
        }

        // DB 저장값 (해시 또는 기존 평문)
        String savedPassword = target.getMemberPassword();

        // 핵심: 해시 비교(matches) + 기존 평문 계정 임시 호환
        boolean matched = isPasswordMatched(rawPassword, savedPassword);
        if (!matched) {
            return null;
        }

        return target;
    }

    // 그 외 조건은 기존처럼 DAO 위임
    return memberDAO.selectOne(dto);
}

 

2) 해시 검증 + 기존 평문 계정 임시 호환

프로젝트 진행 중에는 이미 생성된 평문 계정이

남아 있을 수 있어서 바로 해시만 강제하면

기존 계정 로그인이 모두 실패할 수 있다.

 

그래서 임시로 다음 정책을 적용했다.

  • 저장값이 bcrypt 해시면 matches()
  • 저장값이 평문이면 equals() fallback
/*
 변경: 비밀번호 일치 여부 판단 
 정책
 1) 저장값이 bcrypt 해시이면 matches(raw, encoded)
 2) 저장값이 구 평문이면 raw.equals(saved) (임시 호환)
 ※ 전체 계정 해시 전환 완료 후에는 평문 fallback 제거 권장
 */
private boolean isPasswordMatched(String rawPassword, String savedPassword) {
    if (rawPassword == null || savedPassword == null) {
        return false;
    }

    // 저장값이 bcrypt 해시인 경우: 정식 비교
    if (isBcryptHash(savedPassword)) {
        try {
            return passwordEncoder.matches(rawPassword, savedPassword);
        } catch (Exception e) {
            // 저장값 형식 이상 등 예외 방어
            System.out.println("[MemberService] password matches() error: " + e.getMessage());
            return false;
        }
    }

    // 임시 호환: 기존 평문 계정
    return rawPassword.equals(savedPassword);
}

 

3) bcrypt 해시 문자열 여부 체크 (이중 해시 방지 + 구분용)

/*
 변경: bcrypt 해시 문자열 여부 간단 체크
 용도
 - 이중 해시 방지
 - 저장값이 해시인지 평문인지 판단
 */
private boolean isBcryptHash(String value) {
    if (value == null) {
        return false;
    }
    return value.startsWith("$2a$")
        || value.startsWith("$2b$")
        || value.startsWith("$2y$");
}

실제 테스트/검증 결과

이번 작업은 코드 수정만 하고 끝내지 않고,

실제로 회원가입/변경/재설정/로그인 흐름과

DB 값을 함께 확인했다.

 

1) 회원가입 테스트

회원가입 후 DB에서 member_password를 조회했더니

평문이 아니라 bcrypt 해시 문자열로 저장됐다.

 

예시 형태

$2a$10$MWnhUMAf.p2K3.gdWk5Og.Qr788DtJNY9ywC2MlPsNJnZYZGuA5Zu

이 값은 정상적인 bcrypt 해시 포맷이다.

 

2) 비밀번호 변경 테스트

비밀번호 변경 후 DB 값을 확인했더니

기존 해시가 새로운 해시 문자열로 변경되었다.

 

예시 형태

변경 전
$2a$10$LibYTVC4iken3CMDEtPde.HD5PuTAzmjq5NaF/EkNelX2LRUKR5he

변경 후
$2a$10$66nFsdnuNALyi7/fLXPfTOKXU0ROgeWpwwgyrfd2HtpgnurqIEh6K

중요한 점은 해시 문자열이

달라지는 것이 정상이라는 점이다.

bcrypt는 랜덤 솔트(salt)를 사용하므로
같은 비밀번호여도 결과 문자열이 매번 달라질 수 있다.

 

3) 비밀번호 찾기 후 재설정 테스트

비밀번호 찾기 → 본인인증
→ 비밀번호 재설정 → 로그인까지 직접 확인했다.

 

결과

  • 변경된 비밀번호로 로그인 성공
  • DB 값도 bcrypt 해시로 변경 확인

즉, 회원가입/로그인/비밀번호 변경뿐 아니라

비밀번호 재설정 경로까지 해시 저장/검증이

정상 동작하는 상태가 됐다.


이번 작업으로 실제 바뀐 기능 범위

사용자 체감 기준으로 보면 바뀐 기능은 다음과 같다.

  • 회원가입 비밀번호 저장 방식 변경
  • 로그인 비밀번호 검증 방식 변경
  • 비밀번호 변경 저장 방식 변경
  • 현재 비밀번호 확인 검증 방식 변경
  • 비밀번호 찾기 후 재설정 저장 방식 변경

즉, 단순히 회원가입만 바꾼 것이 아니라

회원 비밀번호 흐름 전반을 정리한 작업이다.


작업하면서 중요했던 포인트

 

1) DB 컬럼 길이부터 확인

member_password 컬럼 길이가 부족하면

해시 문자열이 잘려서 이후 검증이 전부 실패한다.

 

이번 DDL에서는 VARCHAR(255)로 되어 있어

추가 변경 없이 진행할 수 있었다.

 

2) DAO와 Service 역할 분리

해시 기반 검증은 SQL 비교가 아니라

Service 계층에서 처리해야 한다.

이 원칙을 지켜야 이후 유지보수가 쉬워진다.

 

3) 단계적으로 적용

이번 작업은 아래 순서로 진행했다.

  1. DB 컬럼 길이 확인
  2. crypto 의존성 추가
  3. PasswordEncoder Bean 등록
  4. 저장 경로(회원가입/비밀번호 변경/재설정) 해시 적용
  5. 로그인/현재비밀번호확인 검증 구조 전환
  6. 실제 회원가입/로그인/비밀번호 변경/비밀번호 재설정 테스트

이 순서로 진행하니,

중간에 문제가 생겨도 원인 추적이 쉬웠다.


마무리

회원 기능은 '작동한다'에서 끝내기 쉬운 영역이지만,

비밀번호 처리만큼은 초기에 반드시 점검해야 하는 부분이다.

 

이번 작업에서는 기능 동작을 유지하면서도

  • 비밀번호 저장을 해시 기반으로 전환하고
  • 로그인/현재비밀번호 검증을 Service 계층으로 옮기고
  • 비밀번호 재설정 흐름까지 함께 점검

하면서 회원 보안의 기본 구조를 정리할 수 있었다.

작동만 하던 상태에서 한 단계 더 나아가,

실제 운영을 고려한 구조로 바꾼 작업이었다.