Web/Spring Security&OAuth 2019. 4. 17. 23:14

오늘 포스팅할 내용은 Spring Security이다. 사실 필자는 머리가 나빠서 그런지 모르겠지만, 아무리 구글링을 통해 스프링 시큐리티를 검색해도 이렇다할 명쾌한 해답을 얻지 못했다. 대부분 이론적인 설명들은 잘 해주시는 분들이 많지만 실사례로 묵은 채증을 내려주지는 못했다.레퍼런스 또한 마찬가지이다. 영어를 잘 못하는 나로써는 잘 안되는 머릿속 몇 안되는 영어단어를 떠올리고 혹은 정말 모르는 문장은 번역기를 돌려서 보니 오히려 혼란만 가중되었다. 그래서 맘먹고 몇일을 스프링 시큐리티를 파고 들어보니 어느정도 사용할 정도가 되어서 이렇게 포스팅하게 된다. 아마 포스팅은 3~4개 정도로 이어질 것같다. 이번 포스팅에서는 토큰 기반 인증 직전까지의 포스팅이 될 것같고, 다음 포스팅은 이어서 JWT 토큰을 이용할 것이며 마지막으로는 OAuth2.0 포스팅으로 마무리를 지을 것같다. 우선 예제를 들어가기전에 스프링 시큐리티(Spring Security)란 무엇이고, 어떠한 아키텍쳐와 사상으로 이루어져있는지 설명할 것이다. 마지막으로 이번 포스팅을 보며 필자와 같이 스프링 시큐리티에 대해 헤매고 있는 분의 묵은 채증이 쑥 하고 내려갔으면 한다. 모든 예제는 spring boot 2.1.3.RELEASE으로 진행하였고, 모든 설정은 Java Config로 진행하였다.

 

보안 용어

필자가 솔직하게 스프링 시큐리티에 대해 알아보려고 많은 블로그를 돌아다니고 레퍼런스를 보면서 이해하지 못했던 이유중 하나는 바로 보안 용어에 대한 이해가 부족했기 때문이라고 생각한다. 스프링 시큐리티를 접하기 이전 밑에서 나열할 보안 용어들은 정말 필수로 숙지해야 할 것 같다. 

 

  • 접근 주체(Principal) : 보호된 리소스에 접근하는 대상
  • 인증(Authentication) : 보호된 리소스에 접근한 대상에 대해 이 유저가 누구인지, 애플리케이션의 작업을 수행해도 되는 주체인지 확인하는 과정(ex. Form 기반 Login)
  • 인가(Authorize) : 해당 리소스에 대해 접근 가능한 권한을 가지고 있는지 확인하는 과정(After Authentication, 인증 이후)
  • 권한 : 어떠한 리소스에 대한 접근 제한, 모든 리소스는 접근 제어 권한이 걸려있다. 즉, 인가 과정에서 해당 리소스에 대한 제한된 최소한의 권한을 가졌는지 확인한다.

Spring Security(스프링 시큐리티)란?

스프링 시큐리티는 스프링 기반의 애플리케이션의 보안(인증과 권한,인가 등)을 담당하는 스프링 하위 프레임워크이다. 주로 서블릿 필터와 이들로 구성된 필터체인으로의 위임모델을 사용한다. 그리고 보안과 관련해서 체계적으로 많은 옵션을 제공해주기 때문에 개발자 입장에서는 일일이 보안관련 로직을 작성하지 않아도 된다.

 

 

위의 그림은 Form 기반 로그인에 대한 플로우를 보여주는 그림이다.

  1. 사용자가 Form을 통해 로그인 정보를 입력하고 인증 요청을 보낸다.
  2. AuthenticationFilter(사용할 구현체 UsernamePasswordAuthenticationFilter)가 HttpServletRequest에서 사용자가 보낸 아이디와 패스워드를 인터셉트한다. 프론트 단에서 유효성검사를 할 수도 있지만, 무엇보다 안전! 안전을 위해서 다시 한번 사용자가 보낸 아이디와 패스워드의 유효성 검사를 해줄 수 있다.(아이디 혹은 패스워드가 null인 경우 등) HttpServletRequest에서 꺼내온 사용자 아이디와 패스워드를 진짜 인증을 담당할 AuthenticationManager 인터페이스(구현체 - ProviderManager)에게 인증용 객체(UsernamePasswordAuthenticationToken)로 만들어줘서 위임한다.
  3. AuthenticationFilter에게 인증용 객체(UsernamePasswordAuthenticationToken)을 전달받는다.
  4. 실제 인증을 할 AuthenticationProvider에게 Authentication객체(UsernamePasswordAuthenticationToken)을 다시 전달한다.
  5. DB에서 사용자 인증 정보를 가져올 UserDetailsService 객체에게 사용자 아이디를 넘겨주고 DB에서 인증에 사용할 사용자 정보(사용자 아이디, 암호화된 패스워드, 권한 등)를 UserDetails(인증용 객체와 도메인 객체를 분리하지 않기 위해서 실제 사용되는 도메인 객체에 UserDetails를 상속하기도 한다.)라는 객체로 전달 받는다.
  6. AuthenticationProvider는 UserDetails 객체를 전달 받은 이후 실제 사용자의 입력정보와 UserDetails 객체를 가지고 인증을 시도한다.
  7. 8. 9. 10. 인증이 완료되면 사용자 정보를 가진 Authentication 객체를 SecurityContextHolder에 담은 이후 AuthenticationSuccessHandle를 실행한다.(실패시 AuthenticationFailureHandler를 실행한다.)

여기까지 간단히 Form 로그인에 대한 플로우를 설명했다. 사실 글로 보면 이해가 잘되지 않을 수 있다. 이후에 실제 코드를 예를 들어서 설명할 것이다.

 

 

  • SecurityContextPersistenceFilter : SecurityContextRepository에서 SecurityContext를 가져오거나 저장하는 역할을 한다. (SecurityContext는 밑에)
  • LogoutFilter : 설정된 로그아웃 URL로 오는 요청을 감시하며, 해당 유저를 로그아웃 처리
  • (UsernamePassword)AuthenticationFilter : (아이디와 비밀번호를 사용하는 form 기반 인증) 설정된 로그인 URL로 오는 요청을 감시하며, 유저 인증 처리
    • AuthenticationManager를 통한 인증 실행
    • 인증 성공 시, 얻은 Authentication 객체를 SecurityContext에 저장 후 AuthenticationSuccessHandler 실행
    • 인증 실패 시, AuthenticationFailureHandler 실행
  • DefaultLoginPageGeneratingFilter : 인증을 위한 로그인폼 URL을 감시한다.
  • BasicAuthenticationFilter : HTTP 기본 인증 헤더를 감시하여 처리한다.
  • RequestCacheAwareFilter : 로그인 성공 후, 원래 요청 정보를 재구성하기 위해 사용된다.
  • SecurityContextHolderAwareRequestFilter : HttpServletRequestWrapper를 상속한 SecurityContextHolderAwareRequestWapper 클래스로 HttpServletRequest 정보를 감싼다. SecurityContextHolderAwareRequestWrapper 클래스는 필터 체인상의 다음 필터들에게 부가정보를 제공한다.
  • AnonymousAuthenticationFilter : 이 필터가 호출되는 시점까지 사용자 정보가 인증되지 않았다면 인증토큰에 사용자가 익명 사용자로 나타난다.
  • SessionManagementFilter : 이 필터는 인증된 사용자와 관련된 모든 세션을 추적한다.
  • ExceptionTranslationFilter : 이 필터는 보호된 요청을 처리하는 중에 발생할 수 있는 예외를 위임하거나 전달하는 역할을 한다.
  • FilterSecurityInterceptor : 이 필터는 AccessDecisionManager 로 권한부여 처리를 위임함으로써 접근 제어 결정을 쉽게해준다.

 

2-3. Authentication

접근 주체는 Authentication 객체를 생성한다. 이 객체는 SecurityContext(내부 메모리)에 보관되고 사용되어진다.

 

1
2
3
4
5
6
7
8
public interface Authentication extends Principal, Serializable { 
    Collection<extends GrantedAuthority> getAuthorities(); // Authentication 저장소에 의해 인증된 사용자의 권한 목록 
    Object getCredentials(); // 주로 비밀번호 
    Object getDetails(); // 사용자 상세정보 
    Object getPrincipal(); // 주로 ID 
    boolean isAuthenticated(); //인증 여부 
    void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException; 
}
cs

 

 

유저의 요청을 AuthenticationFilter에서 Authentication 객체로 변환해 AuthenticationManager(ProviderManager)에게 넘겨주고, AuthenticationProvider(DaoAuthenticationProvider)가 실제 인증을 한 이후에 인증이 완료되면 Authentication객체를 반환해준다.

  • AbstractAuthenticationProcessingFilter : 웹 기반 인증요청에서 사용되는 컴포넌트로 POST 폼 데이터를 포함하는 요청을 처리한다. 사용자 비밀번호를 다른 필터로 전달하기 위해서 Authentication 객체를 생성하고 일부 프로퍼티를 설정한다.
  • AuthenticationManager : 인증요청을 받고 Authentication을 채워준다.
  • AuthenticationProvider : 실제 인증이 일어나고 만약 인증 성공시 Authentication 객체의 authenticated = true로 설정해준다.

Spring Security 는 ProviderManager 라는 AuthenticationManager 인터페이스의 유일한 구현체를 제공한다. ProviderManager 는 하나 또는 여러 개의 AuthenticationProvider 구현체를 사용할 수 있다. AuthenticationProvider는 많이 사용되고 ProviderManager(AuthenticationManager 의 구현체) 와도 잘 통합되기 때문에 기본적으로 어떻게 동작하는 지 이해하는 것이 중요하다.

 

 

2-5. 비밀번호 인증과정

DaoAuthenticationProvider 는 UserDetailsService 타입 오브젝트로 위임한다. UserDetailsService 는 UserDetails 구현체를 리턴하는 역할을 한다. UserDetails 인터페이스는 이전에 설명한 Authentication 인터페이스와 상당히 유사하지만 서로 다른 목적을 가진 인터페이스이므로 헷갈리면 안된다. 

  • Authentication : 사용자 ID, 패스워드와 인증 요청 컨텍스트에 대한 정보를 가지고 있다. 인증 이후의 사용자 상세정보와 같은 UserDetails 타입 오브젝트를 포함할 수도 있다. 
  • UserDetails : 이름, 이메일, 전화번호와 같은 사용자 프로파일 정보를 저장하기 위한 용도로 사용한다.

2-6. 인증예외

인증과 관련된 모든 예외는 AuthenticationException 을 상속한다. AuthenticationException 은 개발자에게 상세한 디버깅 정보를 제공하기위한 두개의 멤버 필드를 가지고 있다. 

  • authentication : 인증 요청관련 Authentication 객체를 저장하고 있다.
  • extraInformation : 인증 예외 관련 부가 정보를 저장한다. 예를 들어 UsernameNotFoundException 예외는 인증에 실패한 유저의 id 정보를 저장하고 있다.

많이 발생하는 예외들.

  • BadCredentialsException : 사용자 아이디가 전달되지 않았거나 인증 저장소의 사용자 id 에 해당하는 패스워드가 일치하지 않을 경우 발생한다.
  • LockedException : 사용자 계정이 잠긴경우 발생한다.
  • UsernameNotFoundException : 인증 저장소에서 사용자 ID를 찾을 수 없거나 사용자 ID에 부여된 권한이 없을 경우 발생한다.

접근권한 부여

자동으로 설정된 Spring Security 필터 체인의 마지막 서블릿 필터는 FilterSecurityInterceptor 이다. 이 필터는 해당 요청의 수락 여부를 결정한다. FilterSecurityInterceptor 가 실행되는 시점에는 이미 사용자가 인증되어 있을 것이므로 유효한 사용자인지도 알 수 있다. Authentication 인터페이스에는 List<GrantedAuthority> getAuthorities() 라는 메소드가 있다는 것을 상기해 보자. 이 메소드는 사용자 아이디에 대한 권한 목록을 리턴한다. 권한처리시에 이 메소드가 제공하는 권한정보를 참조해서 해당 요청의 승인 여부를 결정하게 된다.

Access Decision Manager 라는 컴포넌트가 인증 확인을 처리한다. AccessDecisionManager 인터페이스는 인증 확인을 위해 두 가지 메소드를 제공한다.

  • supports : AccessDecisionManager 구현체는 현재 요청을 지원하는지의 여부를 판단하는 두개의 메소드를 제공한다. 하나는 java.lang.Class 타입을 파라미터로 받고 다른 하나는 ConfigAttribute 타입을 파라미터로 받는다.
  • decide : 이 메소드는 요청 컨텍스트와 보안 설정을 참조하여 접근 승인여부를 결정한다. 이 메소드에는 리턴값이 없지만 접근 거부를 의미하는 예외를 던져 요청이 거부되었음을 알려준다.

인증과정에서 발생할 수 있는 예상 가능한 에러를 처리하는 AuthenticationException 과 하위 클래스를 사용했던 것처럼 특정 타입의 예외 클래스들을 사용하면 권한처리를 하는 애플리케이션의 동작을 좀더 세밀하게 제어할 수 있다. 

AccessDecisionManager 는 표준 스프링 빈 바인딩과 레퍼런스로 완벽히 설정할 수 있다.디폴트 AccessDecisionManager 구현체는 AccessDecisionVoter 와 Vote 취합기반 접근 승인 방식을 제공한다.

Voter 는 권한처리 과정에서 다음 중 하나 또는 전체를 평가한다.

  • 보호된 리소스에 대한 요청 컨텍스트 (URL 을 접근하는 IP 주소)
  • 사용자가 입력한 비밀번호
  • 접근하려는 리소스
  • 시스템에 설정된 파라미터와 리소스

AccessDecisionManager 는 요청된 리소스에 대한 access 어트리뷰트 설정을 보터에게 전달하는 역할도 하므로 보터는 웹 URL 관련 access 어트리뷰트 설정 정보를 가지게 된다. 

Voter는 사용할 수 있는 정보를 사용해서 사용자의 리소스에 대한 접근 허가 여부를 판단한다. 보터는 접근 허가 여부에 대해서 세 가지 중 한 가지로 결정하는데, 각 결정은 AccessDecisionVoter 인터페이스에 다음과 같이 상수로 정의되어 있다.

  • Grant(ACCESS_GRANTED) : Voter 가 리소스에 대한 접근 권한을 허가하도록 권장한다.
  • Deny(ACCESS_DENIED) : Voter 가 리소스에 대한 접근 권한을 거부하도록 권장한다.
  • Abstain(ACCESS_ABSTAIN) : Voter 는 리소스에 대한 접근권한 결정을 보류한다. 이 결정 보류는 다음과 같은 경우에 발생할 수 있다.

           1. Voter 가 접근권한 판단에 필요한 결정적인 정보를 가지고 있지 않은 경우

           2.Voter 가 해당 타입의 요청에 대해 결정을 내릴 수 없는 경우

 

 

Java Config 기반 코드 설명

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/**
 * 
 * @author yun-yeoseong
 *
 */
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter{
    
//    private UserDetailsService userDetailsService;
//    private PasswordEncoder passwordEncoder;
    private AuthenticationProvider authenticationProvider;
    
    public SpringSecurityConfig(/*UserDetailsService userDetailsService, 
                                PasswordEncoder passwordEncoder,*/
                                AuthenticationProvider authenticationProvider) {
//        this.userDetailsService = userDetailsService;
//        this.passwordEncoder = passwordEncoder;
        this.authenticationProvider = authenticationProvider;
    }
    
    /*
     * 스프링 시큐리티가 사용자를 인증하는 방법이 담긴 객체.
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        /*
         * AuthenticationProvider 구현체
         */
        auth.authenticationProvider(authenticationProvider);
//        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
    }
    
    /*
     * 스프링 시큐리티 룰을 무시하게 하는 Url 규칙.
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring()
            .antMatchers("/resources/**")
            .antMatchers("/css/**")
            .antMatchers("/vendor/**")
            .antMatchers("/js/**")
            .antMatchers("/favicon*/**")
            .antMatchers("/img/**")
        ;
    }
    
    /*
     * 스프링 시큐리티 룰.
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/login*/**").permitAll()
            .antMatchers("/logout/**").permitAll()
            .antMatchers("/chatbot/**").permitAll()
            .anyRequest().authenticated()
        .and().logout()
              .logoutUrl("/logout")
              .logoutSuccessHandler(logoutSuccessHandler())
        .and().csrf()
              .disable()
        .addFilter(jwtAuthenticationFilter())
        .addFilter(jwtAuthorizationFilter())
        .exceptionHandling()
              .accessDeniedHandler(accessDeniedHandler())
              .authenticationEntryPoint(authenticationEntryPoint())
