Unknownpgr

Reality of Clean Architecture

2023-04-10 07:01:03 | Korean

직전 글은 클린 아키텍쳐 책을 읽고 의존성 역전을 이해한 후에 쓴 글이었습니다. 그래서 저는 배운 것을 실천하고자, 제가 CTO 역할로 참여하고 있는 서비스 The-Form을 더 클린한 아키텍쳐로 리팩토링하려고 했습니다. 실제로 지금 새로운 기능 요구사항이 많아 한번 큰 변경이 필요하기도 합니다. 그러나 실제로 의존성 역전을 비롯한 소프트웨어 개발 방법론을 현실에서 적용하려고 하니 난감한 점이 한두 개가 아니었습니다. 그래서 이번엔 이러한 난관들을 정리해두려고 합니다.

이것들이 해결된 것은 아닙니다. 일단 문제 정리만 해 두었을 뿐입니다.

서비스 구조가 이상적이지 않음

의존성 역전은 근본적으로 컴포넌트들이 추상화 및 불안정성(instability)에 따라 잘 분리되어있을 때, 이들의 의존성을 여기에 따라 정렬하기 위해 적용하는 것입니다. 즉, 의존성 그래프가 그 방향이 Instability가 작은 쪽을 향하는 Directed Acyclic Graph (DAG)가 되도록 컴포넌트 의존성을 재구성(지금은 리팩토링중이므로)하는 것으로 간주할 수 있습니다.

그러나 현재 구현상 기능에 따라 분리가 되어 있을 뿐, 이들이 추상화 단계나 불안정성에 따라 구분되어있는 것은 아니었습니다. 예를 들어서 설문 기능/응답 기능/유저 기능, 프론트/백처럼 도메인 및 구체적인 구현에서의 구조는 깔끔하게 분리가 되어 있습니다. 그러나 애초에 도메인으로부터 개발을 시작한 것이 아닌, 개발을 점진적으로 진행했던지라 추상적이어야 할 객체의 특성이 구체적인 구현에 의존하는 경우가 많았습니다.

서비스 개발 초기부터 이런 것을 고려하여 설계했었으면 좋았을 텐데, 아키텍쳐에 대한 이해가 부족했었습니다. 그리고 도메인이 명확하지 않았습니다. 예컨대 이 서비스가 설문 작성과 배포의 최적화에 중점을 둘지, 통계 분석이나 서드파티 연계를 제공하는 것에 중점을 둘지조차 명확하지 않았습니다.

그래서 이것을 이제 와서 엔티티부터 다시 설계하려고 하니 기존 설계의 변경점을 최대한 줄이면서 구체적인 구현 - 예컨대 프레임워크나 데이터베이스 - 등에 의존하지 않아야 했고, 그래서 처음부터 개발하는 것보다 훨씬 어렵습니다.

성능과 구조의 Trade-off가 있음

아직 공부를 덜 해서 그런 것인지도 모르지만, 클린 아키텍쳐를 엄밀히 따르자면 성능과 아키텍쳐 사이의 trade-off가 있는 것처럼 보입니다. 예컨대 상점과 각 상점의 평점을 보여주는 서비스를 가정해보겠습니다.

또한 리포지토리 함수가 반환해야 하는 데이터도 애매합니다. 리포지토리 함수가 반환해야 하는 데이터가 엔티티라면 (특히 join 연산으로 얻어지는) 쓸모 없는 필드들까지 반환해야 할 것이고, 만약 임의의 데이터를 반환해도 된다면 모든 리포지토리 함수에 대한 반환 타입을 전부 정의해주어야 할 것입니다.

위의 문제들은 문제는 ORM이나 쿼리빌더를 사용하면 어느정도 해결됩니다. ORM을 리포지토리 레이어 그 자체로 가정하고 서비스 레이어에서 직접 쿼리를 작성하면 되기 때문입니다. 그러나 그 경우 데이터베이스의 스키마를 알아야만 리포지토리를 사용할 수 있으므로 의존성 역전을 위배하게 됩니다.

그리고 복잡한 연산이 필요한 서비스를 가정해보겠습니다. 예를 들어서 상위 10개의 평점만을 가져오거나, 아니면 위치상 특정 격자 내의 상점만을 가져오는 연산, 혹은 RBAC 구현상 특정한 role에 속한 user가 access할 수 있는 resource만을 가져오는 연산 등이 있습니다. 이러한 연산을 서비스에서 구현하자니 성능이 떨어지고, 데이터베이스 쿼리로 구현하자면 비즈니스 로직이 리포지토리에 포함됩니다.

