
VO(Value Object)란 무엇일까?
사실 VO에 관련된 글은 구글에 조금만 검색해봐도 많이 노출되고 있다.
하지만, 개발이 늘 그렇듯 읽기만 해서는 정말 이해가 되지 않는다.
그래서 회사에서 받은 코드 리뷰를 바탕으로 간략하게 VO 를 이해한 이야기를 정리해보고자 한다.
이전에 Fixture Monkey 관련 포스팅에도 적었지만, 객체를 생성하는 방법에는 대표적으로 3가지 방법이 있다.
이때, 나는 상황에 따라 위 방법들을 취사 선택 하는 편이다.
Service layer 단에서 Domain 을 Response Dto 로 변환할 때, 이때는 정적 팩토리 메서드를 주로 이용하는 편이다.
내가 체감한 정적 팩토리 메서드를 사용하는 이유는 다음과 같다.
- 캡슐화(Encapsulization)가 가능하다.
우리는 생각보다 간단한 도메인이 아닌, 복잡한 도메인을 마주하는 경우가 많다.
이때, 복잡한 도메인은 많은 정보를 내포해야 하기에 비례하여 다량의 필드를 가지게 된다.
그리고 곧 해당 도메인의 각 필드가 무엇을 의미하는지 - 내부 구현을 알아보게 될 것이다.
하지만, 정적 팩토리 메서드를 사용할 경우 자유롭게 형변환이 가능하다.
public static WorkspaceResponse from(
Workspace workspace // <-- 간편하게 필요한 데이터만 파라미터로 넘기고, 인수로 받으면 된다.
) {
return new WorkspaceResponse(
workspace.getWorkspaceId(),
workspace.getWorkspaceName(),
..
}
- 메서드명을 통해 의미를 담을 수 있다.
이미 new 연산자는 public 한 객체 생성 연산자이기 때문에, '메서드명에 의미를 담는다는게 무슨 뜻이지? 그게 중요한가?' 라는 생각을 가질 수 있다.
하지만, 사실 처음 Java (또는 프로그래밍) 를 공부했을 때를 떠올려보자.
new 연산자가 바로 이해가 되었었나?
사실 나의 경우는 와닿지 않았었다, 당장 객체의 개념부터 아리송했었다.
그냥 객체를 생성하기 위해서는 new 연산자를 사용해서, 객체 내에 정의된 모든 필드에 대한 값을 주입시켜줘야 하는구나~ 정도가 다였었다.
하지만, 정적 팩토리 메서드를 사용할 경우 메서드명 작명을 통한 의미를 내포시킬 수 있다.
예시로, Workspace 라는 객체를 생성할 경우는
public class Workspace {
private UUID workspaceId;
private String workspaceName;
..
public static createWorkspace( <-- 메서드명만 보고도, 무엇을 하려는지 알 수 있다.
..
해당 메서드를 호출하는 쪽 (ex. Service layer) 에선 메서드명만 봐도 무엇을 하려는지 알 수 있다는 건 코드 이해에 상당한 큰 이해가 된다.
큰 그림 (A -> B -> C -> .. )을 먼저 이해하고, 작은 그림 (A의 내부 구현이 어떻게 구성되어 있는지, 필드 중 a 는 무엇을 의미하는지, .. )을 이해하는 구조로 이해하기 때문이다.
그리고 소소한 장점으로는
관례가 있는 메서드명 작명 방식
을 꼽을 수 있다.
파라미터 (또는 인수) 하나를 넘길 경우에는 from,
두 개 이상을 넘길 경우에는 of,
새로운 객체를 생성할 경우 newInstance, ..
등등이 있다.
나는 코드에는 일관성이 있다면 context 없이 바로 생각이 유기적으로 연결되면서 빠르게 이해할 수 있었던 것 같기에, 관례를 작은 장점으로 생각했다.
큰 도메인은 많은 정보를 담기 위해 다량의 필드를 가지고 있고, 각 필드들 하나하나가 작지 않은 가중치를 가지고 있다.
이때 new 연산자를 사용하게 될 경우, 강제적으로 모든 필드들에 대해 값을 주입해줘야 하기 때문에 아주 용이하다.
실제로 나는 간편하다는 장점으로 @Builder 를 자주 애용했었는데, 필드가 수십 개에 달하는 도메인의 필드들을 누락시켜본 경험이 있다..
코드 리뷰에서도 종종 피드백을 받을 수 있었던 - 이러한 휴먼 에러 🤖 를 방지하기 위해서는 new 연산자를 사용하는게 좋은 것 같다.
개발을 하다보면, 부피가 큰 도메인에 대해서도 특정 필드들을 누락시켜야 하는 경우가 있다.
아직 구현되지 않은 기능에 대한 값을 담아야 하는 필드나 테스트 코드를 작성할 때 해당 검증(ex. BDD Mockito 의 then 절) 에서 해당 객체를 식별하는 값을 담을 필드만 필요할 경우를 예시로 들 수 있겠다.
물론 후자의 경우(= 테스트 코드 작성 시 .. ), 특히 @Builder 를 이용하여 간편하게 객체를 생성할 수 있다는 장점을 누리기 위해 - 의도적으로 진행하는 action 에 대한 장점인 것 같다.
현재 (2025.06.) 는 팀원 분이 개발한 Fixture 라는 postfix 를 가진 클래스에 내부적으로 builder 패턴 + RandomGenerator 를 통해 값을 넣어주는 메서드를 정의하여 사용하고 있다 ✨
따라서 한 번 만들어두면, 메서드를 호출해서 부피가 큰 도메인도 손쉽고 + random 값 주입을 통한 다양한 예외 케이스를 고려할 수 있다는 장점을 누리고 있다. (그래서 약간 퇴색된 장점이라고 생각했다.)
요즘 실무에서 Admin 관련 업무를 주로 진행하고 있다.
이때, Admin 이란 무엇인가 - 게임으로 비유하자면 운영자와 같은 개념을 가진 도메인이다.
즉 유저 관점에서 경험할 수 없는 기능도 구현이 가능한 영역이다.
대표적으로, Workspace, Super Admin, Admin 이라는 도메인이 있다고 가정해보자.
그리고 요구사항은 다음과 같다.
- Super Admin 은 모든 Admin 의 Workspace 에 대해 생성 / 삭제 / 수정 을 진행할 수 있다.
- Admin 은 본인의 Workspace 에 대해 생성 / 삭제 / 수정 을 진행할 수 있다.
이때, Admin = User 라고 생각해본다면 결국 요구사항은 다음과 같이 정리할 수 있다.
- 관리자는 모든 워크스페이스에 대해 생성 / 삭제 / 수정을 진행할 수 있다.
- 유저는 자신의 워크스페이스에 대해 생성 / 삭제 / 수정을 진행할 수 있다.
그렇다면, 비즈니스 로직에서는 이례적으로 Workspace 라는 도메인에 대해 object 를 create() 하는 경우가 2번이 존재하게 된다.
하지만 우리가 아는 객체는 적재적소에 사용되어야 하는데 - 특히, 도메인은 비즈니스 로직을 관통하는 객체이기에 어떤 객체보다 캡슐화가 중요하다.
하지만, 이 부분에서 내가 간과한 부분이 있었다 ❗️
예를 들어, Workspace 라는 도메인은 여러 하위 도메인을 가지고 있다고 가정해보자.
public class Workspace {
private UUID workspaceId;
private String workspaceName;
..
@Embedded
private WorkspaceOptions options;
..
}
public class WorkspaceOptions {
private String defaultOptions;
private String userOptions;
..
}
이때, 나는 WorkspaceOptions 와 같은 하위 도메인을 수정하는 API 를 개발 중이었다.
그리고 하위 도메인을 포함하여 Workspace 도메인의 모든 필드는 workspace 테이블의 모든 column 과 매핑이 되는 구조를 가지고 있다.
따라서, 하위 도메인만 수정을 진행하더라도 Workspace 객체에 상태 변경이 일어난다.
그리고 해당 하위 도메인은 VO(Value Object)로서 정의되어 있었다.
이때, 나는 WorkspaceOptions 값 변경을 통해 변경사항을 저장하는 식으로 진행했었다.
이쯤에서 내가 이해한 VO에 대해서 정리해보겠다.
해당 값이 변하지 않는다는 불변성을 지닌다.
즉 이는 객체가 생성 후 값이 변경되지 않는다는 것을 의미하며, 이는 곧 한 번의 transaction 내에서 보장된 객체를 사용할 수 있다.
예시로 든 Workspace 를 보면 알 수 있듯이, 해당 도메인은 여러 하위 도메인을 가지고 있다.
즉, 표현하고자 하는 도메인을 하나의 클래스에 모든 필드로 나타내는 것이 아니라 - 내부에서도 각각의 객체로 묶음으로써 구조화할 수 있다.
값을 담고 있는 객체이기 때문에, equals 및 hashCode() 를 오버라이딩해서 재정의함으로써 해당 객체가 갖고 있는 값이 동일한지를 체크한다.
그럼 문제 상황은 뭐였을까?
바로 WorkspaceOptions 를 수정함으로써 불변성을 깨트렸다는 점이다.
수정이라는 측면에서 객체 재생성이나 값을 변경한다는 점이나 별 차이가 없지만, 대상이 VO라는 측면에서는 특성을 위배했다고 볼 수 있다.
따라서, 재생성을 통해 - 한 번 생성되었을 때 담고 있는 값이 변경되지 않도록 로직을 수정했었다.
같은 팀의 책임님이랑도 이야기를 나눴던 부분이지만, 사실 하나의 transaction 내에서 명확하게 하나의 기능만 수행한다는 보장이 있으면 (불변성을 위배한다는 점을 제외하고) VO의 값을 변경해도 괜찮다.
하지만, 실무에서는 항상 유지보수성과 함께 확장성을 고려해야한다.
추후에 요구사항이 어떻게 변경할지 모르기 때문에, 우리는 이 점을 고려해서 VO의 특성을 지켜서 객체 재생성을 진행하는게 안전하다고 볼 수 있다.