[Let's Git It] Auth DTO 설계 — Request / Response

dobby·2026년 5월 2일

Let's git it BE

목록 보기
9/20

Let's Git It — WebSocket 기반 실시간 Git 명령어 학습 게임 프로젝트의 백엔드 인증 구현 시리즈입니다.
이번 글은 로컬 로그인 구현의 두 번째 단계, Auth DTO 설계를 다룹니다.


구현 대상 API

OAuth 관련 API를 제외하고 오늘 다루는 Auth API는 총 8개다.

#APIRequest DTOResponse DTO
1이메일 인증 코드 발송SendEmailCodeRequestSendEmailCodeResponse
2이메일 인증 코드 검증VerifyEmailCodeRequest-
3회원가입RegisterRequest-
4로컬 로그인LoginRequestLoginResponse
5토큰 재발급- (쿠키)ReissueResponse
6로그아웃--
7비밀번호 변경 (찾기용, 인증X)ResetPasswordRequest-
8비밀번호 검증 (인증 필요)VerifyPasswordRequest-

비밀번호 변경(로그인 상태)은 Member 도메인 API이므로 Auth DTO에서 제외했다.


DTO 파일 구조 설계 결정

DTO 선언 방식에는 두 가지가 있다.

방식 1 — 파일 분리 (ranking 도메인 방식)

// SingleRankingScrollResponse.java
public record SingleRankingScrollResponse(
    List<RankingEntry> rankings,
    Integer nextCursor,
    boolean hasNext
) {}

방식 2 — 중첩 선언 (auth 도메인 방식)

// AuthRequest.java
public class AuthRequest {
    public record LoginRequest(...) {}
    public record RegisterRequest(...) {}
}

사용할 때 차이는 다음과 같다.

// 방식 1 — 클래스명 직접 사용
SingleRankingScrollResponse response = new SingleRankingScrollResponse(...);

// 방식 2 — 외부 클래스명으로 네임스페이스 역할
AuthRequest.LoginRequest request = new AuthRequest.LoginRequest(...);
파일 분리중첩 선언
파일 수API 수만큼 생성request/response 각 1파일
가독성파일 열면 바로 보임한 파일에서 전체 구조 파악 가능
충돌 방지클래스명 겹칠 수 있음AuthRequest.Login, MemberRequest.Login으로 명확히 구분
유지보수파일 많아질수록 탐색 불편관련 DTO 한 곳에서 수정 가능

Auth처럼 하나의 도메인에 DTO가 많은 경우 → 중첩 방식이 유리하다.
ranking처럼 DTO 하나하나가 여러 곳에서 재사용되는 경우 → 파일 분리가 유리하다.


AuthRequest.java

package com.gitcat.letsgitit.domain.auth.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

public class AuthRequest {

    // ===================== 이메일 인증 코드 발송 =====================
    public record SendEmailCodeRequest(

        @NotBlank(message = "이메일은 필수입니다.")
        @Email(message = "이메일 형식이 올바르지 않습니다.")
        String email
    ) {}

    // ===================== 이메일 인증 코드 검증 =====================
    public record VerifyEmailCodeRequest(

        @NotBlank(message = "이메일은 필수입니다.")
        @Email(message = "이메일 형식이 올바르지 않습니다.")
        String email,

        @NotBlank(message = "인증 코드는 필수입니다.")
        String code
    ) {}

    // ===================== 회원가입 =====================
    public record RegisterRequest(

        @NotBlank(message = "이메일은 필수입니다.")
        @Email(message = "이메일 형식이 올바르지 않습니다.")
        String email,

        @NotBlank(message = "비밀번호는 필수입니다.")
        @Pattern(
            regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*])[A-Za-z\\d!@#$%^&*]{8,20}$",
            message = "비밀번호는 영문, 숫자, 특수문자를 포함한 8~20자여야 합니다."
        )
        String password
    ) {}

    // ===================== 로컬 로그인 =====================
    public record LoginRequest(

        @NotBlank(message = "이메일은 필수입니다.")
        @Email(message = "이메일 형식이 올바르지 않습니다.")
        String email,

        @NotBlank(message = "비밀번호는 필수입니다.")
        String password  // 로그인 시 형식 검증 없음 — 틀리면 INVALID_CREDENTIALS로 통일
    ) {}

    // ===================== 비밀번호 변경 (비밀번호 찾기용, 인증 불필요) =====================
    public record ResetPasswordRequest(

        @NotBlank(message = "이메일은 필수입니다.")
        @Email(message = "이메일 형식이 올바르지 않습니다.")
        String email,

        @NotBlank(message = "새 비밀번호는 필수입니다.")
        @Pattern(
            regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*])[A-Za-z\\d!@#$%^&*]{8,20}$",
            message = "비밀번호는 영문, 숫자, 특수문자를 포함한 8~20자여야 합니다."
        )
        String newPassword
    ) {}

    // ===================== 비밀번호 검증 (인증 필요) =====================
    public record VerifyPasswordRequest(

        @NotBlank(message = "현재 비밀번호는 필수입니다.")
        String password
    ) {}
}

