본문 바로가기

비밀번호 해시 역할

PasswordHasher를 hash·matches 계약으로 확장하고 테스트 대역과 PBKDF2 어댑터, 로그인 실패 경계를 같은 실행 가능한 계약으로 검증합니다.

앞 글의 PasswordHasher는 회원가입에 필요한 hash 하나만 가졌습니다. 로그인을 추가하려면 저장된 해시와 사용자가 입력한 원문이 맞는지 확인하는 matches가 필요합니다. 인터페이스에 추상 메서드를 추가하면 그 인터페이스를 구현한 기존 클래스는 컴파일되지 않습니다. 회원가입 서비스는 여전히 hash만 호출하므로 바뀌지 않지만, TestPasswordHasher와 운영용 어댑터는 두 메서드를 모두 구현해야 합니다.

앞 글과 같은 프로젝트에서 PasswordHasher와 TestPasswordHasher를 이번 글의 버전으로 바꾸고, 설정과 PBKDF2, 로그인 파일을 새로 추가합니다. hash만 있는 이전 파일과 확장된 파일을 같은 패키지에 함께 두지 않습니다. 기존 가입 테스트는 그대로 두어 역할을 넓힌 뒤에도 함께 통과하는지 확인합니다. 이번 글의 두 테스트 클래스만 실행하려면 다음 명령을 씁니다.

해시와 로그인 테스트 실행
./gradlew test --tests board.member.PasswordHasherTest --tests board.member.LoginServiceTest

hash와 matches를 같은 포트에 배치

hash는 저장할 문자열을 만들고, matches는 그 문자열을 해석해 입력된 원문이 맞는지 검사합니다. 두 메서드를 한 인터페이스에 두면, 호출하는 쪽은 솔트나 반복 횟수, 버전, 결과 바이트의 형식을 직접 해석할 필요가 없습니다.

board-core/src/main/java/board/member/PasswordHasher.java
package board.member;

public interface PasswordHasher {
    String hash(String rawPassword);
    boolean matches(String rawPassword, String encodedPasswordHash);
}

두 메서드 모두 원문 비밀번호의 앞뒤 공백을 지우거나 대소문자를 바꾸지 않습니다. 가입 규칙은 SignUpCommand가 검사하고, 해시 역할은 받은 문자열을 정확히 그대로 해시하고 검증합니다.


테스트 대역도 matches 구현

앞 글의 테스트 대역은 Java 문자열의 해시 코드를 쓰므로 충돌에 강하지 않고, 솔트도 없어 비밀번호를 안전하게 저장하지 못합니다. matches를 추가하면 서비스의 흐름은 확인할 수 있지만, 실제 인증의 보안까지 확인되는 것은 아닙니다.

board-core/src/test/java/board/member/TestPasswordHasher.java
package board.member;

import java.util.Objects;

public final class TestPasswordHasher implements PasswordHasher {
    private static final int MAX_RAW_PASSWORD_LENGTH = 1_024;
    private static final int MAX_ENCODED_LENGTH = 64;

    @Override
    public String hash(String rawPassword) {
        Objects.requireNonNull(rawPassword, "rawPassword must not be null");
        if (rawPassword.length() > MAX_RAW_PASSWORD_LENGTH) {
            throw new IllegalArgumentException("rawPassword is too long");
        }
        return "{test}" + Integer.toHexString(rawPassword.hashCode());
    }

    @Override
    public boolean matches(String rawPassword, String encodedPasswordHash) {
        if (rawPassword == null
                || rawPassword.length() > MAX_RAW_PASSWORD_LENGTH
                || encodedPasswordHash == null
                || encodedPasswordHash.length() > MAX_ENCODED_LENGTH) {
            return false;
        }
        return encodedPasswordHash.equals(hash(rawPassword));
    }
}

이 대역의 equals 비교는 빠르고 결과가 항상 같은 서비스 테스트를 위한 것입니다. 문자열 해시 코드나 이런 비교 방식이 운영 인증에 안전하다는 뜻은 아닙니다.


PBKDF2 정책 값

운영용 예제는 JDK의 PBKDF2WithHmacSHA256을 씁니다. 정책 값은 객체를 만들 때 검사하고, 저장된 문자열이 요구할 수 있는 반복 횟수와 솔트, 결과 길이에도 상한을 둡니다. 아래의 600,000회는 OWASP의 비밀번호 저장 지침이 PBKDF2-HMAC-SHA256에 권하는 반복 횟수를 이 교재의 예제 정책으로 삼은 값입니다. 모든 환경에 영원히 맞는 값은 아니므로, 운영에서는 알고리즘의 선택과 실제 서버에서 측정한 비용, 이미 저장된 해시와의 호환을 함께 검토해야 합니다. 이 예제의 v1 형식은 더 약한 반복 횟수를 호환용으로 남기지 않고 600,000회부터 허용합니다.

board-core/src/main/java/board/member/PasswordHashSettings.java
package board.member;

public record PasswordHashSettings(
        int iterations,
        int saltLengthBytes,
        int hashLengthBytes
) {
    public static final int MIN_ITERATIONS = 600_000;
    public static final int MAX_ITERATIONS = 5_000_000;
    public PasswordHashSettings {
        if (iterations < MIN_ITERATIONS || iterations > MAX_ITERATIONS
                || saltLengthBytes < 16 || saltLengthBytes > 64
                || hashLengthBytes < 32 || hashLengthBytes > 64) {
            throw new IllegalArgumentException(
                    "password hash settings are outside supported ranges");
        }
    }

    public static PasswordHashSettings productionDefaults() {
        return new PasswordHashSettings(600_000, 16, 32);
    }

}

반복 횟수에 상한을 두는 것은, 공격자가 저장 문자열을 조작해 엄청난 반복 횟수를 적어 넣으면 검증 한 번에 서버가 오래 붙잡히기 때문입니다. 상한을 올릴 때는 이 위험도 함께 검토합니다.


self-describing PBKDF2 어댑터

저장 형식에는 $pbkdf2-sha256$v1$600000$<솔트>$<해시>처럼 알고리즘, 버전, 반복 횟수, 솔트, 결과를 함께 기록합니다. 이렇게 스스로를 설명하는(self-describing) 형식이면 나중에 정책을 바꾸어도 예전에 저장한 해시를 그 해시의 정보로 검증할 수 있습니다. matches는 전체 길이와 고정 표식, 반복 횟수의 범위, Base64 URL 형식의 정규형, 솔트와 결과의 정확한 바이트 길이를 모두 확인한 뒤에만 비용이 큰 PBKDF2 계산을 실행합니다.

board-core/src/main/java/board/member/Pbkdf2PasswordHasher.java
package board.member;

import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;

