개발 지식 모음/BE

Value Object란?

calendar2 2025. 8. 12. 01:11

최근에 프로젝트 요구사항을 정리하면서 한 팀원이 VO 도입에 대한 의견을 냈다. 기존에 잘못 들어있던 지식 때문에 다시 알아보았는데, 얻어간 부분이 많아 블로그에 정리한다.

또한, AI 중에 클로드 AI가 글 작성이 좋다라는 소문을 들어 한번 사용해봤다. 아래 정리 내용 대부분은 클로드를 이용해 정리한 내용인 점도 참고바란다.

VO는 뭐고 DTO는 뭐죠?

필자가 처음에 지식이 잘못 잡혀있던 부분이 이 부분이었다. 처음에는 DTO의 대체 = VO 라고 생각했기에 DTO와의 장단점을 비교하는 방향으로 자료를 찾아봤다.(왜 이렇게 지식이 잡혀있었을까...)

결론부터 말하면 VO와 DTO는 사용되는 곳도 사용하는 이유도 다르다!

  • 도메인 : 말 그대로 Entity, DB 모델의 핵심
  • DTO(Data Transfer Object) : 데이터의 전달을 목적으로 사용되는 객체
  • VO(Value Object) : 도메인의 특정 속성들을 내부적으로 관리하기 위한 객체, 캡슐화를 통해 불변성을 높이는 방식

⇒ 도메인과 DTO와 VO의 역할을 한번에 정리했다. VO의 핵심은 객체의 캡슐화를 더해 결합도는 낮추고 응집도는 높이는데 있다.

정보처리기사를 공부하며 응집도, 결합도 얘기는 한번쯤 들어봤을텐데 여기서 그 효과가 나온다.

 

교과서적인 내용만 가지고는 이해가 어렵다(가능했으면 모두가 서울대였겠지) 그러니 예시를 통해 살펴보자!

VO를 사용하지 않은 경우

안티패턴 - 서비스에서 모든 로직 처리

아래는 쇼핑몰에서 흔히 가지는 회원 등급 전략을 서비스 로직에서 구현한 코드이다.

회원 등급은 회원의 구매 데이터를 바탕으로 결정되고 포인트 적립의 차등을 위해 존재한다. 이를 아래 코드처럼 서비스 로직에서 구현할 수 있지만, 이럴경우 코드간 결합도가 어느정도 높아지게 된다.

 

어느정도라는 말이 추상적이긴한데 눈치껏 알아듣길 바란다. 코드의 응집도와 결합도는 절대적인 기준치가 있는 건 아니므로 여기서는 결합도는 낮추고 응집도는 높이는 패턴이 존재한다는 것만 알아두자.

@Service
public class UserService {

    public void processBookPurchase(String userId, int bookCount, BigDecimal amount) {
        User user = userRepository.findById(userId);

        // 서비스에 비즈니스 로직이 다 몰려있음 (안티패턴)
        LocalDate thirtyDaysAgo = LocalDate.now().minusDays(30);
        List<Purchase> recentPurchases = purchaseRepository
            .findByUserIdAndDateAfter(userId, thirtyDaysAgo);

        int totalBooks = recentPurchases.stream()
            .mapToInt(Purchase::getBookCount)
            .sum() + bookCount;

        // 등급 계산 로직
        String currentGrade = user.getGrade();
        String newGrade = currentGrade;

        if (totalBooks >= 20 && "BRONZE".equals(currentGrade)) {
            newGrade = "SILVER";
        } else if (totalBooks >= 50 && "SILVER".equals(currentGrade)) {
            newGrade = "GOLD";
        }

        // 포인트 계산 로직
        BigDecimal pointRate = BigDecimal.ZERO;
        if ("BRONZE".equals(newGrade)) {
            pointRate = new BigDecimal("0.01"); // 1%
        } else if ("SILVER".equals(newGrade)) {
            pointRate = new BigDecimal("0.03"); // 3%
        } else if ("GOLD".equals(newGrade)) {
            pointRate = new BigDecimal("0.05"); // 5%
        }

        BigDecimal earnedPoints = amount.multiply(pointRate);

        user.setGrade(newGrade);
        user.setPoints(user.getPoints().add(earnedPoints));
        userRepository.save(user);
    }
}

