docker-compose를 이용한 ElasticSearch Cluster구성

|

개발을 하다보면 공용장비가 아닌 로컬장비에서 DB나 캐시, 검색엔진등을 실행해야하는 경우가 있는데 이 때 Docker를 사용하면 필요할 때만 올려 사용할 수 있어 자원 관리가 편하고 docker-compose를 이용하면 여러 프로그램을 동시에 실행하고 종료 할 수 있어 편하게 사용할 수 있습니다.
본 글은 docker-compose를 이용하여 ElasticSearch 6.5.3 버전 기반으로 Cluster 환경을 구성하며 Kibana까지 같이 올리는 방법에 관한 글로 이미 elstic reference에 docker를 이용하여 설치하는 방법이 친절하게 설명되어 있으나 실제 설치하면서 추가로 필요했던 부분에 대해 보충하였습니다.

구성

구성은 master-node 1대, data-node 1대, kibana 1대 입니다.
ElasticSearch(이하 ES)와 함께 Celebro를 모니터링 툴로 쓰는 경우가 있는데 Kibana 최신 버전은 xpack을 통해 모니터링하는 기능이 있어 구지 필요가 없어 제외했습니다.

kibana-Monitoring

docker-compose.yml

version: '2.2'
services:
# master-node의 Docker 서비스입니다.
# Kibana에서 기본적으로 호출하는 ES host주소가 'http://elsaticsearch:9200'이기 때문에 서비스명은 elasticsearch로 쓰시는게 편합니다. 
# 다른 서비스명을 사용시 Kibana ES host 설정도 같이 추가해주어야 정상 동작합니다.
  elasticsearch:
    container_name: elasticsearch
    image: elasticsearch:6.5.3
    environment:
# ES Cluster명입니다. ES 서비스마다 동일한 명칭을 사용해야합니다.    
      - cluster.name=docker-cluster
# ES Node명을 설정합니다.
      - node.name=master-node1
# ES운영중 메모리 스왑을 막기 위한 설정을 추가합니다.
# 자세한 설명은 페이지 하단의 [Disable swapping]을 참고하세요.
      - bootstrap.memory_lock=true
# JVM Heap메모리 설정입니다. Xms/Xmx 옵션은 항상 같게 설정합니다.  
# 자세한 설명은 페이지 하단의 [Setting the heap size]을 참고하세요.
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
# 리눅스 시스템 자원제한 관련 옵션입니다.
# ES는 많은 파일디스크립터와 핸들러를 사용하기 때문에 제한 해제가 필요합니다.
# 자세한 설명은 페이지 하단의 [File Descriptors]을 참고하세요.
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - es1:/usr/share/elasticsearch/data
# Kibana에서 본 노드를 호출하기 때문에 외부 9200포트는 master-node에 연결해줍니다.
    ports:
      - 9200:9200
      - 9300:9300
    networks:
      - esnet
# 컨테이너에 bash로 붙고 싶을경우 아래 두 옵션을 추가해주면 됩니다.
    stdin_open: true
    tty: true
# data-node의 Docker 서비스입니다.
# 대부분의 내용이 master-node와 동일하나 몇가지 차이점이 있습니다.
  elasticsearch2:
    container_name: elasticsearch2
    image: elasticsearch:6.5.3
    environment:
      - cluster.name=docker-cluster
      - node.name=data-node1
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
# 다른 Cluster내 노드를 발견하기 위한 설정입니다.
      - "discovery.zen.ping.unicast.hosts=elasticsearch"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - es2:/usr/share/elasticsearch/data
# 외부 연결포트가 master-node와 겹치기 때문에 다르게 설정했습니다.
    ports:
      - 9301:9300
    networks:
      - esnet
    stdin_open: true
    tty: true
# 각 서비스를 순차적으로 실행하기 위해 설정해주었습니다. (필수아님) 
    depends_on:
      - elasticsearch
# Kibana 설정입니다.
  kibana:
    container_name: kibana
    image: kibana:6.5.3
    ports:
      - 5601:5601
    networks:
      - esnet
    depends_on:
      - elasticsearch
      - elasticsearch2
volumes:
  es1:
    driver: local
  es2:
    driver: local
networks:
  esnet:

실행 / 종료

위와 같이 설정파일을 작성 후 docker-compose를 실행하면 아래와 같이 ES가 기동하는것을 볼 수 있습니다.

$ docker-compose up -d
Creating network "elasticsearch_esnet" with the default driver
Creating volume "elasticsearch_es1" with local driver
Creating volume "elasticsearch_es2" with local driver
Pulling elasticsearch (elasticsearch:6.5.3)...
6.5.3: Pulling from library/elasticsearch
Pulling elasticsearch2 (elasticsearch:6.5.3)...
6.5.3: Pulling from library/elasticsearch
Pulling kibana (kibana:6.5.3)...
6.5.3: Pulling from library/kibana
Creating elasticsearch ... done
Creating elasticsearch2 ... done
Creating kibana         ... done

종료방법은 아래와 같습니다.