public final class Pbkdf2PasswordHasher implements PasswordHasher {
    private static final String ALGORITHM = "PBKDF2WithHmacSHA256";
    private static final String FORMAT = "pbkdf2-sha256";
    private static final String VERSION = "v1";
    private static final int MAX_RAW_PASSWORD_LENGTH = 1_024;
    private static final int MAX_ENCODED_LENGTH = 512;
    private static final Base64.Encoder ENCODER =
            Base64.getUrlEncoder().withoutPadding();
    private static final Base64.Decoder DECODER = Base64.getUrlDecoder();

    private final PasswordHashSettings settings;
    private final SecureRandom random;

    public Pbkdf2PasswordHasher(PasswordHashSettings settings) {
        this(settings, new SecureRandom());
    }

    Pbkdf2PasswordHasher(PasswordHashSettings settings, SecureRandom random) {
        this.settings = Objects.requireNonNull(
                settings, "settings must not be null");
        this.random = Objects.requireNonNull(
                random, "random must not be null");
    }

    @Override
    public String hash(String rawPassword) {
        requireHashInput(rawPassword);
        byte[] salt = new byte[settings.saltLengthBytes()];
        random.nextBytes(salt);
        byte[] derived = null;
        try {
            derived = derive(rawPassword, salt, settings.iterations());
            return "$%s$%s$%d$%s$%s".formatted(
                    FORMAT, VERSION,
                    settings.iterations(),
                    ENCODER.encodeToString(salt),
                    ENCODER.encodeToString(derived));
        } finally {
            clear(salt);
            clear(derived);
        }
    }

    @Override
    public boolean matches(String rawPassword, String encodedPasswordHash) {
        if (rawPassword == null
                || rawPassword.length() > MAX_RAW_PASSWORD_LENGTH) {
            return false;
        }
        ParsedHash parsed = parse(encodedPasswordHash);
        if (parsed == null) {
            return false;
        }

        byte[] derived = null;
        try {
            derived = derive(rawPassword, parsed.salt(), parsed.iterations());
            return MessageDigest.isEqual(parsed.hash(), derived);
        } finally {
            clear(derived);
            clear(parsed.salt());
            clear(parsed.hash());
        }
    }

    private ParsedHash parse(String encodedPasswordHash) {
        if (encodedPasswordHash == null
                || encodedPasswordHash.length() > MAX_ENCODED_LENGTH) {
            return null;
        }
        String[] parts = encodedPasswordHash.split("\\$", -1);
        if (parts.length != 6
                || !parts[0].isEmpty()
                || !FORMAT.equals(parts[1])
                || !VERSION.equals(parts[2])
                || !parts[3].matches("[1-9][0-9]{0,7}")) {
            return null;
        }

        int iterations = Integer.parseInt(parts[3]);
        if (iterations < PasswordHashSettings.MIN_ITERATIONS
                || iterations > PasswordHashSettings.MAX_ITERATIONS) {
            return null;
        }

        byte[] salt = decodeCanonical(parts[4]);
        byte[] hash = decodeCanonical(parts[5]);
        if (salt == null || hash == null
                || salt.length != settings.saltLengthBytes()
                || hash.length != settings.hashLengthBytes()) {
            clear(salt);
            clear(hash);
            return null;
        }
        return new ParsedHash(iterations, salt, hash);
    }

    private static byte[] decodeCanonical(String encoded) {
        try {
            byte[] decoded = DECODER.decode(encoded);
            if (!ENCODER.encodeToString(decoded).equals(encoded)) {
                clear(decoded);
                return null;
            }
            return decoded;
        } catch (IllegalArgumentException ignored) {
            return null;
        }
    }

    private byte[] derive(String rawPassword, byte[] salt, int iterations) {
        char[] password = rawPassword.toCharArray();
        var spec = new PBEKeySpec(password, salt, iterations,
                settings.hashLengthBytes() * Byte.SIZE);
        try {
            return SecretKeyFactory.getInstance(ALGORITHM)
                    .generateSecret(spec)
                    .getEncoded();
        } catch (GeneralSecurityException exception) {
            throw new IllegalStateException(
                    "PBKDF2 is unavailable", exception);
        } finally {
            spec.clearPassword();
            Arrays.fill(password, '\0');
        }
    }

    private static void requireHashInput(String rawPassword) {
        Objects.requireNonNull(rawPassword, "rawPassword must not be null");
        if (rawPassword.length() > MAX_RAW_PASSWORD_LENGTH) {
            throw new IllegalArgumentException("rawPassword is too long");
        }
    }

    private static void clear(byte[] value) {
        if (value != null) {
            Arrays.fill(value, (byte) 0);
        }
    }

    private record ParsedHash(int iterations, byte[] salt, byte[] hash) { }
}

decodeCanonical은 디코딩한 바이트를 다시 패딩 없이 인코딩해 원래 문자열과 같은지 확인합니다. Base64 URL 디코더는 끝에 = 패딩이 붙은 문자열도 받아들이므로, 이 확인이 없으면 같은 바이트를 나타내는 서로 다른 문자열이 통과할 수 있습니다. 결과를 비교할 때는 Arrays.equals 대신 MessageDigest.isEqual을 씁니다. 이 메서드는 첫 번째로 다른 바이트에서 바로 멈추지 않고 길이만큼 비교하므로, 비교에 걸린 시간으로 몇 바이트가 맞았는지 알아내기 어렵게 합니다.

형식 오류, 지원하지 않는 버전, 허용 범위를 벗어난 길이와 비용은 모두 false로 끝납니다. 반면 JDK에 알고리즘이 없는 경우는 입력이 잘못된 것이 아니라 서버의 구성이 잘못된 것이므로 IllegalStateException으로 구분합니다.

앞 문서의 hash-only PasswordHasher에 matches를 추가하면 기존 구현이 의도적으로 컴파일 실패하고, TestPasswordHasher와 Pbkdf2PasswordHasher가 raw password와 self-describing encoded hash의 생성·검증 계약을 함께 구현해야 하는 범위를 설명합니다.

PORT EXTENSION · ENCODED FORMAT

hash와 matches는 같은 포트의 두 방향이다

회원가입은 인코딩을 만들고 로그인은 같은 인코딩을 검증합니다. 서비스는 저장 형식을 파싱하지 않고 활성 어댑터에 두 방향을 모두 맡깁니다.

