스프링 3.1 - 애플리케이션 아키텍처

김법우·2023년 5월 21일
0

Spring

목록 보기
4/4

계층형 아키텍처

들어가며

아키키텍쳐는 어떤 경계 안에 존재하는 내부 구성요소들이 어떤 책임을 가지고 어떤 방식으로 관계를 맺으며 동작하는지에 대한 규정이라고 할 수 있다. 정적인 구조뿐만 아니라 동적인 행위와 깊은 연관이 있음을 유의하자.

계층형 아키텍쳐

아키텍쳐와 SoC

객체 레벨에서 객체간 관계를 설정한다거나 구조를 그릴때 인터페이스와 같은 유연한 경계를 두고 분리하거나 응집시키는 경우 수정의 전파를 제한시키고 안정성을 증가시키는 동시에 생산성도 확보 할 수 있다.

이는 객체 레벨보다 조금 더 큰 단위에서도 동일하게 동작한다. 즉, 성격과 책임이 다른 객체들을 그룹을 지어 분리하는 것이 객체 레벨에서 가져왔던 이점을 동일하게 취할 수 있는 것이다.

이것을 계층형 아키텍쳐 혹은 멀티 티어 아키텍쳐라고 한다. 일반적으로 웹 애플리케이션은 3계층으로 이루어지는데, 여기에 대해서 자세히 알아보자.

3계층 아키텍처 : 데이터 엑세스 계층

DAO 계층, EIS(Enterprise Information System) 계층이라고도한다. 데이터베이스뿐만 아니라 레거시 시스템, 메인 프레임등에 접근하는 역할을 수행하기 때문이다.

외부 시스템을 호출해 서비스를 이용하는 것을 기반(infastructure) 계층으로 따로 분류하기도 한다.

위는 스프링의 JdbcTemplate 으로 구현된 DAO 코드에 대한 수직적 구조를 나타낸 도식이다.

여기서 “수직적”이라는 단어가 핵심인데 DAO, JdbcTemplate, Jdbc, Transaction Sync, DataSource … 등등은 데이터 엑세스 계층에 속한다. 하지만 추상화 레벨이 위로 갈수록 높은 구조이다. 따라서 수직적이라는 의미는 추상화의 수준을 표현한 것이라고 할 수 있다.

계층간 협력을 할때는 예외적인 상황이 아닌 경우에야 추상화가 가장 높은 단계와 통신을 수행할 것이다. 일반화된 접근을 사용하기 위해서이다. 그런만큼 추상화가 잘되어있다면 계층간 유연한 구조를 유지하는데 큰 도움이된다.

얼마든지 추상화 계층은 추가할 수 있다. 하지만, 추상화 계층을 추가하게되면 하위 계층의 변화에 대응해야하는 책임을 갖게되므로 신중하게 설계해야한다. 하위 계층의 변화가 잦거나 안정성이 떨어지면 배보다 배꼽이 더 큰 상황이 된다.

3계층 아키텍처 : 서비스 계층

비즈니스 핵심 로직을 POJO 로 구현한 객체들이 모인 계층이다.

엔터프라이즈 애플리케이션에서 가장 중요한 자산은 도메인의 핵심 비즈니스 로직이 들어있는 서비스 계층이어야한다. 따라서, POJO 로 작성해 엔터프라이즈 기술에 대한 종속성을 제거하고 비즈니스 로직의 확장과 변경에 유연하도록 설계해야하는 것이다.

이 책에서 말한 “이상적인 서비스 계층은 데이터 엑세스 계층과 프레젠테이션 계층이 모두 바뀌어도 그대로 유지 될 수 있어야한다” 이라는 표현이 인상깊었는데, 매 스프린트마다 기능을 추가하고 수정하다보니 어렴풋이나마 기계의 기어를 갈아끼우듯이 로직을 교체 할 수 있는 것이 가장 좋은 설계일것 같다고 생각하던 와중이었기 때문이다.

계속해서 이야기하는 부분이지만, 이를 위해서는 반드시 서비스 계층에서 데이터 엑세스 계층 혹은 기반 서비스 계층에 접근할때는 추상화된 인터페이스를 통해서 접근해야하고 AOP 를 통해 서비스 계층의 코드에 부가기능이 침투하지 않도록 만들어야한다.