//        .and().sessionManagement()
//              .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        ;
    }
    
    /*
     * SuccessHandler bean register
     */
    @Bean
    public AuthenticationSuccessHandler authenticationSuccessHandler() {
        CustomAuthenticationSuccessHandler successHandler = new CustomAuthenticationSuccessHandler();
        successHandler.setDefaultTargetUrl("/index");
        return successHandler;
    }
    
    /*
     * FailureHandler bean register
     */
    @Bean
    public AuthenticationFailureHandler authenticationFailureHandler() {
        CustomAuthenticationFailureHandler failureHandler = new CustomAuthenticationFailureHandler();
        failureHandler.setDefaultFailureUrl("/loginPage?error=error");
        return failureHandler;
    }
    
    /*
     * LogoutSuccessHandler bean register
     */
    @Bean
    public LogoutSuccessHandler logoutSuccessHandler() {
        CustomLogoutSuccessHandler logoutSuccessHandler = new CustomLogoutSuccessHandler();
        logoutSuccessHandler.setDefaultTargetUrl("/loginPage?logout=logout");
        return logoutSuccessHandler;
    }
    
    /*
     * AccessDeniedHandler bean register
     */
    @Bean
    public AccessDeniedHandler accessDeniedHandler() {
        CustomAccessDeniedHandler accessDeniedHandler = new CustomAccessDeniedHandler();
        accessDeniedHandler.setErrorPage("/error/403");
        return accessDeniedHandler;
    }
    
    /*
     * AuthenticationEntryPoint bean register
     */
    @Bean
    public AuthenticationEntryPoint authenticationEntryPoint() {
        return new CustomAuthenticationEntryPoint("/loginPage?error=e");
    }
    
    /*
     * Form Login시 걸리는 Filter bean register
     */
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager());
        jwtAuthenticationFilter.setFilterProcessesUrl("/login");
        jwtAuthenticationFilter.setUsernameParameter("username");
        jwtAuthenticationFilter.setPasswordParameter("password");
        
        jwtAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler());
        jwtAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler());
        
        jwtAuthenticationFilter.afterPropertiesSet();
        
        return jwtAuthenticationFilter;
    }
    
    /*
     * Filter bean register
     */
    @Bean
    public JwtAuthorizationFilter jwtAuthorizationFilter() throws Exception {
        JwtAuthorizationFilter jwtAuthorizationFilter = new JwtAuthorizationFilter(authenticationManager());
        return jwtAuthorizationFilter;
    }
}
 
cs

우선 @EnableWebSecurity 어노테이션을 통해 스프링 시큐리티를 사용하겠다라고 선언한다. 그리고 해당 클래스는 WebSecurityConfigurerAdapter를 상속받는다. 첫번째 AuthenticationManagerBuilder를 인자로 받는 configure 메소드는 인증을 담당할 프로바이더 구현체를 설정하는 메소드이다. 현재는 Custom한 Provider를 직접구현하였지만, 밑에 주석에서 보이듯 UserDetailsService 구현체와 PasswordEncoder 구현체만 설정하여서 기본 DaoAuthenticationProvider를 사용하게 할 수도 있다. 그리고 위에서도 설명했듯 AuthenticationProvider는 여러개를 등록가능하다. 두번째 WebSecurity를 인자로 받는 configure 메소드는 스프링 시큐리티 필터에서 제외될 URI를 설정하는 메소드이다. 세번째 HttpSecurity를 인자로 받는 configure메소드가 가장 중요하다. 해당 메소드에는 요청 URI에 대한 권한 설정, 특정 기능 결과에 대한 Handler 등록, Custom Filter 등록(ex. AuthenticationFilter 재정의) 그리고 예외 핸들러 등을 등록 하는 메소드이다. 사실 여기에 왠만한 설정들이 다 들어간다고 해도 무방하다. 가장 중요한 만큼 세번째 메소드 설정은 짚고 넘어갈 것이다.

 

  • 53~57 Line : 보호된 리소스 URI에 접근할 수 있는 권한을 설정해주는 설정이다. AccessDecisionManager에 설정되는 access 정보이다. 이 설정들은 추후 FilterSecurityInterceptor에서 권한 인증에 사용될 정보들이다.
  • 58~60 Line : 로그아웃 기능에 대한 설명이다. logoutUrl()을 통해 로그아웃 기능에 대한 RequestUri정보를 전달한다. 그리고 .logoutSuccessHandler()를 통해 로그아웃이 성공적으로 끝나면 수행될 핸들러를 등록해준다.
  • 61~62 Line : csrf().disable()을 통하여 csrf 보안 설정을 비활성화한다.(해당 기능을 사용하기 위해서는 프론트에서 csrf토큰값을 보내주어야함. 예제라 생략)
  • 63 Line : Form Login에 사용되는 custom AuthenticationFilter 구현체를 등록해준다.
  • 64 Line : Header 인증에 사용되는 BasicAuthenticationFilter 구현체를 등록해준다.(다음 포스팅에서 JWT Token 기반 인증에 본격적으로 사용될 필터이다.)
  • 65~ Line : 예외 핸들러를 등록해주는 설정이다. accessDeniedHandler()는 권한 체크에서 실패할 때 수행되는 핸들러를 등록하고, authenticationEntryPoint()는 현재 인증된 사용자가 없는데(SecurityContext에 인증사용자가 담기지 않음) 인증되지 않은 사용자가 보호된 리소스에 접근하였을 때, 수행되는 핸들러(EntryPoint)를 등록해준다.
  • 124~137 Line : AuthenticationFilter 설정이다. 우선 위에서 말했듯 AuthenticationFilter는 AuthenticationManager에게 위임한다했다. 그렇기 때문에 AuthenticationManager를 생성자로 전달한다. 그리고 setFilterProcessesUrl()을 통해 로그인 요청 URI를 정의해준다.(스프링이 제공하므로 따로 컨트롤러 등록할 필요가 없다. logout URI도 똑같이 스프링에서 제공한다. 하지만 SuccessHandler나 FailureHandler,EntryPoint는 컨트롤러에 등록된 URI로 설정해야한다.) setUsernameParameter(),setPasswordParameter()를 통해 폼으로 넘어오는 사용자 아이디,패스워드 변수값을 설정한다.(<input>의 name속성이라고 생각하면된다.) setAuthenticationSuccess,FailureHandler()를 통해 결과에 대해 수행할 핸들러를 등록한다.

나머지 밑의 빈등록을 위한 메소드들은 직접 custom하게 구현한 클래스들이다. 사실 직접 구현하지 않아도 될 것까지 구현할 것들도 있지만, 직접 구현함으로써 "아~ 이런 클래스가 이런 역할을 하는구나"를 이해하기 위해(디버깅 혹은 행동 재정의) 직접 구현체를 생성해주었다.

 

  • AuthenticationSuccessHandler : Form Login(AuthenticationFilter)에서 인증이 성공했을 때 수행될 핸들러이다. 예제에서는 SimpleUrlAuthenticationSuccessHandler를 상속한 SavedRequestAwareAuthenticationSuccessHandler를 다시 상속한 CustomAuthenticationSuccessHandler를 등록해주었다. 이것은 현재 단순히 성공시 index 페이지로 리다이렉트 하는 역할을 수행한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
 * 실패시 행동을 재정의할 클래스(추상 클래스가 아닌 인터페이스를 구현해도 된다.)
 * Or Interface - AuthenticationSuccessHandler
 * @author yun-yeoseong
 *
 */
@Slf4j
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler implements ExceptionProcessor{
    
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws ServletException, IOException {
        log.debug("CustomAuthenticationSuccessHandler.onAuthenticationSuccess ::::");
        /*
         * 쿠키에 인증 토큰을 넣어준다.
         */
        super.onAuthenticationSuccess(request, response, authentication);
    }
 
    @Override
    public void makeExceptionResponse(HttpServletRequest request, HttpServletResponse response,
            Exception exception) {
    }
    
}
 
cs
  • AuthenticationFailureHandler : Form Login 실패시 수행되는 핸들러이다. SimpleUrlAuthenticationFailureHandler를 상속한 CustomAuthenticationFailureHandler를 등록해주었다. 이 또한 단순히 실패시 로그인 페이지로 리다이렉트 하는 역할을 수행한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * 실패시 행동을 재정의할 클래스(추상 클래스가 아닌 인터페이스를 구현해도 된다.)
 * Or Interface - AuthenticationFailureHandler
 * @author yun-yeoseong
 *
 */
@Slf4j
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler implements ExceptionProcessor{
    
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException exception) throws IOException, ServletException {
        log.debug("CustomAuthenticationFailureHandler.onAuthenticationFailure ::::");
        super.onAuthenticationFailure(request, response, exception);
    }
 
    @Override
    public void makeExceptionResponse(HttpServletRequest request, HttpServletResponse response,
            Exception exception) {
    }
    
}
 
cs
  • LogoutSuccessHandler : 로그아웃에 성공했을 시 수행되는 핸들러이다. SimpleUrlLogoutSuccessHandler를 상속한 CustomLogoutSuccessHandler를 등록해주었다. 역시 성공시 로그인 페이지로 리다이렉트 하는 역할을 수행한다.
1
2
3
4
5
6
7
8
9
10
11
@Slf4j
public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler{
    
    @Override    
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
            throws IOException, ServletException {
        log.debug("CustomLogoutSuccessHandler.onLogoutSuccess ::::");
        super.onLogoutSuccess(request, response, authentication);
    }
    
}
cs

  • AccessDeniedHandler : 권한 체크 실패시 수행되는 핸들러이다.AccessDeniedHandlerImpl를 상속한 CustomAccessDeniedHandler를 등록하였다. 권한 체크 실패시 적절한 에러코드와 메시지를 HttpServletResponse에 담아서 반환하는 역할을 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
 * AccessDenied시 행동을 재정의하는 핸들러 클래스이다.(구현체가 아니라 인터페이스를 직접 구현해도된다.)
 * Or Interface - AccessDeniedHandler
 * @author yun-yeoseong
 *
 */
@Slf4j
public class CustomAccessDeniedHandler extends AccessDeniedHandlerImpl implements ExceptionProcessor{
    
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
            AccessDeniedException accessDeniedException) throws IOException, ServletException {
        log.debug("CustomAccessDeniedHandler.handle");
        this.makeExceptionResponse(request, response, accessDeniedException);
    }
 
    @Override
    public void makeExceptionResponse(HttpServletRequest request, HttpServletResponse response,
            Exception exception) throws IOException {
        log.debug("CustomAccessDeniedHandler.makeExceptionResponse :::: {}",exception.getMessage());
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, exception.getMessage());
    }
    
}
 
cs

AuthenticationEntryPoint : 인증된 사용자가 SecurityContext에 존재하지도 않고, 어떠한 인증되지 않은 익명의 사용자가 보호된 리소스에 접근하였을 때, 수행되는 EntryPoint 핸들러이다. LoginUrlAuthenticationEntryPoint를 상속한 CustomAuthenticationEntryPoint를 등록하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Slf4j
public class CustomAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint{
 
    public CustomAuthenticationEntryPoint(String loginFormUrl) {
        super(loginFormUrl);
    }
 
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException authException) throws IOException, ServletException {
        log.debug("CustomAuthenticationEntryPoint.commence ::::");
        super.commence(request, response, authException);
    }
    
}
cs
  • JwtAuthenticationFilter :  Form Login시 걸리는 Filter이다. UsernamePasswordAuthenticationFilter를 상속한 JwtAuthenticationFilter을 등록하였다. 이 필터는 HttpServletRequest에서 사용자가 Form으로 입력한 로그인 정보를 인터셉트해서 AuthenticationManager에게 Authentication 객체를 넘겨준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
 * 
 * @author yun-yeoseong
 *
 */
@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter{
    
    private boolean postOnly = true;
    
    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }
    
    /*
     * 해당 필터에서 인증 프로세스 이전에 요청에서 사용자 정보를 가져와서
     * Authentication 객체를 인증 프로세스 객체에게 전달하는 역할
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        log.debug("JwtAuthentication.attemptAuthentication ::::");
        
        /*
         * POST로 넘어왔는지 체크
         */
        if(postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }
        
        String username = obtainUsername(request);
        String password = obtainPassword(request);
        
        if(StringUtils.isEmpty(username)) {
            username = "";
        }
        if(StringUtils.isEmpty(password)) {
            password = "";
        }
        
        username = username.trim();
        
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
        
        setDetails(request, authRequest);
        
        return this.getAuthenticationManager().authenticate(authRequest);
    }
    
    
}
cs
  • JwtAuthorizationFilter : Form Login에서 인증된 이후의 요청에 대해 Header 인증을 담당할 Filter이다. BasicAuthenticationFilter를 상속한 JwtAuthorizationFilter를 등록하였다. 이번 포스팅은 아니고 다음 포스팅에서 다룰 JWT 기반 인증에서 실제 JWT 토큰의 인증이 이루어질 필터 부분이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
 * HTTP 기본 인증 헤더를 감시하고 이를 처리하는 역할의 필터이다.
 * @author yun-yeoseong
 *
 */
@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter{
    
    public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
        // TODO Auto-generated constructor stub
    }
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        log.debug("JwtAuthorizationFilter.doFilterInternal ::::");
        /*
         * 쿠키 인증 토큰을 검사한다.
         * 만약 토큰 및 헤더에 대한 검사에 실패한다면,
         * AuthenticationEntryPoint에 위임하거나 혹은 HttpResponse에 적절한
         * 상태코드와 메시지를 담아서 리턴해준다.
         */
        super.doFilterInternal(request, response, chain);
    }
    
    /*
     * 성공시 처리 메소드
     */
    @Override
    protected void onSuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
            Authentication authResult) throws IOException {
        // TODO Auto-generated method stub
        super.onSuccessfulAuthentication(request, response, authResult);
    }
    
    /*
     * 실패시 처리 메소드
     */
    @Override
    protected void onUnsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException failed) throws IOException {
        // TODO Auto-generated method stub
        super.onUnsuccessfulAuthentication(request, response, failed);
    }
    
    
    
}
 
cs
  • UserDetailsService : AuthenticationProvider 구현체에서 인증에 사용할 사용자 인증정보를 DB에서 가져오는 역할을 하는 클래스이다. UserDetailsService를 구현한 UserDetailsServiceImpl를 사용한다.
  • PasswordEncoder : 실제 DB에는 비밀번호가 적절한 암호화 알고리즘으로 암호화되 저장되어있다. 폼에서 넘어오는 평문의 사용자 입력정보를 이용해 인증을 하려면 DB에 저장한 암호화 알고리즘 엔코더가 필요하다. 해당 역할을 하는 클래스이다. PasswordEncoder를 구현한 ShaPasswordEncoder를 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Slf4j
@Component
public class UserDetailsServiceImpl implements UserDetailsService{
    
    @Autowired private ChatbotUserService userService;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.debug("UserDetailsServiceImpl.loadUserByUsername :::: {}",username);
        
        ChatbotUserDto userDto = userService.findByUsername(username);
        
        if(ObjectUtils.isEmpty(userDto)) {
            throw new UsernameNotFoundException("Invalid username");
        }
        
        userDto.setAuthorities(AuthoritiesUtils.createAuthorities(userDto));
        
        return userDto;
    }
    
}
 
