회원가입·게시판 첫 흐름
기존 게시글 API에 회원 도메인·저장소·서비스·컨트롤러를 같은 계층 구조로 추가하고 가입 응답의 회원 식별자로 게시글을 등록·조회합니다.
앞 글까지 게시글의 도메인 모델, 저장소, 서비스, Spring 구성, 웹 API를 차례로 만들었습니다. 이번 글에서는 기존 코드를 버리거나 같은 이름의 클래스를 다시 만들지 않고, 회원 기능만 같은 순서로 추가합니다. 게시글 등록에는 앞에서 만든 PostService.register(CreatePostCommand)와 /api/posts를 그대로 씁니다.
처음 배우는 단계에서는 네 계층의 역할을 구분하면 충분합니다. 웹 계층은 HTTP 요청을 읽고 응답을 만들고, 애플리케이션 계층은 회원가입이나 게시글 등록의 호출 순서를 이끕니다. 도메인 계층은 회원과 게시글이 지켜야 할 규칙과 저장 방법의 인터페이스를 나타내고, 인프라 계층은 메모리 맵이나 비밀번호 해시처럼 구체적인 방법을 구현합니다.
회원가입 포트와 비밀번호 경계
가입 중복 확인 뒤 원문이 hash 포트까지만 가고 Member·저장에는 해시만 남는 전환을 보여 준다.
MEMBER REGISTRATION · PORTS AND ADAPTERS
원문 비밀번호는 해시 포트까지만 흐르고 저장에는 해시만 남는다
웹 입력, 사용 사례, 도메인, 인프라의 책임을 분리합니다. 서비스는 포트만 호출하고 구체 구현은 구성에서 연결합니다.
SUPPORTED REGISTRATION PATH
요청 문자열에서 저장된 회원까지 다섯 경계를 지난다
-
RegisterMemberRequestemail·name만 null 보존strip()후 검증합니다.rawPassword는 앞뒤 공백까지 바꾸지 않습니다. -
RegisterMemberCommand정규화된 이메일·이름과 변경하지 않은
rawPassword를 웹 애노테이션 없는 명령으로 옮깁니다. -
MemberRegistrationServiceexistsByEmail사전 확인 뒤 두 포트를 지휘합니다. 이 확인과 저장은 원자적이지 않아 동시 가입 경쟁은 남습니다. -
PasswordHasher.hash(rawPassword)지원 서비스 경로에서 원문을 전달하는 마지막 경계입니다.
Pbkdf2PasswordHasher어댑터가 salt와 현재 반복 정책을 사용해passwordHash를 반환합니다. -
Member.signUp(..., passwordHash)→ 저장Member에는id·email·name·passwordHash만 있습니다. 저장 포트와 메모리 어댑터는 원문 비밀번호를 받지 않습니다.
DOMAIN STATE
저장 가능 상태
Member(email, name, passwordHash)
rawPassword 전용 필드는 없습니다. 지원 서비스 경로는 해시 결과만 전달하지만, passwordHash도 String이므로 직접 호출에서 원문을 잘못 넘기는 일을 타입이 막지는 않습니다.
POLICY BOUNDARY
현재 정책값
PBKDF2-HMAC-SHA256 · 600_000회 · salt 16바이트 · 결과 32바이트
반복 횟수는 배포 환경에서 측정하고 갱신할 정책이며, 숫자 하나가 모든 운영 환경의 보안을 보장하지 않습니다.
추상 포트와 이번 장에서 선택한 인프라 어댑터
해시
포트: PasswordHasher · 애플리케이션 포트
어댑터: Pbkdf2PasswordHasher · 인프라 어댑터
경계를 지나는 값: rawPassword → passwordHash
넘지 않는 값: 저장 기술과 회원 식별자
회원 저장
포트: MemberRepository · 도메인 포트
어댑터: MemoryMemberRepository · 인프라 어댑터
경계를 지나는 값: Member(passwordHash) → 저장된 Member(id)
넘지 않는 값: rawPassword
핵심 경계는 “해시를 한다”가 아니라 원문이 어디서 끝나는지입니다. 서비스는 PasswordHasher와 MemberRepository 계약에만 의존하고 저장 계층에는 passwordHash만 전달합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 실선은 지원 회원가입 경로의 값 전달과 호출 순서입니다. 서비스의 중복 사전 확인 뒤 원문 비밀번호는 PasswordHasher 포트를 통해 PBKDF2 어댑터에 전달되며 salt와 현재 반복 정책으로 만든 해시가 돌아옵니다. Member와 저장 포트에는 해시만 전달합니다. 직접 String 인자를 잘못 넘기는 것을 타입이 막거나 동시 중복을 원자화한다는 뜻은 아닙니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 회원가입 포트와 비밀번호 경계 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 중복 사전 확인 가입 service가 중복을 먼저 확인합니다. | 입력: 가입 명령 · 중복: 해시·저장 전 종료 | 거부할 입력은 비밀번호 처리 전에 끝납니다. |
| 해시 포트 전환 원문을 PasswordHasher에 전달해 salt·반복 정책으로 해시를 만듭니다. | 입력: 원문 · 출력: passwordHash | 원문이 해시 경계 안에서 변환됩니다. |
| 해시만 저장 전달 Member와 저장 포트에는 해시만 넘깁니다. | Member: 해시 · 저장: 해시 · 원문: 전달 안 함 | 저장 경계를 넘는 비밀번호 표현이 바뀝니다. |
회원가입 포트와 비밀번호 경계
가입 중복 확인 뒤 원문이 hash 포트까지만 가고 Member·저장에는 해시만 남는 전환을 보여 준다.
MEMBER REGISTRATION · PORTS AND ADAPTERS
원문 비밀번호는 해시 포트까지만 흐르고 저장에는 해시만 남는다
웹 입력, 사용 사례, 도메인, 인프라의 책임을 분리합니다. 서비스는 포트만 호출하고 구체 구현은 구성에서 연결합니다.
SUPPORTED REGISTRATION PATH
요청 문자열에서 저장된 회원까지 다섯 경계를 지난다
-
RegisterMemberRequestemail·name만 null 보존strip()후 검증합니다.rawPassword는 앞뒤 공백까지 바꾸지 않습니다. -
RegisterMemberCommand정규화된 이메일·이름과 변경하지 않은
rawPassword를 웹 애노테이션 없는 명령으로 옮깁니다. -
MemberRegistrationServiceexistsByEmail사전 확인 뒤 두 포트를 지휘합니다. 이 확인과 저장은 원자적이지 않아 동시 가입 경쟁은 남습니다. -
PasswordHasher.hash(rawPassword)지원 서비스 경로에서 원문을 전달하는 마지막 경계입니다.
Pbkdf2PasswordHasher어댑터가 salt와 현재 반복 정책을 사용해passwordHash를 반환합니다. -
Member.signUp(..., passwordHash)→ 저장Member에는id·email·name·passwordHash만 있습니다. 저장 포트와 메모리 어댑터는 원문 비밀번호를 받지 않습니다.
DOMAIN STATE
저장 가능 상태
Member(email, name, passwordHash)
rawPassword 전용 필드는 없습니다. 지원 서비스 경로는 해시 결과만 전달하지만, passwordHash도 String이므로 직접 호출에서 원문을 잘못 넘기는 일을 타입이 막지는 않습니다.
POLICY BOUNDARY
현재 정책값
PBKDF2-HMAC-SHA256 · 600_000회 · salt 16바이트 · 결과 32바이트
반복 횟수는 배포 환경에서 측정하고 갱신할 정책이며, 숫자 하나가 모든 운영 환경의 보안을 보장하지 않습니다.
추상 포트와 이번 장에서 선택한 인프라 어댑터
해시
포트: PasswordHasher · 애플리케이션 포트
어댑터: Pbkdf2PasswordHasher · 인프라 어댑터
경계를 지나는 값: rawPassword → passwordHash
넘지 않는 값: 저장 기술과 회원 식별자
회원 저장
포트: MemberRepository · 도메인 포트
어댑터: MemoryMemberRepository · 인프라 어댑터
경계를 지나는 값: Member(passwordHash) → 저장된 Member(id)
넘지 않는 값: rawPassword
핵심 경계는 “해시를 한다”가 아니라 원문이 어디서 끝나는지입니다. 서비스는 PasswordHasher와 MemberRepository 계약에만 의존하고 저장 계층에는 passwordHash만 전달합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 실선은 지원 회원가입 경로의 값 전달과 호출 순서입니다. 서비스의 중복 사전 확인 뒤 원문 비밀번호는 PasswordHasher 포트를 통해 PBKDF2 어댑터에 전달되며 salt와 현재 반복 정책으로 만든 해시가 돌아옵니다. Member와 저장 포트에는 해시만 전달합니다. 직접 String 인자를 잘못 넘기는 것을 타입이 막거나 동시 중복을 원자화한다는 뜻은 아닙니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 회원가입 포트와 비밀번호 경계 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 중복 사전 확인 가입 service가 중복을 먼저 확인합니다. | 입력: 가입 명령 · 중복: 해시·저장 전 종료 | 거부할 입력은 비밀번호 처리 전에 끝납니다. |
| 해시 포트 전환 원문을 PasswordHasher에 전달해 salt·반복 정책으로 해시를 만듭니다. | 입력: 원문 · 출력: passwordHash | 원문이 해시 경계 안에서 변환됩니다. |
| 해시만 저장 전달 Member와 저장 포트에는 해시만 넘깁니다. | Member: 해시 · 저장: 해시 · 원문: 전달 안 함 | 저장 경계를 넘는 비밀번호 표현이 바뀝니다. |
그림은 이번 글에서 만들 회원가입 경로를 보여 줍니다. 요청은 명령으로 바뀌어 가입 서비스로 가고, 서비스는 먼저 같은 이메일이 있는지 확인합니다. 원문 비밀번호는 PasswordHasher 포트를 거쳐 PBKDF2 어댑터까지만 흐르며, 어댑터가 무작위 솔트(salt)와 반복 횟수로 만든 해시만 Member와 회원 저장소로 넘어갑니다.
완성할 요청 순서
이번 글의 흐름은 JSON API 요청 세 개로 확인합니다.
| 순서 | 요청 | 결과 |
|---|---|---|
| 1 | POST /api/members | 회원을 저장하고 authorId를 돌려준다 |
| 2 | POST /api/posts | 받은 authorId로 게시글을 저장한다 |
| 3 | GET /api/posts/{postId} | 저장된 게시글을 식별자로 다시 조회한다 |
1장에서는 아직 로그인 세션을 만들지 않습니다. 가입 응답으로 받은 authorId를 다음 요청에 직접 넣어, 값이 이어지는지만 확인합니다. 브라우저가 보낸 작성자 식별자를 믿지 않고 로그인 세션에서 꺼내는 방법은 인증을 다루는 장에서 추가합니다.
회원 도메인과 저장소
가입 요청에는 원문 비밀번호가 들어 있지만, 저장되는 회원에는 비밀번호 해시만 있어야 합니다. 그래서 원문을 받는 요청과 명령을 해시를 담는 Member와 분리하고, 회원가입 서비스가 해시 결과만 회원을 만드는 쪽에 넘기게 합니다. 다만 passwordHash도 String이므로, 필드 이름과 타입만으로 호출하는 쪽이 실수로 원문을 넘기는 일까지 막지는 못합니다.
package board.domain;
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 = requireText(email, "email", 254)
.toLowerCase(Locale.ROOT);
name = requireText(name, "name", 50);
passwordHash = requireText(passwordHash, "passwordHash");
}
public static Member signUp(
String email,
String name,
String passwordHash
) {
return new Member(0, email, name, passwordHash);
}
public Member identifiedBy(long savedId) {
if (id != 0) {
throw new IllegalStateException("member already has an id");
}
if (savedId < 1) {
throw new IllegalArgumentException("saved id must be positive");
}
return new Member(savedId, email, name, passwordHash);
}
private static String requireText(
String value,
String field,
int maxLength
) {
var normalized = requireText(value, field);
if (normalized.length() > maxLength) {
throw new IllegalArgumentException(
field + " has an invalid length");
}
return normalized;
}
private static String requireText(String value, String field) {
Objects.requireNonNull(value, field + " must not be null");
var normalized = value.strip();
if (normalized.isEmpty()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return normalized;
}
}id가 0인 회원은 아직 저장되지 않은 상태이고, 저장소가 identifiedBy로 양수 식별자를 붙이면 저장된 상태가 됩니다. 게시글은 PostDraft와 Post라는 두 타입으로 이 상태를 나누었지만, 회원은 필드가 적어 id 값 하나로 구분했습니다. 저장 방법의 인터페이스는 PostRepository와 같은 도메인 패키지에 둡니다.
package board.domain;
public interface MemberRepository {
boolean existsByEmail(String email);
Member save(Member member);
}MemberRepository는 회원가입에 필요한 두 동작, 이메일 중복 확인과 저장만 정합니다. 아직 회원을 조회하는 사용 사례가 없으므로 조회 메서드는 만들지 않았습니다. 메모리 구현은 게시글 저장소처럼 인프라 패키지에 둡니다. 아래 existsByEmail은 넘겨받은 이메일도 공백을 지우고 소문자로 바꾼 뒤, Member가 같은 방식으로 정규화해 저장한 이메일과 비교합니다.
package board.infrastructure;
import board.domain.Member;
import board.domain.MemberRepository;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
public final class MemoryMemberRepository implements MemberRepository {
private final AtomicLong sequence = new AtomicLong();
private final ConcurrentHashMap<Long, Member> store =
new ConcurrentHashMap<>();
@Override
public boolean existsByEmail(String email) {
var normalized = email.strip().toLowerCase(Locale.ROOT);
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 IllegalArgumentException("member must be unsaved");
}
var saved = member.identifiedBy(sequence.incrementAndGet());
store.put(saved.id(), saved);
return saved;
}
}MemoryMemberRepository는 회원을 저장할 때만 식별자를 붙이므로, 컨트롤러나 서비스가 번호를 매기는 규칙을 알 필요가 없습니다. 회원 저장소는 게시글 저장소와 다른 AtomicLong을 쓰므로 두 식별자는 서로 따로 증가합니다.
회원가입 사용 사례
웹 요청 객체를 서비스에 바로 넘기지 않고 애플리케이션 명령으로 바꿉니다. 앞 글의 CreatePostRequest를 CreatePostCommand로 바꾼 것과 같은 구조입니다.
package board.application;
import java.util.Locale;
public record RegisterMemberCommand(
String email,
String name,
String rawPassword
) {
public RegisterMemberCommand {
email = requireText(email, "email", 254)
.toLowerCase(Locale.ROOT);
name = requireText(name, "name", 50);
requireRawPassword(rawPassword);
}
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");
}
}
}email과 name은 앞뒤 공백을 지워 정규화하지만, rawPassword는 앞뒤 공백도 비밀번호의 일부이므로 바꾸지 않습니다. 명령은 HTTP 밖에서 직접 만들어지더라도 이메일과 이름의 길이, 원문 비밀번호의 길이를 다시 검사합니다. 이메일 형식 검사는 웹 요청 객체의 @Email이 맡습니다.
비밀번호 해시는 애플리케이션 계층에 인터페이스로 둡니다. 이렇게 하면 서비스는 PBKDF2의 반복 횟수나 결과 문자열의 형식을 몰라도 됩니다. 이메일 중복도 HTTP와 상관없는 사용 사례의 실패이므로 애플리케이션 예외로 만듭니다.
package board.application;
public interface PasswordHasher {
String hash(String rawPassword);
}package board.application;
public final class DuplicateEmailException extends RuntimeException {
public DuplicateEmailException(String email) {
super("email already exists: " + email);
}
}PasswordHasher는 원문을 받아 저장할 문자열을 돌려주는 메서드 하나만 가지므로, 테스트에서는 계산이 가벼운 가짜 구현으로 바꿔 끼울 수 있습니다. DuplicateEmailException은 HTTP 상태를 모르는 예외이며, 409로 바꾸는 일은 뒤에서 웹 계층이 합니다. 서비스는 중복 확인, 해시, 회원 생성, 저장의 순서로 호출합니다.
package board.application;
import board.domain.Member;
import board.domain.MemberRepository;
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(RegisterMemberCommand command) {
Objects.requireNonNull(command, "command must not be null");
if (members.existsByEmail(command.email())) {
throw new DuplicateEmailException(command.email());
}
var passwordHash = passwordHasher.hash(command.rawPassword());
var member = Member.signUp(
command.email(), command.name(), passwordHash);
return members.save(member);
}
}서비스는 원문 비밀번호를 회원 객체나 저장소에 넘기지 않으며, 구체적인 해시 계산은 인프라 계층이 맡습니다. 중복을 해시보다 먼저 확인하는 것은, 어차피 거부할 요청에 비용이 큰 해시 계산을 하지 않기 위해서입니다. 다만 게시글과 마찬가지로 이 확인과 저장 사이에 다른 가입 요청이 끼어드는 경쟁은 막지 못합니다.
PBKDF2 해시 구현
비밀번호는 SHA-256 같은 빠른 일반 해시가 아니라, 계산을 여러 번 반복하고 무작위 솔트를 쓰는 비밀번호 전용 방식으로 저장합니다. 같은 비밀번호라도 솔트가 다르면 해시가 달라지고, 반복 횟수가 많을수록 공격자가 비밀번호를 하나씩 대입해 보는 비용이 커집니다. 다음 구현은 JDK가 제공하는 PBKDF2를 쓰므로 별도 라이브러리가 필요 없습니다.
package board.infrastructure;
import board.application.PasswordHasher;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Base64;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
public final class Pbkdf2PasswordHasher implements PasswordHasher {
private static final String ALGORITHM = "PBKDF2WithHmacSHA256";
private static final int ITERATIONS = 600_000;
private static final int KEY_LENGTH_BITS = 256;
private static final int SALT_LENGTH_BYTES = 16;
private final SecureRandom random = new SecureRandom();
@Override
public String hash(String rawPassword) {
requireRawPassword(rawPassword);
var salt = new byte[SALT_LENGTH_BYTES];
random.nextBytes(salt);
var derived = derive(rawPassword, salt, ITERATIONS);
var encoder = Base64.getUrlEncoder().withoutPadding();
return "$pbkdf2-sha256$%d$%s$%s".formatted(
ITERATIONS,
encoder.encodeToString(salt),
encoder.encodeToString(derived));
}
private static byte[] derive(
String rawPassword,
byte[] salt,
int iterations
) {
var specification = new PBEKeySpec(
rawPassword.toCharArray(),
salt,
iterations,
KEY_LENGTH_BITS);
try {
return SecretKeyFactory.getInstance(ALGORITHM)
.generateSecret(specification)
.getEncoded();
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("PBKDF2 is unavailable", exception);
} finally {
specification.clearPassword();
}
}
private static void requireRawPassword(String rawPassword) {
if (rawPassword == null || rawPassword.isBlank()) {
throw new IllegalArgumentException("rawPassword is required");
}
}
}결과 문자열은 $pbkdf2-sha256$600000$<솔트>$<해시> 모양이며, 알고리즘 이름과 반복 횟수, 솔트, 계산 결과가 들어 있지만 원문 비밀번호는 들어 있지 않습니다. 16바이트 솔트와 32바이트 결과를 패딩 없는 Base64 URL 형식으로 바꾸므로 각각 22자와 43자가 됩니다. 600_000은 OWASP의 비밀번호 저장 권고가 PBKDF2-HMAC-SHA256에 제시하는 반복 횟수를 이 예제의 정책으로 삼은 값입니다. 실제로 배포할 때는 목표 응답 시간과 서버 장비에서 직접 측정해 정책을 정하고 꾸준히 갱신해야 하며, 이 숫자 하나가 모든 환경에서 충분한 보안을 보장하지는 않습니다.
이번 장은 회원가입에 필요한 해시 생성만 구현합니다. 로그인할 때 쓸 검증 기능과 저장된 문자열을 해석하는 코드는 인증 사용 사례가 생길 때 추가합니다. 그 검증기는 알고리즘과 반복 횟수, 솔트와 결과의 길이가 허용 범위인지 확인하고, 계산 결과를 비교할 때 걸리는 시간이 값에 따라 달라지지 않는 상수 시간(constant-time) 비교를 써야 합니다.
회원 웹 API
웹 요청 객체는 Bean Validation으로 HTTP 입력의 모양을 먼저 검사하고, 명령과 도메인에는 정규화와 길이 검사를 남겨 HTTP 밖의 진입점도 같은 규칙을 따르게 합니다. 컨트롤러의 @Valid 검사에서 실패하면 toCommand()가 호출되지 않으므로, 형식이 틀린 요청은 서비스와 비용이 큰 PBKDF2 계산까지 가지 않습니다.
package board.web;
import board.application.RegisterMemberCommand;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterMemberRequest(
@NotBlank @Email @Size(max = 254) String email,
@NotBlank @Size(max = 50) String name,
@NotBlank @Size(min = 8, max = 128) String rawPassword
) {
public RegisterMemberRequest {
email = stripIfPresent(email);
name = stripIfPresent(name);
}
RegisterMemberCommand toCommand() {
return new RegisterMemberCommand(email, name, rawPassword);
}
private static String stripIfPresent(String value) {
return value == null ? null : value.strip();
}
}컴팩트 생성자는 이메일과 이름만 먼저 strip()하고, rawPassword는 JSON에서 읽은 문자열 그대로 둡니다. @Size와 String.length()는 둘 다 UTF-16 코드 단위로 세므로 비밀번호의 8~128 제한은 요청 객체와 명령에서 똑같이 적용됩니다. 그래서 " secret-1234 "처럼 앞뒤에 공백이 있는 비밀번호도 공백까지 그대로 RegisterMemberCommand와 PasswordHasher에 전달됩니다.
응답에는 passwordHash를 넣지 않습니다. 또 기존 CreatePostRequest.authorId가 문자열이므로, 숫자 회원 식별자를 문자열로 바꾼 authorId를 함께 돌려줍니다.
package board.web;
import board.domain.Member;
public record MemberResponse(
long id,
String authorId,
String email,
String name
) {
static MemberResponse from(Member member) {
return new MemberResponse(
member.id(),
Long.toString(member.id()),
member.email(),
member.name());
}
}컨트롤러는 게시글 컨트롤러와 같은 방식으로 서비스 호출 결과와 Location을 돌려줍니다. 이 Location은 새 회원 리소스의 주소를 알리지만, 이번 글에서는 GET /api/members/{id}를 구현하지 않습니다. 아래 실행에서도 회원의 Location은 조회하지 않고 응답 본문의 authorId만 복사합니다.
package board.web;
import board.application.MemberRegistrationService;
import jakarta.validation.Valid;
import java.net.URI;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/members")
public final class MemberController {
private final MemberRegistrationService service;
public MemberController(MemberRegistrationService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<MemberResponse> register(
@Valid @RequestBody RegisterMemberRequest request
) {
var member = service.register(request.toCommand());
var location = URI.create("/api/members/" + member.id());
return ResponseEntity.created(location)
.body(MemberResponse.from(member));
}
}중복 이메일은 게시글의 중복 예외처럼 409로 바꿉니다. 앞 글의 ApiExceptionHandler를 다음 최종 형태로 바꾸면 게시글과 회원의 오류를 한 곳에서 HTTP 응답으로 바꿀 수 있습니다.
package board.web;
import board.application.DuplicateEmailException;
import board.application.DuplicatePostException;
import board.application.PostNotFoundException;
import java.net.URI;
import java.util.Locale;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public final class ApiExceptionHandler {
@ExceptionHandler(PostNotFoundException.class)
ProblemDetail notFound(PostNotFoundException exception) {
return problem(HttpStatus.NOT_FOUND, "POST_NOT_FOUND",
exception.getMessage());
}
@ExceptionHandler(DuplicatePostException.class)
ProblemDetail duplicatePost(DuplicatePostException exception) {
return problem(HttpStatus.CONFLICT, "POST_DUPLICATE",
exception.getMessage());
}
@ExceptionHandler(DuplicateEmailException.class)
ProblemDetail duplicateEmail(DuplicateEmailException exception) {
return problem(HttpStatus.CONFLICT, "MEMBER_EMAIL_DUPLICATE",
exception.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail invalidRequest(
MethodArgumentNotValidException exception
) {
return problem(HttpStatus.BAD_REQUEST, "INVALID_REQUEST",
"request validation failed");
}
@ExceptionHandler(IllegalArgumentException.class)
ProblemDetail invalidRequest(IllegalArgumentException exception) {
return problem(HttpStatus.BAD_REQUEST, "INVALID_REQUEST",
exception.getMessage());
}
private ProblemDetail problem(
HttpStatus status,
String code,
String detail
) {
var problem = ProblemDetail.forStatusAndDetail(status, detail);
problem.setType(URI.create(
"https://andongmin.com/problems/"
+ code.toLowerCase(Locale.ROOT)
.replace('_', '-')));
problem.setTitle(status.getReasonPhrase());
problem.setProperty("code", code);
return problem;
}
}type, title, code를 만드는 방법은 앞 글과 같습니다. 바뀐 것은 검증 실패의 분류입니다. 게시글 전용이던 INVALID_POST를 회원과 게시글이 함께 쓰는 INVALID_REQUEST로 바꾸었으므로, 앞 글의 PostControllerTest에서도 검증 실패의 $.code 기대값을 INVALID_REQUEST로 고칩니다. 고정된 detail인 "request validation failed"는 그대로 둡니다.
기존 구성에 회원 빈 추가
BoardConfig를 다음 최종 형태로 바꿉니다. 앞에서 만든 게시글 빈 세 개는 그대로 두고, 회원 저장소와 해시 구현, 회원가입 서비스만 추가합니다.
package board.config;
import board.application.MemberRegistrationService;
import board.application.PasswordHasher;
import board.application.PostService;
import board.domain.MemberRepository;
import board.domain.PostRepository;
import board.infrastructure.MemoryMemberRepository;
import board.infrastructure.MemoryPostRepository;
import board.infrastructure.Pbkdf2PasswordHasher;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class BoardConfig {
@Bean
PostRepository postRepository() {
return new MemoryPostRepository();
}
@Bean
Clock postClock() {
return Clock.systemUTC();
}
@Bean
PostService postService(
PostRepository repository,
Clock postClock
) {
return new PostService(repository, postClock);
}
@Bean
MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
PasswordHasher passwordHasher() {
return new Pbkdf2PasswordHasher();
}
@Bean
MemberRegistrationService memberRegistrationService(
MemberRepository repository,
PasswordHasher passwordHasher
) {
return new MemberRegistrationService(repository, passwordHasher);
}
}MemberController와 PostController는 둘 다 board.web에 있으므로 board.BoardApplication의 컴포넌트 스캔이 함께 찾습니다. 서비스의 생성자는 역할을 나타내는 인터페이스만 받고, 어떤 구현을 쓸지는 BoardConfig가 고릅니다.
가입 결과로 게시글 등록
아래 세 응답은 애플리케이션을 새로 시작해 회원과 게시글의 메모리 저장소가 비어 있다고 가정합니다. 앞 글의 요청을 같은 프로세스에서 이미 보냈다면 게시글 식별자가 더 커져 있을 수 있으므로, 숫자 1을 그대로 따라 쓰지 말고 이번 응답의 authorId와 게시글의 Location을 다음 요청에 씁니다. 애플리케이션을 실행하고 회원을 등록합니다.
curl -i -X POST http://localhost:8080/api/members \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","name":"안동민","rawPassword":"secret-1234"}'HTTP/1.1 201
Location: /api/members/1
Content-Type: application/json
{"id":1,"authorId":"1","email":"[email protected]","name":"안동민"}PBKDF2를 60만 번 반복하므로 이 요청은 다른 요청보다 눈에 띄게 오래 걸릴 수 있습니다. 응답의 authorId 값 "1"을 게시글 API에 그대로 넣습니다. 이 문자열은 이번 실행에서 회원 식별자를 복사했다는 것만 뜻하며, 게시글 API는 아직 회원이 있는지, 로그인했는지, 작성 권한이 있는지 확인하지 않습니다.
curl -i -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{"authorId":"1","title":"첫 게시글","content":"회원가입 뒤 게시글을 등록합니다.","publishedOn":"2026-07-13"}'HTTP/1.1 201
Location: /api/posts/1
Content-Type: application/json
{"id":1,"authorId":"1","title":"첫 게시글","content":"회원가입 뒤 게시글을 등록합니다.","publishedOn":"2026-07-13"}마지막으로 게시글 등록 응답의 Location을 조회합니다. 회원 등록 응답의 Location에는 아직 대응하는 GET 매핑이 없습니다.
curl -i http://localhost:8080/api/posts/1HTTP/1.1 200
Content-Type: application/json
{"id":1,"authorId":"1","title":"첫 게시글","content":"회원가입 뒤 게시글을 등록합니다.","publishedOn":"2026-07-13"}새 게시글 타입이나 board.post.PostService 같은 클래스는 만들지 않았습니다. 가입한 뒤의 게시글 요청도 앞에서 만든 board.web.CreatePostRequest, board.application.CreatePostCommand, board.application.PostService, board.domain.PostRepository가 그대로 이어받습니다.
회원에서 게시글로 이어지는 요청과 보장 경계
회원 응답 authorId를 게시글 POST에 전달한 뒤 Location GET으로 조회하는 실제 세 요청 순서이다.
MEMBER → POST → GET · GUARANTEE VS LIMIT
복사한 authorId는 요청을 잇지만 신원과 권한을 증명하지 않는다
두 저장소는 서로 다른 ID를 독립적으로 부여합니다. 아래의 같은 숫자 1은 첫 실행의 우연한 결과이지 공유 순번이나 관계 제약이 아닙니다.
THREE REQUESTS · ONE COPIED VALUE
회원 응답을 게시글 입력으로 복사한 뒤 게시글 위치만 조회한다
-
POST /api/members회원 저장소가 자체 순번으로 member ID를 부여하고
MemberResponse가 같은 값을 문자열authorId로 제공합니다. -
authorId값 복사클라이언트가 응답 본문의 문자열을
CreatePostRequest.authorId에 그대로 넣습니다. 서버가 회원을 다시 조회하는 단계는 없습니다. -
POST /api/posts게시글 저장소가 회원 순번과 무관한 자체 post ID를 부여합니다. 게시글은 전달받은
authorId문자열을 그대로 저장합니다. -
Location: /api/posts/{postId}게시글 생성 응답이 새 게시글의 식별 URI를 제공합니다. 이 경로에는 앞 문서에서 구현한 단건 GET이 있습니다.
-
GET /api/posts/{postId}게시글
Location을 조회해 저장된 게시글과 복사된authorId를 확인합니다.
| 경계 | 현재 보장 | 보장하지 않음 |
|---|---|---|
| ID 발급 | 회원 저장소와 게시글 저장소가 각각 양수 ID를 발급 | 공유 순번, 두 ID의 동일성, 숫자만으로 성립하는 관계 |
| 값 복사 | 회원 응답의 ID 문자열이 게시글의 authorId에 저장됨 |
회원 존재 확인과 데이터베이스 외래 키 |
| 보안 | 현재 없음 | 로그인 인증, 작성 권한, 소유권 검증 |
| 회원 위치 | Location: /api/members/{memberId}가 생성 회원의 식별 URI를 알림 |
이번 문서의 회원 단건 GET 구현과 해당 URI 조회 성공 |
| 게시글 위치 | Location: /api/posts/{postId}와 기존 단건 GET으로 쓰기 뒤 읽기 실행 |
영구 저장, 재시작 뒤 유지, 복합 작업 원자성과 중복 경쟁 방지 |
현재 코드가 실제로 보장하는 것과 아직 증명하지 않는 것
ID 발급
현재 보장: 회원 저장소와 게시글 저장소가 각각 양수 ID를 발급
보장하지 않음: 공유 순번, 두 ID의 동일성, 숫자만으로 성립하는 관계
값 복사
현재 보장: 회원 응답의 ID 문자열이 게시글의 authorId에 저장됨
보장하지 않음: 회원 존재 확인과 데이터베이스 외래 키
보안
현재 보장: 현재 없음
보장하지 않음: 로그인 인증, 작성 권한, 소유권 검증
회원 위치
현재 보장: Location: /api/members/{memberId}가 생성 회원의 식별 URI를 알림
보장하지 않음: 이번 문서의 회원 단건 GET 구현과 해당 URI 조회 성공
게시글 위치
현재 보장: Location: /api/posts/{postId}와 기존 단건 GET으로 쓰기 뒤 읽기 실행
보장하지 않음: 영구 저장, 재시작 뒤 유지, 복합 작업 원자성과 중복 경쟁 방지
INDEPENDENT SEQUENCES
서로 다른 식별자 · 독립 순번
각 저장소의 AtomicLong이 독립적으로 증가합니다.
둘 다 1이어도 “같은 엔티티”나 “검증된 작성자”를 뜻하지 않습니다.
NEXT ENFORCEMENT
관계를 신뢰하려면 별도 경계가 필요하다
회원 조회 또는 외래 키, 인증 주체, 인가·소유권 검사가 각자의 책임을 맡아야 합니다.
authorId 문자열 하나로 이 책임들을 대신할 수 없습니다.
이 장의 실행 증거는 “값을 복사해 세 요청이 이어진다”까지입니다. 회원 존재와 보안 관계는 아직 구현되지 않았고, 게시글 Location만 기존 GET 경로로 조회합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 실선은 클라이언트가 수행하는 세 HTTP 요청과 그 사이의 값 전달입니다. 회원 응답의 authorId 문자열을 다음 POST에 복사할 뿐 서버의 회원 재조회나 인증이 없습니다. 회원 ID와 게시글 ID는 별도 저장소의 독립 순번입니다. 마지막 GET은 게시글 Location이며 회원 Location 조회를 구현한 도식이 아닙니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 회원에서 게시글로 이어지는 요청과 보장 경계 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 회원 생성 응답 첫 HTTP 요청의 회원 응답에서 authorId 문자열을 얻습니다. | 요청: 회원 POST · 산출물: authorId | 다음 요청에 복사할 값이 생깁니다. |
| 게시글 POST 클라이언트가 authorId를 게시글 POST에 복사합니다. | 인증·회원 재조회: 없음 · 산출물: 게시글 Location | 독립 저장소가 게시글 ID를 부여합니다. |
| 게시글 조회 Location으로 세 번째 HTTP GET을 보냅니다. | 대상: 게시글 Location · 회원 Location 조회: 아님 | 생성 결과를 별도 요청으로 읽습니다. |
회원에서 게시글로 이어지는 요청과 보장 경계
회원 응답 authorId를 게시글 POST에 전달한 뒤 Location GET으로 조회하는 실제 세 요청 순서이다.
MEMBER → POST → GET · GUARANTEE VS LIMIT
복사한 authorId는 요청을 잇지만 신원과 권한을 증명하지 않는다
두 저장소는 서로 다른 ID를 독립적으로 부여합니다. 아래의 같은 숫자 1은 첫 실행의 우연한 결과이지 공유 순번이나 관계 제약이 아닙니다.
THREE REQUESTS · ONE COPIED VALUE
회원 응답을 게시글 입력으로 복사한 뒤 게시글 위치만 조회한다
-
POST /api/members회원 저장소가 자체 순번으로 member ID를 부여하고
MemberResponse가 같은 값을 문자열authorId로 제공합니다. -
authorId값 복사클라이언트가 응답 본문의 문자열을
CreatePostRequest.authorId에 그대로 넣습니다. 서버가 회원을 다시 조회하는 단계는 없습니다. -
POST /api/posts게시글 저장소가 회원 순번과 무관한 자체 post ID를 부여합니다. 게시글은 전달받은
authorId문자열을 그대로 저장합니다. -
Location: /api/posts/{postId}게시글 생성 응답이 새 게시글의 식별 URI를 제공합니다. 이 경로에는 앞 문서에서 구현한 단건 GET이 있습니다.
-
GET /api/posts/{postId}게시글
Location을 조회해 저장된 게시글과 복사된authorId를 확인합니다.
| 경계 | 현재 보장 | 보장하지 않음 |
|---|---|---|
| ID 발급 | 회원 저장소와 게시글 저장소가 각각 양수 ID를 발급 | 공유 순번, 두 ID의 동일성, 숫자만으로 성립하는 관계 |
| 값 복사 | 회원 응답의 ID 문자열이 게시글의 authorId에 저장됨 |
회원 존재 확인과 데이터베이스 외래 키 |
| 보안 | 현재 없음 | 로그인 인증, 작성 권한, 소유권 검증 |
| 회원 위치 | Location: /api/members/{memberId}가 생성 회원의 식별 URI를 알림 |
이번 문서의 회원 단건 GET 구현과 해당 URI 조회 성공 |
| 게시글 위치 | Location: /api/posts/{postId}와 기존 단건 GET으로 쓰기 뒤 읽기 실행 |
영구 저장, 재시작 뒤 유지, 복합 작업 원자성과 중복 경쟁 방지 |
현재 코드가 실제로 보장하는 것과 아직 증명하지 않는 것
ID 발급
현재 보장: 회원 저장소와 게시글 저장소가 각각 양수 ID를 발급
보장하지 않음: 공유 순번, 두 ID의 동일성, 숫자만으로 성립하는 관계
값 복사
현재 보장: 회원 응답의 ID 문자열이 게시글의 authorId에 저장됨
보장하지 않음: 회원 존재 확인과 데이터베이스 외래 키
보안
현재 보장: 현재 없음
보장하지 않음: 로그인 인증, 작성 권한, 소유권 검증
회원 위치
현재 보장: Location: /api/members/{memberId}가 생성 회원의 식별 URI를 알림
보장하지 않음: 이번 문서의 회원 단건 GET 구현과 해당 URI 조회 성공
게시글 위치
현재 보장: Location: /api/posts/{postId}와 기존 단건 GET으로 쓰기 뒤 읽기 실행
보장하지 않음: 영구 저장, 재시작 뒤 유지, 복합 작업 원자성과 중복 경쟁 방지
INDEPENDENT SEQUENCES
서로 다른 식별자 · 독립 순번
각 저장소의 AtomicLong이 독립적으로 증가합니다.
둘 다 1이어도 “같은 엔티티”나 “검증된 작성자”를 뜻하지 않습니다.
NEXT ENFORCEMENT
관계를 신뢰하려면 별도 경계가 필요하다
회원 조회 또는 외래 키, 인증 주체, 인가·소유권 검사가 각자의 책임을 맡아야 합니다.
authorId 문자열 하나로 이 책임들을 대신할 수 없습니다.
이 장의 실행 증거는 “값을 복사해 세 요청이 이어진다”까지입니다. 회원 존재와 보안 관계는 아직 구현되지 않았고, 게시글 Location만 기존 GET 경로로 조회합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 실선은 클라이언트가 수행하는 세 HTTP 요청과 그 사이의 값 전달입니다. 회원 응답의 authorId 문자열을 다음 POST에 복사할 뿐 서버의 회원 재조회나 인증이 없습니다. 회원 ID와 게시글 ID는 별도 저장소의 독립 순번입니다. 마지막 GET은 게시글 Location이며 회원 Location 조회를 구현한 도식이 아닙니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 회원에서 게시글로 이어지는 요청과 보장 경계 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 회원 생성 응답 첫 HTTP 요청의 회원 응답에서 authorId 문자열을 얻습니다. | 요청: 회원 POST · 산출물: authorId | 다음 요청에 복사할 값이 생깁니다. |
| 게시글 POST 클라이언트가 authorId를 게시글 POST에 복사합니다. | 인증·회원 재조회: 없음 · 산출물: 게시글 Location | 독립 저장소가 게시글 ID를 부여합니다. |
| 게시글 조회 Location으로 세 번째 HTTP GET을 보냅니다. | 대상: 게시글 Location · 회원 Location 조회: 아님 | 생성 결과를 별도 요청으로 읽습니다. |
그림은 세 요청 사이에서 값 하나가 복사되는 모습을 보여 줍니다. 회원 응답의 authorId 문자열을 클라이언트가 게시글 POST에 그대로 넣을 뿐, 서버가 회원을 다시 조회하거나 인증하는 단계는 없습니다. 회원 식별자와 게시글 식별자는 서로 다른 저장소의 독립된 번호이므로, 위 실행에서 둘 다 1이 나온 것은 처음 실행했기 때문에 생긴 우연입니다. 마지막 GET은 게시글의 Location을 조회한 것입니다.
첫 흐름의 보장과 한계
지금 코드로 지켜지는 것은 네 가지입니다. 회원의 원문 비밀번호는 Member와 MemberRepository에 전달되지 않습니다. 회원과 게시글은 서로 다른 저장소에서 따로 식별자를 받습니다. 가입 응답의 회원 식별자를 문자열 authorId로 복사해 게시글 API에 넘길 수 있고, 게시글의 등록과 조회는 앞 글에서 만든 방식을 그대로 씁니다.
아직 지켜지지 않는 것도 분명히 적어 둡니다. 회원 식별자와 게시글 식별자가 같은 숫자여도 같은 번호표나 같은 리소스라는 뜻이 아닙니다. 요청의 authorId가 실제로 가입한 회원인지 확인하지 않고, 데이터베이스의 외래 키로 강제하지도 않습니다. authorId만으로는 인증, 작성 권한, 게시글의 소유권을 확인할 수 없습니다. exists와 save 사이의 동시 가입 경쟁도 막지 않으며, 메모리 저장소이므로 서버를 다시 시작하면 데이터가 모두 사라집니다. 이 한계를 숨기지 않아야 뒤의 장에서 인증, 데이터베이스 제약 조건, 트랜잭션이 왜 필요한지 이해할 수 있습니다.
정리
- 회원 기능은 게시글과 같은 순서로 도메인, 애플리케이션, 인프라, 웹 계층에 추가하고, 게시글 등록에는 기존
PostService를 그대로 씁니다. - 원문 비밀번호는 요청과 명령을 거쳐
PasswordHasher까지만 전달되고,Member와 저장소에는 해시만 남습니다. - 비밀번호는 무작위 솔트와 많은 반복 횟수를 쓰는 PBKDF2 같은 비밀번호 전용 방식으로 해시하며, 반복 횟수는 배포 환경에서 측정해 정합니다.
- 이메일과 이름은 정규화하지만, 비밀번호는 앞뒤 공백까지 그대로 둡니다.
- 가입 응답의
authorId를 복사해 게시글을 등록할 수는 있지만, 이것이 회원의 존재나 인증, 작성 권한을 확인해 주지는 않습니다.
연습 문제
1. 흐름을 직접 확인하기
애플리케이션을 실행해 다음을 차례로 확인하세요. Member와 MemberResponse에 원문 비밀번호와 해시가 각각 없는지, 회원가입 응답의 authorId와 게시글 응답의 authorId가 같은지, 같은 이메일로 두 번 가입하면 무엇이 오는지 확인합니다.
해설 보기
Member의 컴포넌트는 id, email, name, passwordHash뿐이어서 rawPassword 필드가 없고, MemberResponse는 id, authorId, email, name만 담아 passwordHash가 없습니다. 위의 실행에서 두 응답의 authorId는 모두 "1"입니다.
같은 이메일로 다시 가입하면 MemberRegistrationService가 existsByEmail에서 true를 받아 해시를 계산하기 전에 DuplicateEmailException을 던지고, ApiExceptionHandler가 이를 409와 application/problem+json, code가 MEMBER_EMAIL_DUPLICATE인 본문으로 바꿉니다. detail은 email already exists: [email protected]입니다. " [email protected] "처럼 대소문자와 공백만 바꾸어 보내도 명령이 소문자로 정규화하고 저장소도 같은 방식으로 비교하므로 똑같이 409가 나옵니다.
어느 단계에서 기대와 다른 결과가 나오면 한꺼번에 추측하지 말고, HTTP 요청 객체, 명령, 서비스, 저장소의 순서로 값을 따라가 봅니다.
2. 없는 회원의 게시글
한 프로세스의 회원 저장소에는 회원이 두 명 있고, 게시글 저장소에는 식별자가 1, 2, 3인 게시글이 있습니다. 기준 날짜는 2026-07-13입니다. 클라이언트가 authorId를 "999"로 넣고, 유효한 새 제목과 본문, 기준 날짜와 같은 작성일로 게시글 등록을 요청합니다. 회원 999는 없습니다. 지금 코드는 이 요청을 거부할까요? 성공한다면 게시글 식별자와 응답의 authorId는 무엇이고, 이 성공이 작성 권한을 확인해 준 것일까요?
해설 보기
거부하지 않습니다. 지금 PostService에는 PostRepository와 Clock만 주입되어 있어서, 문자열 구조와 날짜, 게시글 중복을 검사할 뿐 회원 저장소는 조회하지 않습니다. 이 요청은 세 검사를 모두 통과하고, 게시글 저장소의 다음 번호가 4이므로 201 응답에는 게시글 식별자 4와 보낸 그대로의 authorId "999"가 담깁니다. 회원 저장소의 두 회원과 번호는 이 저장으로 바뀌지 않습니다.
이 성공은 게시글의 입력 규칙과 저장 규칙을 통과했다는 뜻일 뿐, 회원이 있는지나 인증, 작성 권한을 확인했다는 뜻이 아닙니다. 그것을 보장하려면 뒤의 장에서 인증과 회원 확인을 실제 호출 흐름에 연결해야 합니다.
이것으로 1장을 마칩니다. 이제 회원가입과 게시글 등록 요청이 어떤 객체를 거쳐 저장되는지 설명할 수 있습니다. 다음 장의 역할과 구현 분리부터는 이 객체 그래프를 순수 Java의 관점에서 다시 나누어 보며, 역할과 구현을 나누는 이유와 생성자 주입을 쓰는 이유를 더 깊게 다룹니다.