블로그
를 보다보니 글을 안쓴지도 두 달이나 된 것 같습니다.
꾸준히 공부도 하고 깃에 소스코드도 업로드하고 있지만 너무 블로그에 글을 안쓴 것 같아 마음먹고 이번 인턴 OJT 프로젝트
에 대한 내용을 남겨볼까 합니다.
9월 중순에 입사해서 팀 배정
을 10월에 받고, 다른 팀과 함께 백엔드 OJT
를 시작하게 되었습니다.
현재 이 글을 쓴 시점에는 발표까지 끝났습니다.
이 포스팅은 여러 개
의 시리즈로 이루어질 예정이며, 처음 사용하는 기술
에 주눅들어 작은 아키텍처를 이용해 과제만 완수하려던 마음이 어느새 욕망
이 늘어나 부가 기능
까지 만든 한 인턴의 패기
를 그린 이야기입니다.
백엔드 OJT 과제는 2주동안 API 서버를 만드는 것이였는데, 요구사항은 아래와 같았습니다.
Spring Webflux
& Spring Cloud
를 사용할 것OpenSearch
에서 집계할 수 있는 쿼리를 이용해 3가지 이상의 API를 만들 것stream
을 이용해 처리할 것VM
이 지급되지 않으니 개인 노트북
에 알아서 할 것여기서 이미 저는 난항을 겪게 됩니다.
Spring MVC
로만 개발을 진행해왔고, OpenSearch
에 대해서 들어본적도 없기 때문입니다.
먼저 아키텍처
를 수립하기 전에 해야할 것들을 정해보았습니다.
제가 일정관리를 위해 우선순위로 둔 작업들은 아래와 같습니다.
OpenSearch
에 쿼리를 해서 데이터가 뽑아지는지 확인하기Spring Webflux
로 간단한 API를 만들어보고 어떻게 사용해야하는지 알아보기이 문제가 가장 어려웠던 이유는, 새로운 기술을 이해할 시간도 없이 바로 적용해 개발해보는 것이 난생 처음 해보는 일이였기 때문입니다.
기존에 RDB만 사용하던 저로서는 OpenSearch
의 JSON 형식 쿼리가 이해되지 않았고 이걸 어떻게 써야할지 감이 잡히질 않았습니다.
개발자는 그래도 백문이불여일타!
라는 정신 하나로 100번 이상의 쿼리를 날린 끝에 API로 쓸만한 출력을 얻게 되었습니다.
쿼리가 나왔으니 자바코드
와 매핑시켜주기 위해서 Opensearch-Java-Rest-High-Level- Client
를 이용해 DTO 매핑
을 시켜보자 라는 생각이였습니다.
하지만 시작부터 난관을 만나게 됩니다.
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 인증
을 우회
하는 코드를 작성하여 서버에 접근할 수 있게 되었습니다.
{
"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
이 반환되어 이걸 편집할 방법이 있어야 했습니다.
@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;
}
}
모든 필드를 바인딩 받으니, 필요없는 부분까지도 전부 자바 객체로 받아야 했습니다.
결코 좋은 방법이 아니라는 생각이 들어서 다른 방법을 찾아보았습니다.
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로 전달 받고, 나머지 파라미터를 만들어주었습니다.
hostId
type
max
, min
두 종류의 값만 가능max
일 경우 최대 값, min
일 경우 최소 값default value
interval
from ~ now
까지 몇 분 단위의 데이터 히스토그램
을 조회할 지 결정하는 파라미터1 ~ 60
까지의 정수 값
만 가능default value
from
1~24
까지의 정수 값
만 가능default value
option
cpu
& ram
& all
만 가능default value
이런 파라미터들을 통해 동적으로 쿼리의 결과가 변경될 수 있도록 해주었습니다.
@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
사용이 불가능합니다.
따라서 직접 예외를 컨트롤 하는 클래스를 만들어주어야 합니다.
@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;
}
CustomException
과 ErrorCode
클래스를 작성해줍니다.
ErorrCode
의 각 케이스 별 이름은 서비스에서 예외를 던질때 사용되므로 명확한 목적을 알 수 있도록 작성해주는 것이 좋습니다.
@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
따끈따끈~