Search
Duplicate

MSA 프로젝트 구성하기

포스트에 작성된 전체 코드는 아래 깃허브에서 확인할 수 있다.

개요

처음 웹 어플리케이션을 개발할 때 보통 단일 프로젝트를 생성하여
해당 프로젝트에 사용자, 게시판 등 어플리케이션에서 제공하는 모든 기능을 개발한다.
그리고 프론트엔드와 백엔드 API 또한 포함하여 WAR 혹은 JAR 형태로 빌드하여 운영 환경에 배포를한다. 이러한 아키텍처는 모놀리식 아키텍처 라고 부른다.
본인도 이런 방식으로 개발을 배웠었고, 실무 환경에서도 유사한 환경에서 업무를 수행하고 있다.
회사에서 개발하는 프로젝트의 경우 엔터프라이즈 어플리케이션이기 때문에
단일 프로젝트 구성은 아니고 각 서비스 별로 프로젝트가 분리가 되어 있다.
다만 해당 서비스에도 도메인 별 기능이 나눠있지만 프로젝트가 분리되어 있지 않기 때문에,
특정 기능을 수정한 후 배포하는 과정에서 항상 서비스를 재기동해야하는 이슈가 발생한다.
또한 특정 기능에 대한 수요가 증가할 때 도메인 범위에서 서비스의 스케일업, 스케일 아웃이 아닌 VM 단위로 제어를 해야하기 때문에 자원 낭비가 아닌가라는 고민을 하게 된다.
주로 레거시에서 사용되는 모놀리식 아키텍처와 상반되는 마이크로 서비스 아키텍처
관심이 생겨서 정리를 해보았다.

Monolithic Architecture?

Monolithic : “단단히 짜여 하나로 되어 있는”
단순하고 쉽게 설명하자면 어플리케이션에서 사용되는 비즈니스 로직들이
단일 시스템에 몰려있는 구조를 의미한다.
단일 시스템으로 구성되어 있기 때문에 아래와 같은 특징을 갖는다.
장점
1.
단일 시스템이기 때문에 관리 자체가 용이하다
2.
모든 서비스가 같은 환경이기 때문에, 개발환경 구성하기 용이하다.
단점
1.
프로젝트의 크기가 크기 때문에 구동, 빌드, 배포 시간이 상대적으로 길다.
2.
단일 서비스의 오류로 전체 어플리케이션에 장애를 유발 시킬 수 있다.
3.
각 서비스 별로 최적화된 언어와 프레임워크를 사용할 수 없다.

MSA?

Micro Service Architecture
말 그대로 서비스의 단위를 작게 작게 나눈 아키텍처를 의미한다.
기능 단위로 서비스가 구성되기 때문에 아래와 같은 특징을 갖는다.
장점
1.
서비스에 최적화된 언어와 프레임워크를 사용할 수 있다.
2.
수정이 필요한 마이크로 서비스만 배포를 할 수 있다.
3.
프로젝트의 크기가 작기 때문에 구동, 빌드, 배포 시간이 짧다.
4.
오류의 범위가 전체가 아닌 서비스에 한정이되기 때문에 안정적이다.
단점
1.
작은 서비스가 분산되어 있기 때문에, 관리 및 모니터링이 어렵다.
2.
분리된 서비스간 네트워크를 통하여 호출하기 때문에, 잦은 통신 오류가 발생할 수 있다.
3.
End to End 테스트 시 전체 서비스를 기동해야하는 불편함이 있다.

개발환경 구성

IDE : IntelliJ Framework : Spring Boot Build Tool : gradle
참고로 이클립스의 경우 워크스페이스 → 프로젝트 단위로 구성되며, 인텔리제이의 경우 프로젝트 → 모듈 단위로 구성이 된다.

1. 프로젝트 구성