VO를 활용한 경우

개선된 패턴 - 도메인 객체 활용

위에서 본 안티 패턴에 대해 VO 객체를 도입해서 회원 등급과 포인트 계산을 도메인 내에서 해결했다. 서비스 로직에서 해당 부분에 대한 계산을 진행하지 않으므로 또 다른 회원 등급이나 포인트 계산이 필요한 로직에서 이 부분을 계산할 필요가 없다. 그러므로 코드의 응집도는 높이고 결합도는 낮춘 코드가 완성됐다. 이것이 더욱 OOP 원칙을 준수할 수 있는 하나의 방법이다.

// 회원 등급 VO
public class MemberGrade {
    private final String grade;

    public MemberGrade(String grade) {
        validateGrade(grade);
        this.grade = grade;
    }

    public MemberGrade upgradeIfEligible(int totalBooksInPeriod) {
        if (totalBooksInPeriod >= 50 && isSilver()) {
            return new MemberGrade("GOLD");
        } else if (totalBooksInPeriod >= 20 && isBronze()) {
            return new MemberGrade("SILVER");
        }
        return this; // 등급 변화 없음
    }

    public BigDecimal getPointRate() {
        switch (grade) {
            case "BRONZE": return new BigDecimal("0.01");
            case "SILVER": return new BigDecimal("0.03");
            case "GOLD": return new BigDecimal("0.05");
            default: throw new IllegalStateException("Unknown grade: " + grade);
        }
    }

    private boolean isBronze() { return "BRONZE".equals(grade); }
    private boolean isSilver() { return "SILVER".equals(grade); }
    // ...
}

// 포인트 VO
public class Points {
    private final BigDecimal value;

    public Points(BigDecimal value) {
        if (value.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Points cannot be negative");
        }
        this.value = value;
    }

    public Points add(Points other) {
        return new Points(this.value.add(other.value));
    }

    public Points calculateEarned(BigDecimal purchaseAmount, MemberGrade grade) {
        BigDecimal earned = purchaseAmount.multiply(grade.getPointRate());
        return new Points(earned);
    }
    // ...
}

// 구매 이력 분석을 위한 도메인 서비스
@Component
public class PurchaseAnalyzer {

    public int calculateBooksInPeriod(List<Purchase> purchases, int days) {
        LocalDate cutoffDate = LocalDate.now().minusDays(days);
        return purchases.stream()
            .filter(p -> p.getPurchaseDate().isAfter(cutoffDate))
            .mapToInt(Purchase::getBookCount)
            .sum();
    }
}

// User 엔티티 - 도메인 로직 포함
@Entity
public class User {
    @Id
    private String userId;
    private String nickname;
    private MemberGrade grade;
    private Points points;

    public void processPurchase(int bookCount, BigDecimal amount, int recentBookCount) {
        // 도메인 객체가 비즈니스 로직을 담당
        MemberGrade newGrade = this.grade.upgradeIfEligible(recentBookCount + bookCount);
        Points earnedPoints = Points.calculateEarned(amount, newGrade);

        this.grade = newGrade;
        this.points = this.points.add(earnedPoints);
    }
}

개선된 서비스 - 얇은 서비스 레이어

@Service
public class UserService {

    public void processBookPurchase(String userId, int bookCount, BigDecimal amount) {
        User user = userRepository.findById(userId);
        List<Purchase> recentPurchases = purchaseRepository
            .findByUserIdAndDateAfter(userId, LocalDate.now().minusDays(30));

        int recentBookCount = purchaseAnalyzer.calculateBooksInPeriod(recentPurchases, 30);

        // 도메인 객체에 위임 - 서비스는 orchestration만
        user.processPurchase(bookCount, amount, recentBookCount);

        userRepository.save(user);
    }
}