@Slf4j
@Component
public class ShaPasswordEncoder implements PasswordEncoder{
 
    @Override
    public String encode(CharSequence rawPassword) {
        log.debug("ShaPasswordEncoder.encode :::: {}",rawPassword);
        return Crypto.sha256(rawPassword.toString());
    }
 
    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        log.debug("ShaPasswordEncoder.matches :::: {} <-> {}",rawPassword,encodedPassword);
        return Crypto.sha256(rawPassword.toString()).equals(encodedPassword);
    }
    
 
}
cs
  • AuthenticationProvider : 실제 인증이 일어나는 클래스이다. AuthenticationManager(ProviderManager)는 실제로 많은 AuthenticationProvider 구현체들을 가질 수 있다고 이야기 했듯 저 AuthenticationProvider를 가지고 ProviderManager는 인증 로직을 태우게된다. AuthenticationProvider를 구현한 CustomAuthenticationProvider를 사용한다. 해당 클래스 내부적으로는 UserDetailsService에게 입력받은 사용자 아이디를 넘겨 DB에서 사용자 인증 정보를 받고 암호화된 패스워드를 비교하기 위하여 PasswordEncoder에게 사용자가 입력한 평문 패스워드를 전달해 암호화된 형태로 받아서 암호화<->암호화 형태로 비밀번호를 비교한다(평문<->평문 아님). 만약 인증이 완료되면 Authentication객체를 구현한 UsernamePasswordAuthenticationToken객체를 반환한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
 * 이걸 사용해도 되고 시큐리티에서 기본적으로 제공해주는 DaoAuthenticationProvider를 사용해도 무방.
 * @author yun-yeoseong
 *
 */
@Slf4j
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider{
    
    private UserDetailsService userDetailsService;
    private PasswordEncoder encoder;
    
    public CustomAuthenticationProvider(UserDetailsService userDetailsService,PasswordEncoder encoder) {
        this.userDetailsService = userDetailsService;
        this.encoder = encoder;
    }
    
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        log.debug("CustomUserAuthenticationProvider.authenticate :::: {}",authentication.toString());
        
        UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken)authentication;
        
        String userId = token.getName();
        
        ChatbotUserDto user = null;
        
        if(!StringUtils.isEmpty(userId)) {
            user = (ChatbotUserDto) userDetailsService.loadUserByUsername(userId);
        }
        
        if(ObjectUtils.isEmpty(user)) {
            throw new UsernameNotFoundException("Invalid username");
        }
        
        user.setUsername(user.getUsername());
        user.setPassword(user.getPassword());
        
        String password = user.getPassword();
        
        if(!StringUtils.equals(password, encoder.encode(String.valueOf(token.getCredentials())))) {
            throw new BadCredentialsException("Invalid password");
        }
        
        return new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities());
        
    }
 
    @Override
    public boolean supports(Class<?> authentication) {
        // TODO Auto-generated method stub
        log.debug("CustomUserAuthenticationProvider.supports ::::");
        return UsernamePasswordAuthenticationToken
                .class.equals(authentication);
    }
 
}
 
cs

 

4-4. access

  • hasRole(Role) : 해당 Role 을 갖고있는 사용자 허용
  • hasAnyRole(Role1, Role2, ...) : 해당 Role 중에 1개이상 갖고있는 사용자 허용
  • anonymous : 익명 사용자 허용
  • authenticated : 인증된 사용자 허용
  • permitAll : 모든 사용자 허용
  • denyAll : 모든 사용자 차단

5. 유용한 annotation

  • @Secured : 각 요청경로에 따른 권한 설정은 위의 xml에서도 할 수 있지만, 메소드레벨에서 @Secured 를 통해서도 할 수 있다. @EnableGlobalMethodSecurity(securedEnabled=true) 를 추가하면 된다.
    • - @Secured("ROLE_ADMIN") , @Secured({"ROLE_ADMIN","ROLE_USER"})
    • - 비인가자 접근 시 AccessDeniedException 던짐
  • @PreAuthorize : 위와 비슷하지만 spEL 을 사용할 수 있다. @EnableGlobalMethodSecurity(prePostEnabled=true)를 설정한다.
    • - @PreAuthorize("hasRole('ADMIN')")
  • @PostAuthorize : 위와 동일
  • @AuthenticationPrincipal : 컨트롤러단에서 세션의 정보들에 접근하고 싶을 때 파라미터에 선언해준다.
    • - public ModelAndView userInfo(@AuthenticationPrincipal User user)
    • - 이거 안쓰고 확인하려면 (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); 이런식으로 써야한다.
    • - 4.x 부터는 org.springframework.security.core.annotation.AuthenticationPrincipal 을 import 해야한다.
    • - mvc:annotation-driven.mvc:argument-resolvers 에 bean 으로 org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver 를 등록한다.

 

여기까지 폼로그인 기반 스프링 시큐리티 예제였다. 다음 포스팅에서는 해당 코드를 이어서 JWT 토큰기반 인증을 다루어볼 예정이다. 혹시 틀린점이나 부족한 점이 있으면 꼭 코멘트를 부탁합니다.

 

<소스코드>

 

 

yoonyeoseong/spring-security

Contribute to yoonyeoseong/spring-security development by creating an account on GitHub.

github.com

 

posted by 여성게
:
Web/Spring 2019. 4. 10. 21:29

컨트롤러에서 요청을 엔티티객체로 받는 경우가 있다. 이럴경우 받은 엔티티 객체로 DB까지 로직들이 순차적으로 수행이 될것이다. 그런데 만약 엔티티를 조회하거나, 리스트를 조회하는 경우가 있다고 가정해보자. 그렇다면 요청을 받고 엔티티객체를 조회한 후에 컨트롤러에서 응답값으로 ResponseEntity body에 엔티티객체를 실어 보낼 수 있다. 하지만 여기에서 만약 엔티티객체에서 내가 보내기 싫은 데이터가 포함되어있다면? 그것이 만약 유저정보에 대한 것이고, 그 객체에 패스워드까지 존재한다면? 상상하기 싫은 상황일 것이다. 여기서 해결할 수 있는 방법은 몇가지 있다. 예를 들어 @JsonIgnore,@JsonProperty로 응답을 JSON으로 반환하기 할때 원하는 인스턴스 변수를 제외하고 보낼 수도 있고, 응답용 DTO를 만들어서 응답을 다른 객체로 convert한 후에 보낼 수도 있을 것이다. 어노테이션을 이용한 방법은 추후에 다루어볼것이고, 오늘 다루어 볼 것은 응답용 DTO를 만들어서 응답을 보내는 예제이다. 

 

만약 응답용 DTO를 만들어서 내가 응답으로 내보내고 싶은 정보만 인스턴스변수로 set해서 보내면 될것이다. 하지만 여기서 아주 노가다가 있을 것이다. 그것은 따로 Util 용 Method로 빼서 일일이 set,get하는 방법일 것이다. 만약 한두개의 인스턴스 변수라면 상관없지만 만약 응답으로 내보낼 인스턴수 변수가 아주 많다면 이만한 노가다성 작업도 없을 것이다. 오늘 여기서 소개해줄 해결사가 바로 ModelMapper라는 객체이다. 바로 예제 코드로 들어가겠다.

 

오늘 만들어볼 예제는 이전 포스팅에서 다루어봤던 미완성 GenericController의 응용이다. 자세한 설명은 밑의 링크에서 참고하면 될듯하다.

▶︎▶︎▶︎2019/03/22 - [Spring] - Spring - Springboot GenericController(제네릭컨트롤러), 컨트롤러 추상화

 

1
2
3
4
5
        <dependency>
            <groupId>org.modelmapper</groupId>
            <artifactId>modelmapper</artifactId>
            <version>2.3.0</version>
        </dependency>
cs

ModelMapper를 사용하기 위해서 의존성을 추가해준다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
import org.modelmapper.ModelMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class CommonBean {
    
    @Bean
    public ModelMapper modelMapper() {
        return new ModelMapper();
    }
}
cs

사용할 ModelMapper클래스를 빈으로 등록해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface DomainMapper {
    
    public <D,E> D convertToDomain(E source,Class<extends D> classLiteral);
}
 
@Component
public class DomainMapperImpl implements DomainMapper{
    
    private ModelMapper modelMapper;
    
    public DomainMapperImpl(ModelMapper modelMapper) {
        this.modelMapper=modelMapper;
    }
    
    /*
     * 공통 매퍼
     */
    @Override
    public <D, E> D convertToDomain(E source, Class<extends D> classLiteral) {
        return modelMapper.map(source, classLiteral);
    }
 
}
cs

그리고 ModelMapper를 이용하여 추후에 많은 유틸메소드(도메인 클래스의 인스턴스를 조작하기 위함)를 만들 가능성이 있을 수도 있기 때문에 따로 유틸로 클래스를 만들어서 해당 클래스내에서 ModelMapper를 활용할 것이다. 여기서는 따로 유틸메소드는 없고 하나의 메소드만 있다. 이것은 엔티티클래스와 매핑할 도메인클래스의 클래스리터럴(Domain.class)를 매개변수로 받아서 ModelMapper.map(엔티티클래스,도메인클래스리터럴) 메소드에 전달한다. 메소드의 반환값으로는 도메인클래스를 반환한다. 여기서 도메인클래스 필드에 엔티티클래스의 필드를 매핑할때는 필드명으로 비교를 한다. 아래에서도 다시 설명하겠지만, 도메인 클래스를 만들때는 엔티티 클래스에서 Http Response로 보내고 싶은 데이터의 필드의 이름과 동일하게 도메인클래스의 필드명으로 만들어줘야하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
 * BaseEntity 추상클래스
 * 해당 추상클래스를 상속할때 @Tablegenerator는 상속하는 클래스에서 정의해야함
 * 또한 id field의 칼럼속성도 필요할때에 재정의해야함
 * @author yun-yeoseong
 *
 */
@MappedSuperclass
@EntityListeners(value = { AuditingEntityListener.class })
@Setter
@Getter
@EqualsAndHashCode(of="id")
public abstract class BaseEntity<extends BaseEntity<?>> implements Comparable<T>{
    
    @Id
    @GeneratedValue(strategy=GenerationType.TABLE, generator = "SEQ_GENERATOR")
    private long id;
    
    @JsonProperty(access=Access.READ_ONLY)
    @Column(name="CREATED_DATE",nullable=false,updatable=false)
    @CreatedDate
    private LocalDateTime createdDate;
    
    @JsonProperty(access=Access.READ_ONLY)
    @Column(name="UPDATED_DATE",nullable=false)
    @LastModifiedDate
    private LocalDateTime modifiedDate;
    
//    @Column(name="CREATED_BY",updatable=false/*,nullable=false*/)
//    @CreatedBy
    
//    @Column(name="UPDATED_BY",nullable=false)
//    @LastModifiedBy
 
    @Override
    public int compareTo(T o) {
        if(this == o) return 0;
        return Long.compare(this.getId(), o.getId());
    }
 
}
 
/**
 * Main Category Entity
 * @author yun-yeoseong
 *
 */
@Entity
@Table(name="MAIN_CATEGORY"
        ,indexes=@Index(columnList="MAIN_CATEGORY_NAME",unique=false))
@AttributeOverride(name = "id",column = @Column(name = "MAIN_CATEGORY_ID"))
@TableGenerator(name="SEQ_GENERATOR",table="TB_SEQUENCE",
                pkColumnName="SEQ_NAME",pkColumnValue="MAIN_CATEGORY_SEQ",allocationSize=1)
@Getter
@Setter
@ToString
public class MainCategoryEntity extends BaseEntity<MainCategoryEntity> implements Serializable{
    
    private static final long serialVersionUID = 5609501385523526749L;
    
    @NotNull
    @Column(name="MAIN_CATEGORY_NAME",nullable=false)
    private String mainCategoryName;
    
}
cs

이번 예제에서 사용할 엔티티 클래스이다. 우선 공통 엔티티 필드들은 BaseEntity라는 추상클래스로 뺐다. 그리고 정의할 엔티티클래스에서 해당 BaseEntity를 상속하여 정의하였다.

 

1
2
3
4
5
6
7
8
9
10
11
12
@Getter
@Setter
@ToString
public class MainCategoryDTO implements Serializable{
 
    private static final long serialVersionUID = 3340033210827354955L;
    
    private Long id;
    
    private String mainCategoryName;
    
}
cs

이번 예제에서 사용할 도메인클래스이다. 엔티티의 생성일과 수정일은 굳이 반환할 필요가 없기때문에 도메인클래스 필드에서 제외하였다. 그러면 엔티티 클래스에서 생성일, 수정일을 제외하고 클라이언트에게 값이 반환될 것이다. 여기서 중요한 것은 앞에서도 한번 이야기 했지만, ModelMapper는 매핑에 사용할 필드들을 이름으로 비교하기 때문에 엔티티클래스에서 사용하는 필드이름과 도메인 클래스에서 매핑할 필드이름이 동일해야한다는 것이다. 이점은 꼭 기억해야 할 것 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/**
 * 
 * GenericController - 컨트롤러의 공통 기능 추상화(CRUD)
 * 재정의가 필요한 기능은 @Override해서 사용할것
 * 
 * @author yun-yeoseong
 *
 * @param <E> Entity Type
 * @param <D> Domain Type
 * @param <ID> Entity key type
 */
@Slf4j
public abstract class GenericController<E,D,ID> {
    
    private JpaRepository<E, ID> repository;
    
    private DomainMapper domainMapper;
    
    private Class<D> dtoClass ;
    
    @SuppressWarnings("unchecked")
    public GenericController(JpaRepository<E, ID> repository,DomainMapper domainMapper) {
        
        this.repository = repository;
        this.domainMapper = domainMapper;
        
        /*
         * 타입추론로직
         */
        ParameterizedType genericSuperclass = (ParameterizedType) getClass().getGenericSuperclass();
        Type type = genericSuperclass.getActualTypeArguments()[1];
        
        if (type instanceof ParameterizedType) {
            this.dtoClass = (Class<D>) ((ParameterizedType) type).getRawType();
        } else {
            this.dtoClass = (Class<D>) type;
        }
 
    }
    
    /*
     * 엔티티아이디로 조회
     */
    @GetMapping("/{id}")
    public  ResponseEntity<D> select(@PathVariable ID id) {
        log.info("GenericController.select - {}",id);
        E e = repository.findById(id).get();
        D d = domainMapper.convertToDomain(e, dtoClass);
        return new ResponseEntity<D>(d, HttpStatus.OK);
    }
    
    /*
     * 리스트 조회
     */
    @GetMapping
    public ResponseEntity<List<D>> list(){
        log.info("GenericController.list");
        List<E> lists = repository.findAll();
        List<D> response = lists.stream().map(e->domainMapper.convertToDomain(e, dtoClass)).collect(Collectors.toList());
        return new ResponseEntity<List<D>>(response, HttpStatus.OK);
    }
    
    /*
     * 엔티티 생성
     */
    @Transactional
    @PostMapping
    public ResponseEntity<D> create(@RequestBody E e) {
        log.info("GenericController.create - {}",e.toString());
        log.info("dtoClass type = {}",dtoClass.getName());
        E created = repository.save(e);
        D d = domainMapper.convertToDomain(created, dtoClass);
        return new ResponseEntity<D>(d, HttpStatus.CREATED);
    }
    
    /*
     * 엔티티 수정
     */
    @Transactional
    @PutMapping("/{id}")
    public ResponseEntity<D> update(@RequestBody E t) {
        log.info("GenericController.update - {}",t.toString());
        E updated = repository.save(t);
        D d = domainMapper.convertToDomain(updated, dtoClass);
        return new ResponseEntity<D>(d, HttpStatus.CREATED);
    }
    
    /*
     * 엔티티 삭제
     */
    @SuppressWarnings("rawtypes")
    @Transactional
    @DeleteMapping("/{id}")
    public ResponseEntity<?> delete(@PathVariable ID id) {
        log.info("GenericController.delete - {}",id);
        repository.deleteById(id);
        return new ResponseEntity(HttpStatus.NO_CONTENT);
    }
}
 
