전체 글 (356)

💻 Programming

AWS Elasticsearch ISM을 활용한 인덱스 자동삭제

안녕하세요 오랜만에 포스팅을 하게 되었습니다.

 

한동안 노가다성 작업만 하다보니 딱히 포스팅할 만한 내용이 없었네요.

 

오늘은 AWS Elasticsearch(이하 ES)의 인덱스를 자동으로 삭제하는 방법에 대해서 알려드립니다.

 

저는 AWS ES를 로깅을 위해서 사용하고 있습니다. 로그 검색 속도가 빨라 디버깅시 원인파악을 빠르게 할 수 있어서 좋죠. 특정 트래킹 ID로 특정 프로세스의 로그들을 하나로 묶어주면 해당 프로세스의 로그만 검색이 가능하여 편리합니다.

 

아무튼, 이렇게 로그를 쌓기 위해서 사용하는 ES가 용량이 부족해지면 별다른 에러나 알람을 주지않고 더이상 로그가 쌓이지 않게되는 현상이 있습니다. 운영 환경에서는 그런 일이 없도록 하기 위해서 큐레이터 설정을 하여 사용중입니다. 이 설정은 인프라 전문팀과 논의하여 설정을 했었습니다. 큐레이터를 이용하려면 좀 귀찮은 작업들이 수반됩니다. 그래서 개발환경에서는 큐레이터 설정 없이 수동으로 가끔씩 인덱스를 삭제해주곤 했죠. ES 버전이 올라가면서 이제는 키바나에서 간단히 설정해주면 자동으로 특정 기간이 지난 인덱스를 삭제할 수 있게끔 되었습니다. 참고로 오리지날 ES에서는 이 기능을 ILM (Index Lifecycle Management) 기능이라고 부르는데 AWS에서는 ISM (Index State Management)라고 합니다.

 

우선 제가 사용중인 AWS ES 버전은 7.9 입니다. 동일한 7.9 버전이라도 패치버전에 따라 키바나 메뉴가 조금 상이합니다.

상이한 메뉴는 각각 스샷첨부드리겠습니다.

 

이제부터 우리가 할 것은 30일이 지난 인덱스를 자동으로 삭제하는 설정을 하는 것입니다.

작업은 3스텝으로 진행합니다.

  1. ISM 정책 생성
  2. 기존 인덱스들에 생성한 정책 적용
  3. 신규 인덱스들에 정책 적용

 

우선 키바나에 접속해서 메뉴를 살펴보겠습니다.

구 버전(좌) 메뉴와 최신 버전(우) 메뉴

 좌측은 패치버전이 구버전인 케이스이고, 우측이 최신버전 (게시글 작성일 기준 R20210331) 패치를 적용한 키바나 메뉴입니다.

 

여기서 Index Management 메뉴를 선택하여 들어갑니다.

그러면 또 메뉴가 아래처럼 다르게 나옵니다.

구 버전(좌) 메뉴와 최신 버전(우) 메뉴

좌측 처럼 메뉴가 나올 경우 Index Policies 메뉴를 선택, 우측 처럼 메뉴가 나올 경우 State management policies 메뉴를 선택합니다.

 

그 다음부터는 거의 동일합니다.

구 버전 스샷
신 버전 스샷

제목만 다를 뿐 내용은 모두 동일합니다. 여기서 create policy를 선택합니다.

 

Name policy 에는 원하는 이름을 명명해주시면 됩니다. 저는 30일이 지난 인덱스를 삭제한다는 의미로 delete_old_indexes_30d라고 명명했습니다. 그리고 define policy 에는 아래 json 포멧 데이터를 적어주시면 됩니다.

{
    "policy": {
        "policy_id": "delete_old_indexes_30d",
        "description": "delete old indexes",
        "default_state": "hot",
        "states": [
            {
                "name": "hot",
                "actions": [],
                "transitions": [
                    {
                        "state_name": "delete",
                        "conditions": {
                            "min_index_age": "30d"
                        }
                    }
                ]
            },
            {
                "name": "delete",
                "actions": [
                    {
                        "delete": {}
                    }
                ],
                "transitions": []
            }
        ]
    }
}

지금 우리는 인덱스 상태를 관리하는 설정을 하는 것입니다. 따라서 policy 내에 상태에 대한 정의를 해주고 있습니다.

policy_id는 Name policy에 명명해주었던 이름을 그대로 사용하고, default_state 를 hot 이라는 상태로 설정해주었습니다.

여기서 우리는 두 가지 상태를 정의해서 사용하고 있습니다. hot 과 delete.

인덱스의 기본상태로는 hot을 정의를 해주었고 그 상태의 인덱스들은 transitions에 설정되어있는 조건에 따라 상태를 변경하게 됩니다. 즉 max_index_age가 30d (30일)이 지나면 상태를 delete로 변경(transition)하게 되죠. 그렇게 delete 상태가 된 인덱스들은 delete 상태 정의의 actions에 있는 delete 설정으로 인해 삭제되게 됩니다.

 

이제 ISM 정책 생성을 완료하였습니다.

이렇게 정책을 생성한 뒤에는 기존 인덱스들에 대해서 해당 정책을 적용해주어야 합니다.

정책을 저장한 뒤에 좌측 메뉴에서 Indices 로 들어가보면 현재 인덱스들 목록이 출력됩니다.

인덱스 목록

목록에서 하나 이상의 인덱스를 선택하면 우상단의 Apply policy 버튼이 활성화 됩니다. 버튼을 클릭하면 정책이 반영되고 새로고침을 해보시면 Managed by Policy 값이 No 에서 Yes로 변경됩니다. .kibana 인덱스처럼 잘 모르면 건들지 말아야하는 인덱스들도 여기 목록에 조회가 되기 때문에 전체 선택하여 정책을 적용시킬 때에는 그런 인덱스들이 포함되어있지 않은지 잘 확인하셔야 합니다. 만약 잘못 적용했다면 적용 취소도 가능합니다.

정책을 적용한 인덱스들을 여기서는 Managed Index라고 부릅니다. 따라서 적용을 취소하려고 할 때에는 Managed Indices 또는 Policy managed indices 메뉴로 들어가서 인덱스를 조회하여 처리합니다.

구 버전 화면
최신 버전 화면 (R20210331)

인덱스를 선택하고 Remove policy(삭제) 하거나 Change policy(변경)이 가능합니다.

 

이상으로 AWS ES에서 ISM을 활용하여 인덱스 자동삭제 정책을 생성 및 적용하는 방법을 알아보았습니다.

 

 

 

궁금한 점이나 잘못된 내용이 있다면 언제든지 댓글달아주세요~

 

감사합니다.

💻 Programming

SpringBoot 2 http response utf-8 설정하기

스프링부트 1 을 사용하던 엔터프라이즈 앱을 MSA 적용 작업을 통해 여러 앱으로 분리하면서 스프링부트 2로 업그레이드를 진행했습니다.

이 과정에서 다른 회사와 API 연동하고 있던 부분에서 인코딩 문제가 발생했습니다.

소스 이전을 진행하면서 빼먹은 부분이 있어서였죠.

이슈가 발생한 회사측에서 원인으로 얘기한 것은 응답 헤더의 Content-Type에 utf-8 인코딩 설정이 빠져있다는 것이었습니다.

 

