안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 의존성 주입과 profile 설정으로 filter의 설정 정보를 다르게 적용하는 과정을 정리하도록 하겠습니다.

 

 

1. 문제 상황


MSA 아키텍처에서 Gateway와 Member 서버는 서로 같은 Redis 서버 (Aws ElasticCache)를 사용하고 있습니다. 다른 서버는 Gateway로부터 라우팅을 수행하지만, 공통의 Redis를 사용하지 않기 때문에 컨트롤러 혹은 핸들러 API 테스트를 진행하는 과정에서 Gateway에서 헤더 정보 인증이 안될 수 있습니다.

 

<Gateway-Server>

private boolean validateRequestHeader(String accessToken, String refreshToken, String userId) throws JsonProcessingException {
    return StringUtils.hasText(accessToken)
            && tokenProviderImpl.validateToken(accessToken, userId)
            && tokenProviderImpl.validateToken(refreshToken, userId)
            && existsToken(accessToken, refreshToken)
            && isLoginSession(userId);
}

 

gateway-server는 redis 6379 port에 연결되어 있습니다. gateway는 AuthorizationHeaderFilter에서 다음과 같이 request를 파싱 하여 jwt의 유효성 검사를 진행하고, 실제 6379 redis 서버에 jwt가 존재하고, 로그인 세션이 등록된 유저인지 판단합니다.

 

하지만, 문제가 되는 다른 서버들은 gateway와 다른 6380 port의 다른 redis 서버를 사용하고 있으므로, 토큰을 공유할 수 없습니다. 즉, 비즈니스 테스트 과정에서 임의의 jwt 토큰을 발급하여 헤더 정보로 보내더라도 gateway에서는 유효성을 검사한 후 실제 redis 서버에 존재하는지 파악하기 때문에, 인증 에러가 발생하여 비즈니스 로직 테스트를 할 수 없습니다.

 

매번 gateway에 토큰을 추가하기 위해 member 서버를 기동하여 토큰을 추가하는 방법이나, 혹은 Gateway에서 역으로 member에 대한 토큰을 발급하는 방법이 있는데 이 방법 모두 비효율적이며, 테스트를 위해 다른 부가로직을 추가하는 것은 좋지 못한 방법이라고 생각하였습니다.

 

따라서, 로컬 환경에서 테스트를 위해 Gateway를 기동할 때는 jwt의 유효성만 검사하고, 통과시키는 filter를 적용할 필요성이 있었습니다.

 

 

 

2. 문제 파악하기

 

@Slf4j
@Component
@RequiredArgsConstructor
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {

--- 중략 ---

    public static class Config {}

    @Override
    public GatewayFilter apply(Config config) {

        return (exchange, chain) -> {

            ServerHttpRequest request = exchange.getRequest();
            if (isWhiteList(request.getURI().getPath())) return chain.filter(exchange);
            validateAuthorizationHeaders(request);
            try {
                if (validateRequestHeader(request)) return chain.filter(exchange);
            } catch (JsonProcessingException e) {
                return onError(exchange, ExceptionMessage.BADREQUEST, HttpStatus.BAD_REQUEST);
            }
            return onError(exchange, ExceptionMessage.BADREQUEST, HttpStatus.BAD_REQUEST);
        };
    }
}

 

private boolean validateRequestHeader(String accessToken, String refreshToken, String userId) throws JsonProcessingException {
    return StringUtils.hasText(accessToken)
            && tokenProviderImpl.validateToken(accessToken, userId)
            && tokenProviderImpl.validateToken(refreshToken, userId)
            && existsToken(accessToken, refreshToken)
            && isLoginSession(userId);
}

 

현재 AuthorizationHeaderFilter는 여러 가지 검증 처리와, 토큰 유효성 검사 및 실제 토큰와 로그인 세션이 Redis에 저장되어 있는지 판단합니다. 따라서, 이 네 가지 조건에 하나라도 위배된다면 인증될 수 없습니다.

 

