[데브코스] Spring Boot 인증·인가(Auth) (18강) - 로그인 전에 회원가입부터: 테스트 → 구현, 그리고 @RequestBody의 역할

zuno·2026년 1월 11일

18강은 “로그인 기능”을 만들기 전에 회원가입(join) 기능부터 먼저 구현한 파트다.
이유는 간단하다. 로그인은 결국 “회원이 이미 존재한다”는 전제가 필요하고, 그 전제를 만들려면 회원가입이 먼저여야 한다.

이번 글은 강의 진행 흐름대로

  • 회원가입 테스트 케이스 추가
  • 회원가입 API 구현
  • ✅ (중요) @RequestBody가 정확히 무슨 역할인지 / 빠지면 어떤 일이 생기는지

까지 같이 정리한다.


1️⃣ 회원가입 API 스펙(내가 구현한 형태)

요청:

  • POST /api/v1/members
  • Body(JSON)
{
  "username": "usernew",
  "password": "1234",
  "nickname": "무명"
}

응답(RsData 형태):

  • resultCode: "201-1"
  • msg: "무명님 환영합니다. 회원가입이 완료되었습니다."
  • data: MemberDto (id/createDate/modifyDate/name)

2️⃣ 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가 자연스럽다.


3️⃣ 2번째 커밋: 회원가입 기능 구현

(1) 컨트롤러 뼈대

@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로 시작한다.

(2) 요청 바디를 받는 record + validation

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
) {}
  • 이 record는 “회원가입 요청에 필요한 필드만” 담는 DTO다.
  • @NotBlank, @Size검증 규칙을 붙였다.
  • 그리고 컨트롤러 메서드에서 @Valid로 “검증 실행”을 시킨다.

(3) 회원가입 로직

@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)
    );
}

핵심 포인트:

  • 중복 username 체크 → 있으면 409-1로 예외
  • 없으면 join()으로 저장
  • 응답은 RsData<MemberDto>

4️⃣ 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()
        );
    }
}
  • 응답에서 필요한 데이터만 내려준다.
  • password 같은 민감정보는 DTO에 넣지 않는다. (최소 공개 원칙)

⭐ @RequestBody의 역할: “JSON 바디를 자바 객체로 변환해주는 스위치”

여기부터가 너가 가장 궁금해한 부분.

✅ @RequestBody가 하는 일

클라이언트가 보낸 요청 Body(JSON)를 읽어서

1) JSON을 파싱하고
2) DTO(record/class)의 필드에 매핑하고
3) 그 결과 객체를 파라미터로 넣어준다.

즉 이거다:

@Valid @RequestBody MemberJoinReqBody reqBody

“Body에 있는 JSON을 MemberJoinReqBody로 변환해서 reqBody에 넣어줘!”

Spring 안에서는 보통 HttpMessageConverter(Jackson) 가 이 일을 한다고 보면 된다.


✅ @RequestBody가 없으면 어떻게 되냐?

1) Spring은 기본적으로 “바디”를 DTO에 넣는다고 생각하지 않는다

@RequestBody가 없으면 Spring MVC는 이렇게 해석하려고 한다.

  • 이 파라미터는 쿼리스트링(@RequestParam) 인가?
  • 아니면 폼 데이터(@ModelAttribute) 인가?

근데 우리가 보낸 건 “JSON 바디”잖아?

그래서 결과가 보통 이렇게 된다.

케이스 A) reqBody가 null/빈 객체처럼 들어오면서 검증 실패

  • @Valid가 걸려있고
  • 내부 필드가 전부 null로 들어오면
  • @NotBlank에서 터진다 → 400 Bad Request

케이스 B) 아예 바인딩 자체가 안 돼서 예외

프로젝트 설정/파라미터 형태에 따라 다르지만,
결국 “JSON 바디를 읽어서 DTO로 만들지 못했다”로 실패한다.


✅ 정리: JSON 바디로 받을 때는 거의 무조건 세트

  • JSON Body → DTO로 받는다
    @RequestBody

  • DTO 검증한다
    @Valid

그래서 실무에서도 보통 이렇게 쓴다:

public RsData<MemberDto> join(@Valid @RequestBody MemberJoinReqBody reqBody)

5️⃣ “근데 왜 RequestBody는 record에만 붙이고, 필드에는 안 붙여?”

@RequestBody는 “요청 바디 전체”를 의미한다.

  • Body 전체(JSON)를 DTO로 변환하는 거라서
  • DTO의 각 필드가 아니라
  • “파라미터(객체)”에 붙는다.

반대로 이런 건 필드에 붙는다:

  • @NotBlank, @Size → 필드 검증 규칙

6️⃣ 이번 강의에서 내가 얻은 결론

  • 로그인 전에 회원가입부터 만드는 건 당연한 순서다.
  • 테스트 먼저 작성하면 API 스펙이 깔끔해지고,
    구현이 “테스트를 통과시키는 작업”이 된다.
  • JSON 바디로 받는 DTO는
    @RequestBody 없으면 거의 무조건 사고 난다.

(보너스) 지금 네 코드에서 하나 더 체크할 점

테스트 코드에서:

.andExpect(status().isCreated())

이걸 기대하는데, 컨트롤러는 현재 그냥 return new RsData<>(...)만 하고 있다.

👉 컨트롤러가 201을 내려주려면 보통 둘 중 하나를 한다.

  • ResponseEntity.status(CREATED).body(...) 사용
  • 또는 @ResponseStatus(HttpStatus.CREATED) 붙이기

0개의 댓글