Step 1 : 신규 프로젝트 생성
Step 2
디펜던시는 따로 선택하지 않았다.
Step 3 : 불필요 파일 제거
루트 프로젝트에서는 별도 코드를 작성하지 않고 하위 모듈만 관리하기 때문에 src 디렉토리는 삭제한다.
Step 4 : root 프로젝트 빌드 스크립트 수정
build.gradle은 plugins, allprojects, subprojects 으로 총 3가지 파트로 구분된다.
plugins
plugins { id 'org.springframework.boot' version '2.7.3' id 'io.spring.dependency-management' version '1.0.12.RELEASE' id 'java' }
YAML
복사
dependency-management : sub 모듈 관리에 필요한 플러그인.
allprojects
allprojects { group = 'in.parkjw.apps' version = '0.0.1' }
YAML
복사
root 프로젝트와 sub 모듈 모두 적용되는 빌드 스크립트
subprojects
subprojects { apply plugin: 'java' apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' sourceCompatibility = '11' repositories { mavenCentral() } task initSourceFolders { sourceSets*.java.srcDirs*.each { if (!it.exists()) { it.mkdirs() } } sourceSets*.resources.srcDirs*.each { if (!it.exists()) { it.mkdirs() } } } dependencies { // spring implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.boot:spring-boot-starter-validation' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' // lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' // dev implementation 'org.springframework.boot:spring-boot-devtools' // test testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' } } }
YAML
복사
sub 모듈에 적용되는 빌드 스크립트
initSourceFolders : sub 모듈 별로 기초 디렉터리가 존재하지 않으면, 자동 생성해주도록 설정
dependencies : sub 모듈 공통으로 사용되는 라이브러리 의존성 정의

2. sub 모듈 추가

Step 1
root 프로젝트 → New → Module
Step 2
사용자 API
Step 2
디펜던시는 따로 선택하지 않았다.
Step 3
불필요한 파일 삭제
Step 4
setting.gradle
root 프로젝트에서 관리 될 수 있도록 하위 모듈 정보를 추가한다.
Step 5 : sub 모듈 빌드 스크립트 수정
user-api > build.gradle
bootJar { enabled = true } jar { enabled = true } dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-webflux' } // prepareKotlinBuildScriptModel 오류 방지 tasks.register("prepareKotlinBuildScriptModel") {}
YAML
복사
Step 6
개인 취향으로 메인 메서드가 존재하는 클래스의 이름을 변경.
Step 7
편리한 설정 관리를 위하여 .properties.yml (yaml)로 변경.
Step 8
기본 port를 사용하지 않기 위하여 8081 포트로 변경. 포트 변경의 이유는 이후에 공개하겠다
Step 9 : 테스트를 위한 API 추가
Step 10 : User 서비스 기동 및 테스트
IntelliJ에서 제공하는 Tool Windows 중 Services 를 적극 활용하자. 굉장히 편하다!
정상 동작함을 확인하였다.
Step 11 : 멀티 모듈의 느낌을 내기 위한 신규 모듈 추가
모듈 추가 및 테스트 방법은 동일하기 때문에 상세 과정은 생략.
포트 중복을 피하기 위하여 신규 모듈의 경우 8082 포트로 설정하였다.
이렇게 하나의 프로젝트에서 여러개의 도메인 단위의 서비스 (API)를 구성하였다.
여기서 문제가 하나가 발생되는데, 포트가 다르기 때문에 클라이언트에게 제공하는 엔드포인트가 여러개가 되는 문제이다.
API의 엔드포인트가 여러개인 경우, 클라이언트에서 API URL 관리에 불편함이 생기고,
클라이언트의 Request에 대한 필터링 (토큰 인증 및 로깅) 처리도 개별적으로 관리해야한다.
이러한 이슈들을 해결하기 위한 개념(혹은 기술)로써 API Gateway가 있다.

API Gateway?

엔드포인트를 통합하여, 클라이언트의 요청을 받고 각 API 서버에 라우팅을 하는 역할. 프록시 서버와 유사하며, 공통적인 인증 인가 및 로깅을 수행할 수 있다.
여기서 로깅은 굉장히 중요한 역할을한다. 각 서비스별의 사용량을 측정하여 스케일 아웃의 기준이 되거나 호출 통계 등 여러 방면으로 활용을 할 수 있는점 참고하자.

1. API Gateway 구성

Spring Cloud Gateway 프로젝트를 통하여 구축
Spring Cloud Gateway는 Tomcat이 아닌 Netty를 사용한다.
Netty는 비동기 및 1 Thread & Many Request 방식이기 때문에
일반적인 Spring MVC보다 더 많은 요청을 처리할 수 있음.
Step 1 : 모듈 추가 (Gateway의 경우 sub 모듈 범위로 관리 되지 않지만 편의상 모듈로 구성)
동일하게 디펜던시는 추가하지 않았다.
Step 2 : build.gradle 수정
gateway → build.gradle
plugins { id 'org.springframework.boot' version '2.7.3' id 'io.spring.dependency-management' version '1.0.12.RELEASE' id 'java' } group = 'in.parjw.apps' version = '0.0.1-SNAPSHOT' sourceCompatibility = '11' targetCompatibility = '11' configurations { compileOnly { extendsFrom annotationProcessor } } repositories { mavenCentral() maven { url 'https://repo.spring.io/snapshot' } maven { url 'https://repo.spring.io/milestone' } } ext { set('springCloudVersion', "2021.0.3-SNAPSHOT") } dependencies { implementation 'org.springframework.cloud:spring-cloud-starter-gateway' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' } dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } tasks.named('test') { useJUnitPlatform() }
YAML
복사
Step 3 : 전역 및 서비스 별 필터 추가 (Option)
모든 요청에 대한 필터링을 수행하는 GlobalFilter 클래스
package in.parkjw.apps.gateway.filter; import lombok.Data; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; /* Gateway를 구현하기 위해서는 GatewayFilterFactory를 구현해야 하며, 상속할 수 있는 추상 클래스가 바로 AbstractGatewayFilterFactory */ @Component public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> { private static final Logger logger = LogManager.getLogger(GlobalFilter.class); public GlobalFilter() { super(Config.class); } @Override public GatewayFilter apply(Config config) { /* exchange : 서비스 요청/응답값을 담고있는 변수로, 요청/응답값을 출력하거나 변환할 때 사용한다. 요청값은 (exchange, chain) -> 구문 이후에 얻을 수 있으며, 서비스로부터 리턴받은 응답값은 Mono.fromRunnable(()-> 구문 이후부터 얻을 수 있다. */ return ((exchange, chain) -> { logger.info("GlobalFilter baseMessage : " + config.baseMessage); if (config.isPreLogger()) { logger.info("GlobalFilter Start : " + exchange.getRequest()); } return chain.filter(exchange).then(Mono.fromRunnable(() -> { if (config.isPostLogger()) { logger.info("GlobalFilter End : " + exchange.getResponse()); } })); }); } /* config : application.yml에 선언한 각 filter의 args(인자값) 사용을 위한 클래스 */ @Data public static class Config { private String baseMessage; private boolean preLogger; private boolean postLogger; } }
Java
복사
User API 요청에 대한 필터링을 할 UserFilter 클래스. BoardFilter 또한 동일한 소스이므로 생략.
package in.parkjw.apps.gateway.filter; import lombok.Data; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; /* Gateway를 구현하기 위해서는 GatewayFilterFactory를 구현해야 하며, 상속할 수 있는 추상 클래스가 바로 AbstractGatewayFilterFactory */ @Component public class UserFilter extends AbstractGatewayFilterFactory<UserFilter.Config> { private static final Logger logger = LogManager.getLogger(UserFilter.class); public UserFilter() { super(Config.class); } @Override public GatewayFilter apply(Config config) { /* exchange : 서비스 요청/응답값을 담고있는 변수로, 요청/응답값을 출력하거나 변환할 때 사용한다. 요청값은 (exchange, chain) -> 구문 이후에 얻을 수 있으며, 서비스로부터 리턴받은 응답값은 Mono.fromRunnable(()-> 구문 이후부터 얻을 수 있다. */ return ((exchange, chain) -> { logger.info("UserFilter baseMessage : " + config.baseMessage); if (config.isPreLogger()) { logger.info("UserFilter Start : " + exchange.getRequest()); } return chain.filter(exchange).then(Mono.fromRunnable(() -> { if (config.isPostLogger()) { logger.info("UserFilter End : " + exchange.getResponse()); } })); }); } /* config : application.yml에 선언한 각 filter의 args(인자값) 사용을 위한 클래스 */ @Data public static class Config { private String baseMessage; private boolean preLogger; private boolean postLogger; } }
Java
복사
Step 4 : gateway 설정
gateway → application.yml
server: port: 8080 --- spring: cloud: gateway: default-filters: - name: GlobalFilter args: baseMessage: Spring Cloud Gateway GlobalFilter preLogger: true postLogger: true routes: - id: user-api uri: http://localhost:8081/ predicates: - Path=/api/user/** filters: - name: UserFilter args: baseMessage: Spring Cloud Gateway UserFilter preLogger: true postLogger: true - id: board-api uri: http://localhost:8082/ predicates: - Path=/api/board/** filters: - name: BoardFilter args: baseMessage: Spring Cloud Gateway BoardFilter preLogger: true postLogger: true
YAML
복사
API Gateway의 포트는 8080으로 설정하였다. (여기서 쓰려고 8080 포트를 아꼈다..!)
yaml에서 - 은 배열(혹은 리스트)을 표현할 때 사용한다.
default-filters, filters 는 필요한 경우 설정한다. 필수 X
default-filters : 글로벌 필터. 모든 요청에 대하여 처리한다.
routes : 말 그대로 Request의 Routing 정보를 정의한다.
uri : 마이크로 API 서버의 host와 port
predicates : 라우팅의 기준이 되는 URI 패턴
Step 5 : API Gateway APP 기동
로그를 보면 Netty 서버로 올라갔음을 확인 할 수 있다.
Step 6 : API Gateway를 통한 API 호출 테스트
User API 호출 → API Gateway 콘솔 로그
Board API 호출 → API Gateway 콘솔 로그
두 개의 API를 호출 하였을때, 공통적으로 Global Filter를 먼저 수행 하고,
각 API에 설정한 Filter가 수행됨을 확인할 수 있다.
물론 여기서 확인한 로그는 요청에 대한 필터링 로그이다. 실제 어플리케이션 로그의 경우 각 API 서버에 로깅이 되는 점 잊지말자!
API Gateway를 적용함으로써 우리는 클라이언트에게 하나의 엔드포인트를 제공할 수 있고,
클라이언트의 요청에 대한 통계 및 분석을 할 수 있게 되었다.
해당 포스트에서 소개한 내용은 빙산의 일각이기 때문에,
보다 다양한 기능을 사용하고 싶다면 스프링에서 제공하는 공식 문서를 살펴보는것을 추천한다.