cs

오늘 예제로 살펴볼 GenericController이다. 주석에 나와있는 데로 E,D,ID 제네릭타입은 순서대로 엔티티클래스타입,도메인클래스타입,엔티티클래스 ID타입이다. 그리고 위에서 만든 DomainMapper라는 클래스를 이용하여 사용자 요청에 대한 결과값을 조작하여 도메인클래스로 변경하여 리턴하고 있다.(엔티티에서 반환할 데이터만 DTO로 정의해 반환함) 그리고 하나 더 설명할 것은 이전 포스팅에서 다루었던 제네릭 컨트롤러와는 다르게 제네릭 타입으로 도메인클래스 타입을 받을 수 있게 하나 선언하였고, 해당 도메인 클래스의 타입을 추론하는 로직을 생성자에 한번 넣어주었다. 왜냐하면 도메인 클래스는 직접적으로 실체를 매개변수로 받고 있지 않기 때문에 타입을 추론하여 DomainMapper의 메소드의 매개변수로 들어가야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Slf4j
@RestController
@RequestMapping("/category/main")
public class MainCategoryController extends GenericController<MainCategoryEntity, MainCategoryDTO , Long>{
    
    private MainCategoryService mainCategoryService;
 
    public MainCategoryController(JpaRepository<MainCategoryEntity, Long> repository, DomainMapper domainMapper,
            MainCategoryService mainCategoryService) {
        super(repository, domainMapper);
        this.mainCategoryService = mainCategoryService;
    }
    
}
 
cs

마지막으로 제네릭 컨트롤러를 상속한 컨트롤러이다. 단순 CRUD 메소드가 추상화 되어있기 때문에 굉장히 깔끔한 코드가 되었다. 이 예제 코드를 실행시켜보면 반환 객체로 DTO가 나가는 것을 확인할 수 있다. 

 

여기까지 ModelMapper를 다루어봤다. 굉장히 노가다성 작업을 줄여주는 좋은 놈인것 같다. 글을 읽고 혹시 틀린점이 있다면 꼭 지적해주었음 좋겠다.

posted by 여성게
:
Middleware/Redis 2019. 3. 1. 12:55

Springboot,Redis - Springboot Redis Nodes Cluster !(레디스 클러스터)



이전 포스팅에서는 Redis Server들의 고가용성을 위해 Redis Sentinel을 구성하여 Master-Slave 관계의 구성을 해보았습니다. 


▶︎▶︎▶︎Redis Sentinel 구성


Sentinel을 구성하여 Redis Server들의 고가용성을 키워주는 방법 이외에도 사실 Redis는 Cluster라는 좋은 기능을 지원해줍니다.

그럼 Sentinel은 무엇이고 Redis Cluster는 다른 것인가? 대답은 엄연히 다른 기능입니다. 

간단히 비교하면 Sentinel는 Master-Slave관계를

구성합니다.(Redis Server 끼리 구성을 갖춤). 하지만 Redis Server 이외에 Sentinel 인스턴스를 띄워주어야합니다. 그런 Sentinel 인스턴스들은

Redis Server들을 모니터링하고 고가용성을 위한 적당한 처리를 해줍니다. 그리고 Redis Server끼리의 데이터 동기화도 마춰줍니다. 이말은,

모든 Redis Server는 모두 같은 데이터들을 가지고 있는 것이죠.

하지만 Cluster를 이용하면 각 Redis Server들은 자신만의 HashSlot을 할당 받게 됩니다. 그리고 Cluster도 Master-Slave 관계를

구성하게 됩니다. 이말은 무엇이냐? 대략 16000개의 데이터 바구니를 나누어가지는 Redis Server들은 Master가 됩니다. Sentinel과는

다르게 하나의 마스터만 갖는 것이 아닙니다. 그리고 각 마스터에 대한 Slave 서버를 가지게 되는 것입니다. 더 자세한 사항은 아래 링크를 참조해주세요.


▶︎▶︎▶︎Cluster&Sentinel 그리고 Redis





이제는 Redis Cluster 구성을 해보겠습니다. 오늘 구성해볼 아키텍쳐입니다.

혹시나 Redis를 설치와 간단한 사용법에 대해 모르신다면 아래링크를 참조해주세요.


▶︎▶︎▶︎Redis 설치와 사용법



3개의 Master와 3개의 Slave 입니다.(편의상 Redis 폴더의 루트 == $REDIS)


$REDIS 위치에 cluster라는 폴더를 하나 구성해줍니다. 


그리고 해당 $REDIS/redis.conf를 cluster 폴더에 6개를 복사해줍니다.(redis-cluster1~6.conf)



이제 각 redis-cluster 설정파일을 수정할 것입니다. 이번에 할 설정은 간단한 설정입니다. 프러덕환경에서는

더 세부적인 설정이 필요할 수 있습니다.


이번예제는 동일한 서버에 6개의 port를 나누어 진행합니다. 만약 서로 다른 서버에 구성을 하시기 위해서는

적절히 인스턴스들을 나누어주시고 각 서버에 대해 포트 개방이 필요합니다.



redis-cluster1.conf - port:6379


설정은 직관적으로 어떠한 설정에 대한 것인지 알수 있습니다. 해당 인스턴스의 포트는 6379를 사용하고

클러스터를 사용하겠다. 그리고 해당 인스턴스가 클러스터에 대한 정보를 남기기위해 nodes.conf를 사용한다.

또한 타임아웃은 5초로 하고 모든 데이터는 영속하기 위해 항상 write마다 기록한다 라는 설정입니다.(데이터 유실방지)


나머지 인스턴스들의 설정도 port와 cluster-config-file의 설정만 구분하고 동일하게 작성합니다.


ex) port 6380, cluster-config-file nodes2.conf


설정 파일작성이 끝나셨으면 6개의 터미널을 띄워줍니다.


>cd src

>./redis-server ../cluster/redis-clusterN.conf 


총 6개의 레디스 인스턴스를 실행시킵니다.


그리고 하나 추가적으로 작업을 해주어야할 것이 있습니다. 실행되고 있는 인스턴스에 대해

명시적으로 클러스터 구성을 생성해주는 작업입니다. 이 과정은 버젼에 따라 총 2가지의 방법이 있습니다.


1
2
3
redis-cli --cluster create 127.0.0.1:6379 127.0.0.1:6380 \
127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384 \
--cluster-replicas 1
cs


$REDIS/src 의 redis-cli를 이용한 방법입니다. 클러스터 구성에 참여하는 인스턴스 정보를 모두 입력하고 마지막에 replicas 속성을

명시해줍니다. 마지막 속성은 마스터에 대한 슬레이브를 몇개를 둘것인가 라는 설정입니다.


1
2
./redis-trib.rb create --replicas 1 127.0.0.1:6379 127.0.0.1:6380 \
127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384
cs


동일한 속성에 대한 redis-trib.rb를 이용한 클러스터 구성방법입니다.


저는 첫번째 방법을 이용하였습니다. 명령어를 탁 치는 순간 3개는 마스터 3개는 슬레이브 노드를 임의로 

선택해 이렇게 클러스터를 구성하겠습니까? 라는 질문에 yes||no로 답변해주어야합니다. yes를 입력합니다.


이제는 클러스터 구성이 잘 되었는지 확인해볼까요?



잘 구성이 되었습니다 ! 여기서 한가지 집고 넘어가야 할 것이 있습니다. Redis Cluster 사용을 위해서는 그에 맞는 클라이언트가 필요합니다. 저는

그 클라이언트를 Springboot를 이용하여 구성해보았습니다. springboot의 Spring Redis 프로젝트를 생성해줍니다!



1
2
3
4
#Redis Cluster Config(마스터노드의 리스트)
spring.redis.cluster.nodes=127.0.0.1:6379,127.0.0.1:6380,127.0.0.1:6381
클러스터 노드간의 리다이렉션 숫자를 제한.
spring.redis.cluster.max-redirects=
cs


application.propeties 파일입니다. 클러스터에 참여하는 노드들을 나열해줍니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
 * Redis Cluster Config
 * @author yun-yeoseong
 *
 */
@Component
@ConfigurationProperties(prefix = "spring.redis.cluster")
public class RedisClusterConfigurationProperties {
    
    /**
     * spring.redis.cluster.nodes[0]=127.0.0.1:6379
     * spring.redis.cluster.nodes[1]=127.0.0.1:6380
     * spring.redis.cluster.nodes[2]=127.0.0.1:6381
     */
    List<String> nodes;
 
    public List<String> getNodes() {
        return nodes;
    }
 
    public void setNodes(List<String> nodes) {
        this.nodes = nodes;
    }
    
    
}
cs


properties에 나열한 노드들의 정보를 얻기위한 빈을 하나 띄워줍니다. 물론 @Value로 직접 주입시켜주어도 상관없습니다. 해당 방법은 Spring Redis Document에 나온데로 진행하고 있는 중입니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/**
 * Redis Configuration
 * @author yun-yeoseong
 *
 */
@Configuration
public class RedisConfig {
    
    
    /**
     * Redis Cluster 구성 설정
     */
    @Autowired
    private RedisClusterConfigurationProperties clusterProperties;
    
    /**
     * JedisPool관련 설정
     * @return
     */
    @Bean
    public JedisPoolConfig jedisPoolConfig() {
        return new JedisPoolConfig();
    }
    
    
    /**
     * Redis Cluster 구성 설정
     */
    @Bean
    public RedisConnectionFactory jedisConnectionFactory(JedisPoolConfig jedisPoolConfig) {
        return new JedisConnectionFactory(new RedisClusterConfiguration(clusterProperties.getNodes()),jedisPoolConfig);
    }
    
    /**
     * RedisTemplate관련 설정
     * 
     * -Thread-safety Bean
     * @param jedisConnectionConfig - RedisTemplate에 설정할 JedisConnectionConfig
     * @return
     */
    @Bean(name="redisTemplate")
    public RedisTemplate redisTemplateConfig(JedisConnectionFactory jedisConnectionConfig) {
        
        RedisTemplate redisTemplate = new RedisTemplate<>();
 
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(jedisConnectionConfig);
        
        return redisTemplate;
        
    }
    
    /**
     * 문자열 중심 편의 RedisTemplate
     * 
     * @param jedisConnectionConfig
     * @return
     */
    @Bean(name="stringRedisTemplate")
    public StringRedisTemplate stringRedisTemplate(JedisConnectionFactory jedisConnectionConfig) {
        StringRedisTemplate stringRedisTemplate = new StringRedisTemplate(jedisConnectionConfig);
        
        return stringRedisTemplate;
    }
    
}
 
cs


Redis Config를 위한 자바클래스입니다. 이제 정말로 잘되는지 확인해볼까요?


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RunWith(SpringRunner.class)
@SpringBootTest
public class RedisTest {
    
    @Autowired
    private RedisTemplate redisTemplate;
    
    @Test
    public void testDataHandling() {
        
        redisTemplate.getConnectionFactory().getConnection().info().toString();
        
        String key = "yeoseong";
        String value = "yoon";
        redisTemplate.opsForValue().set(key, value);
        String returnValue = (String) redisTemplate.opsForValue().get(key);
        
        System.out.println(value);
    }
    
}
 
cs


결과값으로 "yoon"이라는 값을 얻어옵니다. 그러면 진짜로 클러스터된 노드들에서 얻어왔는지 확인해봐야겠습니다.



6379 포트의 인스턴스로 해당 값을 얻어오려고 하니 실제로는 6381에 해당 데이터가 있어 리다이렉트 됬다라는 로그와 함께 

결과 데이터를 얻어왔습니다. 여기까지 Redis Cluster 구성이었습니다. 부족한 부분이 많아 틀린 부분이 있다면 댓글 부탁드립니다!!


posted by 여성게
:
Middleware/Redis 2019. 2. 27. 23:56

Redis - Sentinel 이란? 설정방법! Redis 고가용성을 위한 방법




이전 포스팅에서 Redis Server Replication 구성방법에 대해 알아봤습니다. 이번 포스팅은

Redis 고가용성을 위한 Sentinel 기능에 대해 알아보려고 합니다. 어떻게 보면 조금더 완벽한 클러스터링을

구성한다고 생각하시면 됩니다. 


만약 Redis 설치 및 설정 방법을 모르신다면 아래 링크를 통해 참조하시고 오셔도 좋을 것같습니다.

▶︎▶︎▶︎Redis 설치 및 설정, 간단한 사용법!


우선 Sentinel에 대한 기능에 대해 알아보겠습니다.


1) 모니터링

2) 알림기능

3) 페일오버

4) 환경 구성 프로바이더


이러한 기능을 제공해줍니다.



오늘 예제로 구성해볼 이미지입니다.



구성이 이해가 가십니까? 간단하게 설명을 하면 Master&Slave는 이전 포스팅과 동일하게 3개의 노드를 띄웁니다.

그리고 Sentinel도 동일하게 3개의 노드를 띄웁니다(3개의 의미가 있음). 이런 구성에서 Sentinel은 마스터를 지속적으로 

모니터링합니다. 그리고 장애가 있을시에 적당한 프로세스를 거칩니다. 여기서 Sentinel의 노드를 3개를 띄웠는데요. 이것은

의미가 있는 숫자입니다. 이전에 포스팅 중 Zookeeper관련된 글에서도 동일한 정책을 따랐는데요. 


"바로 홀수단위로 띄운다"


입니다. 이것은 만약 네트워크의 잠깐의 오버타임때문에 마스터가 죽었다고 생각하는 하나의 Sentinel이 있을 수 있습니다.

그럼 이것이 진짜로 죽은 거라고 판단을 해야할까요? 우리는 보통 선거를 하게되면 과반수의 원칙에 따르게 됩니다. 여기서도

동일하게 과반수의 원칙을 따르게 되는 겁니다. 과반수 이상의 Sentinel이 "OK" 해야 비로소 그 마스터 노드는

죽은 것이고, 그때서야 Slave 노드에서 마스터 노드를 선출하게 되는 것입니다. 그렇기 때문에 Sentinel은

3개이상의 홀수 인스턴스를 띄운다 원칙을 지켜주셔야합니다.


우리가 구성할 실제 구성도입니다.



진행하기 앞서, $REDIS(Redis 폴더 root)에 있는 sentinel.conf 파일을 2개 복사해줍니다.(총 3개의 Sentinel 설정파일 구성)

그리고 아래 설정을 port만 각기 분리해주고 나머지 설정을 동일하게 작성해줍니다.



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 11001.conf ~ 11003.conf
 
# 센티널이 실행될 포트입니다. (이부분은 포트별로 다르게 설정)
port 11001
# 센티널이 감시할 레디스 Master 인스턴스 정보를 넣어줍니다.
# sentinel monitor mymaster <redis master host> <redis master port> <quorum>
sentinel monitor mymaster 127.0.0.1 10000 2
# 센티널이 Master 인스턴스에 접속하기 위한 패스워드를 넣어줍니다.
sentinel auth-pass mymaster foobared
# 센티널이 Master 인스턴스와 접속이 끊겼다는 것을 알기 위한 최소한의 시간입니다.
sentinel down-after-milliseconds mymaster 30000
# 페일오버 작업 시간의 타임오버 시간을 정합니다.
# 기본값은 3분입니다.
sentinel failover-timeout mymaster 180000
# 이 값은 Master로부터 동기화 할 수 있는 slave의 개수를 지정합니다.
# 값이 클수록 Master에 부하가 가중됩니다.
# 값이 1이라면 Slave는 한대씩 Master와 동기화를 진행합니다.
sentinel parallel-syncs mymaster 1
cs


여기서 하나 설명할 것이 있다면 설정중 quorum 입니다. 이것은 의사결정에 필요한 최소 Sentinel 노드수라고 생각하시면 됩니다.

즉, 지금 구성에서는 딱 과반수가되는 2개의 Sentinel이 동의하면 의사결정이 진행이 되는 것입니다.


SDOWN vs ODOWN