장점

  1. 단일 책임 원칙: 각 객체가 자신의 책임만 가짐
  2. 응집도: 관련된 데이터와 로직이 한 곳에 모임
  3. 테스트 용이성: 각 도메인 로직을 독립적으로 테스트 가능
  4. 재사용성: MemberGrade, Points는 다른 곳에서도 사용 가능
  5. 유지보수성: 등급 정책 변경 시 MemberGrade만 수정하면 됨

⇒ 서비스 로직의 부담을 줄이고 무엇보다 응집도가 높아지고 결합도는 낮추는 패턴이 완성된 것을 확인할 수 있다.

 

이제 lombok을 접목한 예시까지 확인해보자.

위에 예시 코드를 보면 도메인의 필드에 대해 VO 객체가 적용된 것을 볼 수 있다. 그러면 lombok을 어떻게 활용할 수 있을까라는 고민이 든다. 너무 긴 스프링부트 코드를 줄여주어 개발 편의성을 증대시킨 lombok은 스프링 개발자 입장에서는 포기할 수 없다.

1. DTO에서는 Lombok 자유롭게 사용

// DTO - Lombok 마음껏 사용
@Data  // getter, setter 모두 생성
@NoArgsConstructor
@AllArgsConstructor
public class UserCreateRequestDTO {
    private String userId;
    private String nickname;
    private String email;
    private String password;
}

@Data
public class UserResponseDTO {
    private String userId;
    private String nickname;
    private String email;
    private String grade;
    private BigDecimal points;
}

2. VO에서는 Lombok 제한적 사용

// VO - getter만 생성, setter 제외
@Getter
@EqualsAndHashCode
@AllArgsConstructor
public class MemberGrade {
    private final String grade;

    // setter는 생성되지 않음
    // 비즈니스 메서드는 수동으로 작성
    public MemberGrade upgradeIfEligible(int totalBooks) {
        if (totalBooks >= 20 && isBronze()) {
            return new MemberGrade("SILVER");
        }
        return this;
    }

    private boolean isBronze() {
        return "BRONZE".equals(grade);
    }
}

@Getter
@EqualsAndHashCode
@AllArgsConstructor
public class Points {
    private final BigDecimal value;

    public Points add(Points other) {
        return new Points(this.value.add(other.value));
    }
}

3. 도메인 엔티티에서는 Lombok 신중하게 사용

// 도메인 엔티티 - getter만 생성, setter 제외
@Entity
@Getter  // getter만 생성
@NoArgsConstructor(access = AccessLevel.PROTECTED)  // JPA용 기본 생성자
public class User {
    @Id
    private String userId;
    private String nickname;
    private MemberGrade grade;
    private Points points;

    // 생성자는 수동으로 작성 (유효성 검증 포함)
    public User(String userId, String nickname, MemberGrade grade, Points points) {
        validateUserId(userId);
        validateNickname(nickname);
        this.userId = userId;
        this.nickname = nickname;
        this.grade = grade;
        this.points = points;
    }

    // 비즈니스 메서드는 수동으로 작성
    public void changeNickname(String newNickname) {
        validateNickname(newNickname);
        this.nickname = newNickname;
    }

    public void processPurchase(int bookCount, BigDecimal amount, int recentBookCount) {
        MemberGrade newGrade = this.grade.upgradeIfEligible(recentBookCount + bookCount);
        Points earnedPoints = this.points.calculateEarned(amount, newGrade);

        this.grade = newGrade;
        this.points = this.points.add(earnedPoints);
    }

    private void validateNickname(String nickname) {
        if (nickname == null || nickname.trim().isEmpty()) {
            throw new IllegalArgumentException("Nickname cannot be empty");
        }
    }
}

4. 더 실용적인 접근 - 커스텀 Lombok 조합