3계층 아키텍처 : 프레젠테이션 계층

최근의 프레젠테이션 계층은 클라이언트까지 범위가 확장 될 수 있다. RIA(Rich Internet Application), SOFEA(Service Oriented Front End Architecture) 가 대표적인 예로 화면을 어떻게 그리고 화면 흐름을 결정하고 포맷의 변화를 처리하는 것을 클라이언트에 이동시킨 것이다.

부끄러운 말이지만 나는 아직까지 프레젠테이션 계층에서 화면을 그리는 등의 로직을 처리해본 경험은 없다. HTTP 프로토콜을 기반으로 Restful 스타일의 서버 애플리케이션만을 개발해보았다. 그러다보니 프레젠테이션 계층에서 수행하는 역할은 HTTP 요청에 대한 처리와 인증/인가, 데이터나 예외 반환 처리 정도였다.

스프링은 웹 기반의 프레젠테이션 계층 개발이 가능한 엄청 다양한 웹 프레임워크를 제공해주므로 조만간 한번 토이 프로젝트로라도 만들어봐야 진정한 프레젠테이션 계층에 대해 이해하지 않을까 싶다.

설계 원칙

앞서 말했듯이 객체 레벨에서 고수했던 설계 원칙의 기조를 그대로 따라간다. 계층 단위에서 응집도를 높이고 결합도를 낮춰 유연한 관계 설정을 하도록 설계해야 한다는 것이다.

3계층 아키텍처에 바탕해 예시를 들어보자면 서비스 계층이 컨트롤러에서 수행해야할 작업을 같이 수행하는 경우이다. 어떻게 화면에 결과를 뿌릴지에 대해서 아는 것은 당연히 복잡도를 증가시키는데, 서비스 메서드가 프레젠테이션 계층에서 사용되야할 예외를 처리하는 것 또한 책임과 역할을 침범한 것으로 볼 수 있다.

