본문으로 건너뛰기

무엇이 좋은 도메인 설계인가?

· 약 23분
나영서 (산초)
리뷰미 BE

작성 배경

프로젝트를 시작하고 3주 동안, 리뷰미는 3번의 도메인 구조 변경을 겪었습니다. 모든 구조는 우리 팀이 치열하게 고민한 끝에 나온 것들이었습니다. 그래서 바꾸는 것이 아쉽기도 했습니다. (자식같은 내 코드..) 하지만 분명한 필요성을 느껴 구조 변경을 했고, 그 과정에서 ‘좋은 설계란 무엇인가’에 대해 고민하게 되었습니다. 이 글에서는 리뷰미 서비스의 도메인 변천사와, ‘좋은 설계란 무엇인가?’에 대한 개인적 견해를 적어보려 합니다.


첫 번째 설계

목표 : 핵심 기능을 빠르게 구현 ⏩

우리 팀의 첫 목표는 핵심 기능을 빠르게 구현하는 것이었습니다. 따라서 ‘리뷰를 작성하는 기능’과 ‘리뷰를 보는 기능’을 빨리 구현하기 위한 설계를 했습니다. 기능을 빠르게 구현하기 위해 우리 팀은 아래 기준으로 설계를 했습니다.

  1. 팀원들이 모두 사용해본 방법
  2. 인지 부담이 적은(생각할게 적은) 방법

구현 순서 : ERD 작성 → 도메인 설계

우리 팀은 ERD 작성 → 도메인 설계의 순서로 구현했습니다. ERD를 먼저 설계하자는 의견은 저의 의견이었습니다. 우리 서비스에 어떤 데이터들이 저장될 것이며, 이들이 어떻게 연결되어있는지를 정해야 도메인 설계를 할 수 있을 것이라 생각했습니다.

도메인 연관관계

빠른 구현을 위해, ERD를 보며 아래 규칙을 따라 구현했습니다. 앞서 언급한 것처럼 단순하고, 생각할 거리가 많지 않은 규칙입니다. 💁🏻‍♀️

  • 단방향 @ManyToOne 만 존재
  • list 형식을 저장해야 할 때면 별도의 테이블 생성
  • N:N 관계에 대해서는 중간 테이블 생성

로직 위치

우리 팀은 각 도메인을 객체로서 활용하려 했습니다. 그래서 객체들이 협력할 수 있게, 도메인 관련 로직을 도메인 내부에 두었습니다. 그러다 도메인 내부에 너무 많은 로직이 있다면 포장객체로 분리해주었습니다. 예를 들어, Keyword 라는 도메인이 있고, List<Keyword> 에 대해 갯수 검증과 중복 검증을 해줘야 한다면 그 역할을 하는 일급 컬렉션 SelectedKeywords 를 만들어주었습니다.

첫 번째 설계 후..

첫 번째 설계 후 우리 팀원들은 모두 같은 생각을 하게 되었습니다.

‘이건 객체지향이 아니야!!’ 😣

라는 생각이었습니다. 데이터베이스를 먼저 생각하고, 그로부터 엔티티 객체를 도출했기 때문이었을까요? 분명히 ‘도메인 코드는 인프라에 의존적이지 않아야 한다’고 배웠었는데, 도메인이 JPA를 위해 존재하는 듯 했습니다. 그동안 배운 POJO를 사용하는 순수 어플리케이션과는 너무 다르기에 괴리감이 느껴졌습니다. 우리 팀원들은 모두 이 문제에 공감했고, ‘도메인으로부터 시작해보자’ 라는 생각으로 다시 설계를 하게 되었습니다.


두 번째 설계

목표 : 가장 중요한 것은 도메인 간의 협력 🤝

두 번째 설계에서는 ‘도메인 객체’ 그 자체에 집중하기로 했습니다. ‘어떤 성질’을 갖는 도메인이 ‘어떻게 협력’하는지 먼저 정하고, JPA는 나중에 붙이자 생각하며 구현했습니다. 사실 이 방법이 맞다는 확신은 없었습니다😅 영속 로직을 JPA를 사용하여 처리하고 있는데, JPA를 배제해도 되나? 하는 의문도 들었고요. 하지만 결과적으로 이 선택이 올바르지 않더라도, 왜 객체지향적으로만 생각하면 안 되는지를 경험해보고 싶었습니다.