그래서 확인해보니 스프링부트 1 기반 앱에서는 아래와 같은 설정이 있었습니다.

	@Bean
	public HttpMessageConverter<String> responseBodyConverter() {
		return new StringHttpMessageConverter(Charset.forName("UTF-8"));
	}

	@Bean
	public Filter characterEncodingFilter() {
		CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter();
		characterEncodingFilter.setEncoding("UTF-8");
		characterEncodingFilter.setForceEncoding(true);
		return characterEncodingFilter;
	}

그리고 테스트 결과 응답헤더의 Content-Type에 application/json;charset=utf-8 이라고 되어있는 것을 확인할 수 있었습니다.

하지만 신규 앱에서는 charset 정보가 빠져있었습니다.

 

그래서 위 설정을 그대로 스프링부트 2 기반의 신규로 개발한 앱쪽에 넣으려 했더니 앱 기동 시 오류가 발생했습니다.

***************************
APPLICATION FAILED TO START
***************************

Description:

The bean 'characterEncodingFilter', defined in class path resource [org/springframework/boot/autoconfigure/web/servlet/HttpEncodingAutoConfiguration.class], could not be registered. A bean with that name has already been defined in com.yanolja.affiliate.out.api.AffiliateOutApiApplication and overriding is disabled.

Action:

Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true

이미 동일한 이름의 빈이 존재하니 오버라이드 할 수 있도록 설정을 변경하라는 얘기였죠.

근데 생각해보니 굳이 이렇게 해야하나 라는 생각이 들어 좀 찾아보니,

자바 소스에 저렇게 추가하지 않고 스프링 설정 만으로도 헤더의 인코딩 설정은 가능했습니다.

바로 server.servlet.encoding.charset 설정을 추가해주면 되었습니다. 

하위 버전에서는 이 설정이 spring.http.encoding.charset 으로 사용되었었습니다.

 

자바 소스에서 characterEncodingFilter 빈 생성로직을 제거하고, application.yml 파일에 아래와 같이 설정을 추가합니다.

server:
  servlet:
    encoding:
      charset: UTF-8

 

그리고 앱을 재부팅 해주면 응답헤더의 Content-Type 값이 application/json;charset=utf-8 으로 변경된 것을 확인할 수 있습니다.

만약 값이 바뀌지 않는다면 server.servlet.encoding.force-response 또는 server.servlet.encoding.force 설정값을 true로 설정해서 다시 해보시기 바랍니다.

 

 

참고문서: docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#server.servlet.encoding.charset 

자바에서 비동기 호출하기 (AsyncRestTemplate, ListenableFuture)

안녕하세요, 최근에 관리자용 앱을 개선하다가 비동기 처리에 대해서 공유하고 싶은 것이 있어 포스팅을 작성합니다.

제가 관리중인 관리자용 앱은 Spring Framework 4, Java 8, Bootstrap 3 기반으로 작성되어있습니다.

오래된 라이브러리들을 업그레이드 하고 싶지만 메인 업무가 아니다보니 짬날때 조금씩 업그레이드 해주고 있는데 부트스트랩이나 스프링 버전을 올리기엔 작업량이 많아보여서 안건드리고 있네요.

 

아무튼 자바에서 비동기로 호출을 하는 방법은 크게 두 가지가 있는 것 같습니다.

 

첫 번째는 callee 메서드 쪽에서 @Async 를 붙여서 비동기로 동작하게 하는 방법이 있고요

이 방법은 callee가 외부 팀이거나 외부 회사일 경우 불가능하거나 일이 빨리 진행하기 어려울 수 있습니다.

굳이 특별한 이유가 있지 않은이상 callee쪽에 요청을 할 필요는 전혀 없습니다.

하지만 callee가 내부에 있다면 아키텍처 구조상 이 방법을 선택할 수도 있습니다.

 

두 번째는 caller가 호출방식을 직접 비동기로 호출하는 방법입니다.

callee가 어떻게 처리하던지 caller는 신경쓸 필요가 없죠. 

단지 응답만 잘 받아서 처리해주면 됩니다.

 

오늘 소개해 드릴 내용은 두 번째 방식인 caller 입장에서 비동기로 호출하는 방법입니다.

스프링을 사용하신다면 API 호출하실 때 RestTemplate을 사용하실 겁니다.

 

근데 스프링에서는 비동기 호출을 위해서 이미 AsyncRestTemplate 을 제공하고 있습니다.

AsyncRestTemplate 을 이용해서 API를 호출하는 방법은 RestTemplate과 다를게 없죠.

그리고 당연히도 응답을 기다리지 않고 다음 프로세스를 처리하게 됩니다.

그리고 그 응답은 ListenableFuture<ResponseEntity<T>> 타입으로 받습니다.

간단한 예제를 한번 보겠습니다.

 

    // Autowired로 주입된 상태라 가정합니다.
    private final AsyncRestTemplate asyncRestTemplate;
	
    public void test() {
        URI uri = UriComponentsBuilder.fromUriString("http://localhost")
                .path("/test")
                .build()
                .toUri();

        HttpHeaders headers = new HttpHeaders();
        headers.add(HttpHeaders.CONTENT_TYPE, "application/json;charset=UTF-8");

        HttpEntity<String> requestEntity = new HttpEntity<>(headers);
        HttpMethod httpMethod = HttpMethod.POST;

        ListenableFuture<ResponseEntity<Void>> res = 
        	asyncRestTemplate.exchange(uri, httpMethod, requestEntity, Void.class);
        log.info("호출 성공!!");
    }

위 코드는 http://localhost/test 를 POST 방식으로 호출하고 있습니다. 헤더에 application/json;charset=UTF-8 정보도 넣어주고 있고요 응답메시지에 대해서는 Void로 처리하고 있습니다. 어떤 메시지를 응답으로 주던지 신경안쓴다는 거겠죠.

그리고 호출에 대한 결과를 ListenableFuture<ResponseEntity<Void>> res 에 받게 됩니다.

하지만 응답을 받기 전에 호출하자마자 호출 성공!! 로그를 출력하게 됩니다.

 

그러면 그렇게 받은 응답의 결과에 따라 정상과 실패를 구분해서 예외처리를 해줘야 할텐데 그건 어떻게 할까요?

res 에 callback 함수를 넣어주면 됩니다.