@Injectable()
export class AService {
	...
	async test(time: number) {
		... 
		if(time < 10) {
			throw new BadRequestException('dadsfasdf');
		}
	}

시간이 10 보다 작으면 오류를 반환하는 로직이 있다고 가정해보자. 이때 10보다 작은 경우는 입력값이 잘못된 경우이고 컨트롤러에서 해당 메서드를 호출해 예외처리를 별도로 하지 않고 반환하고자 위의 코드처럼 작성한 경우 위 서비스 코드는 사실 단 하나의 요구사항만을 충족시키기 위한 용도로 전락할 수 있다.

BadRequestException 은 HTTP 상태 코드를 포함하는 예외 랩핑 객체인데, 이것을 서비스 코드에서 구성해 반환하게 되면 다른 서비스에서 해당 로직을 사용하고자 호출 할 때 문제가 발생한다.

위 메서드뿐만 아니라 다른 서비스 메서드들도 다 BadRequestException 을 던진다고 가정해보자. 그런데 특정 에러에 대해서는 InternalServerError 500 으로 반환해야한다면 메세지로 판단하거나 불가피하게 메서드를 수정해야 할 수도 있다.

@Injectable()
export class AService {
	...
	async test(time: number) {
		... 
		if(time < 10) {
			throw new TimeValidationFailedError********();********
		}
	}

따라서 Error 를 상속하는 예외 객체를 만들거나 서비스 계층에서 공통으로 사용하는 예외를 사용하도록 하는 것이 확장과 수정에 유연하다고 할 수 있다.

위의 예시로 말하고자하는 부분은 예외 객체 같이, 어쩌면 큰 영향이 없을 것 같다고 판단할만한 부분도 계층간 종속을 발생시키고 이것이 큰 파장을 일으킨다는 점이다.

애플리케이션 정보 아키텍처

전제로 해야할 부분은 엔터프라이즈 애플리케이션에서 애플리케이션이 사용자의 요청을 처리하기 위해 간단한 상태만을 유지한다는 점이다. 클라이언트-서버 구조에서 지속적으로 관리해야할 정보들은 DB, 메인프레임 시스템에 저장되고 임시 상태 정보는 세션, 클라이언트 애플리케이션 등에 저장된다.

이러한 임시 정보, 지속적 관리 정보들을 처리하는 방식에 대한 관점을 기준으로 크게 데이터 중심 아키텍처, 오브젝트 중심 아키텍처로 나누어 이해해보자.

데이터 중심 아키텍처

DB/SQL 중심의 로직 구현 방식

가장 큰 특징은 단일 요구사항을 구현하는데 있어 단일 업무 트랜잭션에 모든 계층의 코드가 종속되는 경향이 있다는 점.

특징- SQL 은 이미 어떤 데이터가 어떻게 화면에 그려질지 알고 있다.
- SQL 의 결과는 컬럼 이름을 키로 갖는 맵 혹은 단순한 오브젝트에 담겨 전달된다.- 일반적으로 재사용이 불가능하다.
장점- 개발하기 쉽다. 각 개발자는 자신이 구현해야할 요구사항에 맞는 프레젠테이션 오브잭트, 서비스 오브젝트, DAO 오브젝트, SQL 을 묶어 한벌씩 개발하면 된다.
단점- 애플리케이션 프레임워크를 단순히 웹 인터페이스와 DB 를 연결하는 도구로 전락시킨다. 객체지향 설계와 계층형 구조의 장점을 전혀 사용하지 못하는 애플리케이션이 된다.
  • 변화에 매우 취약하다. 계층간 한벌이 하나의 요구사항을 만드는 만큼 하나가 수정되면 모든 계층에 수정이 전파된다.
  • DB 리소스 사용에 대한 부하가 커진다. DB 리소스는 공유 자원이며 서버사양을 높이는 것 보다 훨씬 값비싼 자원이다. |

해당 아키텍처의 구조는 보이는 것처럼 매우 단순하다. 계층을 나누어 놓기는 했지만 사실상 계층을 나눔으로서 같는 큰 의미는 없다. 왜냐하면 모든 계층의 로직이 SQL/프로시저의 반환값을 기준으로 작성되기 때문이다. 따라서 반환되는 컬럼이 바뀌거나 반환 코드가 바뀌게되면 모든 계층의 코드는 수정되어야한다.

나도 처음에는 통계 데이터를 뽑거나 복잡한 쿼리를 써야할때면 SQL 을 작성하고 결과를 타입스크립트 오브젝트에 담아 그냥 반환하고는 했다.

내가 경험한 이 방식의 가장 큰 문제는 재사용성의 하락에 따른 중복 코드의 난립이다. SQL 이 최초 특정 요구사항에 맞게 설계되었고 이 결과가 객체와는 다르게 재사용이 어렵기 때문에 자연스럽게 중복 코드가 많아지게 된다.

중복코드가 많아지면 결과적으로 유지보수하기가 매우 힘들어진다. 확장성도 떨어지게된다. 언제까지고 같은 코드를 칠 수는 없는 노릇이다.

거대한 서비스 계층 방식

위 아키텍처에서 발생하는 단점의 원인은 DB 에서 많은 비즈니스 로직을 처리하는 점이다. 거대한 서비스 계층 방식은 DB 에서 좀 더 일반화된 데이터를 가져오고 서비스 계층에서 데이터를 변환, 계산, 분석해 프레젠테이션 계층으로 돌려주는 아키텍처이다.

특징- DAO 는 비교적 단순한 형태의 데이터를 반환한다.
- 서비스 계층에서 해당 데이터를 가공, 분석, 계산하여 비즈니스 로직을 구현하므로 서비스 계층의 코드가 매우 비대해진다.
장점- 애플리케이션 비즈니스 로직이 서비스 계층의 오브젝트에 담겨있어 객체지향 언어의 장점을 살릴 수 있고 테스트하기가 훨씬 수월하다.
- DB 의 로직이 훨씬 가벼워져 리소스를 운영하는 비용이 줄어든다.
단점- 여전히 계층간 결합도가 크다. SQL 이 비즈니스 계층의 필요에 의해 생성되므로 비즈니스 계층의 로직 자체를 재사용하는 것이 어려워진다. 따라서 비슷한 일을 하는 서비스 계층의 메서드가 중복되어 만들어진다.

오브젝트 중심 아키텍처

가장 큰 차이점은 데이터 중심 아키텍처가 하나의 요구사항에 맞는 SQL 의 결과값 데이터를 중심으로 각 계층이 개발되었다면, 오브젝트 중심 아키텍처는 도메인 모델을 표현한 도메인 오브젝트를 통해 각 계층 사이에서 정보를 전달한다는 점이다.

잠깐 생각했을때에는 데이터 중심이랑 큰 차이가 없지 않은가? 라고 생각 할 수 있다. 아래의 도메인 모델을 도메인 오브젝트가 어떻게 표현하는지를 보며 이해해보자.