저장 인코딩을 만들고 같은 포트에서 검증하기 회원가입의hash는저장인코딩을생성한다. 로그인의matches는원문후보와그저장인코딩 두입력을 받아 boolean을반환한다. 서비스는인코딩을파싱하지않는다. 형식·버전·비용·길이거절은KDF실행전false가될수있으며 어댑터전체실행경로는아래표에서구분한다. 6노드5간선을유지하고 서버알고리즘부재오류는별도구성실패다. 회원가입 서비스 hash 호출 해시 어댑터 salt·비용·인코딩 저장 인코딩 Member.passwordHash 로그인 서비스 원문 후보 같은 해시 어댑터 matches(raw, encoded) 검증 결과 boolean 원문 저장 원문 인코딩 판정
저장 인코딩을 만들고 같은 포트에서 검증하기 회원가입의hash는저장인코딩을생성한다. 로그인의matches는원문후보와그저장인코딩 두입력을 받아 boolean을반환한다. 서비스는인코딩을파싱하지않는다. 형식·버전·비용·길이거절은KDF실행전false가될수있으며 어댑터전체실행경로는아래표에서구분한다. 6노드5간선을유지하고 서버알고리즘부재오류는별도구성실패다. 회원가입 정확한 원문 hash salt·비용·인코딩 저장 인코딩 passwordHash 로그인 원문 후보 matches 인코딩 파싱 후 비교 boolean 일치 여부 원문 저장 인코딩 원문 판정

HASH-ONLY → HASH + MATCHES

포트 확장은 구현을 깨뜨리고 호출 서비스는 그대로 둔다

  1. 앞 문서의 hash-only 계약

    PasswordHasher.hash(rawPassword)만 있어 회원가입 인코딩을 만듭니다.

  2. 의도적인 source break

    matches(raw, encoded)를 추가하면 기존 구현은 새 메서드를 구현할 때까지 컴파일되지 않습니다.

  3. 두 어댑터 갱신

    TestPasswordHasher와 Pbkdf2PasswordHasher가 같은 확장 포트를 구현합니다.

  4. 클라이언트 영향 분리

    MemberRegistrationService는 계속 hash만, LoginService는 matches만 호출합니다.

같은 포트가 소유하는 두 호출 방향
메서드 호출 시점 입력 출력·소유 책임
hash 가입·비밀번호 변경 변형하지 않은 원문 어댑터가 self-describing 인코딩 생성
matches 로그인 검증 변형하지 않은 원문 + 저장 인코딩 어댑터가 형식을 파싱하고 boolean 반환

같은 포트가 소유하는 두 호출 방향

hash

호출 시점: 가입·비밀번호 변경

입력: 변형하지 않은 원문

출력·소유 책임: 어댑터가 self-describing 인코딩 생성

matches

호출 시점: 로그인 검증

입력: 변형하지 않은 원문 + 저장 인코딩

출력·소유 책임: 어댑터가 형식을 파싱하고 boolean 반환

PBKDF2 v1 인코딩이 비싼 연산 전에 통과해야 하는 문
검증 문 허용 조건 실패 결과
형식·버전 $pbkdf2-sha256$v1$… · 전체 512자 이하 false · 파서 상세 비노출
반복 횟수 600000–5000000 범위의 정수
Base64url 패딩 없는 정규 인코딩으로 다시 썼을 때 동일
바이트 길이 활성 설정의 salt·hash 길이와 정확히 일치

PBKDF2 v1 인코딩이 비싼 연산 전에 통과해야 하는 문

형식·버전

허용 조건: $pbkdf2-sha256$v1$… · 전체 512자 이하

실패 결과: false · 파서 상세 비노출

반복 횟수

허용 조건: 600000–5000000 범위의 정수

실패 결과: false · 파서 상세 비노출

Base64url

허용 조건: 패딩 없는 정규 인코딩으로 다시 썼을 때 동일

실패 결과: false · 파서 상세 비노출

바이트 길이

허용 조건: 활성 설정의 salt·hash 길이와 정확히 일치

실패 결과: false · 파서 상세 비노출

테스트 대역과 운영 어댑터의 같은 계약·다른 보장
구현 같은 계약 구현별 보장 사용 범위
TestPasswordHasher hash + matches 빠르고 결정적 · 충돌 가능 오케스트레이션 테스트 전용
Pbkdf2PasswordHasher hash + matches 무작위 salt · 비용 · MessageDigest.isEqual 검토된 정책의 운영 후보

테스트 대역과 운영 어댑터의 같은 계약·다른 보장

TestPasswordHasher

같은 계약: hash + matches

구현별 보장: 빠르고 결정적 · 충돌 가능

사용 범위: 오케스트레이션 테스트 전용

Pbkdf2PasswordHasher

같은 계약: hash + matches

구현별 보장: 무작위 salt · 비용 · MessageDigest.isEqual

사용 범위: 검토된 정책의 운영 후보

저장 형식은 어댑터의 책임입니다. 서비스는 알고리즘·버전·비용을 읽지 않으며, 정책 갱신 판단은 별도 capability로 확장합니다.

그림의 위쪽은 회원가입의 hash가 저장 문자열을 만들고, 로그인의 matches가 원문 후보와 그 저장 문자열을 받아 boolean을 돌려주는 두 방향을 보여 줍니다. 가운데 단계는 matches를 추가하면 기존 구현이 일부러 컴파일되지 않게 되고, 두 어댑터가 새 메서드를 구현하는 동안 서비스들은 그대로라는 것을 보여 줍니다. 아래쪽 표는 저장 문자열이 비싼 계산 전에 통과해야 하는 네 가지 검사와, 테스트 대역과 운영 어댑터가 같은 메서드를 구현하지만 보장하는 것은 다르다는 점을 정리합니다.


로그인은 실해시 또는 더미 해시를 한 번 검증

이메일이 없을 때 바로 실패를 돌려주면, PBKDF2 계산을 했는지 안 했는지에 따라 응답 시간이 크게 달라집니다. 공격자는 이 차이로 어떤 이메일이 가입되어 있는지 짐작할 수 있습니다. 그래서 서비스를 만들 때 현재의 PasswordHasher로 유효한 더미 해시를 한 번 만들어 두고, 요청마다 회원이 있으면 실제 해시를, 없으면 더미 해시를 골라 matches를 정확히 한 번 호출합니다. 바깥으로 내보내는 실패 예외는 분기도 필드도 없이 메시지를 고정합니다.

board-core/src/main/java/board/member/InvalidCredentialsException.java
package board.member;

public final class InvalidCredentialsException extends RuntimeException {
    public InvalidCredentialsException() { super("invalid credentials"); }
}
board-core/src/main/java/board/member/LoginService.java
package board.member;

import java.util.Locale;
import java.util.Objects;
import java.util.Optional;

public final class LoginService {
    private static final int MAX_EMAIL_LENGTH = 254;
    private static final String DUMMY_RAW_PASSWORD =
            "dummy-password-for-timing-equalization";

    private final MemberRepository members;
    private final PasswordHasher passwordHasher;
    private final String dummyPasswordHash;

    public LoginService(MemberRepository members, PasswordHasher passwordHasher) {
        this.members = Objects.requireNonNull(
                members, "members must not be null");
        this.passwordHasher = Objects.requireNonNull(
                passwordHasher, "passwordHasher must not be null");
        this.dummyPasswordHash = passwordHasher.hash(DUMMY_RAW_PASSWORD);
    }

