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

DevSeoRex·2023년 10월 29일
9
post-thumbnail

😀 입사 한지 한달 반 된 귀여운 인턴..

블로그를 보다보니 글을 안쓴지도 두 달이나 된 것 같습니다.
꾸준히 공부도 하고 깃에 소스코드도 업로드하고 있지만 너무 블로그에 글을 안쓴 것 같아 마음먹고 이번 인턴 OJT 프로젝트에 대한 내용을 남겨볼까 합니다.

9월 중순에 입사해서 팀 배정을 10월에 받고, 다른 팀과 함께 백엔드 OJT를 시작하게 되었습니다.
현재 이 글을 쓴 시점에는 발표까지 끝났습니다.

이 포스팅은 여러 개의 시리즈로 이루어질 예정이며, 처음 사용하는 기술에 주눅들어 작은 아키텍처를 이용해 과제만 완수하려던 마음이 어느새 욕망이 늘어나 부가 기능까지 만든 한 인턴의 패기를 그린 이야기입니다.

👀 고래 사육의 서막

백엔드 OJT 과제는 2주동안 API 서버를 만드는 것이였는데, 요구사항은 아래와 같았습니다.

  • Spring Webflux & Spring Cloud를 사용할 것
  • OpenSearch에서 집계할 수 있는 쿼리를 이용해 3가지 이상의 API를 만들 것
  • 반복문은 stream을 이용해 처리할 것
  • 배포는 VM이 지급되지 않으니 개인 노트북에 알아서 할 것

여기서 이미 저는 난항을 겪게 됩니다.
Spring MVC로만 개발을 진행해왔고, OpenSearch에 대해서 들어본적도 없기 때문입니다.

먼저 아키텍처를 수립하기 전에 해야할 것들을 정해보았습니다.
제가 일정관리를 위해 우선순위로 둔 작업들은 아래와 같습니다.

  • OpenSearch에 쿼리를 해서 데이터가 뽑아지는지 확인하기
  • Spring Webflux로 간단한 API를 만들어보고 어떻게 사용해야하는지 알아보기

이 문제가 가장 어려웠던 이유는, 새로운 기술을 이해할 시간도 없이 바로 적용해 개발해보는 것이 난생 처음 해보는 일이였기 때문입니다.

😲 OpenSearch 쿼리에 성공하다!

기존에 RDB만 사용하던 저로서는 OpenSearch의 JSON 형식 쿼리가 이해되지 않았고 이걸 어떻게 써야할지 감이 잡히질 않았습니다.

개발자는 그래도 백문이불여일타! 라는 정신 하나로 100번 이상의 쿼리를 날린 끝에 API로 쓸만한 출력을 얻게 되었습니다.

쿼리가 나왔으니 자바코드와 매핑시켜주기 위해서 Opensearch-Java-Rest-High-Level- Client를 이용해 DTO 매핑을 시켜보자 라는 생각이였습니다.

하지만 시작부터 난관을 만나게 됩니다.

😠 이슈 1 - OpenSearch 서버와 연결자체가 불가능하다!

OpenSearch와 연결하려면 https 인증을 통해 연결해야 합니다.
이 부분에서 자꾸 예외가 발생해서 OpenSearch 서버에 만든 쿼리를 보내 볼수 없는 상황이였습니다.

이런 저런 내용을 찾다보니 stackoverflow 형님들이 또 길잃은 중생을 구해주셨습니다.

SSL 우회를 위한 설정을 통해 접속하라는 것이였습니다.

@Configuration
@RequiredArgsConstructor
public class OpenSearchConfig {



