<개요>

- 신규 프로젝트 진행시 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

 

<개요>

  • 테스트의 크기는 되도록이면 작게
  • 각 테스트 단위는 독립적으로 주입할 수 있도록
  • 필요한 것만 주입받고 테스트 해야하며
  • 최대한 빠르게 테스트 실행이 가능해야 함

<내용>

  • Layer
    • Controller
      • Web을 통한 호출시 PathVariable, RequestBody, Header, 인증등 을 담당하는 역할
    • Service
      • Biz Logic 구현
      • Biz Transaction 이 필요할 경우
    • Repository
      • Storage 와 Application 의 다리역할
      • 객체변환, 타입검증
    • POJO
      • 독립된 구현
      • 주로 Input / Output 을 통합 로직 수행
  • 공통사항
    • Given / When / Then 으로 작성하면 편함 (준비-실행-검증)
  • 각 Layer 별 테스트 코드는 다른 레이어가 정상동작한다는 가정으로 테스트하는 것이 원칙
    • Controller Layer
      • WebMvc에 관련된 Context만 로딩 (WebMvcTest)
      • 사용할 Bean들만 TestConfiguration 으로 정의하여 Context의 가동범위를 최소한 으로 한다.  
@RunWith(SpringRunner.class)
@WebMvcTest(BizController.class
)
@Import(SecurityConfig.class)
@ContextConfiguration(classes = SpringSecurityWebAuthTestConfig.class)
public class BizControllerTest {

    @Autowired
    private MockMvc mvc;
    @MockBean
    private BizService bizService;
   
    @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(bizService.findAllDatas(PageRequest.of(0,1))).willReturn(pages);
        //when , then
        this.mvc.perform(get("/datas/all?page=0&size=1"))
                .andExpect(jsonPath("content").isNotEmpty())
                .andExpect(jsonPath("content[0].dataId").value(1))
                .andExpect(status().isOk());
    }
    • 테스트 메소드 작성
      • MockBean : Mockup 대상
      • given : 테스트 범위내에서 정상동작할 경우의 응답, 혹은 주어진 조건
      • when : mvc.perform : 수행
      • then : andExpect : 기대값
  • Service Layer
    • JUnit으로만 테스트 (Spring Mvc 필요없음)
    • 테스트 대상인 Service 만 Inject, 나머지는 Mock
@RunWith(MockitoJUnitRunner.class)
public class BizServiceTest {
    @Mock
    private BizRepository dataRepository;
    @Mock
    private ModelMapper modelMapper;
    @InjectMocks
    private BizService bizService;

    @Test
    public void createService() throws Exception {
        DataDto dataDto = new DataDto();
        dataDto.setDataId(1);
        dataDto.setUserId(1);
        dataDto.setDataName("text");
        
        DataEntity dataEntity = new DataEntity();
        dataEntity.setDataId(1);
        dataEntity.setUserId(1);
        dataEntity.setDataName("text");
				
        //given
        given(modelMapper.map(dataDto, DataEntity.class)).willReturn(dataEntity);
        given(modelMapper.map(dataEntity, DataDto.class)).willReturn(dataDto);
        //when
        DataDto result = dataService.createData(dataDto);
        //then
        Assert.assertEquals(dataDto, result);

    }
      • 테스트 메소드 작성
        • given : 테스트 범위내에서 정상동작할 경우의 응답, 혹은 주어진 조건
        • when : 테스트 대상
        • then : assertEquals ( expected, actual)
  • Repository Layer
    • DataJpaTest 관련된 Context만 로딩
    • 사용할 Bean들만 정의하여 가동범위 최소한으로
    @RunWith(SpringRunner.class)
    @DataJpaTest
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    @Import({EnableEncryptablePropertiesConfiguration.class, JasyptConfig.class, SpringSecurityWebAuthTestConfig.class, TestJpaAuditingConfig.class})
    public class NewsRepositoryTest {
    
        @Autowired
        private NewsRepository newsRepository;
    
        @Test
        public void findAllByEnabled(){
            //given
            Pageable pageable = PageRequest.of(0,10);
    	//when
            Page<NewsEntity> newsEntityPage = newsRepository.findAllByEnabled(true, pageable);
    	//then
            Assert.assertEquals(1, newsEntityPage.getTotalElements());
        }
    • 테스트 메소드 작성
      • given : 테스트 범위내에서 정상동작할 경우의 응답, 혹은 주어진 조건
      • when : 테스트 대상
      • then : assertEquals ( expected, actual )
    • Local Test의 경우 H2 나 기타 메모리DB로 기동될때마다 테스트 데이터를 넣어놓으면 독립적인 테스트가 가능하기 때문에 편리하다.
  • 독립 Module
    • Context 기동없이 가능
    public class EmailValidationTest {
    
