순수 Java 회원가입
회원 입력 정규화, 비밀번호 해시 경계, 저장 전·후 식별자와 실패 뒤 불변을 순수 Java 코드와 테스트로 검증합니다.
Spring을 붙이기 전에 회원가입 자체가 올바르게 동작해야 합니다.
이 문서의 board-core 예제는 board.member 패키지의 완전한 파일 단위로 이어집니다.
운영 코드에는 Spring 의존성이 없고, 테스트 소스만 JUnit과 AssertJ를 사용합니다.
가입 명령에서 입력 경계 고정
이메일과 이름은 비교와 저장 전에 같은 기준으로 정규화합니다.
원문 비밀번호는 앞뒤 공백도 입력 데이터이므로 길이와 공백 여부만 검사하고 strip() 하지 않습니다.
String.length() 기준은 UTF-16 코드 단위입니다.
package board.member;
import java.util.Locale;
public record SignUpCommand(
String email,
String name,
String rawPassword
) {
public SignUpCommand {
email = requireEmail(email);
name = requireText(name, "name", 50);
requireRawPassword(rawPassword);
}
private static String requireEmail(String value) {
if (value == null) {
throw new IllegalArgumentException("email is required");
}
var normalized = value.strip().toLowerCase(Locale.ROOT);
if (normalized.isEmpty() || normalized.length() > 254) {
throw new IllegalArgumentException(
"email has an invalid length");
}
return normalized;
}
private static String requireText(
String value,
String field,
int maxLength
) {
if (value == null) {
throw new IllegalArgumentException(field + " is required");
}
var normalized = value.strip();
if (normalized.isEmpty() || normalized.length() > maxLength) {
throw new IllegalArgumentException(
field + " has an invalid length");
}
return normalized;
}
private static void requireRawPassword(String rawPassword) {
if (rawPassword == null || rawPassword.isBlank()) {
throw new IllegalArgumentException("rawPassword is required");
}
if (rawPassword.length() < 8 || rawPassword.length() > 128) {
throw new IllegalArgumentException(
"rawPassword must be between 8 and 128 UTF-16 code units");
}
}
}이 예제는 이메일 형식 전체를 판정하지 않습니다.
여기서 보장하는 것은 null·공백·최대 길이와 대소문자 정규화이며, 이메일 문법 검사는 별도 입력 경계에서 추가할 수 있습니다.
저장되는 회원은 해시만 보유
Member는 원문 비밀번호 필드를 가질 수 없습니다.
Member.signUp은 식별자 0인 값을 만들고, identifiedBy는 그 값에서 양수 식별자를 가진 새 값을 만듭니다.
package board.member;
import java.util.Locale;
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 = requireEmail(email);
name = requireText(name, "name", 50);
passwordHash = requirePasswordHash(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");
}
if (savedId < 1) {
throw new IllegalArgumentException("saved id must be positive");
}
return new Member(savedId, email, name, passwordHash);
}
private static String requireEmail(String value) {
Objects.requireNonNull(value, "email must not be null");
var normalized = value.strip().toLowerCase(Locale.ROOT);
if (normalized.isEmpty() || normalized.length() > 254) {
throw new IllegalArgumentException(
"email has an invalid length");
}
return normalized;
}
private static String requireText(
String value,
String field,
int maxLength
) {
Objects.requireNonNull(value, field + " must not be null");
var normalized = value.strip();
if (normalized.isEmpty() || normalized.length() > maxLength) {
throw new IllegalArgumentException(
field + " has an invalid length");
}
return normalized;
}
private static String requirePasswordHash(String value) {
Objects.requireNonNull(value, "passwordHash must not be null");
if (value.isBlank()) {
throw new IllegalArgumentException(
"passwordHash must not be blank");
}
return value;
}
}passwordHash는 해시 구현이 만든 저장 형식이므로 임의로 앞뒤를 잘라 바꾸지 않습니다.
명령과 도메인이 이메일·이름 규칙을 각각 검사하므로 HTTP 밖의 다른 진입점에서도 Member 불변식은 남습니다.
이메일 길이는 strip()과 Locale.ROOT 소문자화가 끝난 최종 UTF-16 문자열에서 검사합니다.
다만 Member는 공개 record이므로 양수 ID는 식별된 값이라는 뜻이지 실제 영속화의 증거가 아닙니다.
불변인 id == 0 원본을 한 번만 소비하게 만들지도 않습니다. 이 문서가 검증하는 것은 메모리 저장소가 반환한 양수 ID 값의 재저장을 순번 변경 전에 거절하는 경로입니다.
저장소 역할과 단일 스레드 메모리 구현
findByEmail은 다음 문서의 로그인 예제가 같은 저장 역할을 사용할 수 있게 합니다.
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.Locale;
import java.util.Map;
import java.util.Objects;
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) {
var normalized = normalizeEmail(email);
return store.values().stream()
.anyMatch(member -> member.email().equals(normalized));
}
@Override
public Member save(Member member) {
Objects.requireNonNull(member, "member must not be null");
if (member.id() != 0) {
throw new IllegalStateException("member already has an id");
}
long savedId = sequence + 1;
Member saved = member.identifiedBy(savedId);
sequence = savedId;
store.put(saved.id(), saved);
return saved;
}
@Override
public Optional<Member> findByEmail(String email) {
var normalized = normalizeEmail(email);
return store.values().stream()
.filter(member -> member.email().equals(normalized))
.findFirst();
}
private static String normalizeEmail(String email) {
Objects.requireNonNull(email, "email must not be null");
var normalized = email.strip().toLowerCase(Locale.ROOT);
if (normalized.isEmpty() || normalized.length() > 254) {
throw new IllegalArgumentException(
"email has an invalid length");
}
return normalized;
}
}이 구현은 순차 실행되는 학습 테스트용입니다.
existsByEmail → save는 하나의 원자적 연산이 아니고 LinkedHashMap과 long 순번도 동시 접근을 보호하지 않습니다.
운영 환경의 중복 방지는 데이터베이스 고유 제약 조건과 트랜잭션을 최종 경계로 삼아야 합니다.
또한 save가 직접 호출될 때 이메일 중복까지 강제한다고 주장하지 않습니다.
가입 전용 hash-only 포트와 테스트 대역
앞 문서에서 정한 가입 포트는 아직 hash 하나만 가집니다.
로그인에 필요한 matches는 다음 문서에서 계약과 구현을 함께 확장합니다.
package board.member;
public interface PasswordHasher {
String hash(String rawPassword);
}테스트 대역은 흐름을 결정적으로 확인하기 위한 것이지 보안 해시가 아닙니다.
원문을 그대로 접두사 뒤에 붙이지 않고 테스트용 표식과 Java 문자열 해시 코드를 사용하지만, 충돌 저항성이나 비밀번호 저장 안전성을 제공하지 않습니다.
package board.member;
import java.util.Objects;
public final class TestPasswordHasher implements PasswordHasher {
@Override
public String hash(String rawPassword) {
Objects.requireNonNull(
rawPassword, "rawPassword must not be null");
return "{test}" + Integer.toHexString(rawPassword.hashCode());
}
}실제 비밀번호에는 검증된 비밀번호 전용 해시 구현을 사용합니다.
회원가입 서비스
중복 이메일은 회원가입 사용 사례의 실패이므로 명시적인 예외로 표현합니다.
package board.member;
public final class DuplicateEmailException extends RuntimeException {
public DuplicateEmailException(String email) {
super("email already exists: " + email);
}
}서비스는 협력자를 생성하지 않고 hash-only 역할과 저장 역할을 생성자로 받습니다.
package board.member;
import java.util.Objects;
public final class MemberRegistrationService {
private final MemberRepository members;
private final PasswordHasher passwordHasher;
public MemberRegistrationService(
MemberRepository members,
PasswordHasher passwordHasher
) {
this.members = Objects.requireNonNull(
members, "members must not be null");
this.passwordHasher = Objects.requireNonNull(
passwordHasher, "passwordHasher must not be null");
}
public Member register(SignUpCommand command) {
Objects.requireNonNull(command, "command must not be null");
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));
}
}호출 순서는 중복 확인 → 해시 → 회원 생성 → 저장입니다.
원문 비밀번호는 SignUpCommand 안에서 MemberRegistrationService를 지나 PasswordHasher 인자로만 전달되고, 서비스 필드·Member·MemberRepository에는 남지 않습니다.
PURE JAVA · HASH-ONLY PORT
순수 Java 회원가입은 원문을 해시 경계에서 멈춘다
원문 비밀번호는 명령과 해시 역할까지만 통과합니다. 저장되는 회원은 정규화된 이메일·이름과 해시 문자열만 가집니다.
VALIDATE → CHECK → HASH → CREATE → SAVE
가입 흐름은 다섯 경계를 순서대로 지난다
-
SignUpCommand이메일·이름을 정규화하고 원문 비밀번호는 바꾸지 않은 채 길이와 공백 여부만 검사합니다.
-
MemberRegistrationServiceexistsByEmail로 순차 실행의 중복을 먼저 확인합니다. -
PasswordHasher.hash(rawPassword)원문을 받아 저장용
passwordHash를 반환합니다. 가입 포트에는 아직matches가 없습니다. -
Member.signUp(..., passwordHash)원문 필드 없이
id == 0인 저장 전 회원을 만듭니다. -
MemberRepository.save저장소가 양수 식별자를 부여하고 새 불변 객체를 반환합니다.
| 값 | 명령 경계 | 도메인·저장 경계 | 보존 규칙 |
|---|---|---|---|
strip → Locale.ROOT lowercase 뒤 최종 254 UTF-16 단위 |
Member가 재검사하고 저장소도 같은 비교형으로 조회 |
비교와 저장에 같은 정규형 사용 | |
| name | strip 뒤 1–50 UTF-16 단위 |
Member가 같은 규칙 재검사 |
진입점이 달라도 불변식 유지 |
| rawPassword | 공백 비밀번호 거절 · 8–128 UTF-16 단위 | PasswordHasher까지만 전달 |
앞뒤 공백도 입력이므로 변경 금지 |
| passwordHash | 해시 역할이 생성 | Member와 저장소에만 전달 |
저장 형식을 임의로 strip()하지 않음 |
TEST DOUBLE ≠ PASSWORD SECURITY
테스트 전용
TestPasswordHasher는 결정적 흐름을 확인하는 hash-only 대역입니다.
Java 문자열 해시 코드는 충돌 저항성·salt·비밀번호 저장 안전성을 제공하지 않으므로 실제 비밀번호에 사용할 수 없습니다.
| 경계 | 현재 보장 | 아직 보장하지 않음 |
|---|---|---|
| 단일 스레드 가입 | 중복 확인 뒤 해시·생성·저장 순서 | exists → save의 원자성 |
| 저장 상태 | save(id == 0)가 새 양수 ID 값을 반환 |
데이터베이스 고유 제약·트랜잭션 |
이 흐름은 Spring 없이 직접 조립할 수 있는 단일 스레드 학습 경계입니다. 이메일 문법 전체, 동시 가입 원자성, 운영 비밀번호 보안은 별도 경계에서 완성합니다.
경계 테스트
테스트는 성공 결과뿐 아니라 잘못된 입력, 중복 실패 뒤 상태, 저장소가 반환한 식별된 회원의 재저장을 함께 확인합니다.
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 MemberRegistrationServiceTest {
@Test
void 가입하고_원문_비밀번호는_해시_경계에서_멈춘다() {
var repository = new MemoryMemberRepository();
var hasher = new TestPasswordHasher();
var service = new MemberRegistrationService(
repository, hasher);
String rawPassword = " secret-1234 ";
Member member = service.register(new SignUpCommand(
" MEMBER@EXAMPLE.COM ", " 안동민 ", rawPassword));
assertThat(member.id()).isPositive();
assertThat(member.email()).isEqualTo("member@example.com");
assertThat(member.name()).isEqualTo("안동민");
assertThat(member.passwordHash())
.isEqualTo(hasher.hash(rawPassword))
.doesNotContain(rawPassword);
assertThat(repository.findByEmail(" member@example.com "))
.contains(member);
}
@Test
void 공백_비밀번호는_서비스_호출_전에_거절한다() {
var repository = new MemoryMemberRepository();
assertThatThrownBy(() -> new SignUpCommand(
"member@example.com", "안동민", " "))
.isInstanceOf(IllegalArgumentException.class);
assertThat(repository.findByEmail("member@example.com"))
.isEmpty();
}
@Test
void 이메일_길이는_소문자_정규화_뒤에_검사한다() {
assertThatThrownBy(() -> new SignUpCommand(
"İ".repeat(254), "안동민", "secret-1234"))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void 중복_실패_뒤에는_처음_저장한_회원이_남는다() {
var repository = new MemoryMemberRepository();
var service = new MemberRegistrationService(
repository, new TestPasswordHasher());
Member first = service.register(new SignUpCommand(
"member@example.com", "안동민", "secret-1234"));
assertThatThrownBy(() -> service.register(new SignUpCommand(
" MEMBER@EXAMPLE.COM ", "다른 이름", "another-1234")))
.isInstanceOf(DuplicateEmailException.class);
assertThat(repository.findByEmail("member@example.com"))
.contains(first);
}
@Test
void 저장소가_반환한_식별된_회원을_재저장해도_순번을_소비하지_않는다() {
var repository = new MemoryMemberRepository();
var hasher = new TestPasswordHasher();
Member first = repository.save(Member.signUp(
"first@example.com", "첫 회원",
hasher.hash("secret-1234")));
assertThatThrownBy(() -> repository.save(first))
.isInstanceOf(IllegalStateException.class);
Member second = repository.save(Member.signUp(
"second@example.com", "둘째 회원",
hasher.hash("secret-5678")));
assertThat(second.id()).isEqualTo(first.id() + 1);
}
}이 테스트가 증명하는 범위는 단일 스레드 메모리 구현과 hash-only 가입 흐름입니다.
동시 가입의 원자성, 운영 해시 보안성, 이메일 문법 전체는 아직 보장하지 않습니다.
STATE TRANSITION · FAILURE EVIDENCE
저장 전·후 상태와 실패 뒤 불변을 함께 검증한다
예외 타입만 확인하지 말고 실패 뒤 남은 상태까지 봅니다. 이 저장 흐름은 양수 ID의 새 값을 반환하고, 거절된 경로는 기존 저장 상태를 바꾸지 않아야 합니다.
UNSAVED → IDENTIFIED
메모리 저장소는 id 0 입력에서 양수 ID 값을 반환한다
-
Member.signUp불변식을 통과한 새 회원은
id == 0입니다. -
MemberRepository.save저장 전 상태인지 먼저 확인한 뒤 다음 순번을 계산합니다.
-
identifiedBy(savedId)savedId > 0일 때만 양수 ID를 가진 새 불변 값을 반환합니다. -
식별된 반환값 재저장 거절
이미 양수 ID가 있는 입력은 순번을 소비하거나 저장 행을 덮기 전에 실패합니다.
| 시나리오 | 실행 경계 | 직접 관찰 | 실패 뒤 상태 |
|---|---|---|---|
| 정상 가입 | register(SignUpCommand) |
정규화 값 · 양수 ID · 입력 공백을 포함한 원문으로 계산한 해시 | 이메일 조회로 같은 회원 확인 |
| 잘못된 입력 | new SignUpCommand(...) |
IllegalArgumentException |
서비스 호출 전 · 저장소 비어 있음 |
| 중복 이메일 | existsByEmail |
DuplicateEmailException |
처음 저장한 회원이 그대로 남음 |
| 식별된 값 재저장 | save(id > 0) |
IllegalStateException |
순번을 소비하지 않아 다음 ID가 연속 |
EXECUTED GUARANTEE
현재 테스트가 증명
입력 정규화, 원문 비밀번호의 hash-only 경계, 저장소가 반환한 양수 ID, 순차 중복 실패, 식별된 반환값 재저장 실패를 실행합니다.
DEFERRED GUARANTEE
다음 경계에서 보강
동시성·고유 제약·트랜잭션, 운영 해시 보안, 이메일 문법 전체는 이 메모리 테스트의 보장이 아닙니다.
| 규칙 | 소유 경계 | 현재 의미 |
|---|---|---|
| ID 상태 | Member.signUp + identifiedBy |
0 = sign-up value, positive = identified value; 영속화 증거는 아님 |
| 중복 선확인 | MemberRegistrationService |
순차 학습 흐름이며 원자적 고유성 보장은 아님 |
좋은 경계 테스트는 반환값과 예외뿐 아니라 저장소 관찰값과 다음 순번도 함께 확인합니다. 공개 record의 양수 ID는 영속화 증거가 아니며, id 0 원본의 단회 소비와 운영 동시성은 보장하지 않습니다.
다음 문서에서는 PasswordHasher에 로그인 검증용 matches를 추가하고 테스트·운영 구현이 같은 확장 계약을 지키는지 확인합니다.