<개요>

- 신규 프로젝트 진행시 Spring Boot 3.2 를 사용하기 위해서 공통 프로젝트 생성

- 기존 프로젝트와도 호환성 및 추후 유지보수를 위해서 버전을 올리기로 결정

(엊그제 시작했던 것 같은데 벌써 Support 기간이 끝난 Legacy 가 되어감.. 버전업 속도가 참 빠르다)

- 연계되어 있는 Artifact들이 많고 Compatibility를 확인해야 함 (JDK버전까지 고려해야 함)

Spring Cloud AWS Spring Cloud Spring Boot Spring Framework AWS Java SDK
2.3.x (maintenance) 2020.0.x (3.0) 2.4.x, 2.5.x 5.3.x 1.x
2.4.x (maintenance) 2021.0.x (3.1) 2.6.x, 2.7.x 5.3.x 1.x
3.0.x 2022.0.x (4.0) 3.0.x, 3.1.x 6.0.x 2.x
3.1.x 2023.0.x (4.0) 3.2.x 6.1.x 2.x

 

<내용>

  • Legacy Version
    • spring boot : 2.4.3
    • spring-cloud : 2020.0.2
    • spring-cloud-aws : 2.3.0
    • aws sdk v1, v2
  • Target Version
    • spring boot : 3.2.4
    • spring-cloud : 2023.0.0
    • spring-cloud-aws : 3.1.1
    • aws sdk v2
  1. Code변경사항
    • Spring 6.x
      1. 패키지명
        1. javax → jakarta
      2. Security Config
        1. cors, httpBasic, csrf, antMatchers
          1. depreacted 메소드 수정 및 주입방식 변경
      3. jakarta.annotation.PostConstruct
        1. tomcat-annotations추가가 필요한데 버전은 추가 확인할 필요가 있다.
      4. HttpStatus::is4xxClientError
        1. HttpStatusCode::is4xxClientError 변경
      5. java.lang.NoSuchMethodError: 'void org.springframework.util.Assert.notNull(java.lang.Object)’
        1. spring boot parent만 사용하고 참조라이브러리들 특정버전을 명시하는 것은 삼가한다.
        2. jasypt의 경우 spring 내부 encrypt를 사용하는 것으로 점차 변경할 예정.
      6. PagingAndSortingRepository 가 더이상 CrudRepository를 상속하지 않기 때문에 추가필요
    • Hibernate
      1. Unable to load class [org.hibernate.dialect.MySQL57Dialect]
        1. org.hibernate.dialect.MySQLDialect 로 변경
      2. Alias [creationDateTime] is already used in same select clause [position=4]
        1. 중복되는 컬럼이 있는지 체크하는 부분이 추가됨
      3. spring.datasource.initialization-mode=always is deprecated.
      4. spring: jpa: defer-datasource-initialization: true sql: init: mode: always data-locations: classpath:data-local.sql
      5. Custom으로 작성한 쿼리에서 간혹 자동 생성Count쿼리에 문제가 발생하는 경우가 있다.  기본 Select 문법에 대한 수정이 필요함 (e.g Alias 등)
    • Swagger 변경 (기존 Swagger 2안에 javax 패키지 참조로 오류가 발생하여 변경이 필요함)
      1. maven 변경
      <dependency>    
      	<groupId>org.springdoc</groupId>    
      	<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>    
      	<version>2.0.2</version></dependency>
      <dependency>
          <groupId>io.swagger</groupId>
          <artifactId>swagger-annotations</artifactId>
          <version>1.5.20</version>
      </dependency>
      
    • MySQL
      • <dependency>
      • <groupId>com.mysql</groupId>
      • <artifactId>mysql-connector-j</artifactId>
      • </dependency>
    • AWS SDK V2
      1. 상세 사용법은 AWS Documentation을 참고하여 수정함
    • Spring Security
      • WebFlux SecurityWebFilterChain
        • 기존에는 @Configuration이 없어도 동작했었으나 반드시 필요함
      • 테스트를 위한 Bean 생성 및 방법