        @Test
        public void validation(){
            Pattern codePattern = PatternValidator.ValidationType.EMAIL.getMyPattern();
    
            Matcher matcher = codePattern.matcher("terst@gmail.com");
            Assert.assertTrue(matcher.matches());
    
            matcher = codePattern.matcher("test-1@naver.com");
            Assert.assertTrue(matcher.matches());
    • Bean주입없이 그냥 Java new로 POJO 테스트
public class JasyptTest {

	@Test
	public void encryptDecrypt() throws Exception {
		PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();
		SimpleStringPBEConfig config = new SimpleStringPBEConfig();
		config.setPassword("");
		config.setAlgorithm("PBEWithMD5AndDES");
		config.setKeyObtentionIterations("1000");
		config.setPoolSize("1");
		config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
		config.setStringOutputType("base64");
		encryptor.setConfig(config);

		String raw = "abcdefg";
		String encrypted = encryptor.encrypt(raw);

		String decrypted = encryptor.decrypt(encrypted);
		Assert.assertEquals(raw , decrypted);
	}

 

Git Remote Repository에 Push하기전에 Local Test Case를 모두 통과하는지 반드시 확인해야 한다.

<개요>

- @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);
}

<이전글>

https://icthuman.tistory.com/entry/AWS-Cognito-1-%EC%82%AC%EC%9A%A9%EC%9E%90%EA%B4%80%EB%A6%ACOAuth-20

 

AWS Cognito (1) - 사용자관리/OAuth 2.0

- 대부분의 서비스에서 공통적으로 필요한 기능이 사용자 관리/로그인 기능이다. 단순한 것 같지만 생각보다 많은 리소스가 필요한 시스템이다. (특히 권한연계까지 들어갈 경우) - 기존에는 자

icthuman.tistory.com

 

<개요>

- 가장 많이 사용되는 Authorization Code Grant방식을 이용해서 JWT Token 을 발급받는다.

- 토큰으로부터 claims를 얻고 유효한지 검증한다.

- 기존방식 토큰과의 호환성을 검토한다. (JWT / JWS, Secret Key / RSA)

 

<내용>

1. Authorization Code Request

- Cognito의 설정을 마쳤으니 호스팅UI를 통해서 로그인을 시도한다.

- 반드시 다음값을 파라미터로 넘겨주어야 한다.

 a. client_id : Cognito에서 생성한 클라이언트 ID

 b. response_type : code

 c. scope : resource에 접근할 수 있는 범위

 d. redirect_uri : authorization_code를 받아서 리다이렉트할 주소

 

2. Authorization Code Redirect ( redirect_uri )

- 해당 코드값을 통해서 /oauth2/token에 접근한다.

- access_token, id_token 등이 발급되면 응답을 처리한다.

- 코드로 살펴보면 다음과 같다.

CognitoTokenRequest cognitoTokenRequest = cognitoTokenRequestFactory.newCognitoTokenRequest(code);

        Mono<String> ret = webClient
                .post()
                .uri("/oauth2/token")
                .headers(h -> h.setBasicAuth(cognitoTokenRequest.getBasicAuth()))
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .body(BodyInserters.fromObject(cognitoTokenRequest.getMap()))
                .retrieve()
                .onStatus(HttpStatus::is4xxClientError, clientResponse -> Mono.error(new RuntimeException(clientResponse.bodyToMono(String.class).block())))
                .bodyToMono(String.class)
                .onErrorReturn(null);
        ;

        CognitoTokenResponse cognitoTokenResponse = null;
        String temp = ret.block(Duration.ofMillis(cognitoTokenApiTimeout));
        try {
            cognitoTokenResponse = objectMapper.readValue(temp , CognitoTokenResponse.class );
        } catch (JsonProcessingException e) {
            log.error("error cognito's response is not valid", e.getLocalizedMessage());
        }
        return CompletableFuture.completedFuture(cognitoTokenResponse);

- 일반적으로 MediaType.APPLICATION_FORM_URLENCODED 방식으로 호출할 경우 MultiValueMap 을 사용하게 되는데 해당 값을 FirstCollection 형태로 처리하고 Factory를 통해서 만들도록 하여 소스를 간결하게 처리했다.

- 또한 /oauth2/token에 접근하기 위해서는 basicAuth로 인증을 해야 하는데 이 때 필요한 Secret값은 다음과 같은 형태로 생성한다.

Base64.getEncoder().encodeToString( (clientId+":" + clientSecret).getBytes() );

- Response는 다음과 같은 형태로 확인할 수 있다.

{
"access_token":
    "id_token":
    "tokenType":
    "expiresIn":
}

- 이 때도 주의해야 하는 점이 있는데 AWS Cognito의 응답구조는 cameCase가 아니라 snakeCase로 넘어온다

ObjectMapper objectMapper = new ObjectMapper().setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);

- Mono,CompletableFuture에 대해 부연설명을 하자면 Spring WebFlux에서는 비동기/논블로킹을 일반적인 처리형태로 가져간다.

