🚀 Entity vs DTO Lombok 어노테이션 Troubleshooting!
핵심 질문: Entity에서 @Setter를 쓰면 안 되는 이유가 뭔가요?
🤔 자주 하는 착각
// ❌ "Lombok으로 깔끔하게 만들었으니 된 거 아닌가?"
@Entity
@Getter
@Setter
@NoArgsConstructor
public class User {
@Id
@GeneratedValue
private Long id;
private String name;
private int age;
private String email;
}
// ❌ "DTO도 Entity와 같은 방식으로 만들면 일관성 있잖아요?"
@Getter
@NoArgsConstructor // Setter는 없이?
public class UserResponseDto {
private Long id;
private String name;
private int age;
private String email;
}
“Lombok 어노테이션만 붙이면 되는 거 아닌가요? Entity와 DTO 차이가 뭐가 중요한가요?”
이는 많은 주니어 개발자들이 가지는 자연스러운 의문입니다. 하지만 Entity와 DTO의 역할을 이해하지 못하면 나중에 큰 문제가 됩니다.
🏗️ 정답: Entity는 도메인 모델, DTO는 데이터 전송 컨테이너
🏢 은행 금고 vs 택배 상자 비유로 이해하기
개념 | 은행 비유 | 개발 비유 | 역할 |
---|---|---|---|
Entity | 🏦 은행 금고 (엄격한 보안) | 비즈니스 규칙, 데이터 일관성, 생명주기 관리 | 도메인 핵심 |
DTO | 📦 택배 상자 (간편한 이동) | 계층 간 데이터 전송, 직렬화/역직렬화 | 데이터 운반체 |
왜 이 비유가 중요한가?
- 은행 금고는 아무나 열 수 없음 (Entity에서 @Setter 지양)
- 택배 상자는 쉽게 열고 닫을 수 있음 (DTO에서 @Setter 허용)
- 둘 다 필요하지만 보안 수준이 다름
💥 Entity에서 @Setter의 실제 문제점
시나리오: 쇼핑몰 주문 시스템
🚨 @Setter 사용 시 발생하는 문제들
@Entity
@Getter
@Setter // 🚨 위험한 어노테이션!
@NoArgsConstructor
public class Order {
@Id
@GeneratedValue
private Long id;
private String productName;
private int quantity;
private int unitPrice;
private OrderStatus status;
private LocalDateTime orderDate;
// 계산된 값
public int getTotalPrice() {
return quantity * unitPrice;
}
}
// 클라이언트 코드에서 발생할 수 있는 문제들
@Service
public class OrderService {
public void processOrder(Order order) {
// 🚨 문제 1: 무분별한 상태 변경
order.setQuantity(-5); // 음수 수량 설정 가능!
order.setUnitPrice(0); // 가격을 0원으로 설정!
// 🚨 문제 2: 비즈니스 규칙 무시
order.setStatus(OrderStatus.DELIVERED); // 결제도 안 했는데 배송완료?
// 🚨 문제 3: 데이터 일관성 파괴
order.setOrderDate(LocalDateTime.now().plusDays(30)); // 미래 날짜로 주문?
// 🚨 문제 4: 계산된 값과 실제 값 불일치
// getTotalPrice()는 quantity * unitPrice지만
// quantity나 unitPrice가 중간에 변경되면 혼란
}
public void cancelOrder(Order order) {
// 🚨 문제 5: 상태 변경 로직 분산
// 주문 취소 로직이 여기저기 흩어짐
if (order.getStatus() == OrderStatus.DELIVERED) {
throw new IllegalStateException("배송완료된 주문은 취소할 수 없습니다");
}
order.setStatus(OrderStatus.CANCELLED); // 검증 로직이 분산됨
}
}
🔥 실제 장애 사례들:
- 데이터 일관성 파괴: 수량은 10개인데 총 가격이 엉뚱한 값
- 비즈니스 규칙 위반: 결제 전 주문이 배송완료 상태로 변경
- 버그 추적 어려움: 어디서 상태가 변경되었는지 찾기 힘듦
- 테스트 신뢰성 하락: 예상치 못한 상태 변경으로 테스트 실패
✅ Entity에서 @Setter 없이 안전하게 설계
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA용, 외부 접근 차단
public class Order {
@Id
@GeneratedValue
private Long id;
private String productName;
private int quantity;
private int unitPrice;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private LocalDateTime orderDate;
// 🎯 빌더 패턴으로 안전한 객체 생성
@Builder
public Order(String productName, int quantity, int unitPrice) {
validateProductName(productName);
validateQuantity(quantity);
validateUnitPrice(unitPrice);
this.productName = productName;
this.quantity = quantity;
this.unitPrice = unitPrice;
this.status = OrderStatus.PENDING; // 초기 상태는 항상 PENDING
this.orderDate = LocalDateTime.now();
}
// 🎯 비즈니스 메서드로 명확한 의도 표현
public void confirmPayment() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("결제 대기 상태에서만 결제 확인이 가능합니다");
}
this.status = OrderStatus.PAID;
}
public void startShipping() {
if (this.status != OrderStatus.PAID) {
throw new IllegalStateException("결제 완료된 주문만 배송 시작이 가능합니다");
}
this.status = OrderStatus.SHIPPING;
}
public void completeDelivery() {
if (this.status != OrderStatus.SHIPPING) {
throw new IllegalStateException("배송 중인 주문만 배송 완료 처리가 가능합니다");
}
this.status = OrderStatus.DELIVERED;
}
public void cancel() {
if (this.status == OrderStatus.DELIVERED) {
throw new IllegalStateException("배송 완료된 주문은 취소할 수 없습니다");
}
if (this.status == OrderStatus.CANCELLED) {
throw new IllegalStateException("이미 취소된 주문입니다");
}
this.status = OrderStatus.CANCELLED;
}
// 🎯 수량 변경도 비즈니스 규칙과 함께
public void changeQuantity(int newQuantity) {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("결제 대기 상태에서만 수량 변경이 가능합니다");
}
validateQuantity(newQuantity);
this.quantity = newQuantity;
}
// 🎯 계산된 값은 메서드로
public int getTotalPrice() {
return quantity * unitPrice;
}
// 🎯 도메인 규칙 검증은 private 메서드로
private void validateProductName(String productName) {
if (productName == null || productName.trim().isEmpty()) {
throw new IllegalArgumentException("상품명은 필수입니다");
}
if (productName.length() > 100) {
throw new IllegalArgumentException("상품명은 100자를 초과할 수 없습니다");
}
}
private void validateQuantity(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("수량은 1개 이상이어야 합니다");
}
if (quantity > 999) {
throw new IllegalArgumentException("수량은 999개를 초과할 수 없습니다");
}
}
private void validateUnitPrice(int unitPrice) {
if (unitPrice <= 0) {
throw new IllegalArgumentException("단가는 0원보다 커야 합니다");
}
}
}
// 🎯 개선된 서비스 코드
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
@Transactional
public Order createOrder(OrderCreateRequest request) {
// 안전한 객체 생성
Order order = Order.builder()
.productName(request.getProductName())
.quantity(request.getQuantity())
.unitPrice(request.getUnitPrice())
.build();
return orderRepository.save(order);
}
@Transactional
public void processPayment(Long orderId) {
Order order = findOrder(orderId);
order.confirmPayment(); // 명확한 의도, 비즈니스 규칙 내장
// 자동으로 저장됨 (Dirty Checking)
}
@Transactional
public void cancelOrder(Long orderId) {
Order order = findOrder(orderId);
order.cancel(); // 비즈니스 로직이 Entity 안에 응집
}
}
🎉 Entity에서 @Setter 제거 후 장점들:
- 데이터 일관성 보장: 비즈니스 규칙을 위반하는 상태 변경 차단
-
의도 명확화:
cancel()
,confirmPayment()
등 메서드명으로 의도 표현 - 버그 추적 용이: 상태 변경 지점이 명확함
- 테스트 신뢰성: 예측 가능한 상태 변경
📡 DTO에서는 왜 @Setter가 괜찮을까?
DTO는 순수한 데이터 컨테이너로서 비즈니스 로직이 없기 때문입니다.
🔄 JSON ↔ DTO 변환 과정 이해하기
Spring Boot에서 실제 일어나는 일
// 1️⃣ 클라이언트가 보내는 JSON
{
"name": "김철수",
"age": 30,
"email": "kim@example.com"
}
// 2️⃣ Jackson이 JSON을 DTO로 변환하는 과정
// Step 1: @NoArgsConstructor로 빈 객체 생성
UserRequestDto dto = new UserRequestDto();
// Step 2: @Setter로 각 필드 값 설정
dto.setName("김철수");
dto.setAge(30);
dto.setEmail("kim@example.com");
// 3️⃣ Controller에서 DTO 받기
@PostMapping("/users")
public ResponseEntity<UserResponseDto> createUser(@RequestBody UserRequestDto request) {
// 이미 값이 모두 설정된 DTO를 받음
User user = userService.createUser(request);
return ResponseEntity.ok(UserResponseDto.from(user));
}
// 4️⃣ DTO를 JSON으로 변환 (응답)
// @Getter로 각 필드 값을 읽어서 JSON 생성
{
"id": 1,
"name": "김철수",
"age": 30,
"email": "kim@example.com",
"createdAt": "2025-01-15T10:30:00"
}
📋 DTO 구현 방식별 상세 비교
1. 전통적인 방식 - 개별 어노테이션
@Getter
@Setter
@NoArgsConstructor
public class UserRequestDto {
private String name;
private int age;
private String email;
// 추가 검증이 필요한 경우
public void validate() {
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("이름은 필수입니다");
}
if (age < 0 || age > 150) {
throw new IllegalArgumentException("올바른 나이를 입력해주세요");
}
}
}
@Getter
@Setter
@NoArgsConstructor
public class UserResponseDto {
private Long id;
private String name;
private int age;
private String email;
private LocalDateTime createdAt;
// Entity → DTO 변환 팩토리 메서드
public static UserResponseDto from(User user) {
UserResponseDto dto = new UserResponseDto();
dto.setId(user.getId());
dto.setName(user.getName());
dto.setAge(user.getAge());
dto.setEmail(user.getEmail());
dto.setCreatedAt(user.getCreatedAt());
return dto;
}
}
장점: 명시적이고 이해하기 쉬움
단점: 코드가 다소 장황함
2. @Data 어노테이션 사용
@Data
public class UserRequestDto {
private String name;
private int age;
private String email;
// @Data가 포함하는 것들:
// @Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredArgsConstructor
}
// 주의: @Data 사용 시 고려사항
@Data
public class UserResponseDto {
private Long id;
private String name;
private int age;
private String email;
// 🚨 주의: @EqualsAndHashCode 때문에 의도치 않은 동작 가능
// 예: List에서 중복 제거 시 예상과 다른 결과
}
장점: 매우 간결함
단점:
-
@ToString
에 민감한 정보 포함될 수 있음 -
@EqualsAndHashCode
가 예상치 못한 동작 유발 가능 - 필요 없는 메서드까지 생성됨
3. Record 타입 (Java 16+) ⭐ 현재 추천 방식
// 불변 요청 DTO - 생성자 검증 포함
public record UserCreateRequest(
String name,
int age,
String email
) {
// Compact Constructor - 생성 시 자동으로 검증 수행
public UserCreateRequest {
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("이름은 필수입니다");
}
if (age < 0 || age > 150) {
throw new IllegalArgumentException("올바른 나이를 입력해주세요");
}
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("올바른 이메일 형식이 아닙니다");
}
// 정규화 (trim 처리)
name = name.trim();
email = email.toLowerCase().trim();
}
}
// 불변 응답 DTO
public record UserResponse(
Long id,
String name,
int age,
String email,
LocalDateTime createdAt
) {
// Entity → DTO 변환을 위한 정적 팩토리 메서드
public static UserResponse from(User user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getAge(),
user.getEmail(),
user.getCreatedAt()
);
}
// 추가적인 비즈니스 메서드도 가능
public boolean isAdult() {
return age >= 18;
}
public String getDisplayName() {
return name + " (" + age + "세)";
}
}
// 복잡한 DTO의 경우 - 중첩 record 활용
public record OrderResponse(
Long id,
ProductInfo product,
CustomerInfo customer,
int quantity,
int totalPrice,
OrderStatus status
) {
public record ProductInfo(String name, int unitPrice) {}
public record CustomerInfo(String name, String email) {}
public static OrderResponse from(Order order) {
return new OrderResponse(
order.getId(),
new ProductInfo(order.getProductName(), order.getUnitPrice()),
new CustomerInfo(order.getCustomer().getName(), order.getCustomer().getEmail()),
order.getQuantity(),
order.getTotalPrice(),
order.getStatus()
);
}
}
장점:
- 완전한 불변성: 생성 후 값 변경 불가능
- 간결한 문법: 보일러플레이트 코드 최소화
- 자동 생성: equals, hashCode, toString 자동 생성
- 컴파일 타임 안전성: 타입 안정성 보장
- Jackson 호환: 직렬화/역직렬화 완벽 지원
🎯 상황별 DTO 패턴 권장사항
API 요청/응답 DTO
// 🎯 요청 DTO - 검증 로직 포함 record
public record CreateOrderRequest(
String productName,
int quantity,
int unitPrice
) {
public CreateOrderRequest {
if (productName == null || productName.trim().isEmpty()) {
throw new IllegalArgumentException("상품명은 필수입니다");
}
if (quantity <= 0) {
throw new IllegalArgumentException("수량은 1개 이상이어야 합니다");
}
if (unitPrice <= 0) {
throw new IllegalArgumentException("단가는 0원보다 커야 합니다");
}
}
// Entity 생성을 위한 헬퍼 메서드
public Order toEntity() {
return Order.builder()
.productName(productName)
.quantity(quantity)
.unitPrice(unitPrice)
.build();
}
}
// 🎯 응답 DTO - 불변 데이터
public record OrderResponse(
Long id,
String productName,
int quantity,
int unitPrice,
int totalPrice,
OrderStatus status,
LocalDateTime orderDate
) {
public static OrderResponse from(Order order) {
return new OrderResponse(
order.getId(),
order.getProductName(),
order.getQuantity(),
order.getUnitPrice(),
order.getTotalPrice(),
order.getStatus(),
order.getOrderDate()
);
}
}
복잡한 폼 데이터 DTO
// 🎯 복잡한 폼은 @Data 사용 (Bean Validation과 함께)
@Data
@Valid
public class UserRegistrationDto {
@NotBlank(message = "이름은 필수입니다")
@Size(max = 50, message = "이름은 50자 이하여야 합니다")
private String name;
@Min(value = 0, message = "나이는 0 이상이어야 합니다")
@Max(value = 150, message = "나이는 150 이하여야 합니다")
private int age;
@Email(message = "올바른 이메일 형식이 아닙니다")
@NotBlank(message = "이메일은 필수입니다")
private String email;
@Pattern(regexp = "^\\d{3}-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다")
private String phoneNumber;
@Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다")
@Pattern(regexp = ".*[A-Z].*", message = "대문자를 포함해야 합니다")
@Pattern(regexp = ".*[a-z].*", message = "소문자를 포함해야 합니다")
@Pattern(regexp = ".*[0-9].*", message = "숫자를 포함해야 합니다")
private String password;
@AssertTrue(message = "이용약관에 동의해야 합니다")
private boolean agreeToTerms;
// 복잡한 검증 로직이 필요한 경우
@AssertTrue(message = "나이가 18세 미만인 경우 법정대리인 동의가 필요합니다")
public boolean isValidConsent() {
return age >= 18 || hasParentalConsent();
}
private boolean hasParentalConsent() {
// 복잡한 검증 로직...
return true;
}
}
내부 시스템 간 통신 DTO
// 🎯 마이크로서비스 간 통신용 - record 사용
public record UserEventDto(
Long userId,
String eventType,
LocalDateTime occurredAt,
Map<String, Object> eventData
) {
public static UserEventDto userCreated(User user) {
return new UserEventDto(
user.getId(),
"USER_CREATED",
LocalDateTime.now(),
Map.of(
"name", user.getName(),
"email", user.getEmail(),
"registrationSource", "WEB"
)
);
}
public static UserEventDto userUpdated(User user, String updatedField) {
return new UserEventDto(
user.getId(),
"USER_UPDATED",
LocalDateTime.now(),
Map.of(
"updatedField", updatedField,
"newValue", getFieldValue(user, updatedField)
)
);
}
private static Object getFieldValue(User user, String fieldName) {
// 리플렉션을 사용한 필드 값 추출 로직
return switch (fieldName) {
case "name" -> user.getName();
case "email" -> user.getEmail();
default -> throw new IllegalArgumentException("Unknown field: " + fieldName);
};
}
}
🚨 자주 하는 실수들과 해결책
❌ 실수 1: Entity에서 무분별한 @Data 사용
// 🚨 위험한 코드
@Entity
@Data // toString에 지연로딩 필드까지 포함되어 N+1 문제 발생!
public class Order {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer; // toString 호출 시 추가 쿼리 발생!
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems; // 마찬가지로 추가 쿼리!
}
✅ 해결책: Entity에는 꼭 필요한 어노테이션만
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(exclude = {"customer", "orderItems"}) // 지연로딩 필드 제외
public class Order {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems = new ArrayList<>();
// 필요한 경우에만 명시적으로 toString 구현
@Override
public String toString() {
return "Order{" +
"id=" + id +
", status=" + status +
'}';
}
}
❌ 실수 2: DTO에서 불필요한 비즈니스 로직 포함
// 🚨 잘못된 DTO 설계
@Data
public class OrderDto {
private Long id;
private String productName;
private int quantity;
private int unitPrice;
// 🚨 DTO에 비즈니스 로직이 들어가면 안됨!
public boolean canCancel() {
// 복잡한 비즈니스 로직...
return status != OrderStatus.DELIVERED &&
LocalDateTime.now().isBefore(orderDate.plusDays(1));
}
public void applyDiscount(double rate) {
// 🚨 DTO에서 상태를 변경하는 것도 문제!
this.unitPrice = (int)(unitPrice * (1 - rate));
}
}
✅ 해결책: DTO는 순수한 데이터 컨테이너로
// DTO는 데이터 전송만
public record OrderDto(
Long id,
String productName,
int quantity,
int unitPrice,
int totalPrice,
OrderStatus status,
LocalDateTime orderDate
) {
public static OrderDto from(Order order) {
return new OrderDto(
order.getId(),
order.getProductName(),
order.getQuantity(),
order.getUnitPrice(),
order.getTotalPrice(), // Entity에서 계산된 값 사용
order.getStatus(),
order.getOrderDate()
);
}
}
// 비즈니스 로직은 Entity나 Service에
@Entity
public class Order {
// ...
public boolean canCancel() {
return status != OrderStatus.DELIVERED &&
LocalDateTime.now().isBefore(orderDate.plusDays(1));
}
}
❌ 실수 3: Jackson 직렬화 문제 간과
// 🚨 LocalDateTime 직렬화 문제
@Data
public class EventDto {
private String eventName;
private LocalDateTime eventTime; // 기본적으로 배열 형태로 직렬화됨!
// JSON 결과: {"eventTime": [2025, 1, 15, 10, 30, 0]}
}
✅ 해결책: 적절한 직렬화 설정
// application.yml 설정
spring:
jackson:
serialization:
write-dates-as-timestamps: false
date-format: "yyyy-MM-dd HH:mm:ss"
// 또는 어노테이션으로 개별 설정
public record EventDto(
String eventName,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
LocalDateTime eventTime,
@JsonProperty("created_at") // 스네이크 케이스로 출력
LocalDateTime createdAt
) {}
⚖️ 언제 어떤 방식을 써야 할까?
🟢 Record 적극 사용 상황
// API 응답, 이벤트 데이터, 설정 값 등
public record ApiResponse<T>(
boolean success,
String message,
T data,
LocalDateTime timestamp
) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, "성공", data, LocalDateTime.now());
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, message, null, LocalDateTime.now());
}
}
// 설정 값들
public record DatabaseConfig(
String url,
String username,
String password,
int maxPoolSize,
Duration connectionTimeout
) {}
사용 신호들:
- 불변성이 중요: 데이터가 변경되지 않아야 함
- 간단한 구조: 복잡한 검증 로직이 불필요
- API 응답: 클라이언트에게 전달되는 데이터
- Java 16 이상: 프로젝트가 최신 Java 버전 사용
🟡 @Data 선택적 사용 상황
// 복잡한 폼 데이터나 설정 클래스
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EmailTemplateConfig {
private String templateName;
private String subject;
private String htmlContent;
private String textContent;
private List<String> requiredVariables;
private Map<String, String> defaultVariables;
// 복잡한 빌더 패턴이 필요한 경우
public static EmailTemplateConfig createWelcomeTemplate() {
return EmailTemplateConfig.builder()
.templateName("welcome")
.subject("환영합니다!")
.htmlContent("<h1>환영합니다</h1>")
.textContent("환영합니다")
.requiredVariables(List.of("userName", "activationLink"))
.defaultVariables(Map.of("companyName", "우리회사"))
.build();
}
}
사용 고려 사항:
- 복잡한 빌더 패턴 필요: 많은 선택적 필드
- Bean Validation 활용: @Valid와 함께 사용
- 레거시 호환성: 기존 코드와의 일관성 유지
🔴 개별 어노테이션 사용 상황
// 매우 간단하거나 특수한 요구사항이 있는 경우
@Getter
@Setter
@NoArgsConstructor
public class LegacySystemDto {
private String field1;
private String field2;
// 특수한 setter 로직이 필요한 경우
public void setField1(String field1) {
this.field1 = field1 != null ? field1.toUpperCase() : null;
}
// 특수한 getter 로직이 필요한 경우
public String getField2() {
return field2 != null ? field2.toLowerCase() : "";
}
}
사용 기준:
- 특수한 getter/setter 로직: 단순한 접근자가 아닌 경우
- 레거시 시스템 연동: 기존 시스템과의 호환성
- 팀 컨벤션: 팀에서 정한 코딩 스타일
🎓 실무 적용 로드맵
🏃♂️ 1단계: 기초 다지기 (1-2주)
Entity에서 @Setter 제거부터 시작
// Before: 위험한 Entity
@Entity
@Data
public class User {
// 모든 필드에 setter 생성됨
}
// After: 안전한 Entity
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
// setter 없이 비즈니스 메서드로 상태 변경
public void updateProfile(String newName) {
validateName(newName);
this.name = newName;
}
}
🚶♂️ 2단계: DTO 최적화 (2-3주)
프로젝트 상황에 맞는 DTO 패턴 선택
// Java 16+ 프로젝트: record 적극 활용
public record UserResponse(Long id, String name, String email) {
public static UserResponse from(User user) {
return new UserResponse(user.getId(), user.getName(), user.getEmail());
}
}
// 복잡한 폼: @Data + Bean Validation
@Data
@Valid
public class UserRegistrationForm {
@NotBlank @Size(max = 50)
private String name;
@Email @NotBlank
private String email;
}
🏃♂️ 3단계: 고급 패턴 적용 (3-4주)
변환 로직 체계화
// Mapper 패턴 도입
@Component
public class UserMapper {
public User toEntity(UserCreateRequest request) {
return User.builder()
.name(request.name())
.email(request.email())
.build();
}
public UserResponse toResponse(User user) {
return UserResponse.from(user);
}
public List<UserResponse> toResponseList(List<User> users) {
return users.stream()
.map(UserResponse::from)
.toList();
}
}
🔄 4단계: 지속적 개선
코드 리뷰 체크리스트
- Entity: @Setter 사용하지 않았는가?
- Entity: 비즈니스 메서드로 상태 변경하는가?
- DTO: 역할에 맞는 어노테이션을 선택했는가?
- DTO: 불필요한 비즈니스 로직이 포함되지 않았는가?
- 변환: Entity ↔ DTO 변환 로직이 명확한가?
💡 팀 단위 적용 전략
👥 작은 팀 (2-3명)
// 핵심 Entity만 엄격하게, DTO는 유연하게
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order { // 핵심 비즈니스 로직만 엄격한 설계
// 비즈니스 메서드 중심
}
@Data
public class OrderDto { // 간단한 DTO는 @Data로 빠르게
private Long id;
private String productName;
}
👥 중간 팀 (4-6명)
// 컨벤션 통일, 역할별 명확한 구분
// Entity: 무조건 비즈니스 메서드
// Request DTO: record + 검증
// Response DTO: record + 팩토리 메서드
public record CreateOrderRequest(String productName, int quantity) {
public CreateOrderRequest {
// 검증 로직
}
}
public record OrderResponse(Long id, String productName) {
public static OrderResponse from(Order order) {
return new OrderResponse(order.getId(), order.getProductName());
}
}
👥 큰 팀 (7명 이상)
// 완전한 규칙 정립 + 자동화 도구 활용
// ArchUnit으로 아키텍처 테스트
@ArchTest
static ArchRule entities_should_not_have_setter =
classes().that().areAnnotatedWith(Entity.class)
.should().notBeAnnotatedWith(Setter.class)
.andShould().notBeAnnotatedWith(Data.class);
// CheckStyle이나 SpotBugs로 코딩 규칙 자동 검사
// 예: @Entity 클래스에서 @Setter 사용 시 빌드 실패
🎯 성공 지표와 측정
📊 정량적 지표
// 1. Entity 설계 품질
// Before: Entity에 평균 15개의 public setter
// After: Entity에 setter 0개, 비즈니스 메서드 5-7개
// 2. DTO 변환 성능
// Before: 복잡한 변환 로직으로 응답 시간 증가
// After: 단순한 팩토리 메서드로 성능 개선
// 3. 버그 발생률
// Before: 상태 변경 관련 버그 월 평균 5건
// After: 상태 변경 관련 버그 월 평균 1건 이하
📈 정성적 지표
- 코드 가독성: 새로운 팀원이 Entity 로직을 이해하는 시간 단축
- 유지보수성: 비즈니스 규칙 변경 시 수정 범위 최소화
- 테스트 용이성: Entity 단위 테스트 작성이 쉬워짐
- 팀 생산성: DTO 변환 로직으로 인한 논의 시간 감소
🎪 핵심 원칙 정리
🏆 성공하는 개발자의 Entity vs DTO 마인드셋
“Entity는 비즈니스의 핵심, DTO는 데이터의 운반체”
5가지 실천 원칙
- 🏦 Entity: “상태 변경은 반드시 비즈니스 메서드를 통해서만”
- 📦 DTO: “데이터 전송이 목적이므로 간단하고 명확하게”
- 🔄 변환: “Entity ↔ DTO 변환 로직은 한 곳에 모아서”
- 🧪 테스트: “Entity 비즈니스 로직은 반드시 단위 테스트로”
- 📝 문서: “비즈니스 규칙은 코드에서 읽힐 수 있도록”
🚀 마무리: 실무에서 살아남는 Entity vs DTO 설계
⚡ 실무 적용의 황금률
“완벽한 설계보다는 팀이 이해하고 유지할 수 있는 설계”
Entity와 DTO의 역할을 명확히 구분하는 이유는 설계 자체가 목적이 아니라, 다음을 위해서입니다:
- 🔧 유지보수성: 6개월 후에도 안전하게 수정할 수 있는 코드
- 🚀 확장성: 새로운 비즈니스 요구사항에 빠르게 대응
- 🧪 테스트 용이성: 안정적인 배포를 위한 견고한 테스트
- 👥 협업: 팀원들과 함께 일하기 좋은 코드
🎯 실무 적용 3단계 요약
1️⃣ 시작 단계: Entity 안전화
// @Setter 제거, 비즈니스 메서드 도입
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
public void updateProfile(String newName) {
validateName(newName);
this.name = newName;
}
}
2️⃣ 발전 단계: DTO 최적화
// 프로젝트에 맞는 DTO 패턴 선택
// record (Java 16+) 또는 @Data (복잡한 폼)
public record UserResponse(Long id, String name) {
public static UserResponse from(User user) {
return new UserResponse(user.getId(), user.getName());
}
}
3️⃣ 완성 단계: 팀 컨벤션 정립
// 명확한 규칙과 자동화된 검증
// Entity: 비즈니스 메서드만
// DTO: 역할에 맞는 패턴 선택
// 변환: 팩토리 메서드나 Mapper 활용
🎁 마지막 조언
Entity와 DTO의 구분을 맹목적으로 적용하지 마세요. 프로젝트의 규모, 팀의 크기, 요구사항의 복잡도를 고려해서 적절한 수준에서 적용하는 것이 중요합니다.
- 작은 프로젝트: Entity에서 @Setter만 제거해도 충분
- 중간 프로젝트: + DTO 패턴 통일
- 큰 프로젝트: + 완전한 변환 계층 분리
“오늘의 선택이 6개월 후의 나를 만든다”
지금 당장은 복잡해 보일 수 있지만, Entity와 DTO의 역할을 명확히 구분한 개발자는 더 빠르고, 더 안전하게, 더 즐겁게 개발할 수 있습니다.
여러분의 개발 여정에 이 가이드가 든든한 나침반이 되기를 바랍니다! 🧭✨