회원가입·게시판 첫 흐름
기존 게시글 API에 회원 도메인·저장소·서비스·컨트롤러를 같은 계층 구조로 추가하고 가입 응답의 회원 식별자로 게시글을 등록·조회합니다.
앞 문서까지 게시글의 도메인 모델, 저장소, 서비스, Spring 구성, 웹 API를 차례로 만들었습니다.
이번 문서에서는 기존 코드를 버리거나 같은 이름의 클래스를 다시 만들지 않습니다.
회원 기능만 domain → application → infrastructure → web 순서로 추가하고, 앞에서 만든 PostService.register(CreatePostCommand)와 /api/posts를 그대로 사용합니다.
처음 배우는 단계에서는 다음 네 역할을 구분하면 됩니다.
- 웹 계층: HTTP 요청을 읽고 응답을 만든다.
- 애플리케이션 계층: 회원가입이나 게시글 등록의 호출 순서를 지휘한다.
- 도메인 계층: 회원과 게시글이 지켜야 할 규칙과 저장 계약을 표현한다.
- 인프라 계층: 메모리 맵이나 비밀번호 해시처럼 구체적인 방법을 구현한다.
완성할 요청 순서
첫 번째 수직 흐름은 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").toLowerCase(Locale.ROOT);
name = requireText(name, "name");
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) {
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;
import java.util.Optional;
public interface MemberRepository {
boolean existsByEmail(String email);
Member save(Member member);
Optional<Member> findByEmail(String email);
}메모리 구현은 게시글 저장소와 마찬가지로 인프라 패키지에 둡니다.
package board.infrastructure;
import board.domain.Member;
import board.domain.MemberRepository;
import java.util.Locale;
import java.util.Optional;
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) {
var saved = member.identifiedBy(sequence.incrementAndGet());
store.put(saved.id(), saved);
return saved;
}
@Override
public Optional<Member> findByEmail(String email) {
var normalized = email.strip().toLowerCase(Locale.ROOT);
return store.values().stream()
.filter(member -> member.email().equals(normalized))
.findFirst();
}
}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").toLowerCase(Locale.ROOT);
name = requireText(name, "name");
rawPassword = requireText(rawPassword, "rawPassword");
if (rawPassword.length() < 8 || rawPassword.length() > 128) {
throw new IllegalArgumentException(
"rawPassword must be between 8 and 128 characters");
}
}
private static String requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required");
}
return value.strip();
}
}비밀번호 해시의 역할은 애플리케이션 계층에 인터페이스로 둡니다.
서비스는 PBKDF2의 반복 횟수나 결과 문자열 형식을 알지 않습니다.
package board.application;
public interface PasswordHasher {
String hash(String rawPassword);
boolean matches(String rawPassword, String passwordHash);
}중복 이메일은 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;
public final class MemberRegistrationService {
private final MemberRepository members;
private final PasswordHasher passwordHasher;
public MemberRegistrationService(
MemberRepository members,
PasswordHasher passwordHasher
) {
this.members = members;
this.passwordHasher = passwordHasher;
}
public Member register(RegisterMemberCommand command) {
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.MessageDigest;
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 = 210_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));
}
@Override
public boolean matches(String rawPassword, String passwordHash) {
requireRawPassword(rawPassword);
if (passwordHash == null) {
return false;
}
try {
var parts = passwordHash.split("\\$");
if (parts.length != 5 || !"pbkdf2-sha256".equals(parts[1])) {
return false;
}
var iterations = Integer.parseInt(parts[2]);
if (iterations != ITERATIONS) {
return false;
}
var decoder = Base64.getUrlDecoder();
var salt = decoder.decode(parts[3]);
var expected = decoder.decode(parts[4]);
var actual = derive(rawPassword, salt, iterations);
return MessageDigest.isEqual(expected, actual);
} catch (IllegalArgumentException exception) {
return false;
}
}
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, 계산 결과가 들어가지만 원문 비밀번호는 들어가지 않습니다.
matches는 나중에 로그인에서 사용하고 이번 회원가입 요청에서는 hash만 호출합니다.
회원 웹 API
웹 DTO는 Bean Validation으로 HTTP 입력의 모양을 먼저 검사합니다.
도메인과 명령의 검증도 남겨 두어 HTTP 밖의 진입점이 규칙을 우회하지 못하게 합니다.
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
) {
RegisterMemberCommand toCommand() {
return new RegisterMemberCommand(email, name, rawPassword);
}
}응답은 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을 반환합니다.
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 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({
IllegalArgumentException.class,
MethodArgumentNotValidException.class
})
ProblemDetail badRequest(Exception 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()));
problem.setTitle(code);
problem.setProperty("code", code);
return problem;
}
}기존 구성에 회원 빈 추가
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에 전달합니다.
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을 조회합니다.
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와MemberRepository에 전달되지 않는다. - 회원과 게시글은 서로 다른 저장소에서 식별자를 부여받는다.
- 가입 응답은 게시글 API에 사용할 작성자 식별자를 제공한다.
- 게시글 등록과 조회는 앞 문서에서 만든 계약을 그대로 사용한다.
아직 보장하지 않는 것도 분명히 남겨 둡니다.
- 요청의
authorId가 실제 가입 회원인지 확인하지 않는다. - 로그인 세션과 게시글 소유권을 검사하지 않는다.
exists와save사이의 동시 가입 경쟁을 막지 않는다.- 메모리 저장소이므로 서버를 다시 시작하면 데이터가 사라진다.
이 한계를 숨기지 않아야 뒤 장의 인증, 데이터베이스 제약 조건, 트랜잭션이 왜 필요한지 이해할 수 있습니다.
직접 확인하기
다음 항목을 파일과 실행 결과에서 차례로 확인합니다.
Member에는rawPassword필드가 없는가?MemberResponse에는passwordHash가 없는가?- 회원가입 응답의
authorId와 게시글 응답의authorId가 같은가? - 게시글 등록이 기존
PostService.register를 호출하는가? - 같은 이메일로 두 번 가입하면 409 ProblemDetail이 오는가?
실패하면 한꺼번에 추측하지 말고 HTTP DTO → Command → Service → Repository 순서로 값을 추적합니다.
1장이 끝난 뒤에는 회원가입과 게시글 등록 요청이 어떤 객체를 거쳐 저장되는지 설명할 수 있어야 합니다.
다음 장에서는 이 객체 그래프를 순수 Java 관점에서 다시 분해해 역할과 구현, 생성자 주입의 이유를 더 깊게 다룹니다.