    /*
    *  RestHighLevelClient Bean 등록
    *  -> SSL 우회를 위한 설정 존재
    *  -> OpenSearch 관련 설정 값은 Config Service 에서 가져옴(PORT : 8888)
    * */
    @Bean
    public RestHighLevelClient restHighLevelClient(final Environment env) throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
        final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
        credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(env.getProperty("opensearch.username"), env.getProperty("opensearch.password")));

        // SSL 우회
        final SSLContextBuilder sslBuilder = SSLContexts.custom()
                .loadTrustMaterial(null, (x509Certificates, s) -> true);
        final SSLContext sslContext = sslBuilder.build();


        return new RestHighLevelClient(
                RestClient.builder(new HttpHost(env.getProperty("opensearch.host"), Integer.parseInt(env.getProperty("opensearch.port")), env.getProperty("opensearch.protocol")))
                        .setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder
                                .setSSLContext(sslContext)
                                .setDefaultCredentialsProvider(credentialsProvider)
                                .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE))
                        .setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder.setConnectionRequestTimeout(5000)
                                .setSocketTimeout(120000)));
    }
}

그렇게 SSL 인증우회하는 코드를 작성하여 서버에 접근할 수 있게 되었습니다.

🤦 이슈 2 - JSON의 크기가 너무 방대해 적절한 편집이 필요하다.

{
  "took": 5,
  "timed_out": false,
  "_shards": {
    "total": 7,
    "successful": 7,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 36,
      "relation": "eq"
    },
    "max_score": null,
    "hits": []
  },
  "aggregations": {
    "group_aggs": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "host-16",
          "doc_count": 36,
          "terms_aggs": {
            "buckets": [
              {
                "key_as_string": "2023-10-17T21:30:00.000Z",
                "key": 1697578200000,
                "doc_count": 5,
                "vm_hypervisor_status_memory_utillization": {
                  "value": 59.85908889770508
                },
                "vm_hypervisor_status_cpu_utillization": {
                  "value": 23.969999313354492
                }
              },
              {
                "key_as_string": "2023-10-17T22:00:00.000Z",
                "key": 1697580000000,
                "doc_count": 6,
                "vm_hypervisor_status_memory_utillization": {
                  "value": 59.85908889770508
                },
                "vm_hypervisor_status_cpu_utillization": {
                  "value": 23.215333938598633
                }
              },
              {
                "key_as_string": "2023-10-17T22:30:00.000Z",
                "key": 1697581800000,
                "doc_count": 6,
                "vm_hypervisor_status_memory_utillization": {
                  "value": 59.86366653442383
                },
                "vm_hypervisor_status_cpu_utillization": {
                  "value": 23.125333786010742
                }
              },
              {
                "key_as_string": "2023-10-17T23:00:00.000Z",
                "key": 1697583600000,
                "doc_count": 6,
                "vm_hypervisor_status_memory_utillization": {
                  "value": 59.86366653442383
                },
                "vm_hypervisor_status_cpu_utillization": {
                  "value": 23.198667526245117
                }
              },
              {
                "key_as_string": "2023-10-17T23:30:00.000Z",
                "key": 1697585400000,
                "doc_count": 6,
                "vm_hypervisor_status_memory_utillization": {
                  "value": 59.863670349121094
                },
                "vm_hypervisor_status_cpu_utillization": {
                  "value": 23.374666213989258
                }
              },
              {
                "key_as_string": "2023-10-18T00:00:00.000Z",
                "key": 1697587200000,
                "doc_count": 6,
                "vm_hypervisor_status_memory_utillization": {
                  "value": 59.86366653442383
                },
                "vm_hypervisor_status_cpu_utillization": {
                  "value": 23.20199966430664
                }
              },
              {
                "key_as_string": "2023-10-18T00:30:00.000Z",
                "key": 1697589000000,
                "doc_count": 1,
                "vm_hypervisor_status_memory_utillization": {
                  "value": 59.86366653442383
                },
                "vm_hypervisor_status_cpu_utillization": {
                  "value": 23.94066619873047
                }
              }
            ]
          }
        }
      ]
    }
  }
}

작성한 쿼리를 Opensearch에 전달하면 이렇게 방대한 JSON이 반환되어 이걸 편집할 방법이 있어야 했습니다.

