OJT
프로젝트 발표 전, docker-compose
를 이용해 한 번에 10개
의 컨테이너를 올리는 작업을 하려고 하던 와중에 너무나도 초라한 제 코드를 보고나니 리팩토링
을 해야겠다는 생각이 들었습니다.
가장 큰 문제는 option
이 3개, 최소 값
이냐 최대 값
이냐에 따른 조건에 의해 2개의 분기
가 생겨 총 6개의 분기가 생길 수 있다는 것이였습니다.
각 VM
의 메트릭 정보에서 RAM
또는 CPU
, RAM + CPU
세 가지 조건의 최소 또는 최대값을 보여주는 API
를 Switch 문
을 이용해 리팩토링하면 아래와 같은 코드로 개선됩니다.
private void setSubAggregation(String type, String option, DateHistogramAggregationBuilder dataHistogramAgg) {
switch (type) {
case "max" -> setMaxAggregationType(dataHistogramAgg, option);
case "min" -> setMinAggregationType(dataHistogramAgg, option);
}
}
private void setMaxAggregationType(DateHistogramAggregationBuilder dataHistogramAgg, String option) {
switch (option) {
case "ram" -> {
MaxAggregationBuilder maxRamAgg = AggregationBuilders.max("vm_hypervisor_status_memory_utillization")
.field("basic.host.memory.usage");
dataHistogramAgg.subAggregation(maxRamAgg);
}
case "cpu" -> {
MaxAggregationBuilder maxCpuAgg = AggregationBuilders.max("vm_hypervisor_status_cpu_utillization")
.field("basic.host.cpu.usage.norm.pct");
dataHistogramAgg.subAggregation(maxCpuAgg);
}
case "all" -> {
MaxAggregationBuilder maxRamAgg = AggregationBuilders.max("vm_hypervisor_status_memory_utillization")
.field("basic.host.memory.usage");
MaxAggregationBuilder maxCpuAgg = AggregationBuilders.max("vm_hypervisor_status_cpu_utillization")
.field("basic.host.cpu.usage.norm.pct");
dataHistogramAgg.subAggregation(maxRamAgg);
dataHistogramAgg.subAggregation(maxCpuAgg);
}
}
}
Switch
문을 이용해 조금 깔끔해진 것 같지만, 이렇게 되면 Switch
문 내부의 코드가 길고, 각 조건별로 수정이 발생하게 된다면 Service 로직
을 계속 변경해야 하는 문제가 발생합니다.
여기서 고민한 내용은 Service Layer
에서 어떤 조건으로 조회
를 할 것인지 결정하는 그 내부 구현의 변경으로 인해서 Service 클래스
를 수정
해서 변경하는 것이 과연 좋은 방법
일까 하는 고민이였습니다.
그래서 ram
, cpu
, all
조건을 저는 조회 전략(Strategy)
으로 보고 각 전략을 따로 클래스로 분리해 내부 구현
을 숨겨보기로 결정했습니다.
먼저 ram
, cpu
, all
조건으로 조회를 할 수 있고 각 전략별로 최대 값 또는 최소 값을 조회할 수 있습니다.
그래서 저는 아래와 같이 인터페이스
를 설계 했습니다.
public interface UsageApiSearchStrategy {
void setMaxStrategy(DateHistogramAggregationBuilder dataHistogramAgg);
void setMinStrategy(DateHistogramAggregationBuilder dataHistogramAgg);
}
이렇게 해서 각 전략별로 UsageApiSearchStrategy
를 구현하여 최대 값
과 최소 값
을 조회하는 조건을 셋팅해주는 클래스를 만들었습니다.
@Component
public class UsageAllOptionStrategy implements UsageApiSearchStrategy {
@Override
public void setMaxStrategy(DateHistogramAggregationBuilder dataHistogramAgg) {
MaxAggregationBuilder maxRamAgg = AggregationBuilders.max("vm_hypervisor_status_memory_utillization")
.field("basic.host.memory.usage");
MaxAggregationBuilder maxCpuAgg = AggregationBuilders.max("vm_hypervisor_status_cpu_utillization")
.field("basic.host.cpu.usage.norm.pct");
dataHistogramAgg.subAggregation(maxRamAgg);
dataHistogramAgg.subAggregation(maxCpuAgg);
}
@Override
public void setMinStrategy(DateHistogramAggregationBuilder dataHistogramAgg) {
MinAggregationBuilder minCpuAgg = AggregationBuilders.min("vm_hypervisor_status_cpu_utillization")
.field("basic.host.cpu.usage.norm.pct");
MinAggregationBuilder minRamAgg = AggregationBuilders.min("vm_hypervisor_status_memory_utillization")
.field("basic.host.memory.usage");
dataHistogramAgg.subAggregation(minRamAgg);
dataHistogramAgg.subAggregation(minCpuAgg);
}
}
... 중략
UsageAllOptionStrategy
만 올렸지만, cpu와 all 조건에 맞는 전략 클래스도 만들어주었습니다.
private void setMaxAggregationType(DateHistogramAggregationBuilder dataHistogramAgg, String option) {
switch (option) {
case "ram" -> ramUsageStrategy.setUsageRamMaxOptionStrategy(dataHistogramAgg);
case "cpu" -> cpuUsageStrategy.setUsageCpuMaxOptionStrategy(dataHistogramAgg);
case "all" -> allUsageStrategy.setUsageAllMaxOptionStrategy(dataHistogramAgg);
}
}
각 전략
을 호출하는 서비스 코드에서는 내부 구현
과 상관없이 조건에 맞는 쿼리를 만드는 책임을 전략 클래스
에 위임하게 되는 것입니다.
public class UsageApiService implements ApiService {
private final ObjectMapper mapper;
private final RestHighLevelClient restHighLevelClient;
private final UsageAllOptionStrategy allUsageStrategy;
private final RamAllOptionStrategy ramUsageStrategy;
private final CpuAllOptionStrategy cpuUsageStrategy;
...
전략이 늘어감에 따라서, UsageApiService
에서 의존하는 클래스의 개수가 점차 증가할 것을 염려하게 되었습니다.
하지만 이 전략 클래스
들과의 의존성이 없으면 조건에 맞는 쿼리를 만드는 책임을 위임
할 수 없기 때문에 어떻게 해야 할지 고민이 많았습니다.
그래서 들었던 생각은 "전략 클래스
들에 대한 의존은 누군가 대신 해주고, 서비스는 간편하게 호출
만 하면 좋지 않을까?" 였습니다. 이 생각을 코드로 구현할 수 있도록 이미 만들어진 Facade 패턴
을 차용했습니다.
@Component
@RequiredArgsConstructor
public class UsageSearchStrategyAdapter {
private final UsageAllOptionStrategy usageAllOptionStrategy;
private final UsageCpuOptionStrategy usageCpuOptionStrategy;
private final UsageRamOptionStrategy usageRamOptionStrategy;
public void setUsageAllMaxOptionStrategy(DateHistogramAggregationBuilder dataHistogramAgg) {
usageAllOptionStrategy.setMaxStrategy(dataHistogramAgg);
}
public void setUsageAllMinOptionStrategy(DateHistogramAggregationBuilder dataHistogramAgg) {
usageAllOptionStrategy.setMinStrategy(dataHistogramAgg);
}
public void setUsageCpuMaxOptionStrategy(DateHistogramAggregationBuilder dataHistogramAgg) {
usageCpuOptionStrategy.setMaxStrategy(dataHistogramAgg);
}
public void setUsageCpuMinOptionStrategy(DateHistogramAggregationBuilder dataHistogramAgg) {
usageCpuOptionStrategy.setMinStrategy(dataHistogramAgg);
}
public void setUsageRamMaxOptionStrategy(DateHistogramAggregationBuilder dataHistogramAgg) {
usageRamOptionStrategy.setMaxStrategy(dataHistogramAgg);
}
public void setUsageRamMinOptionStrategy(DateHistogramAggregationBuilder dataHistogramAgg) {
usageRamOptionStrategy.setMinStrategy(dataHistogramAgg);
}
}
Facade 패턴
은 아주 간단합니다. 각 전략 클래스를 UsageSearchStrategyAdapter
클래스가 전부 의존성을 가져가고, 내부적으로 호출
만 해주는 형식을 취합니다.
이렇게 코드에 적용하면 Service 클래스
는 UsageSearchStrategyAdapter
클래스에 대한 의존성 한개만 가지면 모든 전략 클래스의 메서드
를 사용
할 수 있게 됩니다.
지금 생각해보면 UsageSearchStrategyAdapter
보다는 UsageSearchStrategyFacade
가 조금 더 좋은 이름이 아닐까 생각이 듭니다.
public class UsageApiService implements ApiService {
private final ObjectMapper mapper;
private final RestHighLevelClient restHighLevelClient;
private final UsageSearchStrategyAdapter strategyAdapter;
...
이렇게 늘어나는 의존성 문제
까지 전부 해결했습니다.
OJT
때는 여기까지만 코드를 리팩토링
하고 도커라이징
후 발표를 했는데요.
이 글을 쓰는 시점이 OJT
가 끝난지 한 달이 넘은 시점이다보니 이 포스팅의 코드 또한 문제가 있음을 알았고 현재 프로젝트
에서 더 나은 방법을 쓰고 있습니다.
어떤 것들이 있는지 간단히 말씀드리고 싶어 남겨봅니다.
@Getter
@ToString
@AllArgsConstructor
public class TopNSearchCondition {
private String option;
private boolean sort;
private int size;
public static class TopNSearchConditionBuilder{
private String option;
private boolean sort;
private int size;
public TopNSearchConditionBuilder option(String option) {
if (!StringUtils.hasText(option) || !(option.equals("disk") || option.equals("memory") || option.equals("cpu"))) {
throw new CustomException(ILLEGAL_OPTION_TOPN_BAD_REQUEST);
}
this.option = option;
return this;
}
public TopNSearchConditionBuilder sort(String sort) {
if (!StringUtils.hasText(sort) || !(sort.equals("asc") || sort.equals("desc"))) {
throw new CustomException(ILLEGAL_SORT_TOPN_BAD_REQUEST);
}
this.sort = !sort.equals("desc");
return this;
}
public TopNSearchConditionBuilder size(String size) {
int requestSize = Integer.parseInt(size);
if (requestSize < 1 || requestSize > 50) throw new CustomException(ILLEGAL_RANGE_TOPN_BAD_REQUEST);
this.size = requestSize;
return this;
}
public TopNSearchCondition build() {
return new TopNSearchCondition(option, sort, size);
}
}
}
검색 조건 DTO
에 값을 바인딩할때 유효성 검사를 하는 메서드들을 직접 DTO
에 넣어주고 있습니다.
이 부분은 CustomValidator
를 만들어서 서비스 메서드 내부에서 호출해주면 조금 더 깔끔한 처리
가 되지 않을까 하는 생각이 있습니다.
public static UsageApiResponse createUsageApiResponse(JsonNode jsonNode, ObjectMapper mapper) {
// OpenSearch 쿼리의 결과를 JsonNode에 넣어서 원하는 프로퍼티까지 접근한다.
String hostId = jsonNode.get("aggregations").get("sterms#group_aggs").get("buckets").get(0).get("key").asText();
// 원하는 프로퍼티에 접근해서 원하는 데이터를 Java 객체로 변환해준다.
List<UsageApiResponse.Bucket> buckets = mapper.convertValue(jsonNode.get("aggregations")
.get("sterms#group_aggs")
.get("buckets")
.get(0)
.get("date_histogram#date_histogram")
.get("buckets"),
new TypeReference<>() {});
List<UsageApiResponse.UsageResponse> responses = buckets.stream()
.map(UsageApiResponse.UsageResponse::new)
.collect(Collectors.toList());
return new UsageApiResponse(hostId, responses);
}
현재 코드는 이렇게 JsonNode
에 직접 접근해서 원하는 응답 구조를 만들어내고 있습니다.
하지만 이렇게 하지 않아도 SearchResponse
내에는 여러 클래스들이 있어서 Stream
을 이용해 단순하게 값을 뽑아 DTO List
로 만들 수 있어, 개선 가능성이 있습니다.
private List<LogResponseDto.LogDto> createDataList(SearchResponse searchResponse) {
// 데이터가 없는 경우 빈 List를 반환한다.
if (searchResponse.getHits().getTotalHits().value == 0) {
return Collections.emptyList();
}
return Arrays.stream(searchResponse.getHits().getHits())
.map(this::mapToDto)
.toList();
}
이 부분도 개선한다면 조금 더 간결하고 좋은 코드
가 나올 것 같습니다.
아쉬운 부분은 이 정도가 될 것 같습니다.
FROM openjdk:17-ea-11-jdk-slim
VOLUME /tmp
COPY build/libs/service-0.0.1-SNAPSHOT.jar AnalyticsServer.jar
ENTRYPOINT ["java", "-jar", "AnalyticsServer.jar"]
이렇게 각 서비스에 맞는 Dockerfile
을 먼저 각 프로젝트 마다 넣어 주었습니다.
version: '3'
services:
zookeeper:
image: wurstmeister/zookeeper
ports:
- "2181:2181"
networks:
my-network:
ipv4_address: 172.18.0.100
kafka:
image: wurstmeister/kafka
ports:
- "9092:9092"
environment:
KAFKA_ADVERTISED_HOST_NAME: 172.18.0.101
KAFKA_CREATE_TOPICS: "request_log"
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
volumes:
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
- zookeeper
networks:
my-network:
ipv4_address: 172.18.0.101
rabbitmq:
image: rabbitmq:management
ports:
- "15671:15671"
- "15672:15672"
- "5671:5671"
- "5672:5672"
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
networks:
my-network:
config-service:
image: ch4570/config-service
ports:
- "8888:8888"
environment:
spring.rabbitmq.host: rabbitmq
spring.profiles.active: default
depends_on:
- rabbitmq
networks:
my-network:
discovery-service:
image: ch4570/discovery-service
ports:
- "8761:8761"
environment:
spring.cloud.config.uri: http://config-service:8888
depends_on:
- config-service
networks:
my-network:
apigateway-service:
image: ch4570/gateway-service
ports:
- "8000:8000"
environment:
spring.cloud.config.uri: http://config-service:8888
spring.rabbitmq.host: rabbitmq
eureka.client.serviceUrl.defaultZone: http://discovery-service:8761/eureka/
depends_on:
- discovery-service
- config-service
- zipkin
networks:
my-network:
postgredb:
image: postgres:13-alpine
environment:
POSTGRES_USER: ojt
POSTGRES_PASSWORD: ojt
POSTGRES_DB: ojt
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
my-network:
zipkin:
image: openzipkin/zipkin
ports:
- "9411:9411"
networks:
my-network:
ipv4_address: 172.18.0.17
api-service:
image: ch4570/api-service
environment:
spring.cloud.config.uri: http://config-service:8888
spring.rabbitmq.host: rabbitmq
eureka.client.serviceUrl.defaultZone: http://discovery-service:8761/eureka/
server.port: 30001
depends_on:
- apigateway-service
- config-service
- zipkin
ports:
- "30001:30001"
networks:
my-network:
ipv4_address: 172.18.0.12
analytics-service:
image: ch4570/analytics-service
environment:
spring.cloud.config.uri: http://config-service:8888
spring.rabbitmq.host: rabbitmq
eureka.client.serviceUrl.defaultZone: http://discovery-service:8761/eureka/
server.port: 40001
spring.r2dbc.url: r2dbc:postgresql://postgredb:5432/ojt?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Seoul
depends_on:
- apigateway-service
- config-service
- postgredb
- zipkin
ports:
- "40001:40001"
networks:
my-network:
ipv4_address: 172.18.0.13
networks:
my-network:
external: true
name: ecommerce-network
volumes:
pgdata:
그 후에 위와 같이 같은 네트워크
를 쓰도록 docker-compose.yml
에 모든 서비스를 모아주고 서비스끼리는 각 서비스의 이름
으로 서로 찾아갈 수 있도록 했습니다.
서비스는 image
를 만들고 실행해야 하는 문제가 있는데, 그 부분은 배포 스크립트
를 작성해서 자동화가 가능한데 간단한 예시
를 들어 보겠습니다.
cd dummy
// test code를 실행하지 않고 gradle 빌드
./gradlew clean build -x test
// 이 폴더에서 빌드 된 내용을 opensearch/data-inserter라는 이름의 도커 이미지로 만든다.
sudo docker build -t opensearch/data-inserter .
cd ../api
./gradlew clean build -x test
sudo docker build -t opensearch/api-service .
cd ../
sudo docker-compose up -d
sleep 300
sudo docker stop opensearch-datainserter-service-1
이제 이런식으로 서비스 폴더
마다 들어가서 서비스를 빌드
하고, 도커 이미지화
시킨 다음 이 작업이 모두 끝나면 docker-compose up -d
명령어로 모든 서비스를 일괄적으로 실행시키는 방법입니다.
이렇게 OpenSearch
를 이용한 OJT 프로젝트
가 모두 끝났습니다.
기간이 짧다보니 아쉬운 점도 많았지만, 비정형 데이터 베이스
를 사용해 나중에 사이드 프로젝트
에서 ELK
를 구축한다면 조금 더 친숙하게 다가갈 수 있지 않을까 기대가 됩니다.
오늘도 읽어주셔서 감사합니다.
잘 읽고 갑니다 ㅎ