    public long authenticate(String email, String rawPassword) {
        Optional<Member> member = findMember(email);
        String encodedHash = member.map(Member::passwordHash)
                .orElse(dummyPasswordHash);

        boolean passwordMatches = passwordHasher.matches(rawPassword, encodedHash);
        if (member.isEmpty() || !passwordMatches) {
            throw new InvalidCredentialsException();
        }
        return member.orElseThrow().id();
    }

    private Optional<Member> findMember(String email) {
        if (email == null || email.length() > MAX_EMAIL_LENGTH) {
            return Optional.empty();
        }
        String normalized = email.strip().toLowerCase(Locale.ROOT);
        if (normalized.isEmpty() || normalized.length() > MAX_EMAIL_LENGTH) {
            return Optional.empty();
        }
        return members.findByEmail(normalized);
    }
}

없는 이메일, 정규화한 뒤 허용 길이를 벗어난 이메일, 틀린 비밀번호, 잘못된 저장 형식은 모두 같은 메시지의 InvalidCredentialsException으로 바깥에 드러납니다. findMember가 길이를 미리 확인하는 것은 저장소의 normalizeEmail이 길이를 벗어난 이메일에 예외를 던지기 때문이며, 로그인에서는 그것도 다른 실패와 똑같이 보여야 합니다.

더미 검증은 비싼 계산을 했는지의 차이를 줄일 뿐, 저장소 조회나 스레드 스케줄링, 캐시, 네트워크까지 포함한 요청 전체가 엄밀하게 같은 시간이 걸린다고 보장하지는 않습니다. 또 원문과 저장된 해시는 예외 메시지나 클라이언트 응답, 로그에 넣지 않습니다.


두 구현이 함께 지킬 테스트

구현이 달라도 지켜야 할 규칙이 있습니다. 같은 원문은 맞다고 하고, 다른 원문은 거절하며, 앞뒤 공백도 입력의 일부로 다루고, 잘못된 저장 형식은 예외 없이 거절해야 합니다. 다음 테스트는 한 테스트 메서드가 두 구현을 차례로 돌며 이 규칙을 확인하고, PBKDF2에만 해당하는 검사는 따로 확인합니다.

board-core/src/test/java/board/member/PasswordHasherTest.java
package board.member;

import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

final class PasswordHasherTest {
    private static final String RAW_PASSWORD = "secret-1234";
    private static final PasswordHashSettings TEST_SETTINGS =
            new PasswordHashSettings(600_000, 16, 32);

    @Test
    void 두_구현이_같은_규칙을_지킨다() {
        for (PasswordHasher hasher : List.of(
                new TestPasswordHasher(),
                new Pbkdf2PasswordHasher(TEST_SETTINGS))) {
            var spacedRaw = " raw password: 1234 ";
            var encoded = hasher.hash(spacedRaw);

            assertThat(encoded).isNotBlank().doesNotContain(spacedRaw);
            assertThat(List.of(
                    hasher.matches(spacedRaw, encoded),
                    hasher.matches("wrong", encoded),
                    hasher.matches("raw password: 1234", encoded),
                    hasher.matches(RAW_PASSWORD, null),
                    hasher.matches(RAW_PASSWORD, "{unknown}value")))
                    .containsExactly(true, false, false, false, false);
        }
    }

    @Test
    void 같은_원문도_salt가_달라_인코딩이_다르다() {
        var hasher = new Pbkdf2PasswordHasher(
                TEST_SETTINGS, new SequenceSecureRandom());
        var first = hasher.hash(RAW_PASSWORD);
        var second = hasher.hash(RAW_PASSWORD);

        assertThat(first).isNotEqualTo(second);
        assertThat(hasher.matches(RAW_PASSWORD, first)).isTrue();
    }

    @Test
    void 잘못된_버전_비용_인코딩_길이는_파싱에서_거절한다() {
        var hasher = new Pbkdf2PasswordHasher(TEST_SETTINGS);
        var encoded = hasher.hash(RAW_PASSWORD);
        var parts = encoded.split("\\$", -1);
        var encoder = Base64.getUrlEncoder().withoutPadding();
        var rejected = List.of(
                encoded.replace("$v1$", "$v2$"),
                encoded.replace("$600000$", "$599999$"),
                encoded.replace("$600000$", "$5000001$"),
                encodedWith(parts, parts[4] + "==", parts[5]),
                encodedWith(parts,
                        encoder.encodeToString(new byte[15]), parts[5]),
                encodedWith(parts, parts[4],
                        encoder.encodeToString(new byte[31])));

        assertThat(rejected).allSatisfy(candidate ->
                assertThat(hasher.matches(RAW_PASSWORD, candidate)).isFalse());
    }

    private static String encodedWith(String[] parts, String salt, String hash) {
        return "$%s$%s$%s$%s$%s".formatted(
                parts[1], parts[2], parts[3], salt, hash);
    }

    private static final class SequenceSecureRandom extends SecureRandom {
        private int nextValue;

        @Override
        public void nextBytes(byte[] bytes) {
            Arrays.fill(bytes, (byte) nextValue);
            nextValue += 1;
        }
    }
}

둘째 테스트의 SequenceSecureRandom은 호출할 때마다 솔트를 0으로, 다음에는 1로 채우는 가짜 난수 생성기입니다. 이렇게 솔트를 정해 두면, 같은 원문이라도 솔트가 다르면 저장 문자열이 달라진다는 것을 매번 같은 결과로 확인할 수 있습니다. 셋째 테스트의 여섯 후보는 각각 버전, 반복 횟수의 하한과 상한, 패딩이 붙은 솔트, 15바이트 솔트, 31바이트 결과를 망가뜨린 것으로, 모두 PBKDF2 계산 전에 파싱에서 거절됩니다. 이 테스트는 600,000회 PBKDF2를 여러 번 계산하므로 몇 초 걸릴 수 있습니다.


로그인 실패 표면과 호출 횟수 테스트

로그인 테스트는 회원이 있든 없든, 이메일이 유효하든 아니든 matches가 정확히 한 번 호출되는지, 그리고 네 가지 실패가 같은 예외로 끝나는지 확인합니다. 아래의 assertFailure는 실패 조건 하나마다 새 저장소와 새 기록용 대역을 만들어, 예외의 종류와 메시지, matches 호출 횟수를 같은 방식으로 확인하는 도우미입니다. 둘째 테스트는 이 도우미를 네 번 불러 없는 회원, 정규화 뒤 길이를 넘는 이메일, 틀린 비밀번호, 망가진 저장 해시를 차례로 시험합니다.

board-core/src/test/java/board/member/LoginServiceTest.java
package board.member;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

