DTO Pattern (Data Transfer Object 패턴)
"계층 간 데이터 전송을 위한 순수한 데이터 객체를 만들자"
문제 상황
패턴 정의
구조와 구성요소
구현 방법
실전 예제
DTO vs Entity vs VO
장단점
안티패턴
심화 주제
핵심 정리
// 문제 1: Entity를 직접 노출
@ RestController
@ RequestMapping ("/api/users" )
public class UserController {
@ Autowired
private UserRepository userRepository ;
@ GetMapping ("/{id}" )
public User getUser (@ PathVariable Long id ) {
// 😱 Entity를 그대로 반환!
return userRepository .findById (id ).orElseThrow ();
}
// 문제점:
// 1. password 같은 민감 정보 노출
// 2. JPA 연관관계로 인한 무한 순환 참조
// 3. Lazy Loading 예외 발생
// 4. Entity 변경 시 API 스펙 변경
}
// Entity (문제 있음!)
@ Entity
@ Table (name = "users" )
public class User {
@ Id
private Long id ;
private String email ;
private String password ; // 😱 비밀번호 노출!
private String name ;
@ OneToMany (mappedBy = "user" )
private List <Order > orders ; // 😱 무한 순환 참조!
@ ManyToOne (fetch = FetchType .LAZY )
private Company company ; // 😱 LazyInitializationException!
// Getters, Setters
}
// 클라이언트가 받는 JSON (위험!)
{
"id" : 1 ,
"email" : "[email protected] " ,
"password" : "hashed_password" , // 😱 비밀번호 노출!
"name" : "홍길동" ,
"orders" : [
{
"id" : 100 ,
"user" : { // 😱 무한 순환!
"id" : 1 ,
"orders" : [...]
}
}
],
"company" : null // 😱 또는 LazyInitializationException!
}
// 문제 2: 여러 Entity 조합이 필요한 경우
@ GetMapping ("/dashboard" )
public ??? getDashboard () {
User user = userRepository .findById (1L ).orElseThrow ();
List <Order > orders = orderRepository .findByUserId (1L );
Statistics stats = statisticsService .calculate (1L );
// 😱 이걸 어떻게 반환?
// Map? 타입 안전성 없음!
// Entity? 여러 Entity를 하나로 묶을 수 없음!
Map <String , Object > result = new HashMap <>();
result .put ("user" , user );
result .put ("orders" , orders );
result .put ("stats" , stats );
return result ; // 😱 타입 안전성 없음!
}
// 문제 3: 네트워크 오버헤드
public class Product {
private Long id ;
private String name ;
private String description ; // 5000자
private byte [] image ; // 5MB 이미지
private String fullSpecification ; // 10000자
// ... 100개 필드
}
@ GetMapping ("/products" )
public List <Product > getProducts () {
// 😱 목록 조회인데 모든 데이터 전송!
// - 5MB 이미지도 전송
// - 상세 설명도 전송
// - 목록에서는 id, name만 필요한데!
return productRepository .findAll ();
}
// 문제 4: 클라이언트 요구사항과 Entity 불일치
// Entity
public class Order {
private Long id ;
private Long userId ;
private LocalDateTime createdAt ;
private OrderStatus status ;
}
// 클라이언트가 원하는 형식
{
"orderId" : 123 , // ← id가 아닌 orderId
"customerName" : "홍길동" , // ← User 조인 필요
"orderDate" : "2024-01-01" , // ← LocalDateTime을 String으로
"statusText" : "배송중" // ← Enum을 한글로
}
// 😱 Entity로는 표현 불가!
// 문제 5: 입력 검증
@ PostMapping ("/users" )
public User createUser (@ RequestBody User user ) {
// 😱 Entity를 직접 받음!
// 문제점:
// 1. id를 클라이언트가 설정하면?
// 2. createdAt을 조작하면?
// 3. 필수 필드 검증은?
// 4. @Email, @NotNull 같은 검증을?
return userRepository .save (user );
}
// 문제 6: 여러 요청에 대한 다른 응답
// 회원가입 요청
{
"email" : "[email protected] " ,
"password" : "1234" ,
"name" : "홍길동"
}
// 로그인 요청
{
"email" : "[email protected] " ,
"password" : "1234"
}
// 프로필 수정 요청
{
"name" : "김철수" ,
"phone" : "010-1234-5678"
}
// 😱 모두 User Entity 사용?
// - 각 요청마다 필요한 필드가 다름!
// - 검증 규칙도 다름!
// - 하나의 Entity로 불가능!
// 문제 7: 성능 문제
public class OrderWithDetails {
// 주문 정보
private Long orderId ;
// User 테이블 조인
private String userName ;
private String userEmail ;
// Product 테이블 조인
private String productName ;
private BigDecimal productPrice ;
// OrderItem 테이블 조인
private int quantity ;
// 😱 4개 테이블 조인 필요!
// N+1 문제 발생!
}
@ GetMapping ("/orders" )
public List <OrderWithDetails > getOrders () {
// 😱 Entity로는 효율적인 조회 불가!
List <Order > orders = orderRepository .findAll ();
// N+1 문제!
return orders .stream ()
.map (order -> {
User user = userRepository .findById (order .getUserId ()); // +N
Product product = productRepository .findById (order .getProductId ()); // +N
// ...
})
.collect (Collectors .toList ());
}
보안 : Entity를 직접 노출 시 민감 정보 유출
순환 참조 : JPA 연관관계로 무한 순환
성능 : 불필요한 데이터 전송
결합도 : Entity 변경 시 API 영향
표현 불일치 : 클라이언트 요구와 Entity 구조 차이
검증 : 입력 데이터 검증 어려움
N+1 문제 : 비효율적인 쿼리
계층 간 데이터 전송을 위한 순수한 데이터 객체로, Entity와 분리하여 API 요청/응답에 최적화된 구조를 제공하는 패턴
캡슐화 : Entity 내부 구조 숨김
보안 : 민감 정보 제외
성능 : 필요한 데이터만 전송
독립성 : Entity 변경이 API에 영향 안 줌
// Before: Entity 직접 사용
@ RestController
public class UserController {
@ GetMapping ("/users/{id}" )
public User getUser (@ PathVariable Long id ) {
return userRepository .findById (id ).orElseThrow ();
// 😱 password, orders 등 모두 노출!
}
}
// After: DTO 사용
@ RestController
public class UserController {
@ GetMapping ("/users/{id}" )
public UserResponse getUser (@ PathVariable Long id ) {
User user = userRepository .findById (id ).orElseThrow ();
// Entity → DTO 변환
return UserResponse .from (user );
}
}
// Response DTO (필요한 정보만!)
public class UserResponse {
private Long id ;
private String email ;
private String name ;
// password 제외!
// orders 제외!
public static UserResponse from (User user ) {
UserResponse dto = new UserResponse ();
dto .id = user .getId ();
dto .email = user .getEmail ();
dto .name = user .getName ();
return dto ;
}
}
// 클라이언트가 받는 JSON (안전!)
{
"id" : 1 ,
"email" : "[email protected] " ,
"name" : "홍길동"
// password 없음! ✅
// orders 없음! ✅
}
┌─────────────────────────────────────┐
│ Presentation Layer │
│ (Controller) │
└─────────────────────────────────────┘
▲
│ UserResponse (DTO)
│
┌─────────────────────────────────────┐
│ Service Layer │
│ │
│ User entity = repository.find() │
│ UserResponse dto = from(entity) │
│ return dto; │
└─────────────────────────────────────┘
│
│ User (Entity)
▼
┌─────────────────────────────────────┐
│ Persistence Layer │
│ (Repository) │
└─────────────────────────────────────┘
Client Request (JSON)
│
▼
┌──────────────┐
│CreateUserDTO │ (Request DTO)
└──────────────┘
│
│ Controller
▼
┌──────────────┐
│ Service │ → DTO를 Entity로 변환
└──────────────┘
│
▼
┌──────────────┐
│ User Entity │ → DB 저장
└──────────────┘
│
▼
┌──────────────┐
│ Service │ → Entity를 DTO로 변환
└──────────────┘
│
▼
┌──────────────┐
│ UserResponse │ (Response DTO)
└──────────────┘
│
│ Controller
▼
Client Response (JSON)
종류
용도
예시
Request DTO
클라이언트 → 서버
CreateUserRequest
Response DTO
서버 → 클라이언트
UserResponse
Internal DTO
서비스 간 통신
UserSummary
List DTO
목록 조회
UserListResponse
완전한 구현: E-Commerce User API ⭐⭐⭐
/**
* ============================================
* ENTITY (도메인 모델)
* ============================================
* DB 테이블과 매핑, 비즈니스 로직 포함
*/
@ Entity
@ Table (name = "users" )
public class User {
@ Id
@ GeneratedValue (strategy = GenerationType .IDENTITY )
private Long id ;
@ Column (unique = true , nullable = false )
private String email ;
@ Column (nullable = false )
private String password ; // 해시된 비밀번호
@ Column (nullable = false )
private String name ;
private String phone ;
@ Enumerated (EnumType .STRING )
private UserStatus status ;
@ Column (name = "created_at" )
private LocalDateTime createdAt ;
@ Column (name = "updated_at" )
private LocalDateTime updatedAt ;
@ OneToMany (mappedBy = "user" , fetch = FetchType .LAZY )
private List <Order > orders = new ArrayList <>();
@ ManyToOne (fetch = FetchType .LAZY )
@ JoinColumn (name = "company_id" )
private Company company ;
public enum UserStatus {
ACTIVE , INACTIVE , SUSPENDED
}
// 비즈니스 로직
public void activate () {
this .status = UserStatus .ACTIVE ;
}
public void suspend () {
this .status = UserStatus .SUSPENDED ;
}
public boolean canOrder () {
return status == UserStatus .ACTIVE ;
}
@ PrePersist
protected void onCreate () {
createdAt = LocalDateTime .now ();
updatedAt = LocalDateTime .now ();
}
@ PreUpdate
protected void onUpdate () {
updatedAt = LocalDateTime .now ();
}
// Getters, Setters
public Long getId () { return id ; }
public void setId (Long id ) { this .id = id ; }
public String getEmail () { return email ; }
public void setEmail (String email ) { this .email = email ; }
public String getPassword () { return password ; }
public void setPassword (String password ) { this .password = password ; }
public String getName () { return name ; }
public void setName (String name ) { this .name = name ; }
public String getPhone () { return phone ; }
public void setPhone (String phone ) { this .phone = phone ; }
public UserStatus getStatus () { return status ; }
public void setStatus (UserStatus status ) { this .status = status ; }
public LocalDateTime getCreatedAt () { return createdAt ; }
public LocalDateTime getUpdatedAt () { return updatedAt ; }
public List <Order > getOrders () { return orders ; }
public Company getCompany () { return company ; }
public void setCompany (Company company ) { this .company = company ; }
}
/**
* ============================================
* REQUEST DTOs (입력용)
* ============================================
* 클라이언트 → 서버
*/
/**
* 회원가입 요청 DTO
*/
public class SignupRequest {
@ NotBlank (message = "이메일은 필수입니다" )
@ Email (message = "올바른 이메일 형식이 아닙니다" )
private String email ;
@ NotBlank (message = "비밀번호는 필수입니다" )
@ Size (min = 8 , message = "비밀번호는 8자 이상이어야 합니다" )
@ Pattern (
regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\ d).*$" ,
message = "비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다"
)
private String password ;
@ NotBlank (message = "이름은 필수입니다" )
@ Size (min = 2 , max = 50 , message = "이름은 2-50자여야 합니다" )
private String name ;
@ Pattern (regexp = "^01[0-9]-[0-9]{4}-[0-9]{4}$" , message = "올바른 휴대폰 형식이 아닙니다" )
private String phone ;
// Getters, Setters
public String getEmail () { return email ; }
public void setEmail (String email ) { this .email = email ; }
public String getPassword () { return password ; }
public void setPassword (String password ) { this .password = password ; }
public String getName () { return name ; }
public void setName (String name ) { this .name = name ; }
public String getPhone () { return phone ; }
public void setPhone (String phone ) { this .phone = phone ; }
/**
* DTO → Entity 변환
*/
public User toEntity (PasswordEncoder passwordEncoder ) {
User user = new User ();
user .setEmail (this .email );
user .setPassword (passwordEncoder .encode (this .password ));
user .setName (this .name );
user .setPhone (this .phone );
user .setStatus (User .UserStatus .ACTIVE );
return user ;
}
}
/**
* 로그인 요청 DTO
*/
public class LoginRequest {
@ NotBlank (message = "이메일은 필수입니다" )
@ Email
private String email ;
@ NotBlank (message = "비밀번호는 필수입니다" )
private String password ;
// Getters, Setters
public String getEmail () { return email ; }
public void setEmail (String email ) { this .email = email ; }
public String getPassword () { return password ; }
public void setPassword (String password ) { this .password = password ; }
}
/**
* 프로필 수정 요청 DTO
*/
public class UpdateProfileRequest {
@ NotBlank (message = "이름은 필수입니다" )
@ Size (min = 2 , max = 50 )
private String name ;
@ Pattern (regexp = "^01[0-9]-[0-9]{4}-[0-9]{4}$" )
private String phone ;
// Getters, Setters
public String getName () { return name ; }
public void setName (String name ) { this .name = name ; }
public String getPhone () { return phone ; }
public void setPhone (String phone ) { this .phone = phone ; }
/**
* Entity 업데이트
*/
public void updateEntity (User user ) {
user .setName (this .name );
user .setPhone (this .phone );
}
}
/**
* ============================================
* RESPONSE DTOs (출력용)
* ============================================
* 서버 → 클라이언트
*/
/**
* 사용자 상세 응답 DTO
*/
public class UserResponse {
private Long id ;
private String email ;
private String name ;
private String phone ;
private String status ;
private String createdAt ;
// password 제외! ✅
// orders 제외! ✅
// company 제외! ✅
// Getters, Setters
public Long getId () { return id ; }
public void setId (Long id ) { this .id = id ; }
public String getEmail () { return email ; }
public void setEmail (String email ) { this .email = email ; }
public String getName () { return name ; }
public void setName (String name ) { this .name = name ; }
public String getPhone () { return phone ; }
public void setPhone (String phone ) { this .phone = phone ; }
public String getStatus () { return status ; }
public void setStatus (String status ) { this .status = status ; }
public String getCreatedAt () { return createdAt ; }
public void setCreatedAt (String createdAt ) { this .createdAt = createdAt ; }
/**
* Entity → DTO 변환 (정적 팩토리 메서드)
*/
public static UserResponse from (User user ) {
UserResponse dto = new UserResponse ();
dto .id = user .getId ();
dto .email = user .getEmail ();
dto .name = user .getName ();
dto .phone = user .getPhone ();
dto .status = user .getStatus ().name ();
// LocalDateTime → String 변환
DateTimeFormatter formatter = DateTimeFormatter .ofPattern ("yyyy-MM-dd HH:mm:ss" );
dto .createdAt = user .getCreatedAt ().format (formatter );
return dto ;
}
}
/**
* 사용자 목록 응답 DTO (간략 정보)
*/
public class UserListResponse {
private Long id ;
private String email ;
private String name ;
private String status ;
// 목록에서는 phone, createdAt 제외 (더 간략!)
// Getters, Setters
public Long getId () { return id ; }
public void setId (Long id ) { this .id = id ; }
public String getEmail () { return email ; }
public void setEmail (String email ) { this .email = email ; }
public String getName () { return name ; }
public void setName (String name ) { this .name = name ; }
public String getStatus () { return status ; }
public void setStatus (String status ) { this .status = status ; }
public static UserListResponse from (User user ) {
UserListResponse dto = new UserListResponse ();
dto .id = user .getId ();
dto .email = user .getEmail ();
dto .name = user .getName ();
dto .status = user .getStatus ().name ();
return dto ;
}
}
/**
* 로그인 응답 DTO
*/
public class LoginResponse {
private String token ;
private UserResponse user ;
public LoginResponse (String token , UserResponse user ) {
this .token = token ;
this .user = user ;
}
// Getters, Setters
public String getToken () { return token ; }
public void setToken (String token ) { this .token = token ; }
public UserResponse getUser () { return user ; }
public void setUser (UserResponse user ) { this .user = user ; }
}
/**
* ============================================
* SERVICE (비즈니스 로직)
* ============================================
*/
@ Service
public class UserService {
private final UserRepository userRepository ;
private final PasswordEncoder passwordEncoder ;
@ Autowired
public UserService (UserRepository userRepository , PasswordEncoder passwordEncoder ) {
this .userRepository = userRepository ;
this .passwordEncoder = passwordEncoder ;
}
/**
* 회원가입
*/
@ Transactional
public UserResponse signup (SignupRequest request ) {
System .out .println ("\n 👤 회원가입 시작" );
System .out .println (" 이메일: " + request .getEmail ());
// 1. 중복 체크
if (userRepository .existsByEmail (request .getEmail ())) {
throw new DuplicateEmailException ("이미 사용 중인 이메일입니다" );
}
// 2. DTO → Entity 변환
User user = request .toEntity (passwordEncoder );
// 3. 저장
User savedUser = userRepository .save (user );
System .out .println (" ✅ 회원가입 완료: ID=" + savedUser .getId ());
// 4. Entity → DTO 변환
return UserResponse .from (savedUser );
}
/**
* 로그인
*/
public LoginResponse login (LoginRequest request ) {
System .out .println ("\n 🔐 로그인 시도: " + request .getEmail ());
// 1. 사용자 조회
User user = userRepository .findByEmail (request .getEmail ())
.orElseThrow (() -> new InvalidCredentialsException ("이메일 또는 비밀번호가 일치하지 않습니다" ));
// 2. 비밀번호 확인
if (!passwordEncoder .matches (request .getPassword (), user .getPassword ())) {
throw new InvalidCredentialsException ("이메일 또는 비밀번호가 일치하지 않습니다" );
}
// 3. 상태 확인
if (user .getStatus () != User .UserStatus .ACTIVE ) {
throw new UserNotActiveException ("활성화되지 않은 계정입니다" );
}
System .out .println (" ✅ 로그인 성공" );
// 4. 토큰 생성 (JWT 등)
String token = generateToken (user );
// 5. 응답 DTO 생성
return new LoginResponse (token , UserResponse .from (user ));
}
/**
* 프로필 조회
*/
public UserResponse getUserProfile (Long userId ) {
User user = userRepository .findById (userId )
.orElseThrow (() -> new UserNotFoundException ("사용자를 찾을 수 없습니다" ));
return UserResponse .from (user );
}
/**
* 프로필 수정
*/
@ Transactional
public UserResponse updateProfile (Long userId , UpdateProfileRequest request ) {
System .out .println ("\n ✏️ 프로필 수정: ID=" + userId );
User user = userRepository .findById (userId )
.orElseThrow (() -> new UserNotFoundException ("사용자를 찾을 수 없습니다" ));
// DTO로 Entity 업데이트
request .updateEntity (user );
System .out .println (" ✅ 프로필 수정 완료" );
return UserResponse .from (user );
}
/**
* 사용자 목록 조회
*/
public List <UserListResponse > getAllUsers () {
return userRepository .findAll ().stream ()
.map (UserListResponse ::from )
.collect (Collectors .toList ());
}
private String generateToken (User user ) {
// JWT 토큰 생성 (간단히 표현)
return "jwt_token_" + user .getId ();
}
}
/**
* ============================================
* CONTROLLER (API)
* ============================================
*/
@ RestController
@ RequestMapping ("/api/users" )
public class UserController {
private final UserService userService ;
@ Autowired
public UserController (UserService userService ) {
this .userService = userService ;
}
/**
* 회원가입
*/
@ PostMapping ("/signup" )
public ResponseEntity <UserResponse > signup (@ Valid @ RequestBody SignupRequest request ) {
UserResponse response = userService .signup (request );
return ResponseEntity .status (HttpStatus .CREATED ).body (response );
}
/**
* 로그인
*/
@ PostMapping ("/login" )
public ResponseEntity <LoginResponse > login (@ Valid @ RequestBody LoginRequest request ) {
LoginResponse response = userService .login (request );
return ResponseEntity .ok (response );
}
/**
* 프로필 조회
*/
@ GetMapping ("/{id}" )
public ResponseEntity <UserResponse > getUser (@ PathVariable Long id ) {
UserResponse response = userService .getUserProfile (id );
return ResponseEntity .ok (response );
}
/**
* 프로필 수정
*/
@ PutMapping ("/{id}" )
public ResponseEntity <UserResponse > updateProfile (
@ PathVariable Long id ,
@ Valid @ RequestBody UpdateProfileRequest request ) {
UserResponse response = userService .updateProfile (id , request );
return ResponseEntity .ok (response );
}
/**
* 사용자 목록
*/
@ GetMapping
public ResponseEntity <List <UserListResponse >> getAllUsers () {
List <UserListResponse > response = userService .getAllUsers ();
return ResponseEntity .ok (response );
}
}
/**
* ============================================
* REPOSITORY
* ============================================
*/
public interface UserRepository extends JpaRepository <User , Long > {
Optional <User > findByEmail (String email );
boolean existsByEmail (String email );
}
/**
* ============================================
* EXCEPTIONS
* ============================================
*/
class DuplicateEmailException extends RuntimeException {
public DuplicateEmailException (String message ) {
super (message );
}
}
class InvalidCredentialsException extends RuntimeException {
public InvalidCredentialsException (String message ) {
super (message );
}
}
class UserNotFoundException extends RuntimeException {
public UserNotFoundException (String message ) {
super (message );
}
}
class UserNotActiveException extends RuntimeException {
public UserNotActiveException (String message ) {
super (message );
}
}
/**
* ============================================
* DEMO
* ============================================
*/
public class DTOPatternDemo {
public static void main (String [] args ) {
System .out .println ("=== DTO Pattern 예제 ===\n " );
// 1. 회원가입 요청
SignupRequest signupRequest = new SignupRequest ();
signupRequest .setEmail ("[email protected] " );
signupRequest .setPassword ("Password123" );
signupRequest .setName ("홍길동" );
signupRequest .setPhone ("010-1234-5678" );
System .out .println ("📥 회원가입 요청 JSON:" );
System .out .println ("{" );
System .out .println (" \" email\" : \" " + signupRequest .getEmail () + "\" ," );
System .out .println (" \" password\" : \" " + signupRequest .getPassword () + "\" ," );
System .out .println (" \" name\" : \" " + signupRequest .getName () + "\" ," );
System .out .println (" \" phone\" : \" " + signupRequest .getPhone () + "\" " );
System .out .println ("}" );
// 2. 로그인 요청
LoginRequest loginRequest = new LoginRequest ();
loginRequest .setEmail ("[email protected] " );
loginRequest .setPassword ("Password123" );
System .out .println ("\n 📥 로그인 요청 JSON:" );
System .out .println ("{" );
System .out .println (" \" email\" : \" " + loginRequest .getEmail () + "\" ," );
System .out .println (" \" password\" : \" " + loginRequest .getPassword () + "\" " );
System .out .println ("}" );
// 3. 응답 예시
System .out .println ("\n 📤 사용자 응답 JSON:" );
System .out .println ("{" );
System .out .println (" \" id\" : 1," );
System .out .println (" \" email\" : \" [email protected] \" ," );
System .out .println (" \" name\" : \" 홍길동\" ," );
System .out .println (" \" phone\" : \" 010-1234-5678\" ," );
System .out .println (" \" status\" : \" ACTIVE\" ," );
System .out .println (" \" createdAt\" : \" 2024-01-01 10:00:00\" " );
System .out .println ("}" );
System .out .println ("// password 제외! ✅" );
System .out .println ("// orders 제외! ✅" );
System .out .println ("// company 제외! ✅" );
}
}
실행 결과:
=== DTO Pattern 예제 ===
📥 회원가입 요청 JSON:
{
"email": "[email protected] ",
"password": "Password123",
"name": "홍길동",
"phone": "010-1234-5678"
}
👤 회원가입 시작
이메일: [email protected]
✅ 회원가입 완료: ID=1
📥 로그인 요청 JSON:
{
"email": "[email protected] ",
"password": "Password123"
}
🔐 로그인 시도: [email protected]
✅ 로그인 성공
📤 사용자 응답 JSON:
{
"id": 1,
"email": "[email protected] ",
"name": "홍길동",
"phone": "010-1234-5678",
"status": "ACTIVE",
"createdAt": "2024-01-01 10:00:00"
}
// password 제외! ✅
// orders 제외! ✅
// company 제외! ✅
예제 1: 여러 Entity 조합 DTO ⭐⭐⭐
/**
* 주문 상세 조회 (Order + User + Product 조합)
*/
public class OrderDetailResponse {
// Order 정보
private Long orderId ;
private String orderNumber ;
private String orderStatus ;
private BigDecimal totalAmount ;
private String orderDate ;
// User 정보 (일부만)
private Long customerId ;
private String customerName ;
private String customerEmail ;
// Product 정보 (일부만)
private String productName ;
private BigDecimal productPrice ;
private int quantity ;
// Getters, Setters
/**
* Entity 조합 → DTO 변환
*/
public static OrderDetailResponse from (Order order , User user , Product product ) {
OrderDetailResponse dto = new OrderDetailResponse ();
// Order
dto .orderId = order .getId ();
dto .orderNumber = order .getOrderNumber ();
dto .orderStatus = order .getStatus ().name ();
dto .totalAmount = order .getTotalAmount ();
dto .orderDate = order .getCreatedAt ().format (DateTimeFormatter .ISO_DATE );
// User
dto .customerId = user .getId ();
dto .customerName = user .getName ();
dto .customerEmail = user .getEmail ();
// Product
dto .productName = product .getName ();
dto .productPrice = product .getPrice ();
dto .quantity = order .getQuantity ();
return dto ;
}
}
/**
* 효율적인 조회 (JOIN 활용)
*/
@ Service
public class OrderService {
public OrderDetailResponse getOrderDetail (Long orderId ) {
// 한 번의 쿼리로 조회 (JOIN)
Object [] result = orderRepository .findOrderDetailById (orderId );
Order order = (Order ) result [0 ];
User user = (User ) result [1 ];
Product product = (Product ) result [2 ];
return OrderDetailResponse .from (order , user , product );
}
}
특징
Entity
DTO
Value Object
목적
DB 매핑
데이터 전송
값 표현
가변성
가변
가변
불변
비즈니스 로직
✅ 포함
❌ 없음
✅ 포함
영속성
✅ 저장됨
❌ 저장 안됨
❌ 저장 안됨
동등성
ID 기반
필드 기반
값 기반
사용 위치
Domain Layer
API Layer
Domain Layer
// Entity (영속성)
@ Entity
public class User {
@ Id
private Long id ;
private String name ;
// 비즈니스 로직
public void activate () { }
}
// DTO (전송)
public class UserResponse {
private Long id ;
private String name ;
// 로직 없음, 전송만!
}
// Value Object (값)
public class Money {
private final BigDecimal amount ; // 불변!
public Money (BigDecimal amount ) {
this .amount = amount ;
}
// 값 기반 동등성
@ Override
public boolean equals (Object o ) {
if (!(o instanceof Money )) return false ;
Money other = (Money ) o ;
return amount .equals (other .amount );
}
// 비즈니스 로직
public Money add (Money other ) {
return new Money (this .amount .add (other .amount ));
}
}
장점
설명
실무 효과
보안
민감 정보 제외
비밀번호 노출 방지
성능
필요한 데이터만
네트워크 트래픽 감소
독립성
Entity 변경 영향 없음
API 안정성
검증
요청별 검증 규칙
입력 검증 용이
명확성
API 명세 명확
문서화 용이
단점
설명
해결책
코드 중복
변환 코드 반복
MapStruct, ModelMapper
클래스 증가
DTO 클래스 많아짐
적절한 패키지 구조
메모리
객체 생성 비용
필요한 경우만 사용
❌ 안티패턴 1: Entity를 DTO로 사용
// 잘못된 예
@ GetMapping ("/users/{id}" )
public User getUser (@ PathVariable Long id ) {
return userRepository .findById (id ).orElseThrow (); // ❌
}
해결:
// 올바른 예
@ GetMapping ("/users/{id}" )
public UserResponse getUser (@ PathVariable Long id ) {
User user = userRepository .findById (id ).orElseThrow ();
return UserResponse .from (user ); // ✅
}
/**
* MapStruct로 자동 변환
*/
@ Mapper (componentModel = "spring" )
public interface UserMapper {
UserResponse toResponse (User user );
@ Mapping (target = "id" , ignore = true )
@ Mapping (target = "password" , expression = "java(passwordEncoder.encode(dto.getPassword()))" )
User toEntity (SignupRequest dto , @ Context PasswordEncoder passwordEncoder );
}
✅ Entity와 DTO 분리
✅ 민감 정보 제외
✅ 검증 애노테이션 사용
✅ 정적 팩토리 메서드 제공
✅ 용도별 DTO 분리 (Request/Response)
✅ 불변성 고려
상황
추천도
이유
REST API
⭐⭐⭐
필수
계층 간 통신
⭐⭐⭐
결합도 감소
내부 서비스
⭐⭐
Entity 사용 가능