본문 바로가기

순수 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 코드 단위입니다.

board-core/src/main/java/board/member/SignUpCommand.java
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는 그 값에서 양수 식별자를 가진 새 값을 만듭니다.

board-core/src/main/java/board/member/Member.java
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은 다음 문서의 로그인 예제가 같은 저장 역할을 사용할 수 있게 합니다.

board-core/src/main/java/board/member/MemberRepository.java
package board.member;

import java.util.Optional;

public interface MemberRepository {
    boolean existsByEmail(String email);
    Member save(Member member);
    Optional<Member> findByEmail(String email);
}
board-core/src/main/java/board/member/MemoryMemberRepository.java
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는 다음 문서에서 계약과 구현을 함께 확장합니다.

board-core/src/main/java/board/member/PasswordHasher.java
package board.member;

public interface PasswordHasher {
    String hash(String rawPassword);
}

테스트 대역은 흐름을 결정적으로 확인하기 위한 것이지 보안 해시가 아닙니다.

원문을 그대로 접두사 뒤에 붙이지 않고 테스트용 표식과 Java 문자열 해시 코드를 사용하지만, 충돌 저항성이나 비밀번호 저장 안전성을 제공하지 않습니다.

board-core/src/test/java/board/member/TestPasswordHasher.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());
    }
}

실제 비밀번호에는 검증된 비밀번호 전용 해시 구현을 사용합니다.


회원가입 서비스

중복 이메일은 회원가입 사용 사례의 실패이므로 명시적인 예외로 표현합니다.

board-core/src/main/java/board/member/DuplicateEmailException.java
package board.member;

public final class DuplicateEmailException extends RuntimeException {
    public DuplicateEmailException(String email) {
        super("email already exists: " + email);
    }
}

서비스는 협력자를 생성하지 않고 hash-only 역할과 저장 역할을 생성자로 받습니다.

board-core/src/main/java/board/member/MemberRegistrationService.java
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 생성·저장으로 값이 이동한다.

정규화된 SignUpCommand가 MemberRegistrationService로 전달되고, 원문 비밀번호는 hash-only PasswordHasher까지만 흐르며, Member와 MemberRepository에는 passwordHash만 전달되는 순수 Java 회원가입 경계를 설명합니다.

PURE JAVA · HASH-ONLY PORT

순수 Java 회원가입은 원문을 해시 경계에서 멈춘다

지원 가입 경로에서 원문은 명령과 해시 역할까지만 전달됩니다. 저장되는 회원은 정규화된 이메일·이름과 해시 문자열만 가집니다.

가입의 정상 흐름과 중복 거절 분기 명령에서 중복검사로 진행한 뒤 중복이 아니어야 해시·회원생성·저장으로 간다. 중복이면 그 검사에서 바로 예외로 끝나고 해시·저장은 호출하지 않는다. 회원과 저장소에는 passwordHash만 전달된다. 6노드5간선의 같은 분기를 재배치한다. 원문 값은 해시 입력까지이며 해시 자체의 보안성은 이 흐름의 증거가 아니다. 가입 명령 입력 정규화·검사 중복 확인 existsByEmail 해시 생성 원문을 그대로 전달 저장 전 회원 id = 0 · 해시만 보유 저장 결과 새 값의 id > 0 중복 예외 뒤 작업 미실행 명령 중복 아님 해시 save 중복
가입의 정상 흐름과 중복 거절 분기 명령에서 중복검사로 진행한 뒤 중복이 아니어야 해시·회원생성·저장으로 간다. 중복이면 그 검사에서 바로 예외로 끝나고 해시·저장은 호출하지 않는다. 회원과 저장소에는 passwordHash만 전달된다. 6노드5간선의 같은 분기를 재배치한다. 원문 값은 해시 입력까지이며 해시 자체의 보안성은 이 흐름의 증거가 아니다. 가입 명령 입력 정규화·검사 중복 확인 existsByEmail 해시 생성 원문을 그대로 전달 저장 전 회원 id = 0 · 해시만 보유 저장 결과 새 값의 id > 0 중복 예외 해시·저장 미호출 명령 전달 중복 아님 해시 결과 save 중복