More advanced concepts이라는 페이지에서 SDOWN과 ODOWN이라는 단어가 나옵니다. SDOWN은 Subjectively Down condition의 축약어이고 ODOWN은 Objectively Down condition의 축약어입니다.

SDOWN은 센티널 인스턴스가 Master와 접속이 끊긴 경우 주관적인 다운 상태로 바뀝니다. 이것은 잠시 네트워크 순단 등으로 인해 일시적인 현상일 수 있으므로 우선 SDOWN 상태가 됩니다.

그러나 SDOWN 상태인 센티널들이 많아진다면 이는 ODOWN 상태(quorum), 즉 객관적인 다운 상태로 바뀝니다. 이때부터 실질적인 페일오버(failover) 작업이 시작됩니다.



위의 설정을 모두 완료하셨다면 이전 포스팅에서 진행한 redis.conf를 조금 변경해야합니다.


Redis Server 설정 파일에 마스터,슬래이브 관계없이 모두


masterauth yourpassword

requirepass yourpassword


두개의 설정을 3개의 redis conf에 설정해줍니다. 이유는 슬래이브 노드가 마스터 노드로 선출될 수도 있기에

모든 슬래이브는 require pass 설정을 가져야하고 마스터 노드도 슬래이브 노드가 될수 있기 때문에

masterauth설정을 해주어야합니다.


이제 실행을 해봅니다. Redis Server들은 이전 포스팅에서 진행한 데로 실행해주시면 됩니다.


>$REDIS/src/redis-sentinel ../sentinel.conf

>$REDIS/src/redis-sentinel ../sentinel2.conf

>$REDIS/src/redis-sentinel ../sentinel3.conf


명령으로 모든 센티널들을 실행시킵니다.



로그를 하나하나 설명안해도 읽어보시면 어떠한 로그인지 직관적으로 이해가갑니다.



여기에서 마스터 노드를 shutdown 시켜봅니다. 그리고 마스터 선출에 시간이 걸리니 대략

30초 정도 기다려봅니다. 아니? 슬래이브 노드가 마스터노드로 선출됬습니다.



기존에 마스터였던 6379는 슬래이브로, 기존에 슬래이브였던 6381이 마스터로 선출되었습니다.

위의 정보를 출력하기 위해서는 


>./redis-cli -p port -a password

>info


로 접속하셔야합니다. 패스워드를 작성안하시면 해당 명령어를 사용하실수 없습니다.


여기까지 Redis Server 고가용성을 위한 Sentinel 구성이었습니다. production 환경에서는 반드시

위와같이 장애에 대응할 수 있는 서버 구성을 가져야합니다. 


다음 포스팅은 실제 Redis를 Springboot에서 추상화된 객체로 사용해보는 예제를 진행 해볼 것입니다.

posted by 여성게
:
Web/Spring 2019. 2. 25. 15:57

Spring - ApplicationContext,ApplicationContextAware, 빈이 아닌 객체에 빈주입할때!



@Autuwired,@Inject 등의 어노테이션으로 의존주입을 하기 위해서는 해당 객체가 빈으로 등록되어 있어야만 가능하다.

사실 이런 상황은 웹프로그래밍에서는 거의 없겠지만... 빈으로 등록되지 않은 객체에 빈으로 등록된 객체를 의존주입해야할 상황이 있을 수도 있다.

 그럴때 사용할수 있는 하나의 UtilClass 이다.



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
public class BeanUtils implements ApplicationContextAware {
 
    private static ApplicationContext context;
    
    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        // TODO Auto-generated method stub
        context = applicationContext;
    }
 
    public static <T> T getBean(Class<T> beanClass) {
        return context.getBean(beanClass);
    }
}
cs


ApplicationContextAware를 구현한 BeanUtils 클래스를 하나 만들었다. 그리고 setApplicationContext() 메소드로 ApplicationContext를 

주입받고 있는 상황이다. 그리고 static으로 선언된 getBean 메소드를 이용하여 빈주입을 원하는 어딘가에서


BeanUtils.getBean()를 호출하여 빈을 주입받을 수 있다.!

posted by 여성게
:
Web/Spring Cloud 2019. 2. 25. 00:22

Spring Cloud - Zuul API gateway & Proxy !(Netflix Zuul)


Netflix Zuul 이란 무엇인가?

마이크로서비스 아키텍쳐(MSA)에서 Netflix Zuul은 간단히 API gateway 또는 API Service,Edge Service로 정의된다.

그래서 하는 일이 무엇이냐? 마이크로서비스 아키텍쳐에서 여러 클라이언트 요청을 적절한 서비스로 프록시하거나 라우팅하기 위한 서비스이다.




위의 이미지에서 보이듯, 모든 마이크로서비스의 종단점은 숨기고 모든 요청을 최앞단에서 Zuul이 받아 

적절한 서비스로 분기를 시키게된다. 모든 마이크로서비스의 종단점을 숨겨야하는 이유가 무엇인가?


1) 클라이언트는 일부 마이크로서비스만 필요로한다.

2) 클라이언트별로 적용돼야 할 정책이 있다면 그 정책을 여러 곳에서 분산해 두는 것보단 한곳에 두고 적용하는 것이

더욱안전하다.(크로스오리진 접근정책이 바로 이런 방식의 대표적인 예임) 또한 서비스 단에서 사용자별 분기처리 로직은

구현하기 까다롭다.

3)대역폭이 제한돼 있는 환경에서 데이터 집계가 필요하다면 다수의 클라이언트의 요청이 집중되지 않게 중간에 게이트웨이를

두는것이 좋다.



Netflix Zuul 설계목적?



우선 Zuul은 JVM-based router and Server-side load Balancer이다. Zuul을 사용함으로써 서버사이드에서
동적 라우팅, 모니터링, 회복 탄력성, 보안 기능을 지원한다(Filter를 통한 구현)
또한 Zuul은 다른 기업용 API 게이트웨이 제품과는 달리 개발자가 특정한 요구 사항에 알맞게 설정하고 프로그래밍할 수 있게
개발자에게 완전한 통제권을 준다.


Zuul 프록시는 내부적으로 서비스 탐색을 위해 Eureka(유레카) 서버를 사용하고, 서비스 인스턴스 사이의 부하 분산을 위해 Ribbon(리본)을 사용한다.
위에서도 이야기 했던 것처럼 Zuul은 API계층에서 서비스의 기능을 재정의해서 뒤에 있는 서비스의 동작을 바꿀수 있다.




만약 이전 포스팅에서 Eureka에 대해 읽어 보았다면, 위의 그림만 보아도 Zuul이 어떤식으로

동작하는지 이해가 될것이다.


▶︎▶︎▶︎Spring Cloud - Eureka를 이용한 마이크로서비스 동적등록&탐색&부하분산처리



그렇다면 Zuul은 어떠한 요구사항일때 쓸모가 있을까?


많은 요구사항이 있지만, 아래와 같은 요구사항일때 특히 더 쓸모가 있다.


1) 인증이나 보안을 모든 마이크로서비스 종단점에 각각 적용하는 대신 게이트웨이 한곳에 적용한다. 게이트웨이는

요청을 적절한 서비스에 전달하기 전에 보안 정책 적용, 토큰 처리 등을 수행할 수 있다. 또한 특정 블랙리스트(IP차단) 사용자를

거부할 수 있는 비즈니스 정책 적용이 가능하다.

2) 모니터링, 데이터 집계 등을 마이크로서비스 단에서 처리하는 것이아니라, Zuul에서 처리해 외부로 데이터를

내보낼때 사용할 수 있다.

3)부하 슈레딩(shredding),부하 스로틀링(throttling)이 필요한 상황에서도 유용하다.

4)세밀한 제어를 필요로 하는 부하 분산 처리에 유용하다(Zuul+Eureka+Ribbon)



예제 프로젝트의 구성은 Spring Cloud Config, Eureka, Zuul로 구성되어 있습니다.

▶︎▶︎▶︎Spring Cloud Config

▶︎▶︎▶︎Spring Cloud Eureka




예제프로젝트는 위의 이미지의 구성입니다. 하지만 편의상 Zuul은 하나의 인스턴스만 그리고 2개의 마이크로서비스 인스턴스만

띄울 예정입니다. 그리고 마이크로서비스 인스턴스들은 또한 편의상 Spring Cloud Config를 이용하지 않았습니다.

만약 모든 구성을 스프링클라우드 컨피그로 가신다면 다른 유레카나 주울과 같은 컨피그 구성으로 가시면 됩니다.

그리고 이전 포스팅에서 유레카 서버를 독립설치형이 아닌 클러스터링된 구성으로 진행 할 것입니다.



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#유레카 서버 - 1
spring.application.name=eureka-server1
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/,http://localhost:8899/eureka/
#eureka.instance.hostname=localhost
eureka.client.registerWithEureka=true
eureka.client.fetchRegistry=true
 
#유레카 서버 - 2
spring.application.name=eureka-server2
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/,http://localhost:8899/eureka/
eureka.client.registerWithEureka=true
eureka.client.fetchRegistry=true
 
#주울 
server.port=8060
zuul.routes.search-apigateway.serviceId=eurekaclient
zuul.routes.search-apigateway.path=/api/**
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/,http://localhost:8899/eureka/
cs


위는 깃저장소에 있는 spring cloud config 설정파일입니다.(편의상 하나의 파일로 작성함. 실제로는 따로 파일을 나눠야함)

조금 설명할 점이 있다면, 유레카 독립모드와 클러스터 모드의 차이점입니다. 독립모드는 자신을 유레카서버에 등록하지 않고, 

캐시한 서비스 목록을 패치하지 않습니다. 하지만 클러스터모드에서는 유레카서버들이 서로 통신해야하기 때문에 자신을 서비스로

등록하고, 자신들의 서버목록들을 빠르게 통신하기 위해 캐시합니다. 그리고 defaultZone에 모든 유레카서버의 경로를 ","구분으로

나열합니다.(사실 서로 크로스해서 상대방의 주소만 써도됨. 하지만 나중에 유레카 서버가 많고 서로 하나씩 크로스됬다는 구성에서

만약 하나의 유레카서버가 죽어서 유레카서버끼리의 통신이 단절될 가능성도 있음. 그래서 모든 유레카서버 목록을 나열해서

통신하도록 하는 것이 좋음.)

그리고 주울도 하나의 유레카클라이언트이며 주울 프록시이다. 그래서 defaultZone에 유레카서버들을 나열한다. 그리고 프록시 설정이

한가지 방법만 있는 것은 아닌데 이 설정파일에는 /api/**로 들어오는 요청을 모두 eurekaclient로 보내라는 설정이다.



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#서비스 - 1, application.properties
server.port=8090
spring.application.name=eurekaclient
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/,http://localhost:8899/eureka/
eureka.client.healthcheck.enabled=true
 
#서비스 - 2, application.properties
server.port=8080
spring.application.name=eurekaclient
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/,http://localhost:8899/eureka/
eureka.client.healthcheck.enabled=true
 
#서비스 1,2 호출하는 클라이언트, application.properties
server.port=8070
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/,http://localhost:8899/eureka/
spring.application.name=eureka-call-client
 
 
#config server, bootstrap.properties
server.port=8888
spring.cloud.config.server.git.uri=https://github.com/yeoseong/spring-cloud-configserver.git
management.security.enabled=false
management.endpoint.env.enabled=true
 
#eureka server - 1, bootstrap.properties
spring.application.name=eureka
spring.profiles.active=server1
server.port=8889
spring.cloud.config.uri=http://localhost:8888
management.security.enabled=false
 
#eureka server - 2, bootstrap.properties
spring.application.name=eureka
spring.profiles.active=server2
server.port=8899
spring.cloud.config.uri=http://localhost:8888
management.security.enabled=false
 
#zuul , bootstrap.properties
spring.application.name=zuulapi
spring.cloud.config.uri=http://localhost:8888
spring.profiles.active=dev
management.security.enabled=false
 
zuul.routes.eurekaclient=/api3/**
 
 
 애플리케이션들의 application.properties,bootstrap.properties 입니다.(스프링클라우드컨피그 사용여부에 따라 다름)
 
cs


나머지 설정들은 이전 포스팅에서 보고 왔다면 모두 이해할수 있다. 마지막 하나만 설명하자면, zuul의 설정이다. 이미 깃에 있는 설정파일에서 하나의

프록시 룰을 정해줬다. 하지만 그 방법말고도 다른방법이 있다.

zuul.routes.serviceId(eureka)=/path/**로도 라우팅 규칙을 정해줄 수 있다.


이제는 소스 설명이다. 유레카 및 컨피그, 마이크로서비스 클라이언트 소스는 이전과 동일하기 때문에 따로 작성하지 않는다.

▶︎▶︎▶︎Spring Cloud Config

▶︎▶︎▶︎Spring Cloud Eureka




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@EnableZuulProxy
@EnableDiscoveryClient
@SpringBootApplication
public class ZuulApiApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(ZuulApiApplication.class, args);
    }
    
    @Bean
    @LoadBalanced
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
    
    @Bean
    public ZuulFilter zuulFilter() {
        return new ZuulCustomFilter();
    }
    
    @RestController
    class ZuulController{
        
        @Autowired
        RestTemplate restTemplate;
        
        @GetMapping("/api2")
        public String zuulProxy() {
            System.out.println("ZuulController.zuulProxy() :::: /api2");
            return restTemplate.getForObject("http://eurekaclient/eureka/client"String.class);
        }
        
    }
}
cs


위의 소스를 설명하면, @EnableZuulProxy로 이 애플리케이션이 주울 프록시임을 명시한다. 그리고 주울도 하나의 유레카 클라이언트임으로

@EnableDiscoveryClient로 명시해준다. 그리고 주울의 특징중 하나는 스프링 기반으로 만들어진 API임으로 개발자가 자신이 커스터마이징해서

사용할 수 있다는 점이다. @RestController로 직접 주울의 엔드포인트를 정의해서 원하는 서비스로 보낼수 있다. 더 세밀한 무엇인가가

필요하다면 이렇게 컨트롤러를 만들어서 커스터마이징해도 좋을 듯싶다. 그리고 주울도 위에서 말했듯이 하나의 유레카 클라이언트고

내부적으로 리본을 사용해 로드벨런싱 한다고 했으니, 컨트롤러에서 라우팅할때 @LoadBalanced된 RestTemplate을 이용해야

로드밸런싱이 된다.(지금까지 총 3가지 라우팅 룰을 다뤘다.)



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class ZuulCustomFilter extends ZuulFilter{
    
    private static Logger logger = LoggerFactory.getLogger(ZuulCustomFilter.class);
    
    /**
     * Criteria - 필터 실행 여부를 결정
     */
    @Override
    public boolean shouldFilter() {
        // TODO Auto-generated method stub
        return true;
    }
    
    /**
     * Action - Criteria 만족 시에 실행할 비즈니스 로직
     */
    @Override
    public Object run() throws ZuulException {
        // TODO Auto-generated method stub
        
        logger.info("ZuulCustomFilter :::: {}","pre filter");
        
        return null;
    }
    
    /**
     * Type - pre,route,post
     */
    @Override
    public String filterType() {
        // TODO Auto-generated method stub
        return "pre";
    }
    
    /**
     * Order - 필터 실행 순서를 결정, 숫자가 낮을 수록 우선순위가 높아짐.
     */
    @Override
    public int filterOrder() {
        // TODO Auto-generated method stub
        return 0;
    }
    
}
cs


또한 주울은 필터를 정의해서 필요한 요청,응답에 대한 전/후처리가 가능합니다.

Pre Filter

주로 backend에 보내줄 정보를 RequestContext에 담는 역할

Payco의 AccessToken으로 email을 넘겨주는 경우





























public class QueryParamPreFilter extends ZuulFilter {
@Override
public int filterOrder() {
return PRE_DECORATION_FILTER_ORDER - 1; // run before PreDecoration
}

@Override
public String filterType() {
return PRE_TYPE;
}

@Override
public boolean shouldFilter() {
RequestContext context = RequestContext.getCurrentContext();
return "member-api".equals(context.get(SERVICE_ID_KEY));
}

@Override
public Object run() {
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
String email = paycoTokenToEmail(request);
context.addZuulRequestHeader("X-PAYCO-EMAIL", email);
return null;
}
}