AuthResponse.java

package com.gitcat.letsgitit.domain.auth.dto.response;

import java.time.LocalDateTime;

import com.gitcat.letsgitit.domain.member.entity.Member;
import com.gitcat.letsgitit.global.enums.OnboardingStatus;

public class AuthResponse {

    // ===================== 이메일 인증 코드 발송 =====================
    public record SendEmailCodeResponse(
        LocalDateTime expiredAt  // 인증 코드 만료 시각
    ) {}

    // ===================== 로그인 응답 =====================
    // refreshToken은 HttpOnly Cookie로만 내려가므로 body에 포함하지 않음
    public record LoginResponse(
        String accessToken,
        boolean isFirstLogin,        // nickname이 null이면 최초 로그인으로 판단
        String nickname,
        OnboardingStatus onboardingStatus,
        String characterHair,
        String characterHairColor,
        String characterBody,
        String characterEye,
        String characterOutfit,
        String characterOutfitColor
    ) {
        // Member 엔티티 → LoginResponse 변환 팩토리 메서드
        // Controller에서 LoginResponse.from(member, accessToken) 한 줄로 처리 가능
        public static LoginResponse from(Member member, String accessToken) {
            return new LoginResponse(
                accessToken,
                member.getNickname() == null,
                member.getNickname(),
                member.getOnboardingStatus(),
                member.getCharacterHair(),
                member.getCharacterHairColor(),
                member.getCharacterBody(),
                member.getCharacterEye(),
                member.getCharacterOutfit(),
                member.getCharacterOutfitColor()
            );
        }
    }

    // ===================== 토큰 재발급 =====================
    public record ReissueResponse(
        String accessToken
    ) {}
}

주요 설계 포인트

비밀번호 정규식

^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,20}$

영문·숫자·특수문자 포함 8~20자. 명세에 형식 규칙이 별도로 없어 일반적인 기준으로 정의했다. 팀 협의 후 조정 가능.

로그인 Request에 비밀번호 형식 검증 없음

회원가입과 달리 로그인 Request의 password에는 @Pattern을 달지 않았다. 형식이 맞지 않아도 INVALID_CREDENTIALS 하나로 통일해서 내려주는 게 보안상 맞는 방향이다. 형식 오류인지 비밀번호가 틀린 건지를 구분해서 알려주면 공격자에게 힌트가 된다.

refreshToken은 Response body에 없음

LoginResponserefreshToken 필드가 없는 게 의도된 설계다. Refresh Token은 HttpOnly Cookie로만 내려가기 때문에 JS에서 직접 접근이 불가능하고, body에 노출되지 않는다. XSS 공격으로부터 Refresh Token을 보호하기 위한 방식이다.

LoginResponse 팩토리 메서드

Controller에서 Member 엔티티를 직접 매핑하는 코드를 없애기 위해 from() 팩토리 메서드를 Response 내부에 선언했다.

// Controller에서 이렇게 한 줄로 처리 가능
return ApiResponse.ok("로그인 성공", LoginResponse.from(member, accessToken));

다음 단계

  • 3단계: 이메일 인증 발송 / 검증 (SMTP + Redis TTL)
  • 4단계: 회원가입
  • 5단계: 로컬 로그인 (AuthenticationManager + JWT 발급)
profile
느리게 한걸음

0개의 댓글