@RunWith(SpringRunner.class)
@WebMvcTest(Controller.class )
@Import(SecurityConfig.class)
@ContextConfiguration(classes = SpringSecurityWebAuthTestConfig.class)
public class ControllerTest { 
	@Test @WithUserDetails("admin")
@TestConfiguration
@EnableWebSecurity
public class SpringSecurityWebAuthTestConfig { 
	@Bean 
    @Primary 
    public UserDetailsService userDetailsService() { 
    List<SimpleGrantedAuthority> userAuth = new ArrayList<>(); 
    userAuth.add(new SimpleGrantedAuthority("ROLE_USER")); 
    List<SimpleGrantedAuthority> adminAuth = new ArrayList<>(); 
    adminAuth.add(new SimpleGrantedAuthority("ROLE_ADMIN")); 
    
    LoginUserDetails loginUserDetails = new LoginUserDetails(-1, "", "user","", userAuth); 
    LoginUserDetails loginAdminDetails = new LoginUserDetails(-1, "", "admin", "", adminAuth); 
    
    return new CustomInMemoryUserDetailsManager(Arrays.asList(loginUserDetails, loginAdminDetails)); 
}

 

- 참조 사이트

https://github.com/awspring/spring-cloud-aws?tab=readme-ov-file#compatibility-with-spring-project-versions

 

GitHub - awspring/spring-cloud-aws: The New Home for Spring Cloud AWS

The New Home for Spring Cloud AWS. Contribute to awspring/spring-cloud-aws development by creating an account on GitHub.

github.com

https://spring.io/projects/spring-boot#support

 

Spring Boot

 

spring.io

https://docs.aws.amazon.com/ko_kr/sdk-for-java/latest/developer-guide/migration-whats-different.html

 

AWS SDK for Java 1.x와 2.x의 차이점은 무엇입니까? - AWS SDK for Java 2.x

SDK 1.x의 여러 패키지 이름에는 다음이 포함됩니다. v2 이 v2 경우에 를 사용한다는 것은 일반적으로 패키지의 코드가 서비스 버전 2에서 작동하도록 타겟팅되었음을 의미합니다. 전체 패키지 이

docs.aws.amazon.com

https://github.com/aws/aws-sdk-java-v2/blob/master/docs/LaunchChangelog.md#411-s3-operation-migration

 

aws-sdk-java-v2/docs/LaunchChangelog.md at master · aws/aws-sdk-java-v2

The official AWS SDK for Java - Version 2. Contribute to aws/aws-sdk-java-v2 development by creating an account on GitHub.

github.com

https://github.com/quarkusio/quarkus/wiki/Migration-Guide-3.0:-Hibernate-ORM-5-to-6-migration

 

Migration Guide 3.0: Hibernate ORM 5 to 6 migration

Quarkus: Supersonic Subatomic Java. . Contribute to quarkusio/quarkus development by creating an account on GitHub.

github.com

https://docs.spring.io/spring-boot/docs/3.2.4/reference/html/dependency-versions.html

 

Dependency Versions

 

docs.spring.io

 

<개요>

- @AuthenticaionPrincipal 정보확인

- Spring Secuirty를 통한 인증구현에 대한 테스트 코드 작성방법

- @AuthenticaionPrincipal에서 Null 오류가 발생하는 경우

- UserDetials , User 타입확인

 

<내용>

1. Spring Security

- 일반적으로 우리는 Spring을 활용하면서 Security를 통해서 인증/권한에 대한 문제를 쉽게 해결할 수 있습니다.

public @ResponseBody
Page<DataDto> findDataAll (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                           Pageable pageable)  {

- @AuthenticationPrinciapl 을 활용하면 쉽게 인증정보를 가져올 수 있는데 해당 정보는 SecurityContext내에 세팅되어 있는 정보를 전달받는 것입니다. 아래와 같이 출력해보면 확인할 수 있습니다.

System.out.println(SecurityContextHolder.getContext().getAuthentication().getPrincipal());

- 그렇다면 Authentication 과 Principal은 언제 세팅이 되는 걸까요?

- 로그인을 어떻게 구현했느냐와 연관이 있습니다. (이전글 참조)

https://icthuman.tistory.com/m/entry/Spring-Security-%EA%B8%B0%EB%8A%A5-%ED%99%9C%EC%9A%A9-1-Filter-Chain

 

Spring Security 기능 활용 #1 (Filter Chain)

- Spring Security 의 경우 Filter Chain 의 형태로 기능을 제공하고 있으며 필요에 따라서 추가/수정/삭제가 가능하다. 1. Spring Security - Filter Chain - Spring 에서 모든 호출은 DispatcherServlet을 통과하게 되고

icthuman.tistory.com

- JWT Token을 이용할 경우 일반적으로 Filter에서 해당 토큰을 검증한뒤 필요한 정보를 찾아서 Authentication 인터페이스 를 전달하여 세팅하게 됩니다. 이 Authentication 을 SpringSecurity 에서 일반적인 형태로 구현해놓은 것이 UserNamePasswordAuthenticationToken 입니다. 

public Authentication getAuthentication(String token) throws TimeoutException, ExecutionException, InterruptedException {
    LoginUserDetails loginUserDetails = this.makeUserDetailsFromToken(token);
    return new UsernamePasswordAuthenticationToken(loginUserDetails, "", loginUserDetails.getAuthorities());
}

<Token정보로부터 사용자정보를 얻어낸 뒤 UsernamePasswordAuthenticationToken 생성>

if(jwtTokenProvider.validateToken(token)) {
    Authentication auth = token != null ? jwtTokenProvider.getAuthentication(token) : null;
    SecurityContextHolder.getContext().setAuthentication(auth);
    filterChain.doFilter(req, res);

<생성된 Authentication정보를 SecurityContext에 세팅> 

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 620L;
    private final Object principal;
    private Object credentials;

<Sring Security에 구현되어 있는 클래스>

 

2. Test Code 작성법

public @ResponseBody
Page<DataDto> findDataAll (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                           Pageable pageable)  {

- Spring 전체 Context를 기동한다면 가능하겠지만 우리는 일반적으로 Controller Layer를 테스트할때 @WebMvcTest를 사용합니다. (테스트 레이어 분리, 범위 한정 등등의 이유로)

- 문제는 이렇게 진행하면 해당 코드를 테스트할때 그냥 호출하게 되면 loginUserDetails에서는 Null 이 발생하게 됩니다. 

- 1번에서 설명했던 Authentication정보를 Context에 세팅하는 부분 (Filter) 이 호출되지 않기 때문입니다.

- 그래서 아래와 같이 수동으로 UserDatails를 세팅해주는 InMemoryUserDetailManager 사용을 권장합니다.

@Bean
@Primary
public UserDetailsService userDetailsService() {
    List<SimpleGrantedAuthority> userAuth = new ArrayList<>();
    userAuth.add(new SimpleGrantedAuthority("ROLE_USER"));

    List<SimpleGrantedAuthority> adminAuth = new ArrayList<>();
    adminAuth.add(new SimpleGrantedAuthority("ROLE_ADMIN"));

    LoginUserDetails loginUserDetails = new LoginUserDetails(1, "", "user","", userAuth);
    LoginUserDetails loginAdminDetails = new LoginUserDetails(2, "", "admin", "", adminAuth);

    return new InMemoryUserDetailsManager(Arrays.asList(loginUserDetails, loginAdminDetails));
}

- 그리고 테스트 케이스에서는 @WithUserDetails 어노테이션을 사용해서 앞에서 생성했던 UserDetails정보를 통해서 호출할 수 있습니다. (@WithMockUser, @WithAnonymousUser, @WithSecurityContext 등을 적합한 것을 선택해서 사용하면 됩니다.)

@Test
@WithUserDetails("admin")
public void getAllData() throws Exception{
    List<DataDto> datas = new ArrayList<>();

    DataDto dataDto = new DataDto();
    dataDto.setDataId(1);
    String dataName = "ttt";
    dataDto.setDataName(dataName);
    dataDto.setUserId(1);
    datas.add(dataDto);

    Page<DataDto> pages=new PageImpl<>(datas, Pageable.unpaged(), 1);

    //given
    given(dataService.findAllDatas(PageRequest.of(0,1))).willReturn(pages);
    //when
    this.mvc.perform(get("/datas/all?page=0&size=1").header("Authorization", "Bearer " + adminToken))
            .andExpect(jsonPath("content").isNotEmpty())
            .andExpect(jsonPath("content[0].dataId").value(1))
            .andExpect(status().isOk());
}

 

3. NullPointerException이 발생하는 경우

- 일반적으로 구글링을 하면 2번까지는 많이 나옵니다. 테스트도 잘 되지만..

- User 클래스를 상속받아서 별도로 구현한 경우에는 NullPointerException 가 발생하는 경우가 있습니다. (Spring Security 버전에 따라 차이가 있는지는 확인해야 할 것 같습니다.)

public class LoginUserDetails extends User {

    private Integer userId;
    private String userGroup;

    public LoginUserDetails(Integer userId, String password, String userName, String userGroup, Collection<? extends GrantedAuthority> authorities) {
        super(userName, password, authorities);
        this.userId = userId;
        this.userGroup = userGroup;
    }

    .. 	기타 로직구현 메소드

- 이유는 다음과 같습니다. User 클래스를 살펴보면 이 역시 UsenamePasswordAuthenticationToken 처럼 Spring Security에서  UserDetails 인터페이스를 일반적으로 구현해놓은 클래스입니다.

public class User implements UserDetails, CredentialsContainer {
    private static final long serialVersionUID = 620L;
    private static final Log logger = LogFactory.getLog(User.class);
    private String password;
    private final String username;
    private final Set<GrantedAuthority> authorities;
    private final boolean accountNonExpired;
    private final boolean accountNonLocked;
    private final boolean credentialsNonExpired;
    private final boolean enabled;

- 그리고 아까 테스트코드에서 우리가 사용한 InMemoryUserDetailsManager 역시 Spring Security에서 UserDetailsManager를 구현해 놓은 클래스입니다.

public class InMemoryUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {
    protected final Log logger = LogFactory.getLog(this.getClass());
    private final Map<String, MutableUserDetails> users = new HashMap();
    private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
    private AuthenticationManager authenticationManager;

	... 중략 ...
    
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserDetails user = (UserDetails)this.users.get(username.toLowerCase());
        if (user == null) {
            throw new UsernameNotFoundException(username);
        } else {
            return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
        }
    }

- Spring Security는 바로 이 loadUserByUsername 을 호출해서 UserDetails를 세팅하게 되는데 User 객체를 받게 됩니다.

- 바로 여기가 문제의 원인입니다.

- 비지니스 사항때문에 User를 상속받아서 별도로 구현한 LoginUserDetails는 User의 Sub type입니다. 

   LoginUserDetails는 User가 될 수 있지만, User는 LoginUserDetails가 될 수 없습니다. 

- 이러한 내용을 Controller에서 좀 더 명확하게 확인하기 위해서 (errorOnInvalidType=true)를 사용해봅니다.

public @ResponseBody
Page<DataDto> findDataAll (@AuthenticationPrincipal(errorOnInvalidType=true) LoginUserDetails loginUserDetails,
                           Pageable pageable)  {

- 아래와 같은 오류메시지를 확인할 수 있습니다.

Caused by: java.lang.ClassCastException: class org.springframework.security.core.userdetails.User cannot be cast to class ...LoginUserDetails (org.springframework.security.core.userdetails.User and ...LoginUserDetails are in unnamed module of loader 'app')

 

4. 해결방법

- 원인을 알게되면 해결하는 방법은 간단합니다.

- SpringSecurity의 Context에 세팅되는 Principal은 UserDetailsManager의 loadUserByUsername이 Return 해주는 객체 입니다. 

- User의 Sub type인 LoginUserDetails를 생성하여 Return해주면 Controller에서 LoginUserDetails 객체를 받아올 때 ClassCastException 이 발생하지 않으면서 정상적으로 사용할 수 있습니다.

class CustomInMemoryUserDetailsManager extends InMemoryUserDetailsManager{
    public CustomInMemoryUserDetailsManager(Collection<UserDetails> users) {
        super(users);
    }

    @Override
    public LoginUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserDetails userDetails =  super.loadUserByUsername(username);

        return new LoginUserDetails(1 ,"", username, "", userDetails.getAuthorities());
    }
}

- 이제 Local에서 TestCase를 수행했을때에도 정상적으로 Customize된 LoginUserDetails객체를 사용할 수 있습니다.

public @ResponseBody
Page<DataDto> findDataAll (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                           Pageable pageable)  {
    if(loginUserDetails.isAdmin()){
        return dataService.findAllDatas(pageable);
    }
    return new PageImpl(new ArrayList(),Pageable.unpaged(),0);
}

<가상의 시나리오>

- Ingestion Layer에서 수백개의 병렬처리를 통해서 데이터를 생성하고 있으며, 해당 데이터에 접근할 수 있도록 파티션별로 Raw API 제공되고 있음

- Role, Scheduler, Auth 등등 여러 가지 문제 때문에 신규 API 를 만드는 데 시간이 필요함 (파티션별로 나누어진 데이터를 합쳐서 연산해야 함)

-  Application Layer에서 Raw API들을 반복 호출하여 결과값을 연산하도록 로직을 구성하도록 하며 최대한 처리속도를 끌어올리고 자원효율을 극대화하자.

 

<접근방법>

- 다수의 API 호출 후 결과를 조합해야 하는 경우 Web client를 활용하여 비동기 호출로 효과를 봤었다. (이전 포스트 참조)

https://icthuman.tistory.com/entry/Spring-WebClient-%EC%82%AC%EC%9A%A9%EC%8B%9C-%EC%A3%BC%EC%9D%98%EC%A0%90

 

Spring WebClient 사용 #1

- Async API Call 후 응답을 제대로 처리하지 못하는 현상이 있습니다. - 그 여파로 내부적으로 AtomicInteger를 이용하여 호출Count를 처리하는 로직이 있는데 해당 로직이 수행되지 않아서 버그가 발생

icthuman.tistory.com

https://icthuman.tistory.com/entry/Spring-WebClient-%EC%82%AC%EC%9A%A9-2-MVC-WebClient-%EA%B5%AC%EC%A1%B0

 

Spring WebClient 사용 #2 (MVC + WebClient 구조)

- Spring 이후 버전에서는 RestTemplate가 deprecated될 예정이며 WebClient 사용을 권장하고 있다. - 현재 구성 중인 시스템에는 동기/비동기 API가 혼재되어 있으면서, 다양한 Application / DB를 사용중이기 때

icthuman.tistory.com

https://icthuman.tistory.com/entry/Spring-WebClient-%EC%82%AC%EC%9A%A9-3-Configuration-Timeout

 

Spring WebClient 사용 #3 (Configuration, Timeout)

이전글 Spring WebClient 사용 #2 (MVC + WebClient 구조) Spring WebClient 사용 #2 (MVC + WebClient 구조) - Spring 이후 버전에서는 RestTemplate가 deprecated될 예정이며 WebClient 사용을 권장하고 있다. - 현재 구성 중인 시

icthuman.tistory.com

- 신규 프로젝트에서는 기술스택을 Spring WebFlux 로 선정하였다. 그 이유는 다음과 같다.

 

a. 기본적으로 Spring, Java에 대한 이해도가 높다. 하지만 Legacy 코드는 없다.

b. 데이터에 대한 읽기 연산이 대부분이고, 특별한 보안처리나 트랜잭션 처리가 필요없다. (참조해야할만한 Dependecny 가 적다.)

c. 저장공간으로 Redis Cache를 활용한다. 즉, Reactive를 적극 활용할 수 있다.

d. 다수의 API 호출을 통해서 새로운 결과를 만들어 낸다.

즉, IO / Network의 병목구간을 최소화 한다면 자원활용을 극대화 할 수 있을 것으로 보인다.

 

<진행내용>

- 기존의 For loop 방식과 Async-non blocking 차이,그리고 Mono / Flux 를 살펴본다. (Spring WebFlux) 

@ReactiveRedisCacheable
public Mono<String> rawApiCall(...) throws .Exception {

Mono<String> response = webClient
                .get()
                .uri(url)
                .retrieve()
                .onStatus(HttpStatus::is4xxClientError, clientResponse -> Mono.error(new Exception(...)))
                .onStatus(HttpStatus::is5xxServerError, clientResponse -> Mono.error(new Exception(... )))
                .bodyToMono(String.class)
                .timeout(Duration.ofMillis(apiTimeout))
                .onErrorMap(ReadTimeoutException.class, e -> new Exception(...))
                .onErrorMap(WriteTimeoutException.class, e -> new Exception(...))
                .onErrorMap(TimeoutException.class, e -> new Exception(...));
                
                return response;
}

webClient를 이용해서 타 API를 호출하는 부분이다. 응답값에는 다수의 건이 포함되어 있으나 해당 데이터를 보내는 쪽에서도 병렬처리를 진행하고 있기 때문에 Collection 이나 Array 형태로 처리하는 부분을 제외하고 그냥 Raw line 형태로 제공하고 있다.

Spring MVC기반에서는 이 값을 꺼내기 위해서 결국 block하고 값에 접근하는 로직이 필요하다. 굳이 코드로 구현하자면 아마도 이렇게 만들어 질 것이다.

List<ApiResponse> ret = new ArrayList<>();
for(String value : Collection ... ){

   String contents = apiService.rawApiCall(value).block();

   String[] lines = contents.split("\n");
   for(String data : lines){
       if(StringUtils.hasText(data)){

           ApiResponse apiResponse =  mapper.readValue(data, ApiResponse.class);

           if(populationHourApiResponse .. ){
               // biz logic
				
               FinalResponse finalResponse = new FinalResponse();
               // setter
               ...
               ..
               
               ret.add(finalReponse);
           }
       }
   }
}

이 코드에는 여러가지 문제점이 있는데

- block()을 수행하게 되면 비동기 넌블러킹 처리의 여러 장점이 사라진다.

- 오히려 더 적은 수의 쓰레드를 사용해야 하는 구조특성상  block이 생기면 더 병목이 발생하는 경우도 있다.

- return 에 얼만큼의 데이터가 담길지 모르게 된다.

- API Call 이후 biz logic의 수행시간이 길어질 수록 전체 응답시간은 더욱 길어진다.

 

해당 내용을 block없이 처리하도록 Flux를 최대한 활용하여 작성해보았다.

public Flux<FinalResponse> getDataByConditionLevel1{

    List<Mono<String>> monoList = new ArrayList();
    for(String value : Collections ...)){
        monoList.add( apiService.rawApiCall(value) );
    }

    return 
        Flux.merge(monoList)
                .flatMap(s -> Flux.fromIterable(Arrays.asList(s.split("\n"))))
                .filter(s -> StringUtils.hasText(s))
                .map(data -> {
                    try {
                        return mapper.readValue(data, PopulationApiResponse.class);
                    } catch (JsonProcessingException e) {
                        log.error(e.getLocalizedMessage());
                    }
                    return new ApiResponse();
                })                                                                      
                .filter(aApiResponse -> ... biz logic)     
                .map(apiResponse ->
                     new FinalResponse(...)
                );
  }

주요하게 바뀐부분을 살펴보면 다음과 같다.

 

1. API응답의 결과를 block해서 기다리지 않고 Mono를 모아서 Flux 로 변환한다.

Mono는 0..1건의 데이터, Flux는 0..N건의 데이터를 처리하도록 되어있다.

즉 개별 Mono를 대기하여 처리하는 것이 아니라 하나의 Flux로 모아서 단일 Stream처럼 처리할 수 있다,.

 

2.  값이 아니라 행위를 넘겨준다.

Spring WebFlux에서는 기본적으로 Controller - Service - Dao 등의 Layer간 이동을 할때 Mono / Flux 를 넘겨준다.

즉, 어떠한 값을 보내는 것이 아니라 Mono / Flux로 구성된 Publisher를 전달해주면 subscribe를 통해서 실제 데이터가 발생될 때 우리가 정의한 Action을 수행하는 형태가 된다고 이해하면 될듯 하다. (Hot / Cold 방식의 차이가 있는데 일단 Skip하도록 한다.)

 

위의 로직은 각 개별 데이터 간의 연산이나 관계가 없기 때문에 비교적 쉽게 변경할 수 있었다.

하지만 해당 데이터를 다시 조합하거나 Grouping 하거나 하는 경우가 있다면 약간 더 복잡해질 수 있기 때문에 고민이 필요하며 각 비지니스 케이스에 적합한 단위와 연산으로 재설계를 해주는 것이 좋다. ( -> 필수다 !)

예를 들어서 rawApiCall에 필요한 인자값이 yyyyMMdd hh:mm:ss 형태의 timeStamp라면 특정기간 내 시간대별 결과를 얻기 위해서는 다음과 같이 Call을 하고 조합해야 한다.

즉 수행해야 하는 액션은 다음과 같다.

- Flux를 응답받는 메소드를 다시 감싸서

- 응답결과를 적절하게 Biz Logic에 따라서 처리한 뒤

- aggreation 을 통하여 새로운 응답을 만들어 낸다. (e.g 그룹별 개수, 합계, 평균 등등)

 

코드로 작성해보면 이러한 형태가 될텐데

public Flux<NewResponse> getDataByConditionLevel2{	

    List<Flux<NewResponse>> ret = new ArrayList();
        for( ; ; ){
            ...
            // Biz Logic...
            ...
            
            Flux<NewResponse> flux = getDataByConditionLevel1( ...  )
                     .groupBy(apiSummary -> apiSummary.getKey() )
                     .flatMap(groupedFlux -> groupedFlux.reduce( (arg1, arg2) -> ApiSummary.add(arg1, arg2) )
                                                        .map(apiSummary -> NewResponse.valueOf( ...+ groupedFlux.key(), apiSummary ))
                                                      );

            ret.add(flux);
        }
        return Flux.merge(ret);

 위의 코드에서 살펴볼 부분은 세 가지이다.

- groupBy : getDataByConditionLevel1 메소드에서 받아온 결과를 Key단위로 Grouping을 수행한다.

  이때 수행결과로는 GroupedFlux가 리턴되는 데 이는 중첩된 데이터 형태로 flatMap 을 통해서 작업하는 것이 수월하다.

 

- reduce : groupBy 로 분류된 데이터들을 key 단위로 reduce 하게 되는데 ( 자주 보게되는 wordCount sample과 유사하다).

  Java내에는 Integer , Double등의 타입에서 ::sum 메소드를 제공하고 있지만 우리가 직접 작성한 Class 에 대해서는 연산메소드를 정의해주는 것이 필요하다. 위 예제에서는 ApiSummary.add(arg1, arg2) 이다.

 최종 객체변환의 편의성을 위해서 NewResponse.valueOf 메소드도 정의해서 사용하였다.

 

- Mono/Flux간 변환

getDataByConditionLevel1 메소드에서 살펴본것 처럼 여러 개의 Mono는 하나의 Flux로 변환이 가능하다.

또한 Flux에 대한 reduce 연산은 Mono로 변환이 된다.

그리고 여러 개의 Flux 를 합쳐서 하나의 Flux로 변환하는 것도 가능하다.

 순서보장이 필요한지, 병렬처리가 필요한지 등 여러가지 요건을 고려하여 적절한 연산자를 사용하도록 한다.

 

<정리>

- 처음에는 blocking 로직을 벡엔드에서 가지고 있는 것이 적합하지 않아서 FrontEnd에서 해당 API들을 호출하여 결과값을 연산하는 형태로 접근했었다. (Promise all)

- 일주일치의 데이터를 기반으로 결과값을 생성하기 위해서는 총 24 * 7 = 168 회 API 호출이 필요했고, 프론트에서 처리시간은 최악의 경우 15초를 넘어가는 케이스가 발생하였다.

- Spring Web Flux를 활용하여 Backend에서 처리하도록 개선하였으며 또한 Raw API Call을 수행하는 메소드에 별도로 개발한 Cache Aspect를 적용하였다.

그 이유는 Spring Cache Manager에서 async/non-blocking에 대한 표준 구현체가 없다보니 직접 CacheMono/Flux와  ReactiveRedisTemplate등을 사용하여 값을 처리하도록 구현하였다.

이에 대한 내용은 다음 포스트에서 좀 더 자세히 다루도록 하겠다.

 

<결과>

- 최초 호출시 약 4~5초 정도 수행시간이 소요되며, 각 Raw API 캐시 이후에는 약 1초 정도 걸리는 것을 확인할 수 있었다.

  Network 처리에 가장 많은 시간이 소요되기 때문에 사실 개별 API Call만 캐시해도 성능이 대폭 향상된다.

- 하지만 아직 몇 가지 더 살펴보고 싶은 욕심이 있는데.. 

 a. Mono / Flux 레벨에서의 캐시

 b. Raw API뿐만 아니라 최종 API에 대한 값 캐시 

 (각 Raw API 응답값이 변하기도 하고, 워낙 대상이 많다보니 캐시대상을 늘릴 경우 저장공간에 대한 우려가 있다.)

 c. Reactor에서의 병렬처리

 (Schedulers, parallel 등)

 

<참조>

Reactor에 대한 내용이 잘 정리되어 있다.

https://godekdls.github.io/Reactor%20Core/reactorcorefeatures/

 

Reactor Core Features

리액터 코어 기능 한글 번역

godekdls.github.io

 

https://icthuman.tistory.com/entry/Reactive-Programming-1-%EA%B4%80%EB%A0%A8-%EA%B0%9C%EB%85%90%EC%A0%95%EB%A6%AC

 

Reactive Programming #1 (관련 개념정리)

최근 Reactive Programing이라는 개념이 많이 사용되고 있어서 관련하여 개념들을 정리를 해보려고 한다.1. Event DrivenReactive를 알기 위해서 먼저 Event Driven을 알아볼 필요가 있다.Event Driven은 말 그대로

icthuman.tistory.com

 

<개요>

- 일반적으로 많이 사용하는 지리좌표계는 위도, 경도로 이루어져있다.

  이는 실제 정확한 위치를 측정하는 것이 목표이기 때문에 소수점 표현에 제한이 없이 무한하게 표현한다. (보통 6자리)

- 특정 영역(Area) 에 대한 처리를 하기에는 적합하지 않기 때문에, 영역기반의 검색/표현에 적합한 구조가 필요하다.

- 즉, (무한->유한) 한정된 공간내에서 필요한 만큼만의 데이터(의미있는 데이터) 를 관리할 수 있도록 개선이 필요하다.

 

<내용>

- 지도뷰를 기반으로 하는 시스템에서 좌표를 기반으로 검색하는 것은 속도/공간에서 많은 손해를 본다. (대부분 특정영역 내 검색)

- 지도내에서 살짝만 움직여도 소수점 값이 변경되는데 실제 서비스내에서 의미를 갖는 값으로 보기 어려울때가 있다.

- 또한 Round처리를 통해서 어느정도 고정적으로 표현할 수는 있으나 연산에 역시 불편함이 있다.

- 지역내 검색이나 가까운 위치 등을 계산할때도 복잡한 수식을 사용해야 하며 Index 나 Key를 사용하기에 쉬운 구조는 아니다.

 

<GeoHash Concept>

전 세계를 잘라내기

- 이를 보완하기 위해서 전 세계 지역을 특정영역 단위로 잘라낸 것이 GeoHash의 기본사상이다.

- 좌표값을 특정 해시값으로 변경하여 지역기반 검색 이나 캐시 활용에서 편리하게 활용 할 수 있다. ( f(x) -> y )

- GeoHash의 결과는 특정 영역(Area) 이다.  지점(Point)이 아니다.

 

<GeoHash Algorithm>

- 알고리즘은 간단히 설명하면 Index Tree, Binary Search등과 비슷하다.

1. Latitude (-90 ~ 90), Longitude (-180 ~ 180) 범위 내에서 Binary Search를 수행한다.

2. 왼쪽에 속하면 0, 오른쪽에 속하면 1 이다. (bits)

3. 다음 구간으로 이동하여 1,2를 반복한다.

Longtitude / Latitude 변환

4. 이렇게 해서 얻은 각 bit를 하나씩 꺼내어 결합한다.

- Geohash level이 높아질 수록 더 자세한 위치를 표현해야 하기 때문에, 더 많은 bit를 필요로 하게 된다.

- latitude 1개, longitude 1개 순으로 번갈아가면서 결합한다.

 (Longitude의 값의 범위가 더 넓기 때문에 Level에 따라서 Bit가 1개 더 필요한 경우가 있고, 이 때 마지막 두 개를 연속해서 붙인다. )

 

5. 마지막으로 얻은 bits를 5개씩 나눠서 BASE32 encoding으로 변환하여 알파벳 문자를 얻을 수 있다. (2^5=32)

BASE32

6. Binary Search를 많이 반복할 수록 더욱 정확한 숫자를 얻게 되고, GeoHash 의 길이는 길어진다고 볼 수 있다.

 - GeoHash Stirng의 길이가 GeoHash Level로 생각하면 된다.

 

7. 위의 연산을 통해서 얻은 결과값은 다음과 같다.

위/경도 좌표 (37.385595, 127.122759) -> Level 2 (wy), Level 8 (wydkstzf)

위/경도 좌표 (37.384887, 127.123689) -> Level 2 (wy), Level 8 (wydksv8w)

 

*장점

- 매우 길고 큰 값을 상대적으로 짧고 저장공간을 적게 차지하는 String으로 바꿀 수 있게 된다. (Hash의 기본사상)

- GeoHash알고리즘의 특성상 prefix비교를 통해서 이웃인지 판별할 수 있다.

 예를 들어서 GeoHash를 통해서 gbsuv (Level 5)값을 얻은경우 아래의 지역들은  Neighbours 로 판별할 수 있다.

gbsvh gbsvj gbsvn
gbsuu gbsuv gbsuy
gbsus gbsut gbsuw

 

<코드>

전체코드는 다음 위치에서 확인 가능합니다.

https://github.com/ggthename/geohash

 

GitHub - ggthename/geohash: get a geohash value from a coordinate (latitude,longitude)

get a geohash value from a coordinate (latitude,longitude) - GitHub - ggthename/geohash: get a geohash value from a coordinate (latitude,longitude)

github.com

위도/경도 기반의 좌표
GeoHash Level에 따른 Binary Search 응용

 

<정리>

- 이를 통해서 특정 지역 내의 데이터값을 관리할때 Key값으로 사용할 수 있다.

 e.g) 특정지역내 위치한 상점 검색, 50 x 50내 인구 등

 

- 해당 지역간의 인접성도 복잡한 계산없이 판별할 수 있다.

 e.g) 현재 위치에서 10km내 이동가능한 곳에 있는 주유소 위치 등

 

- 최대 Level 12로 32.2mm x 18.6mm의 지역까지 표현할 수 있다. 일반적으로 Level 9 까지 사용한다.

 

<참고>

- https://en.wikipedia.org/wiki/Geohash#Technical_description

 

Geohash - Wikipedia

From Wikipedia, the free encyclopedia Jump to navigation Jump to search This article is about the system for encoding geographic coordinates. For the game, see Geohashing. Public domain geocoding invented in 2008 Geohash is a public domain geocode system i

en.wikipedia.org

- https://en.wikipedia.org/wiki/Base32

 

Base32 - Wikipedia

From Wikipedia, the free encyclopedia Jump to navigation Jump to search Binary-to-text encoding scheme using 32 symbols Base32 is the base-32 numeral system. It uses a set of 32 digits, each of which can be represented by 5 bits (25). One way to represent

en.wikipedia.org

https://www.movable-type.co.uk/scripts/geohash.html

 

Geohash encoding/decoding

Movable Type Scripts Geohashes A geohash is a convenient way of expressing a location (anywhere in the world) using a short alphanumeric string, with greater precision obtained with longer strings. A geohash actually identifies a rectangular cell: at each

www.movable-type.co.uk

 

이전글
 
 - 기존에 Spring boot starter 2.1.x 버전에서는 발생하지 않았던 Exception이 2.3.x 로 오면서 발생하였다.

Caused by: org.springframework.core.io.buffer.DataBufferLimitException: Exceeded limit on max bytes to buffer 

- 해당 오류는 WebClient를 통해서 API Call후 받아오는 응답의 크기가 일정이상이 될 경우 발생한다. 

 현재 사용중인 API의 응답사이즈가 1~3MB 수준으로 조정이 필요한 상태이다.

 

a. 주의해야할 점이 yml 파일내에서 다음 옵션을 사용하여 조정이 가능한 버전이 있으나

spring.codec.max-in-memory-size=20MB

 

 b. 지금 사용중인 버전에서는 동작하지 않아서 별도 설정을 적용하도록 하였다.

ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
                .codecs(clientCodecConfigurer -> clientCodecConfigurer.defaultCodecs().maxInMemorySize(3* 1024 * 1024))
                .build();

        return WebClient.builder()
                .exchangeStrategies(exchangeStrategies)
                .baseUrl( )
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();

 이와 같이 WebClient builder내에 추가하여 정상동작 하는 것을 확인하였다.

 

2.  Timeout 설정

- Spring WebClient를 사용할때 여러종류의 Timeout이 있다.

HttpClient httpClient = HttpClient.create()
                .tcpConfiguration(
                        client -> client.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, -A- ) //miliseconds
                                .doOnConnected(
                                        conn -> conn.addHandlerLast(new ReadTimeoutHandler( -B- , TimeUnit.MILLISECONDS))
                                                .addHandlerLast(new WriteTimeoutHandler( -C- , TimeUnit.MILLISECONDS))
                                )
                );


        return WebClient.builder()
                .exchangeStrategies(exchangeStrategies)
                .baseUrl(webClientProperties.getBaseUri())
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
Mono<String> response = webClient
                .get()
                .uri(" ")
                .retrieve()
                .onStatus(HttpStatus::is4xxClientError, clientResponse -> Mono.error(new Exception())
                .onStatus(HttpStatus::is5xxServerError, clientResponse -> Mono.error(new Exception())
                .bodyToMono(String.class)
                .timeout(Duration.ofMillis( -D- ))
                .onErrorMap(ReadTimeoutException.class, e -> ))
                .onErrorMap(WriteTimeoutException.class, e -> ))

A. Connection Timeout

- Client / Server 간의 Connection을 맺기 위해 소요되는 시간을 의미한다.

- Server에서 새로 Connection을 맺을 자원의 여유가 없다면 발생할 수 있다.

- HTTP Connection에 대한 내용이기 때문에 keep-alive 옵션역시 사용가능하다.

 

B. Read Timeout

 - 데이터를 읽기 위해서 기다리는 시간을 의미한다.

 - 내가 호출한 API 가 응답을 주지 못하면 발생할 수 있다.

 - 너무 길다면? 적절히 설정해주지 않으면 응답을 받을때까지 계속 대기하게 되고, 자원이 고갈되는 현상이 발생한다.

 - 너무 짧다면? 요청을 받은쪽에서는 처리가 되었으나, 응답을 기다리던 쪽에서는 Timeout이 발생하게 되어서 불일치 상태가 발생한다. 

 

C. Write Timeout

 - 데이터를 쓰기 위해서 기다리는 시간을 의미한다.

 - 주어진 시간동안 Write Operation을 완료하지 못하면 발생할 수 있다.

 - 즉, Connection 연결 후 데이터를 보내기 시작하였으나 해당시간보다 길어지게 되면 중단된다.

 

D. reactor timeout

 - Reactive Stream은 Publisher, Subscriber, Subscription 을 통해서 비동기 / 넌블러킹 / back pressure 처리하는 개념이다.

 - 우리가 다루는 Spring WebFlux는 reactive stream의 구현체로 reactor를 사용하고 있으며 Mono / Flux가 Publisher의 구현체이다.

 - 따라서 Exception , Retry등을 처리할때도 기존 방식 대신 reactive stream의 기능을 활용해주는 것이 장점을 충분히 살릴 수 있는 방법이라고 생각한다.

 - Spring WebFlux에서는 WebClient의 호출결과를 받았을때 결과 Body를 Mono로 감싸주어 데이터를 전달하는 형태가 되는데, 해당 시간동안 데이터를 전달하지 못하게 되면 timeout 이 발생하게 된다.

 

E. Transaction Timeout과 비교 (개인적인 생각 , 틀릴 수 있음)

 - 우리가 일반적으로 DB transaction timeout을 설명할 때 Transaction Timeout > Statement Timeout > Socket Timeout 로 각 구간을 나누어서 설명하고 상위 Layer( ? 포장레이어?) 에서는 하위 Layer보다 크거나 같게 설정하는 것이 일반적이다.

 - Web 호출 역시 비슷하게 살펴본다면 Publisher Timeout > Read/Write Timeout > Connection Timeout 정도로 비슷하게 정리해 볼 수 있지 않을까 생각했다.

 

<정리>

- MSA구조에서 각 API의 응답시간과 사이즈는 적절하게 설정해야 한다.

- 특히, 각 레벨에서의 적절한 수준의 timeout 설정은 필수이다. 

- 너무 짧으면 많은 오류가 발생하게 되고, 이에 따른 side-effect (데이터 불일치, 로직의 복잡도 증가) 가 생기게 되며

- 너무 길거나 무제한으로 설정하게 되면 리소스 자원의 낭비가 발생한다.

<개요>

- 내가 사용하는 Dto에 실제 Json필드값과 연관이 없는 getter가 존재한다면

- HttpMessageNotReadableException을 만난적이 있다면

https://icthuman.tistory.com/entry/Redis%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%BA%90%EC%8B%9C-%EC%82%AC%EC%9A%A9%EC%8B%9C-%EC%A3%BC%EC%9D%98%EC%A0%90

 

Redis를 활용한 캐시 사용시 주의점

<개요> - 기본생성자 유무 - Serializer / Deserializer 적용 - 클래스 패키지 위치 <내용> Q1. 기본생성자 사용 Page findDataAll (@AuthenticationPrincipal LoginUserDetails loginUserDetails, Pageable pagea..

icthuman.tistory.com

- 이전 글에서 살펴본 Ser/DeSer 와도 연관이 있다.

 

<내용>

- 현재 사용중인 Dto는 아래와 같은 Abstract class를 상속하도록 되어있다.

public abstract class AbstractCommonTable {
    public abstract Integer getPrimaryKey();
    public boolean checkAvailablePrimaryKey(){
        return //condition;
    }

    public boolean checkNotAvailablePrimaryKey(){
        return !checkAvailablePrimaryKey();
    }
}

- 여러 비지니스 Dto 에 필요한 PK의 필드명은 각각 다르지만, 이를 활용한 공통로직을 구성할때 필요하여 작성했다.

- 일반적으로 Controller 레벨에서 혹은 이를 테스트하는 코드를 작성할때 우리는 객체를 Json string으로 변환하여 사용한다.

- 여러 종류의 오픈소스가 존재하는 데 여기서는 jackson을 사용하였다. (Spring default)

ObjectMapper mapper = new ObjectMapper();
String str = mapper.writeValueAsString(productInsightDto);

 

<현상>

- 그러나 Controller Testcase를 수행하였을때 org.springframework.http.converter.HttpMessageNotReadableException 을 만나게 된다.

- 구글링으로 통해서 다양한 원인을 찾을 수 있는데 정리해보면 Json 형식 오류이다.

 a. body형식 -> JSON

 b. LocalDateTime -> module등록, @JsonSerialize, @JsonFormat/@DateTimeFormat 등등

 

- 내가 발생했던 오류는 getPrimaryKey()라는 메소드가 실제 Json Data Format과 불일치하기 때문에 발생하는 것으로

@JsonIgnoreProperties({"primaryKey"})

를 설정해주면 간단히 해결된다.

 

- 추가 : 간혹 application.yml에 설정해둔 내용이나 나의 의도와는 관계없이 동작하는 경우가 있는데 ObjectMapper가 여러개 만들어있지는 않은지 확인한다.

applicatoin.yml에 설정하는 내용은 Spring에서 자동생성하는 ObjectMapper의 설정으로, 본인이 별도의 Bean으로 생성하였다면 따로 관리해주어야 한다.

 

<참조>

https://stackoverflow.com/questions/51261809/spring-boot-jackson-non-null-property-not-working

1. maven repository 연결

- https 연결시 인증서 문제

- 인증서 추가

 

2. netty

java.lang.UnsupportedOperationException: Reflective setAccessible(true) disabled

--add-opens java.base/jdk.internal.misc=ALL-UNNAMED
-Dio.netty.tryReflectionSetAccessible=true

 

<참조>

https://stackoverflow.com/questions/57885828/netty-cannot-access-class-jdk-internal-misc-unsafe

<개요>

https://icthuman.tistory.com/entry/Spring-EventListener-%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-Refactoring

 

Spring @EventListener 를 활용한 Refactoring

<개요> - 서비스 내에서 로직 처리후 타 서비스 연동을 위해서 Service Call을 다시 하는 구조 - Spring에서 제공하는 ApplicationEvent, ApplicationListener를 활용하여 리팩토링 <내용> - 현재 서비스내에서 다..

icthuman.tistory.com

- 이후 Slack API를 호출하는 중 오류가 간헐적으로 발생함

 

<내용>

2022-02-15 13:01:43.176 DEBUG 9926 --- [nio-8800-exec-3] o.a.h.c.ssl.SSLConnectionSocketFactory   : Enabled protocols: [TLSv1.3, TLSv1.2, TLSv1.1, TLSv1]
2022-02-15 13:01:43.176 DEBUG 9926 --- [nio-8800-exec-3] o.a.h.c.ssl.SSLConnectionSocketFactory   : Enabled cipher suites:[TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, TLS_RSA_WITH_AES_256_GCM_SHA384, TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384, TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384, TLS_DHE_RSA_WITH_AES_256_GCM_SHA384, TLS_DHE_DSS_WITH_AES_256_GCM_SHA384, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256, TLS_DHE_RSA_WITH_AES_128_GCM_SHA256, TLS_DHE_DSS_WITH_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384, TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384, TLS_RSA_WITH_AES_256_CBC_SHA256, TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384, TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384, TLS_DHE_RSA_WITH_AES_256_CBC_SHA256, TLS_DHE_DSS_WITH_AES_256_CBC_SHA256, TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, TLS_RSA_WITH_AES_256_CBC_SHA, TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA, TLS_ECDH_RSA_WITH_AES_256_CBC_SHA, TLS_DHE_RSA_WITH_AES_256_CBC_SHA, TLS_DHE_DSS_WITH_AES_256_CBC_SHA, TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, TLS_RSA_WITH_AES_128_CBC_SHA256, TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256, TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256, TLS_DHE_RSA_WITH_AES_128_CBC_SHA256, TLS_DHE_DSS_WITH_AES_128_CBC_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, TLS_RSA_WITH_AES_128_CBC_SHA, TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA, TLS_ECDH_RSA_WITH_AES_128_CBC_SHA, TLS_DHE_RSA_WITH_AES_128_CBC_SHA, TLS_DHE_DSS_WITH_AES_128_CBC_SHA]
2022-02-15 13:01:43.176 DEBUG 9926 --- [nio-8800-exec-3] o.a.h.c.ssl.SSLConnectionSocketFactory   : Starting handshake
2022-02-15 13:01:43.221 DEBUG 9926 --- [nio-8800-exec-3] o.a.h.c.ssl.SSLConnectionSocketFactory   : Secure session established
2022-02-15 13:01:43.221 DEBUG 9926 --- [nio-8800-exec-3] o.a.h.c.ssl.SSLConnectionSocketFactory   :  negotiated protocol: TLSv1.3
2022-02-15 13:01:43.221 DEBUG 9926 --- [nio-8800-exec-3] o.a.h.c.ssl.SSLConnectionSocketFactory   :  negotiated cipher suite: TLS_AES_128_GCM_SHA256
2022-02-15 13:01:43.223 DEBUG 9926 --- [nio-8800-exec-3] o.a.h.conn.ssl.DefaultHostnameVerifier   : peer not authenticated

javax.net.ssl.SSLPeerUnverifiedException: peer not authenticated
	at java.base/sun.security.ssl.SSLSessionImpl.getPeerCertificates(SSLSessionImpl.java:526) ~[na:na]
	at org.apache.http.conn.ssl.DefaultHostnameVerifier.verify(DefaultHostnameVerifier.java:97) ~[httpclient-4.5.13.jar:4.5.13]
	at org.apache.http.conn.ssl.SSLConnectionSocketFactory.verifyHostname(SSLConnectionSocketFactory.java:503) ~[httpclient-4.5.13.jar:4.5.13]
	at org.apache.http.conn.ssl.SSLConnectionSocketFactory.createLayeredSocket(SSLConnectionSocketFactory.java:437) ~[httpclient-4.5.13.jar:4.5.13]
	at org.apache.http.conn.ssl.SSLConnectionSocketFactory.connectSocket(SSLConnectionSocketFactory.java:384) ~[httpclient-4.5.13.jar:4.5.13]
	at org.apache.http.impl.conn.DefaultHttpClientConnectionOperator.connect(DefaultHttpClientConnectionOperator.java:142) ~[httpclient-4.5.13.jar:4.5.13]
	at org.apache.http.impl.conn.PoolingHttpClientConnectionManager.connect(PoolingHttpClientConnectionManager.java:376) ~[httpclient-4.5.13.jar:4.5.13]
	at org.apache.http.impl.execchain.MainClientExec.establishRoute(MainClientExec.java:393) ~[httpclient-4.5.13.jar:4.5.13]
	at org.apache.http.impl.execchain.MainClientExec.execute(MainClientExec.java:236) ~[httpclient-4.5.13.jar:4.5.13]
	at org.apache.http.impl.execchain.ProtocolExec.execute(ProtocolExec.java:186) ~[httpclient-4.5.13.jar:4.5.13]
	at org.apache.http.impl.execchain.RetryExec.execute(RetryExec.java:89) ~[httpclient-4.5.13.jar:4.5.13]
	at org.apache.http.impl.execchain.RedirectExec.execute(RedirectExec.java:110) ~[httpclient-4.5.13.jar:4.5.13]
	at org.apache.http.impl.client.InternalHttpClient.doExecute(InternalHttpClient.java:185) ~[httpclient-4.5.13.jar:4.5.13]
	at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:83) ~[httpclient-4.5.13.jar:4.5.13]
	at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:56) ~[httpclient-4.5.13.jar:4.5.13]
	at org.springframework.http.client.HttpComponentsClientHttpRequest.executeInternal(HttpComponentsClientHttpRequest.java:87) ~[spring-web-5.3.7.jar:5.3.7]
	at org.springframework.http.client.AbstractBufferingClientHttpRequest.executeInternal(AbstractBufferingClientHttpRequest.java:48) ~[spring-web-5.3.7.jar:5.3.7]
	at org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:66) ~[spring-web-5.3.7.jar:5.3.7]
	at org.springframework.http.client.InterceptingClientHttpRequest$InterceptingRequestExecution.execute(InterceptingClientHttpRequest.java:109) ~[spring-web-5.3.7.jar:5.3.7]

- 동기방식으로 순차처리할 경우 문제가 없으나 비동기식으로 대량 동시호출시 해당 오류가 간헐적으로 발생하고 있음

 

<정리>

- 관련이슈를 살펴보면 다음과 같다.

TLSv1.3 handshake is failing, but passes on TLSv1.2 on JDK 11. It also doesn't fail in TLSv1.2 on JDK 10

TLS 1.3 사용시 SSLSessionImpl 생성할때 PostHandshakeContext로 부터 값을 가져와서 생성하게 됨 (not ClientHandshakeContext)

PostHandshakeContext은 requestedServerNames 에 값을 할당하지 않고 있어서 빈값으로 문제가 발생함

TransportContext의 conSession필드에서 서버목록을 가져오도록 하여 해결할 수 있음

 

<참조>

https://github.com/slackapi/java-slack-sdk/issues/482

https://github.com/slackapi/java-slack-sdk/issues/465#issuecomment-632150000

https://github.com/slackapi/java-slack-sdk/pull/452

 

Add a workaround for OpenJDK 11's bug by seratch · Pull Request #452 · slackapi/java-slack-sdk

Summary Sending HTTP requests to Slack API server on OpenJDK 11 fails due to https://bugs.openjdk.java.net/browse/JDK-8211806 Requirements (place an x in each [ ]) I've read and understood th...

github.com

https://bugs.openjdk.java.net/browse/JDK-8211806

+ Recent posts