도메인이 확장됨

처음 개발할 때는 앞서 말했던 것처럼 도메인이 명확하지 않았습니다. 애자일한 방법에 따라 유저의 니즈를 파악하면서 기능을 추가해나갔고, 따라서 어느정도까지는 확장 가능한 구조를 구현했지만 더이상 기존 구조로부터는 확장이 어렵게 되었습니다.

UI가 일을 많이 함

일반적인 서비스라면 UI는 단지 제일 마지막에 신경쓸 세부사항에 불과하고 서비스 로직(그게 프론트에 있든, 백에 있든) 에는 영향을 미치지 않을 것입니다. 그런데 설문조사는 UI의 중요도가 대단히 높습니다. 그래서 사실상 UI가 서비스 로직이고 DB는 그냥 json 저장소에 불과하며 (사실상 파일시스템으로 구현해도 별 문제 없는) 백은 데이터 권한 관리만을 수행하지 별다른 연산을 하지 않습니다. 그래서 다른 구현을 참고하기가 힘듭니다.

Authorization / Permission 은 어디에 두어야 하나

또 Authorization도 대단히 다루기 애매합니다. Authorization은 어떠한 작업을 허용할지 막을지를 결정할 뿐, 그 작업 자체와는 관련이 없습니다. 그러므로 SRP를 따르자면 분리를 해야 합니다. 권한 검사 방식을 바꾸기 위해 서비스 로직을 수정하게 되면 SRP를 위반하게 되기 때문입니다.

그런데 분리를 하자니 서비스 로직과 Authorization이 너무 긴밀하게 붙어있습니다. 간단한 경우에는 어떤 유저가 어떤 Action을 하려고 하는지만 알면 됩니다. 그러나 그 Action의 목적 객체인 resource도 알아야 하거나 그 resource의 owner까지 알아야 결정할 수 있는 경우도 있습니다.

이걸 분리하게 되면 두 독립적인 컴포넌트가 데이터베이스를 공유하거나, 불필요한 쿼리가 발생하거나, cyclic dependency가 생길 것 같고, 그렇다고 분리를 안 하자니 SRP를 어기는 것 같습니다.

Authorization이 컴포넌트로 분리된다면 그것이 있어야 할 위치도 불명확합니다. API와 서비스 로직 사이에 authorization을 두면 실수로 체크를 잊는 경우 보안 취약점이 생길 수 있습니다. 반대로 서비스 로직 안에 집어넣자니 앞서 말한 것처럼 서비스와의 결합이 너무 강해져서 권한 체크 코드를 너무 많은 곳에 집어넣게 됩니다.

컴포넌트 경계

모든 요소를 전부 독립적으로 구현한다고 좋은 코드가 되는 것은 아닙니다. 예를 들어 더폼 서비스는 uuid 라이브러리를 서너 군데에서 사용합니다. 이 uuid 라이브러리에 대한 의존성을 제거하고자 컴포넌트를 재작성한다면 이는 오히려 코드 양만 늘리는 오버 엔지니어링이 될 것입니다.

그러므로 이러한 오버 엔지니어링을 피하면서 컴포넌트는 SRP에 따라 한 가지 역할만 하도록, 더 정확히는 그 컴포넌트를 변경할 사유가 오직 하나의 이유만이 되도록 분할해야 하는데, 이것이 참 애매합니다.

예컨대 설문에서 질문의 순서를 바꾸는 유스케이스를 고려해보겠습니다. 질문의 바꾸려면 구현상 (설문 객체에 포함된) 질문의 배열에서 그 순서를 바꾸면 됩니다. 이는 분명 설문이라는 엔티티와 UI 사이의 상호작용입니다.

그런데 설문이라는 엔티티틀 UI에서 곧바로 건드려서는 안 됩니다. 그렇게 하면 컴포넌트 분리도 이루어지지 않을 뿐더러 서비스 로직과 UI가 너무 강하게 결합하고, UI와 강하게 결합되므로 서비스 로직을 테스트하는 것이 대단히 난감해집니다.

그렇다고 설문 객체에 행할 수 있는 그 모든 배열 연산과 값의 설정을 전부 getter-setter로 구현한다면 이것 역시 오버 엔지니어링이 될 것처럼 느껴집니다.

결론

항상 그랬듯이 해결된 것은 아무것도 없으며 일단 시도해 봐야 그 결과를 알 수 있겠습니다. 일단 지금은 도메인을 명확히 파악하여 entity와 use case를 작성하는 중입니다.


- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -