Octoping의 블로그
article thumbnail

들어가기 전

주의

이 글은 초보 백엔드 개발자가 정도(正道)가 무엇인지에 대해 가르침을 받지 못해 열심히 땅바닥에 헤딩해가며 도메인 주도 개발에 대해 이해해보려 하는 슬픔의 시간의 결과물로 혼자 생각해낸 교훈입니다...

 

만약 글의 내용 중 잘못된 부분이 있다면 언제든 바로잡는 댓글을 달아주시면 정말 감사드리겠습니다.

 

 

발단

때는 2023년.. Nest와 Prisma를 사용해서 열심히 백엔드 (사이드) 프로젝트를 하고 있었다.

 

Prisma는 ORM이지만 JPA와는 다르게, 쿼리의 결과로 '엔티티의 모델 클래스'가 아니라 순수한 자바스크립트 오브젝트를 반환한다.

다시 말해, Rich Domain Model을 사용해 프로그래밍하기 위해서는 도메인 객체로서 엔티티 클래스를 활용할 수 있는 JPA와 다르게 Prisma에서는 쿼리 결과로 나온 엔티티 오브젝트를 도메인 객체로 매핑하는 매퍼를 반드시 따로 만들어주어야 한다.

 

킹영한 선생님과의 질답

김영한 선생님께서도 (JPA를 사용할 때에) 도메인 객체와 엔티티 객체를 굳이 분리하지 않는 것을 추천하셨다.

하지만 그래도 MyBatis와 같은 순수 SQL로 코딩할 때에도 도메인 객체와 엔티티를 분리해야 할 것이라 생각했기 때문에 '이것도 경험이다 '라고 생각해서 이 시련을 받아들여보기로 했다.

 

그리고 그 덕에 아무 생각 없이 JPA를 사용할 때는 미처 경험해보지 못했던 골칫거리들을 마주하게 되었다.

 

 

객체는 자유롭게 객체 그래프를 탐색할 수 있어야 한다

DEVIEW 2015 - jpa와 모던 자바 데이터 저장 기술 (by 김영한)

객체지향의 세계에서 객체는 응당 자유롭게 객체 그래프를 탐색할 수 있어야 한다.

`member.getTeam()`을 하면 Team 객체가 나와야한다는 뜻이다.

 

하지만 실행하는 SQL에 따라 탐색 범위가 결정되어버린다

// SELECT M.*, T.*
//   FROM MEMBER M
//   JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID;

member.getTeam(); // OK
member.getOrder(); // null

하지만 DB에서 SQL문을 통해 쿼리해온 결과물로는 실행한 쿼리문에 따라 탐색 가능한 범위가 결정될 수 밖에 없다.

그렇다고 Member만 조회하는 조회 메소드, Member와 Team까지만 조회하는 메소드 등을 상황에 따라 여러벌 만들어둘 수는 없는 노릇이다.

 

조회 메소드의 개수가 과다하게 늘어나는 문제도 있고, 불필요한 객체들까지 매번 불러오게 되는 성능의 문제도 있을 것이다.

 

다시 말해.. '모든 객체를 미리 로딩할 수는 없다'

 

 

불필요한 로딩은 지연하라

JPA는 이런 문제를 해결하기 위해 지연 로딩 (lazy fetch)이라는 방법을 지원한다.

 

// SELECT * FROM MEMBER WHERE ID = 1;
Member m = memberRepository.findById(1);

// SELECT * FROM TEAM WHERE T.ID = ??;
member.getTeam(); // OK

엔티티 객체를 프록시로 조회하여, 로딩할 때에 불필요한 부분은 미리 로딩해두지 않고 실제로 해당 객체에 접근할 때에 그제서야 추가 쿼리를 날려서 그 부분을 받아오는 방식이다.

 

이렇게 할 경우 불필요한 객체들까지 매번 불러오는 성능의 문제를 피할 수 있고, 객체 그래프의 탐색 범위도 무한이 될 수 있다!

(물론 이 경우 2번의 쿼리가 나가기 때문에 JOIN으로 한번에 가져오는 것보다는 당연히 느리므로 항상 우월한 방법은 아니다..!)

 

한계

하지만, 나는 기술력의 부재로 JPA와 같이 지연로딩을 직접 구현해낼 자신은 없었다..

그래서 그냥 최초 1번의 쿼리만으로 로딩하는 방법을 사용하고 싶었다. 아니, 할 수 밖에 없었다?

 

근데 그렇다면, 객체를 대체 어디까지 로딩해야 하는걸까?

 

 

정답은 애그리거트

애그리거트는 복잡한 도메인을 이해하고 관리하기 쉽게 해준다

애그리거트란 '데이터 변경의 단위로 다루는 연관 객체의 묶음'을 의미한다.

말이 어려운데, 쉽게 말해서 복잡한 도메인 요소 간의 관계들을 이해하고 관리하기 쉬운 단위로 나누는 방법이자 기준이다.

 

애그리거트 기반으로 복잡한 도메인들을 알기 쉽게 나눴다

복잡한 객체지향 세계 속에서 도메인 객체들을 이 애그리거트로 나누면 도메인을 이해하기 쉬워진다.

 

그리고 나는.. 위에서 얘기했던 문제를 해결하기 위해 객체를 로딩하는 그 기준을 애그리거트 단위로 잡기로 결심했다.

 

 

