본문 바로가기
Spring

Spring Boot에서 VO, DTO, Entity 완벽 정리 - 헷갈리지 말자!

by daddydontsleep 2025. 12. 22.
728x90
728x90

사진: Unsplash 의 David Becker

"이거 DTO로 만들까요, VO로 만들까요?" - 신입 개발자 시절 누구나 한 번쯤 해본 질문

Spring Boot로 개발하다 보면 VO, DTO, Entity라는 용어를 자주 마주칩니다. 비슷해 보이지만 각각의 목적과 사용처가 명확히 다릅니다. 이번 글에서는 개념부터 실전 활용까지, 더 이상 헷갈리지 않도록 정리해보겠습니다.


1. 한눈에 보는 핵심 차이

구분 VO DTO Entity
한 줄 요약 값 그 자체 데이터 택배 상자 DB 테이블의 Java 버전
목적 값 표현 계층 간 데이터 전달 DB 테이블 매핑
동등성 값으로 비교 보통 비교 안 함 ID로 비교
불변성 불변 가변 가변
비즈니스 로직 가질 수 있음 없음 가질 수 있음

비유로 이해하기

  • VO: 만원짜리 지폐 (어떤 만원이든 가치는 같음)
  • DTO: 택배 상자 (물건을 A에서 B로 옮기는 역할)
  • Entity: 주민등록증 (고유 번호로 개인을 식별)

2. 먼저 알아야 할 것: 불변 vs 가변

불변 객체 (Immutable)

한 번 생성되면 상태를 변경할 수 없는 객체입니다.

@Getter
@RequiredArgsConstructor // final 필드 생성자 자동 생성
public class Money {
    private final int amount;      // final = 변경 불가
    private final String currency; // final = 변경 불가

    // 값 변경이 필요하면 새 객체 반환
    public Money add(Money other) {
        return new Money(this.amount + other.amount, this.currency);
    }
}

Money money1 = new Money(1000, "KRW");
// money1.setAmount(2000); // 컴파일 에러! Setter 없음

Money money2 = money1.add(new Money(500, "KRW"));
log.info("money1: {}", money1.getAmount()); // 1000 (변경 안 됨)
log.info("money2: {}", money2.getAmount()); // 11500 (새 객체)
장점 설명
스레드 안전 여러 스레드가 동시에 접근해도 안전
예측 가능 값이 변하지 않아 디버깅 용이
사이드 이펙트 방지 다른 코드에서 값을 변경할 수 없음

Java의 대표적인 불변 객체:

클래스 설명 값 변경 시
String 문자열 새 문자열 객체 생성
Integer, Long 래퍼 클래스 새 래퍼 객체 생성
LocalDate, LocalDateTime 날짜/시간 (Java 8+) 새 날짜 객체 반환
BigDecimal 정밀한 숫자 계산 새 BigDecimal 반환
// String - 불변
String str = "Hello";
str.concat(" World"); // 원본 변경 안 됨
log.info("str: {}", str); // "Hello" (그대로)

String newStr = str.concat(" World"); // 새 객체 반환
log.info("newStr: {}", newStr); // "Hello World"

// LocalDate - 불변
LocalDate today = LocalDate.now();
today.plusDays(1); // 원본 변경 안 됨
log.info("today: {}", today);  // 오늘 날짜 (그대로)

LocalDate tomorrow = today.plusDays(1);  // 새 객체 반환
log.info("tomorrow: {}", tomorrow);  // 내일 날짜

// BigDecimal - 불변 (금융 계산에 필수)
BigDecimal price = new BigDecimal("10000");
price.add(new BigDecimal("500"));  // 원본 변경 안 됨

BigDecimal newPrice = price.add(new BigDecimal("500"));  // 새 객체 반환
log.info("newPrice: {}", newPrice);  // 10500

주의: Date, Calendar는 가변 객체입니다. Java 8 이후에는 불변인 LocalDate, LocalDateTime을 사용하세요.


가변 객체 (Mutable)

생성 후에도 상태를 변경할 수 있는 객체입니다.

@Getter @Setter // Setter 있음 = 가변
public class UserDTO {
    private String name;
    private String email;
}

UserDTO user = new UserDTO();
user.setName("홍길동");
user.setName("김철수"); // 얼마든지 변경 가능

언제 뭘 쓰나?

상황 선택 이유
금액, 주소 같은 값 불변 값이 변하면 안 됨
멀티스레드 환경 불변 동기화 불필요
API 요청/응답 DTO 가변 값 설정이 편리
DB Entity 가변 상태 변경 필요

