의존성 역전, 도메인 우선 개발
저는 아키텍쳐에 관심도 많고, 도메인 자체에도 관심이 많은 터라 이번 미션에서는 DIP를 적용해보고 싶다는 욕심이 들었습니다. 따라서 페어와 합의해 영속성 - 도메인 의존성을 역전시킨채로 미션을 진행했습니다.
Repository의 인터페이스를 business layer에 먼저 만들어두고 domain, service를 구현 완료한 다음 Repository의 구현체 및 DB 테이블을 만들었습니다. 이 과정에서 무의식적으로라도 DB 테이블이나 쿼리문에 대해 신경쓰지 않게 되었습니다.(애초에 Repository 구현체를 만들지 않았으므로)
해당 접근방식을 통해 Layered architecture의 전통적인 문제점인 데이터 중심 설계로부터 벗어날 수 있었고, 순수 비즈니스 로직/도메인이란 무엇인가? 에 대한 고민을 끊임없이 할 수 있었습니다. 이번 미션에서는 도메인 엔티티에 별 다른 로직이 없어(빈약한 도메인 모델), 도메인 엔티티나 영속성 엔티티나 같은 구조를 띠게 되었지만 추후 도메인 모델에 로직이 많이 생기는 경우 위 경험을 토대로 빠르게 문제를 해결할 수 있을 것 같다는 생각이 듭니다.
도메인 엔티티에 ID?
미션 요구사항으로 주어진 웹 화면에서는 다음과 같이 ID를 확인할 수 있었습니다.
웹 개념도 익숙하지 않아서, 고민을 굉장히 많이 했습니다. 분명 레벨 1 때 배웠던 객체지향에서는 객체가 ID 값을 가지지 않는데.. 그러면 화면 상에 나타나는 ID 값은 데이터베이스의 ID인가? 그렇다면 상품 객체는 ID 값을 몰라야 하는 거 아닌가?
따라서 처음에는 도메인 엔티티에 ID 값을 부여하지 않은 채로 미션을 진행했습니다. 단순히 '도메인은 순수해야 돼!' 라는 생각 하나로 미션을 진행했는데요, Repository, Service를 구현하면서 ID가 없는 도메인으로는 더 이상 미션을 진행하기 어려웠습니다. 상품 객체는 필요에 따라 삭제되거나 수정되어야 하는데 도메인에 ID가 없다면 찾을 방법이 없었습니다. 그리고 결국 도메인 엔티티에 ID 필드를 부여했습니다.
이런 문제에 대해 리뷰어님과 이야기를 나누어 보았는데, 다음과 같은 답변을 받았습니다.
도메인에 ID가 없다면 어떻게 될까요? 리뷰어님이 말씀해주신 것처럼, 도메인 엔티티는 모두 VO처럼 필드 하나하나 동등성 비교를 수행해야만 합니다. 그렇다면 같은 필드를 가지는 도메인 엔티티는 항상 같은 엔티티이게 됩니다. 이런 생각이 들자 웹 개발 환경에서는 도메인 엔티티에서의 ID는 데이터베이스의 개념이 아닌 식별자로서 존재하는구나, 깨닫게 되었습니다.
이외에도 VO와 엔티티의 차이점, 그리고 개념을 찾아보았는데요. 너무 깊게 들어가면 DDD와 연관되기에 단순히 엔티티는 식별자를 가지고, VO는 식별자를 가지지 않으며 필드에 대한 동등성 비교를 한다고 정리하고 넘어가려고 합니다. 앞으로는 추적해야 하는 객체라면 ID를 부여하고, 추적할 필요가 없는 값 객체면 ID를 부여하지 않을 것 같네요. 이번 미션에서 상품은 추적해야 되기에 엔티티로 두는 것이 바람직하구요.
TDD, Unit testing
이번에도 TDD를 진행했는데요, 저는 철저한 baby-step TDD 파라서.. 정말 작은 단위부터 테스트하면서 구현을 진행합니다. 그렇게 테스트를 작성하다보니, 다음과 같은 저만의 테스트 철학이 생겼습니다.
- 어떤 객체에 대해 테스트를 할 때, 해당 객체의 관심사만 테스트를 하면 되는 것 아닌가?
- 예를 들어, 컨트롤러에서는 응답 내용을 검증하지 않고 요청 가능 여부, 응답 형식만 검증하면 되지 않을까?
따라서 이런 철학을 유지하면서 테스트를 작성했는데요, 문제가 하나 발생했습니다. 테스트코드가 빈약하다는 겁니다. 철저하게 해당 객체의 관심사만 테스트하다 보니, 서로 다른 객체 간 테스트 연관성이 거의 제로에 가까웠습니다. 이렇게 테스트코드를 구성하는게 맞을지, 이런 테스트 철학을 유지해도 될지 고민이 많이 되었고 리뷰어님께 마찬가지로 질문을 드렸습니다.
리뷰어님은 해당 테스트 철학에 대해 큰 문제점을 제기하지는 않았습니다. 다만 테스트를 보강하라고 요청을 주셨을 뿐이었습니다. 따라서 이런 철학을 유지해도 괜찮겠다, 라는 생각이 들었고 보다 한정된 범위 내에서 보다 질 좋은 테스트코드를 짜기 위해 Mocking 학습을 진행해야겠다는 생각이 든 계기였습니다.
널 체크
이번 미션을 진행하면서 유효성 검증에 관해서도 많은 고민을 했습니다. 페어와 null check 관련 이야기를 나눈 적 있는데, 도메인 단에서 널 체크를 하는 것이 맞는지, 맞다면 모든 메소드에 널 체크가 들어가야 하는 것은 아닌지.. 그렇다면 어디에서 널 체크를 하는 것이 이상적인지 고민을 했었습니다.
일단 한 가지 확신하고 있던 점은, 도메인이나 비즈니스 단에서 널 체크는 해서는 안된다는 것이었습니다. 요즘 <만들면서 배우는 클린 아키텍쳐>를 읽고 있는데 저자는 비즈니스 영역이 널 체크로 오염되어서는 안된다며 서비스나 유스케이스는 확인된 값만 받게 만들라고 주장합니다. 확실히 납득이 되는 부분입니다. 다만 이런 널 체크를 클린 아키텍쳐가 지향하는 대로 비즈니스 계층에서 입력 모델/출력 모델을 만들어 처리할지, 표현 계층에서 처리할지는 조금 더 고민해봐야 할 것 같습니다.
유효성 검증 위치
이전까지는 각 레이어마다 검증 책임이 다르고, 필요한 검증만 수행하면 된다고 생각했었습니다. 예를 들어 표현 계층에서는 데이터 자체에 대한 검증(null인지? empty인지?), 비즈니스 계층에서는 비즈니스 규칙에 대한 검증(이미 존재하는 데이터인지? 등), 도메인에서는 도메인 규칙에 대한 검증을 수행해야 한다고 생각했습니다.
계층 구조 상 저는 이런 방법이 가장 이상적이라고는 생각합니다. 각 계층은 서로의 관심사만 철저하게 검증하고 있으니까요. 다만 현실적인 부분을 고려했을 때는 아닐 수도 있다는 겁니다.
조영호님의 <우아한 객체지향>에서도 검증 로직을 절차지향처럼 짜는 방식을 소개합니다. 객체지향이라고 해서 모든 코드를 객체지향으로 배치해야 할 필요는 없다고 주장합니다. 이런 주장과 리뷰어님의 조언을 듣고 나니, 필요에 따라 타협을 할 수도 있겠구나.. 라는 생각이 들게 되었습니다. 만약 실무나 실제 프로젝트를 하게 된다면 이런 방법을 고려해볼 수 있을 것 같습니다.
로깅
이번 미션에서 로깅을 처음 적용해보았습니다. Spring에서는 어느 로깅 구현체를 사용하는지, 그리고 어떤 방식으로 이루어지는지를 학습했습니다. 로깅도 레벨이 존재하는데 로깅 레벨 사용에 대한 명확한 가이드라인을 제공하주는 곳은 없어서 저만의 기준을 만들고자 했습니다. 필요에 따라 적절하게 로깅을 하는 연습도 중요하다는 생각을 하는 계기였습니다. 특히 리뷰어분이 나중에 정말 중요하다고 강조를 해주시더라구요.
Service를 기능 중심으로 분리하다
이번 미션의 요구사항은 비교적 간단했습니다. CRUD 작업만 있었기에 도메인 로직이랄게 없었는데요. 미션 초기까지만 해도 저는 service가 비즈니스 흐름을 담아야 한다고 생각했습니다. 즉 배달의민족 도메인이라면 주문하기, 장바구니 담기 등의 서비스가 존재해야 한다고 생각했습니다. 이처럼 기능 중심으로 응집도가 높은 서비스를 만드는 것이 아니라 자원 중심으로 응집도가 높은 서비스가 만들어지는 경우 넓은 서비스 문제를 피할 수 없다고 생각했습니다. 서비스의 재사용성도 저해되구요.
따라서 간단한 CRUD 작업을 하는 서비스도 총 4개의 서비스로 분리했습니다. 하지만 리뷰어님을 포함한 많은 크루분들이 너무 과도하게 분리한 것이 아니냐, 라고 지적해주셨고 저도 이에 대해 곰곰히 생각해보았습니다. 서비스는 비즈니스 흐름이 핵심인데, 왜 자원 중심으로 분리되어야 할까요?
확실히 납득되었습니다. 만약 CRUD 중 C + R를 조합한 기능이 생긴다면 이는 어느 서비스에 위치해야 할까요? 이때마다 또 새로운 서비스를 만들 수는 없는 노릇입니다. 그리고 서비스가 이렇게 원자적 단위로 쪼개지고 재사용된다면 확실히 테스트가 어렵다는 것을 알았습니다. 컨테이너에 등록되는 빈들이 늘어나게 되고 참조가 복잡해져서 모킹하기도 어려워집니다.
한참을 고민하다가, step2 제출을 했을 때 리뷰어님께서 다음과 같은 조언을 주셨습니다.
리뷰어님이 제공해주신 코드처럼 작성하면 서비스 내부적 관점으로는 자원 중심, 서비스를 호출하는 클라이언트 입장으로는 기능 중심으로 사용할 수 있게 됩니다. 자원 중심으로 서비스를 구성했을 때의 문제점인 비즈니스 흐름을 담지 못한다는 문제도 해결이 가능했습니다. 그저 유스케이스 인터페이스만 바꾸면 그 변경의 여파는 자연스레 구현(implements)하는 서비스에 전해지니까요. 효율적인 방법인가? 라고 묻는다면 쉽게 답하기 어려운 면이 있지만 서비스를 사용하는 클라이언트 중심으로 보았을 때는 가독성이 월등하게 좋아지게 됩니다. 이 방식에 대해서는 조금 더 고민해보고 기준이 잡힌다면 추후 미션에서도 적용해볼만 하다고 생각됩니다.
Controller - Service 통신
캠퍼스에서도 컨트롤러와 서비스의 통신에서 도메인을 주고받아야 하는지, request를 그대로 넘겨줘서 변환하는게 맞는지 고민하는 크루들이 많았습니다. 저는 도메인 객체를 주고받아야 한다는 입장이었는데(서비스가 특정 컨텍스트에 묶이지 않게 하기 위함) 이 방법도 고민이 많이 되었습니다. 결국 불완전한 도메인 엔티티를 전달하는 꼴이 되고, 서비스가 많아진다면 컨트롤러에서는 도메인 객체를 다루기 위한 코드가 들어갈 수 밖에 없으니까요. 결국 컨트롤러와 도메인 엔티티의 결합도가 상승합니다.
반면 서비스에서 request DTO를 받는 경우도 그렇게 좋은 방법은 아닙니다. 일단 서비스가 특정 컨텍스트에 묶이게 되어 재사용하기 어렵게 됩니다. 또한 표현 계층과 서비스 계층 간 양방향 의존성이 생겨버리기 때문에 추후 유지보수 시 문제가 될 여지는 충분합니다.
두 방법 다 극단적이라고 느껴졌습니다. 그래서 새로운 Layer를 하나 둬서 변환 책임을 담당하게 하는 방법도 시도해봤습니다. 이 방식도 보완하면 상당히 좋을 것 같긴 한데.. 아직 확신은 없습니다.
리뷰어님은 Controller - Service 간 통신에 DTO를 두라고 하시더라구요. 이는 어떻게 보면 클린 아키텍쳐에서 제시하는 입력 모델과 비슷한 느낌인데 확실히 레이어 간 직접적인 의존성을 완전히 끊어버릴 수 있고 서비스에서는 올바른 정보만 받았다는 보장을 받을 수 있기에 좋은 방식이라 생각합니다. 다만 양쪽 계층에서 DTO를 모두 만들어줘야 하고 DTO를 원하는 정보로 변환하는 오버헤드가 생겨버리기 때문에 고민이 되기는 합니다. 이러한 절충안으로 저는 표현 계층에서 DomainConverter를 만들어 주었습니다. 그렇지만 이 방식이 마음에 드는 건 또 아니라서.. 완벽한 정답은 없다는 생각이 드네요 ㅎㅎ ㅠ
관련해서 리뷰어님이 작성하신 아티클 첨부합니다!
https://kafcamus.tistory.com/12
'우테코 5기' 카테고리의 다른 글
HTTPS 개념 및 EC2로 배포한 서버에 적용하기(nginx + cerbot) (3) | 2023.05.25 |
---|---|
[레벨 2 미션] 웹 장바구니 미션 학습 기록 (2) (0) | 2023.05.20 |
[레벨 2 미션] 웹 자동차 경주 미션 학습 기록 (0) | 2023.05.07 |
[트러블슈팅] Interceptor 생성으로 인해 컨트롤러 테스트가 깨지는 경우 (8) | 2023.05.05 |
장바구니 미션) 도메인에서 영속성 개념 분리해보기 (6) | 2023.04.29 |