  • 데이터 중심 아키텍처
    출처 : https://fmhelp.filemaker.com/help/18/fmp/en/FMP_Help/one-to-many-relationships.html


    고객과 고객이 수행한 주문을 표현하는 Customers 와 Orders 는 one-to-many 의 관계를 가진다. 이러한 데이터 구조에서 고객과 주문의 정보를 가져오는 상황에서 JOIN 연산을 통해 2차원의 데이터 구조를 만들고 이를 적절한 자료 구조에 담아 반환할것이다. 즉 1:N 관계를 가지는 두 테이블의 결과를 2차원으로 가져왔기에 이 두 테이블이 가지는 관계 정보를 가지고 있지 않기 때문에 각 요구사항에 맞는 SQL 결과를 작성해야하고 결과적으로 모든 계층의 코드가 하나의 업무 트랜잭션에 종속되는 것이다.

  • 오브젝트 중심 아키텍처
    public class Customers {
    	int customerId;
    	String name;
    	**Set<Orders> orders;**
    }
    
    public class Orders {
    	int orderId;
    	**Customers customer;**
    	int price;
    }
    테이블이 표현하는 데이터 구조를 도메인 오브젝트를 통해 동일하게 구현한다. Customer 한명이 여러개의 Orders 를 가질 수 있다는 그 정보가 자바 오브젝트에 그대로 드러나는 것이다. 어떤 DAO 가 특정 서비스 계층 오브젝트에게 도메인 오브젝트를 반환했는지 알 필요가 전혀 없다. 애플리케이션이 처리가능한 정보를 표현한 도메인 오브젝트를 활용해 필요한 정보를 가공하고 반환하면 될 뿐이다.

빈약한 도메인 오브젝트 방식

도메인 오브젝트를 사용하는 아키텍처는 모든 계층이 도메인 모델이 따르는 도메인 오브젝트를 사용함으로서 각 계층의 코드가 자신이 해야할 역할에 집중 할 수 있도록 하는 구조다.

특징- 애플리케이션 어디에서도 사용가능한 일관된 형식의 도메인 정보를 담은 도메인 오브젝트를 중심으로 각 계층의 코드가 설계된다.
- DB 에서 가져온 로우 데이터를 도메인 오브젝트 구조에 맞게 변환하는 단계가 필요하다.
장점- 각 계층간의 결합도가 획기적으로 낮아진다. 자신이 의존한 계층의 반환값은 도메인 오브젝트로 일관되어 있으므로 요구사항에 맞는 자신의 책임만을 수행한다.
- 재사용가능한 서비스 로직, DAO 코드를 개발할 수 있다.
- 테스트가 편리하다.
단점- 최적화된 SQl 을 만드는 것에 비해 성능상 효율이 떨어질 수 있다. 로우 데이터를 변환하는 작업, 복잡한 데이터 구조를 애플리케이션 코드로 작성하는 작업 등등이 필요하기 때문.
- 특정 요구사항은 특정 도메인 오브젝트의 일부만 필요하지만 , DAO 는 재사요을 위해 모든 참조 객체를 가져와서 반환해야하므로 메모리, 공유 리소스 사용면에서도 오버헤드가 발생한다.
⇒ 이를 방지하기 위해 여러 필터를 걸다보면 DAO 가 비즈니스 로직에 대해 알아야하는 강한 결합이 생길 수 도 있따.

이렇게 도메인 모델을 도메인 오브젝트로 표현하기는 하지만, 각 도메인 오브젝트가 단순한 구조로 설계되어 자신의 데이터만을 가지고 있는 아키텍처를 빈약한 도메인 오브젝트 아키텍처라고한다.

지금 열심히 개발 중인 Nest.js 의 구조도 분류하자면 빈약한 도메인 오브젝트 방식에 가장 가까운 것 같다. 도메인을 모델링한 엔터티 클래스들은 DB 테이블의 컬럼과 유사한 구조로 설계된 프로퍼티만을 가지는 객체들로 구성되기 때문이다.

위에서 언급한 단점 중 모든 프로퍼티를 가져와야 한다는 점을 해결하기 위해서 TypeORM 에서 지원하는 Lazy Loading 을 사용하는 방법으로 해결할 수 있지만, 거대 서비스 아키택쳐 처럼 가면 갈수록 서비스 계층의 코드가 비대해지고 수정 주기가 잦아지는 단점을 해결하는 것은 구조적으로 조금 힘든면이 있다.

이는, 도메인 오브젝트를 통해 도메인의 특징을 훨씬 응집도가 높게 표현할 수 있음에도 여러 비즈니스 로직에 산재되어 있기 때문에 발생할 수 있다. 이 문제를 해결한 아키텍처를 자세히 보자.

풍성한 도메인 오브젝트 방식

RDO(Rich Domain Object) 방식 또는 영리한 도메인 오브젝트(Smart Domain Object)라고 불리는 이 아키텍처는 빈약한 도메인 오브젝트의 단점을 해결하기 위해 고안되었따.

public class Customers {
	int customerId;
	String name;
	**Set<Orders> orders;**
}

public class Orders {
	int orderId;
	**Customers customer;**
	int price;
}

빈약한 도메인 오브젝트 방시에서 특정 고객이 주문한 총 금액을 계산하는 로직이 필요하다고 가정해보자. 이 로직은 비즈니스 로직이므로 서비스 계층의 코드에서 메서드로 구현할 것이다.

서비스 클래스에 이 로직을 담게되면 다른 로직에서 사용하고자 할 때 DI 를 해줘야하고 존재 자체를 몰라서 중복 코드를 작성 할 수도 있다.

public class Customers {
	int orderId;
	**Customers customer;**
	int price;

