Photo by Annie Spratt on Unsplash

JPA를 이용한 Query Service의 성능 개선기

작년 4분기부터 올해 초까지 현재 근무하고 있는 회사에서 제공하고 있는 Saas 솔루션의 대시보드 개발을 맡았다. Spring Boot와 JPA를 이용하였고 현재는 베타버전이 거의 완성되었다. 개발 막바지에 성능과 관련된 이슈를 경험하여 글로 남기려고 한다.

요구 사항과 구현

대시보드에서 구현해야했던 비즈니스 사이드의 요구사항은 다음과 같았다.

  • 우리가 Solution을 통해 고객에게 전달하고 있는 Message를 대시보드에 테이블 형태로 보여주세요.
  • 그런데 테이블에서 Message의 속성별로 필터링도 가능해야합니다.
JPA Query service architecture

여기서 Solution을 이용하고 있는 고객들은 주기적으로 Message라는 것을 받는데 이는 SolutionService를 통해 Postgres에 저장되고 있다. 대시보드는 Postgres에서 Message를 가져와서 Frontend에 전달하기만 하면 되었다. Message의 속성에 대해서는 아래에서 더 설명하겠다.

구조는 간단하다. 솔루션 쪽의 서비스가 관련 데이터를 Postgres에 쌓고 있고 DashboardService는 해당 DB에서 데이터를 가공하여 Frontend에 뿌려주면 된다.

Message Entity는 다른 Entity들과 다음과 같은 관계를 가지고 있다.

Query service entity relation

Message는 Event와 Many-to-One 관계를 가지고 있고 Event가 Message를 참조하지 않는 단방향 관계이다. Event와 Item의 관계도 Message와 Event의 관계와 동일하다.

위에서 언급했던 Message의 속성이란 Message의 status, Message가 가지고 있는 Event의 name, Message가 가지고 있는 Event가 가지고 있는 Item의 id와 같은 것들을 말한다.

DashboardService의 기술스택은 Spring Boot + JPA이기 때문에 쿼리 결과를 매핑할 Entity 가 존재해야한다.

Entity들은 위와 같이 정의되어있고 Application 레이어에서 Message 도메인 객체를 가져오는 수단으로 MessageQueryService를 두었다. 간단하게 로직은 다음과 같다.

findAllWithFilter 메소드가 하는 일은 itemId에 해당하는 Message들 중에서 eventNames, statuses 에 해당하는 속성을 가진 Message들만 필터링해서 가져오는 것이다. 이해를 돕기위해 SQL로 표현하면 다음과 같다.

여기서 Specification을 사용해서 쿼리를 만든 이유는 필터의 대상이 가까운 미래에 추가되거나 없어질 것을 염두에 두었기 때문이다. 쿼리를 프로그래밍적으로 만들어서 필터를 추가할 때 코드의 변화를 최소화하고자 하였다.

MessagePredicateBuilderFactory를 통해 동적으로 MessagePredicateBuilder를 생성하고 MessagePredicateBuilder는 사용자가 넘겨준 필터링 인자들을 이용해 쿼리를 완성시킨다.

테스트 결과

로컬에서 기본적으로 제대로 동작하는지 확인하고 dev 클러스터에 올려서 확인해보니 수십 초 정도 걸린다. 이게 무슨 일이야…?

잠깐 쉬고나서 dev 클러스터의 Message 개수를 살펴보니 백만 단위의 Message가 존재한다. 그리고 어떤 것이 문제인지 살펴보기 위해 코드와 dev 클러스터의 로그를 살펴보았다. 그렇지만 에러는 없었고 모든 것이 정상 동작하는 것처럼 보였다.

문제의 원인이 어디에 있나?

가장 먼저 했던 일은 Application 레이어 쪽에서 실행하고 있는 함수들의 시간을 측정했다. 글의 이해를 돕기 위해 Message들을 가져오기위해 일어나는 일련의 동작들을 설명하면 다음과 같다.

Query service flow

Application 레이어에서는 Domain 로직을 aggregate한 것이기 때문에 Application 레이어에서 실행되는 Domain 로직의 시간을 측정하였다.

이 때 시간을 측정하기 위한 베스트 방법은 로컬에서 dev 클러스터의 환경과 비슷하게 만드는 것이다. 하지만 짧은 시간에 환경을 비슷하게 만드는 것이 어려워 시간 측정 로직만 추가하여 바로 dev 클러스터에 배포하였다.

결과는 findAllWithFilters()findFilterKeys()가 거의 비슷하게 시간이 걸렸다. 이걸 보고 가장 먼저 든 생각은 ‘Message를 조회하는 로직과 필터키를 찾는 로직을 서로 다른 엔드포인트로 만들자’였다. Frontend 쪽에서는 분리한 두개의 엔드포인트에 병렬로 요청을 날려 처리하면 걸리는 시간이 절반 정도로 줄 것이다.

하지만 이게 근본적인 해결책은 아니었다. 엔드포인트를 분리하여도 아직 상당한 시간이 걸렸고 왜 그렇게나 시간이 걸리는지를 찾아야했다.

JPA 쿼리의 상태가?

조금 고민을 하다가 다음으로 하기로 한 것은 실제로 DashboardService에서 어떤 쿼리가 실행되는지 살펴보는 것이었다. 로컬에서 요청을 날려 쿼리를 확인해보았다.

결과를 확인해보니 실행된 쿼리의 형태는 다음과 같았다.

먼저 Message를 page 사이즈에 맞게 가져온 뒤 가져온 Message들을 돌면서 자신과 연결된 Event들과 Item을 가져온다. 이러한 형태의 쿼리는 page 사이즈에 비례해서 시간이 느려진다. O(n)이기 때문에 이를 O(1)로 줄이면 훨씬 조회 속도가 빨라질 것이다.

O(1)로 줄일 수 있는 쿼리가 머리 속에 떠올랐지만 이를 이용하기 위해서는 JPA NativeQuery 기능을 사용해야했고 그렇게 된다면 메소드에 JPA가 제공해주는 추상화된 로직을 담는 것이 아니라 직접 작성한 상당히 구체적인 로직을 담을 것이다.

