📄 Describe
사전 설명
우리 FitTrip은 프로젝트 초반 제가 진행한 MSA 세미나
를 통해 MSA에 관해 알아보았고,
해당 내용을 프로젝트에 적용했습니다.
MSA
FitTrip의 MSA 적용
- API Gateway
- Service Discovery
- Container Management
- Backing Services
- DB(Redis, MySQL, MariaDB, MongoDB)
- Kafka <- MOM
- CI/CD Automation
- Telemetry
- Grafana
- Loki
Config Store와 관련하여 Spring Cloud Config Server
를 이용하고 싶었습니다.
하지만, Github Actions
사용시 발생하는 보안적인 문제가 있었습니다.
-> Config Server를 외부에서 접속하는 위험
- 우리 도메인과 Config Server Port를 알고 있다면 누구나 접속 가능
해결을 위해 Spring Security
의 Basic Auth
를 사용하려 하였지만, 계속 연동에 오류가 생겼습니다.
(이 부분은 추후에 다시 시도해보려 합니다.)
아쉽게도 MSA
의 Config Store를 구현하진 못했지만, 그래도 MSA 구성요소의 상당 부분을 다뤘다고 생각합니다.
FitTrip MSA 연동 서비스 구현
저는 MSA
의 중심 서비스가 되는 Service Discovery
와 API Gateway
구현을 위해Spring Cloud
라이브러리를 활용해 구현했습니다.
- Spring Netflix Eureka Server
- Spring Netflix Eureka Client
- Spring Cloud Gateway
Spring Netflix Eureka
는 Service Registration
& Discovery
역할을 해주는 아주 편리한 친구입니다.\
FitTrip은 Spring Netflix Eureka
를 활용하여 각 서비스를 Service Discovery
에 등록하였고,
클라이언트의 요청은 Spring Cloud Gateway
를 활용하여 라우팅을 해줍니다.
Netflix Eureka Server(Service Discovery)에 등록된 서비스들
Spring Cloud Gateway
가 클라이언트의 요청을 전담하게 되면서 생기는 몇 가지 이점이 존재합니다.
- 각 서비스들은
Service Discovery
에 등록이 되므로 API Gateway는 각 서비스를 확인할 필요가 없음 Scale-Out
시 용이함(서비스의 접속 경로와 포트 번호는Service Discovery
가 알고 있음)- 인증/인가 작업
- 로깅
Spring 라이브러리의 좀 더 자세한 내용은 다음 주소를 참고하면 됩니다.(https://hdbstn3055.tistory.com/72)
FitTrip의 아키텍처
FitTrip의 아키텍처는 위와 같습니다. 여기서, 설명한 서비스들(Service Discovery, API Gateway)은 어떻게 동작할까요?
Spring Netflix Eukreka Server 설정
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServiceApplication {
public static void main(String[] args) {
SpringApplication.run(DiscoveryServiceApplication.class, args);
}
}
server:
port: 8761
spring:
application:
name: discovery-service
eureka:
client:
register-with-eureka: false
fetch-registry: false
위의 설정을 갖는 Service Discovery
역할의 디스커버리 서비스를 구성합니다.
해당 서비스를 통해 우리는 FitTrip의 서비스들을 식별
할 수 있고 관리
할 수 있습니다.
Spring Netflix Eukreka Client 설정
@SpringBootApplication
@EnableFeignClients
@EnableDiscoveryClient
public class GatewayServiceApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayServiceApplication.class, args);
}
}
eureka:
client:
fetch-registry: true # 유레카 클라이언트 활성화
register-with-eureka: true # 유레카 클라이언트 활성화
service-url:
defaultZone: http://discovery-service:8761/eureka # 유레카 클라이언트로 등록
registryFetchIntervalSeconds: 10 # Eureka 클라이언트가 Eureka 서버로부터 서비스 인스턴스 목록을 가져오는 간격을 10초로 설정
instance:
instance-id: {서비스별}
application:
name: {서비스별}
위의 설정을 각 서비스들에 해주게 되면 Eureka Client
로서,
Service Discovery
인 Eureka Server
에 등록이 됩니다.
즉, 이제는 Service Discovery
를 통해 서비스들의 URL을 알 수 있습니다.
FitTrip 동작 흐름
nginx 설정
server {
listen 80;
server_name fittrip.site;
return 301 https://fittrip.site$request_uri;
}
map $http_origin $allowed_origin {
default "";
"http://localhost:3000" $http_origin;
}
server {
listen 443 ssl;
server_name fittrip.site;
ssl_certificate /etc/letsencrypt/live/fittrip.site/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/fittrip.site/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
location /api {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $allowed_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, PATCH, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Allow-Credentials' 'true';
return 204;
}
add_header 'Access-Control-Allow-Origin' $allowed_origin always;
add_header 'Access-Control-Allow-Credentials' 'true';
proxy_pass http://localhost:8000;
}
location /stomp {
proxy_pass http://localhost:8000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /api/notice {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $allowed_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Allow-Credentials' 'true';
return 204;
}
add_header 'Access-Control-Allow-Origin' $allowed_origin always;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header Cache-Control no-cache;
proxy_pass http://localhost:8000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Accel-Buffering 'no';
proxy_set_header Connection '';
proxy_http_version 1.1;
proxy_buffering off;
keepalive_timeout 7200;
}
}
FitTrip은 위의 그림/NGINX 설정 예시처럼 유저의 요청을 라우팅하고 처리합니다.
(채팅 서비스의 경우는 STOMP
사용으로 조금 다르게 동작합니다. #113)
잠깐, 주의할 점이 있습니다.
우리는 이제 API Gateway
를 사용합니다. 이제 유저의 모든 요청은 API Gateway
를 거쳐 갑니다.
이때, 특별한 작업을 해주지 않으면MSA
가 갖는 이점인 쉬운 Scale-Out
에 작은 문제가 생길 수 있습니다.
API Gateway 사용하지 않는 경우
Spring 컨테이너에 대한 포트 번호
를 지정하여 사용합니다.
NGINX는 해당 포트 번호
로 라우팅을 합니다.
API Gateway 사용하는 경우
게이트웨이는 Service Discovery
로부터 서비스 접속 경로
와 PORT
를 전달받습니다.
하지만, 이때 각 서비스마다 포트 번호
가 지정되어 있다면?
서버 구성에 따라 Scale-Out
시 포트 충돌이 일어날 것입니다.
즉, Service Discovery
에 등록만 되면 API Gateway
가 확인하여 라우팅 하는
아주 간단한 작업이 불가능해집니다.
따라서, 우리는 포트 번호
를 고정하지 않도록 작업해야 합니다.
방법은 아주 간단합니다.
각 서비스application.yml
에 아래 내용을 추가해주면 됩니다.
server:
port: 0
위 설정시 Spring 애플리케이션은 사용가능한 남는 포트를 알아서 지정합니다.
게이트웨이 서비스routes
설정에 lb://{서비스 이름}
을 추가해줍니다.
routes:
- id: user-service
uri: lb://USER-SERVICE # 포워딩할 주소, http://localhost:8000/user 로 들어오면 http://localhost:64412 로 포워딩
predicates:
- Path=/api/user/** # 해당 gateway 서버의 /user/**로 들어오는 요은 user-service로 인식하겠다는 조건
filters:
- RewritePath=/api/user/?(?<segment>.*), /$\{segment}
- id: community-service
uri: lb://COMMUNITY-SERVICE # 포워딩 할 주소, http://localhost:8000/team 로 들어오면 http://localhost:54412 로 포워딩
predicates:
- Path=/api/community/** # 해당 gateway 서버의 /order/**로 들어오는 요청은 order-service로 인식하겠다는 조건
filters:
- RewritePath=/api/community/?(?<segment>.*), /$\{segment}
그리고 조금 특별한 경우로, 우리 FitTrip은 각 서비스마다 서버를 전부 구동하는 건 재정적인 이슈로 불가능했습니다.
따라서, 한 서버를 구동하되 조금 성능이 좋은 서버를 사용하는 것으로 하였습니다.
그렇기에 한 서버에 다양한 서비스를 띄워 관리하였고 그에 따라 docker-compose
를 사용하였습니다.
즉, 한 서버 내에서 API Gateway
를 사용하여 각 서비스로 라우트 합니다.
그에 따라 docker-compose.yml
에서도 포트 번호
를 지정하지 않아야 합니다.
docker-compose 설정
...
notification-service:
image: hanyoonsoo/notification-service:1.0
container_name: notification-service
environment:
jasypt.encryptor.password: ${JASYPT_PASSWORD}
depends_on:
- zookeeper
- kafka
- discovery-service
- gateway-service
- mysql-notification
volumes:
- ./logs/notification-service:/logs
...
보통 PORTS
를 통해 포트를 지정하지만, FitTrip에서는 지정하지 않았습니다.
이로써, 우리는 언제 Scale-Out
이 되어도 문제가 없는 라우팅이 가능해졌습니다.
(무중단 배포와는 다른 의미)
API Gateway 인증/인가 관리
FitTrip은 유저 서비스
에서 발급된 JWT를 이용하여 보안 작업을 진행합니다.
따라서, 그에 맞게 API Gateway는 인증/인가 처리를 해주어야 합니다.
인증/인가를 위해 API Gateway
에는 AuthorizationFilter
라는 커스텀 필터를 생성했습니다.
AuthorizationFilter
@Slf4j
@Component
public class AuthorizationFilter extends AbstractGatewayFilterFactory<AuthorizationFilter.Config> {
private final JwtTokenValidator jwtTokenValidator;
private final WebClient.Builder webClientBuilder;
public AuthorizationFilter(JwtTokenValidator jwtTokenValidator, WebClient.Builder webClientBuilder){
super(Config.class);
this.jwtTokenValidator = jwtTokenValidator;
this.webClientBuilder = webClientBuilder;
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
String uri = exchange.getRequest().getURI().getPath();
if(isWhiteList(uri)){
return chain.filter(exchange);
}
String accessToken =
jwtTokenValidator.resolveAccessToken(exchange.getRequest());
String refreshToken =
jwtTokenValidator.resolveRefreshToken(exchange.getRequest());
if (StringUtils.hasText(accessToken) && jwtTokenValidator.validateToken(accessToken)) {
return doNotLogout(accessToken, exchange.getRequest())
.flatMap(isValid -> {
if (Boolean.TRUE.equals(isValid)) {
// Token is valid, continue to the next filter
return chain.filter(exchange);
} else {
// Token is not valid, respond with unauthorized
System.out.println(isValid);
return unauthorizedResponse(exchange);
}
})
.onErrorResume(e -> {
log.error("API Gateway - AccessToken validation error: {}", e.getMessage());
// Handle runtime exceptions by returning unauthorized response
return unauthorizedResponse(exchange);
});
} else {
// If accessToken is not valid or not present
return unauthorizedResponse(exchange);
}
};
}
private boolean isWhiteList(String uri) {
for (WhiteListURI whiteListURI : WhiteListURI.values()) {
// 해당 URI가 화이트 리스트에 정의된 URI로 시작하는지 확인
System.out.println(uri);
if (uri.startsWith(whiteListURI.uri)) {
return true;
}
}
// 화이트 리스트에 없으면 false 반환
return false;
}
private Mono<Boolean> doNotLogout(String accessToken, ServerHttpRequest request) {
HttpHeaders headers = request.getHeaders();
// System.out.println(accessToken);
// accessToken을 JSON 객체로 변환
String requestBody = accessToken;
return webClientBuilder.build().post()
.uri("lb://USER-SERVICE/isLogin")
// .headers(httpHeaders -> httpHeaders.addAll(headers))
.contentType(MediaType.APPLICATION_JSON) // JSON 컨텐츠 타입 명시
.body(BodyInserters.fromValue(requestBody)) // JSON 객체로 변환된 본문 사용
.retrieve()
.bodyToMono(DataResponseDto.class)
.map(response -> Boolean.TRUE.equals(response.getData()));
}
// 인증 실패 Response
private Mono<Void> unauthorizedResponse(ServerWebExchange exchange) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
ErrorResponseDto errorResponseDto = ErrorResponseDto.of(Code.UNAUTHORIZED);
ObjectMapper objectMapper = new ObjectMapper();
byte[] bytes;
try{
bytes = objectMapper.writeValueAsBytes(errorResponseDto);
} catch (JsonProcessingException e) {
bytes = "{}".getBytes(StandardCharsets.UTF_8);
}
// 응답 헤더 설정
exchange.getResponse().getHeaders().add("Content-Type", "application/json");
DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory();
DataBuffer buffer = bufferFactory.wrap(bytes);
return exchange.getResponse().writeWith(Mono.just(buffer));
}
// Config할 inner class -> 설정파일에 있는 args
public static class Config{
}
}
AuthorizationFilter
는 헤더에 존재하는 토큰을 읽어 적절한 JWT 시크릿키로 검증을 시도합니다.
(통상적인 JWT 인증/인가 방식)
이때, 유저의 로그아웃 여부는 doNotLogout()
메소드를 통해 유저 서비스와 소통하며 실시간으로 확인합니다.
(MSA에 따라 유저 서비스의 Redis에는 유저 서비스만 접속가능해야 한다고 판단했기 때문입니다.)
정리
위의 작업들을 통해 FitTrip은 MSA에서 API Gateway
가 갖는 이점들을 갖도록 구현했습니다.
MSA의 Telemetry 서비스
초기에는 Telemetry를 관련하여 작업을 진행할 생각이 없었습니다.
하지만, 프론트엔드와 백엔드 연동간 다양한 문제에 직면했고,
그에 따라 역할 상호간 로그 확인의 필요성을 느끼게 되었습니다.
따라서, Telemetry
환경을 구성했습니다.
정리
FitTrip는 MSA 채택하였고 그에 따라 프로젝트를 진행해나갔습니다.
완벽한 MSA를 구축했다고 할 순 없지만, 최대한 MSA 구성요소에 걸맞도록 구현을 했습니다.
특히, 가장 핵심이 되는 Service Discovery
, API Gateway
를 통해 MSA가 갖는 많은 이점을 얻을 수 있었던 것 같습니다.
이 밖에도 MSA의 구성요소 중 하나인 Telemetry
환경도 구성하여 작업간 정보 불일치 문제를 해결했습니다.Telemetry
구현을 통해 로그 모니터링으로 얻는 시간 단축 및 작업의 용이함을 경험했습니다.
앞으로 있을 프로젝트, 취업의 경우에 MSA로 서비스들이 구성되어있을지는 미지수지만,Cloud Native Architecture
가 유행으로 번지는 요즘, MSA로 프로젝트를 구성한 경험이 큰 도움이 될 것 같습니다.
'프로젝트 > FitTrip' 카테고리의 다른 글
[커뮤니티 서비스] 커뮤니티 서비스와 다른 서비스의 통신 (0) | 2024.08.07 |
---|---|
[커뮤니티 서비스] 포럼 조회시 페이징 반환 Slice로 처리 (0) | 2024.07.18 |
[커뮤니티 서비스] Redis를 활용한 초대코드 구현 (1) | 2024.07.05 |
[FitTrip] DELETE IN을 사용한 배치 처리로 얻을 수 있는 성능 향상 (0) | 2024.07.02 |
[커뮤니티 서비스] 커뮤니티 서비스 기능정리 (0) | 2024.06.29 |