email은 소중한 개인 정보입니다. 다루실 때 주의하시기 바랍니다.

Route Filter

pre filter 이후에 실행되며, 다른 서비스로 보낼 요청을 작성한다

이 필터는 주로 request, response를 client가 요구하는 모델로 변환하는 작업을 수행한다

아래의 예제는 Servlet Request를 OkHttp3 Request로 변환하고, 요청을 실행하고,

OkHttp3 Response를 Servlet Response로 변환하는 작업을 수행한다












































































public class OkHttpRoutingFilter extends ZuulFilter {
@Autowired
private ProxyRequestHelper helper;

@Override
public String filterType() {
return ROUTE_TYPE;
}

@Override
public int filterOrder() {
return SIMPLE_HOST_ROUTING_FILTER_ORDER - 1;
}

@Override
public boolean shouldFilter() {
return RequestContext.getCurrentContext().getRouteHost() != null
&& RequestContext.getCurrentContext().sendZuulResponse();
}

@Override
public Object run() {
OkHttpClient httpClient = new OkHttpClient.Builder()
// customize
.build();

RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();

String method = request.getMethod();

String uri = this.helper.buildZuulRequestURI(request);

Headers.Builder headers = new Headers.Builder();
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
Enumeration<String> values = request.getHeaders(name);

while (values.hasMoreElements()) {
String value = values.nextElement();
headers.add(name, value);
}
}

InputStream inputStream = request.getInputStream();

RequestBody requestBody = null;
if (inputStream != null && HttpMethod.permitsRequestBody(method)) {
MediaType mediaType = null;
if (headers.get("Content-Type") != null) {
mediaType = MediaType.parse(headers.get("Content-Type"));
}
requestBody = RequestBody.create(mediaType, StreamUtils.copyToByteArray(inputStream));
}

Request.Builder builder = new Request.Builder()
.headers(headers.build())
.url(uri)
.method(method, requestBody);

Response response = httpClient.newCall(builder.build()).execute();

LinkedMultiValueMap<String, String> responseHeaders = new LinkedMultiValueMap<>();

for (Map.Entry<String, List<String>> entry : response.headers().toMultimap().entrySet()) {
responseHeaders.put(entry.getKey(), entry.getValue());
}

this.helper.setResponse(response.code(), response.body().byteStream(),
responseHeaders);
context.setRouteHost(null); // prevent SimpleHostRoutingFilter from running
return null;
}
}

Post Filter

Response를 생성하는 작업을 처리한다

아래 예제는 X-Sample 헤더에 임의의 UUID를 넣는 소스이다

























public class AddResponseHeaderFilter extends ZuulFilter {
@Override
public String filterType() {
return POST_TYPE;
}

@Override
public int filterOrder() {
return SEND_RESPONSE_FILTER_ORDER - 1;
}

@Override
public boolean shouldFilter() {
return true;
}

@Override
public Object run() {
RequestContext context = RequestContext.getCurrentContext();
HttpServletResponse servletResponse = context.getResponse();
servletResponse.addHeader("X-Sample", UUID.randomUUID().toString());
return null;
}
}

▶︎▶︎▶︎참고


 



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@EnableDiscoveryClient
@SpringBootApplication
public class Eurekaclient3Application {
 
    
    public static void main(String[] args) {
        SpringApplication.run(Eurekaclient3Application.class, args);
    }
    
    @Bean
    @LoadBalanced
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
    
    @RestController
    class EurekaClientController{
        
        @Autowired
        RestTemplate restTemplate;
        
        @GetMapping("/eureka/client")
        public String eurekaClient() {
            
//            String result = restTemplate.getForObject("http://eurekaclient/eureka/client", String.class);
            System.out.println("EurekaClientController :::: /eureka/client");
            String result = restTemplate.getForObject("http://zuulapi/api/eureka/client"String.class);
            
            return result;
        }
        @GetMapping("/eureka/client2")
        public String eurekaClient2() {
            
            System.out.println("EurekaClientController :::: /eureka/client2");
            String result = restTemplate.getForObject("http://zuulapi/api2"String.class);
            
            return result;
        }
        
        @GetMapping("/eureka/client3")
        public String eurekaClient3() {
            
            System.out.println("EurekaClientController :::: /eureka/client3");
            String result = restTemplate.getForObject("http://zuulapi/api3/eureka/client"String.class);
            
            return result;
        }
    }
}
cs


이제 마이크로서비스를 호출하는 클라이언트 소스입니다..(주울호출) 모두 /apin/~으로 주울에게 요청이 갑니다.

그리고 주울에서는 각각의 마이크로서비스로 /apin/을 제외한 나머지 Uri를 해당 마이크로서비스들에게 요청보냅니다.

(물론 설정으로 앞의 프리픽스까지 붙여서 요청보내게 할 수 있음)

postman 이나 curl로 호출이 잘되는지 확인 해보시면 될듯합니다..



<유레카 및 주울 설정 메모>


구글링을 막하다가 유레카 및 주울 설정들을 막 메모한 것들입니다.

정리하기 힘들어서.... 그냥 메모 그대로 올립니다....


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
================================================================================Zuul=========================================================================================================
#Netflix Zuul 1.x은 외부 API 호출시 클라이언트 사이드 로드 밸런서로 Netflix Ribbon를 사용한다. 
#또한, Netflix Ribbon는 외부 API 서비스의 물리적인 노드 정보를 발견하는 역할로 Netflix Eureka에 의존한다. 
#만약 Netflix Eureka(별도 독립 서비스 구축 필요)를 사용하지 않는다면 ribbon.eureka.enabled 옵션을 false로 설정하면 된다.
#zuul.sensitive-headers에 특정 헤더 이름을 설정하면 라우팅 전에 해당 헤더를 제거할 수 있다. 보안 문제로 라우팅되지 말아야할 헤더가 있을 경우 활용할 수 있다.
#zuul.host.connect-timeout-millis으로 API 요청 후 연결까지의 타임아웃을 설정할 수 있다. 설정된 타임아웃이 초과했을 경우 ZuulException(내부적으로는 java.net.ConnectException) 예외가 발생한다.
#zuul.host.socket-timeout-millis으로 API 요청 후 응답까지의 타임아웃을 설정할 수 있다. 설정된 타임아웃이 초과했을 경우 ZuulException(내부적으로는 java.net.SocketTimeoutException) 예외가 발생한다.
#zuul.routes.url을 직접적으로 명시하면 Netflix Ribbon을 사용하지 않는다.
#zuul.routes.stripPrefix를 false로 설정하면 라우팅시 url에 path가 그대로 보존되어 결합된다. 인지적으로 가장 자연스러운 설정이다. true(기본값)로 설정시에는 url에서 path 부분은 제거되고 나머지 부분이 추가되어 라우팅된다.
 
#구성 등록 정보 zuul.max.host.connections는 
#두 개의 새 등록 정보 zuul.host.maxTotalConnections 및 zuul.host.maxPerRouteConnections로 대체되었습니다. 
#기본값은 각각 200 및 20입니다.
 
#모든 경로의 기본 Hystrix 격리 패턴 (ExecutionIsolationStrategy)은 SEMAPHORE입니다. 
#zuul.ribbonIsolationStrategy는 격리 패턴이 선호되는 경우 THREAD로 변경할 수 있습니다.
#THREAD일때, WAS의 스레드로 API요청을 받는 것이 아니라, Hystrix의 별도의 스레드를 이용하여 
#WAS의 스레드와 격리한다.
 
#프록시는 리본을 사용하여 검색을 통해 전달할 인스턴스를 찾습니다. 
#모든 요청은 hystrix 명령으로 실행되므로 실패는 Hystrix 메트릭에 나타납니다. 
#회선이 열리면 프록시는 서비스에 접속하려고 시도하지 않습니다.
 
#서비스가 자동으로 추가되는 것을 건너 뛰려면 zuul.ignored-services를 서비스 ID 패턴 목록으로 설정하십시오. 
#zuul.ignored-services='*'
 
#zuul.routes.eurekaclient=/api3/**
 
#서비스아이디가 아니라 직접 URL을 등록할 수 있지만, 이것은 클라우드의 로드벨런싱 효과를 얻을 수 없다.
 
#zuul.strip-prefix=true 으로 설정된 prefix를 붙여서 요청을 보낸다.
 
 
#zuul:
#  routes:
#    users:
#      path: /myusers/**
#      serviceId: users
#
#ribbon:
#  eureka:
#    enabled: false
#
#users:
#  ribbon:
#    listOfServers: example.com,google.com
#위와 같이 직접 리본으로 라우팅할 리스트를 작성할 수있다. 이것을 사용하려면 eureka 사용을 비활성화해야한다.
 
#만약 X-Forwarded-Host 헤더같은 것이 요청에 들어갔을 경우,
#헤더값을 추가못하게 설정할수 있다.
#zuul.add-proxy-headers=false
 
 
#기본 경로 (/)를 설정하면 @EnableZuulProxy가있는 응용 프로그램이 독립 실행 형 서버로 작동 할 수 있습니다. 
#예를 들어, zuul.route.home : /는 모든 트래픽 ( "/ **")을 "home"서비스로 라우팅합니다.
 
#zuul:
#  routes:
#    users:
#      path: /myusers/**
#      sensitiveHeaders: Cookie,Set-Cookie,Authorization
#      url: https://downstream
# 위처럼 bypass시킬 헤더값 목록을 지정
 
# zuul:
#  routes:
#    users:
#      path: /myusers/**
#      sensitiveHeaders:
#      url: https://downstream
# 모든 헤더값을 bypass
 
#zuul.ignored-headers=Cookie,Set-Cookie
#Cookie,Set-Cookie 헤더는 버린다.
 
 
#spring security를 쓰고 시큐리티헤더를 통과시키려면
#zuul.ignore-security-headers=false
 
#zuul:
#  forceOriginalQueryStringEncoding: true
# 원래 인코딩값으로 강제로 바꾼다.
 
#Zuul이 서비스 검색을 사용하는 경우 ribbon.ReadTimeout 및 ribbon.SocketTimeout 리본 등록 정보로 이러한 시간 초과를 구성해야합니다.
#URL을 지정하여 Zuul 경로를 구성한 경우 zuul.host.connect-timeout-millis 및 zuul.host.socket-timeout-millis를 사용해야합니다.
 
#기본적으로 Zuul은 모든 Cross Origin Request (CORS)를 서비스로 라우팅합니다. 
#대신 Zuul이 이러한 요청을 처리하기 원하는 경우 사용자 지정 WebMvcConfigurer bean을 제공하여 수행 할 수 있습니다.
#@Bean
#public WebMvcConfigurer corsConfigurer() {
#    return new WebMvcConfigurer() {
#        public void addCorsMappings(CorsRegistry registry) {
#            registry.addMapping("/path-1/**")
#                    .allowedOrigins("http://allowed-origin.com")
#                    .allowedMethods("GET", "POST");
#        }
#    };
#}
================================================================================Zuul=========================================================================================================
================================================================================Eureka=========================================================================================================
#<Eureka 등장 용어 정리>
#    <Eureka 행동 관련>
#        Service Registration: 서비스가 자기 자신의 정보를 Eureka에 등록하는 행동
#        Service Registry: 서비스가 스스로 등록한 정보들의 목록, 가용한 서비스들의 위치 정보로 갱신됨
#        Service Discovery: 서비스 클라이언트가 요청을 보내고자 하는 대상의 정보를 Service Registry를 통해 발견하는 과정
#    <Eureka 구성 요소 관련>
#        Eureka Client: 서비스들의 위치 정보를 알아내기 위해 Eureka에 질의하는 서비스를 가리킴 (like Service consumer)
#        Eureka Service: Eureka Client에 의해 발견의 대상이 되도록 Eureka에 등록을 요청한 서비스를 가리킴 (like Service provider)
#        Eureka Server: Eureka Service가 자기 자신을 등록(Service Registration)하는 서버이자 Eureka Client가 가용한 서비스 목록(Service Registry)을 요청하는 서버
#        Eureka Instance: Eureka에 등록되어 목록에서 조회 가능한 Eureka Service를 의미
#
#<Eureka Client 동작과 Server간 Communication>
#    <Self-Identification & Registration>
#        Eureka Client는 어떻게 Eureka Server로부터 서비스 목록을 받아올까?
#        REST endpoint /eureka/apps를 통해 등록된 인스턴스 정보를 확인할 수 있다.
#        
#        Traffic을 받을 준비가 되면 Eureka Instance의 status가 STARTING → UP으로 바뀐다
#        status:STARTING은 Eureka Instance가 초기화 작업을 진행 중인 상태로 Traffic을 받을 준비가 안되었다는 의미이다
#        eureka.instance.instance-enabled-onit 설정값을 통해 Startup 후 Traffic 받을 준비가 되었을 때 status:UP이 되도록 할 수 있다 (default: false)
#        
#        등록 이후 heartbeat은 eureka.instance.lease-renewal-interval-in-seconds에 설정된 주기마다 스케쥴러가 실행된다 (default: 30)
#        
#        Eureka Server는 interval에 따라 Eureka Service의 status(UP/DOWN/..)를 판단하고 
#        가장 최근 heartbeat 시점 + interval 이후에 heartbeat을 받지 못하면 
#        eureka.instance.lease-expiration-duration-in-seconds에 설정된 시간만큼 기다렸다가 
#        해당 Eureka Instance를 Registry에서 제거한다 (default: 90, 단, Eureka Instance가 정상적으로 종료된 경우 Registry에서 바로 제거된다)
#        위의 값은 lease-renewal-interval-in-seconds보다는 커야한다.
#        
#        등록 이후 Instance 정보가 변경 되었을 때 Registry 정보를 갱신하기 위한 REST를 
#        eureka.client.instance-info-replication-interval-seconds에 설정된 주기마다 호출한다 (default: 30)
#        eureka.client.initial-instance-info-replication-interval-seconds (default: 40)
#        
#        Eureka Server 추가, 변경, 삭제가 일어날 때 Eureka Client가 얼마나 자주 service urls를 갱신할 것인지 
#        eureka.client.eureka-service-url-poll-interval-seconds 값으로 조정할 수 있다 
#        #default: 0, 단 DNS를 통해 service urls를 가져오는 경우)
#    
#    <Service Discovery>
#        -Instance Startup 시점
#        Eureka로부터 Registry 정보를 fetch한다
#        Instance Startup 이후 Fetch Registry
#        등록 이후 Eureka Client는 eureka.client.registry-fetch-interval-seconds에 설정된 주기마다 Local Cache Registry 정보를 갱신한다 (default: 30)
    
    
#<Eureka Server 동작과 Peer Server간 Communication>    
#    <Self-Identification & Registration>
#        -Instance Startup 시점
#            Peer nodes를 찾아서 Registry 정보 등 Sync 맞추는 작업을 한다
#            eureka.server.registry-sync-retrires 값을 통해 Peer nodes로부터 Registry 정보를 얻기 위한 재시도 횟수를 조정할 수 있다 (default: 5)
#            Eureka Server가 시작되고 Peer nodes로부터 Instance들을 가져올 수 없을 때 얼마나 
#            기다릴 것인지 eureka.server.wait-time-in-ms-when-sync-empty 시간(milliseconds)을 조정할 수 있다 (default: 3000)
#
#            나머지 과정은 Server도 Eureka Client이기 때문에 'Eureka Client > Self-Identification & Registration > Instance Startup 시점'에 설명한 바와 같이 동일하게 동작한다
#            Standalone으로 구성하는 경우 Peer nodes가 없기 때문에 eureka.client.register-with-eureka: false 설정을 통해 등록 과정을 생략할 수 있다
 
 
#<Eureka Server Response Cache 설정>  
#    Eureka server에서 eureka client에게 자신의 registry 정보를 제공 시 사용하는 cache.  
#    client에게 더 빠른 registry 정보 제공을 위해 실제 registry 값이 아닌 cache의 값을 제공 함.  
#    eureka.server.response-cache-update-interval-ms: 3000 # 기본 30초
 
