순수 Java 회원가입
회원 입력 정규화, 비밀번호 해시 경계, 저장 전·후 식별자와 실패 뒤 불변을 순수 Java 코드와 테스트로 검증합니다.
Spring을 붙이기 전에 회원가입 자체가 올바르게 동작해야 합니다.
이 문서의 board-core 예제는 board.member 패키지의 완전한 파일 단위로 이어집니다.
운영 코드에는 Spring 의존성이 없고, 테스트 소스만 JUnit과 AssertJ를 사용합니다.
파일을 놓고 따라가는 순서
이 장을 별도로 따라 할 때는 첫 장에서 준비한 Gradle Wrapper·Java 25·테스트 의존성을 갖춘 프로젝트 복사본을 사용하세요. 이 문서의 파일 제목에서 board-core/는 학습 프로젝트의 루트를 뜻합니다. 단일 프로젝트라면 Wrapper와 build.gradle이 있는 디렉터리 아래의 src/main/java와 src/test/java에 놓습니다. 기존 프로젝트 안에 board-core/src라는 하위 폴더만 만들고 별도 Gradle 모듈로 등록하지 않으면 기본 소스 경로에 포함되지 않습니다.
파일 제목과 public 타입 이름을 맞추고, 같은 패키지·타입의 이전 파일은 해당 절의 버전으로 교체합니다. SignUpCommand·Member → 저장소 역할과 메모리 구현 → hash-only 역할과 테스트 대역 → 예외와 서비스 → 테스트 순서로 작성하면 IDE의 미해결 타입이 차례로 연결됩니다. 이 단계에서는 다음 절의 matches를 아직 추가하지 않습니다.
프로젝트 루트에서 ./gradlew test --tests board.member.MemberRegistrationServiceTest를 실행해 다섯 테스트가 모두 통과하는지 확인합니다. 명령이 파일을 못 찾으면 현재 디렉터리와 Wrapper부터, Java/JUnit 타입을 못 찾으면 첫 장의 도구 체인과 테스트 의존성부터 확인하세요.
가입 명령에서 입력 경계 고정
이메일과 이름은 비교와 저장 전에 같은 기준으로 정규화합니다.
원문 비밀번호는 앞뒤 공백도 입력 데이터이므로 길이와 공백 여부만 검사하고 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에는 남지 않습니다.
순수 Java 회원가입은 원문을 해시 경계에서 멈춘다
중복 확인에서 실패하면 hash/save가 0이고 정상만 hash·Member 생성·저장으로 값이 이동한다.
PURE JAVA · HASH-ONLY PORT
순수 Java 회원가입은 원문을 해시 경계에서 멈춘다
지원 가입 경로에서 원문은 명령과 해시 역할까지만 전달됩니다. 저장되는 회원은 정규화된 이메일·이름과 해시 문자열만 가집니다.
VALIDATE → CHECK → HASH → CREATE → SAVE
가입 흐름은 다섯 경계를 순서대로 지난다
-
SignUpCommand이메일·이름을 정규화하고 원문 비밀번호는 바꾸지 않은 채 길이와 공백 여부만 검사합니다.
-
MemberRegistrationServiceexistsByEmail로 순차 실행의 중복을 먼저 확인합니다. -
PasswordHasher.hash(rawPassword)원문을 받아 저장용
passwordHash를 반환합니다. 가입 포트에는 아직matches가 없습니다. -
Member.signUp(..., passwordHash)해시 결과를 전달해
id == 0인 저장 전 회원을 만듭니다.passwordHash도 String이므로 직접 호출의 잘못된 원문 전달까지 타입으로 막지는 못합니다. -
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()하지 않음 |
값마다 허용되는 경계와 변환 규칙
명령 경계: 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 값을 반환 |
데이터베이스 고유 제약·트랜잭션 |
현재 메모리 구현의 보장과 의도적인 한계
단일 스레드 가입
현재 보장: 중복 확인 뒤 해시·생성·저장 순서
아직 보장하지 않음: exists → save의 원자성
저장 상태
현재 보장: save(id == 0)가 새 양수 ID 값을 반환
아직 보장하지 않음: 데이터베이스 고유 제약·트랜잭션
이 흐름은 Spring 없이 직접 조립할 수 있는 단일 스레드 학습 경계입니다. 이메일 문법 전체, 동시 가입 원자성, 운영 비밀번호 보안은 별도 경계에서 완성합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 명령에서 중복 검사로 진행한 뒤 중복이 아니어야 해시·회원 생성·저장으로 간다. 중복이면 그 검사에서 바로 예외로 끝나고 해시·저장은 호출하지 않는다. 회원과 저장소에는 passwordHash만 전달된다. 6노드와 5간선의 같은 분기를 재배치한다. 원문 값은 해시 입력까지이며 해시 자체의 보안성은 이 흐름의 증거가 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 7 | 명령에서 중복 검사로 진행한 뒤 중복이 아니어야 해시·회원 생성·저장으로 간다. 중복이면 그 검사에서 바로 예외로 끝나고 해시·저장은 호출하지 않는다. 회원과 저장소에는 passwordHash만 전달된다. 6노드와 5간선의 같은 분기를 재배치한다. 원문 값은 해시 입력까지이며 해시 자체의 보안성은 이 흐름의 증거가 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 가입 중복 판정 가입 명령의 중복을 확인합니다. | 중복: 예외 · 중복 시 hash·save: 0 | 중복은 비밀번호 처리 전에 끝납니다. |
| 원문 해시 전환 정상 분기만 원문을 해시 협력자에 넘깁니다. | 원문: 해시 입력까지 · 산출물: passwordHash | 저장할 표현이 원문에서 해시로 바뀝니다. |
| 회원 생성·저장 회원과 저장소에는 해시만 전달합니다. | 회원: passwordHash · 저장: passwordHash | 지원 경로가 원문 저장을 피합니다. |
순수 Java 회원가입은 원문을 해시 경계에서 멈춘다
중복 확인에서 실패하면 hash/save가 0이고 정상만 hash·Member 생성·저장으로 값이 이동한다.
PURE JAVA · HASH-ONLY PORT
순수 Java 회원가입은 원문을 해시 경계에서 멈춘다
지원 가입 경로에서 원문은 명령과 해시 역할까지만 전달됩니다. 저장되는 회원은 정규화된 이메일·이름과 해시 문자열만 가집니다.
VALIDATE → CHECK → HASH → CREATE → SAVE
가입 흐름은 다섯 경계를 순서대로 지난다
-
SignUpCommand이메일·이름을 정규화하고 원문 비밀번호는 바꾸지 않은 채 길이와 공백 여부만 검사합니다.
-
MemberRegistrationServiceexistsByEmail로 순차 실행의 중복을 먼저 확인합니다. -
PasswordHasher.hash(rawPassword)원문을 받아 저장용
passwordHash를 반환합니다. 가입 포트에는 아직matches가 없습니다. -
Member.signUp(..., passwordHash)해시 결과를 전달해
id == 0인 저장 전 회원을 만듭니다.passwordHash도 String이므로 직접 호출의 잘못된 원문 전달까지 타입으로 막지는 못합니다. -
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()하지 않음 |
값마다 허용되는 경계와 변환 규칙
명령 경계: 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 값을 반환 |
데이터베이스 고유 제약·트랜잭션 |
현재 메모리 구현의 보장과 의도적인 한계
단일 스레드 가입
현재 보장: 중복 확인 뒤 해시·생성·저장 순서
아직 보장하지 않음: exists → save의 원자성
저장 상태
현재 보장: save(id == 0)가 새 양수 ID 값을 반환
아직 보장하지 않음: 데이터베이스 고유 제약·트랜잭션
이 흐름은 Spring 없이 직접 조립할 수 있는 단일 스레드 학습 경계입니다. 이메일 문법 전체, 동시 가입 원자성, 운영 비밀번호 보안은 별도 경계에서 완성합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 명령에서 중복 검사로 진행한 뒤 중복이 아니어야 해시·회원 생성·저장으로 간다. 중복이면 그 검사에서 바로 예외로 끝나고 해시·저장은 호출하지 않는다. 회원과 저장소에는 passwordHash만 전달된다. 6노드와 5간선의 같은 분기를 재배치한다. 원문 값은 해시 입력까지이며 해시 자체의 보안성은 이 흐름의 증거가 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 7 | 명령에서 중복 검사로 진행한 뒤 중복이 아니어야 해시·회원 생성·저장으로 간다. 중복이면 그 검사에서 바로 예외로 끝나고 해시·저장은 호출하지 않는다. 회원과 저장소에는 passwordHash만 전달된다. 6노드와 5간선의 같은 분기를 재배치한다. 원문 값은 해시 입력까지이며 해시 자체의 보안성은 이 흐름의 증거가 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 가입 중복 판정 가입 명령의 중복을 확인합니다. | 중복: 예외 · 중복 시 hash·save: 0 | 중복은 비밀번호 처리 전에 끝납니다. |
| 원문 해시 전환 정상 분기만 원문을 해시 협력자에 넘깁니다. | 원문: 해시 입력까지 · 산출물: passwordHash | 저장할 표현이 원문에서 해시로 바뀝니다. |
| 회원 생성·저장 회원과 저장소에는 해시만 전달합니다. | 회원: passwordHash · 저장: passwordHash | 지원 경로가 원문 저장을 피합니다. |
경계 테스트
테스트는 성공 결과뿐 아니라 잘못된 입력, 중복 실패 뒤 상태, 저장소가 반환한 식별된 회원의 재저장을 함께 확인합니다.
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(
" [email protected] ", " 안동민 ", rawPassword));
assertThat(member.id()).isPositive();
assertThat(member.email()).isEqualTo("[email protected]");
assertThat(member.name()).isEqualTo("안동민");
assertThat(member.passwordHash())
.isEqualTo(hasher.hash(rawPassword))
.doesNotContain(rawPassword);
assertThat(repository.findByEmail(" [email protected] "))
.contains(member);
}
@Test
void 공백_비밀번호는_서비스_호출_전에_거절한다() {
var repository = new MemoryMemberRepository();
assertThatThrownBy(() -> new SignUpCommand(
"[email protected]", "안동민", " "))
.isInstanceOf(IllegalArgumentException.class);
assertThat(repository.findByEmail("[email protected]"))
.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(
"[email protected]", "안동민", "secret-1234"));
assertThatThrownBy(() -> service.register(new SignUpCommand(
" [email protected] ", "다른 이름", "another-1234")))
.isInstanceOf(DuplicateEmailException.class);
assertThat(repository.findByEmail("[email protected]"))
.contains(first);
}
@Test
void 저장소가_반환한_식별된_회원을_재저장해도_순번을_소비하지_않는다() {
var repository = new MemoryMemberRepository();
var hasher = new TestPasswordHasher();
Member first = repository.save(Member.signUp(
"[email protected]", "첫 회원",
hasher.hash("secret-1234")));
assertThatThrownBy(() -> repository.save(first))
.isInstanceOf(IllegalStateException.class);
Member second = repository.save(Member.signUp(
"[email protected]", "둘째 회원",
hasher.hash("secret-5678")));
assertThat(second.id()).isEqualTo(first.id() + 1);
}
}이 테스트가 증명하는 범위는 단일 스레드 메모리 구현과 hash-only 가입 흐름입니다.
동시 가입의 원자성, 운영 해시 보안성, 이메일 문법 전체는 아직 보장하지 않습니다.
저장 전·후 상태와 실패 뒤 불변을 함께 검증한다
id 0 입력 검증·새 양수 ID 값 생성·순번/Map 갱신에서 원 입력 객체는 유지되는 저장 전후 상태이다.
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가 연속 |
경계 테스트가 실행하는 입력과 관찰 증거
정상 가입
실행 경계: 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 |
순차 학습 흐름이며 원자적 고유성 보장은 아님 |
식별자와 중복 검사의 책임 범위
ID 상태
소유 경계: Member.signUp + identifiedBy
현재 의미: 0 = sign-up value, positive = identified value; 영속화 증거는 아님
중복 선확인
소유 경계: MemberRegistrationService
현재 의미: 순차 학습 흐름이며 원자적 고유성 보장은 아님
좋은 경계 테스트는 반환값과 예외뿐 아니라 저장소 관찰값과 다음 순번도 함께 확인합니다. 공개 record의 양수 ID는 영속화 증거가 아니며, id 0 원본의 단회 소비와 운영 동시성은 보장하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 현재 record의 유효한 ID는 0 또는 양수다. save는 양수 ID 입력을 순번 계산 전에 거절하며, id 0이면 먼저 새 불변 값을 만든 뒤 순번과 Map을 갱신한다. 입력 객체가 직접 변하는 그림이 아니다. 5노드와 4간선이고 거절은 검사 지점의 별도 분기다. 원본 id 0의 단회 소비·동시성·실제 영속화를 보장하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 7 | 현재 record의 유효한 ID는 0 또는 양수다. save는 양수 ID 입력을 순번 계산 전에 거절하며, id 0이면 먼저 새 불변 값을 만든 뒤 순번과 Map을 갱신한다. 입력 객체가 직접 변하는 그림이 아니다. 5노드와 4간선이고 거절은 검사 지점의 별도 분기다. 원본 id 0의 단회 소비·동시성·실제 영속화를 보장하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| ID 입력 확인 save가 양수 ID를 순번 계산 전에 거부합니다. | id 양수: 거부 · id 0: 진행 | 이미 식별된 입력이 상태 변경 전에 끝납니다. |
| 새 불변 값 생성 id 0에서 새 양수 ID 값을 먼저 만듭니다. | 입력: id 0 유지 · 새 값: 양수 ID | 원 입력 객체 대신 새 값이 생깁니다. |
| Map·순번 갱신 생성 성공 뒤 순번과 Map을 갱신합니다. | 저장 상태: Map 갱신 · 순번: 갱신 · 영속·동시성: 보장 없음 | 성공한 생성 뒤에만 저장소 상태가 바뀝니다. |
저장 전·후 상태와 실패 뒤 불변을 함께 검증한다
id 0 입력 검증·새 양수 ID 값 생성·순번/Map 갱신에서 원 입력 객체는 유지되는 저장 전후 상태이다.
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가 연속 |
경계 테스트가 실행하는 입력과 관찰 증거
정상 가입
실행 경계: 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 |
순차 학습 흐름이며 원자적 고유성 보장은 아님 |
식별자와 중복 검사의 책임 범위
ID 상태
소유 경계: Member.signUp + identifiedBy
현재 의미: 0 = sign-up value, positive = identified value; 영속화 증거는 아님
중복 선확인
소유 경계: MemberRegistrationService
현재 의미: 순차 학습 흐름이며 원자적 고유성 보장은 아님
좋은 경계 테스트는 반환값과 예외뿐 아니라 저장소 관찰값과 다음 순번도 함께 확인합니다. 공개 record의 양수 ID는 영속화 증거가 아니며, id 0 원본의 단회 소비와 운영 동시성은 보장하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 현재 record의 유효한 ID는 0 또는 양수다. save는 양수 ID 입력을 순번 계산 전에 거절하며, id 0이면 먼저 새 불변 값을 만든 뒤 순번과 Map을 갱신한다. 입력 객체가 직접 변하는 그림이 아니다. 5노드와 4간선이고 거절은 검사 지점의 별도 분기다. 원본 id 0의 단회 소비·동시성·실제 영속화를 보장하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 7 | 현재 record의 유효한 ID는 0 또는 양수다. save는 양수 ID 입력을 순번 계산 전에 거절하며, id 0이면 먼저 새 불변 값을 만든 뒤 순번과 Map을 갱신한다. 입력 객체가 직접 변하는 그림이 아니다. 5노드와 4간선이고 거절은 검사 지점의 별도 분기다. 원본 id 0의 단회 소비·동시성·실제 영속화를 보장하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| ID 입력 확인 save가 양수 ID를 순번 계산 전에 거부합니다. | id 양수: 거부 · id 0: 진행 | 이미 식별된 입력이 상태 변경 전에 끝납니다. |
| 새 불변 값 생성 id 0에서 새 양수 ID 값을 먼저 만듭니다. | 입력: id 0 유지 · 새 값: 양수 ID | 원 입력 객체 대신 새 값이 생깁니다. |
| Map·순번 갱신 생성 성공 뒤 순번과 Map을 갱신합니다. | 저장 상태: Map 갱신 · 순번: 갱신 · 영속·동시성: 보장 없음 | 성공한 생성 뒤에만 저장소 상태가 바뀝니다. |
다음 문서에서는 PasswordHasher에 로그인 검증용 matches를 추가하고 테스트·운영 구현이 같은 확장 계약을 지키는지 확인합니다.
예시
비어 있는 메모리 저장소에 " [email protected] ", " 안동민 ", " secret-1234 "로 한 번 가입하면 이메일은 [email protected], 이름은 안동민, 반환 ID는 1입니다. 비밀번호의 앞뒤 두 공백은 해시 입력에 그대로 남습니다. 그 문자열은 13 UTF-16 코드 단위이며, 공백을 지운 11단위 문자열의 해시를 기대하면 이 코드의 계약과 달라집니다.
저장 전 회원의 ID 0은 변경되지 않습니다. 저장소는 ID 1을 가진 새 값을 만들어 반환하고 보관합니다. 그래서 반환된 ID 1 회원을 다시 save하면 다음 순번 계산 전에 실패합니다.
다음 입력은 어떻게 끝날까요?
동일한 새 저장소에서 순차적으로 ① [email protected] 가입 성공, ② 같은 이메일의 대소문자·공백만 바꿔 가입 요청, ③ 별도 이메일에 공백뿐인 비밀번호로 명령 생성, ④ [email protected]에 유효한 비밀번호로 가입을 시도하세요. 두 유효한 비밀번호는 길이 조건을 지킨다고 가정합니다. 각 실패가 서비스 안에서 발생하는지, 서비스 호출 전에 발생하는지와 마지막 회원 ID를 먼저 적습니다.
정규화·실패 경계·다음 ID 풀이
①은 ID 1로 저장됩니다. ②는 정규화한 이메일이 같아 서비스의 중복 검사에서 실패하고 해시·저장으로 진행하지 않습니다. ③은 SignUpCommand 생성 시 실패하므로 서비스가 호출되지 않습니다. 어느 실패도 이 저장소의 순번을 소비하지 않아 ④의 ID는 2입니다. 이 결론은 단일 스레드와 현재 메모리 구현의 조건이며, 동시 요청의 고유성을 증명하지 않습니다.