 다만 이번 소스처럼 일부 동기화 구간이 필요할 경우 block() 을 사용하게 되고 이때 별도의 쓰레드로 대기하도록 ComletableFuture를 활용하여 @Async 처리를 하는 것이 좋다. 과거글 참조

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

 

3. JWT Token 

- JWT Token은 {header}.{body}.{signature} 의 형태로 구성되어 있으며 Cognito에서 응답받은 accessToken토큰의 내용을 확인해보면 다음과 같다.

- Header부분에 해당 토큰을 생성할 때 사용된 알고리즘과 공개키(비대칭)방식일 경우 kid가 포함된다.

  이를 통해서 서명을 검증할 수 있으며 공개키는 발급자가 제공하는 uri에서 확인할 수 있고, 일반적으로 다음과 같다.

   {issuer_uri}/.well-known/jwks.json

 

- 알고리즘의 대칭 / 비대칭 알고리즘에 대해서는 따로 설명해야 할 만큼 긴데 단순히 요약하면

 a. 대칭 알고리즘 : 서로 같은 키를 사용

 b. 비대칭 알고리즘 : 서로 다른 키를 사용

  e.g) 개인키로 암호화하여 서명을 첨부하고, 공개키를 통해서 서명을 검증할 수 있다.

 

4. JWT Token Encode / Decode (Java)

- io.jsonwebtoken 의 DefaultJwtParser를 살펴보면 세부로직을 좀더 잘 파악할 수 있다.

-  기본 내부에서 사용하던 토큰은 이와 같은 방식으로 되어 있으며 secretKey를 사용하는 대칭 알고리즘 방식이었다.

Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(refreshToken);

(변수)  = claims.getBody().get("userId");
...
...

그러다 보니 같은 방식으로는 Cogtino의 토큰을 처리할 수 없어서 일단 다음과 같은 방식으로 변경하였다.

Jwt<Header, Claims> preclaims = Jwts.parser().parseClaimsJwt(getUnsignedTokenFromOriginalToken(token));


private static String getUnsignedTokenFromOriginalToken(String token){
        String[] splitToken = token.split("\\.");
        String unsignedToken = splitToken[0] + "." + splitToken[1] + ".";
        return unsignedToken;
} // {[0]}.{[1]}.

- 이와 같이 {signature} 부분을 제외하고 body부분을 읽어올 수 있으며 Header에 대한 정보도 같이 처리가 가능하다.

 다만 누군가 토큰을 위조할 수 있는 가능성이 있어서 아래 Verifier에서 약간 보완을 해보았다.

 

5. JWT / JWS / JWE / JWK

- JWT Token에 대해서 살펴보면 관련된 용어들이 자주 나오는데 간단히 살펴보면 다음과 같다.

 

 A. JWT : 인증을 위한 일반적인 메커니즘. JWS 나 JWE 로 구현된다. (implements)

 [header].[payload].[signature]

 

 B. JWS(JSON Web Signature)

 Claim의 내용이 노출되고 디지털 서명을 하는 방식이다. (Client 가 Claim을 사용하기 위해서 일반적으로 JWS를 사용한다.)

 다음 Signature 에서 일반적으로 사용되는 알고리즘이다.

  • HMAC using SHA-256 or SHA-512 hash algorithms (HS256, HS512)
  • RSA using SHA-256 or SHA-512 hash algorithms (RS256, RS512)

 

C. JWE(JSON Web Encryption)

Claim 자체를 암호화시키는 방식

"header":
{
    "alg" : "RSA-OAEP",                --------------------> For content encryption 
    "enc" : "A256GCM"                  --------------------> For content encryption algorithm
},
 "encrypted_key" : "qtF60gW8O8cXKiYyDsBPX8OL0GQfhOxwGWUmYtHOds7FJWTNoSFnv5E6A_Bgn_2W"
"iv" : "HRhA5nn8HLsvYf8F-BzQew",       --------------------> initialization vector
"ciphertext" : "ai5j5Kk43skqPLwR0Cu1ZIyWOTUpLFKCN5cuZzxHdp0eXQjYLGpj8jYvU8yTu9rwZQeN9EY0_81hQHXEzMQgfCsRm0HXjcEwXInywYcVLUls8Yik",
"tag" : "thh69dp0Pz73kycQ"             --------------------> Authentication tag
}

 

D. JWK(JSON Web Key)

private key로 만들어진 signature 를 검증하기 위해서 public key를 제공하는 구조이다.

{
"alg":"RSA",

"mod": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMs
tn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbI
SD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",

"exp":"AQAB",

"kid":"2011-04-29"
}

 

 

6. Customize Token Verifier