구현 순서 : 요구 사항 분석 → 협력 관계 파악 → 도메인 설계 → JPA 적용

두번째 설계에서는 가장 먼저 요구 사항에 대해 분석하고 도메인의 협력 관계를 파악했습니다. 그리고 각 도메인이 어떤 역할을 해야 하는지 적어보고, 협력해야 하는 방향을 화살표로 표현했습니다. 이를 바탕으로 도메인 객체를 도출하고 참조 관계를 설정했습니다.

팀원들은 “이게 자바지!” 하며 만족해했습니다. 브랜치 이름을 pure-java-is-god 라고 할 정도였습니다. 하지만 JPA를 붙이자 재앙이 시작되었습니다..

POJO들의 협력 + JPA ⇒ 양방향 연관관계 뒤범벅

객체에서 시작한 설계에 JPA를 붙이려 하니 부자연스러운 부분들이 생겼습니다. 가장 어려움을 겪었던 것은 ‘연관관계’에 대한 부분이었습니다. 도메인의 협력을 생각하면 A → B 의존인데, JPA의 연관관계를 생각하면 B → A 의존이 필요했습니다. 하지만 우리 팀은 순수 자바를 놓치지 말자는 목표가 있었기 때문에, 두 의존 방향 모두 가져가기로 했습니다. 그리고 그 결과, 무수한 양방향 연관관계들이 생겨났습니다😵‍💫

양방향 연관관계로 이어진 객체들은 그 경계가 모호했기 때문에 여러 가지 이슈로 이어졌습니다. 조회를 할 때는 Lazy Initialization 문제가 발생했습니다. 이를 해결하기 위해 fetchType을 설정했어야 했는데, 어디까지를 EAGER, 어디까지를 LAZY로 둘지 애매했습니다. 저장을 할 때는 DB와 메모리의 동기화를 위해 연관관계 편의 메세드를 작성해야 했습니다. 또한 테스트를 할 때는 연관된 모든 데이터를 세팅하기 위해 given 구문 길게 작성해서, 정작 무엇을 테스트하는 것인지 알아보기 힘들어졌습니다. 이렇듯 복잡한 부분들이 많았지만, 이런 문제들은 ‘좋은 설계를 위해 개발자가 수고해야 하는 부분’이라 생각했습니다.

길을 잃은 우리 🍂

두번째 설계를 바꾸게 된 결정적 계기가 된 것은 바로 기획의 변경이었습니다. 바뀐 기획에 따라 특정 도메인을 삭제할 필요가 있었는데, 그 도메인은 여러 도메인에서 의존하는 것이라 변경이 매우 어려웠습니다. 하나의 도메인 객체를 변경하니 여러 곳에서 컴파일 에러가 발생했습니다.

또한 기획이 달라짐에 따라 도메인과 관련된 정책들도 변경이 되었는데요. 로직을 도메인에서 처리하게 했기 때문에 정책이 변경될 때마다 도메인 내부를 바꿔야 했습니다. 설상가상으로 도메인들이 촘촘한 연관관계로 엮여있었기 때문에, 한 도메인의 변경이 다른 도메인에게 전파되었습니다. 조금의 변경에도 와장창 무너지는 코드를 보며, 설계가 잘못되었다는 것을 깨달을 수 있었습니다. 이 설계도 아니라니! java-is-god이었던 브랜치는 어느새 we-are-lost으로 변경되었습니다😔

객체지향? JPA?

잘 협력한다고 생각했던 설계가 오히려 변경에 취약한 설계였다니.. 되게 많은 생각이 들었습니다. 이때 ‘어떤 것을 중요하게 생각해야 할까?’에 대해 많은 생각을 했습니다. 객체지향을 배운 사람으로서, 객체지향의 가치들이 좋은 것이라 여겼으나, 이를 어떻게 DB 에 적용을 할 수 있는 것인지 갈피를 잡기 힘들었습니다. 아니, 사실은 정말로 객체지향이 좋은 가치였을까? 라는 생각도 들었습니다.