#<Eureka Client Cache 설정> 
#    Eureka client에 존재하는 cache로 eureka server에 서비스 정보 요청 시 이 cache의 값을 이용 한다.   
#    eureka.client.fetchRegistry 값이 false이면 client cache는 적용되지 않는다.   
#    eureka.client.registryFetchIntervalSeconds: 3 # 기본 30초
 
#어떤 경우에는 유레카가 호스트 이름보다는 서비스의 IP 주소를 광고하는 것이 바람직합니다.
#eureka.instance.preferIpAddress를 true로 설정하고 응용 프로그램이 eureka에 등록하면 호스트 이름 대신 IP 주소를 사용합니다.
 
#spring-boot-starter-security를 ​​통해 서버의 classpath에 Spring Security를 ​​추가하기 만하면 유레카 서버를 보호 할 수 있습니다. 
#기본적으로 Spring Security가 classpath에있을 때, 모든 요청에 ​​대해 유효한 CSRF 토큰을 앱에 보내야합니다. 
#유레카 고객은 일반적으로 유효한 CSRF (cross site request forgery) 토큰을 보유하지 않으므로 / eureka / ** 엔드 포인트에 대해이 요구 사항을 비활성화해야합니다.
#@EnableWebSecurity
#class WebSecurityConfig extends WebSecurityConfigurerAdapter {
#
#    @Override
#    protected void configure(HttpSecurity http) throws Exception {
#        http.csrf().ignoringAntMatchers("/eureka/**");
#        super.configure(http);
#    }
#}
 
#Eureka Discovery Client를 사용하지 않으려면 eureka.client.enabled를 false로 설정할 수 있습니다. 
#Eureka Discovery Client는 spring.cloud.discovery.enabled가 false로 설정된 경우에도 비활성화됩니다.
 
#eureka.client.serviceUrl.defaultZone URL 중 하나에 자격 증명이 포함되어 있으면 HTTP 기본 인증이 자동으로 유레카 클라이언트에 추가됩니다 
#(컬 스타일, http : // user : password @ localhost : 8761 / eureka). 
#보다 복잡한 요구를 위해, DiscoveryClientOptionalArgs 타입의 @Bean을 생성하고 ClientFilter 인스턴스를 클라이언트에 삽입 할 수 있습니다.
#이 인스턴스는 모두 클라이언트에서 서버로의 호출에 적용됩니다.
 
#Eureka 인스턴스의 상태 페이지 및 상태 표시기는 각각 Spring Boot Actuator 응용 프로그램의 유용한 끝점의 기본 위치 인 / info 및 / health로 기본 설정됩니다.
#eureka:
#  instance:
#    statusPageUrlPath: ${server.servletPath}/info
#    healthCheckUrlPath: ${server.servletPath}/health
 
#HTTPS를 통해 앱과 연락하려는 경우 EurekaInstanceConfig에서 다음과 같은 두 가지 플래그를 설정할 수 있습니다.
#eureka.instance.[nonSecurePortEnabled]=[false]
#eureka.instance.[securePortEnabled]=[true]
 
#eureka.hostname == eureka.instance.hostname
 
#기본적으로 Eureka는 클라이언트 하트 비트를 사용하여 클라이언트가 작동 중인지 확인합니다.
#별도로 지정하지 않는 한, Discovery Client는 Spring Boot Actuator에 따라 응용 프로그램의 현재 상태 검사 상태를 전파하지 않습니다. 
#따라서 성공적으로 등록한 후 Eureka는 응용 프로그램이 항상 UP 상태임을 발표합니다. 
#이 동작은 Eureka 상태 점검을 활성화하여 응용 프로그램 상태를 Eureka에 전파함으로써 변경 될 수 있습니다. 
#결과적으로 다른 모든 응용 프로그램은 'UP'이외의 상태로 응용 프로그램에 트래픽을 보내지 않습니다.
#반드시 actuator 의존이 필요
 
#상태 검사를 더 많이 제어해야하는 경우에는 com.netflix.appinfo.HealthCheckHandler를 직접 구현하는 것이 좋습니다.
 
#라우터를 사용하는 경우 (권장 또는 필수, 플랫폼 설정 방식에 따라 다름) 라우터를 사용하도록 명시 적으로 호스트 이름과 포트 번호 (보안 또는 비보안)를 설정해야합니다.
#eureka:
#  client:
#    healthcheck:
#      enabled: true
#만약 bootstrap.properties에 등록하면 UNKNOWN등의 비정상적인 상태값이 나올수 있다.(반드시 application.xxx에 설정하자)
 
#Cloud Foundry에는 글로벌 라우터가있어 동일한 앱의 모든 인스턴스가 동일한 호스트 이름을 갖는다면,
#이것은 반드시 유레카 사용의 문제가 있지는 않다.
#그러나 라우터를 사용하는 경우 (권장 또는 필수, 플랫폼 설정 방식에 따라 다름) 라우터를 사용하도록 명시 적으로 호스트 이름과 포트 번호 (보안 또는 비보안)를 설정해야합니다.
#eureka:
#  instance:
#    hostname: ${vcap.application.uris[0]}
#    nonSecurePort: 80
 
 
 
 
 
#########################################################################################################################################
#Register
#    eureka.instance, eureka.client 설정값을 바탕으로 Eureka에 등록하기 위한 Eureka Instance 정보를 만듦
#    Client가 eureka 서버로 첫 hearbeat 전송 시 Eureka Instance 정보를 등록
#    등록된 instance 정보는 eureka dashboard나  http://eurekaserver/eureka/apps를 통해 확인할 수 있음
#Renew
#    Client는 eureka에 등록 이후 설정된 주기마다 heatbeat를 전송하여 자신의 존재를 알림
#    eureka.instance.lease-renewal-interval-in-seconds (default: 30)
#    설정된 시간동안 heartbeat를 받지 못하면 해당 Eureka Instance를 Registry에서 제거
#    eureka.instance.lease-expiration-duration-in-seconds (default: 90)
#    renew 관련 interval은 변경하지 않는것을 권장 함(서버 내부적으로 client를 관리하는 로직 때문)
#Fetch Registry
#    Client는 Server로부터 Registry(서버에 등록된 인스턴스 목록) 정보를 가져와서 로컬에 캐시
#    캐시 된 정보는 설정된 주기마다 업데이트 됨
#    eureka.client.registryFetchIntervalSeconds (default: 30)
#Cancel
#    Client가 shutdown될 때 cancel 요청을 eureka 서버로 보내서 registry에서 제거 하게 됨
#Time Lag
#    Eureka server와 client의 registry 관련 캐시 사용으로 인해 client가 호출 하려는 다른 instance 정보가 최신으로 갱신되는데 약간의 시간 차가 있음
 
 
#Peering
#    여러대의 eureka server를 사용하여 서로 peering 구성이 가능하다.
#    Eureka server는 설정에 정의된 peer nodes를 찾아서 Registry 정보 등 Sync 맞추는 작업을 한다 .
#    
#    관련 설정
#        Standalone으로 구성하려면 아래 처럼 설정
#            eureka.client.register-with-eureka: false
#        Peer nodes 로부터 registry를 갱신할 수 없을 때 재시도 횟수
#            eureka.server.registry-sync-retrires (default: 5)
#        Peer nodes 로부터 registry를 갱신할 수 없을때 재시도를 기다리는 시간
#            eureka.server.wait-time-in-ms-when-sync-empty (default: 3000) milliseconds
 
#Self-Preservation Mode(자가보존모드)
#    Eureka 서버는 등록된 instance로부터 heartbeat를 주기적으로 받는다.
#    하지만 네트워크 단절 등의 상황으로 hearbeat를 받을 수 없는 경우 보통 registry에서 해당 instance를 제거 한다.
#    Eureka로의 네트워크는 단절되었지만, 해당 서비스 API를 호출하는데 문제가 없는 경우가 있을수 있어서,
#    self-preservation 을 사용하여 registry에서 문제된 instance를 정해진 기간 동안 제거하지 않을 수 있다.
#    EvictionTask가 매분 마다 Expected heartbeats 수와 Actual heartbeats 수를 비교하여 Self-Preservation 모드 여부를 결정한다.
#        eureka.server.eviction-interval-timer-in-ms (default: 60 * 1000)
 
#Expected heartbeats updating scheduler
#    기본 매 15분(renewal-threshold-update-interval-ms) 마다 수행되며 preservation mode로 가기 위한 임계값을 계산한다.
#    예를 들어 인스턴스 개수가 N개이고, renewal-percent-threshold값이 0.85이면 계산식은 아래와 같다.
#    최소 1분이내 받아야 할 heartbeat 총 수 = 2  N  0.85  
#    위 값은 아래 설정으로 변경 가능 
#        eureka.instance.lease-renewal-interval-in-seconds (default: 30)
#        eureka.server.renewal-percent-threshold (default: 0.85)
#        scheduler 수행 주기 설정 eureka.server.renewal-threshold-update-interval-ms (default: 15  60  1000)
 
#Actual heartbeats calculation scheduler
#    기본 매 1분 마다 수행되며 실제 받은 heartbeats 횟수를 계산하다.
 
#eureka
#    instance:
#         preferIpAddress: true # 서비스간 통신 시 hostname 보다 ip 를 우선 사용 함
 
#server:
#  port: 8761
#
#eureak:
#  server:
#    enable-self-preservation: true
#  client:
#    registerWithEureka: true      
#    fetchRegistry: true           
#
#---
#
#spring:
#  profiles: eureka1
#eureka:
#  instance:
#    hostname: eureka1
#  client:
#    serviceUrl:
#      defaultZone: http://eureka2:8761/eureka/
#
#---
#spring:
#  profiles: eureka2
#eureka:
#  instance:
#    hostname: eureka2
#  client:
#    serviceUrl:
#      defaultZone: http://eureka1:8761/eureka/
 
#동일서버에서 실행하는 경우 instance hostname은 unique하게 설정되어야 한다.
#registerWithEureka true로 설정
#    true설정시 서버 자신도 유레카 클라이언트로 등록한다.
#fetchRegistry true로 설정
#    defaultZone의 유레카 서버에서 클라이언트 정보를 가져온다(registerWithEureka가 true로 설정되어야 동작함)
#profile 추가하여 서로 참조하도록 serviceUrl.defaultZone 설정
#self preservation
 
#spring:
#  application:
#    name: customer-service
#
#eureka:
#  client:
#    serviceUrl:
#      defaultZone: http://eureka1:8761/eureka/,http://eureka2:8761/eureka/
#    enabled: true
#eureka.client.serviceUrl.defaultZone에 clustering한 유레카 서버 모두 입력
#    heart-beat는 defaultZone의 very first 항목인 eureka1에 만 전송
#여러개의 Eureka에 등록할 경우 defaultZone에 ,(comma)로 구분하여 입력한다.
 
#유레카 (Eureka)는 CAP 정리의 관점에서 AP 시스템입니다. 그러면 레지스트리의 정보가 네트워크 파티션 동안 서버간에 일치하지 않게됩니다. 자체 보존 기능은 이러한 불일치를 최소화하기위한 노력입니다.
#
#자기 보존 정의
#    자체 보존은 Eureka 서버가 특정 임계 값 이상으로 하트 비트 (피어 및 ​​클라이언트 마이크로 서비스에서)를 수신하지 않을 때 레지스트리에서 만료 인스턴스를 중지하는 기능 입니다.
================================================================================Eureka=========================================================================================================
 
cs


posted by 여성게
:
Web/Spring Cloud 2019. 2. 24. 01:04

Spring Cloud - Spring Cloud Bus



Spring Cloud Bus 는 분산 시스템에 존재하는 노드들을 경량 메시지 브로커(rabbitmq, kafka etc)와 연결하는 역할을 합니다.

구성 변경과 같은 상태변경, 기타관리 등을 브로드캐스트하는데 사용이 가능합니다.

현재 AMQP 브로커를 전송으로 사용하지만 Kafka, Redis도 사용 할 수 있습니다. 그 외의 전송은 아직 지원되지 않습니다.



1. 개요

Spring Cloud Config Server를 구축하게 되면 각 어플리케이션에 대한 설정정보(ex: applicatoin.yml)를 한 곳에서 관리 할 수 있습니다.

  • 하지만 해당 정보가 수정 될 경우 클라이언트 어플리케이션을 재기동해야 하는 것은 변함이 없습니다.

  • 이러한 방식은 이상적이지 않기 때문에 spring-boot-actuator와 @RefreshScope 어노테이션을 추가한 이후에 해당 클라이언트에 아래와 같은 명령을 보내어 재기동 없이 설정정보를 다시 읽어오게 할 수 있습니다.

    $ curl -x POST http://[ip]:[port]/refresh
  • 하지만 클라우드 환경에서는 모든 actuator endpoint에 접근하여 모든 클라이언트 어플리케이션을 refresh 해야 하는 번거로움이 존재합니다. 이러한 문제는 Spring Cloud Bus를 통해서 해결 할 수 있습니다.

  • 아래와 같은 서버(또는 브로커)를 만들도록 하겠습니다.

    • hello-act-client

    • config-server

    • RabbitMQ(Docker)

2. hello-act-client 구축


해당 어플리케이션은 GET request 를 통해 간단한 문자열을 출력하는 어플리케이션입니다.

먼저 간단한 dependency를 추가합니다. (해당 프로젝트는 spring-boot-starter-parent:2.0.2.RELEASE를 사용합니다.)

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

application.yml을 작성합니다. 여기서 message.act 는 GET request 호출 시 출력할 문자열입니다.

server:
port: 8090
message:
act: "act"
spring:
application:
name: hello-act

Controller를 작성합니다.

import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloActController {
@Value("${message.act}")
private String message;
@GetMapping("/")
public String getMessage() {
return message;
}
}

서버를 기동하고 terminal에서 GET request를 호출하면 "act" 라는 메시지가 출력 되는 것을 확인 할 수 있습니다.

$ curl -X GET http://localhost:8090/
act

3. config-server 구축

해당 message를 config 서버에서 읽어와서 출력하기 위해서는 config-server 구축이 필요합니다. 새로운 프로젝트를 생성하고 아래와 같은 dependency를 추가합니다.

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.2.RELEASE</version>
<relativePath/>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<spring-cloud.version>Finchley.RELEASE</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
...
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
...
</dependencies>

해당 어플리케이션이 config-server임을 알리기 위해서 @EnablieConfigServer 어노테이션을 추가합니다.

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.config.server.EnableConfigServer;
@EnableConfigServer
@SpringBootApplication
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}

해당 서버가 바라보는 config 저장소를 application.yml에 추가합니다. 해당 예제에서는 git 주소를 로컬 git으로 하였습니다.

server:
port: 8091
spring:
cloud:
config:
server:
git:
uri: file:/Users/bristol/bradley/configure

이제 /Users/bristol/bradley/configure 경로에 hello-act-client에서 사용 할 설정 정보를 가져옵니다.

hello-act.yml을 만들어 아래와 같은 설정 정보를 넣습니다.

server:
port: 8090
message:
act: "act"

이 후에 commit 을 해주도록 합니다.

$ git add.
$ git commit -m 'init yml'



4. hello-act-client 수정

config-server를 구축하였으므로 이제 포트정보와 message 정보는 config-server를 통해서 가져오도록 하겠습니다.

먼저 cloud-config를 사용 할 수 있도록 dependency를 추가합니다.

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<spring-cloud.version>Finchley.RELEASE</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
...
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-client</artifactId>
</dependency>
...
</dependencies>

기존의 application.yml 내용 중 아래의 내용을 bootstrap.yml으로 이동하고 application.yml을 삭제합니다.

spring:
application:
name: hello-act
cloud:
config:
uri: http://localhost:8091

해당 서버를 재기동 한 이후 아래와 같이 명령어를 보내면 act 라는 문자열이 출력되는 것을 볼 수 있습니다.

$ curl -X GET http://localhost:8090/
act

configure 폴더에서 메시지 정보를 수정합니다.

