비밀번호 해시 역할
비밀번호 원문과 해시의 차이, PasswordHasher 계약, 테스트·운영 구현의 교체 지점을 회원가입 서비스에 연결합니다.
비밀번호 해시는 원문을 데이터베이스에 남기지 않기 위한 단방향 변환입니다.
같은 비밀번호라도 임의 값인 salt를 섞어 서로 다른 결과를 만들고, 계산 비용을 높여 대량 추측을 어렵게 합니다.
암호화처럼 나중에 원문을 복원하는 용도가 아닙니다.
이 장의 목적은 암호 알고리즘을 직접 구현하는 것이 아니라 회원가입 서비스가 구체 알고리즘에서 분리되는 구조를 만드는 것입니다.
운영에서는 검증된 라이브러리 구현을 사용합니다.
역할 계약
package board.member;
public interface PasswordHasher {
String hash(String rawPassword);
boolean matches(String rawPassword, String passwordHash);
}hash는 가입과 비밀번호 변경에 사용하고 matches는 로그인에 사용합니다.
호출하는 쪽은 결과 문자열의 내부 형식을 파싱하지 않습니다.
두 구현을 같은 역할로 사용
테스트에서는 빠르고 예측 가능한 TestPasswordHasher를 사용합니다.
운영 구성에서는 PBKDF2, bcrypt, scrypt, Argon2처럼 비밀번호 저장을 위해 설계된 구현을 선택합니다.
PasswordHasher testHasher = new TestPasswordHasher();
PasswordHasher productionHasher = new Pbkdf2PasswordHasher(settings);
String testHash = testHasher.hash("secret-1234");
String productionHash = productionHasher.hash("secret-1234");
assert testHasher.matches("secret-1234", testHash);
assert productionHasher.matches("secret-1234", productionHash);두 클래스의 내부 코드는 다르지만 서비스는 PasswordHasher라는 같은 역할로 호출합니다.
이것이 다형성을 사용하는 이유입니다.
잘못된 직접 생성
다음 서비스는 다시 구현을 직접 선택합니다.
public final class DefaultMemberRegistrationService {
private final MemberRepository members =
new MemoryMemberRepository();
private final PasswordHasher passwordHasher =
new TestPasswordHasher();
public Member register(SignUpCommand command) {
if (members.existsByEmail(command.email())) {
throw new DuplicateEmailException(command.email());
}
String passwordHash = passwordHasher.hash(command.rawPassword());
return members.save(Member.signUp(
command.email(), command.name(), passwordHash));
}
}운영용 구현으로 바꾸려면 업무 서비스를 수정해야 합니다.
다음 문서에서는 이 한 줄 변경이 왜 OCP와 DIP 위반 신호인지 확인하고 생성자 주입으로 고칩니다.
로그인에서 같은 계약 사용
public final class LoginService {
private final MemberRepository members;
private final PasswordHasher passwordHasher;
public long authenticate(String email, String rawPassword) {
Member member = members.findByEmail(email)
.orElseThrow(InvalidCredentialsException::new);
if (!passwordHasher.matches(
rawPassword, member.passwordHash())) {
throw new InvalidCredentialsException();
}
return member.id();
}
}존재하지 않는 이메일과 틀린 비밀번호를 외부에 서로 다른 메시지로 알려 주면 계정 존재 여부가 노출될 수 있습니다.
두 경우 모두 같은 인증 실패로 다루되 서버 진단 정보에는 원문 비밀번호를 남기지 않습니다.
계약 테스트
구현마다 같은 계약 테스트를 적용하면 교체 가능성을 검증할 수 있습니다.
abstract class PasswordHasherContract {
abstract PasswordHasher hasher();
@Test
void 만든_해시는_같은_원문과_일치한다() {
String hash = hasher().hash("secret-1234");
assertThat(hasher().matches("secret-1234", hash)).isTrue();
}
@Test
void 다른_원문은_일치하지_않는다() {
String hash = hasher().hash("secret-1234");
assertThat(hasher().matches("wrong", hash)).isFalse();
}
}운영 구현에는 같은 원문을 두 번 해시했을 때 salt 때문에 결과가 달라지는지, 설정한 비용이 결과 형식에 기록되는지, 이전 비용의 해시를 로그인 뒤 갱신할 수 있는지도 추가로 검증합니다.
연습 문제
비밀번호 정책이 바뀌어 기존 해시의 비용을 높여야 합니다.
로그인 성공 시 needsRehash(passwordHash)가 참이면 새 해시로 교체하는 흐름을 설계하세요.
인증 실패와 저장 실패 때 회원 비밀번호가 어떻게 유지돼야 하는지도 구분합니다.