AuthorizationHeaderFilter는 AbstractGatewayFilterFactory의 추상클래스를 확장한 클래스로 apply를 Override 하면 GatewayFilter를 생성하고 있습니다.

AbstractGatewayFilterFactory는 Spring Cloud Gateway에서 사용되는 필터를 생성하는 추상 클래스입니다. GatewayFilter는 Spring Cloud에서 사용하는 필터로 헤더 정보를 처리하거나 요청 및 응답을 변경하는 역할을 수행합니다.

AbstractGatewayFilterFactory는 Config 설정 정보에 따라 GatewayFilter에 대한 설정 정보를 다르게 할 수 있습니다.

 

 

여기서 Config는 특정 인터페이스가 정해져 있지 않는 제네릭 타입이므로, 사용자가 Custom하게 수정할 수 있다는 장점이 있었습니다. 따라서 저는 Config 클래스를 Custom하게 변경하여 profile에 따라 다르게 적용되도록 구현하였습니다.

 

 

 

3. 자바의 다형성과 의존관계 주입하기

 

자바의 최고 장점은 다형성인 것 같습니다. 특정 인터페이스를 구현하는 다양한 구현체가 있을 때 인터페이스로 캐스팅 할 수 있습니다. 스프링의 의존관계 주입과 함께 사용하면, 특정 상황에 따라 다른 구현체가 필요할 때 인터페이스로 의존 관계를 주입 받은 후 스프링 서버의 기동시에 구현체 빈 등록을 스프링에게 위임할 수 있습니다.

 

따라서, AuthorizationConfig라는 인터페이스를 선언한 후 공통 클래스를 추상 클래스로 선언한 후, AuthorizationDefaultConfig와 AuthorizationDevConfig로 AbstractAuthorizationConfig 클래스를 상속한다면, AuthorizationConfig로 의존관계를 주입 받을 수 있습니다. 스프링은 AuthoziationConfig에 대한 구현체가 하나라면 바로 스프링 빈으로 등록하여 구현체가 적용되도록 해줍니다. 

 

 

장점은 이렇게 서로 다른 구현체를 모두 스프링 빈을 등록하면, 스프링은 어떤 구현체를 선택해야할 지 선택할 수 없기 때문에 서버 기동시에 개발자에게 에러를 발생시킵니다. (정말 엄청난 기술입니다 ㅠㅠ) 이러한 에러를 바탕으로 복수의 구현체가 등록된 것을 확인하고 에러를 바로 잡을 수 있습니다.

 

그렇다면 이제 복수의 구현체가 등록되는 문제를 해결하기 위해  Profile을 설정하는 과정을 코드로 함께 정리하도록 하겠습니다.

 

 

 

4. 코드 수정하기

 

먼저 AuthorizationConfig 인터페이스를 선언합니다. 

이 인터페이스의 역할은 AuthorizationHeader를 검증하고, 토큰 유효성 및 존재 여부를 판별합니다.

 

public interface AuthorizationConfig {

    void validateAuthorizationHeaders(ServerHttpRequest request);
    boolean validateRequestHeader(ServerHttpRequest request) throws JsonProcessingException;
    boolean validateRequestHeader(String accessToken, String refreshToken, String userId) throws JsonProcessingException;

}

 

기존에 있던 AuthorizationHeaderFilter에서 AuthoziationConfig의 메서드에서 사용해야할 메서드를 분리하고 구현체들에서 공통적으로 활용하는 코드를 AbstractAuthorizationConfig 추상 클래스에 옮기면 다음과 같습니다.

 

@Slf4j
@RequiredArgsConstructor
public class AbstractAuthorizationConfig implements AuthorizationConfig {

    private final TokenProviderImpl tokenProviderImpl;
    private final TokenRepository tokenRepository;
    private final LoginSessionRepository loginSessionRepository;

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String REFRESHTOKEN_HEADER = "RefreshToken";
    public static final String USER_ID_HEADER = "UserId";