코드가 어떻게 될지 잠깐 상상해보았을 때 끔찍하긴 했다. 필터들이 동적으로 바뀌기 때문에 쿼리문도 동적으로 생성해주어야하고 쿼리 결과를 다시 도메인 객체로 매핑해주는 로직도 상당히 번잡할 것이다. 그래서 NativeQuery를 사용하는 방법은 최후의 보루로 두고 다시 다른 방법으로 이를 해결할 방법에 대해서 리서치를 하였다.

JPA 1+N issue

리서치를 해보니 현재 겪고 있는 문제가 JPA의 1+N 이슈인 것을 알게되었다. 조회하고자하는 Entity를 한 번 조회하고 안에 nested 된 Entity들을 다시 하나하나 조회한다.

그런데 보통 1+N 문제가 자신과 관련된 Entity를 LazyFetch하기 때문에 발생하는 문제라고 기술하고 있다. 하지만 지금 겪고 있는 문제는 Many-To-One의 관계를 가지고 있기 때문에 default 동작이 EagerFetch에서의 상황이다. 그래서 구글링에서 발견한 해결책 대부분이 현재 나의 문제의 해결책에 해당되지 않는다고 생각했다.

글을 쓰면서 또 잠시 리서치를 하였는데 second-level Hibernate Query Cache를 이용하는 방법이 있었다. 왜 이 방법을 찾지 못했을까…

https://vladmihalcea.com/hibernate-query-cache-n-plus-1-issue/

2시간 더 넘게 리서치를 하고 실험을 했지만 원하는대로 동작하지 않아 결국에 NativeQuery를 사용하기로 마음먹게 되었다.

결국엔 NativeQuery로…

잠깐 쉬고와서 구현을 하기 전에 어떻게하면 native query를 잘 숨길 수 있을까에 대해서 고민하였다. 현재 만들어진 코드들을 최대한 변경하지 않으면서 native query만 저- 구석에 두고 싶었다. 추가되었는지도 모르고 혹시나 native query가 삭제되더라도 전-혀 문제가 되지 않도록.

필터링과 관련된 요구사항이 바뀌면 저 구석탱이에 있는 native query를 사용하는 모듈만 바꾸면 모든 것이 깔끔하게 해결될 수 있도록 코드를 작성하고 싶었다.

그래서 JPA 레포지토리를 커스터마이징하는 방법을 찾다가 다음과 같은 패턴을 사용하기로 결정했다.

기존의 코드는 모두 MessageRepository를 사용하고 있다. 그래서 MessageRepository 를 직접적으로 바꿔버리면 이를 사용하고 있는 코드들이 깨질 수 있다. 또한 MessageRepository 를 바꾸면 JPA에서 제공해주는 편리한 기능들을 더이상 사용할 수 없게 된다.

그래서 MessageRepository 인터페이스는 그대로 둔 채 필요한 기능에 대해서만 따로 인터페이스로 빼서 (CustomRepository 에 해당) 그것의 구현체를 만들기로 하였다.

위와 같이 코드를 분리한 결과 MessageQueryService는 더러운 native sql query문이 들어있는 MessageRepositoryImpl에 대해서 몰라도 된다. (하나 아쉬운 점은 MessageRepositoryImpl 이름인데 JPA에서 저 이름을 강제해서 원하는 다른 이름으로 바꾸지 못했다.)

MessageRepositoryImpl 구현

MessageRepositoryImpl을 구현할 때 고민했던 포인트에 대해서 적어보려고 한다.

일단 첫 번째는 도메인 객체는 위에서 봤다시피 Message.Event.Item과 같이 nested된 구조이다. 하지만 native query를 통해 가져온 데이터는 nested 하지않고 flat하다. 그래서 flat한 데이터를 담을 DTO와 이를 다시 도메인 객체로 변환할 Mapper가 필요하다. 두 번째도 위에서 잠깐 언급했는데 필터에 따라서 동적으로 native sql문이 바뀌어야한다. 그래서 QueryFactory도 둘까 생각 중이다.

그래서 결국엔 MessageRepositoryImpl이 온전하게 동작하기 위해서는 MessageRepositoryImpl 뿐만 아니라 FlatMessage (DTO), FlatMessageMapper, QueryFactory와 같은 것들이 추가적으로 필요하다.

하지만 여기서 중요한 점이 있는데 FlatMessage, FlatMessageMapper, QueryFactory 같은 것들은 MessageRepositoryImpl에게만 필요한 것이지 다른 모듈에겐 전혀 필요하지 않다. 그래서 굳이 이것들을 외부에 노출시키기보단 MessageRepositoryImpl 내부에 숨기기로 결정하였다.

MessageRepositoryImpl을 사용하는 클라이언트 객체는 사용하기가 훨씬 수월해진다. 또 중요한 것은 native query문을 바꿨을 때 동시에 FlatMessage, FlatMessageMapper, QueryFactory를 함께 바꿔줘야한다. 하지만 이를 모두 MessageRepositoryImpl의 inner 클래스로 두었기 때문에 코드 변경이 일어나는 범위가 MessageRepositoryImpl 클래스 하나로 한정된다.

그래서 사용성과 유지 보수 측면에서 이들을 모두 inner 클래스로 두는게 좋다고 생각하여 다음과 같이 MessageRepositoryImpl을 구현하였다.

다시 테스트! 하지만…

리뷰를 받고 코드가 합쳐진 후 다시 테스트 해보니 여전히 10초 정도 걸린다. 많이 줄었지만 내부에서 요구하는 시간은 최대 1~2초였다. 확실히 프론트에서 눈에 보기에도 넘어가는 속도가 답답하다.

이전에는 여러 쿼리문을 수행하여 속도가 느렸던 것을 쿼리 하나로 줄여서 속도를 개선시켰다. 다시 쉬면서 이제는 어디를 봐야할까에 대해서 생각했다. 생각이 다다른 곳은 ‘내가 만든 쿼리에 문제가 있는 것은 아닐까’ 였고 내가 만든 쿼리를 테스트할 방법에 대해서 리서치를 시작하였다.

