도메인과 엔티티
도메인
소프트웨어가 동작하는 영역을 말한다. 즉, 소프트웨어를 통해 해결해야 하는 문제 영역이 도메인이다.
예를 들어 ATM에 탑재되어있는 소프트웨어의 도메인은 계좌, 은행, 카드 등이라고 할 수 있다.
Chat GPT와 함께 하는 도메인 영역이란?
도메인 로직
도메인 영역에서 해결해야 하는 구체적인 문제를 해결하기 위한 논리. 당연히 프로그램의 소스 코드와 마크업 코드로 구현된다.
은행의 예시를 들면, “송금을 하기 위해서는 송금할 금액보다 계좌의 잔액이 많거나 같아야 하고, 송금을 하는 계좌에서 잔액이 줄어들고 그만큼 송금 대상이 되는 계좌의 잔액이 증가해야 한다. 이 일련의 과정은 부분적으로 완료될 수 없고 모두 성공하거나 실패해야 한다.” 라는 논리가 도메인 로직의 일부가 될 것이다. 이를 코드로 표현하면 대충 아래와 같을 것이다.
@Transactional
public void sendMoney(Account from, Account to, int money) {
if(from.remain < money) {
throw new RuntimeException("잔액 부족");
}
from.minus(money);
to.plus(money);
}
Java
복사
엔티티
관계형 데이터베이스의 엔티티를 자바의 클래스로 표현했을 때, 그 클래스를 엔티티 클래스라고 하고, 그 인스턴스를 엔티티라 한다.
즉, 아래와 같이 JPA를 사용하는 상황에서 엔티티는 다음과 같이 생겼다. 아래 코드를 명확히 이해할 필요는 없다. 중요한 것은 아래 코드는 관계형 데이터베이스의 엔티티를 추상화한 자바 클래스라는 것이다.
package kr.co.goalkeeper.api.model.entity.goal;
import kr.co.goalkeeper.api.model.entity.Category;
import kr.co.goalkeeper.api.model.request.ManyTimeGoalRequest;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.BatchSize;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@Entity
@DiscriminatorValue("ManyTimeGoal")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ManyTimeGoal extends Goal {
@Column
@NotNull
private int successCount;
@Column
@NotNull
private int failCount;
@BatchSize(size = 100)
@OneToMany(mappedBy = "manyTimeGoal",cascade = CascadeType.ALL)
private List<ManyTimeGoalCertDate> certDates;
@Builder
private ManyTimeGoal(long id, User user, String title, String content, int point, GoalState goalState, Reward reward, Category category, int successCount, List<ManyTimeGoalCertDate> certDates) {
super(id, user, title, content, point, goalState, reward, category);
this.successCount = successCount;
this.certDates = certDates;
}
public ManyTimeGoal(ManyTimeGoalRequest request,User user){
this.endDate = request.getEndDate();
this.goalState = GoalState.ONGOING;
this.category = new Category(request.getCategoryType());
this.point = request.getPoint();
this.content = request.getContent();
this.title = request.getTitle();
this.reward = request.getReward();
this.startDate = LocalDate.now();
this.certDates = new ArrayList<>();
Collections.sort(request.getCertDates());
for (LocalDate certDate: request.getCertDates()) {
certDates.add(new ManyTimeGoalCertDate(this,certDate));
}
this.user = user;
minusUserPoint();
}
public void successCertification(){
successCount++;
int maxSuccessCount = certDates.size();
if(successCount>Math.round(maxSuccessCount*0.7f) && goalState!=GoalState.SUCCESS){
success();
if(successCount>Math.round(maxSuccessCount*0.8f)){ // 70% 달성이랑 80% 달성이 동시에 만족하는 경우
success80();
}
}else if(successCount>Math.round(maxSuccessCount*0.8f)){
success80();
if(successCount == maxSuccessCount){ // 80% 달성이 동시에 100% 달성인 경우
success100();
}
}else if(successCount == maxSuccessCount){
success100();
}
}
private void success80(){
goalState = GoalState.SUCCESS;
int rewardPoint = (int) Math.round(point *0.1);
user.plusPoint(rewardPoint,category.getCategoryType());
}
private void success100(){
goalState = GoalState.SUCCESS;
int rewardPoint = (int) Math.round(point *0.3);
user.plusPoint(rewardPoint,category.getCategoryType());
}
public void failCertification(){
failCount++;
int maxSuccessCount = certDates.size();
if(maxSuccessCount - failCount < Math.round(maxSuccessCount*0.7f)){
failFromOngoing();
}
}
}
Java
복사
도메인 로직을 엔티티에서 분리하는 것이 더 좋은 설계인가?
DDD 개념까지 가지 않더라도, 도메인 로직이 엔티티에 작성되어있다면 데이터베이스의 종류가 변경 혹은 추가되거나, 데이터베이스와 통신하는 기술이 변경되는 경우 문제가 되는 것은 자명하다. 따라서 분리하는 것이 보다 더 객체지향적인 설계이고, 유연한 설계인 것은 자명하다. 그런데…
과연 가성비 있는 선택인가?
도메인 로직을 분리하는 대가
많은 코드의 추가
도메인 로직을 담을 클래스가 새로 필요하다.
당연한 이야기지만, 도메인 로직을 엔티티로부터 분리한다면 이를 담을 클래스를 새로 추가해야 하므로 새로운 클래스가 추가되는 것이다. 앞선 코드를 분리한다면 다음과 비슷한 코드가 추가될 것이다.
package kr.co.goalkeeper.api.model.domain.goal;
// 필요한 생성자나 getter setter를 위한 lombok 어노테이션을 추가해야 한다.
public class ManyTimeGoal extends Goal { // 도메인 로직을 상속을 이용해 표현한다면 더 고려할 것이 많다.
// 필요한 필드들을 추가해야한다.
public void successCertification(){
// 도메인 로직을 구현한 공개 API
...
success80();
...
success100();
...
}
private void success80(){
// 상세 구현
}
private void success100(){
// 상세 구현
}
public void failCertification(){
// 도메인 로직을 구현한 공개 API
}
}
Java
복사
도메인 로직을 엔티티와 분리하는 것이 목적이므로, 새로 만든 도메인 클래스가 엔티티 클래스를 필드나 매개변수로 가져선 안된다.
도메인 클래스와 엔티티 클래스 사이의 통신을 위한 코드가 추가되어야 한다.
도메인 클래스를 통해 도메인 로직을 수행했다면, 그 결과를 데이터베이스에 반영해야 한다. 따라서 개념적으로 연관된 엔티티 인스턴스를 생성 혹은 수정해야 한다. 그런데, 도메인 클래스와 엔티티 클래스는 서로를 알면 안되기 때문에, 둘의 존재를 모두 아는 Service 레이어에서 도메인 인스턴스와 엔티티 인스턴스 사이의 변환을 수행해야 한다. 예를 들면 아래와 같다.
public GoalService {
@Transactional
public void doSomething(int goalId) {
GoalEntity entity = GoalRepository.findById(goalId);
GoalDomain domain = new GoalDomain(entity.data);
domain.doLogic();
entity.update(domain.data);
}
}
Java
복사
ORM의 기능을 제대로 활용하기 힘들다
사실 이 부분은 도메인과 엔티티 사이의 변환 로직을 잘 수행한다면, 어느정도 극복이 가능하지만, 그만큼 코드가 늘어날 것이다.
만약, 도메인 인스턴스를 생성할 때 엔티티의 모든 필드를 사용한다면, 지연 로딩의 장점을 누릴 수 없을 것이다.
이를 해결하기 위해서는 필연적으로 엔티티에서 도메인 로직을 분리하면 하나 이상의 도메인 클래스가 추가되는 것을 의미한다. 특정 도메인 로직에서는 지연 로딩 대상인 데이터를 사용하지 않고, 다른 도메인 로직에서 사용할 경우 이 두 로직을 아예 다른 클래스로 분리하는 것이 더 객체지향스러워진다.
만약, 도메인 인스턴스를 이용해 매번 새로운 엔티티를 생성한다면, 변경 감지의 장점을 누릴 수 없을 것이다.
변경 감지의 동작 원리 자체가, 최초 조회 시점을 백업해두고, 커밋 전에 엔티티의 상태를 비교해 다르면 update 쿼리를 수행하는 것인데, 기존 엔티티 객체는 그대로고 새로운 엔티티 객체로 변화를 표현한다면 update 쿼리는 수행되지 않을 것이다. 즉, 수동으로 레퍼지토리의 save 메서드를 호출해야 한다.
대가가 생각보다 크다
우리는 미래에 소프트웨어의 요구사항이 어떻게 변할지 알 수 없다. 오직 예측만 가능하다.
그렇기 때문에 당장 기능의 구현에 아무런 문제도 없고, 당장 얻을 수 있는 이득도 없는데 미래를 섣부르게 예측하여 개발의 생산 비용을 늘리는 것이 합리적인 선택이 되기는 힘들다.
리팩토링의 시점
사실 이 고민은 리팩토링 시점과도 밀접한 연관이 있다. 일반적으로 엔티티에 도메인 로직을 작성하기 때문에 이를 분리하는 것은 리팩토링이다. 따라서, 리팩토링을 언제 하는지 결정하는 기준이 도메인 로직을 엔티티로부터 분리하는 것을 선택하는 근거가 될 수 있다.
아주 유명한 책 리팩토링의 저자이자, 소프트웨어 거장인 마틴 파울러의 강연에서 리팩토링의 본질적인 이유가 나온다.
리팩토링을 하는 이유는 경제적인 이유다. 개발자들이 새로운 기능을 추가할 때 더 빠르게 할 수 있도록 하기 위해 리팩토링을 하는 것이다.
결국, 엔티티와 도메인 로직을 분리하는 시점은 그것이 새로운 기능을 추가할 때 더 유리해진다고 판단되는 때 하는 것이다.
결론
도메인 로직과 엔티티를 분리하는 것은 분명히 객체지향적인 생각이지만, 그 시점은 기능을 추가하기 힘들어질 때 하는 것이 좋다.