3. VO (Value Object)

개념

"값 그 자체"를 표현하는 객체입니다. 핵심은 값으로 동등성을 판단한다는 것입니다.

제 지갑의 만원과 여러분 지갑의 만원은 "같은" 만원입니다. 지폐 일련번호가 달라도 가치는 동일하니까요.

코드 예시

@Getter
@EqualsAndHashCode // 모든 필드로 동등성 비교
@RequiredArgsConstructor
public class Money {
    private final int amount;
    private final String currency;

    // 유효성 검증이 필요하면 정적 팩토리 메서드 사용
    public static Money of(int amount, String currency) {
        if (amount < 0) {
            throw new IllegalArgumentException("금액은 0 이상이어야 합니다");
        }
        return new Money(amount, currency);
    }

    // 비즈니스 로직 포함 가능
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("통화가 다릅니다");
        }
        return new Money(this.amount + other.amount, this.currency);
    }
}

@EqualsAndHashCode란?

equals()hashCode() 메서드를 자동 생성해주는 Lombok 어노테이션입니다.

메서드 역할
equals() 두 객체가 같은지 비교 (필드 값 기준)
hashCode() 객체의 해시값 반환 (HashMap, HashSet에서 사용)

왜 필요한가요?

// 센서에서 읽은 좌표 두 개
Coordinate sensorA = new Coordinate(37.5665, 126.9780);  // 서울시청
Coordinate sensorB = new Coordinate(37.5665, 126.9780);  // 또 다른 센서도 서울시청

// @EqualsAndHashCode 없으면 → false (메모리 주소가 다르니까)
// @EqualsAndHashCode 있으면 → true (좌표 값이 같으니까)
sensorA.equals(sensorB);

// 실무 상황 1: 이 위치에 이미 매장이 있는지 확인
List<Coordinate> storeLocations = getStoreLocations();
if (storeLocations.contains(newLocation)) {
    throw new IllegalArgumentException("이미 해당 위치에 매장이 존재합니다");
}

// 실무 상황 2: 중복 좌표 제거 (여러 센서가 같은 위치를 보고할 때)
Set<Coordinate> uniqueLocations = new HashSet<>(allSensorData);
log.info("중복 제거 후 위치 수: {}", uniqueLocations.size());

// 실무 상황 3: 좌표별 날씨 정보 캐싱
Map<Coordinate, WeatherInfo> weatherCache = new HashMap<>();
weatherCache.put(seoul, seoulWeather);
weatherCache.get(anotherSeoulCoord);  // 같은 좌표면 캐시 히트
// @EqualsAndHashCode가 자동 생성하는 코드
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Money)) return false;
    Money money = (Money) o;
    return amount == money.amount &&
           Objects.equals(currency, money.currency);
}

@Override
public int hashCode() {
    return Objects.hash(amount, currency);
}

이 어노테이션 덕분에 위 코드를 직접 작성하지 않아도 됩니다.

Money price1 = new Money(1000, "KRW");
Money price2 = new Money(1000, "KRW");

// @EqualsAndHashCode 덕분에 값으로 비교 가능
log.info("price1 equals price2: {}", price1.equals(price2)); // true (같은 값)

도메인별 VO 활용 사례

VO는 단순한 금액뿐 아니라 GIS, IoT, 물류, 의료 등 다양한 도메인에서 핵심적으로 사용됩니다.

GIS/지도 시스템

@Embeddable
@Getter
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED)  // JPA용
@AllArgsConstructor
public class Coordinate {
    private double latitude;
    private double longitude;

    // 유효성 검증이 필요하면 정적 팩토리 메서드
    public static Coordinate of(double latitude, double longitude) {
        if (latitude < -90 || latitude > 90)
            throw new IllegalArgumentException("위도는 -90 ~ 90 사이");
        if (longitude < -180 || longitude > 180)
            throw new IllegalArgumentException("경도는 -180 ~ 180 사이");
        return new Coordinate(latitude, longitude);
    }

    // 두 좌표 간 거리 계산 (Haversine 공식)
    public double distanceKmTo(Coordinate other) {
        double R = 6371;
        double dLat = Math.toRadians(other.latitude - this.latitude);
        double dLon = Math.toRadians(other.longitude - this.longitude);
        double a = Math.sin(dLat/2) * Math.sin(dLat/2) +
                   Math.cos(Math.toRadians(this.latitude)) *
                   Math.cos(Math.toRadians(other.latitude)) *
                   Math.sin(dLon/2) * Math.sin(dLon/2);
        return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
    }