Postgres에서 EXPLAIN ANALYZE <SQL> 을 사용하여 <SQL> 에 내가 테스트할 sql을 작성해서 실행시키면 해당 sql이 어느정도 시간이 걸릴지와 각 sql 구문에 대해서 어느정도 cost가 드는지를 보여준다.

참고했던 블로그의 샘플 결과를 가져왔다.

위와 같은 쿼리에 대해서 EXPLAIN ANALYZE 를 사용하면 다음과 같이 결과가 나온다.

보다시피 각각 구문이 얼마나 걸릴지 cost와 시간에 대해서 분석해준다.

현재 사용하고 있는 쿼리 분석 결과를 살펴보니 JOIN 문에 엄청난 cost와 시간이 들었다. Message에 해당 Event와 Item을 가져오기 위해서 JOIN문을 사용했기 때문이다. (그리고 분석 결과 덕분에 빼먹은 인덱싱도 발견하였다.)

그래서 JOIN을 모두 없앤 쿼리로 바꾸고 다시 검사를 해보니 확연히 낮은 cost의 결과를 보여주었다.

결과

결론부터 말하면 JOIN을 모두 없애도 1~2초대에 조회할 수 없었다 (5초 정도까지 나왔다). 혹시나 하는 마음에 stage 클러스터에 올려서 확인해보니 1초대에 결과가 나온다. 잉?

조금 허망하지만 꽤 큰 비중의 문제의 원인은 하드웨어 문제였다. dev 클러스터의 스펙이 1개의 cpu에 4기가 정도의 메모리 stage 클러스터는 그것의 5배 정도 되었다.

문제가 해결되었을 때 허망함 반, 안도 반이었다. 이번 문제를 해결해나가면서 했던 생각의 흐름들이 재미있었던 것 같다. 뿐만아니라 여러 상황들에 대해서 코드들을 어떻게 배치해나갈까 고민했던 시간들도 참 좋았다.

그렇지만 글을 쓰면서 아직 부족하다는 생각이 들 때가 있었는데 아직 JPA에 대해서 많이 모른다는 것과 좀 더 리서치를 잘했으면 문제를 더 빨리, 더 쉽게 해결할 수 있지 않았을까라는 것이었다.

Photo by Museums Victoria on Unsplash

EBI 아키텍쳐에 대해서

Entity-Boundary-Interactor(EBI) 아키텍쳐는 Robert C. Martin의 Clean Architecture에서 언급되었다. 그런데 사실 EBI 아키텍쳐는 1992년 Ivar Jacobson의 Object-Oriented Software Engineering: A use case driven approach에서 먼저 등장하였다.

EBI Architecture - introduction to object types
Ivar Jacobson 1992, pp. 169

Entity

Entity 객체는 시스템에서 사용되는 데이터들을 가지고 있고 시스템은 이 데이터를 통해 움직인다. Entity 객체는 도메인 문제를 해결하기 위해 존재하고 주로 Entity 객체는 identity를 가지고 있거나 persistent한 데이터를 가지고 있다.

Jacobson은 Entity 객체가 자신이 가진 데이터가 바뀌면 그에 따라 동작이 달라져야한다고 보았다. 예를 들어 어떤 시스템에서 사용되고 있는 자료구조가 바뀐다면 해당 자료구조에 맞춰서 시스템의 동작이 달라져야하고, 데이터가 달라짐에 따라 시스템의 동작이 달라지기 때문에 해당 자료구조와 그와 관련된 로직들은 Entity 객체 안에 담겨야 한다.

Jacobson이 1992년에 Entity 객체와 관련해서 다음과 같이 언급했다:

Beginners may sometime only use entity object as data carriers and place all dynamic behaviour in control objects […]. This should, however be avoided. […] Instead, quite a lot of behaviour should be placed in the entity objects.

Ivar Jacobson 1992, pp. 134

Boundary (Interface)

Boundary 객체는 시스템의 인터페이스를 정의한다.

[…] everything concerning the interface of the system is placed in an interface object

Ivar Jacobson 1992, pp. 134

외부 라이브러리 툴 혹은 데이터베이스, 메세지 큐와 같은 delivery mechanism들에 의존하는 모든 기능들은 Boundary 객체에 포함되어야한다.

Actor와 시스템의 상호작용은 Boundary 객체를 통해 일어나야한다. Jacobson은 Actor를 고객 혹은 관리자와 같은 사람으로 볼 수 도 있지만 알람, 프린터 혹은 외부 API와 같은 ‘사람이 아닌’ 것들도 있다고 보았다.

EBI Architecture - overview of Boundary object
Ivar Jacobson 1992, pp. 171

위와 같은 개념은 Ports & Adapters 아키텍쳐와도 밀접하게 연관이 있다. 그런데 Ports & Adapters 아키텍쳐는 무려 13년이 지난 2005년에 등장하였다.

Interactor (Control)

Interactor 객체는 Entity나 Boundary와 같은 다른 타입의 객체들이 할 수 없는 일을 한다. 예를 들어 Entity의 동작들과 Boundary 객체의 결과를 이어나감으로써 다른 큰 동작을 만들어 낸다.

Behaviour that remains after the Interface objects and Entity objects have obtained their parts will be placed in the control objects

Ivar Jacobson 1992, pp. 185

다시말해 Jacobson은 Entity 객체나 Boundary 객체가 할 수 없는 일들은 Control 객체에 담기게 된다고 말하고 있다. Control 객체는 여러 다른 객체의 동작들을 orchestrate할 뿐만 아니라 비즈니스 로직과 관련이 있지만 Entity나 Boundary 객체가 할 수 없는 일을 하게 된다.

DDD의 개념과 대조해보았을 때 Control 객체는 Application Service (도메인 객체들을 orchestrate 하는 역할)와 Domain Service(도메인 로직을 담고 있지만 Entity가 아닌 경우)의 역할을 한다고 볼 수 있다.