$ docker-compose down
Stopping kibana         ... done
Stopping elasticsearch2 ... done
Stopping elasticsearch  ... done
Removing kibana         ... done
Removing elasticsearch2 ... done
Removing elasticsearch  ... done
Removing network elasticsearch_esnet

Reference

Install Elasticsearch with Docker:https://www.elastic.co/guide/en/elasticsearch/reference/6.5/docker.html
Disable swapping:https://www.elastic.co/guide/en/elasticsearch/reference/6.4/setup-configuration-memory.html
Setting the heap size:https://www.elastic.co/guide/en/elasticsearch/reference/6.5/heap-size.html
File Descriptors:https://www.elastic.co/guide/en/elasticsearch/reference/6.5/file-descriptors.html

(React) Component Life Cycle Methods

|

React Component의 생명주기에 대해 정리하고 테스트 예제를 포스팅합니다.
참고로 version 17부터 deprecated 되는 항목(componentWillMount, componentWillUpdate, componentWillReceiveProps)은 제외했습니다.
위 3개의 lifecycle은 오용되는 케이스가 많아 삭제 되었으며 ‘UNSAFE_‘라는 prefix를 붙여 메소드가 남아있는 상태로 자세한 내용은 Update on Async Rendering문서를 참고하세요.

React 버전에 따라 생명주기가 살짝 다른데 아래 그림을 참고하세요.

React v16.3 : https://code.likeagirl.io/understanding-react-component-life-cycle-49bf4b8674de
React Component LifeCycle v16.3

React v16.4 : https://medium.com/@nancydo7/understanding-react-16-4-component-lifecycle-methods-e376710e5157
React Component LifeCycle v16.4

위 그림과 같이 React Component의 생명주기는 실행 이벤트 관점에서 “mount/update/unmount”로 구분 할 수 있으며 실행 단계 관점에서는 “랜더링전/DOM 반영전/DOM 반영이후”로 구분 할 수 있습니다.
아래 메서드들은 실행 순서 보다는 “일반적으로 사용되는”, “드믈게 사용되는” 생명주기로 구분합니다. 버전별 일반 생명주기 메서드 표는 아래 링크를 참고하세요.

react-lifecycle-methods-diagram : http://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/

1. Commonly Used Lifecycle Methods

render()

React.Component에서 유일하게 필수 구현되어야 하는 함수입니다. 미구현시 아래와 같은 오류를 만나게 됩니다.

Warning: TodoApp(...): No `render` method found on the returned component instance: you may have forgotten to define `render`.

Uncaught TypeError: instance.render is not a function

이 메서드 안에서는 this.props와 this.state를 사용할 수 있으나 state의 값을 변경하면 안됩니다. 변경시 아래와 같은 오류를 만나게 됩니다.

Uncaught Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.

반환 값으로 일반적으로 DOM노드를 반환하나 아래와 같이 여러종류의 데이터를 넘길 수 있습니다.

1. React Element

  • JSX타입의 요소를 반환할 수 있습니다.
  • 반환시 최상위 DOM은 단일노드여야 합니다.
render() {
  return <div> ... </div>;
}

render() {
  return <MyComponent/>;
}

// error case
render() {
  return <div>1</div><div>2</div>;
}
/**
* Uncaught SyntaxError: Inline Babel script: Adjacent JSX elements must be wrapped in an enclosing tag
*/

2. Fragment

  • 특정 태그로 묶고 싶지 않다면 태그로 묶어 반환할 수 있습니다.
  • 해당 태그로 묶을 경우 렌더링시 Fragment태그는 제거됩니다.
render() {
  return (
    <div>
      <div>1</div>
      <div>2</div>
    </div>
  );
}
/**
* Result
* <div id="app">
*   <div>
*     <div>1</div>
*     <div>2</div>
*   </div>
* </div>
*/

render() {
  return (
    <React.Fragment>
      <div>1</div>
      <div>2</div>
    </React.Fragment>
  );
}
/**
* Result
* <div id="app">
*   <div>1</div>
*   <div>2</div>
* </div>
*/

3. Portal

  • ReactDOM.createPortal 메서드를 이용하여 다른 DOM의 서브트리로 만들수 있습니다.
  • 반드시 리턴 할 경우에만 적용됩니다.
  • 일반 렌더링값과는 달리 해당 DOM의 하위 노드를 제거후 렌더링하는것이 아니라 서브 노드로 추가가 됩니다.
// 본 컴포넌트가 속한 부모 노드 하위가 아닌 #portal 노드 하위에 그려집니다. 
render() {
    let domNode = document.querySelector("#portal");
    return ReactDOM.createPortal(
        <div>Portal</div>,
        domNode
    );
}
// 아래와 같은 경우 아무것도 그려지지 않습니다.    
render() {
    let domNode = document.querySelector("#portal");
    ReactDOM.createPortal(
        <div>Portal</div>,
        domNode
    );
    return true;
}

Portal의 경우 컴포넌트가 속하지 않은 노드에 렌더링을 할 수 있게 하는데 다음과 같은 경우에 사용하기 유용합니다.
ex) dialog, hovercard, tooltips …
자세한 가이드는 React Portals 문서를 참고하세요.

4. String, Number

  • 문자열이나 숫자를 반환 할 수 있으며 TextNode로 렌더링 됩니다.
render() {
  return 'Text';
}
/**
* Result
* <div id="app">Text</div>
*/

5. boolean, null

  • boolean(true/false)값이나 null값 역시 반환 가능합니다.
  • 반환시 화면에 아무것도 안그리는 것으로 보일수 있으나 사실상 공백을 그려줍니다. 렌더링되는 요소 하위에 다른 요소가 존재한다면 삭제 됩니다.
  • 심지어 undefined도 가능합니다. 테스트 해보면 에러는 안나지만 공식문서에 undefined는 명시되 있지 않습니다.

6. Array

  • 배열을 반환할 수 있습니다.
  • 배열 요소들은 렌더링 가능한 모든 타입이 가능합니다. (function은 불가능합니다.)
  • 컴포넌트 배열을 렌더링 할 경우 어떤 원소에 변동이 있는지 알아내기 위해 각 원소에 고유 key가 포함되어야 합니다.
render() {
  return [
    "start",
    1234,
    false,
    null,
    <React.Fragment key="frag"><div key="1">1</div><div key="2">2</div></React.Fragment>,
    <b key="3">{this.state.status}</b>,
    ReactDOM.createPortal(
      <div>Portal</div>,
      this.props.portal
    )
  ];
}

constructor(props)

constructor(props) {
  super(props);
  // Don't call this.setState() here!
  this.state = { counter: 0 };
  this.handleClick = this.handleClick.bind(this);
}

컴포넌트 생성자로 생성시 맨 처음에 실행하게 되는데 props를 인자로 받는데 React.Component를 상속했을 경우 반드시 super(props);를 호출해야합니다.
그리고 constructor는 유일하게 this.state 를 직접 할당하는 메서드입니다. 이 메서드 안에서는 setState()를 호출하지 말아야 합니다. 호출시 아래와 같은 오류가 발생합니다.
그 외 이벤트를 해당 인스턴스로 바인드 하는 등의 작업을 할 수 있습니다.

Warning: Can't call setState on a component that is not yet mounted. This is a no-op, but it might indicate a bug in your application. Instead, assign to `this.state` directly or define a `state = {};` class property with the desired state in the YourApp component.

componentDidMount()

Component가 DOM트리에 마운트 될 때 실행되며 일반적으로 DOM노드가 필요한 초기화 작업은 본 메서드에서 실행해야 합니다. 또한 여기서 실행한 트리거나 이벤트, 비동기 작업등은 componentWillUnmount메서드에서 만드시 해제 해주는 것이 좋습니다.
componentDidMount() 메서드에도 안티패턴이 존재하는데 아래와 같은 케이스입니다.

componentDidMount() {
  ...
  this.setState({ ... });
}

위와 같이 호출 가능하나 화면을 업데이트 하기 전에 추가 렌더링이 발생하므로 뷰에는 중간 상태를 나타내지 않으며 성능상 이슈를 발생하기도 합니다.
하지만 DOM에 렌더링 되기 전에 크기 측정이 필요 할 경우 위와 같이 사용 할 수도 있습니다. (ex. 툴팁이나 모달창등을 만들때.. )

componentDidUpdate(prevProps, prevState, snapshot)

리렌더링 발생 이후 실행되며 초기 렌더링 시에는 실행되지 않습니다. DOM업데이트가 완료된 이후에 실행되므로 DOM관련 처리를 해도 됩니다.
그리고 이전 상태의 props/state값과 getSnapshotBeforeUpdate()메서드를 통해 전달 받은 데이터가 넘어오기 때문에 이전 상태와 현 상태의 변화에 따른 네트워크 작업을 하기에 좋습니다.
단, 주의해야할 점은 본 메서드에서 setState()를 실행할 수 있으나 제약조건없이 실행 할 경우 무한루프에 빠질 수 있습니다.

componentWillUnmount()

Component가 DOM에서 제거되고 파기 되기 직전에 호출 됩니다. 본 Component에서 구독한 이벤트나 트리거등을 제거할때 사용되며 Component가 리렌더링 되지 않기 때문에 setState()를 호출 할 수 업습니다.

2. Rarely Used Lifecycle Methods

shouldComponentUpdate(nextProps, nextState)

본 메서드에서 리렌더링 여부를 결정하는 메서드로 새로운 props/state 데이터를 받을 경우 동작하며, 초기 렌더링시나 forceUpdate()를 통한 리렌더링시에는 동작하지 않습니다.
재정의 하지 않을 경우 기본적으로는 모든 변경시마다 리렌더링을 실행하며 false를 반환 할 경우 render() 메서드를 실행하지 않습니다.
shouldComponentUpdate메서드는 보통 성능 최적화를 위해 특정 값의 변경에 따라서만 리렌더링을 조절가능하나 버그를 양산하기 쉽기 때문에 대신 React.PureComponent를 사용할 것을 권장하고 있습니다.
PureComponent는 shouldComponentUpdate()메서드가 이미 구현되어 있는 클래스로 React.Component 대신 상속 받아 사용할 경우 props/state의 변화시 얕은 비교를 통해 변경된 것이 있을 경우에만 리렌더링을 해줍니다. PureComponent및 성능 최적화에 관련된 내용은 아래 링크를 참고하세요.

리액트 성능 향상 시키기 - React.PureComponent : https://wonism.github.io/react-pure-component/
Optimizing Performance : https://reactjs.org/docs/optimizing-performance.html

getDerivedStateFromProps(nextProps, prevState)

React v16.3이후 새로 생긴 메서드로 모든 render()메서드 실행 전에 시작됩니다.
업데이트가 필요한 state값을 반환하거나 null을 반환해야 하고 본 메서드는 static 메서드로 Component에 직접 접근 할 수 없습니다.
레퍼런스 문서에는 getDerivedStateFromProps 메서드의 용도를 오직 props의 변화에 따른 state 상태 변화를 위한 용도로만 정의하고 있습니다.
이를 위해 일부 개발자가 양산하는 아래와 같은 버그를 막는 용도로 설명하고 있으며 더 자세한 내용은 You Probably Don’t Need Derived State에서 확인 할 수 있습니다.

constructor(props) {
 super(props);
 // Don't do this!
 this.state = { color: props.color };
}

위 예제는 props값을 직접 참조함으로써 props값이 변할 때 마다 state에 영향을 미치길 바라지만 실제로는 변경되지 않습니다.
하지만 아래와 같이 getDerivedStateFromProps 메서드를 이용하면 props값이 변경시마다 getDerivedStateFromProps 메서드를 호출하기 때문에 state값을 변경 가능합니다.

static getDerivedStateFromProps(nextProps, prevState) {
  if (nextProps.status != prevState.status) {
    return { status : nextProps.status };
  }
  
  return null;
}

getSnapshotBeforeUpdate(prevProps, prevState)

React v16.3이후 새로 생긴 메서드로 render()메서드 호출 이후 DOM트리에 반영전에 호출 됩니다.
특정 동작에 따른 Component의 변화가 적용 되기전에 이전 scroll position와 같은 이전 상태의 snapshot을 남겨 넘길수 있는데 이 때 리턴된 값은 componentDidUpdate() 메서드에 전달 됩니다. 재정의 하지 않을 경우 기본값은 null이 리턴됩니다.

잘못 사용하는 케이스로 본 메서드에서 현재 렌더링된 DOM의 데이터를 읽어들이는 케이스가 있습니다.

getSnapshotBeforeUpdate(prevProps, prevState) {
  if (prevProps.list.length < this.props.list.length) {
    const list = this.listRef.current;
    return list.scrollHeight - list.scrollTop;
  }
  return null;
}

위와 같이 사용할 경우 render과정과 commit과정 사이의 딜레이가 존재하기 때문에 원하는 결과를 얻을수 없습니다. 본 메서드에서는 과거 상태만을 반환하고 componentDidUpdate()메서드에서 처리하는 것이 좋습니다.

3. Example

위 생명주기를 테스트 해 볼수 있는 샘플 코드입니다.

4. 그 외…

Error Handling : https://reactjs.org/docs/error-boundaries.html

(React) PropTypes 사용방법과 종류

|

React Component의 prop값을 검증하기 위해 PropTypes를 이용하여 값을 지정할 수 있습니다.
React v15.5부터 다른 패키지로 변경되었는데 ‘prop-types’라이브러리를 사용하라고 권고하고 있습니다.

사용방법

import React, { Component } from 'react';
import PropTypes from 'prop-types';

class Foo extends Component {
  ...
  
  static propTypes = {
    strArg : PropTypes.string,            
    numArg : PropTypes.number.isRequired   
  }
}

위와 같이 설정할 경우 Foo 컴포넌트의 strArg값은 string 타입이여야 하며, numArg값은 number 타입이여야 합니다.
그리고 strArg값은 지정하지 않아도 되나 isRequired를 지정한 numArg값은 반드시 설정해야 합니다.

또한 propTypes은 아래와 같이 class 밖에서도 설정 가능합니다.

class Foo extends Component {
  ...
}

Foo.propTypes = {
  strArg : PropTypes.string
}

PropTypes의 종류

PropTypes으로 설정할 수 있는 종류는 아래와 같습니다.

kind description
array 배열
bool true/false
func 함수
number 숫자
object 객체
string 문자열
symbol 심벌 개체(ES6)
node 렌더링 가능한 모든것(number, string, element, 또는 그것들이 포함된 array/fragment)
element React element
instanceOf(ClassName) JS에서 instanceof로 정의 가능한 클래스 인스턴스
oneOf([…Value]) 포함된 값들중 하나.(ex: oneOf([‘남자’,’여자’]))
oneOfType([…PropTypes]) 포함된 PropTypes들중 하나. (ex: oneOfType([PropTypes.string, PropTypes.instanceOf(MyClass)]))
arrayOf(PropTypes) 해당 PropTypes으로 구성된 배열
objectOf(PropTypes) 주어진 종류의 값을 가진 객체
shape({key:PropTypes}) 해당 스키마를 가진 객체.(ex:shape({name:PropTypes.string,age:PropTypes.number}))
exact({key:PropTypes}) 명확하게 해당 스키마만 존재해야함.

Reference

Typechecking With PropTypes:https://reactjs.org/docs/typechecking-with-proptypes.html

SSM(simple-spring-memcached) MultiCache 사용하기 (@ReadThroughMultiCache, @UpdateMultiCache, @InvalidateMultiCache)

|

“Spring + Memcached” 조합일때 simple-spring-memcached(이하 ssm)이 많이 사용되는데 인터넷에 보면 대부분 @ReadThroughSingleCache@ReadThroughAssignCache에 대한 설명이나 예제는 많은데 @ReadThroughMultiCache관련된 예제는 유독 찾아보기 힘들었습니다.
심지어 공식 가이드에도 간략하게만 써있어서 실제 동작 방식에 대해 알아보기 위해 xmemcached기반으로 직접 테스트 프로젝트를 만들어보고 테스트 해본 내용에 대한 포스팅입니다.

Basic

아래 코드는 기본적인 사용 방법입니다.

@ReadThroughMultiCache
public List<Integer> randomMulti(@ParameterValueKeyProvider List<Integer> nums) {
    ......
}

하나의 java.util.List인자값을 포함하고 java.util.List 또는 java.util.List를 상속한 클래스를 리턴하는 메소드에만 적용할 수 있습니다.
위와 같이 적용시 ssm은 자동으로 인자값의 List와 결과값의 List를 매핑하여 분산하여 캐시키를 저장하게 됩니다.
위 Java코드를 기반으로 실행시 인자값과 반환값이 아래와 같을 경우 실제 캐시에 저장되는 예시입니다.

// argument : List(1,2,3,4,5)
// returnValue : List(11,22,33,44,55)

stats cachedump 5 100
ITEM local:TMON::COMMON:randomMulti:1 [135 b; 1543323568 s]    // value is 11
ITEM local:TMON::COMMON:randomMulti:2 [145 b; 1543323568 s]    // value is 22
ITEM local:TMON::COMMON:randomMulti:3 [155 b; 1543323568 s]    // value is 33
ITEM local:TMON::COMMON:randomMulti:4 [165 b; 1543323568 s]    // value is 44
ITEM local:TMON::COMMON:randomMulti:5 [175 b; 1543323568 s]    // value is 55
END

@ReadThroughSingleCache의 경우 “1,2,3,4,5” 전체를 키 값으로 사용하지만 @ReadThroughMultiCache의 경우 List의 각 키 값을 분산 저장하고 재활용 합니다.
위와 같이 캐시가 저장된 상태에서 다시 아래와 같이 실행할 경우 기존 캐시된 값은 그대로 사용하고 캐시가 없는 부분만 다시 캐시에 저장합니다.

// argument : List(2,4,6,8)
// returnValue : List(22,44,66,88)

stats cachedump 5 100
ITEM local:TMON::COMMON:randomMulti:1 [135 b; 1543323568 s]
ITEM local:TMON::COMMON:randomMulti:2 [145 b; 1543323568 s]   // use existing cache
ITEM local:TMON::COMMON:randomMulti:3 [155 b; 1543323568 s]   
ITEM local:TMON::COMMON:randomMulti:4 [165 b; 1543323568 s]   // use existing cache
ITEM local:TMON::COMMON:randomMulti:5 [175 b; 1543323568 s]
ITEM local:TMON::COMMON:randomMulti:6 [185 b; 1543324016 s]   // new cache
ITEM local:TMON::COMMON:randomMulti:8 [205 b; 1543324016 s]   // new cache
END

새로 캐시를 저장하는 것이 아니기 때문에 expiration 설정을 했을 경우 1~5번 캐시는 동시에 삭제되고 6,8번 캐시는 이후 삭제됩니다.

위 메서드를 디버깅해보면 애초에 인자값에 캐시 데이터가 없는 값만 추려 전달하는것을 볼 수 있습니다.

// execute 1 => argument = List(1,2,3,4,5)
@ReadThroughMultiCache
public List<Integer> randomMulti(@ParameterValueKeyProvider List<Integer> nums) {
    // nums = List(1,2,3,4,5)
    ......
}

// execute 2 => argument = List(2,4,6,8)
@ReadThroughMultiCache
public List<Integer> randomMulti(@ParameterValueKeyProvider List<Integer> nums) {
    // nums = List(6,8)
    ......
}

이미 캐싱 되 있는 값을 Update 하기 위해서는 @UpdateMultiCache를 이용 할 수 있습니다.

@UpdateMultiCache
public void updateMulti(@ParameterValueKeyProvider List<Integer> multi, @ParameterDataUpdateContent List<List<Integer>> content) {
    ......
}

위 예제와 같이 @ParameterDataUpdateContent어노테이션을 사용하여 저장할 값을 직접 주입할 수 있으며,

@ReturnDataUpdateContent
@UpdateMultiCache
public List<Integer> updateMulti(@ParameterValueKeyProvider List<Integer> multi) {
    ......
}

@ReturnDataUpdateContent어노테이션을 사용하여 반환값을 저장할 수도 있습니다.

캐시를 만료 시킬때는 @InvalidateMultiCache어노테이션을 사용합니다.

@InvalidateMultiCache
public void invalidateMulti(@ParameterValueKeyProvider List<Integer> multi) {
    ......
}

Caution - Argument

@ReadThroughMultiCache는 반드시 하나의 java.util.List 인자값을 포함해야합니다.

@ReadThroughMultiCache
public List<Integer> randomMulti(@ParameterValueKeyProvider Integer multi) {
    ......
}

위와 같이 인자값이 잘못됬을 경우 정상실행 되나 캐싱되지 않으며 아래와 같은 오류 메시지가 발생합니다.

[2018-11-27 22:13:46] [WARN ] c.g.c.s.a.CacheAdvice.warn[55] Caching on execution(XXXService.randomMulti(..)) aborted due to an error.
com.google.code.ssm.aop.support.InvalidAnnotationException: No one parameter objects found at dataIndexes [[0]] is not a [java.util.List]. [public java.util.List test.service.XXXService.randomMulti(java.lang.Integer)] does not fulfill the requirements.

java.util.List타입 대신 Array를 사용하더라도 동일한 오류를 발생시킵니다.

@ReadThroughMultiCache
public List<Integer> randomMulti(@ParameterValueKeyProvider Integer[] multi) {
    ......
}
[2018-11-27 22:16:23] [WARN ] c.g.c.s.a.CacheAdvice.warn[55] Caching on execution(XXXService.randomMulti(..)) aborted due to an error.
com.google.code.ssm.aop.support.InvalidAnnotationException: No one parameter objects found at dataIndexes [[0]] is not a [java.util.List]. [public java.util.List test.service.XXXService.randomMulti(java.lang.Integer[])] does not fulfill the requirements.

java.util.List타입 인자값이 2개 이상일 경우에도 오류를 발생시킵니다.

@ReadThroughMultiCache
public List<Integer> randomMulti(@ParameterValueKeyProvider List<Integer> multi, @ParameterValueKeyProvider(order = 1) List<Integer> multi2) {
    ......
}
[2018-11-27 22:19:18] [WARN ] c.g.c.s.a.CacheAdvice.warn[55] Caching on execution(XXXService.randomMulti(..)) aborted due to an error.
com.google.code.ssm.aop.support.InvalidAnnotationException: There are more than one method's parameter annotated by @ParameterValueKeyProvider that is list public java.util.List test.service.XXXService.randomMulti(java.util.List,java.util.List)

하지만 java.util.List타입 인자값이 1개라면 아래와 같이 다른 키 값이 추가 되더라도 정상적으로 캐시를 저장합니다.

@ReadThroughMultiCache
public List<List<Integer>> randomMulti(@ParameterValueKeyProvider Integer fixSize, @ParameterValueKeyProvider(order = 1) List<Integer> multi) {
    ......
}
// argument : fixSize = 2 , multi = List(1,2,3)

stats cachedump 6 100
ITEM local:TMON::COMMON:randomMulti:2,3 [145 b; 1543325621 s]
ITEM local:TMON::COMMON:randomMulti:2,2 [145 b; 1543325621 s]
ITEM local:TMON::COMMON:randomMulti:2,1 [145 b; 1543325621 s]
END

순서를 반대로 해도 정상적으로 저장합니다.

@ReadThroughMultiCache
public List<List<Integer>> randomMulti(@ParameterValueKeyProvider List<Integer> multi, @ParameterValueKeyProvider(order = 1) Integer fixSize) {
    ......
}
// argument : multi = List(1,2,3), fixSize = 2 

stats cachedump 6 100
ITEM local:TMON::COMMON:randomMulti:3,2 [145 b; 1543325799 s]
ITEM local:TMON::COMMON:randomMulti:2,2 [145 b; 1543325799 s]
ITEM local:TMON::COMMON:randomMulti:1,2 [145 b; 1543325799 s]
END

Caution - Return Data

@ReadThroughMultiCache는 반드시 java.util.List타입의 반환값을 가져야합니다.

반환값이 java.util.List이 아닐 경우 메소드 자체는 정상 동작하지만 아래와 같이 오류 메시지를 반환하며 캐시는 저장되지 않습니다.

@ReadThroughMultiCache
public Integer randomMultiFindOne(@ParameterValueKeyProvider List<Integer> multi) {
    ......
}
[2018-11-27 22:30:32] [WARN ] c.g.c.s.a.CacheAdvice.warn[55] Caching on execution(XXXService.randomMultiFindOne(..)) aborted due to an error.
com.google.code.ssm.aop.support.InvalidAnnotationException: The annotation [com.google.code.ssm.api.ReadThroughMultiCache] is only valid on a method that returns a [java.util.List] or its subclass. [public java.lang.Integer test.service.XXXService.randomMultiFindOne(java.util.List)] does not fulfill this requirement.

인자값과 반환값을 쌍으로 캐시에 저장하기 때문에 인자값의 size와 반환값의 size는 동일해야 합니다.
인자값보다 반환값의 size가 더 많거나 적을 경우 아래와 같은 오류 메시지를 남기고 캐시는 저장되지 않습니다.

[2018-11-27 22:38:24] [WARN ] c.g.c.s.a.ReadThroughMultiCacheAdvice.generateByKeysProviders[166] Did not receive a correlated amount of data from the target method: %s. Result list will be unsorted and won't respect the order of the keys passed in argument.

인자값과 반환값의 size가 같을 경우 캐시를 분할하여 저장하게 되는데 인자값과 반환값의 같은 index끼리 저장하게 되기 때문에 반환값의 순서가 중요합니다.
순서가 다를 경우 오류도 없이 캐시가 엉망으로 저장될 수 있습니다.

Conclusion

SSM은 분명 편하게 “Spring + Memcached” 조합을 사용 할 수 있게 해주지만 간단한 만큼 인적오류로 인한 실수를 범할 수 있으며 오류 로그 역시 warn 레벨로 남기기 때문에 잘못을 인지하지 못하고 사용하는 경우가 많습니다.
특히 @ReadThroughMultiCache의 경우 위에서 알아본 바와 같이 개발자가 실수할 수 있는 여지가 많기 때문에 더더욱 신중하게 사용해야 하지만 자동으로 분할하여 캐시를 저장하며 실행시 알아서 캐시되 있지 않은 값만 따로 실행해주기 때문에 분명 매력적인 부분이 존재합니다.

(Spring) ImportAware is not work

|

ImportAware 구현체가 동작하지 않는 이슈가 발생해 구글링해보다가 해결이 되지 않아 직접 스프링 코어 소스를 까서 원인을 확인한 부분에 대해 기록한 글입니다.

Issue

개발중인 모듈을 @EnableXXX 방식으로 어노테이션 지정시 별도 설정없이 동작하기 위해 아래와 같은 Configuration을 Import 하도록 하였습니다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(LocalCacheConfig.class)
public @interface EnableLocalCache {
    ……
}

public class LocalCacheConfig implements ImportAware, BeanFactoryPostProcessor {
    ……

@Override
    public void setImportMetadata(AnnotationMetadata importMetadata) {
        enabled = importMetadata.hasAnnotation(EnableLocalCache.class.getName());
        if (! enabled) {
            return;
        }
……
    }

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        if (! enabled) {
            return;
        }

        beanFactory.registerSingleton("localCacheManager", localCacheManager());
    }
}

ImportAware.setImportMetadata 메소드에서 @EnableLocalCache 여부를 파악하고 다른 bean이 생성되기전 BeanFactoryPostProcessor.postProcessBeanFactory 메소드를 통해 localCacheManager를 주입하여 다른 Bean 생성시 주입되는 localCacheManager Bean이 null이 되지 않게하는 처리 부분입니다. 간단하게 테스트용 프로젝트를 만들고 위와 같이 실행시

  1. ImportAware.setImportMetadata
  2. BeanFactoryPostProcessor.postProcessBeanFactory

위 순서대로 동작함을 확인하고 개발을 진행하였습니다.

모듈 개발을 완료하고 실 서비스에 적용하였는데 POC를 진행한 Spring 5.x 버전에서는 이상이 없었으나 Spring 4.x 버전에서는 ImportAware가 실행되지 않아 localCacheManager Bean을 등록하지 못하는 이슈가 발생하였습니다. (사실 이슈가 처음 발생했을때는 버전 문제라는 것 조차 인식하지 못한 상태이긴 합니다.)

Spring Context refresh 주요 단계

// AbstractApplicationContext.java
// Allows post-processing of the bean factory in context subclasses.
postProcessBeanFactory(beanFactory);

// Invoke factory processors registered as beans in the context.
invokeBeanFactoryPostProcessors(beanFactory);

// Register bean processors that intercept bean creation.
registerBeanPostProcessors(beanFactory);

// Initialize message source for this context.
initMessageSource();

// Initialize event multicaster for this context.
initApplicationEventMulticaster();

// Initialize other special beans in specific context subclasses.
onRefresh();

// Check for listener beans and register them.
registerListeners();

// Instantiate all remaining (non-lazy-init) singletons.
finishBeanFactoryInitialization(beanFactory);

// Last step: publish corresponding event.
finishRefresh();

Spring 기동시 context를 초기화 하는 과정의 일부입니다. BeanFactoryPostProcessor 구현시 두번째 단계인 invokeBeanFactoryPostProcessors메소드를 실행하게 되는데 이 과정에서 bean 생성에 필요한 BeanDefinition을 추가하거나 BeanFactory 생성 후 작업을 진행하게 됩니다.

Spring 4.x

invokeBeanFactoryPostProcessors 메소드 실행과정에서 bean이 Singleton으로 생성되며 beanFactory에 저장되게 됩니다. PriorityOrdered를 구현하거나 @Order어노테이션이 추가되 있지 않다면 위 코드에서 beanFactory.getBean시 생성되어 beanFactory에 저장되게 됩니다.

// PostProcessorRegistrationDelegate.java
// Finally, invoke all other BeanFactoryPostProcessors.
List<BeanFactoryPostProcessor> nonOrderedPostProcessors = new ArrayList<BeanFactoryPostProcessor>();
for (String postProcessorName : nonOrderedPostProcessorNames) {
   nonOrderedPostProcessors.add(beanFactory.getBean(postProcessorName, BeanFactoryPostProcessor.class));
}
invokeBeanFactoryPostProcessors(nonOrderedPostProcessors, beanFactory);

Bean 생성이후 BeanFactoryPostProcessor.postProcessBeanFactory 메소드를 실행한 후 invokeBeanFactoryPostProcessors 메소드 실행과정이 끝나며 이 때 ImportAware.setImportMetadata는 실행되지 않습니다. 이 후 ImportAware.setImportMetadata 메소드가 실행되는 시점은 finishBeanFactoryInitialization 메소드에서 BeanDefinition중 생성되지 않은 bean에 대해 생성하면서 실행되게 되는데 이 때 BeanFactoryPostProcessor를 구현한 구현체는 이미 singleton으로 생성되었기 때문에 더 이상 실행되지 않고 끝나게 됩니다.

Spring 5.x

5.x 버전의 경우 똑같이 invokeBeanFactoryPostProcessors메소드 실행과정을 거치나 BeanDefinitionRegistryPostProcessor중 ConfigurationClassPostProcessor의 postProcessBeanFactory 동작시 차이가 생깁니다.

// PostProcessorRegistrationDelegate.java
invokeBeanFactoryPostProcessors(registryProcessors, beanFactory);
invokeBeanFactoryPostProcessors(regularPostProcessors, beanFactory);

BeanFactoryPostProcessor만 구현한 구현체를 처리하기전 BeanDefinitionRegistryPostProcessor를 구현한 구현체를 먼저 처리하는 과정이 있는데 Spring에서 기본적으로 실행하는 구현체로 ConfigurationClassPostProcessor가 존재합니다.

//ConfigurationClassPostProcessor.java
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
   int factoryId = System.identityHashCode(beanFactory);
   if (this.factoriesPostProcessed.contains(factoryId)) {
      throw new IllegalStateException(
            "postProcessBeanFactory already called on this post-processor against " + beanFactory);
   }
   this.factoriesPostProcessed.add(factoryId);
   if (!this.registriesPostProcessed.contains(factoryId)) {
      // BeanDefinitionRegistryPostProcessor hook apparently not supported...
      // Simply call processConfigurationClasses lazily at this point then.
      processConfigBeanDefinitions((BeanDefinitionRegistry) beanFactory);
   }

   enhanceConfigurationClasses(beanFactory);
   beanFactory.addBeanPostProcessor(new ImportAwareBeanPostProcessor(beanFactory));
}

위 구현체의 먼저 실행하는 postProcessBeanFactory 메소드를 보면 마지막줄에 beanFactory에 ImportAwareBeanPostProcessor를 추가하는 부분이 있습니다. 이로 인해 bean을 생성할때 ImportAwareBeanPostProcessor.postProcessBeforeInitialization이 실행되면서 ImportAware.setImportMetadata 메소드가 먼저 실행하게 되고 그 이후 BeanFactoryPostProcessor.postProcessBeanFactory 실행되게 됩니다. 참고로 ConfigurationClassPostProcessor는 PriorityOrdered를 구현한 구현체로 실행순서는 최하위 입니다.

@Override
public int getOrder() {
   return Ordered.LOWEST_PRECEDENCE;  // within PriorityOrdered
}

그래서 BeanFactoryPostProcessor가 아닌 BeanDefinitionRegistryPostProcessor를 구현한 구현체와 함께 ImportAware를 사용할 경우 5.x 버전에서도 똑같이 동작하지 않게 됩니다.

Conclusion

  • BeanFactoryPostProcessor는 bean을 생성하기 전 BeanFactory 초기화 이후 실행되며 ImportAware는 bean이 생성되는 시점에 실행되므로 일반적으로 실행시점은 ImportAware가 더 늦게 실행됩니다.
  • bean생성 이전 (또는 BeanFactoryPostProcessor실행 이전)에 로직을 실행하고 싶을 경우 ImportBeanDefinitaionRegistrar를 이용할 수 있습니다.
public class LocalCacheConfig implements ImportBeanDefinitionRegistrar {
    @Override
    public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
        if (!importingClassMetadata.hasAnnotation(EnableLocalCache.class.getName())) {
            return;
        }

        try {
            Map<String, Object> metaData = importingClassMetadata.getAnnotationAttributes(EnableLocalCache.class.getName());
……
        } catch (Exception e) {
            log.error("localcache initialize error : {}", e.getMessage());
        }
    }
}

ConfigurationClassPostProcessor가 postProcessBeanDefinitionRegistry를 실행하면서 ImportBeanDefinitaionRegistrar구현체를 모두 실행해줍니다.

  • POC진행은 꼭 같은버전에서 하세요 ㅠㅠㅠㅠㅠ