콜백을 추가한 예제를 보겠습니다.

 

    // Autowired로 주입된 상태라 가정합니다.
    private final AsyncRestTemplate asyncRestTemplate;

    public void test() {
        URI uri = UriComponentsBuilder.fromUriString("http://localhost")
                .path("/test")
                .build()
                .toUri();

        HttpHeaders headers = new HttpHeaders();
        headers.add(HttpHeaders.CONTENT_TYPE, "application/json;charset=UTF-8");

        HttpEntity<String> requestEntity = new HttpEntity<>(headers);
        HttpMethod httpMethod = HttpMethod.POST;

        ListenableFuture<ResponseEntity<Void>> res = asyncRestTemplate.exchange(uri, httpMethod, requestEntity, Void.class);
        res.addCallback(new ListenableFutureCallback<ResponseEntity<Void>>() {
            @Override
            public void onFailure(Throwable th) {
                log.error("실패");
            }

            @Override
            public void onSuccess(ResponseEntity<Void> voidResponseEntity) {
                if (HttpUtil.isNot2xxSuccessful(voidResponseEntity)) {
                    log.error("실패, 응답코드:{}", voidResponseEntity.getStatusCodeValue();
                } else {
                    log.info("성공");
                    // TODO: 추가 작업
                }
            }
        });

    }

여기서는 annonymous ListenableFutureCallback 클래스를 만들어서 addCallback 메서드에 넘겨주었습니다.

onFailure 와 onSuccess 메서드를 구현해주면 되는데요 이때 주의할 점이 하나 있습니다.

onSuccess 메서드가 호출되는 케이스가 2xx 성공인 케이스만이 아니라는 점입니다.

4xx 케이스도 onSuccess 메서드를 호출하게 됩니다.

onFailure는 SocketTimeout과 같은 예외가 발생하면 호출됩니다.

따라서 실질적으로 우리가 원하는 성공 은 response entity에서 응답코드로 확인해줘야 합니다.

 

참고로 위 코드에서 HttpUtil.isNot2xxSuccessful(voidResponseEntity) 메서드는 아래처럼 코딩되어 있습니다.

public class HttpUtil {

    public static boolean is2xxSuccessful(ResponseEntity responseEntity) {
        return HttpStatus.Series.valueOf(responseEntity.getStatusCodeValue())
        	.equals(HttpStatus.Series.SUCCESSFUL);
    }

    public static boolean isNot2xxSuccessful(ResponseEntity responseEntity) {
        return !is2xxSuccessful(responseEntity);
    }
}

ListenableFuture<ResponseEntity<T>> 에서 제공하는 addCallback 메서드 시그니처는 아래 두 가지가 있습니다.

 

void addCallback(ListenableFutureCallback<? super T> var1);
void addCallback(SuccessCallback<? super T> var1, FailureCallback var2);

 

위 예제에서는 첫 번째 addCallback 메서드를 사용했습니다만, 만약 SuccessCallback와 FailureCallback을 나눠서 구현하여 사용하고 싶다면 각각 구현해서 addCallback 메서드에 파라미터로 전달해주셔도 됩니다.

 

추가로 AsyncRestTemplate 을 스프링에서 제공해주는 기본 클래스를 사용해도 되지만 타임아웃 등 설정을 바꿔서 사용해야한다면 아래 코드를 참고하시면 도움이 되실겁니다.

    @Bean
    public AsyncRestTemplate asyncRestTemplate2() {
        SimpleAsyncTaskExecutor simpleAsyncTaskExecutor = new SimpleAsyncTaskExecutor();
        simpleAsyncTaskExecutor.setTaskDecorator(new MdcTaskDecorator());

        SimpleClientHttpRequestFactory simpleClientHttpRequestFactory = new SimpleClientHttpRequestFactory();
        simpleClientHttpRequestFactory.setConnectTimeout(30_000);    // 밀리초 단위
        simpleClientHttpRequestFactory.setReadTimeout(30_000);       // 밀리초 단위
        simpleClientHttpRequestFactory.setTaskExecutor(simpleAsyncTaskExecutor);

        AsyncRestTemplate asyncRestTemplate = new AsyncRestTemplate(simpleClientHttpRequestFactory);

        return asyncRestTemplate;
    }

    static class MdcTaskDecorator implements TaskDecorator {

        @Override
        public Runnable decorate(Runnable runnable) {
            // Web thread context
            Map<String, String> contextMap = MDC.getCopyOfContextMap();
            return () -> {
                try {
                    // @Async thread context
                    MDC.setContextMap(contextMap);
                    runnable.run();
                } finally {
                    MDC.clear();
                }
            };
        }
    }

 

이상으로 AsyncRestTemplate을 이용하여 자바에서 비동기 호출하기에 대해서 알아봤습니다.

 

하지만 AsyncRestTemplate은 Spring 5로 넘어가면서 deprecated 되었고, WebClient를 사용하도록 권장하고 있습니다.

Deprecated as of Spring 5.0, in favor of org.springframework.web.reactive.function.client.WebClient

WebClient에 대해서는 다음 포스팅에서 다루도록 하겠습니다.

 

오늘도 즐프행프하세요~

 

 

💻 Programming/Java

SpringBoot에서 ThreadPoolTaskExecutor 설정하기

이번 포스팅에서는 SpringBoot에서 ThreadPoolTaskExecutor를 어떻게 간단하게 설정하여 사용할 수 있는지 알려드립니다.

SpringBoot 프로젝트는 이미 구성되어 있다는 전제하에 Java11, SpringBoot 2.4.2 기준으로 작성했습니다.

참고로 ThreadPoolTaskExecutor의 설정 및 구체적인 사용법에 대해서는 이전에 포스팅한 문서를 참고하시기 바랍니다.

 

ThreadPoolTaskExecutor의 사용법

 

ThreadPoolTaskExecutor를 이용하여 비동기 처리하기(멀티쓰레드)

async(비동기) 처리를 위한 ThreadPoolTaskExecutor ThreadPoolTaskExecutor를 이용하여 비동기처리하는 방법을 알아보겠습니다. ThreadPoolTaskExecutor는 스프링에서 제공해주는 클래스로 org.springframework.s..

keichee.tistory.com

ThreadPoolTaskExecutor Bean 등록하기

우선 ThreadPoolTaskExecutor를 빈으로 등록하도록 하겠습니다. 

TaskExecutorConfig 클래스를 만들고 @Configuration 어노테이션을 추가하여 설정을 위한 클래스를 하나 만들었습니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
public class TaskExecutorConfig {

	@Bean(name = "executor")
	public ThreadPoolTaskExecutor executor() {
		ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
		executor.setThreadNamePrefix("my-");
		return executor;
	}
}

그리고 executor() 메서드를 하나 만들어서 ThreadPoolTaskExecutor를 생성하여 반환해주도록 하고 @Bean으로 등록하면서 name 속성을 지정해주었습니다. 참고로 name 속성의 값은 메서드명과 달라고 무관합니다. 또한, 해당 executor를 이용하여 쓰레드를 실행시켰을 때 내가 만든 ThreadPoolTaskExecutor가 사용되는 것인지 확인할 수 있도록 쓰레드명prefix를 "my-"로 세팅해주었습니다. 

이전 포스팅을 읽어보셨거나 또는 ThreadPoolTaskExecutor를 직업 new 키워드로 생성해서 사용해보신 분은 눈치채셨을 수도 있을텐데요, 여기서 initialize()를 하지 않았습니다. 공식문서에는 initialize 메서드를 호출하는 것으로 나와있지만 굳이 하지 않아도 빈으로 등록될 때 initialize를 하더군요.

 

@Autowired 로 ThreadPoolTaskExecutor 사용하기

아래는 빈으로 등록한 executor를 서비스 레이어에서 autowired하여 사용하는 예제입니다.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
public class TestService {

  @Autowired
  @Qualifier("executor")
  private ThreadPoolTaskExecutor executor;

  public void executeThreads() {
      log.info("executing threads....");
      Runnable r = () -> {
          try {
              log.info(Thread.currentThread().getName() + ", Now sleeping 10 seconds...");
              Thread.sleep(10000);
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
      };

      for (int i = 0; i < 10; i++) {
          executor.execute(r);
      }
  }
}

TestService라는 서비스 클래스에서 멤버변수 executor에 위에서 빈으로 등록한 객체를 쓰도록 @Qualifier로 executor 이름을 명시해주었습니다.

위 코드를 실행해보면 쓰레드 명이 my-1 로 출력되는 것을 확인하실 수 있습니다. corePoolSize를 default 값인 1 로 사용하여 싱글 쓰레드로 실행되기 때문에 my-2, my-3은 보이지 않습니다. corePoolSize 를 2 이상으로 세팅해주면 my-2, my-3 등의 쓰레드도 확인가능합니다.

2021-01-26 18:16:23.147  INFO 16390 --- [           my-1] com.keichee.test.service.TestService     : my-1, Now sleeping 10 seconds...
2021-01-26 18:16:33.150  INFO 16390 --- [           my-1] com.keichee.test.service.TestService     : my-1, Now sleeping 10 seconds...
2021-01-26 18:16:43.152  INFO 16390 --- [           my-1] com.keichee.test.service.TestService     : my-1, Now sleeping 10 seconds...
2021-01-26 18:16:53.157  INFO 16390 --- [           my-1] com.keichee.test.service.TestService     : my-1, Now sleeping 10 seconds...

 

@Async로 ThreadPoolTaskExecutor 사용하기

@Async 어노테이션을 이용하여 위에서 빈으로 등록한 ThreadPoolTaskExecutor를 사용하려면 @EnableAsync 도 추가해주어야 합니다.

따라서 TaskExecutorConfig 클래스에 @EnableAsync 어노테이션을 추가해줍니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@EnableAsync
@Configuration
public class TaskExecutorConfig {

	@Bean(name = "executor")
	public ThreadPoolTaskExecutor executor() {
		ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
		executor.setThreadNamePrefix("my-");
		return executor;
	}
}

 

그리고 서비스 레이어의 메서드를 하나 추가하겠습니다.

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
public class TestService {

    @Async("executor")
    public void task() {
        try {
            log.info(Thread.currentThread().getName() + ", Now sleeping 10 seconds...");
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }
}

이제 controller 쪽에서 task() 메서드를 loop 돌면서 호출해보면 @Autowired 를 이용한 방법과 동일하게 로그가 출력되는 것을 확인하실 수 있을 겁니다.

 

이상으로 SpringBoot에서 ThreadPoolTaskExecutor 설정하여 사용하는 방법에 대해서 알아보았습니다.

간략하게 다시 정리해보자면

 

1. @Configuration 으로 등록한 클래스에 executor @Bean 추가 (@Async 를 이용할 경우 @EnableAsync 도 추가)

2. @Autowired @Qualifier 로 주입하여 사용하거나 또는 메서드 레벨에 @Async 를 붙여 사용

 

이렇게 정리할 수 있겠네요.

 

그럼 오늘도 행복한 코딩하세요~

프로그래밍을 하다보면 DB 조회 시에 정렬해서 조회해오는 방법도 있고, 애플리케이션 레벨에서 정렬해야 할 때도 있습니다.

오늘은 애플리케이션에서 배열, 리스트, 맵, 셋에 대하여 각각 오름차순, 내림차순 정렬하는 방법에 대해서 알아보도록 하겠습니다.

1. 배열 정렬하기

우선 아래와 같이 int 배열이 있다고 가정해보겠습니다.

int[] arr = new int[] { 5, 1, 89, 255, 7, 88, 200, 123, 66 };

배열의 정렬은 java.util.Arrays 클래스에 있는 sort() 메서드를 이용하여 정렬할 수 있습니다.

이 메서드를 이용하면 모든 primitive 타입에 대한 정렬이 가능합니다. 물론 원시타입이 아닌 Object[] 도 정렬이 가능합니다.

int[] arr = new int[] { 5, 1, 89, 255, 7, 88, 200, 123, 66 };
Arrays.sort(arr);
System.out.println(Arrays.toString(arr));

Arrays.sort(배열) 호출을 하면 인자로 넘겨준 배열을 정렬해줍니다. 

그리고 출력을 해보면 아래와 같이 잘 정렬이 된 것을 확인할 수 있습니다.

[1, 5, 7, 66, 88, 89, 123, 200, 255]

그럼 이 메서드가 내부적으로 사용하는 정렬 알고리즘은 어떤 것일까요?

Java8 문서에는 듀얼 피봇 퀵소트(Dual-Pivot Quick Sort) 알고리즘을 사용한다고 나와있습니다.

듀얼 피봇 퀵소트는 모든 데이터 셋에 대해서 O(n log(n)) 의 퍼포먼스를 보이고 일반적으로 기존의 One-Pivot 퀵소트 정렬보다 빠르다고 합니다.

Java7 문서, Java14 문서에도 동일한 알고리즘을 사용하는 것으로 나와있습니다.

1.1 부분정렬

위 예제는 배열의 전체 요소를 정렬하는 반면 배열의 일부분만 정렬을 할 수도 있습니다.

일부분만 정렬할 때는 Arrays.sort(int[] a, int fromIndex, int toIndex) 메서드를 이용하시면 됩니다.

int[] arr = new int[] { 5, 1, 89, 255, 7, 88, 200, 123, 66 };
Arrays.sort(arr, 0, 4);
System.out.println(Arrays.toString(arr));

출력을 해보면 아래와 같이 0번 인덱스부터 3번 인덱스까지(4는 exclude입니다) 정렬이 된 것을 확인할 수 있습니다.

[1, 5, 89, 255, 7, 88, 200, 123, 66]

 

1.2 병렬 정렬 (ParallelSort)

Arrays.sort()가 싱글쓰레드로 돌아간다면 Arrays.parallelSort()는 멀티쓰레드로 동작을 합니다. parallelSort는 배열을 sub-array로 나눈 뒤 각각을 별도의 쓰레드에서 처리를 하고 각 쓰레드에서는 Arrays.sort()를 실행하여 정렬을 하게 됩니다.

Arrays.parallelSort 의 결과는 당연히 Arrays.sort의 결과와 동일합니다. 멀티쓰레딩을 이용했느냐의 차이만 존재할 뿐입니다.

참고로 Arrays.parallelSort 도 부분정렬이 가능합니다. Arrays.parallelSort (int [] a, int fromIndex, int toIndex);

2. List 정렬하기

리스트를 정렬할 때는 java.util.Collections.sort(List<T> list) 메서드를 이용하는 방법이 있습니다.

int[] arr = new int[] { 5, 1, 89, 255, 7, 88, 200, 123, 66 };
List<Integer> toSortList = Ints.asList(arr);    // int[]을 Integer[]로 변환 (구글 guava 라이브러리 이용) 
Collections.sort(toSortList);

출력 결과는 Arrays.sort()를 사용한 것과 동일합니다.

기본적으로 오름차순으로 정렬을 합니다. 만약 내림차순으로 정렬하고 싶다면 comparator를 인자로 전달받는 sort​(List list, Comparator c) 메서드를 이용할 수 있습니다.

그리고 너무나도 기본적인 얘기지만 정렬을 하려면 두 요소(element)를 비교할 수 있어야 하기 때문에 리스트 내의 모든 요소들이 Comparable 인터페이스를 구현하고 있어야 합니다.

Collections.sort(List<T> list) 는 merge sort 알고리즘을 이용하여 정렬을 합니다. List.sort(Comparator c)에서 제공하는 기본 알고리즘과는 조금 다르다고 합니다만, 더 안좋게 변형된 알고리즘을 사용하는 건 아니겠죠. 그리고 기본적으로 List.sort()에서 사용하는 merge sort는 어느정도 부분 정렬이 되어있는 데이터에 대해서는 n log(n) 보다 훨씬 적은 횟수만 비교하며(거의 정렬이 되어있는 상태라면 거의 n번의 비교만으로도 정렬 가능), 랜덤하게 섞여있는 데이터 셋에 대해서는 전통적인 merge sort의 퍼포먼스를 낸다고 합니다. Java14 Collections.sort() 참고하시면 좋을 것 같습니다.

 

만약 역순(내림차순)으로 정렬을 하고 싶으면 Collections.sort(List list, Comparator c) 메서드를 이용하면 됩니다.

// 내림 차순 정렬
Collections.sort(list, Comparator.reverseOrder());

자바에서는 내림차순 정렬을 위해 Comparator.reverseOrder() 메서드를 통해 기본 comparator를 제공해주고 있습니다. 

3. Set 정렬하기

Set은 기본적으로 순서를 보장하지 않습니다. 하지만 LinkedHashSet은 순서를 보장하죠. 따라서 Set을 정렬한다는 것은 이 LinkedHashSet 처럼 순서를 보장하는 set 구현체에 대한 얘기입니다.

Set을 정렬할 때도 Collections.sort()를 이용합니다. 하지만 해당 메서드는 List만을 인자로 전달받고 있기 때문에 Set을 정렬하려면 List로 변경하는 작업이 필요합니다.

Set<Integer> set = new LinkedHashSet<>(Ints.asList(arr));
System.out.println(set);    // 정렬하기 전 출력

List<Integer> list = new ArrayList<>(set);
Collections.sort(list);
set = new LinkedHashSet<>(list);

System.out.println(set);    // 정렬 후 출력

위에서 보듯이 list로 변환 후 다시 set으로 변환하여 정렬을 할 수 있습니다.

4. Map 정렬하기

Map은 아시다시피 key와 value 쌍으로 이루어진 자료구조입니다. 따라서 Map을 정렬할 때는 key를 기준으로 정렬하는 방법과 value를 기준으로 정렬하는 방법, 두 가지 방법이 있습니다. 또한, Map도 Set과 마찬가지로 HashMap 같은 경우는 순서와는 무관한 자료구조이기 때문에 정렬을 위해서는 LinkedHashMap을 이용해야 합니다.

 

우선 key를 기준으로 정렬하는 방법에 대해서 알아보겠습니다. Map을 정렬할 때도 List, Set과 마찬가지로 Collections.sort(List list) 메서드를 이용할 수 있습니다. 따라서 list로 변환을 해주어야 하죠. 그리고 정렬 후에는 순서의 보장을 위해 list를 LinkedHashMap으로 변환해주어야 합니다. 또한 List.sort(Comparator c)를 이용할 수도 있습니다. 예제에서 두 가지 방법에 대해 모두 확인해보도록 하겠습니다.

// 기본 데이터 셋
HashMap<Integer, String> map = new HashMap<>();
map.put(5, "Orange");
map.put(8, "Apple");
map.put(2, "WaterMelon");
map.put(13, "Pear");
map.put(9, "Grape");
map.put(4, "Banana");


// key, value 를 모두 가져오기 위해서 entrySet()을 리스트로 변환
List<Map.Entry<Integer, String>> entries = new ArrayList<>(map.entrySet());

// 1. List.sort 를 이용하는 방법
entries.sort(Comparator.comparing(Map.Entry::getKey));

// 2. Collections.sort 를 이용하는 방법
Collections.sort(entries, Comparator.comparing(Map.Entry::getKey));

// 정렬된 데이터를 LinkedHashMap에 저장
Map<Integer, String> sortedByKey = new LinkedHashMap<>();
for (Map.Entry<Integer, String> entry : entries) {
	sortedByKey.put(entry.getKey(), entry.getValue());
}

// 정렬된 내용 출력
System.out.println(sortedByKey);

정렬할 때 List.sort(Comparator c) 와 Collections.sort(List list)를 이용하는 방법을 모두 소개해드렸습니다.

여기서 사용한 Comparator.comparing() 메서드는 comparator를 반환하는 메서드입니다. Comparator.comparing(Map.Entry::getKey) 는 Map의 key 값을 비교하는 comparator를 만들어주죠.

위 코드에서 출력되는 내용은 아래왁 같습니다.

{2=WaterMelon, 4=Banana, 5=Orange, 8=Apple, 9=Grape, 13=Pear}

key 값 순서대로 정렬이 된 것을 확인할 수 있습니다.

 

그럼 이번에는 위 코드를 조금 수정하여 value를 기준으로 정렬을 해보도록 하겠습니다.

// 1. List.sort 를 이용하는 방법
entries.sort(Comparator.comparing(Map.Entry::getValue));

// 2. Collections.sort 를 이용하는 방법
Collections.sort(entries, Comparator.comparing(Map.Entry::getValue));

Comparator.comparing() 메서드의 인자로 key가 아닌 value 값을 넘겨주기만 하면 됩니다.

수정된 코드 실행 결과는 다음과 같습니다.

{8=Apple, 4=Banana, 9=Grape, 5=Orange, 13=Pear, 2=WaterMelon}

value값으로 넣어주었던 과일명 순으로 정렬이 되어 출력된 것을 확인할 수 있습니다.

5. 커스텀 객체 정렬하기

이번에는 커스텀 객체를 정렬하는 방법에 대해 알아보겠습니다. 사실 커스텀 객체를 정렬하는 방법이라고 따로 있는건 아닙니다. 이미 위에서 설명드린 내용중에 comparator를 이용하는 방법을 이용해야 합니다.

일단 쇼핑몰에 판매중인 상품에 대한 객체 정의를 해보겠습니다.

@Getter
@Setter
@AllArgsConstructor
class Product {
    String name;        // 상품 명
    int price;          // 상품 가격
    float sellerRating; // 판매자 평점
    
    @Override
    public String toString() {
    	return "{" + this.name + ", " + price + ", " + sellerRating + "}";
    }
}

이제 이 객체를 리스트로 만들어 가격, 판매자 평점을 기준으로 각각 오름차순, 내림차순으로 정렬을 해보도록 하겠습니다.

// 기본 데이터 셋
List<Product> data = new ArrayList<>(Arrays.asList(
    new Product("Apple", 100, 4.3f),
    new Product("Apple", 200, 3.3f),
    new Product("Apple", 150, 4.8f)));

// 1. List.sort 를 이용하여 가격 오름차순 정렬
data.sort(Comparator.comparing(Product::getPrice));
System.out.println("\n1. List.sort 를 이용하여 가격 오름차순 정렬");
System.out.println(data);

// 2. List.sort 를 이용하여 가격 내림차순 정렬
data.sort(Comparator.comparing(Product::getPrice, Comparator.reverseOrder()));
System.out.println("\n2. List.sort 를 이용하여 가격 내림차순 정렬");
System.out.println(data);

// 3. Collections.sort 를 이용하여 판매자 편점 오름차순 정렬
Collections.sort(data, Comparator.comparing(Product::getSellerRating));
System.out.println("\n3. Collections.sort 를 이용하여 판매자 편점 오름차순 정렬");
System.out.println(data);

// 4. Collections.sort 를 이용하여 판매자 편점 내림차순 정렬
Collections.sort(data, Comparator.comparing(Product::getSellerRating, Comparator.reverseOrder()));
System.out.println("\n4. Collections.sort 를 이용하여 판매자 편점 내림차순 정렬");
System.out.println(data);

Product 리스트를 특정 필드를 기준으로 오름차순 내림차순으로 정렬하는 코드는 List.sort를 사용해도, Collections.sort를 사용해도 한 줄이면 됩니다. 매우 간단합니다. 내림차순으로 정렬을 하고 있을 때는 Comparator.comparing() 메서드에 Comparator.reverseOrder() 인자를 추가해주면 됩니다. 즉, Comparator.comparing(정렬기준, 정렬순서) 이렇게 사용하시면 되는거죠.

 

자, 이제 출력 결과를 확인해보면 다음과 같습니다.

1. List.sort 를 이용하여 가격 오름차순 정렬
[{Apple, 100, 4.3}, {Apple, 150, 4.8}, {Apple, 200, 3.3}]

2. List.sort 를 이용하여 가격 내림차순 정렬
[{Apple, 200, 3.3}, {Apple, 150, 4.8}, {Apple, 100, 4.3}]

3. Collections.sort 를 이용하여 판매자 편점 오름차순 정렬
[{Apple, 200, 3.3}, {Apple, 100, 4.3}, {Apple, 150, 4.8}]

4. Collections.sort 를 이용하여 판매자 편점 내림차순 정렬
[{Apple, 150, 4.8}, {Apple, 100, 4.3}, {Apple, 200, 3.3}]

Summary

여기까지 자바에서 정렬하는 방법에 대해서 알아보았습니다.

간략히 요약을 하면 다음과 같습니다.

 

배열의 정렬 -> Arrays.sort() 이용

List 정렬 -> Collections.sort(), List.sort() 이용

Set, Map 정렬 -> List로 변환 후 Collections.sort(), List.sort() 이용

기본적으로 오름차순 정렬

내림차순 정렬은 Comparator.reverseOrder() 이용

 

문장 내에서 사용된 distinct 문자 개수 구하기

한 문장 안에서 사용된 distinct 문자의 개수를 구해야 한다면 어떻게 할 수 있을까요?

stream이 나오기 전이었다면 그저 String을 캐릭터 배열로 만들어서 loop를 돌려 set에 넣은 뒤 set의 사이즈를 구하면 됐겠죠.

아래처럼 말이죠.

    String sentence = "Computer users take it for granted that their systems can do more than one thing at a time.";
    
    Set<Character> s = new HashSet<>();
    for (char c : sentence.toCharArray()) {
    	s.add(c);
    }
    
    System.out.println(s.size());

하지만 stream 을 이용하면 한줄로 해결이 가능합니다.

 

Stream을 이용한 distinct 문자 개수 구하기

stream에는 distinct() 메서드가 있으며 이는 해당 스트림내에서 distinct한 것들만 추출해줍니다. 그리고 count()메서드를 이용하여 그 개수를 셀 수 있습니다.

그래서 위에서 set과 for-loop를 사용한 부분을 stream을 이용하여 한줄로 줄이면 아래와 같이 바꿀 수 있습니다.

    String sentence = "Computer users take it for granted that their systems can do more than one thing at a time.";

    long distinctCharsCount = sentence.chars().distinct().count();

    System.out.println(distinctCharsCount);

훨씬 간단하게 구할 수 있습니다.

 

 

 

이번 포스팅에서는 jQuery를 이용하여 사용자가 선택한 라디오 버튼이나 체크박스를 해제하는 방법에 대해서 알아봅니다.

라디오 버튼 해제하기

우선 아래와 같은 화면을 하나 만들어 줍니다.

html 은 아래처럼 작성했습니다.

<div>
    <input type="radio" name="fruit" value="orange"><label>오렌지</label>
    <input type="radio" name="fruit" value="apple"><label>사과</label>
    <input type="radio" name="fruit" value="banana"><label>바나나</label>
    <button onclick="deselect()">선택해제</button>
</div>

여기서 name 값이 중요합니다. 라이오 버튼을 하나의 그룹으로 묶어주는 속성이기 때문이죠.

처음엔 아무런 버튼이 선택이 안되어있습니다. 

 

그리고 스크립트는 아래처럼 작성합니다.

function deselect() {
  // name이 fruit인 라디오 버튼 일괄 해제
  $("input:radio[name='fruit']").prop('checked', false);
}

선택해제 버튼을 클릭하면 deselect 함수를 호출하고 함수 내에서는 radio 버튼타입의 input 중에서 name값이 fruit인 것들의 체크상태를 false로 설정합니다. 즉, fruit name에 속한 모든 체크상태가 해제되게 됩니다.

 

자바스크립트로 라디오 버튼 체크 해제하기

jQuery를 사용하지 않고 자바스크립트로 직접 구현하려면 아래와 같이 작성할 수 있습니다.

function deselect() {
    var fruits = document.getElementsByName("fruit");
    for (var i = 0; i < fruits.length; i++) {
      if (fruits[i].getAttribute('type') === 'radio') {
      		fruits[i].checked = false;
      }
    }
}

 

체크박스 선택 해제하기

만약 라디오 버튼이 아니라 체크박스일 경우 선택자(selector)만 아래와 같이 변경해주면 됩니다.

function deselect() {
  // name이 fruit인 체크박스 일괄 해제
  $("input:checkbox[name='fruit']").prop('checked', false);
}

 

체크박스/라디오버튼 일괄 해제하기

그럼 만약 라디오 버튼과 체크박스를 모두 해제하려면 어떻게 할까요?

selector에서 radio나 checkbox라고 명시한 부분만 빼주면 됩니다.

단, 동일한 name 값으로 묶여있어야 합니다.

function deselect() {
  // name이 fruit인 모든 input 의 체크 상태 일괄 해제
  $("input[name='fruit']").prop('checked', false);
}

이렇게 말이죠.

 

간단하죠?

 

오늘도 즐코하세요~

 

💻 Programming

[5분 코딩] Spring4 + JCache (feat. Ehcache)

자바에서 초간단 캐시 사용하기

 

안녕하세요, 오늘은 캐시 관련 내용을 들고 왔습니다.

회사에서 관리중인 관리자 웹앱에서 특정 기능을 하는 버튼을 광클릭 하시는 전직 프로게이머 같은 분이 계셔서 자꾸 에러가 나길래 개선 방안을 고민하다가 한 번 클릭해서 정상적으로 실행이 되었다면 1분 동안 재처리를 하지 않도록 캐시를 이용해보면 좋겠다는 생각이 들었습니다. 즉, 특정 API를 호출할 때 캐시에 관련 데이터를 저장해놓고 1분 내에 동일한 내용으로 API 요청이 들어올 경우 에러 메시지를 출력하고 해당 기능을 실행하지 않도록 구현하면 되겠다 싶었죠. 그리고 예전에 카카오톡과 연동하여 G사의 매니저들이 사용하는 챗봇을 만든 적이 있는데 이 때 Ehcache 를 사용해본적이 있어서 이번에도 동일하게 Ehcache를 사용하면 되겠다 싶었습니다.

우선 현재 스펙은 백엔드 SpringFramework 4 기반이고 프론트엔드는 부트스트랩을 이용한 구식 앱입니다. (스프링 부트로 전환하고 싶지만 더 중요한 업무들이 많아 생각만 하고 있다는...ㅜㅜ) AWS의 Tomcat 8 플랫폼을 사용하여 서비스 중이죠.

 

Ehcache 에 대해서 오랜만에 구글링을 해보니 Jcache를 지원한다고 하여 표준인 Jcache를 사용할 수 있도록 구현해보았습니다. Jcache는 JSR-107 표준을 따르는 자바에서 제공하는 인터페이스입니다. 이 표준 인터페이스를 구현하고 있는 구현체들에는 Ehcache 뿐 아니라 Hazelcast, Oracle Coherence, Infinispan 등도 있습니다.

 

자바에서 간단하게 캐싱하기 (Ehcache를 이용한 Jcache 사용법)

우선 pom.xml 파일에 아래 두 개의 의존성을 추가합니다.

        <dependency>
            <groupId>org.ehcache</groupId>
            <artifactId>ehcache</artifactId>
            <version>3.9.0</version>
        </dependency>

        <dependency>
            <groupId>javax.cache</groupId>
            <artifactId>cache-api</artifactId>
            <version>1.1.1</version>
        </dependency>

ehcache, jcache 모두 포스팅 시점 최신버전으로 추가했습니다.

 

그리고 CacheConfig 클래스를 만들어서 아래처럼 작성했습니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.cache.Cache;
import javax.cache.CacheManager;
import javax.cache.Caching;
import javax.cache.configuration.MutableConfiguration;
import javax.cache.expiry.CreatedExpiryPolicy;
import javax.cache.expiry.Duration;
import javax.cache.spi.CachingProvider;


@Configuration
public class CacheConfig {

    @Bean
    public Cache<String, String> syncCache() {

        CachingProvider provider = Caching.getCachingProvider();
        CacheManager cacheManager = provider.getCacheManager();
        MutableConfiguration<String, String> configuration =
                new MutableConfiguration<String, String>()
                        .setTypes(String.class, String.class)
                        .setStoreByValue(false)
                        .setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(Duration.ONE_MINUTE));
        return cacheManager.createCache("syncCache", configuration);
    }
}

이 설정 부분이 가장 중요한 부분일텐데요, 특히 캐시만료정책(expiry policy) 설정하는 부분은 잘 확인하시는 것을 권장드립니다.

jcache에서 제공하는 만료정책은 5가지가 있습니다.

 

- AccessExpiryPolicy

- CreatedExpiryPolicy

- EternalExpiryPolicy

- ModifiedExpiryPolicy

- TouchedExpiryPolicy

 

각각 특정 만료정책을 위해 기본적으로 제공하는 것들입니다. 예를들어 EternalExpiryPolicy는 캐싱된 데이터가 만료되지 않는 무기한 저장하는 정책이죠. 자세한 내용은 구글링 해보시면 많은 자료들이 있으니 참고하시기 바랍니다.

 

여기서는 CreatedExpiryPolicy를 사용해서 Duration으로 1분을 주었습니다. 기본적으로 많이 사용될만한 시간들이 static하게 정의되어 있으나 정의되어있지 않은 다른 시간을 사용하고자 할 경우 new Duration(TimeUnit timeUnit, long durationAmount) 을 이용하여 커스텀하게 정의해줄 수도 있습니다.

 

이제 저 캐시를 사용할 CacheService라는 서비스 클래스를 하나 만들겠습니다.

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import javax.cache.Cache;

@Service
@RequiredArgsConstructor
public class CacheService {

    private final Cache<String, String> syncCache;

    public boolean isCached(String key) {
        return syncCache.containsKey(key);
    }

    public void doCache(String key, String value) {
        syncCache.put(key, value);
    }

    public boolean remove(String key) {
        return syncCache.remove(key);
    }

}

 

이제 해당 서비스를 가져다 쓰기만 하면 됩니다.

꼼꼼하신 분들은 좀 이상한게 눈에 띌 수도 있습니다. 

ehcache 패키지에서 제공하는 클래스를 사용한 부분이 하나도 없다는 것이죠.

그럼 pom.xml 파일에서 ehcache 의존성을 제거 해볼까요??

그럼 아래 에러가 발생하는 것을 확인하실 수 있습니다.

Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [javax.cache.Cache]: Factory method 'syncCache' threw exception; nested exception is javax.cache.CacheException: No CachingProviders have been configured
	at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:189)
	at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:588)
	... 67 more