궁여지책 - 전부 DTO로 바인딩하고 필요한 부분만 뽑아내기

@Getter
@ToString
public class ResponseDto {

    private int took;
    private boolean timed_out;
    private Shards shards;
    private Hits hits;
    private Aggregations aggregations;


    @Getter
    public static class Shards {
        private int total;
        private int successful;
        private int skipped;
        private int failed;
    }

    @Getter
    public static class Hits {
        private Total total;
        private Object max_score;
        private List<Object> hits;
    }

    @Getter
    public static class Total {
        private int value;
        @JsonAlias("relation")
        private String relation;
    }

    @Getter
    public static class Aggregations {
        @JsonAlias("sterms#group_aggs")
        private MainAggregation mainAggregation;
    }

    @Getter
    public static class MainAggregation {
        @JsonAlias("doc_count_error_upper_bound")
        private int docCountErrorUpperBound;
        @JsonAlias("sum_other_doc_count")
        private int sumOtherDocCount;

        @JsonAlias("buckets")
        private List<Bucket> dataList;
    }

    @Getter
    public static class Bucket {
        @JsonAlias("key")
        private String hostId;

        @JsonAlias("doc_count")
        @JsonIgnore
        private int docCount;

        @JsonAlias("date_histogram#date_histogram")
        private DateHistogram resultData;
    }

    @Getter
    public static class DateHistogram {
        private List<DateBucket> buckets;

        public List<DateBucket> getBuckets() {
            return buckets;
        }
    }

    @Getter
    public static class DateBucket {
        @JsonAlias("key_as_string")
        private String timestamp;

        @JsonIgnore
        private long key;

        @JsonAlias("doc_count")
        @JsonIgnore
        private int docCount;

        @JsonAlias("min#vm_hypervisor_status_memory_utillization")
        @JsonInclude(JsonInclude.Include.NON_NULL)
        private Value minMemoryUsage;

        @JsonAlias("min#vm_hypervisor_status_cpu_utillization")
        @JsonInclude(JsonInclude.Include.NON_NULL)
        private Value minCpuUsage;

        @JsonAlias("max#vm_hypervisor_status_memory_utillization")
        @JsonInclude(JsonInclude.Include.NON_NULL)
        private Value maxMemoryUsage;

        @JsonAlias("max#vm_hypervisor_status_cpu_utillization")
        @JsonInclude(JsonInclude.Include.NON_NULL)
        private Value maxCpuUsage;
    }

    @Getter
    public static class Value {
        private double value;
    }

}

모든 필드를 바인딩 받으니, 필요없는 부분까지도 전부 자바 객체로 받아야 했습니다.
결코 좋은 방법이 아니라는 생각이 들어서 다른 방법을 찾아보았습니다.

해결방법 - 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를 이용해 추출해서, API 응답으로 조합하여 내보내고 있습니다.

package com.example.demo.domain.dto.response;

import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;

import java.util.List;
import java.util.Objects;

@Getter
@AllArgsConstructor
public class UsageApiResponse {

   private final String hostId;
   private List<UsageResponse> result;



   @Getter
   @ToString
   public static class Bucket {
      @JsonAlias("key_as_string")
      private String timestamp;

      @JsonAlias("min#vm_hypervisor_status_memory_utillization")
      private Value minMemoryUsage;

      @JsonAlias("min#vm_hypervisor_status_cpu_utillization")
      private Value minCpuUsage;

      @JsonAlias("max#vm_hypervisor_status_memory_utillization")
      private Value maxMemoryUsage;

      @JsonAlias("max#vm_hypervisor_status_cpu_utillization")
      private Value maxCpuUsage;
   }

   @Getter
   @ToString
   public static class UsageResponse {

      private final String timestamp;

