18강은 “로그인 기능”을 만들기 전에 회원가입(join) 기능부터 먼저 구현한 파트다.
이유는 간단하다. 로그인은 결국 “회원이 이미 존재한다”는 전제가 필요하고, 그 전제를 만들려면 회원가입이 먼저여야 한다.
이번 글은 강의 진행 흐름대로
@RequestBody가 정확히 무슨 역할인지 / 빠지면 어떤 일이 생기는지까지 같이 정리한다.
요청:
POST /api/v1/members {
"username": "usernew",
"password": "1234",
"nickname": "무명"
}
응답(RsData 형태):
"201-1""무명님 환영합니다. 회원가입이 완료되었습니다."테스트에서 핵심은 “진짜로 회원이 DB에 생겼는지” + “응답 JSON이 스펙대로인지”를 같이 검증하는 것이다.
ResultActions resultActions = mvc
.perform(
post("/api/v1/members/join")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"username": "usernew",
"password": "1234",
"nickname": "무명"
}
""".stripIndent())
)
.andDo(print());
Member member = memberService.findByUsername("usernew").get();
MockMvc로 실제 HTTP 요청처럼 날린다.memberService.findByUsername("usernew")로 DB에 저장됐는지 확인한다.jsonPath로 응답 값들을 검증한다.참고: 네 코드에서
post("/api/v1/members/join")→post("/api/v1/members")로 바뀌는 커밋이 있었는데,
이건 “회원가입 endpoint를 /join으로 둘지 /members로 둘지” 라우팅을 맞추는 과정이다.
REST 스타일에서는 보통 “컬렉션에 POST = 생성”이라/api/v1/members가 자연스럽다.
@RestController
@RequestMapping("/api/v1/members")
@RequiredArgsConstructor
@Tag(name = "ApiV1MemberController", description = "API 회원 컨트롤러")
public class ApiV1MemberController {
private final MemberService memberService;
}
@RequestMapping("/api/v1/members") 덕분에 이 컨트롤러 안의 모든 API는 /api/v1/members로 시작한다.record MemberJoinReqBody(
@NotBlank @Size(min = 2, max = 30) String username,
@NotBlank @Size(min = 2, max = 30) String password,
@NotBlank @Size(min = 2, max = 30) String nickname
) {}
@NotBlank, @Size로 검증 규칙을 붙였다.@Valid로 “검증 실행”을 시킨다.@PostMapping
public RsData<MemberDto> join(@Valid @RequestBody MemberJoinReqBody reqBody) {
memberService.findByUsername(reqBody.username)
.ifPresent(_member -> {
throw new ServiceException("409-1", "이미 존재하는 아이디입니다.");
});
Member member = memberService.join(
reqBody.username(),
reqBody.password(),
reqBody.nickname()
);
return new RsData<>(
"201-1",
"%s님 환영합니다. 회원가입이 완료되었습니다.".formatted(member.getName()),
new MemberDto(member)
);
}
핵심 포인트:
409-1로 예외join()으로 저장RsData<MemberDto>public record MemberDto(
int id,
LocalDateTime createDate,
LocalDateTime modifyDate,
String name
) {
public MemberDto(Member member) {
this(
member.getId(),
member.getCreateDate(),
member.getModifyDate(),
member.getName()
);
}
}
여기부터가 너가 가장 궁금해한 부분.
클라이언트가 보낸 요청 Body(JSON)를 읽어서
1) JSON을 파싱하고
2) DTO(record/class)의 필드에 매핑하고
3) 그 결과 객체를 파라미터로 넣어준다.
즉 이거다:
@Valid @RequestBody MemberJoinReqBody reqBody
“Body에 있는 JSON을
MemberJoinReqBody로 변환해서 reqBody에 넣어줘!”
Spring 안에서는 보통 HttpMessageConverter(Jackson) 가 이 일을 한다고 보면 된다.
@RequestBody가 없으면 Spring MVC는 이렇게 해석하려고 한다.
근데 우리가 보낸 건 “JSON 바디”잖아?
그래서 결과가 보통 이렇게 된다.
@Valid가 걸려있고@NotBlank에서 터진다 → 400 Bad Request프로젝트 설정/파라미터 형태에 따라 다르지만,
결국 “JSON 바디를 읽어서 DTO로 만들지 못했다”로 실패한다.
JSON Body → DTO로 받는다
✅ @RequestBody
DTO 검증한다
✅ @Valid
그래서 실무에서도 보통 이렇게 쓴다:
public RsData<MemberDto> join(@Valid @RequestBody MemberJoinReqBody reqBody)
@RequestBody는 “요청 바디 전체”를 의미한다.
반대로 이런 건 필드에 붙는다:
@NotBlank, @Size → 필드 검증 규칙@RequestBody 없으면 거의 무조건 사고 난다.테스트 코드에서:
.andExpect(status().isCreated())
이걸 기대하는데, 컨트롤러는 현재 그냥 return new RsData<>(...)만 하고 있다.
👉 컨트롤러가 201을 내려주려면 보통 둘 중 하나를 한다.
ResponseEntity.status(CREATED).body(...) 사용@ResponseStatus(HttpStatus.CREATED) 붙이기