Caused by: javax.cache.CacheException: No CachingProviders have been configured
	at javax.cache.Caching$CachingProviderRegistry.getCachingProvider(Caching.java:391)
	at javax.cache.Caching$CachingProviderRegistry.getCachingProvider(Caching.java:361)
	at javax.cache.Caching.getCachingProvider(Caching.java:151)

 

즉, 실제로 사용하는 API는 javax.cache 패키지 내에 정의되어있는 것이지만 내부적으로 ehcache에서 구현한 구현체로 실행된다는 결론을 내릴 수 있습니다.

 

어떤가요? 정말 쉽게 구현이 가능하죠?

 

이상으로 ehcache를 활용한 jcache 사용법에 대한 포스팅을 마칩니다.

 

 

 

참고자료

- www.ehcache.org/

- www.ehcache.org/documentation/3.3/107.html

- www.baeldung.com/jcache

스프링 순환참조 문제

스프링으로 프로젝트를 진행하다보면 여러 빈들의 순환참조로 인해 앱 기동이 안되는 상황에 종종 맞딱드리게 됩니다. 최근에 젠킨스로 빌드한 앱이 순환참조 문제로 인해 정상적으로 기동이 되지 않는 문제가 발생했습니다. 그런데 웃긴건 로컬환경에서는 아무런 문제없이 잘 돌아간다는 것이었죠. 결국 담당자의 확인하에 젠킨스의 파이프라인 옵션 설정으로 해결은 했습니다만, setter injection (field injection) 이라도 순환참조가 발생하는 디자인은 좋지 않죠. 그래서 관련내용을 알아보다가 끄적여봅니다.