private LoginUserDetails makeUserDetailsFromToken(String token) throws TimeoutException, ExecutionException, InterruptedException {
        Jwt<Header, Claims> preclaims = Jwts.parser().parseClaimsJwt(getUnsignedTokenFromOriginalToken(token));
        String iss = (String)preclaims.getBody().get("iss");

        if(StringUtils.hasText(iss) && iss.startsWith("https://cognito-idp.ap-northeast-2.amazonaws.com")){  // 1. Cognito Token

            CognitoUserInfoResponse cognitoUserInfoResponse = cognitoService.getAuthInfoFromAccessToken(token).get(3, TimeUnit.SECONDS);
            if(cognitoUserInfoResponse== null || cognitoUserInfoResponse.getSub().isEmpty()){
                return null;
            }
            Jwt<Header, Claims> claims = Jwts.parser().parseClaimsJwt(getUnsignedTokenFromOriginalToken(token));
        	...
            Collection<GrantedAuthority> authorityList = new ArrayList<>();
            List<String> cognitoGroups = (List<String>)claims.getBody().get("cognito:groups");
            if(null != cognitoGroups) {
                for (String group : cognitoGroups) {
                    authorityList.add(new SimpleGrantedAuthority(group));
                }
            }
            return new LoginUserDetails(......, authorityList);
        }else{                                                                                                // 2. Legacy Token
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            ...
            Collection<GrantedAuthority> authorityList = new ArrayList<>();
            List<String> authorities = (List<String>)claims.getBody().get("roles");
            if(null != authorities) {
                for (String auth : authorities) {
                    authorityList.add(new SimpleGrantedAuthority(auth));
                }
            }
            return new LoginUserDetails(..., authorityList);
        }
    }

 

 A. 발급자가 Cognito라면

 -- 발급된 토큰이 정상인지 확인요청을 한다. (/oauth/userInfo)

   => 이부분은 사실 원격요청을 하지 않고 제공되는 공개키, pem을 통해서 로컬에서 검증이 가능하지만 현재 java로는 직접 구현해야 하는 부분이 많고, 자주는 아니지만 공개키는 변경될 수 있기 때문에 키값을 확인하기 위해서는 결국 원격요청이 필요해서 쉽게 가기로 했다. 

 -- "cognito:groups" 정보를 이용해서 권한을 세팅한다.

 B. 기존 토큰이라면

 - secretKey를 이용해서 검증하고

 - "roles" 정보를 이용해서 권한을 세팅한다.

 

<정리>

이렇게 만들면 기존의 JWT Token(HS256) 와 신규 Cognito Token (RSA256) 를 모두 소화할 수 있다.

그러나 만들어 놓고보니 소스가 지저분하다. ResourceServer에서 권한을 확인하여 API접근제어를 해야하는데 좋은 구조는 아니다.

  - 로직이 복잡하고(구조 특성상 해당모듈을 그대로 copy해가는 방식이 될텐데 유지보수성이...영), secretKey 공유의 문제도 있다.

  - 꼭 두 가지 토큰을 소화할 필요가 있을까?

  - 기존 방식은 외부 사용자가 고려되지 않은 방식이고, OAuth 2.0은 출발자체가 3rd Party연계를 간편하게 하기 위한 방법이다.

 

다양한 확장을 위해서 인증은 각각의 방식을 유지하고 서비스를 사용자에 따라서 분리하는 것이 해결책이라는 판단을 내렸다.

- 내부 사용자가 이용하며 Client, Resource Server가 같은 Boundary에 있는 레거시의 경우 기존 토큰을 사용하고

- 외부 사용자가 이용해야 하는 API 서비스는 별도로 분리 구성하며, 이 서비스에서는 OAuth 2.0만을 활용하여 인가/인증을 하도록 한다.

  (이부분은 AWS API Gateway내에서 자격증명으로 Cognito를 연계하는 방식과 시너지를 낼 수 있을 것 같다.)

 

- 이를 위해서 보다 쉽게 Resource Server를 세팅하는 법을 Spring Security에서 제공하고 있는데 다음글에서 좀 더 자세히 적용해보고 서비스를 어떻게 분리하는 것이 보다 효과적일지 살펴본다.

 

<참조>

https://aws.amazon.com/ko/premiumsupport/knowledge-center/decode-verify-cognito-json-token/

 

Cognito JSON 웹 토큰의 서명 디코딩 및 확인

Amazon Cognito JSON 웹 토큰의 서명을 디코딩 및 확인하려면 어떻게 해야 합니까? 최종 업데이트 날짜: 2022년 9월 6일 Amazon Cognito 사용자 풀을 애플리케이션의 인증 방법으로 사용하고 싶습니다. 클라

aws.amazon.com

https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html

 

Verifying a JSON web token - Amazon Cognito

Amazon Cognito might rotate signing keys in your user pool. As a best practice, cache public keys in your app, using the kid as a cache key, and refresh the cache periodically. Compare the kid in the tokens that your app receives to your cache. If you rece

docs.aws.amazon.com

https://www.loginradius.com/blog/engineering/guest-post/what-are-jwt-jws-jwe-jwk-jwa/

 

What are JWT, JWS, JWE, JWK, and JWA? | LoginRadius Blog