    public boolean isWithinRadius(Coordinate center, double radiusKm) {
        return distanceKmTo(center) <= radiusKm;
    }
}

활용:

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Store {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @Embedded
    private Coordinate location;

    @Builder
    public Store(String name, Coordinate location) {
        this.name = name;
        this.location = location;
    }
}

// 반경 3km 내 매장 검색
List<Store> nearbyStores = stores.stream()
    .filter(s -> s.getLocation().isWithinRadius(userLocation, 3.0))
    .toList();

IoT/센서 시스템

@Getter
@EqualsAndHashCode
@RequiredArgsConstructor
public class Temperature {
    private final double value;
    private final TemperatureUnit unit;

    public Temperature toCelsius() {
        return switch (unit) {
            case CELSIUS -> this;
            case FAHRENHEIT -> new Temperature((value - 32) * 5/9, TemperatureUnit.CELSIUS);
            case KELVIN -> new Temperature(value - 273.15, TemperatureUnit.CELSIUS);
        };
    }

    public boolean isOverheat(double thresholdCelsius) {
        return toCelsius().value > thresholdCelsius;
    }

    public boolean isFreezing() {
        return toCelsius().value <= 0;
    }
}

@Getter
@EqualsAndHashCode
public class BatteryLevel {
    private final int percentage;

    // 유효성 검증 필요 시 생성자 직접 작성
    public BatteryLevel(int percentage) {
        if (percentage < 0 || percentage > 100)
            throw new IllegalArgumentException("0~100 사이여야 합니다");
        this.percentage = percentage;
    }

    public boolean isLow() { return percentage <= 20; }
    public boolean isCritical() { return percentage <= 5; }
}

활용:

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(exclude = {})  // 연관관계 있으면 제외
public class IoTDevice {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Embedded
    private Coordinate location;

    @Embedded
    @AttributeOverride(name = "percentage", column = @Column(name = "battery_level"))
    private BatteryLevel battery;

    private Instant lastSeen;

    @Builder
    public IoTDevice(Coordinate location, BatteryLevel battery) {
        this.location = location;
        this.battery = battery;
        this.lastSeen = Instant.now();
    }

    public boolean needsMaintenance() {
        return battery.isLow() ||
               Duration.between(lastSeen, Instant.now()).toHours() > 24;
    }

    public void updateLastSeen() {
        this.lastSeen = Instant.now();
    }
}

도메인별 VO 요약

도메인 VO 비즈니스 로직
GIS Coordinate, BoundingBox 거리 계산, 영역 포함 여부
IoT Temperature, BatteryLevel 단위 변환, 임계값 체크
물류 Weight, Dimension 배송비 계산, 부피 계산
의료 BloodPressure, HeartRate 정상 범위 판단
금융 Money, ExchangeRate 환율 계산

VO를 쓰면 좋은 점:

  • 타입 안정성: int 대신 Temperature로 의미 명확
  • 비즈니스 로직 캡슐화: temperature.isOverheat() 처럼 메서드로 제공
  • 유효성 검증 내장: 생성자에서 검증하여 잘못된 값 원천 차단

4. DTO (Data Transfer Object)

개념

계층 간 데이터를 전달하는 "택배 상자"입니다. 로직 없이 순수하게 데이터만 담습니다.

전통적인 방식

@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserRequestDTO {
    @NotBlank(message = "이름은 필수입니다")
    private String name;

    @Email(message = "올바른 이메일 형식이 아닙니다")
    private String email;

    private int age;
}

@Getter @Setter
public class UserResponseDTO {
    private Long id;
    private String name;
    private String email;
    private LocalDateTime createdAt;

    public static UserResponseDTO from(User user) {
        UserResponseDTO dto = new UserResponseDTO();
        dto.setId(user.getId());
        dto.setName(user.getName());
        dto.setEmail(user.getEmail());
        dto.setCreatedAt(user.getCreatedAt());
        return dto;
    }
}

Java Record로 간결하게 (Java 14+)

public record UserRequestDTO(
    @NotBlank String name,
    @Email String email,
    int age
) {}

public record UserResponseDTO(
    Long id,
    String name,
    String email,
    LocalDateTime createdAt
) {
    public static UserResponseDTO from(User user) {
        return new UserResponseDTO(
            user.getId(),
            user.getName(),
            user.getEmail(),
            user.getCreatedAt()
        );
    }
}

Record는 불변이고, equals(), hashCode(), toString()이 자동 생성됩니다.