순환참조란? (What is Circular Reference ? )

순환참조란 서로 다른 여러 빈들이 서로 물고늘어져서 계속 연결되어 있음을 의미합니다.

즉, 아래처럼 A는 B에서 필요한데 B는 또 A에서 필요한 상태를 말합니다.

Bean A → Bean B → Bean A

 

만약 Bean A -> Bean B -> Bean C 처럼 연결되어있다면 스프링은 A를 먼저 만들고 A를 필요로 하는 B를 만들고 B를 필요로 하는 C를 만들게 됩니다. 하지만 순환참조가 발생하면 스프링은 어느 빈을 먼저 생성해야할지 결정하지 못하게되고 순환참조 오류가 발생하게 됩니다. 이렇게 순환참조가 발생한다는건 결국 설계가 잘못되었다는 것입니다. 하지만 그렇다고 설계를 다 뜯어고치자니 비용이 너무 많이 들어갈 수도 있죠. 순환참조 오류는 참고로 스프링의 의존성 주입방법 중에서도 특히 생성자 주입방법을 사용했을 때 발생합니다. 빈 생성시 필요한 다른 빈이 서로 물고늘어져 있으니 어떤 빈도 생성이 불가능한 상황이 되어버리는 것이죠.

순환참조 문제 해결방법

