EC2란 Elastic Compute Cloud의 약자로 탄력적인 클라우드 컴퓨터를 의미한다. 간단하게 설명하자면 유연하게 생성할수 있는 가상의 클라우드 서버 컴퓨터이다. EC2는 주로 백엔드 API 서버를 배포할때 사용한다. 우리는 개발을 하고 나면 배포를 해야하는데, 내 컴퓨터에 배포를 할 수는 없다. 그렇기 때문에 서비스를 배포할 다른 서버 컴퓨터를 이용해야 한다. 기존에는 온디맨드(On-Demand) 형식의 물리적인 컴퓨터에 배포를 하였다면, AWS는 필요한만큼의 가상의 컴퓨터를 만들어 사용한다. 우리는 이 가상의 컴퓨터를 인스턴스(Instance)라고 정의한다.
AWS 리전(Region) 선택
AWS Region은 클라우드 데이터 센터의 위치를 의미한다. 우리가 인터넷을 통해 서버에 접근하려면 네트워크를 통해야 하는데, 거리가 멀수록 네트워크의 접속 시간이 길어지기 때문에 서비스를 배포할때, 해당 지역에 가까운 데이터 센터를 이용해야한다. 리전(Region)은 AWS에서 구축한 데이터센터마다 구성이 되어있다. 한국의 경우 '아시아 태평양 서울'을 사용하면 된다.
AWS 인스턴스란?
AWS에서 인스턴스란 하나의 컴퓨터를 의미한다. 따라서 우리는 인스턴스 설정을 통해 가상의 컴퓨터를 생성할것이다.
AWS 인스턴스 생성
EC2 -> 인스턴스 -> '인스턴스 시작' 버튼 클릭시, 아래와 같은 화면이 나타난다.
인스턴스 이름과 사용할 OS를 선택한다.
인스턴스 유형은 기존 설정을 건드리지 않는다. 신규 가입시 프리 티어를 1년간 사용가능하여 무료이다. 다만 무리하게 사용하게 되면 100% 무료가 아닐 수 있으니, 사용 전 프리 티어에서 제공하는 자원을 확인해야한다.
키페어는 서버에 접근하여 관리할 수 있는 키와 같다. 키 페어 생성시, 파일이 생성되는데 해당 파일을 잃어버리지 않도록 주의하자. 키페어는 나만 알고 있어야 한다. 다른 사람이 해당 파일을 가지고 있게 되면, 나도 모르는 새 다른 사람이 내 서버를 이용할 수 있다. 요금 폭탄을 맞지 않으려면, 키페어 관리에 유의하자.
네트워크 설정에서 편집을 누를시 위와 같이 보안 그룹 규칙을 설정할 수 있다. 보안 그룹이란 내 서버에 접속하는 대상의 IP와 PORT를 제한하는 것이다. 위 SSH 설정은 원격접속으로 TCP/22번 포트만을 통해 접속할 수 있도록 제한을 한다는 설정이다. 해당 컴퓨터로 들어오는 IP와 PORT를 제한하고 싶다면, 인바운드 보안 그룹 규칙을 추가해주면 된다.
스토리지(Storage)의 EBS Volumes 이란 컴퓨터의 하드웨어 저장 공간을 의미한다. AWS에서는 인스턴스의 저장 공간을 설정하는 것이다. 해당 메뉴에서 하드웨어의 크기 및 그 외 정보를 설정할 수 있다. 필자는 크기(Gib)만 수정하여 사용하도록 하였다.
'인스턴스 시작' 버튼을 클릭하면 우리가 설정한 하나의 컴퓨터(인스턴스)를 생성하게 된다.
정상적으로 생성이 되면 위와 같은 화면이 나온다.
AWS 인스턴스 접속
위와 같이 연결하면 필자의 경우 ubuntu로 생성된 컴퓨터에 접속되는 것을 확인할 수 있다.
AOP란 Aspect Oriented Programming의 약자로 관점 지향 프로그래밍이라는 의미를 가집니다. 스프링에서는 이러한 AOP 기능을 제공합니다. AOP를 간단하게 설명하자면 인터셉터와 필터와는 다르게 내가 원하는 코드에 접근할때 공통으로 처리하는 전후 처리 기능을 구현하는 것이라고 생각하면 편리합니다. @Transactional 또한 AOP를 활용하여 구현된 스프링의 어노테이션입니다. 이번 글에서는 AOP를 활용하여 어노테이션 없이 트랜잭션을 보장하는 방법을 알아보고자 합니다. AOP에 대한 개념은 쉽게 이해하기 어렵기 때문에 자세한 내용은 추후에 다루도록 하겠습니다.
@Transactional을 제외하고 만드는 이유가 무엇인가?
여기서 만들고자 하는 것은 @Transactional 어노테이션 없이 Service 단에서만 트랜잭션을 보장받는 방법으로 굳이 어노테이션을 제거하고 만드는 이유는 프레임워크와 연관이 있습니다. 기존 업무에서 사용하던 상용 프레임워크에서는 비즈니스 로직이 동작하는 서비스 단에서만 트랜잭션이 보장되는 것을 확인할 수 있었는데, 이는 프레임워크에서 트랜잭션이 서비스단에서만 적용되도록 커스텀하였기 때문에 어노테이션 없이 동작하는 것이었습니다. 어노테이션을 굳이 제거하려는 이유는 모든 개발자가 @Transactional 어노테이션을 빼놓지 않고 쓰면 상관이 없지만, 대형 프로젝트의 경우 모든 개발자가 그것을 인식해서 사용하지 않기 때문에 애초에 프레임워크에서 보장을 해주자는 취지였습니다. 그렇기 때문에 이러한 업무 경험을 토대로 이번 프로젝트에서도 또한 서비스 단에서는 @Transactional 없이 트랜잭션을 보장해줘야 한다는 요구사항이 나왔고, 제가 담당하는 업무는 아니였지만 이러한 문제를 AOP로 해결할 수 있을 것 같아 AOP 클래스 파일을 작성하여 적용 할 수 있게 되었습니다. 이러한 내용을 공유하고자 글을 작성하게 되었으며, 아래와 같은 예제를 기반으로 설명을 드리고자 합니다.
샘플 프로젝트 동작 예제 - 트랜잭션이 보장되지 않는 경우
SampleService.java 함수 코드
public void updateSampleServiceImpl(SampleInVo sampleInVo) throws Exception {
/**
* 수정 쿼리 호출
*/
int result = dbMapper.sampleUpdate(sampleInVo);
/**
* DB 접근 후 예외 발생! - 트랜잭션 보장시 롤백
*/
if(true) {
throw new CustomException("CUSTOM00001", "임의 오류");
}
}
위 코드는 Mybatis의 Mapper를 사용한 DB UPDATE 코드와 DB 접근 이후 임의의 오류를 발생시키는 코드입니다. 위처럼 동작시 트랜잭션이 보장되지 않는 경우 트랜잭션이 롤백되지 않고, 커밋을 수행합니다. 그 경우 예시는 아래 화면과 같습니다.
Sample.html
위와 같이 오류가 발생하였음에도 15 -> 20으로 수정하는 업데이트 쿼리가 적용이 된 것을 볼 수 있습니다. 서비스 코드 내에서 오류가 발생했음에도 DB에 적용이 되는 것은 실무에 있어서는 치명적인 문제로 이어지게 됩니다. 이렇듯 트랜잭션을 보장해주는 역할이 중요하기 때문에 @Transactional을 넣지 않은 것을 운영까지 가서 알게 된다면, 실무에서는 돈과 연결되는 리스크로 이어지기 때문에 이런 리스크를 제거하고자 하는 것입니다. 그렇기 때문에 다음으로 트랜잭션을 적용하기 위한 코드들을 만들어볼 것입니다.
AOP 클래스 작성
서비스 단에서 트랜잭션을 적용하기 위해 3개의 클래스를 만들것입니다.
TransactionContextHolder.java
import org.springframework.transaction.TransactionStatus;
public class TransactionContextHolder {
private static final ThreadLocal<TransactionStatus> transactionStatusHolder = new ThreadLocal<>();
public static TransactionStatus getTransactionStatus() {
return transactionStatusHolder.get();
}
public static void setTransactionStatus(TransactionStatus transactionStatus) {
transactionStatusHolder.set(transactionStatus);
}
public static void clearTransactionStatus() {
transactionStatusHolder.remove();
}
}
해당 클래스는 ThreadLocal 변수를 사용하여 TransactionStatus의 트랜잭션 상태를 유지해주는 코드입니다. 트랜잭션의 상태를 유지한다는 의미는 멀티 쓰레드 환경에서 서비스 동작시 각 쓰레드가 전역변수 등에 의한 문제 없이 A 쓰레드, B쓰레드의 일관 된 처리를 보장해준다는 의미입니다. 쓰레드 로컬을 사용하지 않는 경우, A 트랜잭션의 쓰레드, B 트랜잭션 쓰레드가 동시 발생시 A가 B의 처리 혹은 B가 A의 처리에 영향을 주는 경우가 생깁니다. 이러한 문제를 방지하는 것이 쓰레드 로컬의 역할입니다.
CustomTransactionAspect.java
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
@Aspect
@Component
public class CustomTransactionAspect {
@Autowired
private PlatformTransactionManager transactionManager;
// com.sample 하위 패키지의 ServiceImpl 명을 가지는 프로그램 실행시 동작하는 AOP 메소드
@Around("execution(* com.sample..*ServiceImpl*.*(..))")
public Object manageTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
// 전 처리 : 트랜잭션 시작
TransactionStatus transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition());
TransactionContextHolder.setTransactionStatus(transactionStatus);
try {
// 호출 할 메서드 호출
Object result = joinPoint.proceed();
// 후처리 : 트랜잭션 커밋
transactionManager.commit(transactionStatus);
return result;
} catch (Exception ex) {
// 서비스에서 예외 발생 시 트랜잭션 롤백
transactionManager.rollback(transactionStatus);
throw ex; // 예외를 다시 던져서 상위 레벨에서 처리할 수 있도록 함
} finally {
TransactionContextHolder.clearTransactionStatus();
}
}
}
위 코드에서 @Aspect는 이 클래스가 AOP 클래스인것을 명시합니다.
@Around 어노테이션은 AOP를 어떤 패키지 경로에 적용할지 지정하는 것으로 위에서는 com.sample 하위 경로의 클래스명이 ServiceImpl가 포함된 클래스에 AOP를 적용시키겠다는 의미입니다.
ProceedingJoinPoint 객체는 실제로 호출할 메소드 정보를 담고 있으며, manageTransaction 안에서 동작하는 함수는 CGLIB 혹은 JDK 다이나믹 프록시로 동작하여 기존의 호출을 인터셉터 하여 동작하는 방식으로 함수가 동작합니다. 그렇기 때문에 이 함수 내에서 전처리,후처리를 할 수 있으며 구현한 함수 내에서 전처리로 트랜잭션을 시작하고 joinPoint.proceed()를 통해 실제 호출 함수를 동작 시킵니다. 동작 후 다시 후처리로 넘어와 트랜잭션을 커밋 혹은 롤백 시키는 전후처리를 구현하였습니다.
이렇게 내가 원하는 클래스 혹은 패키지 경로에 전후 처리를 구현하는 것을 AOP(관점 지향 프로그래밍) 이라고 합니다.
마지막으로 작성할 클래스는 AOP를 사용할 것이라는 Config 클래스로 @Aspect 코드를 찾아주는 클래스입니다.
AopConfig.java
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
@Configuration
@EnableAspectJAutoProxy
@ComponentScan("com.ibks.cwcore.common.aop") // CustomTransactionAspect가 속한 패키지 지정
public class AopConfig {
// 추가적인 설정이 필요한 경우 작성
}
위 코드는 설명하면 @ComponentScan으로 실제 동작 시킬 AOP 클래스 경로를 지정하고, @Configuration을 통해 스프링의 환경설정 파일로 등록하겠다는 의미입니다. 마지막으로 @EnableAspectJAutoProxy는 AOP 사용을 위한 프록시를 사용하겠다는 의미로 위와 같은 설정에서는 기본 값 설정으로 JDK 다이나믹 프록시를 사용하며, @EnableAspectJAutoProxy(proxyTargetClass = true)로 옵션을 줄 시 CGLIB를 사용하게 됩니다. JDK 다이나믹 프록시와 CGLIB의 개념 또한 프록시의 어려운 개념이기에 다음에 따로 정리하도록 하겠습니다. 현재로써의 가장 큰 차이점은 JDK 다이나믹 프록시는 인터페이스를 구현한 클래스에 대해서만 프록시 객체를 생성하는 의미라고 하며, CGLIB는 모든 클래스에 대해서 프록시 객체를 생성하겠다는 뜻입니다.
이렇듯 3개의 클래스를 작성하게 되면, ServiceImpl이 포함된 클래스 안의 메소드에서는 @Transactional 없이 트랜잭션이 보장 되는 것을 알 수 있습니다. 아래는 적용된 코드의 예시입니다.
샘플 프로젝트 동작 예제 - AOP 클래스 적용 후
Sample.html
AOP 적용 후 똑같은 케이스로 UPDATE 후 예외 발생시, 트랜잭션이 롤백 처리 되어 트랜잭션의 일관성이 보장되는 것을 확인할 수 있습니다.
정리
이번 글에서는 스프링에서 트랜잭션을 보장해주는 @Transactional 어노테이션 없이 함수의 트랜잭션을 보장받는 방법을 알아보았습니다. 트랜잭션의 전파 속성 등을 고려하면 아직 초안이라고 생각합니다. 하지만 이러한 방법을 잘 완성하였을때에는 코드의 반복성을 줄일 수 있고, 인적 리스크를 줄일 수 있습니다. 그렇기 때문에 다른 프레임워크에서 지원하는 기능을 비슷하게나마 만들어 현재 프로젝트에 적용하게 되었습니다. 트랜잭션 처리를 위한 전후 처리 이외에도 이러한 AOP는 클래스별 로깅, 공통 처리 등이 가능합니다. 저 또한 아직 기본적인 개념만 알기 때문에 꾸준히 공부하여 효율적인 아키텍처를 만들 수 있는 고급 개발자가 되고 싶습니다. 글에 대해서 부족한 점이나 수정해야 할 점이 있을때에는 말해주시면 수정 보완하도록 하겠습니다. 읽어주신 분들께 감사드립니다.
API Gateway Service를 간략하게 설명하면 고객이 접근하는 클라이언트(모바일,웹)에서 서비스를 요청시, 모든 요청을 받는 중간자 역할을 수행하며, 요청에 맞는 마이크로서비스 인스턴스의 API를 찾아줍니다. 또한 통일 된 접근 경로를 가지기 때문에 공통 처리 기능인 로깅,인증,필터와 같은 기능을 수행할 수 있는 서비스입니다.
마이크로 서비스의 전체적인 호출 구조는 아래 사진과 같습니다.
MSA 호출 구조
위 그림처럼, API Gateway는 모든 클라이언트 APP의 서비스 요청 정보를 받아 통합적으로 마이크로 서비스의 호출을 도와주는 기능을 제공합니다. 해당 API Gateway는 모든 요청을 일관 되게 처리하기 위하여 요청 정보를 Service Discovery에 전달하며, 요청 정보에 맞는 서비스가 있을 시 게이트웨이 서비스의 설정 정보에 따라 마이크로 서비스의 호출을 보다 간편하게 도와줍니다. 좀 더 자세한 내용은 구현한 내용을 기반으로 설명하도록 하겠습니다.
API Gateway Service 프로젝트 생성
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.17'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '11'
}
repositories {
mavenCentral()
}
ext {
set('springCloudVersion', "2021.0.8")
}
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
필자는 강의 정보에 따라 스프링 부트 2.7.17 버전에 호환 되는 스프링 클라우드 2021.0.8 버전을 사용하였습니다. 스프링 클라우드 사용시 자바 및 스프링 부트의 버전 호환성을 고려하여 생성하여야 오류 없이 동작합니다. dependency로는 Gateway, Lombok, Eureka Discovery Client를 추가하게 되면 다음과 같은 설정 정보를 가지게 됩니다.
초기 프로젝트 생성시, application.yml의 설정 정보는 위와 같습니다. 게이트웨이 서비스의 포트는 8000번으로 등록하였습니다. 또한 이전에 예시로 만들었던 두 개의 마이크로 서비스인 first-service와 second-service의 uri 주소를 게이트웨이에 등록하였습니다.
해당 설정의 하나의 예시로써 게이트웨이에서 first-service를 호출할때에는 http://localhost:8000/first-service/** 호출시 first-service의 주소인 http://localhost:8081/first-service/**를 불러 온다는 설정입니다. 이렇게 마이크로서비스를 설정 정보에 등록하게 되면, 여러 개의 마이크로 서비스의 호출을 게이트웨이 서비스에서 관리 할 수 있습니다.
Spring Cloud Gateway - FilterConfig 적용
앞에서 설명한 것처럼, 모든 서비스 요청시 하나의 게이트웨이를 지나가기 때문에 공통의 기능으로 필터를 적용할 수 있습니다. 필터를 제공하는 설정 클래스 정보는 아래와 같습니다.
RouteLocator 함수를 정의하여 빈 설정 정보로 등록하게 되면, 게이트웨이 호출시 원하는 필터 기능을 구현할 수 있습니다.
위와 같은 설정의 의미로는 [게이트웨이주소]/first-service/** 경로 호출시 first-service의 주소인http://localhost:8081/first-service/** 로 매핑 시켜주는 라우팅 설정 기능이며, RequestHeader에는 key:value 쌍으로 first-request:first-request-value를 필터에 추가하여 입력해주겠다는 의미입니다. 다음과 같이 설정하면 first-service의 Controller에서는 아래와 같이 받게 됩니다.
FirstServiceController.java
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
@Slf4j
@RestController
@RequestMapping("/first-service")
public class FirstServiceController {
@GetMapping("/message")
public String message(@RequestHeader("first-request") String header){
log.info(header);
return "Heelo World First Service message";
}
}
위 필터를 적용하게 되면 [게이트웨이주소] = http://localhost:8000 이라고 가정하였을 때, http://localhost:8000/first-service/message를 호출 시, first-service의 주소인 http://localhost:8081/first-service/message를 호출하게 됩니다.
또한 @RequestHeader를 통해 필터에서 설정한 first-request의 정보를 가져옵니다.
로그를 찍게 되면 first-request의 값인 first-request-value를 정상적으로 가져오는 것을 알 수 있습니다.
이렇듯 FilterConfig.java 파일을 통해 서비스 라우팅 및 필터 기능이 수행 되는 것을 알 수 있었습니다.
다만 주의할 점은 FilterConfig 클래스에서 RouteLocator를 빈으로 등록시, application.yml의 설정 정보는 주석을 쳐주셔야 합니다. 해당 부분은 아래와 같습니다.
위와 같이 spring.application.cloud 부분을 지워줘야 FilterConfig.java 설정이 정상적으로 동작합니다.
다음은 FilterConfig.java를 사용하여 설정 정보를 잡는 것이 아닌 application.yml의 설정으로 라우팅과 필터 기능을 적용하는 방법을 알아보겠습니다. 적용하기 전 FileterConfig.java에서는 @Configuration에 '//' 주석을 달아 해당 클래스가 동작하지 않도록 사전에 바꿔줍니다.
기존에 FilterConfig.java에서 설정했던 RequestHeader 정보를 yml에서는 AddRequestHeader와 AddResponseHeader를 설정하여 세팅할 수 있습니다. 위 yml 파일 사용시 FilterConfig.java와 같이 라우팅과 필터가 동작하는 것을 알 수 있습니다. 다음은 사용자 정의에 따라 CustomFilter를 등록하는 방법을 알아보겠습니다.
Spring Cloud Gateway - CustomFilter 적용
CustomFilter.java
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.server.reactive.ServerHttpRequest; // Webflux 지원
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Slf4j
@Component
public class CustomFilter extends AbstractGatewayFilterFactory<CustomFilter.Config> {
public CustomFilter(){
super(Config.class);
}
@Override
public GatewayFilter apply(CustomFilter.Config config){
// Pre Filter
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
log.info("Custom PRE filter : request id -> {}",request.getId());
//Post Filter
return chain.filter(exchange).then(Mono.fromRunnable(() ->{
log.info("Custom POST filter : resonse code ->{}",response.getStatusCode());
}));
};
}
public static class Config{
}
}
CustomFilter 클래스는 위와 같이 설정합니다. 위와 같이 설정할 시, Webflux 모듈에서 지원하는 reactive 클래스인 ServerHttpRequest와 ServerHttpResponse 클래스를 정의하여 GatewayFilter 함수 안에서 선처리 필터인 Pre Filter와 후처리 필터인 Post Filter를 만들 수 있습니다. 위와 같이 동작하면 서비스가 동작하기 전, request.getId()를 로그로 출력할 것이며, 서비스가 동작한 후에는 response.getStatusCode()를 로그로 출력할 것입니다. 위와 같은 Pre Filter를 사용하게 되는 예시로는 JWT 토큰을 가져와 인증 및 인가를 하는 기능을 추가할 수 있습니다. 위 클래스를 정의하고 적용하기 위해서는 아래와 같이 application.yml 파일을 수정합니다.
위와 같이 이전에 yml 파일에서 설정했던 FilterConfig 정보를 주석으로 설정하고, filters에 CustomFilter를 추가해주면 정상적으로 동작하는 것을 확인 할 수 있습니다.
Spring Cloud Gateway - GlobalFilter 적용
GlobalFilter.java
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Slf4j
@Component
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {
public GlobalFilter(){
super(Config.class);
}
@Override
public GatewayFilter apply(GlobalFilter.Config config){
// Pre Filter
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
log.info("Global Filter baseMessage : {}",config.getBaseMessage());
if(config.isPreLogger()){
log.info("Global Filter Start : request id -> {}",request.getId());
}
//Post Filter
return chain.filter(exchange).then(Mono.fromRunnable(() ->{
if(config.isPostLogger()){
log.info("Global Filter End : response status -> {}",response.getStatusCode());
}
}));
};
}
@Data
public static class Config{
private String baseMessage;
private boolean preLogger;
private boolean postLogger;
}
}
GlobalFilter 클래스를 만든 이유는 기존에 만들었던 CustomFilter와 같이 사용하여 GlobalFilter -> CustomFilter 순으로 동작하는 기능을 만들기 위함입니다. GlobalFilter가 CustomFilter와 다른 부분은 Config 부분의 baseMessage, preLogger, postLogger와 같은 변수의 선언 부분과 application.yml 파일에서 default-filters를 설정하는 부분입니다.
filters에 다음과 같이 LoggingFilter의 설정을 추가하면 GlobalFilter, CustomFilter, LoggingFilter를 적용할 수 있게 됩니다.
다음은 우선순위에 따른 필터의 동작 순서를 보겠습니다.
Ordered.HIGHEST_PRECEDENCE
Ordered.LOWEST_PRECEDENCE
LoggingFilter에 우선순위를 각각 HIGHEST와 LOWEST로 적용했을때 필터가 동작하는 순서가 바뀌는것을 위와 같이 볼 수 있습니다.
Spring Cloud Gateway - Eureka 연동
다음은 Service Discovery에 등록되어 있는 서비스를 호출하기 위해 Eureka Client로 등록하는 작업을 할 것입니다.
Eureka Client로 등록할 시, Service Discovery(Eureka Server)에 등록 되므로, 해당 서비스가 존재하는지 확인하여 호출하는 구조를 만들 수 있게 됩니다. Eureka Client로 등록하기 위해 필요한 application.yml 설정 파일은 아래와 같습니다.
eureka.client 설정의 service-url은 Service Discovery 서버의 주소로 해당 서버에 Eureka Client로 등록하겠다는 설정입니다. 해당 설정을 사용하게 되면 서버 기동시 Service Discovery에 등록됩니다. Dependency로써 Eureka Client를 등록하였다면 해당 설정을 할 수 있습니다. 추가적으로 변경된 first-service와 second-service의 uri를 수정하였는데, Service Discovery에 등록 된 application name을 참조하여 lb:// 문구로 선언하면, 해당 서비스 정보를 가져오게 됩니다.
first-service 프로젝트와 second-service 프로젝트를 Service Discovery에 등록하기 위해서는 각 프로젝트의 application yml 파일을 수정해줘야 합니다.
위와 같이 spring.application.name을 각각 my-first-service, my-second-service로 등록하였기 때문에 Service Discovery의 application 명이 해당 설정을 참조하여 게이트웨이에 등록할 수 있습니다.
그 외의 마이크로 서비스를 임의의 인스턴스로 여러 개를 띄우기 위해서는 application.yml 설정 파일의 server.port = 0으로 선언하여 랜덤 포트 형식으로 마이크로 서비스를 매번 다르게 띄울 수 있습니다.
정리
지금까지 스프링 클라우드에서 제공하는 Eureka Server, Eureka Client, Gateway Dependency를 참조하여 API Gateway의 역할을 알아보았습니다. 스프링 게이트웨이를 구현해보면서 마이크로 서비스를 호출하기 위해 어떤 설정이 필요한지, 필터는 어떤식으로 사용하는지, 여러 개의 인스턴스 사용시 로드 밸런싱을 어떻게 하는지 등을 알 수 있었습니다. MSA 아키텍처의 핵심 서비스 중 하나인 API Gateway의 개념은 중요한 부분이기에 기본 개념만이 아닌 응용 환경에서도 어떻게 동작하는지 꾸준히 공부해야 하는 중요 사항이라고 생각합니다.
스프링 프레임워크는 @Transactional 어노테이션을 제공하여 트랜잭션을 지원합니다. @Transactional의 옵션 중 isolation level 옵션은 무엇이고, 어떠한 경우에 필요한 개념인지 뱅킹 프로세스에 자주 사용되는 입출금 예제를 기반으로 설명하고자 합니다.
Isolation Level
일반적으로 어플리케이션에서 서비스가 실행 되면 프로세스가 생성되고 각각 쓰레드가 할당 됩니다. 이러한 쓰레드들이 동시에 같은 서비스에 접근하여 SELECT , UPDATE 등의 작업을 공유하게 되었을 경우 예외적인 경우가 발생합니다. 이러한 문제를 해결하기 위하여 격리 수준(Isolation Level)을 지정하며 쓰레드의 작업, 즉 트랜잭션을 어느 정도까지 공유할 것인지 정의 해주는 것이 해당 옵션입니다. Isolation Level은 크게 4가지로 분류됩니다.
READ UNCOMMITTED
READ COMMITTED
REPEATABLE READ
SERIALIZABLE
READ UNCOMMITTED
READ UNCOMMITTED는 COMMIT 여부에 상관없이 모든 트랜잭션이 자원을 공유하는 레벨로 트랜잭션의 가장 낮은 격리 수준입니다. 해당 레벨로 설정하게 될 경우 위와 같은 ROLLBACK 상황에서 잘못 된 데이터를 읽게 되는데 이를 Dirty Read(더티 리드)라고 합니다. 스프링에서는 아래와 같이 작성하여 적용합니다.
READ COMMITTED는 COMMIT된 데이터만 읽어오겠다는 설정으로 트랜잭션A 내에서 UPDATE가 이루어지더라도 COMMIT 되지 않았다면, 트랜잭션B는 트랜잭션A의 COMMIT 이전 시점의 데이터를 읽어옵니다. 해당 옵션을 사용하게 되면 DIRTY READ 문제를 해결할 수 있지만 동시성 문제를 해결하지 못합니다. 위와 같이 COMMIT 되기 전 트랜잭션 B가 접근하게 된다면, 트랜잭션A의 작업이 끝나고 트랜잭션B의 작업이 종료되는 경우가 발생하여 이전 작업이 덮어씌워지게 될 수 있습니다. 그 외에도 READ COMMITTED에 생길 수 있는 문제로는 Phantom Read와 Non-Repeatable Read 문제가 발생하는데 이 부분은 자세히 다루지 않도록 하겠습니다.
REPEATEABLE READ는 트랜잭션 내에서 항상 동일한 조회를 보장하는 설정으로 Non-Repeatable Read 문제가 발생하지 않는 격리 수준입니다. 이는 동일한 트랜잭션에서 재조회시 조회 내용을 보장해주는 것이지만, 위와 같은 동시성 문제를 해결하지는 못합니다.
SERIALIZABLE은 SELECT가 사용되는 모든 데이터에 트랜잭션이 끝날때까지 Shared Lock(공유 락)을 걸어 동기 처리를 지원하는 레벨로 트랜잭션 A와 트랜잭션 B가 동시에 실행된다면 완전한 격리 수준으로써 트랜잭션A가 종료되고 트랜잭션B가 실행될 수 있습니다. 이러한 레벨을 사용하면 동시적인 처리에 대해서 해결할 수 있지만, 동기 처리를 지원하기 때문에 트래픽이 많은 서비스에서는 속도를 보장해주지 못할 수 있습니다.
지금까지 계좌입출금 예제를 통해 동시성 처리를 위주로 트랜잭션 격리 수준에 대해 정리해보았는데요. 실무에서 isolation 옵션을 사용하면서 개발자로써 중요한 내용이라는 생각이 들어 글을 작성하게 되었습니다. 입출금을 예제로 든 이유는 이 업무를 해보고 싶어 예제로 정리하면 괜찮지 않을까 하여 주제로 정하게 되었습니다. 현업에서는 이러한 입출금 트랜잭션 처리를 어떻게 하는지 궁금하며, 혹시 실무를 경험한 분들이 비슷한 처리 경험을 공유해주신다면 감사하겠습니다. 부정확한 내용이 포함되어 있는것 또한 댓글로 남겨주시면 수정하도록 하겠습니다. 앞으로도 공유하고 싶은 지식이 있을시 글로 정리하고자 하며 많은 분들이 봐주셨으면 좋겠습니다.
Service Discovery란 클라이언트의 요청 정보에 따라 필요한 서비스의 위치를 알려주는 역할을 하는 기능입니다.
클라우드 환경에서 동작하는 MSA 서비스는 동적인 IP 주소와 포트를 사용하는 인스턴스를 만들게 되는데, 이러한 잦은 인스턴스 생성으로 인해 호출 서비스가 어떤 IP와 포트를 사용하는지 알아야 합니다. 이러한 서비스 인스턴스의 IP/Port를 찾아 호출해주는 기능을 제공하는 서버가 서비스 디스커버리의 역할입니다.
Service Discovery
위 그림처럼 각각의 서비스 인스턴스를 Service Registry에 등록한 후, 해당 서비스를 호출할때마다 Service Discovery 수행 과정을 거쳐 요청 정보마다 서비스를 찾아줍니다.
스프링부트 Spring Cloud Netflix Eureka 서버 프로젝트 생성
스프링은 이러한 Service Discovery 역할을 Eureka Server로 제공합니다. 이러한 유레카 서버를 스프링부트 2.X와 Eureka 의존성을 추가하여 생성합니다.
start.spring.io 사이트에서 다음과 같이 프로젝트를 생성하였으며, Dependency에서 Eureka Server를 추가하여야 스프링에서 제공하는 spring-cloud-netflix-Eureka Server를 사용할 수 있습니다.
스프링부트 Eureka 서버 설정 및 기동
pom.xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.15</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>myspringcloud</groupId>
<artifactId>discoveryservice</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>discoveryservice</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>11</java.version>
<spring-cloud.version>2021.0.8</spring-cloud.version>
</properties>
프로젝트를 생성하고 pom.xml을 확인해보면 spring-cloud.version과 spring-boot-starter 버전을 볼 수 있습니다. 해당 버전이 맞지 않을 경우 오류가 날 수 있으니, 오류가 날시 버전 체크가 필요합니다.
resources/application.properties 파일의 이름을 yml로 바꿔주고 다음과 같이 설정합니다.
서버 포트는 8761번을 사용하며, 어플리케이션 이름을 discoveryservice라고 정의하였습니다. 이름은 다르게 설정해도 무관합니다. eureka.client 하위 설정을 false로 한 이유는 default 설정으로 true로 되어있기 때문에 해당 설정이 true로 되어있다면 유레카 서버가 자기 자신의 서비스 정보도 유레카 클라이언트로 등록하겠다는 의미입니다. 이러한 설정은 필요하지 않기 때문에 false로 설정하여 자기 자신을 등록하지 않고 서버로만 기동하도록 하는 설정입니다.
DiscoveryServiceApplication
main이 있는 class를 들어가게 되면 @EnableEurekaServer 어노테이션 설정이 추가되어 있지 않기 때문에 추가해줍니다. 해당 어노테이션이 없을 경우 유레카 서버로 등록되지 않아 실행시 오류가 발생합니다.
서버를 기동 후, localhost:8761 주소를 들어가게 되면 다음과 같이 스프링에서 제공하는 Eureka Server UI를 볼 수 있습니다. 해당 화면이 정상적으로 뜬다면 유레카 서버로써의 기본 설정은 완료됩니다.
스프링부트 Eureka 클라언트 서비스 프로젝트 생성
다음은 Eureka Server가 관리할 Eureka Client 서비스 인스턴스 서버를 만들어보겠습니다.
Dependency 목록으로는 Eureka Discovery Client, Lombok, Spring Boot DevTools, Spring Web을 추가하여 프로젝트를 생성하였습니다.
Eureka 클라이언트 서비스 프로젝트 포트는 9001번을 사용할것이며, spring.application.name을 user-service로 등록하게 되면 Eureka 서버가 해당 서비스 서버명을 user-service로 인식하게 됩니다. register-with-eureka: true로 설정하게 되면 해당 서버를 Eureka Client로 등록하게 됩니다. Service Registry에 등록한다는 의미와 같습니다. fetch-registry: true는 Eureka 서버로부터 인스턴스들의 정보를 주기적으로 가져오겠다는 설정입니다. 마지막으로 service-url.defaultZone은 등록하고자 하는 Eureka 서버의 엔드포인트 주소입니다.
UserServiceApplication
main 클래스 파일을 열어 @EnableDiscoveryClient 설정을 추가해줍니다. 해당 설정을 추가하지 않을시 Eureka Client로 등록되지 않기 때문에 추가해줘야 합니다.
Eureka Client 서비스를 등록후 Eureka Server의 주소로 접근한다면 인스턴스로 등록된 USER-SERVICE 어플리케이션을 볼 수 있으며, 설정했던 application의 name과 port로 등록이 된 것을 확인할 수 있습니다. 해당 인스턴스 정보는 Eureka Client의 서버가 기동 되어 있을때만 UI에 표시가 됩니다.
Eureka 클라이언트 인스턴스 등록
위에서 만든 Eureka Server와 Eureka Client를 기반으로 여러개의 서비스 인스턴스를 생성하는 예제를 해보겠습니다.
클라이언트 서비스 서버를 실행하여 인스턴스를 만드는 기본적인 방법 3가지를 소개합니다.
1. IntelliJ Run 실행
InteliJ 우측 상단의 화살표를 클릭하여 실행하는 일반적인 방법입니다. 해당 실행은 yml 파일을 참조하여 서비스 인스턴스를 생성합니다.
2. IntelliJ - Edit Configurations - Add VM Options 실행
IntelliJ 무료 버전을 사용할 경우의 설정 방법으로 Copy Configuration으로 설정을 복사하여 Modify options의 Add VM Options를 추가해주어야 합니다.
VM Option를 -Dserver.port=9002로 설정하게 될 경우 해당 어플리케이션은 9002번 포트로 실행이 됩니다. 이렇게 설정을 해줄 경우 포트의 중복을 피할 수 있습니다.
3. CMD maven command 실행 방법
cmd 커맨드에서 mvn 커맨드가 설치되어 있는 경우에 사용할수 있으며, 실행하고자 하는 어플리케이션 root 경로로 이동하여 위와 같은 명령을 실행합니다.
위 3가지 방법으로 같은 서비스를 9001, 9002, 9003 포트로 실행시 Eureka Server UI에서 3개의 Instance가 생성된 것을 볼 수 있습니다. 하지만 동적으로 인스턴스가 생성되지 않습니다. 이러한 문제를 스프링에서는 랜덤 포트를 부여함으로써 자동 생성해줄 수 있습니다.
Eureka 클라이언트 인스턴스 랜덤 포트 생성 방식
위와 같은 서비스 인스턴스 포트를 자동으로 부여해주는 방법이 랜덤 포트 방식입니다. 스프링은 기본적으로 서버 포트를 0 으로 설정시 포트를 자동으로 부여해줍니다. 해당 설정을 위해 yml 파일을 변경해줍니다.
VM Option을 지운 후 다시 각각의 동일한 어플리케이션을 실행하여 Eureka Server UI를 확인해본 결과 아래와 같이 보이게 됩니다.
Build 결과에 보이는 내용으로는 각각 다른 포트로 성공적으로 올라간것으로 보이지만, UI에서는 0번포트의 인스턴스 1개만 실행 된것을 볼 수 있습니다. 이렇게 생성된 이유는 Eureka Server가 Client yml의 application name과 port를 읽기 때문에 동일한 설정 정보이기 때문에 하나의 인스턴스로 생성되게 됩니다. 이런 문제를 해결하기 위해서 yml의 설정을 다시 수정해줍니다.
eureka.instanc.instance=id : ${spring.cloud.client.hostname}:${spring.application.instance_id:${random.value}} 설정을 추가해주면 random.value를 사용해 인스턴스ID를 랜덤한 값으로 생성하겠다는 의미입니다. 이렇게 설정하게 되었을때 클라이언트 인스턴스는 랜덤한 포트를 가지는 랜덤한 인스턴스 ID로 생성되게 됩니다. 그 결과는 아래 화면과 같이 보입니다.
위와 같이 랜덤한 값으로 인스턴스ID가 할당되어 정상적으로 다수의 인스턴스가 생성된 것을 확인할 수 있습니다.
정리
위와 같은 과정을 클라우드 환경의 MSA 구조에서는 서비스 인스턴스를 자동으로 Scale-Out 해 주며, 로드 밸런싱을 통해 서버의 부하여부에 따라 인스턴스를 선택하게 해줍니다. 이러한 기능을 Spring Cloud Eureka Server가 Service Directory의 역할을 대체하여 수행하게 됩니다.
MSA와 Monolithic의 차이를 알기 위해서는 각 설계의 개념과 구조를 알아야 합니다. 아래 사진은 각 어플리케이션 설계 방식의 차이를 나타냅니다.
Monolithic vs Front & Back vs MSA
Monolithic이란 무엇인가?
Monolithic은 MSA와는 정반대되는 개념을 가지며,모든 리소스를 하나의 어플리케이션으로 제공하고 배포하는 설계 구조입니다. Monolithic 설계 방식은 하나의 어플리케이션에서 프론트,데이터베이스,백엔드 모든 것을 관리합니다. MSA와의 가장 큰 차이점은 셋 중 하나의 리소스가 변경되었을때마다 하나의 어플리케이션을 배포해주어야 하는 특징을 가집니다.
Front & Back 구조란?
Front & Back 설계 방식은 기존 Monolithic 방식과는 다르게 프론트와 백엔드를 나누는 방식입니다. 프론트와 백엔드를 나눔으로써 별도의 독립된 API 서버를 가지게 되어 기존에는 생각할 수 없었던 오픈 API를 제공할 수 있는 구조가 되었습니다. 이러한 API 서버를 독립된 비즈니스 서비스별로 쪼개어 관리하는것이 MSA의 유래가 되었습니다.
MSA란?
MSA는 Micro Service Architecture의 약자로작은 규모의 여러 비즈니스 서비스를 묶어 하나의 어플리케이션을 제공하고 배포하는 설계 구조입니다. MSA는 각각의 서비스별로 다른 개발언어와 데이터베이스를 사용할 수 있으며 컨테이너 기반으로 관리됩니다. 위 그림에서는 Product Service, RecoService를 각각 다른 개발언어와 데이터베이스를 사용하여 개발한 뒤 추후에 하나의 어플리케이션으로 배포됩니다. 이러한 작업을 하기 위하여 MSA는 Cloud Native 기반의 환경으로 구축하게 되며 Docker 컨테이너(Container)를 생성하여 각각의 서비스를 운영하게 됩니다.
MSA를 왜 사용하여야 하는가?
하나의 어플리케이션으로 모든 것을 관리하는 Monolithic 방식이 훨씬 간편하고 관리하기 쉬울텐데 구현이 어려워 보이는 MSA 방식을 왜 사용하여야 하는지 의문이 들게 될 것입니다. 그러한 이유 중 하나로 MSA는 클라우드 기반 어플리케이션(Cloud Nature Application)에 가장 적합하기 때문입니다.
Cloud Nature Application 특징
1. 확장 가능한 아키텍처
2. 탄력적 아키텍처
3. 장애 격리
클라우드 기반 어플리케이션은 수평적인 확장이 가능한 어플리케이션을 가져야 하며 확장된 서버로 시스템의 부하를 분산시키고 가용성을 보장해야 하기 때문에 이러한 특징을 Docker의 컨테이너(Container) 패키지를 사용하여 관리합니다.
탄력적 아키텍처란 분할 된 서비스 구조를 하나의 어플리케이션으로 생성-통합-배포해야 하기 때문에 이러한 어플리케이션의 변경을 유연하게 해야 합니다. MSA에서는 이런 아키텍처 구조를 가지기 위하여 External Gateway, CI/CD Automation, Service Mesh 구성 환경을 가지게 됩니다. 전체적인 MSA 구조는 아래와 같습니다.
마지막으로 장애 격리란 하나의 서비스에서 오류가 날시, 전체 서비스에 지장이 없어야 하는 특징입니다. 예를 들어 회원가입에 대한 서비스에 장애가 났을시, 주문에 대한 서비스나 조회에 대한 서비스는 지장 없이 운영이 되어야 합니다. 이러한 특징을 모두 가지는 것이 Cloud Nature Application의 MSA 구조입니다.
MSA 동작 방식
MSA는 독립된 서비스를 제공하기 위하여 REST API를 사용하는데 각각의 독립된 서비스를 REST API 인터페이스로 제공함으로써 클라이언트 단말 화면에서 접근할 수 있도록 합니다. 동작 순서는 다음과 같습니다.
1. 단말(APP)에서 요청하여 단말 요청 정보를 통합 관리해주는 API 게이트웨이를 호출합니다.
2. API 게이트웨이는 Service Router로 전달하고 Service Router는 Service Discovery에 해당 서비스 요청에 매칭되는 컨테이너 인스턴스가 있는지 확인합니다.
3. 요청에 맞는 서비스 정보가 있을 경우 로드 밸런싱을 통해 실제로 호출되는 각 컨테이너 서비스를 호출합니다.
정상적인 동작을 위해서는 위와 같은 과정이 필요하며 많은 기술이 요구 됩니다.
이러한 구조를 독립적으로 제공하는 프레임워크로는 스프링 클라우드가 있습니다.
추후에 스프링 클라우드를 사용한 MSA 서비스를 소개하도록 하겠습니다.
정리
MSA와 Monolithic 설계 구조가 생기게 된 결정적인 이유는 과거에 중앙집중적으로 관리하던 물리적 서버를 가상화하는 클라우드 기술이 나옴으로써 서버를 가상화하여 효율적인 관리를 하기 위하여 생기게 된 구조라고 생각합니다. 이러한 급진적인 변화에 맞춰 나또한 클라우드 기반의 어플리케이션을 개발할 수 있겠다는 생각을 하게 되었고, 그러한 개념과 구조를 이해해야 추후에 뒤쳐지지 않는 개발자가 될 수 있겠다는 생각이 들었습니다. 그렇기 때문에 클라우드 기반의 MSA 어플리케이션을 이해하고 개발하는 연습을 누구나 해야할 것입니다.
사진 출처 : 인프런 Dowon Lee 저자의 Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) 강의 화면