DTO 네이밍 컨벤션

// 요청용
UserCreateRequestDTO
UserUpdateRequestDTO

// 응답용
UserResponseDTO
UserListResponseDTO
UserSimpleResponseDTO

5. Entity

개념

DB 테이블과 1:1로 매핑되는 객체입니다. 고유 식별자(ID)로 구분됩니다.

이름이 같은 "홍길동"이 100명 있어도 주민등록번호로 각각을 구분하듯, Entity도 ID로 구분합니다.

코드 예시

@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    @CreatedDate
    private LocalDateTime createdAt;

    @Builder
    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    // ID로 동등성 비교
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User)) return false;
        User user = (User) o;
        return Objects.equals(id, user.id);  // ID만 비교!
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

    // 비즈니스 로직 (Setter 대신)
    public void changeEmail(String newEmail) {
        if (newEmail == null || newEmail.isBlank()) {
            throw new IllegalArgumentException("이메일은 필수입니다");
        }
        this.email = newEmail;
    }
}

Setter를 지양하는 이유

// Bad: Setter 남발
user.setName("홍길동");
user.setEmail("test@test.com");
// 어디서든 마음대로 변경 가능 → 의도 불명확

// Good: 의미 있는 메서드
user.changeEmail("new@test.com");  // "이메일 변경"이라는 의도가 명확

Entity vs VO 동등성 비교

// Entity: ID로 비교
User user1 = userRepository.findById(1L);
User user2 = userRepository.findById(1L);
user1.equals(user2);  // true (같은 ID)

// VO: 값으로 비교
Money m1 = new Money(1000, "KRW");
Money m2 = new Money(1000, "KRW");
m1.equals(m2);  // true (같은 값)

6. 실전! 함께 사용하는 방법

전체 흐름

Controller          Service             Repository         DB
    │                  │                    │               │
RequestDTO ────→ Entity 변환 ────→ Entity ────→ 저장
    │                  │                    │               │
ResponseDTO ←──── DTO 변환 ←──── Entity ←──── 조회

실전 코드

Entity (Order.java)

@Entity
@Table(name = "orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(exclude = {"user"})  // 연관관계 제외
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @Embedded
    private Money totalAmount;  // VO

    @Embedded
    private Address shippingAddress;  // VO

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Builder
    public Order(User user, Money totalAmount, Address shippingAddress) {
        this.user = user;
        this.totalAmount = totalAmount;
        this.shippingAddress = shippingAddress;
        this.status = OrderStatus.PENDING;
    }

    // Setter 대신 의미 있는 메서드
    public void complete() {
        if (this.status != OrderStatus.PENDING) {
            throw new IllegalStateException("대기 상태에서만 완료 가능");
        }
        this.status = OrderStatus.COMPLETED;
    }
}

DTO

public record OrderCreateRequest(
    Long userId,
    int amount,
    String currency,
    String city,
    String detail
) {
    public Money toMoney() {
        return new Money(amount, currency);
    }

    public Address toAddress() {
        return new Address(city, detail);
    }
}

public record OrderResponse(
    Long orderId,
    String userName,
    int totalAmount,
    String status
) {
    public static OrderResponse from(Order order) {
        return new OrderResponse(
            order.getId(),
            order.getUser().getName(),
            order.getTotalAmount().getAmount(),
            order.getStatus().name()
        );
    }
}

Controller

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public ResponseEntity<OrderResponse> create(@Valid @RequestBody OrderCreateRequest request) {
        Order order = orderService.createOrder(request);
        return ResponseEntity.ok(OrderResponse.from(order));
    }
}

Service

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {

    private final OrderRepository orderRepository;
    private final UserRepository userRepository;

    @Transactional
    public Order createOrder(OrderCreateRequest request) {
        User user = userRepository.findById(request.userId())
            .orElseThrow(() -> new IllegalArgumentException("사용자 없음"));

        Order order = Order.builder()
            .user(user)
            .totalAmount(request.toMoney())
            .shippingAddress(request.toAddress())
            .build();

        return orderRepository.save(order);
    }
}

7. 자주 하는 질문

Q1. DTO에 비즈니스 로직 넣어도 되나요?

No. DTO는 데이터 전달만. 변환 로직(from())은 OK, 비즈니스 로직은 Service나 Entity에.

Q2. Entity를 Controller에서 직접 반환해도 되나요?

권장하지 않습니다.

  • 순환 참조 문제 (JSON 직렬화 시 무한 루프)
  • 민감한 필드 노출 위험
  • Entity 변경이 API 스펙에 영향