순환참조는 발생하지 않도록 해주는게 제일 좋긴 하지만 어쩔 수 없는 상황은 꼭 생기기 마련입니다. 그럼 어떻게 순환참조 문제를 해결할 수 있을까요? 가장 우선시 해야할 것은 순환참조의 고리를 끊어버리는 것입니다. 이것이 스프링에서 권장하는 방법이며 설계를 조금만 바꿔서 해결이 가능한 경우가 이에 해당합니다. 하지만 만약 설계의 변경이 힘든 경우라면 @Lazy 어노테이션을 사용 해볼 수 있습니다. (해볼 수 있다 라고 얘기한 이유는 아래에 나옵니다)

@Component
public class BeanA {

    private BeanB beanB;

    @Autowired
    public BeanA(BeanB beanB) {
        this.beanB = beanB;
    }
}

 

이렇게 되어있던 코드를 아래처럼 바꿔주는 것이죠.

@Component
public class BeanA {

    private BeanB beanB;

    @Autowired
    public BeanA(@Lazy BeanB beanB) {
        this.beanB = beanB;
    }
}

 

하지만 이런 접근 방식은 문제가 있습니다. Lazy initialization 에 대한 문제는 Spring 공식문서(Lazy Initialization)를 참고하시면 되겠습니다. 간단히 말씀드리면 앱 기동시점이 아닌 실제 해당 빈이 필요한 시점에 빈을 생성하기 때문에 특정 http 요청을 받았을 때 힙메모리가 증가할 수 있으며 메모리가 충분하지 않은 상황이었다면 장애로 이어질 수 있다는 얘기죠. 아무튼 스프링에서 권장하지 않는 방식이니 사용하지 않으시면 됩니다. 

 