애그리거트 간 직접 참조는 문제를 야기한다

JPA를 사용하던 시절, 나는 항상 지연 로딩을 믿고 애그리거트 간의 참조를 필드를 통해 구현했다.

 

class Member {
  @Id
  private Long memberId;
  
  @ManyToOne
  @JoinColumn(name = "teamId")
  private Team team;
}

public class Team {
  @Id
  private Long teamId;
  
  @OneToMany(mappedBy = "team")
  private List<Member> teamMember;
}

Member를 불러오고 그 후에 Team이 필요할 때 접근하면 지연로딩을 통해 데이터를 불러와주니 아주 구현이 편리했다.

 

하지만 최범균님께서는 도메인 주도 개발 시작하기 책에서 이 방식의 문제를 언급해주시는데,

  • 편한 탐색 오용
  • 성능 문제
  • 확장 어려움 (like MSA)

 

그러나 이것보다 당장 내 피부에 더 와닿는 문제는 따로 있었다.

 

양방향 관계의 무한 루프를 해결할 수 없다

위의 코드처럼 양방향 연관관계를 구현했을 경우 다음과 같은 무한루프도 당연히 객체 그래프의 탐색이 가능해야 할 것이다.

member.getTeam().getMember().getTeam().getMember()

 

꼭 다른 애그리거트 간이 아니더라도 follower 과 같이 User 자신이 자신을 참조하는 경우에도 똑같다.

member.getFollowers().get(0).getFollowers().get(0).getFollowers()

 

하지만 이런 장단에 맞춰주자면 도메인 객체의 매핑 비용이 미친듯이 오르다 못해 그냥 불가능하다.

이 문제를 해결하기 위해서는 한 가지 방법 밖에 없는듯 보인다.

 

 

애그리거트 간 참조는 id로 하기

class Member {
  private MemberId id;

  // 같은 애그리거트이므로 직접 참조
  private LoginInfo loginInfo;
  private Profile profile;
  
  // 다른 애그리거트이므로 간접 참조
  private TeamId teamId;
  
  // 자기 자신도 간접 참조
  private List<MemberId> followers;
}

다른 애그리거트 루트는 id를 이용해서 참조하고, 같은 애그리거트의 객체들만 직접 참조로 연결하는 것이다.

 

이러면 애그리거트의 경계도 명확해지고, 응집도도 높여주고, 무엇보다 매핑의 난이도가 팍 줄어든다.

 

애그리거트 하나에 레포지토리 하나

 

애그리게잇 하나에 리파지토리 하나 | Popit

필자는 도메인 주도 설계 Domain-Driven Design (이하 DDD) 빌딩 블록 Building blocks[1] 으로 애플리케이션을 구현하면서 엔티티 ENTITY[2] 마다 리파지토리 REPOSITORY 를 만드는 것을 자주 보았는데 자세히 살

www.popit.kr

에릭 에반스 씨는 레포지토리를 만들 때 애그리거트 루트 엔티티에 대해서만 레포지토리를 만들라고 하셨다.

쉽게 말해.. 애그리거트 하나에 레포지토리 하나만 제공하라고 하셨다.

 

이 말인 즉슨.. 애그리거트 당 해당 애그리거트의 객체들만 싹 가져오는 한방 쿼리 한 개만으로 조회 쿼리를 구성하면 된다는 뜻이 아닐까?

 

오오.. 좋은 울림이다.

 

 

트레이드오프

애그리거트 루트가 자신의 무결성을 지키기 어렵다

class TeamBlog {
  private TeamId team;
  private List<ArticleId> articles;
  
  // 팀의 관리자이거나 해당 글의 작성자만 글을 삭제할 수 있다
  public void deleteArtice(User requester, Article article) {
    boolean isWriter = article.isWriter(requester);
    boolean isAdmin = // 어떻게 이 User가 우리 팀의 관리자인지 확인하지?
    
    assert(isWriter || isAdmin);
    
    this.article.remove(article.getId());
  }
}

애그리거트는 본인의 도메인 규칙을 지키려면 본인의 애그리거트에 속한 모든 객체가 정상인 상태를 가져야한다.

따라서 애그리거트 루트가 애그리거트 전체를 관리하는 책임을 지는데..

 

다음과 같은 상황에서 해당 User가 팀의 관리자인지를 알아내는 로직은 Team에 있는데, User도 TeamBlog도 팀의 id밖에 가지고 있지 않다.

 

이런 경우 결국 TeamBlog 입장에서는 애그리거트를 정상인지 판별하기 위한 로직을 응용 서비스 로직에 위임할 수 밖에 없게 된다.

 

class ArticleServie {
  public void removeArticle(User requester, Article article, TeamBlog teamBlog) {
    Team team = teamRepository.findById(teamBlog.getTeamId());
    
    // 도메인 로직이 응용 서비스로 빠져나왔다..
    if(team.isAdmin(requester) || requester.getId().equals(article.getWriterId())) {
      teamBlog.removeArticle(requester, article);
    }
  }
}

결국 이런 불쾌한 꼴을 겪게 된다.

 

하지만 id를 통해 애그리거트를 참조하므로 도메인 모델의 노출은 최소화했으므로.. 결국 트레이드 오프가 아닌가 싶다.

 

프로그래밍에 있어서 은탄환은 없다고 하였으니..

profile

Octoping의 블로그

@Octoping

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!