final class LoginServiceTest {
    private static final String EMAIL = "[email protected]";
    private static final String RAW_PASSWORD = "secret-1234";
    private static final String DUMMY_RAW_PASSWORD =
            "dummy-password-for-timing-equalization";

    @Test
    void 올바른_비밀번호는_회원_ID를_반환한다() {
        var members = new MemoryMemberRepository();
        var recording = new RecordingPasswordHasher();
        Member saved = members.save(Member.signUp(
                EMAIL, "회원", recording.hash(RAW_PASSWORD)));
        var login = new LoginService(members, recording);

        long memberId = login.authenticate(
                " [email protected] ", RAW_PASSWORD);

        assertThat(memberId).isEqualTo(saved.id());
        assertThat(recording.matchesCalls).isEqualTo(1);
    }

    @Test
    void 네_실패는_같은_예외와_한_번의_matches로_수렴한다() {
        String dummyHash = assertFailure(
                null, "[email protected]", RAW_PASSWORD);
        assertThat(new TestPasswordHasher().matches(
                DUMMY_RAW_PASSWORD, dummyHash)).isTrue();
        assertFailure(null, "İ".repeat(254), RAW_PASSWORD);
        String correctHash = new TestPasswordHasher()
                .hash("correct-password");
        assertFailure(correctHash, EMAIL, "wrong-password");
        assertFailure("{broken}", EMAIL, RAW_PASSWORD);
    }

    private static String assertFailure(
            String storedHash, String email, String rawPassword
    ) {
        var members = new MemoryMemberRepository();
        if (storedHash != null) {
            members.save(Member.signUp(EMAIL, "회원", storedHash));
        }
        var recording = new RecordingPasswordHasher();
        var login = new LoginService(members, recording);

        assertThatThrownBy(() -> login.authenticate(email, rawPassword))
                .isInstanceOf(InvalidCredentialsException.class)
                .hasMessage("invalid credentials");
        assertThat(recording.matchesCalls).isEqualTo(1);
        return recording.lastEncodedHash;
    }

    private static final class RecordingPasswordHasher
            implements PasswordHasher {
        private final PasswordHasher delegate = new TestPasswordHasher();
        private int matchesCalls;
        private String lastEncodedHash;

        @Override
        public String hash(String rawPassword) {
            return delegate.hash(rawPassword);
        }

        @Override
        public boolean matches(String rawPassword, String encodedPasswordHash) {
            matchesCalls += 1;
            lastEncodedHash = encodedPasswordHash;
            return delegate.matches(rawPassword, encodedPasswordHash);
        }
    }
}

RecordingPasswordHasher는 실제 판단을 TestPasswordHasher에 맡기고, matches가 몇 번 어떤 저장 문자열로 호출되었는지만 기록합니다. 없는 회원의 경우 기록된 저장 문자열이 더미 해시이며, 그 더미 해시가 더미 원문과 실제로 맞는 유효한 값인지도 확인합니다. 더미 해시가 망가진 값이었다면 검증이 계산 전에 끝나 시간 차이를 줄이는 효과가 사라지기 때문입니다.

로그인 실패와 구현 교체를 같은 계약으로 검증한다

회원 조회 뒤 실해시 또는 더미를 선택하고 matches 한 번과 회원 존재를 합쳐 성공/동일 실패로 끝난다.

LoginService가 회원 존재 시 실제 해시, 부재나 유효하지 않은 이메일이면 활성 PasswordHasher로 만든 유효한 더미 해시를 선택해 matches를 정확히 한 번 호출하고, invalid email·missing·wrong·malformed를 같은 InvalidCredentialsException으로 수렴시키며 하나의 PasswordHasherTest가 두 구현의 공통·구현별 계약을 검증하는 흐름입니다.

ONE VERIFY · UNIFORM FAILURE

로그인 실패와 구현 교체를 같은 계약으로 검증한다

회원은 실제 저장 값을, 부재·유효하지 않은 이메일은 활성 어댑터의 유효한 더미 해시를 한 번 검증합니다. 손상된 저장 값도 같은 외부 실패로 수렴시키되 요청 전체가 엄밀한 상수 시간이라고 과장하지 않습니다.

해시 선택은 나뉘지만 검증 호출은 하나다 회원이있으면실제해시,없거나이메일이부적합하면유효한더미해시를선택하고matches로합류한다. 성공에는회원존재와비밀번호일치가동시에필요하다. 손상된저장값은실해시분기로들어가false가되며 모든실패는같은외부예외로끝난다. 7노드7간선이며호출1회가모든입력의KDF1회나요청전체상수시간을보장하지않는다. 회원 조회 존재 여부로 선택 실제 저장 해시 회원 있음 유효한 더미 해시 회원 없음·이메일 부적합 matches 한 번 원문 후보 + 선택 인코딩 회원 존재 && 일치 boolean과회원상태 판정 회원 ID 반환 두 조건 모두 충족 동일 자격 증명 실패 그 밖의 모든 경우 존재 부재 boolean 충족 그 외
해시 선택은 나뉘지만 검증 호출은 하나다 회원이있으면실제해시,없거나이메일이부적합하면유효한더미해시를선택하고matches로합류한다. 성공에는회원존재와비밀번호일치가동시에필요하다. 손상된저장값은실해시분기로들어가false가되며 모든실패는같은외부예외로끝난다. 7노드7간선이며호출1회가모든입력의KDF1회나요청전체상수시간을보장하지않는다. 회원 조회 존재 여부로 선택 실해시 회원 있음 더미 회원 없음 matches 한 번 raw + 선택 인코딩 회원 존재·일치? 두 조건 모두 필요 회원 ID 모두 충족 같은 실패 그 밖의 경우 존재 부재 boolean 충족 그 외

LOOKUP → SELECT HASH → MATCH ONCE → DECIDE

실제 해시와 더미 해시는 한 검증 지점으로 수렴한다

  1. 회원 조회

    입력 또는 정규화 결과가 254자를 넘거나 비면 조회 전 empty로 두고, 그 밖에는 MemberRepository.findByEmail 결과를 optional로 유지합니다.

  2. 검증할 인코딩 선택

    회원이 있으면 저장 해시를, 없으면 같은 활성 어댑터로 생성한 유효한 더미 해시를 고릅니다.

  3. matches(raw, encoded) 한 번

    존재·부재 두 분기 모두 정확히 한 번 호출하고 boolean을 받은 뒤 결론을 냅니다.

  4. 성공 또는 동일 실패

    회원이 있고 일치할 때만 ID를 반환하며 나머지는 InvalidCredentialsException으로 수렴합니다.

로그인 분기별 검증 횟수와 외부 실패 표면
상태 matches 입력 호출 횟수 외부 결과
올바른 비밀번호 원문 후보 + 실제 저장 해시 1 회원 ID
틀린 비밀번호 원문 후보 + 실제 저장 해시 1 invalid credentials
없는 회원 원문 후보 + 유효한 더미 해시 1 invalid credentials
유효하지 않은 이메일 원문 후보 + 유효한 더미 해시 1 invalid credentials
잘못된 저장 형식 원문 후보 + 손상된 저장 값 1 invalid credentials