Interactor 객체가 중요한 이유는 만약에 우리가 Interactor 객체를 사용하지 않는다면 특정 use case의 비즈니스 로직을 Entity에 담게될 것인데 Entity는 본래 도메인 지식 내에서 다양한 상황에 쓰일 수 있도록 유연하게 설계되어야 한다.

Entity 객체에 특정 use case의 로직을 넣게 된다면 재사용율이 떨어질 것이고 이 Entity에 의존하는 다른 객체들의 로직들도 바뀌어야할 수도 있다. 따라서 전체 시스템의 복잡도를 증가시키게 된다.

Why 3 object types?

1992년 당시에 다른 OO 방법론에서는 모든 책임들과 로직들을 Entity 객체에 넣으려고 했었다. 하지만 Jacobson은 이 모든 책임들을 세 가지 타입으로 분류하여 각 책임들을 다른 타입의 객체에서 처리하고자 하였다. 결과적으로 이를 통해 좀 더 변화에 유연하게 바뀔 수 있는 시스템을 만들고자 하였다.

[…] all systems will change. Therefore stability will occur in the sense that all changes will be local, that is, affect (preferably) only one object in the system.

Ivar Jacobson 1992, pg. 135

EBI 아키텍쳐의 목표는 결국 서로 다른 타입의 객체들이 각자의 책임을 캡슐화를 함으로써 시스템에 변화가 필요할 때 그 변화가 시스템 전체에서 일어나지 않고 특정 부분에서만 바꿈으로써 문제가 해결될 수 있도록 하는 것이다.

이러한 철학은 10년 뒤에 Robert C. Martin의 “Agile Software Development, Principles, Patterns, and Practices“에서 언급한 Single Responsibility Principle 라고 알려져있는 원칙과 유사하다.

Conclusion

MVC 패턴에서 Model이 백엔드 시스템에서 모든 entity, service 그리고 그들의 관계를 나타내는 것처럼, EBI 아키텍쳐에서 Boundary는 view, controller 혹은 interface와 같이 외부에게와의 연결통로 역할을 하게 된다. 이는 MVC 패턴에서 View와 Controller의 역할을 한다고 볼 수 있다. 그리고 Entity는 실제 데이터를 가지고 있고 그와 관련해서 비즈니스 로직을 담고 있는 역할을 한다. 마지막으로 Interactor는 presentation 레이어와 Entity를 연결시키는 역할을 하는데 이는 Application Services와 Domain Service의 역할을 한다고 볼 수 있다.

Sources

1992 – Ivar Jacobson – Object-Oriented Software Engineering: A use case driven approach

2002 – Robert C. Martin – Agile Software Development, Principles, Patterns, and Practices

2002 – Robert C. Martin – Single Responsibility Principle

Eclipse Process Framework – Entity-Control-Boundary Pattern

Jon Pearce – Implementing Use Cases

2012 – Robert C. Martin – Clean Architecture (NDC 2012)

2014 – Adam Bien – How to tackle JEE

2014 – Ali Parvini – Model View Controller vs Boundary Control Entity


본 글은 아래 링크의 포스트를 번역한 글입니다. 의역과 오역이 있을 수 있습니다.

원본 링크: https://herbertograca.com/2017/08/24/ebi-architecture/

Ports & Adapters Architecture

Hexagonal Architecture로 알려져있는 Ports & Adapters Architecture는 2005년에 Alistair Cockburn 블로그에 소개되었다. 거기서 그는 Ports & Architecture의 목표를 한 문장으로 정리했다.:

Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases. – Alistair Cockburn 2005, Ports and Adapters

Ports & Adapters Architecture에 관한 글들 중에 layer들에 대해서 많이 얘기를 하는데 실제 Alistair Cockburn가 작성한 포스트에서는 layer에 관해서는 별로 언급이 없었다.

핵심은 application을 외부 기술적인 부분들과 분리시켜놓는 것이다. 그리고 그 둘 사이의 input과 output의 전달은 port를 통해서만 하게 된다. Application 입장에서 보았을 때 application은 누가 어떤 input을 보내고 output을 받는지 모르는 것이 좋다. 왜냐하면 application이 사용하는 기술적인 부분들과 비즈니스적인 요구사항들을 분리시켰을 때 기술적인 부분과 비스니스 측면에서 변화가 생기더라도 application 내부에서는 그 변화와 상관없이 독립적으로 개발할 수 있기 때문이다.

이번 포스팅에서는 다음 주제에 대해서 다뤄보려고 한다.

과거 방식의 문제점

과거의 접근 방식은 front-end와 back-end 쪽 둘 다에서 문제를 가지고 있었다. Front-end에서는 비즈니스 로직을 다루는 쪽에서의 문제점을 UI까지 끌고 오거나(예를 들어, use case logic을 controller나 view쪽에 넣어서 다른 UI 화면에서 재사용이 불가능하게 만들어버린다.) 아니면 반대로 UI에서 결함이 있는 부분을 비즈니스 로직에서 해결하려고 한다.(template에서 필요한 로직을 수행하기 위해 entity에 method를 추가해버린다.)

external technology corrupts application business

Back-end는 외부 라이브러리나 db와 같은 기술적인 부분의 문제점을 비즈니스 로직으로 끌고올 수 있다. 예를 들어, 외부 라이브러리를 직접적으로 참조(reference)한다던가 외부 라이브러리의 타입을 비즈니스 로직 내부에서 참조하고 더 심각하게는 비즈니스 로직 내부에서 외부 라이브러리 클래스를 인스턴스화 하는 것이다.

Layered Architecture으로부터의 발전

2005년 쯤에 EBIDDD가 소개되고 그 덕분에 전체 system과 관련이 깊은 것은 다름아닌 안쪽에 위치하고 있는 layer라는 것을 알게되었다. 안쪽의 layer에 모든 비즈니스 로직들이 들어가야했고(들어가는 것이 좋다.) 그 비즈니스 로직이 다른 어플리케이션과 실제로 다른 부분이 되었다.

그런데 Alistair Cockburn은 최상층부와 최하층부의 layer가 단순히 데이터들이 어플리케이션으로부터 나가고(exit point), 어플리케이션으로 들어오는 부분인 것(entry point)을 알게되었다. 최상층부와 최하층부는 실제로 다르지만 그 두 개가 비슷한 목표와 역할을 가지고 디자인적인 부분에서 대칭적이라는 것을 또한 알게되었다. 또한 만약 우리가 어플리케이션의 비즈니스 레이어와 분리시키고 싶다면 entry/exit points들을 이용해서 분리시키면 되었다.

port diagram

일반적인 layering diagram과는 다르게 이번엔 entry/exit point들을 각각 위와 다이어그램의 아래가 아니라 왼쪽과 오른쪽에 둘 것이다. 그런데 그림과 같이 Application 양쪽에 entry/exit point들이 여러개 있을 수 있다는 것을 알 수 있다. 실제로 entry/exit point가 여러개 있는 경우가 많은데 예를 들면, API와 UI는 왼쪽 편에 두 개의 다른 entry/exit points로 그려질 것이다. 반면에 ORM과 search engine의 경우 오른쪽 편에 그려질 것이다. Application이 여러개의 entry/exit points를 가질 수 있다는 것을 나타내기 위해서 application diagram을 다각형으로 그렸고 다이어그램은 얼마든지 다른 다각형이 될 수 있었다. 그런데 일반적인 다이어그램의 형태가 육각형(hexagon)이 되었기 때문에 “Hexagonal Architecture”으로 불린다.

hexagonal diagram

Ports & Adapters Architecture는 port와 adapter로 구현된 abstraction layer을 이용해서 이 문제를 해결했다.

Port란 무엇인가?

Port는 application 입장에서 consumer, 또는 application에서 나가거나/들어오는 끝 부분이라고 볼 수 있다.

많은 프로그래밍 언어에서 port는 interface로 표현된다. 예를 들어, search engine에서 검색을 수행하는 역할의 interface가 있을 수 있다. Application 비즈니스 로직에서 우리는 이 interface를 search engine의 구체적인 구현은 모른체 search engine을 사용하기 위한 entry/exit point로 사용할 것이다.

Adapter란 무엇인가?

Adapter는 한 interface를 다른 interface로 바꿔주는 클래스를 말한다.

예를 들어, 한 adapter가 interface A를 구현하고 그 adapter에서 interface B를 주입받는다고 생각해보자. 그 adapter는 인스턴스화할 때 constructor에서 interface B를 구현한 객체를 주입받을 것이다. 외부에서는 interface A가 필요할 때 이 adapter를 주입한다. 그리고 ineterface A의 method가 호출될 때마다 adapter 내부의 interface B의 객체가 호출된다.

두 가지 종류의 adapter

위의 다이어그램에서 왼쪽에 있는 adapter들은 Primary 또는 Driving Adapters라고 부른다. 왜냐하면 Driving Adapter에서 주로 application의 동작을 시작하기 때문이다. Driving Adapter에는 주로 UI 쪽이 들어간다.

반대로 오른쪽 편에는 back-end와 연결되는 부분이 들어간다. 오른쪽의 adapter들은 Secondary 또는 Driven Adapters라고 불리는데 항상 Primary adapter에 의해서 반응하고 동작하기 때문이다.

또한 두 종류의 adapter에서 어떻게 port와 adapter가 쓰이는지도 차이점이 있다.:

  • 왼쪽의 adapter는 port에 의존하고 있다. 실제로 사용될 때는 구체적인 port의 구현체가 adapter에 주입된다. 이 때 왼쪽에서 port와 그 구현체는 application 내부에 속하게 된다.
  • 오른쪽의 adapter는 port의 구체적인 구현체가 되고 application의 비즈니스 로직에 주입된다. 그렇기 때문에 Application 비즈니스 로직의 입장에서는 port의 interface만 알고 있다. 이 때 port는 application 내부에 속하지만 port의 구체적인 구현체는 application 외부에 속하게 된다. 그리고 그 구현체는 외부 tool(SMS lib, ORM 등)을 감싸고 있다.

diagram that describe how adapters and ports work

Port & Adapter Design의 장점은 무엇인가?

Port & Adapter Design을 사용하면 우리 application을 중심에 두고 외부적 요소와는 분리시킬 수 있다는 장점이 있다. 여기서 외부적 요소는 ORM이나 ES lib과 같은 외부 라이브러리 등이 있다. 우리는 이런 외부적인 요소들을 분리시키면서 구체적인 구현체는 몰라도 쉽고 빠르게 테스트할 수 있고 다른 곳에서 재사용할 수 있다.

구현체와 기술적인 부분의 분리

Context

예를 들어 우리가 SOLR을 search engine으로 사용하는 application을 만드려고 한다고 생각해보자. 그리고 우리는 우리의 application과 SOLR을 오픈소스 라이브러리를 사용해서 연결하고 조회라는 요구사항을 수행하려고 한다.

Traditional approach

과거의 방식을 사용하면 우리는 외부 라이브러리를 우리 코드베이스에 직접 사용하게 될 것이다. 외부 라이브러리의 타입을 직접 가져다 쓰고 구현체와 상위 클래스들을 직접 가져다 쓴다.

Ports and adapters approach

Ports and adapters design을 적용하면 우리는 먼저 interface를 만든다. 예를 들어 UserSearchInterface를 만든다고 생각해보자. 우리는 외부 라이브러리의 타입을 직접 가져다 쓰는 것이 아니라 UserSearchInterface를 대신 사용할 것이다. 그 다음으로 UserSearchSolrAdapter이라는 SOLR의 adapter를 만들자. 그리고 이것으로 UserSearchInterface의 함수들을 구현하게 된다.이것을 구현할 때는 SOLR 라이브러리를 wrapping하는 형식으로 만들어지고 나중에 실제 라이브러리를 외부에서 주입받게 된다. 그리고 UserSearchSolrAdapter의 함수를 호출할 때마다 내부의 SOLR 라이브러리가 호출된다.

Problem

시간이 지나면서 우리는 search engine을 SOLR에서 Elasticsearch로 바꾸고 싶어졌다. 그리고 같은 search 기능에 대해서 런타임때 한번씩은 SOLR을 이용해서 수행하고 다른 때에는 Elasticsearch 이용하고 싶다.

그런데 과거의 방식으로는 SOLR에서 Elasticsearch로 라이브러리 전환이 힘들다는 것을 알 수 있다. 왜냐하면 현재 SOLR이 사용되고 있는 부분을 찾아서 Elasticsearch 라이브러리로 교체를 해주어야하는데 SOLR과 Elasticsearch 라이브러리는 각각 다른 함수 이름을 가지고 있고 함수의 인자와 리턴값도 제각각이기 때문에 SOLR에서 Elasticsearch로 교체하는 것이 쉬운 일이 아님을 느낄 수 있다. 런타임때 두 개의 라이브러리를 번갈아가면서 쓰는 것은 불가능에 가깝다.

Ports & Adapters design에서는 반면에 이 문제를 해결하기 위해서 단순히 Elasticsearch 라이브러리를 위한 새로운 adapter를 만들면 된다. 그 새로운 adapter를 UserSearchElasticsearchAdapter라고 해보면 그 adapter를 SOLR adapter 대신 주입해주면 된다. 그리고 런타임때 어떤 라이브러리를 사용할지 결정하는 것은 Factory를 사용해서 어떤 adapter룰 주입할지 정해주면 된다.

전달 메커니즘의 분리

위의 예시와 비슷한 방식으로 이제 web GUI, CLI, web API를 사용하는 application이 있다고 생각해보자. 그리고 이 application에서 아까 나열한 총 세 가지의 UI에서 공통적으로 사용되는 기능이 있을 수 있는데 여기서는 예시로 그 기능이 UserProfileUpdate라고 해보자.

Ports & Adapters design에서 이 기능은 interface 형태로 되어있는 application service에 UserProfileUpdate 의 인자와 리턴 값이 명시되어 있을 것이다.

각각의 UI들은 각자의 controller(또는 console command)를 가지고 있을 것이다. 그리고 그 controller는 UserProfileUpdate를 가지고 있는 application service를 사용해서 함수를 호출할 것이며 그 구체적인 구현체는 외부에서 주입될 것이다. 이 때 controller(혹은 console command)가 adapter가 된다.

결국 우리는 UserProfileUpdate라는 비즈니스 로직은 건드리지 않은 채 UI를 바꿀 수 있게 된다.

테스트

Ports & Adapters Architecture에서는 테스트가 훨씬 쉬워 진다. 첫 번째 예시의 경우 port에 해당하는 interface를 이용해서 mock 객체를 만들어서 테스트를 할 수 있다. 그러면 실제 SOLR이나 Elasticsearch 라이브러리를 사용하지 않아도 된다.

두 번째 예시의 경우, 우리는 모든 UI를 application service와 분리한 채로 테스트 할 수 있다. application service에 해당하는 interface를 mock 객체로 만들면 되기 때문이다.

마무리

지금까지 살펴본 Ports & Adapters Architecture에서는 한 가지의 목표를 가지고 있다.: 전체 시스템에서 application의 비즈니스 로직을 외부 라이브러리 및 툴(tools)로부터 분리(isolate) 시키는 것이다. 그리고 이 분리는 interface를 사용해서 달성할 수 있다.

UI(the driving adapters)에서는 application의 interface를 사용하는 adapter를 만들었다. (controller)

Infrastructure side(the driven adapters)에서는 application의 interface를 구현하는 adapter를 만들었다.(repositories)

Sources

1992 – Ivar Jacobson – Object-Oriented Software Engineering: A use case driven approach

200? – Alistair Cockburn – Hexagonal Architecture

2005 – Alistair Cockburn – Ports and Adapters

2012 – Benjamin Eberlei – OOP Business Applications: Entity, Boundary, Interactor

2014 – Fideloper – Hexagonal Architecture

2014 – Philip Brown – What is Hexagonal Architecture?

2014 – Jan Stenberg – Exploring the Hexagonal Architecture

2017 – Grzegorz Ziemoński – Hexagonal Architecture Is Powerful

2017 – Shamik Mitra – Hello, Hexagonal Architecture

본 글은 원작자의 허가를 받고 번역한 글입니다. 의역과 오역이 있을 수 있습니다. 원본 링크: https://herbertograca.com/2017/09/14/ports-adapters-architecture/#implementation-and-technology-isolation

Domain-Driven Design Key Concepts

현재 오픈소스로 활동하고 있는 it-chain-Engine에서 적용 중인 아키텍쳐, 디자인에 대해서 조금 더 자세하게 알아보고 개념에 대해서 포스팅해보려고 한다. 그 중에 첫 번째가 DDD(Domain-Driven-Design)이다.

본 글은 원작자의 허가를 받고 번역한 글입니다. 의역과 오역이 있을 수 있습니다.

원본 링크: https://herbertograca.com/2017/09/07/domain-driven-design/

Domain-Driven Design과 관련해서 엄청나게 많은 중요한 컨셉들이 있지만 여기서 그것들에 대해서 모두 다루는 것은 아니고 중요하다고 생각하는 개념들에 대해서 나열하고 그것들에 대해서 설명해보려고 한다.

이번 포스팅에는 다음과 같은 개념들에 대해서 적어보려고 한다.

  • Ubiquitous language
  • Layers
  • Bounded contexts
  • Anti-Corruption Layer
  • Shared Kernel
  • Generic Subdomain

Ubiquitous language

소프트웨어 개발에서 계속 생기는 문제 중 하나는 코드를 보면서 이것이 무엇을 하는 것이고, 어떻게 동작하는지에 대해서 이해하는 것이 어렵다는 것이다. 만약 코드를 만든 사람이 어떤 코드에 대해서 ‘A’라고 말하는데 실제로 코드는 ‘B’와 관련된 것이라면, 그 코드를 보는 사람들은 더더욱 혼란스러워질 것이다. 하지만 이러한 문제들은 class와 method에 더욱 적절한 네이밍을 해주면 어느정도 사라지게 될 것이다. 여기서 ‘적절한 네이밍’이란 도메인 컨텍스트에 대해서 어떤 object가 어떤 일을 하고 어떤 method가 어떤 일을 하는지 더욱 명확하게 표현하는 것을 말한다.

Ubiquitous Language의 메인 아이디어는 앞으로 구현하게 될 application 및 technology와 요구사항에 해당하는 business을 매칭시키는 것이다. 매칭시킨다는 의미는 둘 사이간에 의미를 공유할 수 있는 공용어(common language)를 두는 것이다. 그리고 이렇게 만들어진 공용어를 이용해 코드를 작성하게 된다. 요구사항에 해당하는 business에서 용어의 컨셉을 가져온다 그 다음 그것을 구현할 technology 쪽에서 그것을 다듬고 확정하게 된다. (business에서 가져온 컨셉으로는 항상 좋은 네이밍을 가져가기 힘들다.) 이렇게 둘 사이의 입장을 반영한 용어를 만듦으로써 우리는 business와 technology에서 사용할 수 있고, 앞으로 우리가 작성할 코드에서도 모호함 없이 사용할 수 있다. 이것이 Ubiquitous Language라고 할 수 있다. 코드에서 사용될 class, methods, properties, modules의 네이밍은 Ubiquitous Language와 매칭돼야 한다.

Layers

다른 디자인에서도 layer라는 컨셉은 사용되지만, DDD에서 특징적인 layer는 다음과 같다.

  • User Interface

    User Interface에서는 사용자들이 상호작용할 수 있는 screen을 만들고 사용자들의 input을 application의 명령들(commands)로 변환한다. 여기서 중요한 점은 사용자(user)들은 사람이 될 수도 있지만 어떤 application이 다른 application의 api를 사용한다면 그 application도 사용자가 될 수 있다.

  • Application Layer

    사용자들이 요구하는 tasks들을 수행하기위해 domain object들을 사용한다. Application Layer는 busniess logic을 가지고 있지 않고 Application Service가 포함된다. Application Service는 domain object에 해당하는 repository, domain service, entity, value object을 가지고 그것들을 조합함으로써 필요한 목표를 달성한다.

  • Domain Layer

    Domain Layer에서 Domain Services, Entities, Events와 같은 domain object들은 필요한 모든 business logic들을 담고 있다. 그렇기 때문에 Domain layer가 전체 시스템에서 핵심이라고 할 수 있다. Domain Services는 Entity에 딱 맞지 않는 로직이 들어가거나 혹은 몇몇 Entity들을 이용해서 business logic을 처리한다.

  • Infrastructure

    persistence나 messaging과 같이 상위 계층에서 layers들을 서포트해주는 것들이 포함된다.

Domain-Driven Design layer diagram
Eric Evans, 2003

Bounded Contexts

기업용 application에서는 model이 커질 수 있고 그에 따라 같은 코드베이스에서 작업을 하는 개발자의 수가 늘어날 수 있다. 그런데 그에 따라서 두 가지 문제점이 발생할 수 있다.

  1. 한 코드 베이스에서 작업하는 개발자의 수가 늘어날수록 한 사람이 알아야하는 코드의 양이 많아지고, 여러명이 작업하기 때문에 코드를 이해하기도 어려워진다. 그렇기 때문에 버그나 에러가 생길 가능성이 커지게된다.
  2. 같은 코드 베이스에서 작업하는 개발자가 많아질수록 작업을 조율하기가 힘들어지고 같이 쓰는 domain 코드들이나 기술적인 부분이 많아지게 된다.

위와 같은 상황을 해결할 수 있는 방법 중 하나는 많은 개발자들이 공동으로 작업해야하는 코드베이스들을 쪼개는 것이다. 그리고 이렇게 쪼갤 수 있게 도와주는 것이 “bounded context”이다.

Bounded context는 model들이 분리되어 적용될 수 있는 context를 말한다. 이 ‘분리’는 기술적인 분리나 코드의 분리, 데이터베이스 schema의 분리 그리고 팀의 분리 등을 말한다. 이러한 분리의 여러 단계 중에서 어디까지 실제 프로젝트에서 적용할지는 그때 그때 상황과 필요에 맞춰서 이뤄진다.

그런데 이러한 bounded context를 이용한 분리라는 개념은 완전히 새로운 것은 아니다. Ivar Jacobson이라는 사람이 1992년에 그의 에 ‘subsystems’이라는 비슷한 개념에 대해서 서술한 적이 있다.

Subsystems description diagram
Ivar Jacobson, 1992

그 때 당시 그가 서술했을때 이미 다음과 같은 구체적인 아이디어를 가지고 있었다:

  • 전체 system은 여러 개의 subsystem들로 이루어져있다. 그리고 그 각각의 subsystem들은 또다시 subsystem을 가질 수 있다. (중략…) Subsystem들로 전체 system을 구성하는 것은 그렇기 때문에 전체 system을 개발하고 유지보수할 수 있는 방법이다.
  • Subsystem의 과제는 객체들을 패키징하는 것이다. 그렇게 함으로써 전체 시스템의 복잡도가 줄어들게 된다.
  • 특정 기능과 관련된 모든 객체들은 같은 subsystem에 두어야한다.
  • 이렇게 특정 기능과 관련된 객체들을 묶는 것은 같은 subsystem에서는 strong function coupling을 가지고 다른 subsystem과는 weak coupling을 가지게하기 위해서다. (오늘날에는 low coupling, high cohesion으로 알려지고 있는 개념이다.)
  • 한 subsystem에서는 한 명의 actor와만 coupled 되어야한다. 왜냐하면 system의 변화는 주로 actor에 의해서 일어나기 때문이다.
  • (중략…) control object들을 subsystem에 두기 시작하였고 그것들과 강한 결합을 가지는 entity object들과 interface들을 같은 subsystem 내에 두었다.
  • object 사이에서 기능적으로 강한 결합을 가진 것들은 같은 subsystem 내에 두어야한다. (중략…)
    • 어떤 한 object의 변화가 다른 object에도 영향을 미치는가?(지금 이 개념은 현재 Robert C. Martin이 1996년도에 “Granularity”라는 논문에 서술한 The Common Closure Principle ― 같이 변하는 class들은 같은 package로 묶여야한다.―로 알려져있다.)
    • 같은 actor에 대해서 커뮤니케이션하는가?
    • *어떤 두 object가 interface나 entity와 같은 다른 제 3의 object에 의존하는가? *
    • 어떤 object가 다른 object들에게 영향을 주고 있는가?(지금 이 개념은 현재 Robert C. Martin이 1996년도에 “Granularity”라는 논문에 서술한 The Common Reuse Principle ― 같이 사용되는 class들은 같은 package로 묶여야한다.―로 알려져있다.)
  • 또다른 subsystem 분리 기준은 그들 사이에서 최소한의 커뮤니케이션만 일어나야한다는 것이다.(low coupling)
  • 사이즈가 큰 프로젝트에서는 그렇기 때문에 subsystem을 나누는 다른 기준이 있을 수 있다. 예를 들어:
    • 다른 두 개발 팀은 다른 능력과 자원을 보유하고있고, 그에 따라 적절하게 작업량을 분배해야할 것이다.(두 팀이 지리적으로 떨어져있을 수도 있다.)
    • 분산 환경에서는, 각각의 subsystem은 각각의 logical node에 의해 사용될 수 있다.(SOA, web services and micro services)
    • 만약 어떤 product가 현 시스템에서 사용되려면, 그것은 또다른 subsystem으로 여겨져야할 것이다.(시스템이 ORM과 같은 library에 의존하는 경우)

Anti-Corruption Layer

Anti-corruption layer는 기본적으로 두 subsystem 사이에서 middleware역할을 한다. 그리고 나눠진 subsystem들은 서로 직접적으로 의존하는 대신 anti-corruption layer에 의존하게된다. 이렇게 하게되면 한 subsystem을 완전히 다른 것으로 바꾸더라도 우리가 수정해야할 부분은 anti-corruption layer밖에 없다. 다른 subsystem들은 그대로 두어도 된다.

Anti-corruption layer는 legacy 시스템과 새로운 시스템을 합쳐야할 때 유용하다. legacy 구조가 새롭게 디자인한 구조에 영향을 주지 않으려면 anti-corruption layer를 두어서 새로운 시스템에 꼭 필요한 legacy system의 API만 가져오면 된다.

이것은 세 가지 역할을 가지고 있다:

  1. Client subsystem이 필요한 API를 또다른 subsystem에서 가져오는 것
  2. Subsystem 사이에서 data와 command를 전달하는 것
  3. 단방향이든 양방향이든 필요함에 따라 서로 다른 subsystem들 사이에 communication을 형성해주는 것

Roles of anti-corruption layer in diagram
Eric Evans, 2003

이것은 여러 subsystem들을 직접 제어하고 싶지 않을 때 더욱 유용하다. 그런데 이것은 모든 subsystem들을 컨트롤하고 싶을 때에도 유용할 수 있다. 그리고 아주 성격이 다른 model들이 있을 때와 한 model을 바꿨을 때 나머지 시스템에 줄 수 있는 영향을 줄이고 싶을 때에도 좋다.

Shared Kernel

우리가 아무리 컴포넌트를 분리시키고 decouple 하려고해도 어떤 domain 코드들은 여러 컴포넌트에서 공유하는게 더 좋을 때도 있다.

이렇게 공유가 필요한 코드들을 여러 컴포넌트에 공유하는 부분이 Shared Kernel이고, Shared Kernel을 두게 되면 shared kernel과는 강하게 결합이 생기지만 나머지 컴포넌트끼리는 여전히 분리시킬 수 있다.

예를 들어, 어떤 컴포넌트가 event를 전파시키고 다른 컴포넌트들이 그 event를 listen하는 경우라면 event가 shared kernel 부분에 들어갈 수 있다. 물론 service interface나 entity들도 shared kernel에 포함될 수 있다.

그렇지만 shared kernel은 가능한 작게 두는 것이 좋다. 그리고 shared kernel 코드는 다른 컴포넌트들에서 사용될 가능성이 높기 때문에 이 부분을 수정할 때 어떤 다른 부분에 영향을 주는지 확인하는 것이 좋다. 그렇기 때문에 shared kernel 코드들을 수정할 땐 전체 팀원과 충분한 상의와 합의 후에 작업하는 것이 좋다.

Generic Subdomain

Subdomain은 완전히 분리된 domain을 말한다. 그리고 generic subdomain은 현재 우리가 작업 중인 application 뿐만 아니라 비슷한 성격의 다른 application에서도 충분히 쓰일 수 있는 subdomain을 말한다.

만약 우리가 금융과 관련된 부분이 우리 프로젝트에 있다면, 다른 곳에서 만들어진 금융 관련 library를 사용할 수도 있을 것이다. 그 library를 가져와서 쓰든 안쓰든, 만약 그 금융 부분이 generic subdomain이라고 한다면 우리 application의 꼭 필요한 부분이지만 핵심 business는 아니다. 그렇기 때문에 팀이 작업할 때 그 부분은 엄청 신경써야할 부분이 아니고 또 결정하기에 따라서 dependency management tool을 이용해서 아예 소스코드 밖에 두어도 된다.

Conclusion

여기서 소개된 DDD와 관련된 개념들은 대부분 single responsibility, low coupling, high cohesion, isolating logic과 관련된 것들이다. 그래서 이러한 것들은 application을 더욱 견고하고 요구사항에 따른 변화에 잘 적응할 수 있게 만들어준다.

Sources

1992 – Ivar Jacobson – Object-Oriented Software Engineering: A use case driven approach

1996 – Robert C. Martin – Granularity

2003 – Eric Evans – Domain-Driven Design: Tackling Complexity in the Heart of Software

2014 – Eric Evans – Domain-Driven Design Reference

본 글은 원작자의 허가를 받고 번역한 글입니다. 의역과 오역이 있을 수 있습니다.

원본 링크: https://herbertograca.com/2017/09/07/domain-driven-design/