VALIDATE → CHECK → HASH → CREATE → SAVE

가입 흐름은 다섯 경계를 순서대로 지난다

  1. SignUpCommand

    이메일·이름을 정규화하고 원문 비밀번호는 바꾸지 않은 채 길이와 공백 여부만 검사합니다.

  2. MemberRegistrationService

    existsByEmail로 순차 실행의 중복을 먼저 확인합니다.

  3. PasswordHasher.hash(rawPassword)

    원문을 받아 저장용 passwordHash를 반환합니다. 가입 포트에는 아직 matches가 없습니다.

  4. Member.signUp(..., passwordHash)

    해시 결과를 전달해 id == 0인 저장 전 회원을 만듭니다. passwordHash도 String이므로 직접 호출의 잘못된 원문 전달까지 타입으로 막지는 못합니다.

  5. MemberRepository.save

    저장소가 양수 식별자를 부여하고 새 불변 객체를 반환합니다.

값마다 허용되는 경계와 변환 규칙
값 명령 경계 도메인·저장 경계 보존 규칙
email strip → Locale.ROOT lowercase 뒤 최종 254 UTF-16 단위 Member가 재검사하고 저장소도 같은 비교형으로 조회 비교와 저장에 같은 정규형 사용
name strip 뒤 1–50 UTF-16 단위 Member가 같은 규칙 재검사 진입점이 달라도 불변식 유지
rawPassword 공백 비밀번호 거절 · 8–128 UTF-16 단위 PasswordHasher까지만 전달 앞뒤 공백도 입력이므로 변경 금지
passwordHash 해시 역할이 생성 Member와 저장소에만 전달 저장 형식을 임의로 strip()하지 않음

값마다 허용되는 경계와 변환 규칙

email

명령 경계: 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 생성·저장으로 값이 이동한다.

정규화된 SignUpCommand가 MemberRegistrationService로 전달되고, 원문 비밀번호는 hash-only PasswordHasher까지만 흐르며, Member와 MemberRepository에는 passwordHash만 전달되는 순수 Java 회원가입 경계를 설명합니다.

PURE JAVA · HASH-ONLY PORT

순수 Java 회원가입은 원문을 해시 경계에서 멈춘다

지원 가입 경로에서 원문은 명령과 해시 역할까지만 전달됩니다. 저장되는 회원은 정규화된 이메일·이름과 해시 문자열만 가집니다.

가입의 정상 흐름과 중복 거절 분기 명령에서 중복검사로 진행한 뒤 중복이 아니어야 해시·회원생성·저장으로 간다. 중복이면 그 검사에서 바로 예외로 끝나고 해시·저장은 호출하지 않는다. 회원과 저장소에는 passwordHash만 전달된다. 6노드5간선의 같은 분기를 재배치한다. 원문 값은 해시 입력까지이며 해시 자체의 보안성은 이 흐름의 증거가 아니다. 가입 명령 입력 정규화·검사 중복 확인 existsByEmail 해시 생성 원문을 그대로 전달 저장 전 회원 id = 0 · 해시만 보유 저장 결과 새 값의 id > 0 중복 예외 뒤 작업 미실행 명령 중복 아님 해시 save 중복
가입의 정상 흐름과 중복 거절 분기 명령에서 중복검사로 진행한 뒤 중복이 아니어야 해시·회원생성·저장으로 간다. 중복이면 그 검사에서 바로 예외로 끝나고 해시·저장은 호출하지 않는다. 회원과 저장소에는 passwordHash만 전달된다. 6노드5간선의 같은 분기를 재배치한다. 원문 값은 해시 입력까지이며 해시 자체의 보안성은 이 흐름의 증거가 아니다. 가입 명령 입력 정규화·검사 중복 확인 existsByEmail 해시 생성 원문을 그대로 전달 저장 전 회원 id = 0 · 해시만 보유 저장 결과 새 값의 id > 0 중복 예외 해시·저장 미호출 명령 전달 중복 아님 해시 결과 save 중복