	public int getTotalPrice() {
		...
	}
}

이런식으로 특정 도메인 오브젝트와 매우 밀접한 관계가 있는 로직은 도메인 오브젝트에서 스스로 수행하도록 만들어 응집도를 높이고 재사용성을 극대화 할 수 있다.

특징- 도메인 오브젝트가 자신에 국한된 기능을 스스로 제공한다.
장점- 도메인 오브젝트에 일부 비즈니스 로직이 옮겨가고 도메인 오브젝트는 전 계층에서 사용되므로 서비스 코드가 비교적 간결해지고 재사용성이 높아진다.
단점- 이상적인 도메인 모델 설계와 책임 설계가 되지 않았다면 미스 커뮤니케이션으로 인해 중복 코드가 또다시 발생 할 수 있다 → 적절한 컨벤션과 설계가 반드시 필요

풍성한 도메인 방식에서 각 도메인 오브젝트는 비즈니스 로직을 일부 포함하지만 인프라 기능 혹은 DB 기능을 사용하지 않는 로직만을 가질 수 있다. 왜? 도메인 오브젝트는 빈이 아니므로 스프링이 관리하는 데이터 액세스 계층의 오브젝트, 다른 서비스 오브젝트를 주입 받을 수 없기 떄문이다.

따라서 실제로는 단순한 집계로직, 자신이 가진 데이터에 대한 분석, 조건에 따른 데이터 변경 등의 로직만이 도메인에 포함되고 여전히 복잡한 비즈니스 로직은 서비스 계층에서 처리할 것이다.

도메인 계층 방식

도메인 오브젝트의 그룹을 하나의 계층으로 분리하는 방식이다. 도메인 오브젝트도 빈으로 등록하고 자신과 관련된 모든 비즈니스 로직을 스스로 수행한다. DB 엑세스, 인프라 서비스 사용 등등 을 모두 도메인 오브젝트가 수행하는 것이다. 따라서 서비스 계층에서는 여러 도메인 오브젝트의 기능을 혼합해 구현하는 것에 중점을 둔 로직이 만들어질것이다.

이때 DTO(Data Transfer Object)가 등장하는데 도메인 계층의 오브젝트를 모든 계층에서 사용할지 안할지에 따라 DTO 가 제시되었다.

도메인 계층 방식에서 각 도메인 객체는 DB 엑세스 등 외부로 노출하기에는 중요한 작업을 수행하게되므로 해당 로직은 감추고 순수한 데이터만을 복사한 DTO 를 계층간 통신에 사용하는 것이다. 즉, DTO 를 통해 도메인 오브젝트를 보호하고 로직의 안전성을 높여줄 수 있다.

특징- 도메인 오브젝트가 관련이 있는 비즈니스 로직을 전반적으로 처리한다. (DB, 인프라 사용 포함)
장점- 훨씬 기능의 응집도가 높아져 복잡한 도메인 모델에서 유지보수성을 높여준다.
- DAO, 외부 서비스와 연동할때 도메인 오브젝트 타입을 유지하므로 단위 테스트 작성이 매우 용이하다.
단점- 도메인 계층의 오브젝트는 요청에 따라 생성과 삭제를 반복하게되는데 DB 와 인프라를 사용하기 위해선는 빈으로 등록되야하므로 기존의 방식과는다른 특별한 DI 가 필요하다.
- 복잡하지 않은 애플리케이션이라면 오히려 설계의 오버헤드가 발생한다.

앞서 말한 내가 개발중인 Nest.js 에서는 DTO 를 사용하지만 도메인 계층에 포함될만한 도메인 오브젝트를 사용하지는 않는다. TypeORM 을 사용해 특정 클래스를 상속하면 쉽게 도메인 오브젝트 계층을 만들 수는 있지만 단순한 도메인 오브젝트를 서비스 계층에서 가공해 요구사항을 구현하는 것이 구현상 단순해 빈약한 오브젝트 방식을 사용하고있다.

하지만 빈약한 오브젝트 방식에서도 DTO 를 사용하는 것은 유연한 계층간 구조를 만드는것에 도움이 되는데, 간단한 로직이라면 어차피 도메인 오브젝트에 중요한 로직이 포함되지 않으므로 그대로 계층을 넘어 사용해도 되지만 POST, Patch 요청에 대한 바디 데이터를 정의하는 DTO 라던지 특정 집계 로직의 결과값을 담은 DTO 등은 계층간 결합도를 낮추는 효과를 가져온다.

마치며

개발을 하면서 가장 어려웠던것은 아키텍처의 설계와 기술의 선택이었다. 처음 혼자 백엔드 개발을 맡아 진행할때 설계가 중요하다는 것은 당연히 알고있었지만 무엇이 맞는 설계인지는 몰랐고, 잘못된 설계에 대한 책임이 오롯이 나에게 돌아왔기에 어려웠다.

다른 개발자분들에게 어떻게 설계를 해야할지 질문을 하면 항상 상황에 따라 다르다는 답변이 오곤했다. 지금와서는 어떤 의미인지 어렴풋이 이해할 수 있을 것 같다. 개발팀의 규모, 개발 인력의 수준, 개발을 진행할 총 기간, 기능 수정/추가의 주기 등등 다양한 외부 환경적인 요소와 더불어 서비스의 성격, 기술적 요구사항 등등 기술 제약의 측면에서도 충분히 고려를 해야 할 수 있는 것이 아키텍처 설계와 기술 선택이기 때문이다.

그렇다고 해서 전반적인 아키텍처에 대해 몰라야한다는 뜻은 아니다. 오늘 정리했던 데이터 중심 아키텍처에서 도메인 계층 아키텍처까지 오면서 각 아키텍처가 어떤 장단점이 있고 어떤 특징이 있는지 알고 내 상황에 맞게 점차 설계를 바꾸어가는 것과 아무것도 모르고 남들 하는 것을 따라하는 것에는 큰 차이가 있기 때문이다.

profile
개발을 사랑하는 개발자. 끝없이 꼬리를 물며 답하고 찾는 과정에서 공부하는 개발자 입니다. 잘못된 내용 혹은 더해주시고 싶은 이야기가 있다면 부디 가르침을 주세요!

0개의 댓글