로그인 분기별 검증 횟수와 외부 실패 표면

올바른 비밀번호

matches 입력: 원문 후보 + 실제 저장 해시

호출 횟수: 1

외부 결과: 회원 ID

틀린 비밀번호

matches 입력: 원문 후보 + 실제 저장 해시

호출 횟수: 1

외부 결과: invalid credentials

없는 회원

matches 입력: 원문 후보 + 유효한 더미 해시

호출 횟수: 1

외부 결과: invalid credentials

유효하지 않은 이메일

matches 입력: 원문 후보 + 유효한 더미 해시

호출 횟수: 1

외부 결과: invalid credentials

잘못된 저장 형식

matches 입력: 원문 후보 + 손상된 저장 값

호출 횟수: 1

외부 결과: invalid credentials

REDUCED TIMING GAP

KDF 유무 차이 축소

null이 아니고 길이 상한 이내인 원문 후보는 없는 회원에서도 유효한 더미 인코딩으로 PBKDF2를 실행하여 큰 실행 유무 차이를 줄입니다.

NOT STRICT CONSTANT-TIME

요청 전체 보장 아님

null·과도한 원문 길이와 손상된 저장 형식은 KDF 전에 false로 끝날 수 있습니다. 저장소·캐시·비용·스케줄링·네트워크 차이도 남으며 rate limit과 audit은 외부 운영 경계가 담당합니다.

공통 계약과 구현별 추가 테스트의 책임
테스트 층 공통 증거 구현별 추가 증거
PasswordHasherTest same raw true · different raw false · 공백 보존 · malformed false 한 concrete test가 두 구현을 직접 순회
테스트 대역 hash + matches 호출 계약 단순 equals 대역 · 보안성은 주장하지 않음
PBKDF2 어댑터 hash + matches 호출 계약 salt 차이 · bounded cost · 버전·형식 거절
LoginService success ID · 네 실패의 동일 예외 found/missing 모두 matchesCalls == 1

공통 계약과 구현별 추가 테스트의 책임

PasswordHasherTest

공통 증거: same raw true · different raw false · 공백 보존 · malformed false

구현별 추가 증거: 한 concrete test가 두 구현을 직접 순회

테스트 대역

공통 증거: hash + matches 호출 계약

구현별 추가 증거: 단순 equals 대역 · 보안성은 주장하지 않음

PBKDF2 어댑터

공통 증거: hash + matches 호출 계약

구현별 추가 증거: salt 차이 · bounded cost · 버전·형식 거절

LoginService

공통 증거: success ID · 네 실패의 동일 예외

구현별 추가 증거: found/missing 모두 matchesCalls == 1

같은 오류 형식과 더미 검증은 계정 존재 노출을 줄이는 한 층입니다. 원문·저장 해시는 메시지와 로그에서 제외하고, rate limit·audit·rehash 정책은 별도 capability와 운영 경계에 둡니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 회원이 있으면 실제 해시, 없거나 이메일이 부적합하면 유효한 더미 해시를 선택하고 matches로 합류한다. 성공에는 회원 존재와 비밀번호 일치가 동시에 필요하다. 손상된 저장 값은 실제 해시 분기로 들어가 false가 되며 모든 실패는 같은 외부 예외로 끝난다. 7노드와 7간선이며 호출 1회가 모든 입력의 KDF 1회나 요청 전체의 상수 시간을 보장하지 않는다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
7회원이 있으면 실제 해시, 없거나 이메일이 부적합하면 유효한 더미 해시를 선택하고 matches로 합류한다. 성공에는 회원 존재와 비밀번호 일치가 동시에 필요하다. 손상된 저장 값은 실제 해시 분기로 들어가 false가 되며 모든 실패는 같은 외부 예외로 끝난다. 7노드와 7간선이며 호출 1회가 모든 입력의 KDF 1회나 요청 전체의 상수 시간을 보장하지 않는다.
17boolean과 회원 상태 판정
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
회원과 해시 선택

회원이 있으면 실제 해시, 없거나 부적합 이메일이면 유효한 더미 해시를 고릅니다.

존재: 실해시 · 부재·부적합: 더미입력별 검증 표현이 선택됩니다.
matches 합류

선택한 표현으로 matches를 한 번 호출합니다.

호출: matches 1 · 손상 저장 값: false검증 결과가 하나의 판정으로 모입니다.
존재와 일치 판정

회원 존재와 비밀번호 일치가 모두 필요합니다.

성공: 존재 AND 일치 · 실패: 같은 외부 예외외부 실패를 같은 응답 계약으로 좁힙니다.

로그인 실패와 구현 교체를 같은 계약으로 검증한다

회원 조회 뒤 실해시 또는 더미를 선택하고 matches 한 번과 회원 존재를 합쳐 성공/동일 실패로 끝난다.

LoginService가 회원 존재 시 실제 해시, 부재나 유효하지 않은 이메일이면 활성 PasswordHasher로 만든 유효한 더미 해시를 선택해 matches를 정확히 한 번 호출하고, invalid email·missing·wrong·malformed를 같은 InvalidCredentialsException으로 수렴시키며 하나의 PasswordHasherTest가 두 구현의 공통·구현별 계약을 검증하는 흐름입니다.

ONE VERIFY · UNIFORM FAILURE

로그인 실패와 구현 교체를 같은 계약으로 검증한다

회원은 실제 저장 값을, 부재·유효하지 않은 이메일은 활성 어댑터의 유효한 더미 해시를 한 번 검증합니다. 손상된 저장 값도 같은 외부 실패로 수렴시키되 요청 전체가 엄밀한 상수 시간이라고 과장하지 않습니다.

해시 선택은 나뉘지만 검증 호출은 하나다 회원이있으면실제해시,없거나이메일이부적합하면유효한더미해시를선택하고matches로합류한다. 성공에는회원존재와비밀번호일치가동시에필요하다. 손상된저장값은실해시분기로들어가false가되며 모든실패는같은외부예외로끝난다. 7노드7간선이며호출1회가모든입력의KDF1회나요청전체상수시간을보장하지않는다. 회원 조회 존재 여부로 선택 실제 저장 해시 회원 있음 유효한 더미 해시 회원 없음·이메일 부적합 matches 한 번 원문 후보 + 선택 인코딩 회원 존재 && 일치 boolean과회원상태 판정 회원 ID 반환 두 조건 모두 충족 동일 자격 증명 실패 그 밖의 모든 경우 존재 부재 boolean 충족 그 외
해시 선택은 나뉘지만 검증 호출은 하나다 회원이있으면실제해시,없거나이메일이부적합하면유효한더미해시를선택하고matches로합류한다. 성공에는회원존재와비밀번호일치가동시에필요하다. 손상된저장값은실해시분기로들어가false가되며 모든실패는같은외부예외로끝난다. 7노드7간선이며호출1회가모든입력의KDF1회나요청전체상수시간을보장하지않는다. 회원 조회 존재 여부로 선택 실해시 회원 있음 더미 회원 없음 matches 한 번 raw + 선택 인코딩 회원 존재·일치? 두 조건 모두 필요 회원 ID 모두 충족 같은 실패 그 밖의 경우 존재 부재 boolean 충족 그 외