또 다른 방법은 생성자 주입방법 대신에 setter 주입방법을 사용하시면 됩니다.

아래 setter 주입 예제를 한번 보시죠.

@Component
public class BeanA {

    private BeanB beanB;

    @Autowired
    public void setBeanB(BeanB beanB) {
        this.beanB = beanB;
    }

    public BeanB getBeanB() {
        return beanB;
    }
}

@Component
public class BeanB {

    private BeanA beanA;

    @Autowired
    public void setBeanA(BeanA beanA) {
        this.beanA = beanA;
    }
}

 

 

혹시나 주입방법에 다시 한번 짚고 넘어가고 싶으시다면 스프링 의존성 주입 포스팅을 참고하시기 바랍니다.

 

Conclusion

스프링으로 개발을 하다가 순환참조가 발생한다면 가장 좋은 해결책은 순환참조의 연결고리를 끊어버리는 것이며 그렇게 하는 것이 깔끔하게 설계된 디자인이라고 할 수 있습니다. 스프링에서도 권장하는 방식이죠. 그래서 setter주입이 아닌 생성자 주입방식을 사용하는 것이 권장됩니다. 우회적으로 순환참조를 잠시 피한다 하더라도 언제 또 똑같은 문제가 발생할지 아무도 알 수 없으며 메모리 부족(lazy initialization의 경우) 현상이 발생할 수도 있기 때문에 가급적 순환참조해야하는 설계는 피하도록 하는 것이 좋겠습니다.

 

 

 

 

