Let's Git It — WebSocket 기반 실시간 Git 명령어 학습 게임 프로젝트의 백엔드 인증 구현 시리즈입니다.
이번 글은 로컬 로그인 구현의 두 번째 단계, Auth DTO 설계를 다룹니다.
OAuth 관련 API를 제외하고 오늘 다루는 Auth API는 총 8개다.
| # | API | Request DTO | Response DTO |
|---|---|---|---|
| 1 | 이메일 인증 코드 발송 | SendEmailCodeRequest | SendEmailCodeResponse |
| 2 | 이메일 인증 코드 검증 | VerifyEmailCodeRequest | - |
| 3 | 회원가입 | RegisterRequest | - |
| 4 | 로컬 로그인 | LoginRequest | LoginResponse |
| 5 | 토큰 재발급 | - (쿠키) | ReissueResponse |
| 6 | 로그아웃 | - | - |
| 7 | 비밀번호 변경 (찾기용, 인증X) | ResetPasswordRequest | - |
| 8 | 비밀번호 검증 (인증 필요) | VerifyPasswordRequest | - |
비밀번호 변경(로그인 상태)은 Member 도메인 API이므로 Auth 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 하나하나가 여러 곳에서 재사용되는 경우 → 파일 분리가 유리하다.
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
) {}
}
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에 없음
LoginResponse에 refreshToken 필드가 없는 게 의도된 설계다. Refresh Token은 HttpOnly Cookie로만 내려가기 때문에 JS에서 직접 접근이 불가능하고, body에 노출되지 않는다. XSS 공격으로부터 Refresh Token을 보호하기 위한 방식이다.
LoginResponse 팩토리 메서드
Controller에서 Member 엔티티를 직접 매핑하는 코드를 없애기 위해 from() 팩토리 메서드를 Response 내부에 선언했다.
// Controller에서 이렇게 한 줄로 처리 가능
return ApiResponse.ok("로그인 성공", LoginResponse.from(member, accessToken));