Learn about the JOSE framework and its specifications, including JSON Web Token (JWT), JSON Web Signature (JWS), JSON Web Encryption (JWE), JSON Web Key (JWK), and JSON Web Algorithms (JWA). For easier reference, bookmark this article.

www.loginradius.com

 

<개요>

- 대부분의 서비스에서 공통적으로 필요한 기능이 사용자 관리/로그인 기능이다. 단순한 것 같지만 생각보다 많은 리소스가 필요한 시스템이다. (특히 권한연계까지 들어갈 경우)

- 기존에는 자체적으로 사용자 관리 시스템을 운영하고 있었다. (Password Grant방식)

 그 이유는 특별한 사용자의 정보를 담지 않고 있으며 인증, Resource Server, Client가 모두 같은 System Boundary에 포함되어 있었기 때문에 가장 간편한 방식으로 개발하였다.

- 이제부터는 관리기능을 확장시키고 추가 정보를 담아야 하는 요건이 생겼으며, 이를 위해서 사용자 인증에 관련된 부분을 별도 시스템으로 분리하여 구성하는 것이 좋다고 판단하였다.

- AWS Cognito의 경우 외부연동, MFA인증등 편리한 기능을 기본적으로 가지고 있으며 JWT Token, IAM 연동도 가능한 것으로 보여서 가능성을 검토해본다.

 a. 기존 시스템을 유지하면서 개선할 것인지 (Spring Boot / Security / JWT Token)

 b. 새로 개발할 것인지 (e.g AWS Cognito)

 c. 혹은 a,b를 혼합하여 가져갈 수 있을지 (커스터마이징의 범위)

 

<내용>

1. 사용자 풀

사용자 풀

- 사용자를 생성하고 그룹을 할당할 수 있다.

- 이메일 확인 및 비밀번호 초기화 등의 기능을 기본으로 제공한다.

- 그룹은 개별적으로 추가 가능하다.

- AWS Cognito user pool은 기본적으로 대소문자를 구분하지 않도록 되어있다. (권장사항)

  만약에 대소문자를 구별하고 싶다면 Make user name case sensitive 옵션을 활성화 해야한다.

https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-case-sensitivity.html

 

User pool case sensitivity - Amazon Cognito

User pool case sensitivity Amazon Cognito user pools that you create in the AWS Management Console are case insensitive by default. When a user pool is case insensitive, user@example.com and User@example.com refer to the same user. When user names in a use

docs.aws.amazon.com

 

2. 연동자격증명 공급자

- 우리가 SNS연동을 하기 위해서는 일반적으로 App을 생성하고 할당받은 Client ID, Secret을 이용하여 API호출 및 연계개발을 하게 되는데 이부분이 쉽게 설정으로 가능하도록 되어있다. (많이 사용하는 Google / Apple / Facebook)

- 그 외에도 표준 OIDC를 준수한다면 쉽게 추가하고 속성을 매핑할 수 있는 구조로 되어있다.

자격 증명 공급자 : Google

 

3. SMS / SNS

-  스타트업에서는 내부에 문자 / 이메일 발송기능이 없는 경우도 많은데 AWS SES / SNS 등과 쉽게 연동하여 활용이 가능하다.

- 단, SMS는 현재 도쿄 리전에서만 사용이 가능하며 샌드박스 내에서는 등록된 번호로만 발신이 가능하다.

 

4. App 구성

- AWS Cognito도 결국 우리가 만든 서비스와 연결되기 위해서 여러가지 요소가 필요한데 이에 대한 부분도 제공하고 있다.

- 도메인 : Cognito 도메인 or 사용자 지정도메인 가능

- 리소스 서버 구성 (OAuth 2.0)

- 호스팅 UI, 앱 클라이언트

 

5. OAuth 2.0 

- 앱 유형을 선택하기 전에 OAuth 2.0에 대한 기본적인 이해도가 있으면 도움이 된다.

https://icthuman.tistory.com/entry/OAuth-20-Flow

 

OAuth 2.0 Flow

1. Authorization Code - 권한 부여 승인을 위해서 자체생성한 Authorization Code를 전달하는 방식 - 기본이 되는 방식 - Refresh Token 사용이 가능 2. Client Credentials - 클라이언트의 자격증명만으로 Access Token을

icthuman.tistory.com

 

6. 앱 클라이언트

- 앱 클아이언트 부분을 좀 더 자세히 살펴보면 다음과 같다.

- 클라이언트 보안키 생성유무 선택에 따라서 추후 토큰발급 엔드포인트를 호출할 때 방법에 차이가 있다.

- 세션, 토큰( Id/Access/Refresh )별 만료시간을 선택할 수 있으며 Revoke Token에 대해서 선택사항으로 정할 수 있다.

- 사용자를 찾을 수 없다고 응답을 하면 해킹에 취약해질 수 있기 때문에 최근 로그인 시스템에서는 이름 또는 암호가 잘못되었다고 묶어서 표현하고 있다.

 