LOOKUP → SELECT HASH → MATCH ONCE → DECIDE

실제 해시와 더미 해시는 한 검증 지점으로 수렴한다

  1. 회원 조회

    입력 또는 정규화 결과가 254자를 넘거나 비면 조회 전 empty로 두고, 그 밖에는 MemberRepository.findByEmail 결과를 optional로 유지합니다.

  2. 검증할 인코딩 선택

    회원이 있으면 저장 해시를, 없으면 같은 활성 어댑터로 생성한 유효한 더미 해시를 고릅니다.

  3. matches(raw, encoded) 한 번

    존재·부재 두 분기 모두 정확히 한 번 호출하고 boolean을 받은 뒤 결론을 냅니다.

  4. 성공 또는 동일 실패

    회원이 있고 일치할 때만 ID를 반환하며 나머지는 InvalidCredentialsException으로 수렴합니다.

로그인 분기별 검증 횟수와 외부 실패 표면
상태 matches 입력 호출 횟수 외부 결과
올바른 비밀번호 원문 후보 + 실제 저장 해시 1 회원 ID
틀린 비밀번호 원문 후보 + 실제 저장 해시 1 invalid credentials
없는 회원 원문 후보 + 유효한 더미 해시 1 invalid credentials
유효하지 않은 이메일 원문 후보 + 유효한 더미 해시 1 invalid credentials
잘못된 저장 형식 원문 후보 + 손상된 저장 값 1 invalid credentials

로그인 분기별 검증 횟수와 외부 실패 표면

올바른 비밀번호

matches 입력: 원문 후보 + 실제 저장 해시

호출 횟수: 1

외부 결과: 회원 ID

틀린 비밀번호

matches 입력: 원문 후보 + 실제 저장 해시

호출 횟수: 1

외부 결과: invalid credentials

없는 회원

matches 입력: 원문 후보 + 유효한 더미 해시

호출 횟수: 1

외부 결과: invalid credentials

유효하지 않은 이메일

matches 입력: 원문 후보 + 유효한 더미 해시

호출 횟수: 1

외부 결과: invalid credentials

잘못된 저장 형식

matches 입력: 원문 후보 + 손상된 저장 값

호출 횟수: 1

외부 결과: invalid credentials

REDUCED TIMING GAP

KDF 유무 차이 축소

null이 아니고 길이 상한 이내인 원문 후보는 없는 회원에서도 유효한 더미 인코딩으로 PBKDF2를 실행하여 큰 실행 유무 차이를 줄입니다.

NOT STRICT CONSTANT-TIME

요청 전체 보장 아님

null·과도한 원문 길이와 손상된 저장 형식은 KDF 전에 false로 끝날 수 있습니다. 저장소·캐시·비용·스케줄링·네트워크 차이도 남으며 rate limit과 audit은 외부 운영 경계가 담당합니다.

공통 계약과 구현별 추가 테스트의 책임
테스트 층 공통 증거 구현별 추가 증거
PasswordHasherTest same raw true · different raw false · 공백 보존 · malformed false 한 concrete test가 두 구현을 직접 순회
테스트 대역 hash + matches 호출 계약 단순 equals 대역 · 보안성은 주장하지 않음
PBKDF2 어댑터 hash + matches 호출 계약 salt 차이 · bounded cost · 버전·형식 거절
LoginService success ID · 네 실패의 동일 예외 found/missing 모두 matchesCalls == 1

공통 계약과 구현별 추가 테스트의 책임

PasswordHasherTest

공통 증거: same raw true · different raw false · 공백 보존 · malformed false

구현별 추가 증거: 한 concrete test가 두 구현을 직접 순회

테스트 대역

공통 증거: hash + matches 호출 계약

구현별 추가 증거: 단순 equals 대역 · 보안성은 주장하지 않음

PBKDF2 어댑터

공통 증거: hash + matches 호출 계약

구현별 추가 증거: salt 차이 · bounded cost · 버전·형식 거절

LoginService

공통 증거: success ID · 네 실패의 동일 예외

구현별 추가 증거: found/missing 모두 matchesCalls == 1

같은 오류 형식과 더미 검증은 계정 존재 노출을 줄이는 한 층입니다. 원문·저장 해시는 메시지와 로그에서 제외하고, rate limit·audit·rehash 정책은 별도 capability와 운영 경계에 둡니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 회원이 있으면 실제 해시, 없거나 이메일이 부적합하면 유효한 더미 해시를 선택하고 matches로 합류한다. 성공에는 회원 존재와 비밀번호 일치가 동시에 필요하다. 손상된 저장 값은 실제 해시 분기로 들어가 false가 되며 모든 실패는 같은 외부 예외로 끝난다. 7노드와 7간선이며 호출 1회가 모든 입력의 KDF 1회나 요청 전체의 상수 시간을 보장하지 않는다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
7회원이 있으면 실제 해시, 없거나 이메일이 부적합하면 유효한 더미 해시를 선택하고 matches로 합류한다. 성공에는 회원 존재와 비밀번호 일치가 동시에 필요하다. 손상된 저장 값은 실제 해시 분기로 들어가 false가 되며 모든 실패는 같은 외부 예외로 끝난다. 7노드와 7간선이며 호출 1회가 모든 입력의 KDF 1회나 요청 전체의 상수 시간을 보장하지 않는다.
17boolean과 회원 상태 판정
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
회원과 해시 선택

회원이 있으면 실제 해시, 없거나 부적합 이메일이면 유효한 더미 해시를 고릅니다.

존재: 실해시 · 부재·부적합: 더미입력별 검증 표현이 선택됩니다.
matches 합류

선택한 표현으로 matches를 한 번 호출합니다.

호출: matches 1 · 손상 저장 값: false검증 결과가 하나의 판정으로 모입니다.
존재와 일치 판정

회원 존재와 비밀번호 일치가 모두 필요합니다.

성공: 존재 AND 일치 · 실패: 같은 외부 예외외부 실패를 같은 응답 계약으로 좁힙니다.