    @Override
    public void validateAuthorizationHeaders(ServerHttpRequest request) {
        hasAuthorizationHeader(request);
        hasRefreshToken(request);
        hasUserIdHeader(request);
    }

    @Override
    public boolean validateRequestHeader(ServerHttpRequest request) throws JsonProcessingException {
        String accessToken = parseAccessToken(request);
        String refreshToken = parseRefreshToken(request);
        String userId = parseUserId(request);

        if (validateRequestHeader(accessToken, refreshToken, userId)) return true;
        return false;
    }

    @Override
    public boolean validateRequestHeader(String accessToken, String refreshToken, String userId) throws JsonProcessingException {
        log.info("this is dev");
        return StringUtils.hasText(accessToken)
                && validateToken(accessToken, userId)
                && validateToken(refreshToken, userId)
                && existsToken(accessToken, refreshToken)
                && isLoginSession(userId);
    }

   ---- 중략 ----

    /**
     * request 요청에서 userId 파싱
     */
    protected String parseUserId(ServerHttpRequest request) {
        return request.getHeaders().get(USER_ID_HEADER).get(0);
    }

    protected boolean existsToken(String jwt, Class<?> clazz) throws JsonProcessingException {
        Token token = tokenRepository.findTokenByKey(jwt, clazz);
        return token != null;
    }

    protected boolean existsToken(String accessToken, String refreshToken) throws JsonProcessingException {
        return existsToken(accessToken, AccessToken.class) && existsToken(refreshToken, RefreshToken.class)
                && !existsToken(accessToken, LogoutSessionAccessToken.class)
                && !existsToken(refreshToken, LogoutSessionRefreshToken.class);
    }

    protected boolean isLoginSession(String userId) throws JsonProcessingException {
        return loginSessionRepository.existLoginSession(userId);
    }

    protected boolean validateToken(String token, String userId) {
        return tokenProviderImpl.validateToken(token, userId);
    }

}

 

추상 클래스는 필요한 메서드 헤더 파싱, 토큰 유효성 검사, 토큰 존재성 파악, 로그인 세션 확인 등을 수행합니다. 

이제, Default 및 Local환경에서 사용할 AuthorizationDefaultConfig를 설정하면 다음과 같습니다.

 

@Slf4j
@Profile({"default", "local"})
@Component
public class AuthorizationDefaultConfig extends AbstractAuthorizationConfig {

    public AuthorizationDefaultConfig(TokenProviderImpl tokenProviderImpl, TokenRepository tokenRepository, LoginSessionRepository loginSessionRepository) {
        super(tokenProviderImpl, tokenRepository, loginSessionRepository);
    }

    @Override
    public boolean validateRequestHeader(String accessToken, String refreshToken, String userId) throws JsonProcessingException {
        log.info("this is local");
        return StringUtils.hasText(accessToken)
                && validateToken(accessToken, userId)
                && validateToken(refreshToken, userId);
    }
}

 

@Profile 설정을 통해 run 혹은 jar 실행 시 적용할 설정 정보를 등록합니다. 이 경우, 설정이 없거나 local profile이 활성화될 때 스프링이 해당 클래스를 스프링 빈으로 등록합니다. 

 

제가 필요했던 기능은 AuthorizationDefaultConfig에서는 헤더 정보를 파싱하고 헤더에 있는 토큰의 유효성(subject, issue) 정도만 검증하고 라우팅하도록 하는 기능이 필요하므로 validateRequestHeader를 오버라이드 했습니다.

 

이제 실제 Dev와 Prod에서 적용할 필터는 @Profile로 "dev", "prod"를 설정하였습니다.

추상 클래스에서 이미 오버라이드 할 때 모든 정보를 전부 검사하도록 설정했으므로 추가로 구현하지 않았습니다.

 

@Profile({"dev", "prod"})
@Component
public class AuthorizationDevConfig extends AbstractAuthorizationConfig {