예를 들어, SOLID 중 S에 해당하는 단일 책임 원칙을 지키기 위해 도메인과 관련된 로직을 도메인 안에 넣었습니다. 그랬더니 비지니스 정책이 바뀔 때마다 도메인이 달라져야 했습니다. 그뿐 아니라, JPA 에서는 FK 를 저장해야 한다는 이유로 객체를 직접 참조합니다. 이런 참조는 강한 결합을 하게 만들어 객체지향에서는 피해야 하는 것입니다. 그렇다면 어떤 것을 우선시 해야 좋은 설계라고 할 수 있을까? 고민이 되었습니다. 이때 코치님께 들었던 조언이 방향이 되어주었습니다.

많은 개발자들이 ‘공들여 설계를 해야 변화가 없을’ 것이라고 생각한다. 하지만 사실은 그렇지 않다. 절대 변하지 않은 것이라 생각했던 것도 나중에 변경이 될 수 있다. 중요한 것은, 요구사항이 변경되었을 때 어떻게 유연하게 대처할 수 있는가? 이다.


무엇이 좋은 설계인가?

앞선 경험들과 코치님의 조언을 토대로, 좋은 설계에 대한 기준을 세울 수 있었습니다. 좋은 설계에 앞서, 좋은 어플리케이션에 대해 생각해봅시다. 어떤 어플리케이션이 좋은 어플리케이션일까요? 요구사항을 잘 지키는 어플리케이션입니다. 그런데 요구사항이 변하는 것은 막을 수도, 예측할 수도 없습니다. 그러니 변경되는 요구사항을 빨리 반영하고, 잘 지키는 어플리케이션이 좋은 어플리케이션일 것입니다.

그렇다면 좋은 도메인 설계는 무엇일까요? 변경에 유연한 설계가 좋은 설계라고 생각합니다. 애초에 우리가 객체지향 프로래밍을 하는 이유를 생각해봅시다. 그것은 '아름다운 코드'를 위해서가 아닌 '유지보수'를 위해서입니다. 즉, 우리 코드가 객체지향적인지, 절차지향적인지, JPA 중심 설계인지보다 '변경에 유연한지'가 더 중요한 기준이라는 결론을 내리게 되었습니다.


세번째 설계

목표 : 변경에 유연한 구조 🌊

우리 팀은 ‘변경에 유연한 구조’를 최상위 기준으로 두며 세번째 설계를 했습니다. 그리고 이를 위해 세가지 큰 변화를 주었습니다. (이 과정에서 우아한 객체지향이라는 동영상을 많이 참고했습니다🙇🏻‍♀️)

1. 불필요한 객체 참조 끊기 ✂️

두번째 설계에서 우리 팀이 경험한 객체 참조의 불편함을 정리하자면, 아래와 같습니다.

  1. 객체 변경이 전파된다.
  2. 조회 시, Lazy initialization 를 야기할 수 있다. 이를 방지하기 위해 fetch 를 eager 로 설정해야 하는데, 이렇게 되면 lazy loading 의 장점을 활용하지 못한다. 또한 어떤것까지 eager 로 할지, lazy 로 할지에 대한 경계가 모호하다.
  3. 간단한 조회에도 복잡한 join 문으로 성능이 저하된다.
  4. 객체 그래프가 깊다면, 트랜잭션도 길어진다. 트랜잭션이 길어지면 하나의 커넥션을 오래 사용하게 된다.
  5. 연관된 객체의 생명주기가 다르다면, lock 을 걸어줘야 한다. 예를들어 A가 B에 의존하는데, B는 A와 생명주기가 달라서 A와 관계없이 수정, 삭제될 수 있다고 하자. 그렇다면 A 에 대한 연산을 할 때 B 가 수정, 삭제되지 않도록 B 에 대해 lock 을 걸어줘야 한다. 이는 성능 저하로 이어질 수 있다.

따라서 JPA 연관관계를 끊어주고, 다른 엔티티의 id 를 long 타입으로 참조하게 했습니다.

2. 생명주기가 같은 것끼리 묶어주기 🪢