그림은 회원 조회 결과에 따라 실제 해시와 더미 해시로 갈라졌던 길이 matches 한 번으로 다시 합쳐지는 모습을 보여 줍니다. 성공하려면 회원이 있고 비밀번호도 맞아야 하며, 그 밖의 모든 경우는 같은 실패로 끝납니다. 아래쪽 표는 다섯 가지 상태마다 matches에 무엇이 넘어가고 몇 번 호출되며 바깥에는 무엇이 보이는지를 정리하고, 그 옆의 두 상자는 이 방법이 시간 차이를 줄일 뿐 요청 전체를 엄밀하게 같은 시간으로 만들지는 않는다는 점을 짚습니다. null이거나 너무 긴 원문, 손상된 저장 형식은 계산 전에 false로 끝날 수 있기 때문입니다.


운영에서 따로 챙길 일

로그인 실패 응답은 계정이 있는지, 비밀번호가 틀렸는지, 저장된 해시가 망가졌는지를 구분해 드러내지 않습니다. 같은 IP나 계정에서 오는 시도의 횟수 제한(rate limit)과 보안 감사 기록은 HTTP 계층과 운영 환경에서 따로 적용합니다. 반복 횟수를 올린 뒤 예전 해시를 다시 만들어야 하는지는 needsRehash(encodedHash) 같은 별도 메서드로 나타내고, LoginService가 저장 형식을 직접 해석하지 않게 합니다. 운영 설정을 바꿀 때는 기존 해시를 어디까지 검증할 수 있는지 배포 전에 확인합니다.

더 깊이: Spring Security의 PasswordEncoder는 같은 일을 어떻게 나눌까

Spring Security도 이 글의 포트와 거의 같은 모양의 PasswordEncoder 인터페이스를 둡니다. encode가 hash에, matches가 matches에 해당하고, 저장된 값을 다시 해시해야 하는지 알려 주는 upgradeEncoding이 앞에서 말한 needsRehash의 자리입니다. upgradeEncoding의 기본 구현은 언제나 false를 돌려주므로, 정책이 바뀌었을 때 다시 해시하려면 구현이 직접 판단을 넣어야 합니다. matches는 저장된 값을 원문으로 되돌리지 않고, 원문이나 저장 값이 null이거나 빈 문자열이면 결코 true가 되지 않는다고 정해져 있습니다.

스스로를 설명하는 저장 형식도 같은 발상입니다. DelegatingPasswordEncoder는 {id}encodedPassword처럼 앞에 붙은 식별자로 검증에 쓸 구현을 고르고, 새로 해시할 때는 생성자에 넘긴 idForEncode의 구현을 써서 {bcrypt}$2a$10$... 같은 값을 만듭니다. 다만 실패를 다루는 방식은 이 글과 다릅니다. 매핑되지 않은 식별자, 식별자가 없는 값까지 포함해 기본적으로 matches가 IllegalArgumentException을 던지며, 이 동작은 setDefaultPasswordEncoderForMatches로 바꿀 수 있습니다. 이 글처럼 망가진 저장 형식도 다른 실패와 똑같이 보이게 하려면, 그 예외 경로를 어떻게 처리할지도 함께 정해야 합니다.

비용을 정하는 기준도 참고할 만합니다. Spring Security의 비밀번호 저장 문서는 반복 횟수 같은 작업량을 고정된 숫자로 주지 않고, 각자의 시스템에서 비밀번호 하나를 검증하는 데 1초 정도 걸리도록 조정하라고 권합니다. PBKDF2는 FIPS 인증이 필요할 때 알맞은 선택이라고도 설명합니다.


정리

  • PasswordHasher에 matches를 추가하면 기존 구현은 컴파일되지 않으므로, 테스트 대역과 운영 어댑터가 두 메서드를 모두 구현해야 합니다. 회원가입 서비스는 그대로입니다.
  • 저장 형식에 알고리즘과 버전, 반복 횟수, 솔트, 결과를 함께 기록하면, 서비스는 형식을 몰라도 되고 정책이 바뀌어도 예전 해시를 검증할 수 있습니다.
  • matches는 형식, 버전, 반복 횟수 범위, Base64 정규형, 바이트 길이를 모두 확인한 뒤에만 PBKDF2를 계산하고, 결과는 MessageDigest.isEqual로 비교합니다.
  • 로그인은 회원이 없을 때도 유효한 더미 해시로 matches를 한 번 호출하고, 모든 실패를 같은 예외로 바꿔 계정이 있는지 드러나지 않게 합니다.
  • 더미 검증은 시간 차이를 줄이는 한 가지 방법일 뿐이며, 횟수 제한과 감사 기록은 운영 환경에서 따로 적용합니다.

연습 문제

1. 같은 false, 다른 실패 지점

정상 저장 문자열의 반복 횟수를 599999로 바꾼 경우와, 형식은 맞는 실제 해시에 틀린 원문을 넣은 경우는 둘 다 matches가 false를 돌려줍니다. 두 경우는 어디서 실패하며, matchesCalls == 1이라는 테스트 결과가 무엇을 뜻하는지 설명하세요.

해설 보기

반복 횟수 599999는 parse에서 하한 600,000보다 작아 거절되므로 PBKDF2를 계산하기 전에 false가 됩니다. 반면 형식이 맞는 해시에 틀린 원문을 넣으면 파싱은 통과하고 PBKDF2를 계산한 뒤, MessageDigest.isEqual의 비교에서 false가 됩니다. 최종 결과는 같아도 실패한 지점이 다릅니다. 그러므로 matchesCalls == 1은 해시 역할의 메서드를 한 번 호출했다는 뜻일 뿐, 모든 입력에서 비싼 계산을 정확히 한 번 했다는 뜻은 아닙니다.

2. 회원이 없어도 matches가 true라면

저장소에 회원이 하나도 없는 상태에서, LoginService 생성자가 쓴 더미 원문과 정확히 같은 문자열로 로그인을 시도한다고 해 봅시다. 해시 구현은 규칙대로 더미 해시를 올바르게 검증합니다. matches의 호출 횟수와 반환값, 최종 인증 결과를 각각 예측하세요.

해설 보기

회원이 없으므로 더미 해시를 고르고, 같은 더미 원문을 검증하므로 matches는 한 번 호출되어 true를 돌려줍니다. 하지만 member.isEmpty()가 true이므로 최종 결과는 InvalidCredentialsException입니다. 로그인에 성공하려면 회원이 있고 비밀번호가 맞아야 하는 두 조건이 모두 필요하며, 더미 검증은 없는 회원을 인증해 주는 기능이 아닙니다. if 조건에서 member.isEmpty()를 빼먹으면 이 입력으로 로그인이 되어 버리므로, 이 조건은 반드시 남겨 두어야 합니다.

다음 글 OCP·DIP와 생성자 주입의 경계에서는 구현을 고르는 코드가 회원가입 서비스 안에 들어가면 어떤 변경이 퍼지는지 OCP와 DIP의 관점에서 다시 확인합니다.