Q3. Record vs 일반 클래스?

상황 추천
응답 DTO Record
요청 DTO 둘 다 OK
복잡한 변환 클래스 (Builder 필요 시)

Q4. VO를 왜 써야 하나요?

// Bad: 원시 타입
private int amount;      // 금액? 수량?
private String currency; // amount와 연관?

// Good: VO
private Money totalAmount;  // 명확!

8. Lombok 어노테이션 가이드

Lombok을 사용할 때 VO, DTO, Entity 각각에 맞는 어노테이션을 써야 합니다. 잘못 쓰면 불변 객체가 가변이 되거나, Entity에서 순환 참조가 발생할 수 있습니다.

VO - 불변을 지켜라

어노테이션 사용 여부 이유
@Getter O 값 조회 필요
@EqualsAndHashCode O 값으로 동등성 비교
@ToString O 디버깅용
@RequiredArgsConstructor O final 필드 생성자
@Value O 위 4개 + 불변 한방에 해결
@Setter X 불변 객체에 Setter 금지
@Data X Setter 포함되어 있음
// 방법 1: 개별 어노테이션
@Getter
@EqualsAndHashCode
@RequiredArgsConstructor
public class Money {
    private final int amount;
    private final String currency;

    public Money add(Money other) {
        return new Money(this.amount + other.amount, this.currency);
    }
}

// 방법 2: @Value (한방에 불변 객체)
@Value
public class Money {
    int amount;        // 자동으로 private final
    String currency;

    public Money add(Money other) {
        return new Money(this.amount + other.amount, this.currency);
    }
}

주의: @Embeddable VO는 JPA가 기본 생성자를 요구하므로 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 추가해야 합니다.

@Embeddable
@Getter
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED)  // JPA용
@AllArgsConstructor
public class Coordinate {
    private double latitude;
    private double longitude;
}

DTO - 상황에 따라 선택

어노테이션 사용 여부 이유
@Data O 가변 DTO에 편리 (Getter+Setter+ToString+EqualsAndHashCode)
@Getter @Setter O @Data 대신 명시적으로
@NoArgsConstructor O Jackson 역직렬화에 필요
@AllArgsConstructor O 모든 필드 초기화
@Builder O 복잡한 DTO 생성 시
// 방법 1: @Data (가변 DTO)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserRequestDTO {
    @NotBlank
    private String name;

    @Email
    private String email;

    private int age;
}

// 방법 2: Record (불변 DTO, Lombok 불필요)
public record UserRequestDTO(
    @NotBlank String name,
    @Email String email,
    int age
) {}

Record vs @Data 선택 기준:

상황 추천
Java 14+ & 불변 원할 때 Record
MapStruct 사용 @Data (Setter 필요)
복잡한 변환 로직 @Data + @Builder

Entity - Setter 절대 금지

어노테이션 사용 여부 이유
@Getter O 조회 필요
@NoArgsConstructor(access = PROTECTED) O JPA 필수, 외부 생성 방지
@Builder O 생성자 대신 빌더로 객체 생성
@ToString(exclude = "연관관계") O 순환 참조 방지
@EqualsAndHashCode(of = "id") ID만 비교 (또는 직접 구현)
@Setter X 무분별한 변경 방지
@Data X Setter + EqualsAndHashCode 문제
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(exclude = {"orders"})  // 연관관계 필드 제외
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    @OneToMany(mappedBy = "user")
    private List<Order> orders = new ArrayList<>();

    @Builder
    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    // Setter 대신 의미 있는 메서드
    public void changeEmail(String newEmail) {
        this.email = newEmail;
    }

    // ID로 동등성 비교 (직접 구현 권장)
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User)) return false;
        User user = (User) o;
        return id != null && id.equals(user.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();  // 프록시 고려
    }
}

한눈에 보는 Lombok 정리

구분 권장 금지
VO @Value 또는 @Getter @EqualsAndHashCode @RequiredArgsConstructor @Setter, @Data
DTO @Data 또는 Record -
Entity @Getter @NoArgsConstructor(access=PROTECTED) @Builder @Setter, @Data

9. 정리

결정 가이드

이 객체가 어디서 쓰이나요?
├── DB에 저장? → Entity
├── API 요청/응답? → DTO
└── 값 자체가 의미? (금액, 좌표) → VO

사용 위치

위치 사용
Controller RequestDTO, ResponseDTO
Service Entity, VO
Repository Entity

참고 자료

728x90
300x250