하지만 모든 객체 참조를 없애진 않았고, 생명 주기를 같이 하는 객체는 참조하게 했습니다. 예를 들어, 리뷰를 작성할 때 Review 객체 뿐 아니라 TextAnswer 이라는 객체도 같이 생성됩니다. 조회를 할 때도 이들은 하나로 묶여 조회되며, 수정이나 삭제를 할 때도 함께합니다. 이러한 객체들은 FetchType.EAGER, CascadeType.PERSIST 와 같은 속성을 통해 하나의 단위처럼 관리되게 해주었습니다. 이렇듯 반드시 필요한 연관관계는 하나처럼 만들어주고, 불필요한 관계를 끊으며 객체 사이에 명확한 경계를 만들어주었습니다.

3. 하나의 기능을 하나의 객체로 모으기 🪣

두번째 설계에서는 로직을 모두 도메인에 넣고 이를 외부에서 호출해주었습니다. 이는 객체지향의 관점에서 보면 아름다운 협력처럼 보입니다. 하지만 변경이 생길 때 어느 부분을 수정해야 하는지 잘 보이지 않았습니다. 즉, 변경을 같이 해야 할 것이 여러 곳에 분산이 되는 문제가 있었습니다. 이를 해결하기 위해 세번째 설계에서는 하나의 기능에 연관된 것들을 하나의 객체로 모아주었습니다.

이는 다소 절차지향적인 설계라고도 할 수 있습니다. 하지만, 우리의 코드가 객체지향인지 절차지향인지보다 더 중요한 것은 변경에 유연히 대응하는 것입니다. 따라서 변경 시 확인할 곳을 명확하게 해주는 코드를 작성해주었습니다.


이 과정을 통해 배운 것

  1. 객체지향이 반드시 옳지 않을 수 있다는 것을 배웠습니다. 모든 곳에 객체지향을 엄격히 적용하기에는 괴리가 있는 부분들이 있습니다. 어플리케이션에는 여러가지 개념적인 것들이 사용됩니다. Layered architeture, URI resource, ORM, DB 등.. 객체지향 또한 개념적인 것이기 때문에 이들과 어울리지 않을 수 있습니다. 그럼에도 객체지향을 고수하려 하다가는 오히려 혼란스러울 것입니다. (우리의 두번째 설계처럼 말이죠)

  2. 달라지는 요구사항에 따라서 우리 코드 또한 언제든 달라질 수 있다는 것을 배웠습니다. 우리 팀은 초반에 꼼꼼히 코드리뷰 하고, 열심히 토론했었습니다. 저 역시 저를 소개하는 문구가 ‘한 줄의 코드를 작성하더라도 의미를 담자’라고 할 정도로, 정성을 들여 코드를 짰었습니다. 하지만 요구사항의 변경으로 그 코드 전체가 날라가는 경험을 하니.. 사소한 것에 집착하지 않게 되었습니다. 물론 코드를 대충 작성하겠다는 것은 절대 아닙니다. 다만 너무 세부적인 것에 집중하느라 시간을 쓰지 말아야겠다는 생각입니다.

  3. 서비스는 동적으로 변화한다는 것을 배웠습니다. 기존 기능에 살이 붙기도 하고, 있던 기능이 사라지기도 합니다. 앞으로 붙을 살, 없어질 기능에 대해서는 미리 알 수 없는 노릇입니다. 따라서 우리가 할 수 있는 최선은 핵심 기능에 집중하며, 변경에 유연하게 설계하는 것입니다.

  4. 앞으로의 확장에 대해 예측하는 것은 좋지 않을 수 있습니다. 저도 알고싶지는 않았는데요😅, 첫번째 설계에서 ‘이건 나중에 무조건 생긴다’고 생각했던 기능을 갈아엎으며 깨닫게 되었습니다.

우리 팀은 왜 계속 기획이 바뀌는지, 왜 설계가 바뀌는지 원망스러웠던 적도 있었습니다. 하지만 변경이 많았기 때문에 좋은 설계에 대한 기준을 세울 수 있었던 것 같아 지금은 감사한 마음이 듭니다. 앞으로 또 어떤 변경이 있을지는 모르겠지만, 그 변화에 유연히 대처할 수 있는 코드를 작성해보겠습니다🤗