// 커스텀 어노테이션 조합으로 실용성 확보
@Getter
@ToString
@EqualsAndHashCode
@RequiredArgsConstructor  // final 필드만으로 생성자 생성
public class User {
    @NonNull
    private final String userId;

    private String nickname;  // final 아님 (변경 가능)
    private MemberGrade grade;
    private Points points;

    // setter 없이 비즈니스 메서드만
    public void changeNickname(String newNickname) {
        validateNickname(newNickname);
        this.nickname = newNickname;
    }
}

5. 하이브리드 접근법

// 실무에서 많이 사용하는 절충안
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class User {
    @Id
    private String userId;
    private String nickname;
    private String grade;  // 단순한 건 String으로
    private BigDecimal points;  // 단순한 건 기본 타입으로

    // 중요한 비즈니스 로직만 메서드로
    public void changeNickname(String newNickname) {
        validateNickname(newNickname);
        this.nickname = newNickname;
    }

    public void upgradeGrade(int totalBooks) {
        if (totalBooks >= 20 && "BRONZE".equals(this.grade)) {
            this.grade = "SILVER";
        } else if (totalBooks >= 50 && "SILVER".equals(this.grade)) {
            this.grade = "GOLD";
        }
    }

    // 간단한 변경은 setter 허용 (팀 컨벤션에 따라)
    public void updatePoints(BigDecimal newPoints) {
        this.points = newPoints;
    }
}

정리 - 레이어별 Lombok 사용 전략

레이어 Lombok 사용 이유

DTO @Data 자유롭게 데이터 전송이 목적, 비즈니스 로직 없음
VO @Getter, @EqualsAndHashCode 만 불변성 유지, 비즈니스 로직 수동 작성
Entity @Getter 위주, setter 제한적 중요한 로직은 수동, 단순한 건 Lombok

핵심은 "중요도에 따른 선택적 사용" 입니다. Lombok을 완전히 포기할 필요 없이, 각 레이어의 특성에 맞게 적절히 활용하면 됩니다!

 

이상으로 클로드 선생님의 예시 코드를 살펴봤다.

간단하게 정리하면

  • DTO에서는 lombok을 제한없이 사용 가능하다.
  • VO와 도메인에서는 제한적으로 적용한다.

lombok에 대해 제한사항이 존재한다는 것 자체가 아쉬운 점이긴 하다.

또한, 이 외에 단점도 명확히 존재한다.

  1. 프로젝트 볼륨이 커지게되어 오버엔지니어링이 될 수도 있다.
  2. 어찌됐든 lombok 사용이 제한적으로 줄어들게 되어 생산성은 떨어질 수 있다(생산성과 유지보수성을 교환하는 느낌)

결론

늘 그렇듯이 프로그래밍에는 정답이 없는 것 같다. 분명히 VO의 장점과 더 좋은 객체지향 기법을 소개하기 위한 목적으로 글을 작성했지만 명확히 어떤 단점이 존재하는지도 보여주게 되었다.

그러니 기술 스택과 패턴의 도입은 팀원들과의 회의 그리고 현재 프로젝트의 규모와 프로젝트의 목적 여부를 고려해서 결정해야 한다.

 

프로젝트 설계 시에 위와 같은 내부 응집 로직이 자주 등장하여 재사용성 효과가 높다면 VO의 도입을 고려해보고 그렇지 않거나 눈에 잘 보이지 않는다면 단순히 도메인으로만 진행한다.

VO를 적용하지 않더라도 util 메서드로 해당 로직의 재사용성은 대체할 수 있다.

그리고 프로젝트 하나를 장기적으로 운영해본다면 프로젝트를 확장하면서 VO의 도입을 고민해볼 수도 있으므로 너무 VO 도입에 매몰되지 않길 바란다.

 

기술과 패러다임의 도입을 위해서 프로젝트의 본질이 변하는 지 고민해보면 쉽게 결정을 내릴 수 있을 것이다.