인턴의 당돌한 고래사육(a.k.a OJT 프로젝트에 MSA를 태워?) - 최종화

DevSeoRex·2023년 11월 26일
4

😎 조건이 너무 많아, IF - ELSE로 오염된 나의 코드..

OJT 프로젝트 발표 전, docker-compose를 이용해 한 번에 10개의 컨테이너를 올리는 작업을 하려고 하던 와중에 너무나도 초라한 제 코드를 보고나니 리팩토링을 해야겠다는 생각이 들었습니다.

가장 큰 문제는 option이 3개, 최소 값이냐 최대 값이냐에 따른 조건에 의해 2개의 분기가 생겨 총 6개의 분기가 생길 수 있다는 것이였습니다.

😃 Switch 문을 사용해 개선을 시도해보자!

VM의 메트릭 정보에서 RAM 또는 CPU, RAM + CPU 세 가지 조건의 최소 또는 최대값을 보여주는 APISwitch 문을 이용해 리팩토링하면 아래와 같은 코드로 개선됩니다.

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를 만들어서 서비스 메서드 내부에서 호출해주면 조금 더 깔끔한 처리가 되지 않을까 하는 생각이 있습니다.

🫣 JsonNode를 이용한 파싱보다 좋은 건 없을까?

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();
}

이 부분도 개선한다면 조금 더 간결하고 좋은 코드가 나올 것 같습니다.
아쉬운 부분은 이 정도가 될 것 같습니다.

🫡 간단한 실행을 위한 Docker-Compose 사용

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를 구축한다면 조금 더 친숙하게 다가갈 수 있지 않을까 기대가 됩니다.

오늘도 읽어주셔서 감사합니다.

🙇

5개의 댓글

comment-user-thumbnail
2023년 11월 26일

잘 읽고 갑니다 ㅎ

1개의 답글
comment-user-thumbnail
2023년 11월 26일

좋은 글 항상 감사합니다~ 잘 보고 있습니다~!

1개의 답글