들어가면서
우테코 레벨 2에 들어와 DB를 프로젝트에 도입하게 된 이래로 지금까지 도메인을 설계하면서, 도메인 객체에서 ID를 갖는 것이 당연하다고 생각해왔다. 여태까지는 이렇다 할 복잡한 비즈니스 로직이 없었고 간단한 검증만을 필요로 할 뿐 기본적인 CRUD 기능 구현이 주된 목표였기에 도메인 설계 시 상태값으로 ID를 가지는 것이 자연스러웠다.
하지만 상대적으로 복잡한 비즈니스 로직을 가지는 지하철 미션을 진행하면서, 도메인 객체에 ID를 부여하는 것에 대해서 다시 한 번 생각하게 되었다. 다음과 같이 노선의 역과 역사이의 구간 정보를 나타내는 Section이라는 객체를 설계하고 ID를 부여했다.
public class Section {
private final Long id;
private final Station upward;
private final Station downward;
private final Distance distance;
private final Line line;
public Section(Long id, Station upward, Station downward, Distance distance, Line line) {
validateCorrectStationInputs(upward, downward);
validateSameStations(upward, downward);
this.id = id;
this.upward = upward;
this.downward = downward;
this.distance = distance;
this.line = line;
}
private void validateCorrectStationInputs(Station upward, Station downward) {
if (upward == null || downward == null) {
throw new IllegalArgumentException("[ERROR] 구간을 구성할 역이 입력되지 않았습니다.");
}
}
private void validateSameStations(Station upward, Station downward) {
if (upward.equals(downward)) {
throw new IllegalArgumentException("[ERROR] 구간을 구성하는 역은 동일한 역일 수 없습니다.");
}
}
// 구간을 생성, 삭제하는 비즈니스 로직...
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Section section = (Section) o;
return Objects.equals(id, section.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
생성된 Section들에 대해서 영속성을 부여하고 나중에도 해당 데이터를 조회할 수 있도록 했어야 했기 때문이다. 그런데 새로운 역을 노선에 등록하는 비즈니스 로직을 구현하는 도중 ID가 없는 Section객체, 즉 불완전한 객체가 생성되는 문제가 발생했다. 이러한 상황이 무조건적으로 문제가 되는 것은 아니지만 ID가 null인 Entity가 생성됨으로 인해 다음과 같은 문제를 겪었다.
public class Sections {
private final Map<Station, List<Section>> adjacentStations;
public Sections(Map<Station, List<Section>> adjacentStations) {
this.adjacentStations = new HashMap<>(adjacentStations);
}
// Section을 추가, 삭제하는 비즈니스 로직...
}
당시 외부 라이브러리인 JGrapht를 쉽게 사용하기 위해서 노선의 구간들을 graph 형태로 관리하는 방식을 채택했었다. 역을 노선에 추가하는 로직 상 중복되는 Section이 필연적으로 남아있게 되어서 새로운 Section 객체를 생성한 이후에 중복 제거해주는 작업이 필요했다. 이 때 자바의 stream.distinct()와 같은 API를 사용해서 해당 작업을 수행하려 했다. 하지만 영속화 과정을 거치지 않아서 ID가 null인 Section들이 예상한대로 동작하지 않는 문제가 발생했다. Entity는 부여받은 ID만으로 동등성 비교가 가능하도록 equals & hashcode 메서드를 재정의해줘야만 한다. 그렇기 때문에 ID가 null 상태인 Section Entity들을 포함해서 자바 API를 사용해 비즈니스 로직을 수행하는 것은 불가능했다.
이러한 상황에서 왜 이러한 문제를 겪게 되었는지, 어떤 것이 문제였는지 정리하고 해당 과정에서 정립하게된 Domain, Entity, VO에 대한 스스로의 정의를 정리하고자 포스팅을 작성하려 한다.
Domain, Entity, Vo에 대한 개인적인 정의
도메인 : 우리가 해결하고자 하는 문제영역
- 문제 영역을 클래스화한 것이라면 모두 도메인에 속한다.
- VO, Entity, Domain Service, POJO Domain 객체 등이 문제 영역을 클래스화한 것이라면 모두 도메인에 속할 수 있다.
엔티티 : 식별자를 가지고 있는 개체
- 여기서 말하는 식별자란 DB의 ID값만을 의미하는 것은 아니다. 비즈니스적으로 어떤 도메인 개체를 해석했을 때 두 도메인 개체가 다르다라고 판단할 수 있게 해주는 근거가 식별자이다.
- 엔티티를 사용해야 하는 시점은 해당 객체가 어플리케이션의 전체 맥락에서 고유한 정체성을 부여받아서 유지하는 경우이다.
- 엔티티의 일부 상태 값이 변경된다고 하더라도 식별자가 유지되는 한 해당 객체의 고유 정체성이 유지되어야 한다.
- 비즈니스 로직을 포함하는 도메인 엔티티 / DB(영속화) 처리를 위한 영속성 엔티티로 나뉘어 질 수 있다.
VO : 특정한 값을 표현하기 위한 객체로 별도의 식별자를 가지지 않는다.
- 객체 고유의 정체성을 유지하기 위한 식별자를 가지지 않으므로 값을 표현하는 프로퍼티의 일부가 변경된다면 아예 다른 객체가 된다.
- 그렇기 때문에 한 번 생성된 값 객체는 불변 객체로 정의되어 처음 나타내고자 했던 값이 변경되지 않음을 보장해야 한다.
- 만약 프로퍼티가 변경되어야 한다면 새로운 값을 표현하는 VO가 생성되어야 한다.
어떤 경우에 Entity와 VO를 분리해야 하는가?
어플리케이션에서 제공하는 서비스의 Context에 따라 결정해야 한다.
실제로 어플리케이션 내에서 객체를 사용하는 Context에 따라 Entity로 사용되던 것이 VO로 설계될 수 있고, VO로 사용되던 것이 Entity로 설계될 수 있다.
예를 들어 location은 경도와 위도로 이루어져 있으며 경도와 위도가 동일하면 두 location은 동일하다고 판단되어야 하기에 이는 보통 VO로서 사용된다. 하지만 만약 지역 기반 체크인 서비스와 같은 어플리케이션에서 모든 사용자의 위치가 시간에 따라 구분되어야 한다면 어떻게 될까? 한 시간 전의 location과 현재의 location의 경도와 위도 값은 동일하다고 하더라도 두 객체는 서로 구별되어야 한다. 이는 location 객체에 Identifier를 부여해서 사용해야 하는 맥락이 되었기에 location은 Entity가 된 것이라고 말할 수 있다.
반대로 Person은 통념적으로 Entity로서 사용된다. 하지만 화상 감지 기반 보안 시스템에서 사용하는 Person이라는 객체는 단지 보안 구역의 한 부분에 경보를 발생시키는 트리거에 불과하다. 이 때 Person 객체는 어떤 특정한 사람의 정보를 담는 것이 아니다. 따라서 보안 구역의 동일한 부분에 경보를 발생시킨 Person은 어떤 이름과 나이를 가졌건 동일한 Person이다. 이러한 Context에서는 Person에게 Identifier를 부여할 필요가 없기에 VO로 사용되는 것이라고 말할 수 있다.
이렇듯 어플리케이션 내에서 올바르게 현실 세계의 개념을 모델링하기 위해 Entity와 VO를 잘 구분해서 사용해야 한다. 얼핏 봤을 때 이건 Identity가 있는 것 같은데? 라고 생각된다고 해서 반드시 해당 객체가 Entity가 되어야만 하는 것은 아니다. 해당 객체를 불변 VO로 설계했을 때 원치 않는 사이드 이펙트를 어플리케이션 내에서 유발할 가능성이 있다면 Identifier를 부여하고 Entity로서 설계하면 된다. 또한 한 번 정해진 값이 이후에 변경되면 곤란한 Location과 같은 경우 VO로 설계하는 것이 적절할 것이다.
결론
결국은 비즈니스 로직이다. 서비스 맥락에 따라 유연하게 대처하는 것이 핵심이라고 할 수 있다. 여기까지 정리하고 다시 지하철 미션으로 돌아가보자. 앞에서 언급했던 Section들은 영속화되었다가 이후에 노선 조회시 해당 정보를 같이 반환해야 하기에 ID가 부여되어 Entity로서 관리되어야만 한다. 하지만 다른 Section들과 비교했을 때 중복되는 상태 값을 가지는지 확인해야 하는 상황에서는 Section의 ID를 제외한 나머지 상태 값들만을 가지고 비교되어야 한다. 이는 Entity로서의 Section이 아니라 VO로서의 Section이 필요한 상황이라고 얘기할 수 있다. 즉, Entity와 VO로서의 기능을 단일 객체에게 모두 부여하려고 했다는 것이 문제상황이었다고 정리할 수 있다. 따라서 역을 추가하고 이를 검증하는 비즈니스 로직을 수행하기 위해 사용되는 VO로서의 Section을, Entity로서의 Section과 분리해야 하는 당위성을 도출해낼 수 있다.
해당 포스팅에서는 Entity와 VO를 분리했을 때 더 유연하게 비즈니스 로직을 수행할 수 있는 경우를 예를 들어 정리했다. 하지만 굳이 Entity와 VO를 구분할 필요가 없는 경우도 분명히 있을 것이다. 이번의 경우에도 역시 정답은 없다. 제공하고자 하는 서비스의 Context를 고려하여 더 비즈니스 로직을 직관적이고 효율적으로 구현할 수 있는 방향으로 결정하면 될 것이다.
Reference
https://culttt.com/2014/04/30/difference-entities-value-objects/
'우아한테크코스 > 학습 정리' 카테고리의 다른 글
엔티티 간 상속관계를 DB 모델로 구현하기 위해 Join 전략을 선택하는 이유에 대한 고찰 (0) | 2023.07.23 |
---|---|
브랜치 전략에서 squash and merge 방식 사용에 대한 고찰 (0) | 2023.07.16 |
스프링 @Transactional(readOnly=true)에 관한 간단한 고찰 (1) | 2023.06.04 |
스프링의 예외처리 과정 및 처리 방법에 대하여 (2) (2) | 2023.05.12 |
스프링의 예외처리 과정 및 처리 방법에 대하여 (1) (0) | 2023.05.12 |