message:
act: "hello-act"

다시 commit을 합니다.

$ git add .
$ git commit -m 'change message'

서버 재 기동 없이 아래와 같이 명령어를 보내면 여전히 act 라는 문자열이 출력되는 것을 볼 수 있습니다.

$ curl -X GET http://localhost:8090/
act

5. RefreshScope

환경설정을 바꿨다고 해서 서버를 재기동하는 것은 불필요한 행위입니다. 따라서 서버 재기동 없이 환경설정을 읽어오는 방법을 알아보겠습니다.

먼저 hello-act-client 어플리케이션에 dependency를 추가합니다.

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

@RefreshScope 어노테이션을 추가합니다. @RefreshScope로 표시된 Spring Bean은 사용시 초기화 되는 lazy proxy로 범위는 초기화 된 캐쉬 값으로 작동합니다.

import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RefreshScope
public class HelloActController {
@Value("${message.act}")
private String message;
@GetMapping("/")
public String getMessage() {
return message;
}
}

그리고 bootstrap.yml에 아래와 같은 설정을 추가합니다. (기본적으로 actuator로 추가 된 민감한 엔드포인트는 보안에 묶여 있습니다. 아래와 같은 설정을 하거나 특정 url을 노출 시킬 수 있습니다.)

management:
security:
enabled: false

서버를 재기동하고 아래와 같이 명령어를 보내면 hello-act가 출력됩니다.

$ curl -X GET http://localhost:8090/
hello-act

config저장소에서 hello-act.yml을 열어 hello-sds로 변경하고 commit을 합니다.

message:
act: "hello-sds"
$ git add.
$ git commit -m 'change sds'

이제 서버 재기동 없이 터미널에서 아래와 같은 명령어를 보냅니다.

$ curl -X POST http://localhost:8090/actuator/refresh

해당 명령어를 보내면 변경된 프로퍼티가 출력 됩니다. 다시 아래와 같은 명령어를 날리게 되면 변경된 메시지가 출력 되는 것을 확인 할 수 있습니다.

$ curl -X GET http://localhost:8090/
hello-sds



6. Spring Cloud Bus

변경된 설정 값이 반영 되는 것을 확인하였으나 이와 같은 방법은 클라우드환경에서 endpoint 가 늘어날 수록 번거로울수 밖에 없습니다. 따라서 Spring Cloud Bus를 사용해보도록 합니다.

먼저 rabbitmq를 docker로 실행합니다.

$ docker run -d \
--hostname rabbit \
--name rabbit \
-p 15672:15672 \
-p 5672:5672 \
rabbitmq:3.7.5-management

클라이언트 어플리케이션에 아래와 같은 dependency를 추가합니다.

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>

config 저장소에 hello-act.yml을 아래의 구문을 추가합니다.

spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest

이제 config-server 설정을 변경합니다.

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-monitor</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>

다음으로 config-server의 application.yml을 수정합니다.

spring:
...
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
management:
endpoints:
web:
exposure:
include: "bus-refresh"

이제 모든 서버를 재 기동 한 이후에 hello-act.yml의 메시지 값을 다시 변경합니다.

...
message:
act: "hello-sds-act"
...

기존에는 hello-act 서버에 리퀘스트를 보냈으나 이제는 config-server에 리퀘스트를 보냅니다.

$ curl -X POST http://localhost:8091/actuator/bus-refresh

이후에 hello-act 서버에 GET 리퀘스트를 보내면 변경된 메시지를 확인 할 수 있습니다.

$ curl http://localhost:8090/
hello-sds-act

이러한 방법을 사용하면 코드 저장소(github, gitlab, bitbucket 등)에 webhook 기능을 사용하여 설정파일 변경이후에 commit, push가 일어날 때마다 자동으로 모든 클라우드 노드의 refrshscope 가 적용된 어플리케이션이 환경설정을 다시 읽게 할 수 있습니다.

posted by 여성게
:
Web/Spring Cloud 2019. 2. 24. 00:20

Spring Cloud - Eureka를 이용한 마이크로서비스 

동적등록&탐색&부하분산처리


스프링 클라우드 유레카는 넷플릭스 OSS에서 유래됐다. 

자가 등록, 동적 탐색 및 부하 분산에 주로 사용되며, 부하 분산을 위해 내부적으로 리본을 사용한다.

마이크로서비스의 장점 중 하나인 동적인 서비스 증설 및 축소를 유레카를 이용하면

아주 쉽게 가능하다.




위의 그림과 같이 사용자의 사용이 급격하게 많아졌다고 가정해보자.

그렇다면 위와 같이 서비스 인스턴스를 증설할 것이다. 

여기에서 유레카를 사용한다면 마이크로서비스 인스턴스를 하나 추가하면

자가 등록을 통해 유레카서버에 자신의 서비스를 등록한다.

그러면 동적으로 추가된 인스턴스를 탐색할 수 있게 되고 내부적으로 리본에 의해

같은 인스턴스 4개가 부하 분산(로드밸런싱) 처리가 될 것이다.


만약 유레카와 같은 것을 사용하지 않았다면? 개발자가 수동으로 전부다 등록해야하고 

그렇게 함으로써 추가된 인스턴스만 배포하는 것이 아니라, 관련된 다른 인스턴스까지 추가로 

재배포가 필요할 수도 있을 것이다.


위의 구성에 대해서 간단히 설명하자면 유레카는 서버와 클라이언트 컴포넌트로 이루어져있다.

서버 컴포넌트는 모든 마이크로서비스가 자신의 가용성을 등록하는 레지스트리이다.

등록되는 정보는 일반적으로 서비스 ID&URL이 포함된다.

마이크로서비스 인스턴스는 유레카 클라이언트를 이용해서 자기 자신의 가용성을 유레카 서버의 레지스트리에 

등록한다. 등록된 마이크로서비스를 호출해서 사용하는 컴포넌트도 유레카 클라이언트를 이용해서 

필요한 서비스를 탐색한다.


마이크로서비스가 시작되면 유레카 서버에 접근해 서비스 ID&URL 등의 정보를 등록하고 자신이

기동되었다는 것을 알린다.(통신은 모두 REST) 일단 등록이 되면 유레카 서버의 레지스트리에 

30초 간격으로 ping을 날리면서 자신의 status가 정상이다라는 것을 알린다.

만약 이 ping요청이 제대로 이루어지지 않는다면 유레카서버는 서비스가 죽은 것으로 

판단하여 레지스트리에서 제거한다.


유레카 클라이언트는 서비스의 정보를 받기 위하여 매번 유레카 서버에서 요청을 보내지않고

한번 받으면 로컬캐시에 저장을 해둔다. 그리고 기본 30초마다 계속 서버에 요청을 보내서

서비스의 목록을 들여다보며 변경이 있다면 로컬캐시에 저장된 것을 갱신시킨다.

(로컬캐시와 서버에 있는 서비스 정보를 비교해차이가 있는 것을 가져오는 Delta Updates 방식으로 갱신)



예제로 만들어볼 소스는 우선 Spring Cloud Config를 이용할 것이다.

만약 스프링 클라우드 컨피그에 대한 개념을 모른다면 아래 링크를 통해 한번 보고와도 좋을 것같다.


▶︎▶︎▶︎Spring Cloud Config






우선 유레카 서버로 이용할 스프링 부트 프로젝트를 생성한다.


Cloud Config>Config Client

Cloud Discovery>Eureka Server

Ops>Actuator


를 체크하여 프로젝트를 생성해준다.




1
2
3
4
spring.application.name=eureka
spring.profiles.active=server1
server.port=8889
spring.cloud.config.uri=http://localhost:8888
cs



spring.application.name=eureka,spring.profiles.active=server1는 

클라우드 컨피그에서 가져올 프로퍼티 파일명을 뜻한다.

> eureka-server1.properties

나머지설정은 위의 클라우드 컨피그 링크에서 참조하면 될 것같다.



유레카 서버는 Standard alone과 cluster mode 모두가 가능하다. 하지만

이번 예제에서는 Standard alone mode로 진행할 것이다.




1
2
3
4
spring.application.name=eureka-server1
eureka.client.serviceUrl.defaultZone=http://localhost:8899/eureka/
eureka.client.registerWithEureka=false
eureka.client.fetchRegistry=false
cs



위의 설정정보는 git에 저장된 eureka-server1.properties에 작성될 설정정보이다.

유레카서버는 서버임과 동시에 클라이언트가 될수 있다. 즉, 유레카서버도 결국은 유레카 클라이언트로

동작하는 것이다.(유레카 서버가 여러대일때, peer 관계에 있는 유레카서버의 서비스 목록을 가져오기 위하여

자신의 클라이언트를 이용해서 가져온다. 하지만 지금은 일단 클라이언트들과 동일한 동작이 계속 시도되지 않도록 false로 한것이다.) 

eureka.client.serviceUrl.defaultZone 설정으로 Zone을 지정해준다.

그리고 eureka.client.registerWithEureka=false로 자기자신을 서비스로 등록하지 않는다.

마지막으로 eureka.client.fetchRegistry=false로 마이크로서비스인스턴스 목록을 로컬에 캐시할 것인지의

여부로 등록한다. 하지만 여기서 유레카서버는 동적 서비스 탐색등의 목적으로

사용되지는 않음으로 밑의 두개의 설정은 false로 등록한다.(즉,Standard alone이면 두개다 false)

만약 registerWithEureka를 true로 동작하면 자기 자신에게 계속 health check 요청 및 다른 유레카 클라이언트가 보내는 요청을

자기스스로에게 보내게 될것이다.



1
2
3
4
5
6
7
8
9
@EnableEurekaServer
@SpringBootApplication
public class EurekaserverApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(EurekaserverApplication.class, args);
    }
 
}
cs


@EnableEurekaServer 어노테이션으로 자기자신이 유레카서버임을 명시한다.

http://localhost:8889 로 접속하면 유레카 관리페이지가 나온다.

현재는 아무런 서비스도 등록되어 있지않은 상태이다.



나머지 유레카 클라이언트들의 코드는 편의상 클라우드 컨피그를 이용하지 않았다.



1
2
3
server.port=8070
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/
spring.application.name=eureka-call-client
cs


위의 설정파일은 마이크로서비스 인스턴스들을 호출할 하나의 클라이언트 설정이다. 유레카클라이언트는 defaultZone 속성값이 같은

다른 유레카 클라이언트와 동료관계를 형성하므로, 해당 애플리케이션의 defaultZone설정으로 유레카서버와 동일하게 작성한다.

그 다음 spring.application.name 설정은 유레카서버에 등록될 서비스이름이다.

유레카서버에게 동적서비스 등록을 하고,

동적탐색의 대상이 되는 어떠한 서비스들을 호출하기 위한 애플리케이션도 유레카 클라이언트이어야한다.



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@EnableDiscoveryClient
@SpringBootApplication
public class Eurekaclient3Application {
 
    
    public static void main(String[] args) {
        SpringApplication.run(Eurekaclient3Application.class, args);
    }
    
    @Bean
    @LoadBalanced
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
    
    @RestController
    class EurekaClientController{
        
        @Autowired
        RestTemplate restTemplate;
        
        @GetMapping("/eureka/client")
        public String eurekaClient() {
            
            String result = restTemplate.getForObject("http://eurekaclient/eureka/client"String.class);
            
            return result;
        }
    }
}
cs


위의 소스를 설명하자면 우선 애플리케이션이 유레카 클라이언트임을 @EnableDiscoveryClient 어노테이션으로 명시한다.

그리고 우리가 많이 사용하던 RestTemplate을 빈으로 등록할때 @LoadBalanced 어노테이션을 등록하여

Ribbon에 의해 로드벨런싱할 RestTemplate임을 명시한다.(@LoadBalanced 때문에 서비스로 등록된 마이크로서비스 인스턴스 등을 호출할때

라운드로빈 방식으로 분산으로 요청이 가게된다.)


그런데 RestTemplate을 사용하는 메소드 안의 URL정보가 조금 특이하다. eurekaclient? 우리는 로컬환경이고

따로 호스트를 등록하지도 않았는데, localhost가 아니고 다른 DNS명으로 호출하고 있다.

해답은 다음 과정에 나오게 된다.


다음은 서비스로 등록될 마이크로서비스 인스턴스 애플리케이션 2개이다.(2개의 애플리케이션 코드는 동일하고 설정정보만 조금 다르니, 소스코드는

하나의 애플리케이션만 명시한다.)



1
2
3
server.port=8090
spring.application.name=eurekaclient
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/
cs


1
2
3
server.port=8080
spring.application.name=eurekaclient
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/
cs


두개의 마이크로서비스 인스턴스의 설정정보이다. 다른 것은 서버포트 하나뿐이다. 그리고 해당 애플리케이션들은

같은 애플리케이션은 증설한 상황이다. 즉, 서비스 이용자 입장에서는 같은 인스턴스인 것이다. 그렇기 때문에

spring.application.name을 eurekaclient로 동일하게 등록한다. 어? 이건 이 인스턴스들을

호출한 클라이언트에서 RestTemplate의 메소드의 DNS였는데? 맞다. 그것이다.


즉, 유레카서버에 등록한 서비스 이름으로 RestTemplate 요청을 보내는 것이다. 그런 다음 해당 서비스 이름으로

서비스가 등록되어있는지 확인하고 있다면 Ribbon이 로드밸런싱(라운드로빈 방식) 해줘서 요청이 가게된다.



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@EnableDiscoveryClient
@SpringBootApplication
public class EurekaclientApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(EurekaclientApplication.class, args);
    }
    
    @RestController
    class EurekaClientController{
        
        @GetMapping("/eureka/client")
        public String eurekaClient() {
            return "eureka client - 1";
        }
    }
}
cs


마이크로서비스 인스턴스의 소스이다. 애플리케이션을 하나더 생성하여 위의 소스에서 반환값만 수정하자.


그런다음 유레카 관리자 페이지를 들어가보자.



총 3개의 서비스가 등록되어 있는 것을 볼 수 있다.(Eureka-call-client(1),EurekaClient(2))


마지막으로 postman 툴이나 curl로 Eureka-call-client의 "/eureka/client"를 호출해보자


계속해서 반환되는 값이 "eureka client - 1" , "eureka client - 2" 로 번갈아가면서

반환될것이다. 지금은 로컬에서 나자신 혼자만 요청을 보내니 라운드로빈방식으로 각각 번갈아가면서

한번씩 호출된다.


이렇게 독립모드형 유레카서버,클라이언트 구성을 해보았다.


마지막으로 간단한 유레카서버,클라이언트 용어 및 부트설정 설명이다.



1
2
3
4
5
6
7
8
9
10
#<Eureka 등장 용어 정리>
#    <Eureka 행동 관련>
#        Service Registration: 서비스가 자기 자신의 정보를 Eureka에 등록하는 행동
#        Service Registry: 서비스가 스스로 등록한 정보들의 목록, 가용한 서비스들의 위치 정보로 갱신됨
#        Service Discovery: 서비스 클라이언트가 요청을 보내고자 하는 대상의 정보를 Service Registry를 통해 발견하는 과정
#    <Eureka 구성 요소 관련>
#        Eureka Client: 서비스들의 위치 정보를 알아내기 위해 Eureka에 질의하는 서비스를 가리킴 (like Service consumer)
#        Eureka Service: Eureka Client에 의해 발견의 대상이 되도록 Eureka에 등록을 요청한 서비스를 가리킴 (like Service provider)
#        Eureka Server: Eureka Service가 자기 자신을 등록(Service Registration)하는 서버이자 Eureka Client가 가용한 서비스 목록(Service Registry)을 요청하는 서버
#        Eureka Instance: Eureka에 등록되어 목록에서 조회 가능한 Eureka Service를 의미
cs



일단 이번 포스팅은 간단하게 유레카의 사용법을 익혀봤다. 다음 포스팅에서는 더 다양한 유레카 설정과 유레카 서버를 클러스터구성으로

예제를 진행할것이다. 이번에는 대략적인 유레카의 사용법을 익히는 것으로 간다.

posted by 여성게
: