저는 저희 팀 프로젝트를 확장 가능한 구조로 만들고자 포트어댑터 아키텍처를 알아보았습니다.
포트와 어댑터를 간단하게 설명하자면 애플리케이션이 외부 요소(인프라)등에 관련없이 지속가능하게 만드는 것이 목표이며, 포트는 인터페이스고 어댑터는 구현하는 클래스를 말합니다.
- 외부에 의해 동작하는 포트를 주포트 (구현은 주어댑터)
ex) MVC의 Controller와 같은 외부에서 애플리케이션과 통신하고자 할때 필요한 포트
- 내부적으로 외부의 프로토콜과 통신하는 것을 부포트 (구현은 부어댑터)ex) MVC의 DAO와 같은 애플리케이션이 외부와 통신할 때 필요한 필요한 포트
현재는 MVC구조로 HTTP API → 서비스 → Repository(MySQL) 로 구성되어있는데
이중 HTTP API가 grpc로 변경되거나, MySQL이 Redis, Oracle 등으로 바뀌어도 애플리케이션(서비스)가 변경되어선 안된다는게 포인트입니다.
- 만약 변경이 필요해지면 많은 부분을 수정해야할 수 있습니다.

위 그림을 보면 어댑터는 다른 계층을 침범하지 않고 다른 계층은 오직 포트에만 의존함을 알 수 있습니다.
포트는 변경이 잦은 구현체(어댑터)와 실제 애플리케이션간의 결합도를 낮춰주는 중간역할을 합니다.
- 위 그림을 해석해보면, HTTP API가 RPC로 변경되거나 MySQL이 Redis로 변경되어도 애플리케이션은 전혀 영향을 받지 않습니다.
- 애플리케이션(예를들면 서비스의 구현)이 변경되어도, 도메인(엔티티 등)은 전혀 영향을 받지 않습니다.
RPC(Remote Procedure Call)는 별도의 코딩없이 원격의 함수나 프로시저를 실행시킬 수 있는 통신기술이다. (gRPC는 구글에서 성능을 향상시킨 프레임워크이다.) IPC(Inter Process Comunication) 방법의 한 종류고, MSA 환경에서 많이 사용된다.

프로젝트를 진행하던 중, 팀원분께 아래와 같은 피드백을 받았습니다.

요약 Controller-Service-Repository가 같은 데이터가 필요하므로 하나의 DTO로 재사용해도 되는데, 결합도를 낮추기 위해 각 계층마다 다른 DTO를 사용하면 new
명령으로 메모리 사용량이 크게 늘고 성능에 영향이 생기지 않겠는가?
성능 차이?
생각해보니 저도 궁금해서 과연 new
가 성능에 큰 영향을 미칠지 확인해보았습니다.
요청이 많아지는 상황을 가정하여 새로운 쓰레드안에서 인스턴스를 생성하는 루프를 '1억'회 실행해보았습니다.
ExecutorService executorService = Executors.newFixedThreadPool(4);
// 1억건의 요청
for (int i = 0; i < 100_000_000; i++) {
// 요청의 내용은 Prob1의 인스턴스를 arraylist에 저장하는 것이다.
// 이 코드에서는 j < 1 로 1회만 추가하지만, 20으로 바꾸어서도 실험해보았다.
executorService.execute(() -> {
ArrayList<Prob1> al = new ArrayList<>();
for (int j = 0; j < 1; j++) {
al.add(new Prob1());
}
});
}
/**
* 결과
*
* 1회 생성
* [17.938s][info][gc,heap,exit ] Heap
* [17.938s][info][gc,heap,exit ] garbage-first heap total 3267584K, used 2050864K [0x0000000700000000, 0x0000000800000000)
* [17.938s][info][gc,heap,exit ] region size 1024K, 462 young (473088K), 57 survivors (58368K)
* [17.938s][info][gc,heap,exit ] Metaspace used 4944K, capacity 7186K, committed 7424K, reserved 1056768K
* [17.938s][info][gc,heap,exit ] class space used 647K, capacity 986K, committed 1024K, reserved 1048576K
*
* 20회 생성
* [18.860s][info][gc,heap,exit ] Heap
* [18.860s][info][gc,heap,exit ] garbage-first heap total 3751936K, used 2589488K [0x0000000700000000, 0x0000000800000000)
* [18.860s][info][gc,heap,exit ] region size 1024K, 568 young (581632K), 93 survivors (95232K)
* [18.860s][info][gc,heap,exit ] Metaspace used 4918K, capacity 7186K, committed 7424K, reserved 1056768K
* [18.860s][info][gc,heap,exit ] class space used 647K, capacity 986K, committed 1024K, reserved 1048576K
*/
결론
생성한 인스턴스의 수는 20배나 차이나지만, 두 결과에는 큰 차이가 없습니다.
실행할때 마다 결과가 조금씩 다르긴 했지만, GC 발생 빈도도 차이가 없거나 2회정도 차이가 났습니다.
메모리 사용량 (region size)를 보더라도, 20회 인스턴스 생성이 더 많긴 하지만 큰 차이는 없어 보입니다.
이펙티브 자바 아이템 67을 보면 빠른 프로그램보다 좋은 프로그램을 작성하라고 나옵니다. 좋은 프로그램은 컴포넌트들을 독립적으로 개발 가능하고, 사이드 이펙트 없이 재설계가 가능합니다.
그러나 빠른 프로그램을 작성하려다 보면 설계를 희생해야할 수 있습니다.
같은 페이지에 나온 최적화에 관한 격언이 있습니다. - 하지마라. (M.A. 잭슨)
그리고 DTO를 하나만 사용했을 때 문제점
결합도가 크게 상승합니다. 지금 구조에서 DTO를 하나만 사용하게 되면 주포트-어댑터에서 사용된 DTO를 애플리케이션도 알게되고, 부포트-어댑터까지 알게됩니다. 그럼 어떤 문제가 생길까요?
- (부포트) 만약 MyBatis → Data JPA로 마이그레이션 한다면?
- JpaRepository는 제네릭타입으로 엔티티를 받아서, 엔티티 타입을 기반으로 매핑하는데, DTO를 파라미터로 받으면 jpa가 제공하는 인터페이스를 활용하지 못하고 dto별로 쿼리를 만들어 줘야합니다.
- (주포트) 만약 HTTP API + RPC 를 붙인다면?
- gRPC 서비스는 IDL을 사용하고, IDL을 컴파일하면 각 언어에 맞는 코드가 생성되고, 그 안에
message
라는 별도의 DTO를 얻게 된다.
- 그런데 서비스의 인터페이스에서 HTTP API에서 사용하던 dto를 알고있다면,
message
라는 DTO를 전혀 무관한 HTTP API의 DTO로 변경해서 Service를 호출해야한다.
- 근데 만약 HTTP API의 DTO에 변경이 생겼다.
- 그럼 전혀 무관한 gRPC의 DTO까지 변경되야한다.
- gRPC 서비스는 IDL을 사용하고, IDL을 컴파일하면 각 언어에 맞는 코드가 생성되고, 그 안에
- 기타 등등.. 회원가입 form이 바뀌면 모든 계층이 영향을 받는 등의 문제가 생길 수 있습니다.
IDL 인터페이스 정의 언어(Interface Description Language 또는 Interface Definition Language, IDL)는 소프트웨어 컴포넌트의 인터페이스를 묘사하기 위한 명세 언어이다. IDL은 어느 한 언어에 국한되지 않는 언어중립적인 방법으로 인터페이스를 표현함으로써, 같은 언어를 사용하지 않는 소프트웨어 컴포넌트 사이의 통신을 가능하게 한다.