Reference

- www.baeldung.com/circular-dependencies-in-spring 

- docs.spring.io/spring-framework/docs/current/reference/html/core.html#beans-dependencies

💻 Programming

스프링 의존성 주입(DI)

스프링 의존성 주입 (Spring Dependency Injection)

스프링에서 의존성을 주입하는 방법은 현재 세 가지가 있습니다.

첫 번째는 생성자 주입 방식이고 두 번째는 Setter 주입 방식, 그리고 마지막으로 Field 주입 방식이 있습니다.

순서대로 한번 보도록 하겠습니다.

 

1. 생성자 주입 (Constructor Based Dependency Injection)

말 그대로 생성자에 @Autowired로 다른 빈을 주입하는 방식입니다.

@Controller
public class MyController {
    private MyService myService;
 
    @Autowired
    public MyController(MyService myService) {
        this.myService = myService;
    }
}

주입해야하는 빈이 많을 경우 생성자 자체가 커지게 됩니다. 그런경우 만약 lombok 을 사용한다면 아래처럼 작성할 수 있습니다.

@Controller
@RequiredArgsConstructor
public class MyController {

    private final MyService myService;
 
}

@RequiredArgsConstructor 어노테이션을 class 레벨에 붙여주고 주입할 빈은 final 키워드를 붙여주면 lombok에서 자동으로 생성자 주입을 이용하여 빈들을 주입해줍니다. 필요한 빈들은 모두 final 키워드를 붙여서 필드로 추가만 해주면 되죠. 

2. Setter 주입 (Setter Based Dependency Injection)

Setter 메서드를 이용하여 주입하는 방식이죠. 

@Controller
public class MyController {

    private MyService myService;
    
    @Autowired
    public void setMyService(MyService myService) {
        this.myService = myService;
    }
}

특이한 경우를 제외하고는 이 방식은 거의 사용하는 것을 못봤네요

3. Field 주입 (사용하지 마세요)

예전부터 많이 사용되는 방식입니다. 하지만 스프링에서 권고하지 않는 방식이기도 하죠. 

@Controller
public class MyController {

    @Autowired
    private MyService myService;
}

필드 주입 방식은 공식문서에도 소개조차 하고있지 않습니다. 공식문서에는 두 가지 방식만 있다고 설명합니다. 생성자 주입 방식과 setter 주입방식이죠. 그 이유는 필드 주입을 사용하게되면 순환참조 문제를 우회할 수 있게되어 순환참조를 고려하지 않고 개발을 하게되고, 그렇게 설계된 클래스는 하나 이상의 기능을 하게되어 하나의 책임만 가져야 한다는 Single Responsibility 원칙에 위배될 가능성이 높아지게되죠. 또 다른 여러 단점들에 대해서 stackoverflow를 참고하시면 좋을 것 같습니다. 만약 필드 주입을 사용하고 계시다면 생성자 주입 방식으로 바꿔주세요. 그러면 순환참조를 찾아낼 수 있고 순환참조 고리 안에 있는 클래스들을 살펴보면 Single Responsibility 원칙을 위배하는 클래스가 보일겁니다. 그런 것들은 클래스 분리를 통해 개선해주시면 됩니다.

 

 

 

 

 

참고문서

docs.spring.io/spring-framework/docs/current/reference/html/core.html#beans-dependencies

stackoverflow.com/questions/39890849/what-exactly-is-field-injection-and-how-to-avoid-it