순수 Java 회원가입
회원 입력 정규화, 비밀번호 해시 경계, 저장 전·후 식별자와 실패 뒤 불변을 순수 Java 코드와 테스트로 검증합니다.
Spring을 붙이기 전에 회원가입 자체가 올바르게 동작해야 합니다. 이 글은 앞 글의 설계를 board.member 패키지의 완전한 파일로 옮깁니다. 운영 코드에는 Spring 의존성이 전혀 없고, 테스트 코드만 JUnit과 AssertJ를 씁니다.
이 장을 따로 따라 할 때는 1장 첫 글에서 준비한 Gradle Wrapper와 Java 25, 테스트 의존성을 갖춘 프로젝트를 복사해 씁니다. 파일 제목의 board-core/는 이 학습 프로젝트의 루트, 곧 Wrapper와 build.gradle이 있는 디렉터리를 뜻하므로 파일은 그 아래의 src/main/java와 src/test/java에 둡니다. 기존 프로젝트 안에 board-core/src라는 폴더만 만들고 별도의 Gradle 모듈로 등록하지 않으면 기본 소스 경로에 들어가지 않으니 주의합니다.
파일은 SignUpCommand와 Member, 저장소 역할과 메모리 구현, 해시 역할과 테스트용 구현, 예외와 서비스, 테스트의 순서로 작성하면 IDE가 표시하는 미해결 타입이 차례로 사라집니다. 다음 글에서 추가할 matches 메서드는 아직 넣지 않습니다. 모두 작성한 뒤 프로젝트 루트에서 다음 명령으로 다섯 테스트가 통과하는지 확인합니다.
./gradlew test --tests board.member.MemberRegistrationServiceTest명령이 Wrapper를 찾지 못하면 현재 디렉터리를, Java나 JUnit 타입을 찾지 못하면 1장의 도구 체인과 테스트 의존성을 먼저 확인합니다.
가입 명령의 입력 검사
이메일과 이름은 비교하고 저장하기 전에 같은 기준으로 정규화합니다. 원문 비밀번호는 앞뒤 공백도 사용자가 입력한 데이터이므로, 길이와 공백 여부만 검사하고 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, 공백, 최대 길이와 소문자 정규화뿐이며, 이메일 문법 검사는 1장의 @Email처럼 별도의 입력 단계에서 추가할 수 있습니다.
더 깊이: 254와 소문자 정규화는 메일 규격과 어떻게 맞을까
254라는 상한은 메일 전송 규격인 RFC 5321에서 나옵니다. 이 규격은 MAIL FROM과 RCPT TO에 쓰는 경로(path)의 최대 길이를 꺾쇠괄호까지 포함해 256옥텟으로 정하고, 경로의 문법은 <와 > 사이에 주소 하나를 두는 형태입니다. 그래서 꺾쇠괄호 두 개를 뺀 주소 자체는 254옥텟을 넘을 수 없습니다. 같은 규격은 @ 앞의 로컬 부분(local-part)을 64옥텟, 도메인을 255옥텟으로 따로 제한하므로, 254 이하라는 검사만으로 각 부분의 길이까지 지켜지지는 않습니다.
단위도 다릅니다. 규격의 254는 옥텟, 곧 바이트 수이고 이 예제의 String.length()는 UTF-16 코드 단위입니다. ASCII로만 된 주소라면 두 값이 같지만, 한글처럼 ASCII가 아닌 글자가 들어간 주소라면 같은 254가 서로 다른 길이를 뜻합니다.
대소문자 정규화는 규격보다 애플리케이션의 정책에 가깝습니다. RFC 5321의 2.4절은 도메인은 DNS 규칙대로 대소문자를 구분하지 않지만, 로컬 부분은 대소문자를 구분하는 것으로 다뤄야 한다고 정합니다. 동시에 로컬 부분의 대소문자 차이에 기대는 운용은 상호 운용성을 해치므로 권장하지 않는다고도 적습니다. 그래서 Smith@와 smith@를 같은 회원으로 보려고 전체를 소문자로 바꾸는 것은 흔한 선택이지만, 원래 입력의 대소문자로만 메일을 받는 서버가 있을 수 있다는 점은 남습니다. 메일을 실제로 보내야 하는 서비스라면 중복 검사용 정규화 값과 사용자가 입력한 원래 주소를 따로 보관하는 설계도 검토할 수 있습니다.
저장되는 회원은 해시만 보유
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 소문자 변환이 끝난 최종 문자열에서 검사합니다.
다만 Member는 공개된 레코드이므로, 양수 식별자는 식별된 값이라는 뜻일 뿐 실제로 저장되었다는 증명은 아닙니다. 또 불변인 id == 0 원본 객체를 한 번만 쓰게 막지도 않습니다. 이 글에서 확인하는 것은 메모리 저장소가 돌려준 양수 식별자의 회원을 다시 저장하려 하면, 번호가 바뀌기 전에 거절된다는 것입니다.
저장소 역할과 단일 스레드 메모리 구현
findByEmail은 다음 글의 로그인 예제가 같은 저장소 역할을 쓸 수 있도록 미리 넣어 둡니다. 메모리 구현의 existsByEmail과 findByEmail은 같은 normalizeEmail을 거친 뒤 비교하므로, 대소문자나 앞뒤 공백만 다른 이메일도 같은 회원으로 찾습니다.
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;
}
}save는 다음 번호를 먼저 계산해 새 값을 만든 뒤에야 sequence와 맵을 바꿉니다. 그래서 identifiedBy가 예외를 던지면 번호도 맵도 그대로 남습니다. 1장의 구현과 달리 이 구현은 차례로 실행되는 학습용 테스트를 위한 것이어서, LinkedHashMap과 long 번호는 동시 접근을 보호하지 않습니다. existsByEmail 다음 save도 하나의 원자적 연산이 아니며, save를 직접 호출할 때 이메일 중복까지 막아 주지도 않습니다. 운영 환경에서 중복을 확실히 막는 마지막 수단은 데이터베이스의 고유 제약 조건과 트랜잭션입니다.
가입 전용 hash-only 포트와 테스트 대역
앞 글에서 정한 대로 가입에 쓰는 PasswordHasher는 아직 hash 하나만 가집니다. 로그인에 필요한 matches는 다음 글에서 역할과 구현을 함께 넓힙니다.
package board.member;
public interface PasswordHasher {
String hash(String rawPassword);
}테스트용 구현은 흐름을 매번 같은 결과로 확인하기 위한 것이지 보안 해시가 아닙니다. 원문을 그대로 붙이지 않고 {test} 표식과 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());
}
}같은 원문은 언제나 같은 문자열이 되므로, 뒤의 테스트는 hasher.hash(rawPassword)를 한 번 더 불러 기대값을 만들 수 있습니다. 이 파일은 src/test 아래에 있으므로 운영 코드의 클래스 경로에는 들어가지 않습니다.
회원가입 서비스
중복 이메일은 회원가입 사용 사례의 실패이므로 이름이 분명한 예외로 나타냅니다. 이 예외는 저장소가 아니라 서비스가 던집니다. 저장소의 existsByEmail은 같은 이메일이 있다는 사실만 알려 주고, 그 사실을 가입 실패로 판단하는 것은 사용 사례의 일이기 때문입니다.
package board.member;
public final class DuplicateEmailException extends RuntimeException {
public DuplicateEmailException(String email) {
super("email already exists: " + email);
}
}RuntimeException을 상속하므로 호출하는 쪽에 throws 선언을 강요하지 않으며, 메시지에는 명령이 정규화한 이메일이 들어갑니다. 서비스는 협력 객체를 직접 만들지 않고, 해시 역할과 저장소 역할을 생성자로 받습니다.
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에 담겨 서비스를 지나 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 | 지원 경로가 원문 저장을 피합니다. |
그림은 이 흐름이 중복 여부에서 갈라지는 모습을 보여 줍니다. 중복이면 그 자리에서 예외로 끝나 해시와 저장을 호출하지 않고, 중복이 아닐 때만 원문이 해시로 바뀌어 id가 0인 회원이 만들어지고 저장소가 양수 식별자를 붙입니다. 아래쪽 표는 값마다 어디서 어떻게 바뀌는지를 정리합니다. 이메일은 공백을 지우고 소문자로 바꾼 뒤 254 이하인지, 이름은 공백을 지운 뒤 1~50인지 명령과 Member가 모두 검사하고, 원문 비밀번호는 바꾸지 않은 채 PasswordHasher까지만 전달되며, 해시는 저장 형식 그대로 보관됩니다.
성공과 실패 테스트
테스트는 성공한 결과뿐 아니라 잘못된 입력, 중복으로 실패한 뒤의 상태, 저장소가 돌려준 회원을 다시 저장하는 경우도 함께 확인합니다. 모든 테스트는 필요한 객체를 직접 만들고, 서비스가 필요한 테스트는 MemoryMemberRepository와 TestPasswordHasher를 서비스 생성자에 넘기므로 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(
" [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);
}
}세 번째 테스트의 "İ"(U+0130, 점이 있는 대문자 I)는 Locale.ROOT로 소문자로 바꾸면 i와 결합 문자 U+0307의 두 코드 단위가 됩니다. 그래서 254자였던 입력이 정규화 뒤에는 508 코드 단위가 되어 거절됩니다. 정규화하기 전에 길이를 검사했다면 통과했을 입력입니다.
이 테스트들이 확인하는 범위는 단일 스레드에서의 메모리 구현과 해시만 쓰는 가입 흐름입니다. 동시 가입의 원자성, 운영용 해시의 보안, 이메일 문법 전체는 아직 확인하지 않습니다.
저장 전·후 상태와 실패 뒤 불변을 함께 검증한다
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 갱신 · 순번: 갱신 · 영속·동시성: 보장 없음 | 성공한 생성 뒤에만 저장소 상태가 바뀝니다. |
그림은 save가 입력의 식별자부터 확인하는 순서를 보여 줍니다. 이미 양수 식별자가 있으면 번호를 계산하기 전에 거절하고, id가 0이면 먼저 다음 번호로 새 불변 값을 만든 뒤에 번호와 맵을 바꿉니다. 입력 객체 자체가 바뀌는 것이 아니라 새 값이 만들어진다는 점에 주의합니다. 아래쪽 표는 네 가지 시나리오마다 예외의 종류뿐 아니라 실패한 뒤 저장소에 무엇이 남는지까지 확인한다는 것을 보여 줍니다. 좋은 테스트는 반환값과 예외만 보지 않고, 실패한 뒤의 저장소 상태와 다음 번호까지 확인합니다.
정리
- 이메일과 이름은 비교와 저장 전에 같은 기준으로 정규화하고, 원문 비밀번호는 공백까지 그대로 두어 해시 역할에만 넘깁니다.
Member는 원문 비밀번호 없이 해시만 가지며, 식별자 0은 가입 직후의 값, 양수는 식별된 값을 뜻합니다.- 메모리 저장소는 새 값을 먼저 만든 뒤에 번호와 맵을 바꾸므로, 거절된 저장은 번호를 소비하지 않습니다.
- 테스트용 해시는 흐름을 확인하는 대역일 뿐, 실제 비밀번호를 저장할 수 있는 해시가 아닙니다.
- 테스트는 예외의 종류뿐 아니라 실패한 뒤 저장소에 남은 상태까지 확인합니다.
연습 문제
1. 차례로 들어온 네 요청
비어 있는 새 저장소에서 다음을 차례로 실행합니다. ① [email protected]으로 가입에 성공합니다. ② 같은 이메일의 대소문자와 공백만 바꾸어 가입을 요청합니다. ③ 다른 이메일과 공백뿐인 비밀번호로 명령을 만듭니다. ④ [email protected]과 유효한 비밀번호로 가입합니다. 각 실패가 서비스 안에서 일어나는지 서비스를 호출하기 전에 일어나는지, 그리고 마지막 회원의 식별자가 무엇인지 적어 보세요.
해설 보기
①은 식별자 1로 저장됩니다. ②는 정규화한 이메일이 같으므로 서비스의 중복 검사에서 DuplicateEmailException으로 실패하고, 해시와 저장으로 넘어가지 않습니다. ③은 SignUpCommand를 만드는 순간 IllegalArgumentException으로 실패하므로 서비스가 아예 호출되지 않습니다. 두 실패 모두 저장소의 번호를 쓰지 않았으므로 ④의 식별자는 2입니다. 이 결론은 단일 스레드와 지금의 메모리 구현을 전제로 한 것이며, 동시에 들어온 요청까지 고유성을 보장한다는 뜻은 아닙니다.
2. 공백을 지우면 생기는 일
" secret-1234 "로 가입한 뒤, 누군가 "비밀번호도 이메일처럼 strip()하자"고 SignUpCommand를 고쳤습니다. 이미 가입한 회원에게 어떤 일이 생기고, 첫 테스트는 어떻게 되나요?
해설 보기
가입할 때는 앞뒤 공백을 포함한 13 코드 단위 문자열로 해시를 만들어 저장했고, SignUpCommand를 고쳐도 이미 저장된 해시는 바뀌지 않습니다. 다음 글의 LoginService는 SignUpCommand를 거치지 않고 받은 원문을 그대로 matches에 넘기므로, 이 회원은 가입할 때와 똑같이 입력하면 여전히 로그인됩니다. 반면 첫 테스트는 실패합니다. 고친 뒤에는 서비스가 공백을 지운 11 코드 단위 문자열을 해시하는데, 테스트는 isEqualTo(hasher.hash(rawPassword))에서 공백이 있는 원문의 해시를 기대하기 때문입니다.
문제는 고친 뒤에 같은 " secret-1234 "로 새로 가입하는 회원입니다. 이 회원의 해시는 공백을 지운 문자열로 만들어지므로, 가입할 때와 똑같이 공백을 넣어 입력하면 로그인하지 못합니다. 그렇다고 로그인에서도 원문을 strip()하면 이번에는 공백을 포함해 가입한 기존 회원이 로그인하지 못합니다. 비밀번호를 정규화하는 규칙은 가입과 로그인, 이미 저장된 해시가 모두 같은 기준을 따라야 하므로 함부로 바꾸지 않습니다.
다음 글 비밀번호 해시 역할에서는 PasswordHasher에 로그인 검증용 matches를 추가하고, 테스트용 구현과 운영용 구현이 같은 규칙을 지키는지 확인합니다.