2장 : 객체지향 설계와 Spring
순수 Java 회원가입
회원 불변식, 메모리 저장소, 비밀번호 해시 경계를 순수 Java로 구현하고 정상·중복·잘못된 입력을 검증합니다.
Spring을 사용하기 전에 회원가입 자체가 올바르게 동작해야 합니다.
프레임워크 없이 실행할 수 있어야 어떤 문제가 업무 코드에 있고 어떤 문제가 객체 조립에 있는지 구분할 수 있습니다.
원문 비밀번호와 회원 분리
가입 요청은 원문 비밀번호를 잠시 가지지만 저장되는 Member는 비밀번호 해시만 가집니다.
두 값의 수명이 다르므로 같은 객체에 넣지 않습니다.
package board.member;
public record SignUpCommand(
String email,
String name,
String rawPassword
) { }package board.member;
import java.util.Objects;
public record Member(
long id,
String email,
String name,
String passwordHash
) {
public Member {
if (id < 0) throw new IllegalArgumentException("id must not be negative");
email = requireText(email, "email").toLowerCase();
name = requireText(name, "name");
passwordHash = requireText(passwordHash, "passwordHash");
}
public static Member signUp(
String email,
String name,
String passwordHash
) {
return new Member(0, email, name, passwordHash);
}
public Member identifiedBy(long savedId) {
if (id != 0) throw new IllegalStateException("member already has an id");
return new Member(savedId, email, name, passwordHash);
}
private static String requireText(String value, String field) {
Objects.requireNonNull(value, field + " must not be null");
String normalized = value.strip();
if (normalized.isEmpty()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return normalized;
}
}이메일 정규화와 공백 검증은 어떤 진입점에서 회원을 만들더라도 적용됩니다.
비밀번호 원문은 Member 생성자에 전달할 수조차 없습니다.
저장소 역할과 메모리 구현
package board.member;
import java.util.Optional;
public interface MemberRepository {
boolean existsByEmail(String email);
Member save(Member member);
Optional<Member> findByEmail(String email);
}package board.member;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
public final class MemoryMemberRepository implements MemberRepository {
private final Map<Long, Member> store = new LinkedHashMap<>();
private long sequence;
@Override
public boolean existsByEmail(String email) {
return store.values().stream()
.anyMatch(member -> member.email().equalsIgnoreCase(email));
}
@Override
public Member save(Member member) {
Member saved = member.identifiedBy(++sequence);
store.put(saved.id(), saved);
return saved;
}
@Override
public Optional<Member> findByEmail(String email) {
return store.values().stream()
.filter(member -> member.email().equalsIgnoreCase(email))
.findFirst();
}
}메모리 구현은 프로세스를 종료하면 사라지지만 역할을 빠르게 검증하기에는 충분합니다.
JDBC 구현은 뒤에서 같은 인터페이스를 구현합니다.
테스트용 해시 구현
다음 구현은 테스트에서 흐름을 확인하기 위한 가짜입니다.
실제 운영 비밀번호에 사용하지 않습니다.
package board.member;
public final class TestPasswordHasher implements PasswordHasher {
@Override
public String hash(String rawPassword) {
return "{test}" + rawPassword;
}
@Override
public boolean matches(String rawPassword, String passwordHash) {
return hash(rawPassword).equals(passwordHash);
}
}회원가입 서비스
package board.member;
public final class MemberRegistrationService {
private final MemberRepository members;
private final PasswordHasher passwordHasher;
public MemberRegistrationService(
MemberRepository members,
PasswordHasher passwordHasher
) {
this.members = members;
this.passwordHasher = passwordHasher;
}
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));
}
}호출 순서는 중복 확인 → 해시 → 회원 생성 → 저장입니다.
원문 비밀번호를 로그에 남기거나 저장소로 넘기는 코드가 없습니다.
경계 테스트
@Test
void 회원을_가입시키고_원문_비밀번호를_저장하지_않는다() {
var repository = new MemoryMemberRepository();
var service = new MemberRegistrationService(
repository, new TestPasswordHasher());
Member member = service.register(new SignUpCommand(
"member@example.com", "안동민", "secret-1234"));
assertThat(member.id()).isPositive();
assertThat(member.passwordHash()).isEqualTo("{test}secret-1234");
assertThat(member.passwordHash()).doesNotMatch("^secret-1234$");
}
@Test
void 같은_이메일은_두_번_가입할_수_없다() {
var repository = new MemoryMemberRepository();
var service = new MemberRegistrationService(
repository, new TestPasswordHasher());
var command = new SignUpCommand(
"member@example.com", "안동민", "secret-1234");
service.register(command);
assertThatThrownBy(() -> service.register(command))
.isInstanceOf(DuplicateEmailException.class);
}다음 문서에서는 테스트용 해시와 운영용 해시를 같은 PasswordHasher 역할로 다루고, 구현 선택을 서비스 밖으로 옮깁니다.