      @JsonInclude(JsonInclude.Include.NON_NULL)
      private final Double minMemoryUsage;
      @JsonInclude(JsonInclude.Include.NON_NULL)
      private final Double minCpuUsage;
      @JsonInclude(JsonInclude.Include.NON_NULL)
      private final Double maxMemoryUsage;
      @JsonInclude(JsonInclude.Include.NON_NULL)
      private final Double maxCpuUsage;

      public UsageResponse(Bucket bucket) {
         this.timestamp = bucket.getTimestamp();
         this.minMemoryUsage = Objects.nonNull(bucket.getMinMemoryUsage()) ? bucket.getMinMemoryUsage().getValue() : null;
         this.minCpuUsage = Objects.nonNull(bucket.getMinCpuUsage()) ? bucket.getMinCpuUsage().getValue() : null;
         this.maxMemoryUsage = Objects.nonNull(bucket.getMaxMemoryUsage()) ? bucket.getMaxMemoryUsage().getValue() : null;
         this.maxCpuUsage = Objects.nonNull(bucket.getMaxCpuUsage()) ? bucket.getMaxCpuUsage().getValue():  null;
      }
   }

   @Getter
   static class Value {
      private Double value;
   }
}

Response 객체 역시 상당히 경량화 된 것을 볼 수 있습니다.
UsageResponse를 따로 분리한 이유는, Value 클래스는 단순히 값만을 표현하기 위해 존재하는데 이것까지 직렬화 시키는 비용을 줄이고 싶다는 고민이 있었습니다.

따라서 OpenSearch에서 가져올때의 모양에서 인덴트를 하나 줄인 모양을 가지도록 Stream으로 반복 처리하여 지금의 응답형태를 가지게 되었습니다.

😳 동적 쿼리 해결

현재 만든 쿼리는 특정 Host가 현재부터 3시간 전까지 30분 단위CPU & RAM 사용량 최대값을 뽑는 쿼리입니다.

여기서 어떤 것들을 파라미터화 시켜서 동적으로 보여줄 수 있을지 고민했습니다.
그렇게 API 설계를 아래와 같이 했습니다.

/api/usage/{hostId}?type={type}&interval={interval}&from={from}&option={option}

hostId는 PathVariable로 전달 받고, 나머지 파라미터를 만들어주었습니다.

PathVariable

  • hostId
    • 조회할 host의 id
    • 필수 값이므로, 요청시 빠져있으면 404 발생

Params

  • type

    • max, min 두 종류의 값만 가능
    • max일 경우 최대 값, min일 경우 최소 값
    • default value
      • max
  • interval

    • from ~ now 까지 몇 분 단위의 데이터 히스토그램을 조회할 지 결정하는 파라미터
    • 1 ~ 60 까지의 정수 값만 가능
    • default value
      • 30
  • from

    • 현재 시간부터 몇 시간 전의 데이터를 조회할 건지 결정하는 파라미터
    • 1~24 까지의 정수 값만 가능
    • default value
      • 3
  • option

    • cpu, ram, cpu+ram 조회 대상을 결정
    • cpu & ram & all 만 가능
    • default value
      • all

이런 파라미터들을 통해 동적으로 쿼리의 결과가 변경될 수 있도록 해주었습니다.

@Getter
@ToString
@AllArgsConstructor
public class UsageSearchCondition {

    private String hostId;
    private String type;
    private int interval;
    private int from;
    private String option;


    public static class UsageConditionBuilder {
        private String hostId;
        private String type;
        private int interval;
        private int from;
        private String option;

        public UsageConditionBuilder hostId(String hostId) {
            if (!StringUtils.hasText(hostId)) throw new CustomException(ILLEGAL_HOSTNAME_BAD_REQUEST);
            this.hostId = hostId;
            return this;
        }

        public UsageConditionBuilder type(String type) {
            if (!StringUtils.hasText(type) || !(type.equals("max") || type.equals("min"))) {
                throw new CustomException(ILLEGAL_INPUT_TYPE_USAGE_BAD_REQUEST);
            }
            this.type = type;
            return this;
        }

        public UsageConditionBuilder interval(String interval) {
            int requestInterval = Integer.parseInt(interval);
            if (requestInterval > 60 || requestInterval < 1) throw new CustomException(ILLEGAL_RANGE_USAGE_BAD_REQUEST);
            this.interval = requestInterval;
            return this;
        }

        public UsageConditionBuilder from(String from) {
            int requestFromTime = Integer.parseInt(from);
            if (requestFromTime > 24 || requestFromTime < 1) throw new CustomException(ILLEGAL_RANGE_USAGE_BAD_REQUEST);
            this.from = requestFromTime;
            return this;
        }

        public UsageConditionBuilder option(String option) {
            if (!StringUtils.hasText(option) || !(option.equals("ram") || option.equals("cpu") || option.equals("all"))) {
                throw new CustomException(ILLEGAL_OPTION_USAGE_BAD_REQUEST);
            }
            this.option = option;
            return this;
        }

        public UsageSearchCondition build() {
            return new UsageSearchCondition(hostId, type, interval, from, option);
        }
    }

Builder 패턴을 구현하여 잘못된 값이 들어올 경우 예외를 발생시키도록 개발해두었습니다.
예외의 종류는 Enum을 이용해서 미리 정의해 두었습니다.

이렇게 예외가 발생하면 중앙에서 처리해주거나 try - catch 블럭을 이용해야 하는데, Webflux의 함수형 라우팅 방식을 이용하면 @ControllerAdvice 사용이 불가능합니다.

따라서 직접 예외를 컨트롤 하는 클래스를 만들어주어야 합니다.

CustomException & ErrorCode 클래스 작성

@Getter
@AllArgsConstructor
public class CustomException extends RuntimeException {

    private final ErrorCode errorCode;
}

@Getter
@AllArgsConstructor
public enum ErrorCode {


    /* 400 BAD_REQUEST */
    ILLEGAL_RANGE_USAGE_BAD_REQUEST(BAD_REQUEST, "조회 범위가 잘못 되었습니다. 시간은 [1-24], 분은 [1-60] 범위 값을 입력해주세요"),
    ILLEGAL_HOSTNAME_BAD_REQUEST(BAD_REQUEST, "Host ID는 필수 입력 값 입니다."),
    ILLEGAL_INPUT_TYPE_USAGE_BAD_REQUEST(BAD_REQUEST, "조회 타입은 비어있거나, [min, max] 가 아닌 값이 들어오지 않아야 합니다."),
    ILLEGAL_OPTION_USAGE_BAD_REQUEST(BAD_REQUEST, "조회 옵션은 비어있거나, [all, ram, cpu] 가 아닌 값이 들어오지 않아야 합니다."),
    ILLEGAL_OPTION_TOPN_BAD_REQUEST(BAD_REQUEST, "조회 옵션은 비어있거나, [disk, memory, cpu] 이 아닌 값이 들어오지 않아야 합니다."),
    ILLEGAL_SORT_TOPN_BAD_REQUEST(BAD_REQUEST, "정렬 기준은 비어있거나, [asc, desc] 가 아닌 값이 들어오지 않아야 합니다."),
    ILLEGAL_RANGE_TOPN_BAD_REQUEST(BAD_REQUEST, "데이터 리스트의 크기는 [1-50] 범위 값을 입력해주세요"),
    ILLEGAL_OBJECTID_AGGREGATE_BAD_REQUEST(BAD_REQUEST, "ObjectID는 필수 입력 값입니다."),

    /* 500 SERVER ERROR */
    SEARCH_SERVER_ERROR(INTERNAL_SERVER_ERROR, "조회 중 문제가 발생했습니다. 다시 시도해주시기 바랍니다.");