6. 호스팅 UI

- 자체적으로 로그인 UI 를 제공해준다.

- 주의할 점은 사용자 풀에서 연동자격증명을 선택하였다면 여기서도 선택을 해주어야 UI 상에서 버튼이 활성화 된다.

- 해당 호스팅UI는 내가 등록한 App에서만 사용하는 것이다. 따라서 호출할 때 다음 파라미터들을 전달해주어야 정상적으로 동작한다.

- client_id, response_type, scope, redirect_uri

 

<정리>

- AWS Cognito에서 User Pool을 생성하고 Identity Providers를 추가한 뒤 호스팅 UI 를 통해서 로그인/가입까지 완료했다.

- 다음 글에서는 OAuth 2.0 기본 Flow를 따라서 Cognito 사용자 풀과 호스팅 UI를 연동하여 Authorization Code를 발급받고

- 이를 기반으로 엔드포인트에 접근하여 Token발급, UserInfo 조회하는 기능을 살펴본다.

 

<참고>

- https://aws.amazon.com/ko/cognito/details/

 

기능 | Amazon Cognito | Amazon Web Services(AWS)

 

aws.amazon.com

- https://docs.aws.amazon.com/ko_kr/cognito/latest/developerguide/what-is-amazon-cognito.html

 

Amazon Cognito란 무엇입니까? - Amazon Cognito

Amazon Cognito란 무엇입니까? Amazon Cognito는 웹 및 모바일 앱에 대한 인증, 권한 부여 및 사용자 관리를 제공합니다. 사용자는 사용자 이름과 암호를 사용하여 직접 로그인하거나 Facebook, Amazon, Google

docs.aws.amazon.com

 

<가상의 시나리오>

- 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

 

<개요>

- 서비스 내에서 로직 처리후 타 서비스 연동을 위해서 Service Call을 다시 하는 구조

- Spring에서 제공하는 ApplicationEvent, ApplicationListener를 활용하여 리팩토링

 

<내용>

- 현재 서비스내에서 다음과 같이 특정 상황이 발생했을 때 Noti하기 위해서 Slack으로 메시지를 보내는 로직들이 구성되어 있다.

 @Async("executorBean")
    public CompletableFuture<Boolean> refreshCount(){

        log.info("Before Current API Call remains {} ", countCallService.getCallCount());
        if(countCallService.getCallCount() > 0){
            sendSlackMessageInfo("");
        }
        List<MetaDto> metaList = filterService.findAllMetas();

        sendSlackMessageInfo( String.format("%s %s", metaList.size(), "metas") );
        log.info(...);
        ...
        
    }
    
    private void sendSlackMessageInfo(String text){
        log.info(text);
        SlackMessage message = SlackMessage
                .builder()
                .text(text)
                .color(SlackMessage.Color.GOOD)
                .build();
        slackMessageService.postMessage(message);
    }

 

* 문제점

- 비지니스와 관계없는 SlackService와 Dependency가 발생한다.

- Slack 이외에 다른 연동이나 로직이 추가될 수록 코드가 지저분해진다. 

- 실제 비지니스 로직과 관계없는 Message 조립에 대한 로직이 포함되고 있다.

 

<Refactoring #1>

1. Service

 @Async("executorBean")
    public CompletableFuture<Boolean> refreshCount(){

        log.info("Before Current API Call{} ", countCallService.getCallCount());
        if(countCallService.getCallCount() > 0){
             eventPublisher.publishEvent(new RefreshMetaErrorEvent(""));
        }
        List<MetaDto> metaList = filterService.findAllMetas();

        eventPublisher.publishEvent(new RefreshMetaStartEvent(metaList.size()));
        log.info(...);
        ...
        
    }

Biz Service내에서는 Event만 publish 하도록 변경하였다.

 

2. EventHandler

public class RefreshMetaEventHandler {

    private final SlackMessageService slackMessageService;

    @EventListener
    public void errorMessage(final RefreshMetaErrorEvent refreshMetaErrorEvent){
        final String message = refreshMetaErrorEvent.getMessage();

        sendSlackMessageInfo(message);
    }

    @EventListener
    public void startMessage(final RefreshMetaStartEvent refreshMetaStartEvent){
        final int count = refreshMetaStartEvent.getCount();
        final String message =  String.format("%s %s", count, "filters");
        sendSlackMessageInfo(message);
    }

    private void sendSlackMessageInfo(String text){
        log.info(text);
        SlackMessage message = SlackMessage
                .builder()
                .text(text)
                .color(SlackMessage.Color.GOOD)
                .build();
        slackMessageService.postMessage(message);
    }
}
@Getter
@RequiredArgsConstructor
public class RefreshMetaStartEvent {

    private int count;
}
@Getter
@RequiredArgsConstructor
public class RefreshMetaErrorEvent {

    private String message;
}

