회원가입·게시판 첫 흐름
기존 게시글 API에 회원 도메인·저장소·서비스·컨트롤러를 같은 계층 구조로 추가하고 가입 응답의 회원 식별자로 게시글을 등록·조회합니다.
앞 문서까지 게시글의 도메인 모델, 저장소, 서비스, Spring 구성, 웹 API를 차례로 만들었습니다.
이번 문서에서는 기존 코드를 버리거나 같은 이름의 클래스를 다시 만들지 않습니다.
회원 기능만 domain → application → infrastructure → web 순서로 추가하고, 앞에서 만든 PostService.register(CreatePostCommand)와 /api/posts를 그대로 사용합니다.
처음 배우는 단계에서는 다음 네 역할을 구분하면 됩니다.
- 웹 계층: HTTP 요청을 읽고 응답을 만든다.
- 애플리케이션 계층: 회원가입이나 게시글 등록의 호출 순서를 지휘한다.
- 도메인 계층: 회원과 게시글이 지켜야 할 규칙과 저장 계약을 표현한다.
- 인프라 계층: 메모리 맵이나 비밀번호 해시처럼 구체적인 방법을 구현한다.
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 필드와 생성 인자가 없으므로 원문을 회원 상태에 넣는 경로를 닫습니다.
POLICY BOUNDARY
현재 정책값
PBKDF2-HMAC-SHA256 · 600_000회 · salt 16바이트 · 결과 32바이트
반복 횟수는 배포 환경에서 측정하고 갱신할 정책이며, 숫자 하나가 모든 운영 환경의 보안을 보장하지 않습니다.
| 책임 | 포트 | 어댑터 | 경계를 지나는 값 | 넘지 않는 값 |
|---|---|---|---|---|
| 해시 | PasswordHasher · 애플리케이션 포트 |
Pbkdf2PasswordHasher · 인프라 어댑터 |
rawPassword → passwordHash |
저장 기술과 회원 식별자 |
| 회원 저장 | MemberRepository · 도메인 포트 |
MemoryMemberRepository · 인프라 어댑터 |
Member(passwordHash) → 저장된 Member(id) |
rawPassword |
핵심 경계는 “해시를 한다”가 아니라 원문이 어디서 끝나는지입니다. 서비스는 PasswordHasher와 MemberRepository 계약에만 의존하고 저장 계층에는 passwordHash만 전달합니다.
완성할 요청 순서
첫 번째 수직 흐름은 JSON API 세 요청으로 확인합니다.
| 순서 | 요청 | 결과 |
|---|---|---|
| 1 | POST /api/members | 회원을 저장하고 authorId를 반환한다. |
| 2 | POST /api/posts | authorId로 게시글을 저장한다. |
| 3 | GET /api/posts/{postId} | 저장된 게시글을 식별자로 다시 조회한다. |
1장에서 아직 로그인 세션을 만들지는 않습니다.
가입 응답으로 받은 authorId를 다음 요청에 직접 넣어 값 전달만 확인합니다.
브라우저가 보낸 작성자 식별자를 신뢰하지 않고 로그인 세션에서 꺼내는 과정은 인증을 배우는 장에서 추가합니다.
회원 도메인과 저장 계약
가입 요청에는 원문 비밀번호가 있지만 저장되는 회원에는 비밀번호 해시만 있어야 합니다.
Member 생성 경로가 원문 비밀번호를 받을 수 없게 타입부터 분리합니다.
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인 회원은 아직 저장되지 않은 상태이고, 저장소가 양수 식별자를 부여하면 저장된 상태가 됩니다.
저장 역할은 게시글의 PostRepository와 같은 도메인 패키지에 둡니다.
package board.domain;
public interface MemberRepository {
boolean existsByEmail(String email);
Member save(Member 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는 회원을 저장할 때만 식별자를 부여합니다.
컨트롤러나 서비스가 순번 생성 규칙을 알 필요는 없습니다.
회원가입 사용 사례
웹 요청 객체를 서비스에 직접 넘기지 않고 애플리케이션 명령으로 바꿉니다.
이 구조는 앞 문서의 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은 경계마다 같은 strip() 기준으로 정규화하지만 rawPassword는 앞뒤 공백도 비밀번호의 일부이므로 변경하지 않습니다.
명령은 HTTP 밖에서 직접 만들어져도 정규화된 이메일·이름 길이와 원문 비밀번호 길이를 다시 검사합니다. 이메일 형식은 웹 DTO의 @Email이 담당합니다.
비밀번호 해시의 역할은 애플리케이션 계층에 인터페이스로 둡니다.
서비스는 PBKDF2의 반복 횟수나 결과 문자열 형식을 알지 않습니다.
package board.application;
public interface PasswordHasher {
String hash(String rawPassword);
}중복 이메일은 HTTP와 무관한 사용 사례 실패입니다.
package board.application;
public final class DuplicateEmailException extends RuntimeException {
public DuplicateEmailException(String email) {
super("email already exists: " + email);
}
}서비스의 호출 순서는 중복 확인 → 해시 → 회원 생성 → 저장입니다.
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 해시 구현
비밀번호는 빠른 일반 해시가 아니라 반복 계산과 무작위 salt를 사용하는 비밀번호 전용 방식으로 저장합니다.
다음 구현은 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");
}
}
}인코딩된 문자열에는 알고리즘 이름, 반복 횟수, salt, 계산 결과가 들어가지만 원문 비밀번호는 들어가지 않습니다.
600_000은 이 예제의 현재 정책값입니다. 배포 환경에서는 목표 응답 시간과 장비를 기준으로 측정하고 정책을 갱신해야 하며, 이 숫자 하나가 모든 환경의 충분한 보안을 보장하지는 않습니다.
이번 장은 회원가입에 필요한 해시 생성만 구현합니다. 로그인 검증과 저장 문자열 parser는 인증 사용 사례가 생길 때 추가하며, 그 검증기는 알고리즘·작업 계수·salt·결과 길이의 상하한을 확인하고 계산 결과를 constant-time 비교해야 합니다.
회원 웹 API
웹 DTO는 Bean Validation으로 HTTP 입력의 모양을 먼저 검사합니다.
도메인과 명령에는 정규화와 구조·길이 검증을 남겨 두어 HTTP 밖의 진입점도 같은 값 경계를 따르게 합니다. 이메일 형식 검사는 웹 DTO의 책임입니다.
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();
}
}compact constructor는 null을 그대로 두고 이메일과 이름만 먼저 strip()합니다. Bean Validation은 정규화된 두 문자열을 검사하고, rawPassword는 JSON에서 역직렬화된 문자열 그대로 길이와 공백 여부를 검사합니다. @Size와 String.length()가 같은 UTF-16 code unit 기준을 쓰므로 비밀번호의 8–128 상한도 DTO와 명령에서 일치합니다.
따라서 " 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은 생성된 회원 리소스의 식별 URI를 알리지만, 이번 문서에는 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;
}
}Bean Validation 실패에는 프레임워크의 긴 바인딩 메시지 대신 고정된 detail을 사용합니다. type은 기본 locale과 무관한 kebab-case URI이고, title은 HTTP reason phrase이며, 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가 구체 구현을 선택합니다.
가입 결과로 게시글 등록
애플리케이션을 실행하고 회원을 등록합니다.
curl -i -X POST http://localhost:8080/api/members \
-H "Content-Type: application/json" \
-d '{"email":"member@example.com","name":"안동민","rawPassword":"secret-1234"}'HTTP/1.1 201
Location: /api/members/1
Content-Type: application/json
{"id":1,"authorId":"1","email":"member@example.com","name":"안동민"}응답의 authorId 값인 "1"을 앞 문서에서 만든 게시글 API에 그대로 복사합니다.
이 문자열은 이 실행에서 회원 ID를 복사했다는 사실만 나타냅니다. 게시글 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가 같은 요청을 이어받습니다.
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으로 쓰기 뒤 읽기 실행 |
영구 저장, 재시작 뒤 유지, 복합 작업 원자성과 중복 경쟁 방지 |
INDEPENDENT SEQUENCES
서로 다른 식별자 · 독립 순번
각 저장소의 AtomicLong이 독립적으로 증가합니다.
둘 다 1이어도 “같은 엔티티”나 “검증된 작성자”를 뜻하지 않습니다.
NEXT ENFORCEMENT
관계를 신뢰하려면 별도 경계가 필요하다
회원 조회 또는 외래 키, 인증 주체, 인가·소유권 검사가 각자의 책임을 맡아야 합니다.
authorId 문자열 하나로 이 책임들을 대신할 수 없습니다.
이 장의 실행 증거는 “값을 복사해 세 요청이 이어진다”까지입니다. 회원 존재와 보안 관계는 아직 구현되지 않았고, 게시글 Location만 기존 GET 경로로 조회합니다.
첫 흐름의 보장과 한계
현재 코드가 보장하는 것은 다음과 같습니다.
- 회원 비밀번호 원문은
Member와MemberRepository에 전달되지 않는다. - 회원과 게시글은 서로 다른 저장소와 순번으로 식별자를 독립적으로 부여받는다.
- 가입 응답의 회원 ID를 문자열
authorId로 복사해 게시글 API에 전달할 수 있다. - 게시글 등록과 조회는 앞 문서에서 만든 계약을 그대로 사용한다.
아직 보장하지 않는 것도 분명히 남겨 둡니다.
- 회원 ID와 게시글 ID가 같은 숫자여도 같은 순번이나 같은 리소스라는 뜻이 아니다.
- 요청의
authorId가 실제 가입 회원인지 확인하거나 외래 키로 강제하지 않는다. authorId만으로 인증, 작성 권한, 게시글 소유권을 증명하지 않는다.exists와save사이의 동시 가입 경쟁을 막지 않는다.- 메모리 저장소이므로 서버를 다시 시작하면 데이터가 사라진다.
이 한계를 숨기지 않아야 뒤 장의 인증, 데이터베이스 제약 조건, 트랜잭션이 왜 필요한지 이해할 수 있습니다.
직접 확인하기
다음 항목을 파일과 실행 결과에서 차례로 확인합니다.
Member에는rawPassword필드가 없는가?MemberResponse에는passwordHash가 없는가?- 회원가입 응답의
authorId와 게시글 응답의authorId가 같은가? - 게시글 등록이 기존
PostService.register를 호출하는가? - 같은 이메일로 두 번 가입하면 409 ProblemDetail이 오는가?
- 비밀번호 앞뒤에 공백을 넣어도
PasswordHasher가 같은 문자열을 그대로 받는가? - 회원 응답의
Location을 GET으로 조회하는 기능은 아직 없다고 설명할 수 있는가?
실패하면 한꺼번에 추측하지 말고 HTTP DTO → Command → Service → Repository 순서로 값을 추적합니다.
1장이 끝난 뒤에는 회원가입과 게시글 등록 요청이 어떤 객체를 거쳐 저장되는지 설명할 수 있어야 합니다.
다음 장에서는 이 객체 그래프를 순수 Java 관점에서 다시 분해해 역할과 구현, 생성자 주입의 이유를 더 깊게 다룹니다.