    private final HttpStatus httpStatus;
    private final String message;


}

CustomExceptionErrorCode 클래스를 작성해줍니다.
ErorrCode의 각 케이스 별 이름은 서비스에서 예외를 던질때 사용되므로 명확한 목적을 알 수 있도록 작성해주는 것이 좋습니다.

GlobalExceptionHandler & GlobalErrorAttributes 클래스 작성

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class GlobalExceptionHandler extends AbstractErrorWebExceptionHandler {



    public GlobalExceptionHandler(ErrorAttributes errorAttributes,
                                  ApplicationContext applicationContext, ServerCodecConfigurer serverCodecConfigurer) {
        super(errorAttributes, new WebProperties.Resources(), applicationContext);
        super.setMessageReaders(serverCodecConfigurer.getReaders());
        super.setMessageWriters(serverCodecConfigurer.getWriters());
    }

    @Override
    protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
        return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse);
    }

    /*
    *  컨트롤러 방식의 요청처리가 아니므로 @ControllerAdvice 사용 불가
    *  -> 예외 메시지 등을 담은 의미있는 데이터를 사용자에게 보여줄 수 있도록 에외는 중앙에서 처리한다.
    * */
    private Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
        Map<String, Object> errorMap = getErrorAttributes(request, ErrorAttributeOptions.defaults());

        return ServerResponse.status(Integer.parseInt(errorMap.get("status").toString()))
                .contentType(MediaType.APPLICATION_JSON)
                .body(BodyInserters.fromValue(errorMap));
    }
}

AbstractErrorWebExceptionHandler 클래스를 상속받은 커스텀 예외 처리 클래스를 작성해줍니다.

@Component
public class GlobalErrorAttributes extends DefaultErrorAttributes {


    /*
    *  Custom 예외를 처리할 때 사용자에게 보여줄 메세지를 정의
    *  -> 기존 예외 메시지는 사용자에게 유용한 정보를 주기 어려워, CustomAttributes 정의
    * */
    @Override
    public Map<String, Object> getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) {

        Map<String, Object> errorMap = super.getErrorAttributes(request, options);

        Throwable throwable = getError(request);

        if (throwable instanceof CustomException) {
            CustomException ex = (CustomException) getError(request);
            return Map.of("status", ex.getErrorCode().getHttpStatus().value(),
                         "message", ex.getErrorCode().getMessage(),
                        "timestamp", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss")),
                        "requestPath", request.requestPath().toString()
                    );
        }

        return errorMap;
    }
}

GlobalExceptionHandler 클래스는 예외를 잡아서 처리하는 역할을 하고, GlobalErrorAttributes 클래스가 사용자에게 보여줄 실질적인 메시지를 만드는 역할을 합니다.

스프링은 예외 발생시 기본적으로 보여주는 메시지 포멧이 있습니다.
CustomException과 같이 사용자 정의 예외는 그 정보를 그대로 쓰지 않고 비즈니스적으로 의미있는 메시지를 주고자 정의하는 경우가 많습니다.

따라서 예외의 인스턴스가 CustomException일 경우에만 잡아서 사용자에게 보여줄 메시지를 직접 생성해 반환해주게 됩니다.

{
    "status": 400,
    "requestPath": "/api/vm/aggregate/%20",
    "timestamp": "2023.10.20 14:01:08",
    "message": "ObjectID는 필수 입력 값입니다."
}

이런식으로 사용자에게 의미있는 메시지가 표출되게 됩니다.

🥳 다음으로..

오늘은 OpenSearch를 이용해 데이터를 뽑아서 DTO로 바인딩하고 필요없는 데이터를 제외한 응답 포멧을 가질 수 있도록 최적화하는 것 까지 다뤄보았습니다.

다음 포스팅에서는, 왜 MSA 구성을 생각하게 되었는지와 그 구축 과정에 대해서 포스팅하겠습니다.

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

🙇

다음 게시글로 이동 -> 인턴의 당돌한 고래사육(a.k.a OJT 프로젝트에 MSA를 태워?) - 2

참고한 레퍼런스

2개의 댓글

comment-user-thumbnail
2023년 10월 30일

따끈따끈~

1개의 답글