- 상황에 따라 Event를 수신하여 처리하는 로직은 모두 EventHandler로 모아서 구성하였다. (Service Dependency정리)

- slack연동외의 다른 로직을 추가하기 간결한 구조로 정리되었다.

 

* 남은 문제점

- Event가 많아질 수록 메소드가 늘어난다.

- EventHandler나 SlackMessageService의 경우 각 Message의 전달만을 책임져야 한다.

- Message조립에 대한 로직은 분리되는 것이 맞다.

 

<Refactoring #2>

1. Abstract Class 및 Concrete Class 정의

public abstract class AbstractRefreshMetaEvent {

    public abstract String getMessage();
}
public class RefreshMetaStartEvent extends AbstractRefreshMetaEvent{

    private final int count;

    @Override
    public String getMessage() {
        return String.format("%s %s", count, "metas");
    }
}
public class RefreshMetaDemoEvent extends AbstractRefreshMetaEvent {

    private final String svcColumnName;
    private final int count;

    @Override
    public String getMessage() {
        return String.format("%s has %s data",svcColumnName, count);
    }
}
public class RefreshMetaErrorEvent extends AbstractRefreshMetaEvent{

    private final String message;
}

- 각 Event별로 Message 조립에 대한 역할을 가져가도록 분리한다.

- 필요에 따라서 세부 Event를 추가로 정의하고 각 상황에 맞게 상세구현을 진행할 수 있다.

 

2. EventHandler

public class RefreshMetaEventHandler {

    private final SlackMessageService slackMessageService;

    @Async
    @EventListener
    public void sendMessage(final AbstractRefreshMetaEvent refreshMetaEvent){
        final String message = refreshMetaEvent.getMessage();

        sendSlackMessageInfo(message);
        // 연동 및 로직 추가구성 가능 
    }

    private void sendSlackMessageInfo(String text){
        log.info(text);
        SlackMessage message = SlackMessage
                .builder()
                .text(text)
                .color(SlackMessage.Color.GOOD)
                .build();
        slackMessageService.postMessage(message);
    }
}

- AbstractRefreshMetaEvent 타입으로 정리하고 Slack으로 전송하는 로직만 남게된다.

- 추가 연동이 발생하거나 로직 수정시 타 소스에 영향도가 낮다.

 

<정리>

- Event 발행구조 : 해당 Event발생에 따른 후속 상세로직은 Biz Service에서 알 필요가 없다.

- AbstractClass 를 활용하여 추상화하고, 세부 메세지 조립에 대한 로직은 상세클래스에서 구현한다.

- 후속 로직은 요건에 따라 비동기로 처리하여 Biz Logic에 영향이 없도록 한다.

 (비동기/넌블러킹 기술스택으로 변경하여 throughput 을 증가시키는 것은 덤!)

- 구조가 기술보다 우선이다.

 

<개요>

- WebSecurityConfigurerAdapter 사용

- Annotation기반의 설정

 

<내용>

 - 기본적인 세팅 방법 및 제공메소드를 알아본다.

 - 특정 IP 의 호출만 가능하도록 해본다. (white list)

 - hasIpAddress 활용을 위한 API, SpEL을 사용해본다.

 - IP subnet , IPv4/v6 차이점 확인

 

