
Spring을 공부하다 보면 한 번쯤 듣게 되는 개념, DTO (Data Transfer Object).
처음에는 "뭔가 계층 간 데이터를 전달하는 용도인가보다~" 하고 지나쳤지만, 실제로 회원가입 기능을 만들면서 DTO가 왜 필요한지, 어떻게 써야 하는지 체감하게 되었습니다.
이 글에서는 직접 회원가입 기능을 구현하면서
- DTO를 왜 따로 만드는것이 좋은지
- 유효성 검증 과정에서 어떤 문제가 발생했는지
- 그리고 어떻게 해결했는지
제가 겪은 과정을 기반으로 정리해보려고 합니다.
DTO (Data Transfer Object)는 계층 간 데이터 전달을 위해 사용하는 순수 데이터 전송 객체입니다. 웹 애플리케이션에서는 사용자의 입력값을 안전하게 받기 위한 전용 폼 객체로 자주 사용됩니다.
처음엔 이렇게 생각했습니다.
"User 엔티티를 그냥 써도 되지 않나? 굳이 SignupFormDto 같은 걸 왜 만들어야 하지?"
처음엔 이렇게 User 엔티티를 그냥 컨트롤러에 받아서 처리했습니다.
@PostMapping("/signup")
public String signup(User user) {
userRepository.save(user);
return "redirect:/login";
}
간단해 보이지만 몇 가지 문제가 있었습니다.
❌ User 엔티티를 바로 받으면 생기는 문제
| 문제 | 설명 |
|---|---|
| 비즈니스 목적과 불일치 | 회원가입 시 필요한 필드만 받으면 되는데, 불필요한 필드까지 열려있음 |
| 보안상 위험 | User 내부에 있는 민감한 필드까지 클라이언트에서 보낼 수 있음 |
| 유지보수 어려움 | 나중에 User 엔티티가 바뀌면 폼 로직이 다 깨짐 |
회원가입에 필요한 필드만 딱 담은 전용 객체 SignupFormDto를 만들었습니다.
@Data
public class SignupFormDto {
@NotBlank(message = "아이디는 필수입니다.")
private String userId;
@NotBlank(message = "비밀번호는 필수입니다.")
private String password;
@NotBlank(message = "이름은 필수입니다.")
private String username;
@Email(message = "이메일 형식이 올바르지 않습니다.")
@NotBlank(message = "이메일 입력은 필수입니다.")
private String email;
@NotBlank(message = "소속 선택은 필수입니다.")
private String affiliation; //학생대, 교육대
@NotBlank(message = "중대 선택은 필수입니다.")
private String unit; //1~3중대
}
오직 회원가입 시 사용자가 입력한 정보만 받도록 설계했습니다.
@NotBlank, @Email을 다 붙였는데 값 안넣고 제출해도 그대로 통과되는 문제가 발생했습니다.
그 이유는 바로 @Valid와 BindingResult의 위치와 사용 여부 때문이었습니다.
컨트롤러는 다음처럼 구성되어야 합니다.
@PostMapping("/signup")
public String processSignup(@Valid @ModelAttribute("signupForm") SignupFormDto form, BindingResult bindingResult, Model model) {
if (bindingResult.hasErrors()) {
return "signup"; // 유효성 에러 처리
}
try {
signupService.registerUser(form);
return "redirect:/login?signupSuccess"; //회원가입 성공 시 로그인 페이지로
} catch (IllegalArgumentException e) {
model.addAttribute("error", e.getMessage());
return "signup";
}
}
| 요소 | 역할 |
|---|---|
| @Valid | DTO에 붙은 @NotBlank 등의 어노테이션을 실제로 작동시킴 |
| BindingResult | 유효성 검사 결과를 잡아주는 바구니 역할 |
❗️ 순서 주의: 반드시 @Valid → BindingResult 순서로 선언
처음엔 try-catch문이 있는데 왜 유효성 에러처리가 안되지? 라고 생각했는데 @Valid 유효성 검사는 컨트롤러 메서드 호출 전에 실행되기 때문이었습니다.
BindingResult가 없으면?
: 검증 실패 시 Spring이 MethodArgumentNotValidException을 던지고 예외로 끝냅니다 → try-catch 안으로 못 들어옵니다.
<form th:action="@{/signup}" th:object="${signupForm}" method="post">
<input type="text" th:field="*{userId}" />
<small th:if="${#fields.hasErrors('userId')}" th:errors="*{userId}"></small>
<input type="text" th:field="*{email}" />
<small th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></small>
<select th:field="*{affiliation}">
<option value="">📌 선택하세요</option>
<option value="학생대">학생대</option>
<option value="교육대">교육대</option>
</select>
<small th:if="${#fields.hasErrors('affiliation')}" th:errors="*{affiliation}"></small>
</form>
자 그럼 이렇게 DTO를 쓸 경우 어떤 점이 좋아질까요?
| 장점 | 설명 |
|---|---|
| 유효성 검증 최적화 | @NotBlank, @Email, @Size 등을 자유롭게 붙여 검증 가능 |
| 보안성 강화 | 민감한 필드를 사용자에게 노출하지 않음 |
| 필요한 필드만 사용 | 회원가입, 로그인, 비밀번호 변경 등 용도에 맞는 DTO 설계 가능 |
| 구조적 유연성 | DTO는 컨트롤러~뷰 전용 객체라, Entity가 바뀌어도 독립적으로 유지 가능 |
DTO는 단순한 데이터 전달용 객체가 아닙니다. 사용자 입력을 안전하게 받고, 검증하고, 분리해서 처리하기 위한 아키텍처적 보호막이자, 유지보수성과 보안성을 높이는 필수 도구입니다.