Spring REST 로깅

  • Spring Web RestTemplate을 이용한 HTTP 통신시 Interceptor를 활용하여 요청과 응답 로그를 남긴다.
  • 주의할 점은, ResponseEntity의 Body는 Stream 이므로 로깅 인터셉터에서 Body Stream을 읽어 소비가되면 실제 비즈니스 로직에서는 Body가 없어진다.
  • 이를 해결하기 위해 RestTemplate Bean 설정에서 requestFactory에 BufferingClientHttpRequestFactory를 세팅해줘야 한다.

설정

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    ...

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder
                // 로깅 인터셉터에서 Stream을 소비하므로 BufferingClientHttpRequestFactory 을 꼭 써야한다.
                .requestFactory(() -> new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory()))
                // 타임아웃 설정 (ms 단위)
                .setConnectTimeout(30000)
                .setReadTimeout(30000)
                // UTF-8 인코딩으로 메시지 컨버터 추가
                .additionalMessageConverters(new StringHttpMessageConverter(Charset.forName("UTF-8")))
                // 로깅 인터셉터 설정
                .additionalInterceptors(new RestTemplateLoggingRequestInterceptor())
                .build();
    }

    ...
}

RestTemplateLoggingInterceptor

/**
 * Spring RestTemplate 로깅 인터셉터
 *
 * @author Hoonmaro
 */
@Slf4j
public class RestTemplateLoggingRequestInterceptor implements ClientHttpRequestInterceptor {

    /**
     * <pre>
     * RestTemplate 로깅 Interceptor
     *
     * <pre>
     *
     * @param request HttpRequest
     * @param body Request Body
     * @param execution ClientHttpRequestExecution
     * @throws IOException
     */
    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {

        // request log
        URI uri = request.getURI();
        traceRequest(request, body);

        // execute
        ClientHttpResponse response = execution.execute(request, body);

        // response log
        traceResponse(response, uri);
        return response;
    }

    /**
     * <pre>
     * RestTemplate Request 로깅
     *
     * <pre>
     * @param request HttpRequest
     * @param body Request Body
     */
    private void traceRequest(HttpRequest request, byte[] body) {
        StringBuilder reqLog = new StringBuilder();
        reqLog.append("[REQUEST] ")
        .append("Uri : ").append(request.getURI())
        .append(", Method : ").append(request.getMethod())
        .append(", Request Body : ").append(new String(body, StandardCharsets.UTF_8));
        log.info(reqLog.toString());
    }

    /**
     * <pre>
     * RestTemplate Response 로깅
     *
     * <pre>
     * @param response ClientHttpResponse
     * @throws IOException
     */
    private void traceResponse(ClientHttpResponse response, URI uri) throws IOException {
        StringBuilder resLog = new StringBuilder();
        resLog.append("[RESPONSE] ")
        .append("Uri : ").append(uri)
        .append(", Status code : ").append(response.getStatusCode())
        .append(", Response Body : ").append(StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8));
        log.info(resLog.toString());
    }
}

ELK 설치

도커를 기반으로 ELK를 설치해보려고 한다. ELK를 각각 도커 이미지로 설치해도 되지만, Docker Compose로 구성할 것이다. docker-elk 를 사용하여 설치해보자

사전 준비

  • Docker 설치
  • Docker Compose 설치
  • docker-elk 레포지토리 클론

호스트

호스트 OS는 CentOS 7.2 버전을 사용한다. Docker Engine이 올라가는 호스트 OS에서 Docker와 Docker Compose를 설치해야한다.

Dcoker 설치

Docker는 CE(Community Edition)버전과 EE(Enterprise Edition)이 있다. 여기서는 CE 버전을 설치할 것이다. 설치 과정은 공식문서를 참고했으며 패키지 관리자 yum을 이용하여 설치를 진행해보자. Install Docker CE on Linux

# 1. 필요 패키지 설치
$ sudo yum install -y yum-utils device-mapper-persistent-data lvm2

# 2. CentOS용 docker-ce stable 버전 레포지토리 설정
$ sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo

# 3. DockerCE 설치
$ sudo yum install -y docker-ce

# 4. Docker 시작
$ sudo service docker start

Docker Compose 설치

Docker Compose는 아래와 같은 절차로 설치하면 되는데, 중간에 1.22.0은 버전명이다. 다른 버전을 설치하려면 버전 릴리즈에서 확인후 해당 버전으로 적어주면 된다.

# 1. Docker Compose 설치
$ sudo curl -L "https://github.com/docker/compose/releases/download/1.22.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

# 2. 퍼미션 수정
$ sudo chmod +x /usr/local/bin/docker-compose

# 3. 테스트
$ docker-compose --version

docker-elk repository clone

작업 디렉토리에서 docker-elk git 레포지토리를 clone 한다.

$ git clone https://github.com/deviantony/docker-elk#requirements

SELinux 설정

SELinux가 활성화된 상태라면 다음 설정을 해줘야 한다.

$ chcon -R system_u:object_r:admin_home_t:s0 docker-elk/

docker-elk 설정

docker-elk를 clone 받으면 docker-elk 폴더가 생긴다 폴더 구조는 다음와 같다

# docker-elk
.
+-- elasticsearch
|    +-- Dockerfile
|    +-- config
|    |    +-- elasticsearch.yml
+-- logstash
|    +-- Dockerfile
|    +-- config
|    |    +-- logstash.yml
|    +-- pipeline
|    |    +-- logstash.conf
+-- kibana
|    +-- Dockerfile
|    +-- config
|    |    +-- kibana.yml
+-- extenseions
|    +-- README.md
|    +-- curator...
|    +-- logspout ...
+-- docker-compose.yml
+-- README.md
+-- LICENSE
  • Dockerfile: Docker 상에서 동작하는 컨테이너 구성 정보를 저장한 파일이다.
  • docker-compose.yml: docker compose에 필요한 설정 파일이다.

도커에 관련해서는 따로 포스팅을 할 예정이며, 다른 블로그나 책을 통해 금방 익힐 수 있으니 참고하면 된다. 기존에 설정된 docker-compose.yml 그대로 사용해도 테스트하는데 문제는 없다.

docker-elk 실행

# docker-compose.yml이 있는 디렉토리에서 수행해야 한다
# build
$ docker-compose build

# 빌드를 통해 생성된 도커 이미지 확인
$ docker images

# up: 컨테이너 생성 및 구동, -d는 백그라운드로 실행 옵션
$ docker-compose up -d

# 컨테이너 목록 확인
$ docker-compose ps

# 컨테이너 로그 확인
$ docker-compose logs -f

# elasticsearch 인덱스 확인
$ curl -XGET localhost:9200/_cat/indices?v

컨테이너 목록 확인에 정상적으로 컨테이너 3개(elasticsearch, logstash, kibana)가 보이고, 로그에서도 문제가 없으면 정상적으로 작동된 것이다. 브라우저에서 localhost:5601로 접속하면 kibana 웹 UI 화면이 뜰 것이다. 아직은 elasticsearch에 데이터가 없기 때문에 볼 수 있는 데이터는 없다.

Logstash는 웹서버(Apache HTTP server, Nginx 등)의 access.log나 WAS의 로그, 애플리케이션 로그, DB 로그, JDBC, 웹소켓, 트위터 등 다양한 입력자원을 읽어들일 수 있고 elasticsearch에 데이터를 보내 kibana에서 시각화하여 확인할 수 있다.

자바스크립트 비동기

목차

  1. 들어가며
  2. 자바스크립트에서 비동기란?
  3. Promise

1. 들어가며

백엔드 개발자이지만 프론트엔드 개발자/퍼블리셔가 없어서 풀스택으로 업무를 진행하고 있다. 간단한 페이지 수정, CSS 수정도 하지만 주로 자바스크립트를 통해 프론트단 로직을 작성하는 일이 많다. 모놀리틱 애플리케이션이라 Spring + JSP 템플릿 구조에 jQuery를 사용 중이다.

최근에는 vue.js를 라이브러리 형태로 사용하며 jQuery로 하기엔 귀찮고 불편한 부분들을 vue로 대체하고 있다. 그 중 대부분은 서버에 API를 호출하고 JSON으로 결과를 받아오면, 리스트를 뿌리거나 동적으로 화면을 구현하는 일이다. 비동기로 API 호출 시 jQuery의 $.ajax를 주로 사용했는데, 콜백헬에 빠지는 문제를 해결하려다보니 promise에 대해서 알게되었다.

이번 글에서는 자바스크립트 콜백헬 문제와 이를 해결하기 위해 ES6 표준인 Promise에 대해 알아보려고 한다.

2. 자바스크립트에서 비동기란?

비동기(Asynchrony)라는 용어는 컴퓨터 프로그래밍에서 메인 프로그램의 플로우에 독립적인 이벤트의 발생과 그러한 이벤트들을 다루는 방법을 의미한다. 하나의 프로그램은 메인 프로세스 또는 쓰레드가 있으며 메인 함수로 부터 출발하여 로직의 수행, 함수의 호출 등의 코드 흐름대로 프로그램이 작동된다. 비동기는 새로운 쓰레드 또는 프로세스를 만들어 메인 함수의 흐름과는 병렬적으로 코드를 진행하는 것을 말한다.

하지만 자바스크립트는 단일 쓰레드 기반이고 모든 코드는 순차적으로 실행되므로 병렬적으로 수행할 수 없다. 그래서 자바스크립트에서는 비동기 논-블로킹 I/O 모델을 통해 비동기 프로그래밍을 수행한다. 자바스크립트 작업은 차단되지만 I/O 작업은 차단되지 않는다. I/O 작업은 병렬적으로 Ajax 또는 WebSocket 연결을 통해 데이터를 가져오는 등의 작업을 할 수 있는데, 자바스크립트 코드 실행과 병렬적으로 수행할 수 있다. 그러나 자바스크립트가 작업을 수행하는 것은 아니다.

그럼 누가 수행하는 것인가? 바로 자바스크립트가 동작하는 호스팅 환경이다. 우리가 익히 알고있는 웹브라우저 혹은 Node.js가 JS엔진이 올라가는 호스팅 환경이다. (최근에는 로봇, IoT 디바이스에도 JS엔진을 올려 자바스크립트가 사용되기도 한다) 여러 호스팅 환경의 공통으로 내장된 메커니즘인 이벤트 루프를 통해 비동기 프로그래밍이 가능해진다.

자바스크립트 엔진과 메모리, 콜스택, 이벤트루프 등에 대해 자세한 내용은 참고: 자바스크립트는 어떻게 작동하는가? (시리즈연재)을 참조하길 바란다.

웹 브라우저로 예로 들면,

  1. JS엔진 내부의 콜스택 영역이 있고, 스택 순서대로 프로그램이 동작
  2. AJAX 비동기 코드에서 WebAPI의 AJAX 함수를 호출
    • WebAPI는 웹 브라우저의 내장 API이며 $.ajax 처럼 AJAX를 호출하면 이 API를 사용하는 것이다.
    • 자바스크립트가 수행하는게 아닌, 호스팅 환경이 수행한다는 말이 이 때문이다.
  3. WebAPI의 AJAX 작업이 끝나면 결과(콜백)를 이벤트 루프의 콜백큐(이벤트큐)에 넣는다.
  4. 콜스택이 비어졌을 때, 이벤트 루프는 작업 결과를 콜백큐에서 콜스택으로 밀어넣는다.
  5. 콜스택에서 비동기 작업의 결과가 수행된다. (콜백함수)

콜백헬(Callback Hell)

자바스크립트 비동기 작업을 수행하면, 그 결과를 콜백 함수를 통해 받고 결과에 대한 후처리를 한다.

위의 예제를 보면 처음에 getPosts를 호출 후에 done 부분에서 계속 다음 ajax 요청을 호출하는 것을 볼 수 있다. done은 jQuery AJAX의 success 콜백 옵션이고 deferred.done()을 참조한다. Deferred Object를 참고하면, jQuery 1.5에 나온 여러 콜백들을 콜백 큐에 등록할 수 있는 유틸리티 오브젝트이다. 자세한 내용은 jQuery API 공식문서를 참고하길 바란다.

예제는 콜백헬의 진가를 제대로 보여준 코드는 아니다. 더 콜백헬스러운 코드라면, done안에 $.ajax.done($.ajax.done($.ajax.done(...) 이러한 구조가 진짜 콜백헬이다. 이렇게 콜백헬임에도 불구하고 사용하는 이유는 비동기이므로 앞의 비동기가 완료된 후에 수행해야하는 로직이라면 비동기의 콜백으로 해야된다. 그렇지 않고 단순히 다음 라인에 코딩을 하면 비동기 결과를 받지도 않았는데 JS엔진은 다음 라인의 코드를 수행하므로 아무 의미가 없는 로직이 수행되고 오류가 발생할 수도 있다.

3. Promise

비동기 메소드를 연결할 때, 콜백헬은 가독성이 떨어지고 각 중첩된 콜백마다 에러를 체크해줘야 하는 단점이 있다. 또 try-catch 블록 내에서 비동기 함수를 사용할 때 콜백 함수 내의 예러를 캐치하지 못한다는 단점이 있다. 예외가 호출자 방향으로 전파되는데 비동기 함수 호출자는 콜스택에서 이미 사라졌기 때문이다.

이를 보완하기 위해 Promise를 사용한다

Promise는 비동기식 작업의 최종 완료 또는 실패를 나타내는 객체이다.

let promise = new Promise(function(resolve, reject) {
   setTimeout(resolve, 100, 'foo');
});

console.log(promise);
// [object Promise]

문법

new Promise(function(resolve, reject) { ... });

생성자 함수를 통해 인스턴스화한다. 생성자 함수는 비동기 작업을 수행할 성공시 resolve, 실패시 reject 콜백 함수를 인자로 받는다.


Promise 상태

Promise는 비동기 처리에 대한 세 가지 상태를 갖는다.


상태설명
pending(대기) fulfiled 또는 rejected가 아닌 상태
fulfilled(이행) promise.then(f)에서 f가 콜되자마자의 상태
rejected(실패) promise.then(undefined, r)에서 r이 콜되자마자의 상태


추가적으로 settled, resolve, unresolved라는 표현을 쓰기도 하는데, 각각의 설명은 다음과 같다.

상태설명
settled pending이 아닐 때를 말하며, Promise의 상태는 아닌 언어적 편의를 위한 표현이다.
resolved 기본적으로 settled되어 이행(fulfilled)이든 실패(rejected)든 처리가 해소된 상태를 말하며,
연결된
Promise가 있을 경우에도 사용되는 용어
unresolved 기본적으로 resolved가 아닌 상태를 말하며, pending인 상황에 사용되는 용어


States and Fates를 참고하면 정확히 알 수 있다. 앞의 세 가지 상태는 Promise 표준의 2.1 Promise States에 명시된 객체의 상태를 나타낸다. 뒤의 세 가지 표현 중 settled는 pending이 아닐 때를 부르기 편하기 위해 사용되는 용어이다.

resolved와 unresolved는 함수가 해결되었냐 안되었냐의 함수의 관점에서 사용되는 용어인 것 같다. (개인적인 추측)


  • Promise 기본 사용법 및 에러 처리

마지막 코드를 보면 then 메소드에서 일부러 에러를 발생시켰고, console에서는 catch에서만 에러가 잡힌다. then 메소드의 두번째 콜백 함수는 비동기 처리 중 에러가 발생하여 reject 함수가 호출된 상태만을 캐치한다. 그러나 catch 메소드를 쓰면 then 메소드 내부에서 발생한 에러도 캐치하므로, 에러 캐치는 catch 메소드를 쓰는게 좋다.


  • Promise 연결(chain)

주의할 점은 then() 메소드 안에 {} 블록에 코딩되어 있다면, return을 해줘야 다음 then()에서 Promise를 받을 수 있다.



참고


RestTemplate

RestTemplate은 HTTP 클라이언트 라이브러리를 통해 높은 수준의 API를 제공한다. REST 엔드포인트를 코드 한줄로 호출하기 쉽게 해준다. 오버로드된 메소드들은 다음과 같다.

 

RestTemplate methods

Method group Description
getForObject GET method를 통해 representation을 조회
getForEntity GET method를 통해 ResponseEntity를 조회 (status, headers, body)
headForHeaders HEAD method를 통해 리소스의 모든 헤더들을 조회
postForLocation POST method를 통해 새로운 리소스를 생성하고 응답에 Location 헤더를 리턴
postForObject POST method를 통해 새로운 리소스를 생성하고 응답에 representation을 리턴
postForEntity POST method를 통해 새로운 리소스를 생성하고 응답에 representation을 리턴
put PUT method를 통해 리소스를 새로 생성하거나 수정
patchForObject PATCH method를 통해 리소스를 수정하고 응답의 representation을 리턴.
주의할 것은 JDK HttpURLConnection은 PATCH 를 지원하지 않고, Apache HttpComponents와 다른 것들은 지원함
delete DELETE method를 통해 특정 URI의 리소스를 삭제
optionsForAllow ALLOW method를 통해 리소스가 허용하는 HTTP method들을 조회
exchange 위의 method들 보다 더 일반화되고, 덜 선택적인 버전으로 필요한 경우 추가적인 유연성을 제공함
HTTP 메서드, URL, 헤더 및 본문을 포함한 RequestEntity를 입력받아 ResponseEntity를 리턴
이러한 방법을 사용하면 Class 대신 ParameterizedTypeReference를 사용하여 제네릭 타입 응답을 지정할 수 있음
execute 요청을 수행하는 가장 일반화된 방법으로, 콜백 인터페이스를 통한 요청 준비 및 응답 추출에 대한 완벽한 제어가 가능

 

초기화

기본 생성자는 java.net.HttpURLConnection 라이브러리를 사용한다. ClientHttpRequestFactory를 사용하여 다른 HTTP 라이브러리로 바꿀 수 있다. 스프링은 Apache HttpComponents, Netty, OkHttp를 지원한다.

  • 예)
RestTemplate template = new RestTemplate(new HttpComponentsClientHttpRequestFactory());

ClientHttpRequestFactory는 기본 HTTP 클라이언트 라이브러리(예: 자격 증명, 연결 풀링 등)에 대한 구성 옵션을 제공한다. 주의할 점은 java.net 패키지의 HTTP 구현체를 사용할 경우 401 상태를 가진 응답 객체에 접근할 때 익셉션이 발생할 수 있으므로, 이럴 경우 다른 HTTP 라이브러리 사용해야 한다.

 

URIs

대부분의 RestTemplate 메소드는 String vararg 또는 Map<String, String>을 통해 URI 템블릿과 URI 템플릿 변수를 허용한다.

/* String vararg */
String result = restTemplate.getForObject(
        "http://example.com/hotels/{hotel}/bookings/{booking}", String.class, "42", "21");

/* Map<String, String> */
Map<String, String> vars = Collections.singletonMap("hotel", "42");

String result = restTemplate.getForObject(
        "http://example.com/hotels/{hotel}/rooms/{hotel}", String.class, vars);

URI 템플릿은 자동으로 인코딩된다.

restTemplate.getForObject("http://example.com/hotel list", String.class);

// 요청 url: "http://example.com/hotel%20list"

RestTemplateuriTemplateHandler 속성을 사용하여 URI 인코딩 방법을 지정할 수 있다 또는 java.net.URI를 사용하여 이를 사용하는 RestTemplate 메소드에 인자로 전달할 수 있다.

 

Headers

exhcnage() 메소드를 사용하여 요청 헤더를 지정할 수 있다.

String uriTemplate = "http://example.com/hotels/{hotel}";
URI uri = UriComponentsBuilder.fromUriString(uriTemplate).build(42);

RequestEntity<Void> requestEntity = RequestEntity.get(uri)
        .header(("MyRequestHeader", "MyValue")
        .build();

ResponseEntity<String> response = template.exchange(requestEntity, String.class);

String responseHeader = response.getHeaders().getFirst("MyResponseHeader");
String body = response.getBody();

ResponseEntity를 반환하는 RestTemplate 의 여러 메소드를 통해 응답 헤더를 얻을 수 있다.

 

Body

RestTemplate 메서드에 전달되고 반환되는 객체는 HttpMessageConverter의 도움으로 raw content로 변환된다.

POST 메서드에서 입력된 객체는 요청 body에 직렬화된다.

URI location = template.postForLocation("http://example.com/people", person);

요청의 Content-Type 헤더를 명시적으로 설정할 필요는 없다. 대부분의 경우 소스 객체 타입에 따라 호환되는 메시지 컨버터를 찾을 수 있고, 선택한 메시지 컨버터가 적절히 content-type을 설정한다. 필요한 경우 exchange 메서드를 사용하여 Content-Type 요청 헤더를 명시적으로 제공할 수 있으며, 이는 선택된 메시지 컨버터에 영향을 미칠 수 있다.

GET 메서드에서는 응답의 body가 출력 객체로 역직렬화된다.

Person person = restTemplate.getForObject("http://example.com/people/{id}", Person.class, 42);

요청의 Accept 헤더는 명시적으로 설정할 필요가 없다. 대부분의 경우 Accept 헤더를 예상 응답 유형에 따라 호환되는 메시지 컨버터를 찾을 수 있다. 이 컨버터는 Accept 헤더를 채우는데 도움이 된다. 필요한 경우 exchange 메서드를 사용하여 Accept헤더를 명시적으로 제공할 수 있다.

기본적으로 RestTemplate은 선택적인 변환 라이브러리가 있는지 확인하는데 도움이 되는 클래스패쓰 검사에 따라 모든 내장 메시지 컨버터들을 등록한다. 또, 명시적으로 메시지 컨버터를 설정할 수 있다.

 

Message Conversion

spring-web 모듈은 InputStreamOutputStream을 통해 HTTP 요청 및 응답의 body를 읽고 쓰는 역할을 맡은 HttpMessageConverter를 포함한다. HttpMessageConverter는 클라이언트 측(예, RestTemplate)과 서버 측 (예, Spring MVC REST controllers)에서 사용된다.

주요 미디어(MIME) 유형에 대한 구체적인 구현은 프레임워크에서 제공되며 기본적으로 클라이언트 측 RestTemplate 및 서버측 RequestMethodHandlerAdapter에 등록된다. (메시지 구성 참조)

HttpMessageConverter의 구현은 다음 섹션에 기술되어있다. 모든 컨버터들이 기본 미디어 타입을 사용하지만 supportedMediaTypes 빈 프로퍼티 설정으로 오버라이드 할 수 있다.

 

HttpMessageConverter 구현체 목록

MessageConverter Description
StringHttpMessageConverter HTTP 요청 및 응답으로부터 문자열(String)을 읽고 쓸 수 있는 구현체
기본적으로 모든 텍스트 미디어 타입들 (text/*)을 지원하며, text/plain Content-Type으로 작성한다.
FormHttpMessageConverter HTTP 요청 및 응답으로부터 form 데이터를 읽고 쓸 수 있는 구현체
기본적으로 application/x-www-form-urlencoded 미디어 타입을 지원한다.
Form 데이터는 MultiValueMap<String, String>읽고 작성된다.
ByteArrayHttpMessageConverter HTTP 요청 및 응답으로부터 바이트 배열을 읽고 쓸 수 있는 구현체
기본적으로 모든 미디어 타입 (*/*)을 지원하며, application/octet-stream Content-Type으로 작성한다.
이 컨버터는 supportedMediaTypes 속성을 설정하고 getContentType(byte[])를 오버라이드하여 재정의할 수 있다
MarshallingHttpMessageConverter org.springframework.oxm 패키지의 스프링의 마샬러(Marshaller)와 언마샬러(Unmarshaller) 추상화를 사용하여 XML을 읽고 쓸 수 있는 구현체
이 컨버터는 사용하기 전에 마샬러와 언마샬러를 필요로 한다.
이들은 생성자 또는 빈 프로퍼티를 통해 주입될 수 있다.
기본적으로 text/xmlapplication/xml을 지원한다
MappingJackson2HttpMessageConverter Jackson 라이브러리의 ObjectMapper를 사용하여 JSON을 읽고 쓸 수 있는 구현체
JSON 매핑은 필요에 따라 Jackson이 제공하는 어노테이션을 통해 커스터마이징 될 수 있다
추가 제어가 필요할 경우 특히, 특정 유형에 대해 커스텀 JSON 직렬화/역직렬화를 제공해야 하는 경우 ObjectMapper 속성을 통해 커스텀 ObjectMapper를 주입할 수 있다.
기본적으로 application/json을 지원한다
MappingJackson2XmlHttpMessageConverter Jackson XML의 XmlMapper를 사용하여 XML을 읽고 쓸 수 있는 구현체
Jackson이 제공하는 어노테이션을 또는 JAXB를 사용하여 필요에 따라 XML 매핑은 커스터마이징 될 수 있다
추가 제어가 필요한 경우, 특히 특정 유형에 대해 커스텀 XML 직렬화/역직렬화를 제공해야하는 경우 ObjectMapper 속성을 통해 커스텀 XmlMapper를 주입할 수 있다
기본적으로 application/xml을 지원한다
SourceHttpMessageConverter HTTP 요청 및 응답으로부터 javax.xml.transform.Source를 읽고 쓸 수 있는 구현체
DOMSource, SAXSource, StreamSource만 지원된다.
기본적으로 text/xmlapplication/xml를 지원한다
BufferedImageHttpMessageConverter HTTP 요청 및 응답으로부터 java.awt.image.BufferedImage를 읽고 쓸 수 있는 구현체
Java I/O API에서 지원하는 미디어 타입을 읽고 쓴다

 

Jackson JSON Views

객체 속성의 일부만 직렬화하도록 Jackson JSON View를 지정할 수 있다.

  • 예)
MappingJacksonValue value = new MappingJacksonValue(new User("eric", "7!jd#h23"));
value.setSerializationView(User.WithoutPasswordView.class);

RequestEntity<MappingJacksonValue> requestEntity =
    RequestEntity.post(new URI("http://example.com/user")).body(value);

ResponseEntity<String> response = template.exchange(requestEntity, String.class);

 

Multipart

multipart 데이터를 전송하기 위해 multipart 내용을 대표하는 Objects 또는 내용과 헤더를 대표하는 HttpEntity를 값으로 가지는 MultiValueMap<String, ?>를 제공할 필요가 있다. MultipartBodyBuilder는 multipart 요청을 만들어주는 편리한 API를 제공한다.

MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("fieldPart", "fieldValue");
builder.part("filePart", new FileSystemResource("...logo.png"));
builder.part("jsonPart", new Person("Jason"));

MultiValueMap<String, HttpEntity<?>> parts = builder.build();

대부분의 경우 각 part에 Content-Type를 지정할 필요가 없다. Content-Type은 직렬화 하도록 선택한 HttpMessageConverter 또는 파일 확장자에 기반한 Resource의 경우에 자동으로 결정된다. 필요한 경우 오버로드된 builder 메소드를 통해 각 part에 사용할 MediaType을 명시적으로 제공할 수 있다.

MultiValueMap이 준비되면, RestTemplate에 인자를 넘겨줄 수 있다.

MultipartBodyBuilder builder = ...;
template.postForObject("http://example.com/upload", builder.build(), Void.class);

MultiValueMap이 일반적인 form 데이터를 나타낼수 있는(예: application/x-www-form-urlencoded) String이 아닌 값을 가질 경우, Content-Typemultipart/form-data로 지정할 필요 없다. 이는 ㅇ항상 HttpEntity 래퍼를 보장하는 MultipartBodyBuilder를 사용하는 경우에 해당된다.

 

참고

 

스프링 프레임워크 정적 자원 버전 관리




HTTP 캐싱

  • 네트워크를 통해 자원을 가져오는 작업은 느리고 비용이 발생한다. 특히, 크기가 크면 그만큼 브라우저가 화면을 표시하는데 오래 걸려 사용자 경험이 나빠진다.
  • 가져온 자원을 캐시했다가 재활용하는 것은 성능 최적화에 중요하다.
  • 모든 브라우저는 HTTP 캐시 구현 기능이 포함되어 있다.
  • 개발자는 각 서버 응답이 브라우저에 응답을 캐시할 수 있는 시점과 그 기간을 지시하게 위한 HTTP Header를 올바르게 제공해야한다.

정적 자원 버전 관리

  • 브라우저는 한 번 방문한 사이트의 정적 자원은 캐시가 되어 만료 될 때까지 사용한다.
  • 이때문에 정적 자원이 수정된 후에 사이트를 방문한 사용자는 최신 정적 자원을 사용하는 반면, 이전에 방문했던 사용자는 과거 정적 자원을 사용하게 되어 이슈가 발생할 수 있다.
  • 예) 이미지가 다르게 보임, 수정된 자바스크립트가 실행이 안되거나 동작이 달라짐
  • 따라서 정적 자원의 버전관리를 통해 수정된 정적 자원일 경우 새로운 버전이 제공되어 사용자들이 서버에서 최신 버전 정적 자원을 받을 수 있도록 해야한다.

정적 자원 캐시 만료되는 경우

  • 사용자 직접 삭제
  • max-age 또는 expires로 정해진 기한이 지난 경우
  • 정적 자원의 URL을 변경한 경우

스프링 프레임워크에서 정적 자원 버전 관리

  • 스프링 프레임워크는 4.1 버전 이후부터 WebMvcConfigureraddResourceHandlers 설정 메서드에서 버전 관리 핸들러를 리졸버를 등록하여 관리할 수 있다.
  • ContentVersionStrategy 통해서 브라우저가 요청한 자원 각 파일의 내용에 따라 MD5 해쉬된 문자열이 요청한 자원 파일명 뒤에 버전처럼 붙는다.
  • 수정된 정적 자원의 경우 파일 내용이 바뀌므로 서버에서 응답한 요청한 자원 파일명에 붙은 MD5 해쉬 문자열이 사용자의 브라우저가 캐시해서 가지고 있던 것과 다르기 때문에 새로 내려받게 된다.

WebMvcConfig 설정

@Configuration
public class MvcConfig implements WebMvcConfigurer {

@Override
public void addResourceHandlers(final ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/resources/**", "/webjars/**")
                .addResourceLocations("/resources/", "classpath:/META-INF/resources/webjars/")
                .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS))
                .resourceChain(true)
                .addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"))
                .addResolver(new WebJarsResourceResolver());
	}

}

정적 자원 접근 처리 설정

  • 컨트롤러 어드바이스 설정을 ResourceUrlProvider를 모든 응답에 urls라는 이름으로 제공한다.
  • 템플릿에서 urls로 접근이 가능하다.
@ControllerAdvice
public class ResourceUrlAdvice {

    @Autowired
    private ResourceUrlProvider resourceUrlProvider;

    @ModelAttribute("urls")
    public ResourceUrlProvider urls() {
        return this.resourceUrlProvider;
    }
}

JSP에서 사용

<!-- CSS -->
<link rel="stylesheet" href="${urls.getForLookupPath('/resources/sss/style.css')}" />

<!-- JavsScript -->
<script src="${urls.getForLookupPath('/resources/js/common/common.js')}"></script>
<script src="${urls.getForLookupPath('/resources/js/main.js')}"></script>

예시

이전 버전


변경된 버전


흐름



생각해볼 점

  • 하나의 HTTP 요청에 필요한 정적 자원을 하나씩 검사하는 부분이 추가 되므로 극악한 상황의 네트워크, 서버 환경에서는 성능에 악영향을 줄 수도 있을 것 같다.
  • 하지만 일반적으로 문제는 없고, 오히려 버전 관리가 안되서 사용자가 과거 버전의 자원을 가진 채로 사이트가 운영됨으로 발생하는 이슈에 대한 비용이 더 클 것 같다.

참고


매직 넘버 치환

  • 소스 코드에 특정한 숫자(매직 넘버(magic number))를 직접 작성하는 것은 나쁜 코딩 스타일

나쁜 예

public class MagicNumber {
    public static void main(String[] args) {
        MagicNumber magicNumber = new MagicNumber();
        MagicNumber.Player player = magicNumber.new Player("Hoonmaro");
        // 0은 공격
        player.action(0);
        // 1은 방어
        player.action(1);
    }

    public class Player {

        private String name;

        public Player() {
        }

        public Player(String name) {
            this.name = name;
        }

        public void setName(String name) {
            this.name = name;
        }

        public void action(int command) {
            if (command == 0) {
                attack();
            } else if (command == 1) {
                defence();
            }
        }

        private void attack() {
            System.out.println(this.name + " is going to Attack!!");
        }

        private void defence() {
            System.out.println(this.name + " is going to Defence!!");
        }

    }
}

나쁜이유

  1. 매직 넘버의 의미를 알기 어렵다
    • 0과 1이 무슨 의미인가?
    • COMMAND_ATTACK과 같은 기호 상수를 쓰면 의미를 알기 쉽다
  2. 매직 넘버는 수정하기 어렵다
    • 0 또는 1 커맨드 숫자가 변경 될 경우 치환해야 하는데, 만약 많은 곳에서 동일한 커맨드를 쓴다면?
    • 손이 많이 가고, 틀리기 쉽고, 빼먹는 경우가 생길 수도 있다.

카탈로그

이름 매직 넘버를 기호 상수로 치환 (Replace Magic Number with Symbolic Constant)
상황 상수를 사용하고 있음
문제 • 매직 넘버는 알기 어려움
• 매직 넘버가 여러 곳에 있으면 변경하기 어려움
해법 매직 넘버를 기호 상수로 치환함
결과 장점
• 상수의 의미를 알기 쉬워짐
• 기호 상수의 값을 변경하면 상수를 사용하는 모든 곳이 변경됨
단점/주의 • 이해하기 어려운 이름을 사용하면 오해가 생길 수 있음
방법
  1. 기호 상수 선언하기
    • 1. 기호 상수 선언
    • 2. 매직 넘버를 기호 상수로 치환
    • 3. 기호 상수에 의존하는 다른 매직 넘버를 찾아서 기호 상수를 사용한 표현식으로 변환
    • 4. 컴파일
  2. 2. 테스트
    • 1. 모든 기호 상수 치환이 끝나면 컴파일해서 테스트
    • 2. 가능하다면 기호 상숫값을 변경한 후 컴파일해서 테스트 |
관련 항목 • 분류 코드를 클래스로 치환
• 분류 코드를 상태/전략 패턴으로 치환

리팩토링

기호 상수 선언하기

  1. 기호 상수 선언
  • public static final 클래스 필드 사용
  • 또는 enum 사용
  • 어떤 클래스 안에서만 사용할 기호 상수를 선언할 경우 private 접근지시자를 사용할 수 있다
public static final int COMMAND_ATTACK = 0;
public static final int COMMAND_DEFENCE = 1;
  1. 매직 넘버를 기호 상수로 치환
  • 0, 1과 같은 매직 넘버를 기호 상수로 치환
// if (command == 0) {
if (command == COMMAND_ATTACK) {

// else if (command == 1) {
} else if (command == COMMAND_DEFENCE) {


// player.action(0)
// player.aciton(1)
player.action(Player.COMMAND_ATTACK);
player.action(Player.COMMAND_DEFENCE);
  1. 기호 상수에 의존하는 다른 매직 넘버를 기호 상수를 사용한 표현식으로 변환
  • 상수 의존 관계는 상수 사이에 의존 관계가 있어 한 상수의 변경이 다른 상수에게도 영향을 미치는 경우를 말한다.
  • 이 때, 표현식으로 의존 관계를 표현해야 한다.
// public static final int MAX_INPUT_LENGTH = 100;
// public static final int WOR_ARE_LENGTH = 100 * 2;

public static final int MAX_INPUT_LENGTH = 100;
public static final int WOR_ARE_LENGTH = MAX_INPUT_LENGTH * 2;

테스트

  1. 모든 기호 상수 치환이 끝나면 컴파일해서 테스트

    • 테스트 결과는 리팩토링하기 전과 같아야 한다.
  2. 가능하다면 기호 상숫값을 변경한 후 컴팡리해서 테스트

    • 기호 상수의 값을 다른 값으로 변경한 후 테스트하면 빠드린 곳이 없는지 확인할 수 있다.
    • COMMAND_ATTACK 값을 0에서 1000으로 변경해도, 매직 넘버가 없다면 테스트가 성공하지만 매직 넘버가 있다면 테스트가 실패한다.

리팩토링 후

public class MagicNumber {
    public static void main(String[] args) {
        MagicNumber magicNumber = new MagicNumber();
        MagicNumber.Player player = magicNumber.new Player("Hoonmaro");
        player.action(Player.COMMAND_ATTACK);
        player.action(Player.COMMAND_DEFENCE);
    }

    public class Player {

        public static final int COMMAND_ATTACK = 0;
        public static final int COMMAND_DEFENCE = 1;


        private String name;

        public Player() {
        }

        public Player(String name) {
            this.name = name;
        }

        public void setName(String name) {
            this.name = name;
        }

        public void action(int command) {
            if (command == COMMAND_ATTACK) {
                attack();
            } else if (command == COMMAND_DEFENCE) {
                defence();
            }
        }

        private void attack() {
            System.out.println(this.name + " is going to Attack!!");
        }

        private void defence() {
            System.out.println(this.name + " is going to Defence!!");
        }

    }
}
  • 기호 상수가 충분한 정보를 제공하므로 주석이 필요 없다.

더 나은 리팩토링

  • 기호 상수로 만든다고 해도 실제로는 상수 값이므로 매직 넘버를 직접 적어도 문제없이 컴파일 된다.
  • 실수가 생길 수 있다.
  • 커맨드 클래스 객체를 활용하거나 enum을 활용한다.

enum 활용

  • 자바 5부터 enum을 사용할 수 있다.
public class EnumMagicNumber {
    public static void main(String[] args) {
        EnumMagicNumber magicNumber = new EnumMagicNumber();
        Player player = new Player("Hoonmaro");
        player.action(Player.Command.ATTACK);
        player.action(Player.Command.DEFENCE);
    }
}

// Player 클래스 분리
public class Player {

    public enum Command {
        ATTACK,
        DEFENCE
    }

    private String name;

    public Player() {
    }

    public Player(String name) {
        this.name = name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void action(Command command) {
        if (command == Command.ATTACK) {
            attack();
        } else if (command == Command.DEFENCE) {
            defence();
        }
    }

    private void attack() {
        System.out.println(this.name + " is going to Attack!!");
    }

    private void defence() {
        System.out.println(this.name + " is going to Defence!!");
    }

}

  • 기존 Player Inner 클래스를 분리하였다.
  • 증첩 enum 은 암묵적으로 static 클래스 이기 때문에 Inner 클래스 안에 선언할 수 없기 때문이다.
  • Java Language Spec 8.9 Enums 참고
  • enum을 활용하여 직관적으로 의미를 전달 할 수 있다.
  • 상수가 아니므로 잘못 사용된 곳에서 컴파일에서 경고가 발생하여 에러를 사전에 방지할 수 있다.

기호 상수가 적합하지 않은 경우

  • 배열 길이
    • 배열 길이는 배열 객체에 length라는 필드가 있다.
    • 기호 상수가 언제나 올바른 배열길이가 아닐 수도 있다.
  • 잘 알려진 값을 대체하는 기호 상수 (오히려 가독성이 떨어짐)

바이트 코드에 내장된 상수에 주의

  • 필드값이 컴파일할 때 정해지는 상수일 경우 리컴파일시 변경된 값으로 바뀌겠지만,
  • 이를 사용하는 다른 클래스에서는 리컴파일 하기 전에 이전 상수 값을 가지고 있으므로 문제가 발생한다.
  • 매직 넘버를 치환한 모든 소스코드를 리컴파일 해야 정상 작동 된다.

AJAX Patterns

  • Vue에서 AJAX를 사용하는 가장 좋은 방법에 대해서는 개발자들 사이에서도 의견이 갈린다.
  • 그 중 4가지의 디자인 패턴을 소개하며, 각각의 장단점을 알고 상황에 맞게 쓸 수 있어야 한다.
  • AJAX Design Patterns in Vue.js
    1. Root instance
    2. Components
    3. Vuex ations
    4. Route navigation guards

1. Root instance

  • 이 아키텍쳐에서는 root instance로부터 모든 AJAX 요청이 보내지고 모든 상태가 저장된다.

  • 다른 하위 컴포넌트들이 data를 필요로 한다면, props를 통해 전달한다.

  • 하위 컴포넌트들이 data 최신화를 원하면, 커스텀 이벤트를 통해 root instacne의 AJAX 요청을 호출한다.

  • 예제

new Vue({
  data: {
    message: ''
  },
  methods: {
    refreshMessage(resource) {
      this.$http.get('/message').then((response) {
        this.message = response.data.message;
      });
    }
  }
})

Vue.component('sub-component', {
  template: '<div>{{ message }}</div>',
  props: [ 'message' ]
  methods: {
    refreshMessage() {
      this.$emit('refreshMessage');
    }
  }
});

장점

  • 모든 AJAX 로직과 data를 한 곳에서 관리할 수 있다.
  • 모든 컴포넌트들이 프레젠테이션에 집중할 수 있다.

단점

  • 많은 props와 커스텀 이벤트가 필요하면서 앱의 크기가 커진다.

2. Components

  • 이 아키텍쳐에서는 컴포넌트 별로 AJAX 요청과 상태를 독립적으로 관리한다.

  • 일반적으로 컨테이너 역할을 하는 컴포넌트가 data를 관리하고 하위 컴포넌트들을 프레젠테이션에 집중시킨다.

  • 예제

let mixin = {
  methods: {
    callAJAX(resource) {
      ...
    }
  }
}

Vue.component('container-comp', {
  // No meaningful template, I just manage data for my children
  template: '<div><presentation-comp :mydata="mydata"></presentation-comp></div>', 
  mixins: [ myMixin ],
  data() {
    return { ... }
  },

})

Vue.component('presentation-comp', {
  template: <div>I just show stuff like {{ mydata }}</div>,
  props: [ 'mydata' ]
})

장점

  • 컴포넌트 결합도를 낮추고 재활용성을 높인다.
  • data를 필요할 때 얻을 수 있다.

단점

  • 다른 컴포넌트들과 data를 주고받기 쉽지 않다.
  • 컴포넌트가 너무 많은 책임을 가지게 되거나 기능적 중복이 발생할 수 있다.

3. Vuex actions

  • 이 아키텍쳐에서는 AJAX 로직과 상태를 Vuex Store에서 관리할 수 있다.

  • 컴포넌트들은 action을 호출하여 새로운 data를 요청할 수 있다.

  • 이 패턴을 구현하는 경우, AJAX요청(예, 로딩 스피너 숨기기, 버튼 재활성화)의 해결에 반응할 수 있도록 액션에서 promise를 반환하는 것이 좋다.

  • 예제

store = new Vuex.Store({
  state: {
    message: ''
  },
  mutations: {
    updateMessage(state, payload) {
      state.message = payload
    }
  },
  actions: {
    refreshMessage(context) {
      return new Promise((resolve) => {
        this.$http.get('...').then((response) => {
          context.commit('updateMessage', response.data.message);
          resolve();
        });
      });
    }
  }
});

Vue.component('my-component', {
  template: '<div>{{ message }}</div>',
  methods: {
    refreshMessage() {
      this.$store.dispatch('refeshMessage').then(() => {
        // do stuff
      });
    }
  },
  computed: {
    message: { return this.$store.state.message; }
  }
});

장점

  • 추가적인 props와 커스텀 이벤트 없이도 Root instance 패턴과 Components 패턴의 장점을 모두 가진다.

단점

  • Vuex를 사용하기 위한 학습비용 등의 오버헤드

4. Route navigation guards

  • 애플리케이션이 여러 페이지로 분할되고 route가 변경되면 해당 페이지와 하위 컴포넌트들에 필요한 모든 데이터를 가져온다.

  • 이 방식의 주요 이점은 UI가 굉장히 단순해지는 것이다.

  • 만약 컴포넌트들이 독립적으로 data를 가져오면, 컴포넌트 데이터가 임의의 순서로 입력되어 페이지가 예측 불가능하게 재전송된다.

  • 예제

import axios from 'axios';

router.beforeRouteEnter((to, from, next) => {
  axios.get(`/api${to.path}`).then(({ data }) => {
    next(vm => Object.assign(vm.$data, data))
  });
})

장점

  • UI를 보다 쉽게 예측할 수 있다.

단점

  • data가 준비될 때가지 페이지가 렌더링 되지 않으므로 전체적으로 느려진다.
  • routes를 사용하지 않는다면 도움이 되지 않는다.

참고


+ Recent posts