1.  WebSecurityConfigurerAdapter

 - 우리가 일반적으로 가장 많이 사용하는 방법이다.

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	@Override
    public void configure(HttpSecurity http) throws Exception
    {
        http
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()

                .authorizeRequests()

                // swagger-ui 관련 접속 url - permitAll 추가
                .antMatchers("/v2/api-docs/**").permitAll()
                .antMatchers("/swagger.json").permitAll()
                .antMatchers("/swagger-ui.html").permitAll()
                .antMatchers("/swagger-resources/**").permitAll()
                .antMatchers("/webjars/**").permitAll()

- Session은 STATELESS를 기본으로 하며 각 url에 permitAll() 권한을 부여하는 것으로 시작하였다. 

- 상세한 정책은 antMatchers() 를 사용하며 permitAll(), anonymous(), denyAll(), authenticated(), hasAuthority()등 다양한 방법을 통해서 정의가 가능하다.

  • hasRole() or hasAnyRole()
    특정 역할을 가지는 사용자
  • hasAuthority() or hasAnyAuthority()
    특정 권한을 가지는 사용자
  • hasIpAddress()
    특정 아이피 주소를 가지는 사용자
  • permitAll() or denyAll()
    접근을 전부 허용하거나 제한
  • rememberMe()
    리멤버 기능을 통해 로그인한 사용자
  • anonymous()
    인증되지 않은 사용자
  • authenticated()
    인증된 사용자

- 내부 구현방식을 알아보기 위해서 Spring 내부소스를 보면 다음과 같다.

public final class ExpressionUrlAuthorizationConfigurer<H extends HttpSecurityBuilder<H>>
		extends
		AbstractInterceptUrlConfigurer<ExpressionUrlAuthorizationConfigurer<H>, H> {
	static final String permitAll = "permitAll";
	private static final String denyAll = "denyAll";
	private static final String anonymous = "anonymous";
	private static final String authenticated = "authenticated";
	private static final String fullyAuthenticated = "fullyAuthenticated";
	private static final String rememberMe = "rememberMe";

public ExpressionInterceptUrlRegistry permitAll() {
			return access(permitAll);
		}

		public ExpressionInterceptUrlRegistry anonymous() {
			return access(anonymous);
		}

		public ExpressionInterceptUrlRegistry rememberMe() {
			return access(rememberMe);
		}

		public ExpressionInterceptUrlRegistry denyAll() {
			return access(denyAll);
		}

 - 내부적으로 SpEL을 활용하고 access메소드를 통해서 처리하는 것을 볼 수 있다.

 

2. Annotation을 통한 관리

 - @PreAuthorize, @PostAuthorize, @Secured 를 사용하여 Controller레벨에서 메소드별 관리도 가능하다.

@RestController
public class FilterController {

	@PreAuthorize("hasRole('ROLE_ADMIN')")
	@RequestMapping(value = "/api/a", method = RequestMethod.POST)
    public @ResponseBody
    List<String> testMethod()throws Exception {
		...
    }

 - SpEL을 사용하는 등 내부적인 처리는 1번방식과 동일하다.

 - 다음과 같은 전역설정을 필요로 하며 되도록 @Configuration 과 같은 성격들은 한곳에 모아두는 것을 추천한다.

  @EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true

 

3. hasIpAddress 예제

- SecurityConfig에 다음과 같이 추가를 하게되면 로컬에서만 /login 으로 접근하는 것이 가능하다.

.authorizeRequests()
.antMatchers("/login").hasIpAddress("127.0.0.1")

- 이 때 IP, Subnet등의 다양한 방법이 가능하다.

 e.g) 192.168.1.0/24, 172.16.0.0/16

- 여러 개의 ip를 list로 처리하고 싶다면? hasIpAddress()로는 처리가 어렵다. 힌트는 1번에서 살펴보았던 access()메소드이다.

  hasIpAddress()도 내부적으로 access()를 통해서 처리하고 있다.

    public ExpressionInterceptUrlRegistry hasIpAddress(String ipaddressExpression) {
			return access(ExpressionUrlAuthorizationConfigurer
					.hasIpAddress(ipaddressExpression));
    }

- 즉, hasIpAdress("ip #1") or hasIpAdress("ip #2") 의 형태로 SpEL을 작성하여 access()메소드를 호출하면 될 것 같다.

 

4. 구현소스

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {


	@Override
    public void configure(HttpSecurity http) throws Exception
    {
        http
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()

                .authorizeRequests()
                .antMatchers("/login").access( buildSpEL(ssoProperties.getAllowedIps()) )
                
                ...
                
                
    private String buildSpEL(List<String> ipLists){

        if(ipLists.isEmpty()){
            return "hasIpAddress('0.0.0.0/0')";
        }

        return ipLists.stream()
                .map(s -> String.format("hasIpAddress('%s')",s) )
                .collect(Collectors.joining(" or "));

    }

- 특정 ip 목록이 추가될 경우 SpEL처리를 하며, 빈 값이 넘어올 경우 전체 ip에 대해서 허용해주도록 한다.

@ConfigurationProperties(prefix = "1.2.3")
@Getter
@Setter
@ToString
public class Properties {
	private SsoProperties ssoProperties;
}


public class SsoProperties {
    private List<String> allowedIps = new ArrayList<>();
}

- ip목록의 경우 수시로 바뀔수 있기 때문에 별도 properties를 활용하도록 한다.

 

<수행결과>

- FilterSecurityInterceptor에 의해서 처리되었으며 처리 결과는 다음과 같다.

localhost주소 127.0.0.1

<주의사항>

- IPv6를 사용하고 있을 경우 ip형식이 다음과 같이 처리가 되기 때문에 허용주소를 "127.0.0.1"로 한 경우 403오류가 발생한다.

IPv6

- IPv6로 주소를 사용하거나 JVM 기동시 -Djava.net.preferIPv4Stack=true 옵션을 사용하면 된다.

 

<참조>

https://namu.wiki/w/%EC%84%9C%EB%B8%8C%EB%84%B7%20%EB%A7%88%EC%8A%A4%ED%81%AC

https://ko.wikipedia.org/wiki/IPv6

 

IPv6 - 위키백과, 우리 모두의 백과사전

IPv6(Internet Protocol version 6)는 인터넷 프로토콜 스택 중 네트워크 계층의 프로토콜로서 버전 6 인터넷 프로토콜(version 6 Internet Protocol)로 제정된 차세대 인터넷 프로토콜을 말한다. 인터넷(Internet)은

ko.wikipedia.org

 

+ Recent posts