    public AuthorizationDevConfig(TokenProviderImpl tokenProviderImpl, TokenRepository tokenRepository, LoginSessionRepository loginSessionRepository) {
        super(tokenProviderImpl, tokenRepository, loginSessionRepository);
    }
}

 

@Slf4j
@Component
@RequiredArgsConstructor
public class AuthorizationHeader2Filter extends AbstractGatewayFilterFactory<AuthorizationConfig> {
    private final AntPathMatcher antPathMatcher;
	
    ---- 중략 ----

    @Override
    public GatewayFilter apply(AuthorizationConfig config) {
        return (exchange, chain) -> {

            ServerHttpRequest request = exchange.getRequest();
            if (isWhiteList(request.getURI().getPath())) return chain.filter(exchange);
            config.validateAuthorizationHeaders(request);
            try {
                if (config.validateRequestHeader(request)) return chain.filter(exchange);
            } catch (JsonProcessingException e) {
                return onError(exchange, ExceptionMessage.BADREQUEST, HttpStatus.BAD_REQUEST);
            }
            return onError(exchange, ExceptionMessage.BADREQUEST, HttpStatus.BAD_REQUEST);
        };
    }

 

기존애는 AuthorizationHeaderFilter에서 의미없는 static Config를 생성하였지만, 이제는 AuthorizationConfig를 생성하여 파라미터로 받을 수 있었습니다.

 

이제 라우팅을 설정하는 FilterConfig를 수정하면 다음과 같습니다.

 

@Configuration
@RequiredArgsConstructor
public class Filter2Config {

    private final AuthorizationConfig authorizationConfig;

    @Bean
    public RouteLocator gatewayRoutes(RouteLocatorBuilder builder, AuthorizationHeader2Filter authorizationHeaderFilter) {
        return builder.routes()

                .route("wait-service", r -> r
                        .path("/wait-service/**")
                        .filters(spec -> spec.filter(authorizationHeaderFilter.apply(authorizationConfig)))
                        .uri("lb://wait-service"))

                ---- 중략 -----

                .build();
    }

 

Filter2Config는 AuthorizationConfig를 의존 관계 주입받습니다. 이후, AuthorizationHeader2Filter에 필요한 AuthorizationConfig의 인스턴스를 apply의 파라미터로 넣을 수 있습니다.

 

 

 

5. 테스트 

 

인텔리제이의 http 테스트를 통해  프로파일 환경이 다른 경우 어떻게 적용되는지 테스트를 진행하였습니다.

### 1. 유저 정보 요청

GET {{baseUrl}}/users
Content-Type: application/json
Authorization: Bearer {{accessToken}}
RefreshToken: {{refreshToken}}
UserId: {{userId}}
Accept: */*
Connection: keep-alive


> {%
    client.test("유저 정보 요청", function() {
        client.assert(response.status === 200, "Response status is OK");
    });
%}

 

1) Local

 

2) dev

 

성공적으로 테스트를 마칠 수 있었습니다.

 

 

 

6. 정리하며 ...

 

AWS에 배포를 진행할 때, 한번의 다 수의 MSA 서버를 올리기에는 비용이 부담스러웠습니다. 따라서, 일부의 서버는 AWS에 올리고 제 로컬 환경에서 api를 요청하는 방식으로 서버 테스트를 진행하였습니다. 

이 과정에서 인증 문제가 발생하였고, 이를 profile과 자바의 다형성, 스프링의 의존 관계 주입으로 해결할 수 있었습니다.

 

이전에 AWS에 배포를 진행하며 Dev와 Local이 다른 환경으로 인해 비슷한 문제로 하루 이틀 밤새며 코드를 수정했던 적이 있었습니다. 그때의 고생으로 profile에 대해 정리하였었는데 비슷한 문제를 맞이하게 되니 즉각 해결할 수 있었습니다.

 

다시 한 번 자바와 스프링의 위대함을 느낄 수 있는 시간이었습니다.!

 

 

읽어주셔서 감사드립니다.!!

+ Recent posts