VALIDATE → CHECK → HASH → CREATE → SAVE

가입 흐름은 다섯 경계를 순서대로 지난다

  1. SignUpCommand

    이메일·이름을 정규화하고 원문 비밀번호는 바꾸지 않은 채 길이와 공백 여부만 검사합니다.

  2. MemberRegistrationService

    existsByEmail로 순차 실행의 중복을 먼저 확인합니다.

  3. PasswordHasher.hash(rawPassword)

    원문을 받아 저장용 passwordHash를 반환합니다. 가입 포트에는 아직 matches가 없습니다.

  4. Member.signUp(..., passwordHash)

    해시 결과를 전달해 id == 0인 저장 전 회원을 만듭니다. passwordHash도 String이므로 직접 호출의 잘못된 원문 전달까지 타입으로 막지는 못합니다.

  5. MemberRepository.save

    저장소가 양수 식별자를 부여하고 새 불변 객체를 반환합니다.

값마다 허용되는 경계와 변환 규칙
값 명령 경계 도메인·저장 경계 보존 규칙
email strip → Locale.ROOT lowercase 뒤 최종 254 UTF-16 단위 Member가 재검사하고 저장소도 같은 비교형으로 조회 비교와 저장에 같은 정규형 사용
name strip 뒤 1–50 UTF-16 단위 Member가 같은 규칙 재검사 진입점이 달라도 불변식 유지
rawPassword 공백 비밀번호 거절 · 8–128 UTF-16 단위 PasswordHasher까지만 전달 앞뒤 공백도 입력이므로 변경 금지
passwordHash 해시 역할이 생성 Member와 저장소에만 전달 저장 형식을 임의로 strip()하지 않음

값마다 허용되는 경계와 변환 규칙

email

명령 경계: 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지원 경로가 원문 저장을 피합니다.

경계 테스트

테스트는 성공 결과뿐 아니라 잘못된 입력, 중복 실패 뒤 상태, 저장소가 반환한 식별된 회원의 재저장을 함께 확인합니다.

board-core/src/test/java/board/member/MemberRegistrationServiceTest.java
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 갱신에서 원 입력 객체는 유지되는 저장 전후 상태이다.

Member.signUp이 id 0 값을 만들고 메모리 저장소가 양수 ID의 새 값을 반환하며, 정상·잘못된 입력·중복·식별된 반환값 재저장 테스트가 성공 결과와 실패 뒤 저장소 불변을 함께 검증하는 범위를 설명합니다.

STATE TRANSITION · FAILURE EVIDENCE

저장 전·후 상태와 실패 뒤 불변을 함께 검증한다

예외 타입만 확인하지 말고 실패 뒤 남은 상태까지 봅니다. 이 저장 흐름은 양수 ID의 새 값을 반환하고, 거절된 경로는 기존 저장 상태를 바꾸지 않아야 합니다.

저장 반환값의 생성과 재저장 거절 현재 record의 유효한 ID는0 또는 양수다. save는 양수 ID입력을 순번계산 전에 거절하며, id0이면 먼저새불변값을 만든 뒤 순번과Map을갱신한다. 입력 객체가 직접변하는 그림이 아니다. 5노드4간선이고 거절은 검사지점의 별도 분기다. 원본id0의단회소비·동시성·실제영속화를 보장하지 않는다. 저장 입력 Member · id = 0 저장 전 상태 검사 id가 이미 있는가? 다음 ID의 새 값 identifiedBy(sequence + 1) 저장 상태 갱신 순번 갱신·Map 저장 재저장 거절 상태 유지 save id = 0 새 값 준비 id > 0
저장 반환값의 생성과 재저장 거절 현재 record의 유효한 ID는0 또는 양수다. save는 양수 ID입력을 순번계산 전에 거절하며, id0이면 먼저새불변값을 만든 뒤 순번과Map을갱신한다. 입력 객체가 직접변하는 그림이 아니다. 5노드4간선이고 거절은 검사지점의 별도 분기다. 원본id0의단회소비·동시성·실제영속화를 보장하지 않는다. 저장 입력 Member · id = 0 저장 전 상태 검사 id가 이미 있는가? 다음 ID의 새 값 identifiedBy(sequence + 1) 저장 상태 갱신 순번 갱신·Map 저장 재저장 거절 순번·Map 변경 전 save id = 0 새 값 준비 id > 0

UNSAVED → IDENTIFIED

메모리 저장소는 id 0 입력에서 양수 ID 값을 반환한다

  1. Member.signUp

    불변식을 통과한 새 회원은 id == 0입니다.

  2. MemberRepository.save

    저장 전 상태인지 먼저 확인한 뒤 다음 순번을 계산합니다.

  3. identifiedBy(savedId)

    savedId > 0일 때만 양수 ID를 가진 새 불변 값을 반환합니다.

  4. 별도 실패 경로: 식별된 반환값 재저장

    이미 양수 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 갱신에서 원 입력 객체는 유지되는 저장 전후 상태이다.

Member.signUp이 id 0 값을 만들고 메모리 저장소가 양수 ID의 새 값을 반환하며, 정상·잘못된 입력·중복·식별된 반환값 재저장 테스트가 성공 결과와 실패 뒤 저장소 불변을 함께 검증하는 범위를 설명합니다.

STATE TRANSITION · FAILURE EVIDENCE

저장 전·후 상태와 실패 뒤 불변을 함께 검증한다

예외 타입만 확인하지 말고 실패 뒤 남은 상태까지 봅니다. 이 저장 흐름은 양수 ID의 새 값을 반환하고, 거절된 경로는 기존 저장 상태를 바꾸지 않아야 합니다.

저장 반환값의 생성과 재저장 거절 현재 record의 유효한 ID는0 또는 양수다. save는 양수 ID입력을 순번계산 전에 거절하며, id0이면 먼저새불변값을 만든 뒤 순번과Map을갱신한다. 입력 객체가 직접변하는 그림이 아니다. 5노드4간선이고 거절은 검사지점의 별도 분기다. 원본id0의단회소비·동시성·실제영속화를 보장하지 않는다. 저장 입력 Member · id = 0 저장 전 상태 검사 id가 이미 있는가? 다음 ID의 새 값 identifiedBy(sequence + 1) 저장 상태 갱신 순번 갱신·Map 저장 재저장 거절 상태 유지 save id = 0 새 값 준비 id > 0
저장 반환값의 생성과 재저장 거절 현재 record의 유효한 ID는0 또는 양수다. save는 양수 ID입력을 순번계산 전에 거절하며, id0이면 먼저새불변값을 만든 뒤 순번과Map을갱신한다. 입력 객체가 직접변하는 그림이 아니다. 5노드4간선이고 거절은 검사지점의 별도 분기다. 원본id0의단회소비·동시성·실제영속화를 보장하지 않는다. 저장 입력 Member · id = 0 저장 전 상태 검사 id가 이미 있는가? 다음 ID의 새 값 identifiedBy(sequence + 1) 저장 상태 갱신 순번 갱신·Map 저장 재저장 거절 순번·Map 변경 전 save id = 0 새 값 준비 id > 0

UNSAVED → IDENTIFIED

메모리 저장소는 id 0 입력에서 양수 ID 값을 반환한다

  1. Member.signUp

    불변식을 통과한 새 회원은 id == 0입니다.

  2. MemberRepository.save

    저장 전 상태인지 먼저 확인한 뒤 다음 순번을 계산합니다.

  3. identifiedBy(savedId)

    savedId > 0일 때만 양수 ID를 가진 새 불변 값을 반환합니다.

  4. 별도 실패 경로: 식별된 반환값 재저장

    이미 양수 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입니다. 이 결론은 단일 스레드와 현재 메모리 구현의 조건이며, 동시 요청의 고유성을 증명하지 않습니다.