Home > Troubleshooting > 🔍[Troubleshooting] 🚀 Entity vs DTO Lombok 어노테이션

🔍[Troubleshooting] 🚀 Entity vs DTO Lombok 어노테이션
Troubleshooting Backend Development Spring Boot Lombok Entity DTO Annotation

🚀 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가지 실천 원칙

  1. 🏦 Entity: “상태 변경은 반드시 비즈니스 메서드를 통해서만”
  2. 📦 DTO: “데이터 전송이 목적이므로 간단하고 명확하게”
  3. 🔄 변환: “Entity ↔ DTO 변환 로직은 한 곳에 모아서”
  4. 🧪 테스트: “Entity 비즈니스 로직은 반드시 단위 테스트로”
  5. 📝 문서: “비즈니스 규칙은 코드에서 읽힐 수 있도록”

🚀 마무리: 실무에서 살아남는 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의 역할을 명확히 구분한 개발자는 더 빠르고, 더 안전하게, 더 즐겁게 개발할 수 있습니다.

여러분의 개발 여정에 이 가이드가 든든한 나침반이 되기를 바랍니다! 🧭✨