???: 확장&유연한 설계가 중요해요? 그럼 이걸 안 볼수 없는데?

2024. 10. 13. 16:06Spring

728x90

1. 배경과 한계

저는 회사의 기존 인증/인가 시스템을 개선하기 위한 작업을 진행했습니다. 회사는 Spring Security를 사용해 인증/인가 시스템을 구축하려 했으나, 도입 과정에서 여러 문제에 직면했습니다. Spring Security는 강력한 기능을 제공하지만, 설정이 복잡하고 유지보수가 어려운 단점이 있었습니다.

Fxxxx Filter Chains....

또한, 기존 전역 필터와 새롭게 도입하려는 Spring Security Filter Chain 간의 충돌 문제가 발생했습니다. 기존 시스템은 전역 필터를 통해 인증과 인가를 처리하고 있었고, 이 필터들이 Spring Security의 Security Context를 사용하고 있었습니다. 이로 인해 새로운 Spring Security Filter Chain과 충돌이 발생했고, 이를 해결하기 위해 다른 방법을 모색해야 했습니다.

전역 Filter vs Spring Security Filter

팀원들에게 이러한 구조가 만들어진 이유를 물어보니, 전임자가 충분한 학습 없이 시스템을 구축했고,
팀원들 또한 Spring Security에 대한 지식이 부족해 유지보수가 제대로 이루어지지 않았다고 했습니다.
이러한 문제를 해결하기 위해 저는 다음과 같은 목표를 세웠습니다:

1. Spring Security보다 직관적이고
2. 관심사의 분리가 잘 이루어지며,
3. 디버깅이 쉬운,
4. 방식특정 컨트롤러 메서드의 파라미터에 인증 정보를 주입할 수 있는 방법

이러한 목표를 달성하기 위해 다양한 대안을 검토했습니다.

2. MethodArgumentResolver 도입

검토한 대안은 다음과 같았습니다:

1. Spring Security: 강력하지만 설정이 복잡하고 유지보수가 어려웠습니다.

2. Interceptor: 요청 전후의 공통 작업 처리에는 적합했지만, 특정 메서드의 파라미터에 인증 정보를 주입하는 데에는 부적합했습니다.

3. AOP (Aspect-Oriented Programming): 인증과 인가 로직을 분리할 수 있다는 장점이 있었지만, 메서드에 직접 파라미터를 주입하는 데 한계가 있었습니다.

4. MethodArgumentResolver: 컨트롤러 메서드에 파라미터를 직접 주입할 수 있어 인증 로직을 간결하게 유지하면서 유연하게 확장할 수 있었습니다.

이러한 이유로 저는 MethodArgumentResolver를 채택했고, 이를 통해 AuthArgumentResolver라는 클래스를 작성하여 @Auth 라는 커스텀 애너테이션이 달린 파라미터를 처리하도록 설정했습니다. 이를 통해 복잡한 Spring Security 설정 없이도 필요한 인증 정보를 컨트롤러에서 사용할 수 있게 되었습니다.

처음에는 다음과 같이 구현되었습니다:

@Override
public Mono<Object> resolveArgument(MethodParameter parameter, BindingContext bindingContext, ServerWebExchange exchange) {
    String ssoToken = exchange.getRequest().getHeaders().getFirst("Authorization");
    if (ssoToken == null || !ssoToken.startsWith("Bearer ")) {
        return Mono.error(new IllegalArgumentException("Invalid or missing SSO token"));
    }
    try {
        JwtInfo jwtInfo = ssoAuthenticatorUtil.decrypt(ssoToken.substring(7));
        Long userNo = Long.valueOf(jwtInfo.getPayload().getUserNo());
        if (userNo == null) {
            return Mono.error(new IllegalArgumentException("User is not authenticated"));
        }

        // 항상 인증과 인가를 확인
        if (!jwtInfo.getPayload().getRoles().contains("REQUIRED_ROLE")) {
            return Mono.error(new IllegalArgumentException("User does not have the required role"));
        }

        return Mono.just(jwtInfo);
    } catch (Exception e) {
        return Mono.error(new IllegalArgumentException("Failed to decode SSO token", e));
    }
}

처음에는 JWT 토큰의 모든 정보를 객체에 담아 컨트롤러에 전달하거나, 항상 인증과 인가를 모두 수행하도록 구현했습니다. 그러나 AuthArgumentResolver를 다른 곳에서도 재사용할 수 있는 모듈로 고려하게 되면서, 현재 구조가 확장에 닫혀 있다는 것을 깨달았습니다. 즉, 인증과 인가를 항상 강제하는 방식은 유연하지 않았습니다.

회사 상황을 고려할 때, API마다 인증과 인가를 모두 해야 하는 경우도 있고, 인증만 필요한 경우도 있었습니다. 또한, 인증, 인가와 SSO 토큰의 스펙이 자주 변경되는 점도 있었습니다.

이 방식에는 두 가지 문제가 있었습니다:

1. 인증과 인가를 각각 필요한 경우에만 수행하도록 분리하지 않아 OCP(Open-Closed Principle)에 위배되었습니다.
다른 사용자가 AuthArgumentResolver 모듈을 사용할 때, 매번 인증과 인가를 강제된다는 점이 문제였습니다.

2. JwtInfo 객체를 컨트롤러 메서드에 그대로 주입하는 방식이었습니다. JwtInfo는 외부에서 발급된 토큰에서 추출한 정보로, 스펙이 자주 변경되었습니다. 이를 그대로 컨트롤러에 주입하면 컨트롤러가 변경에 취약해졌습니다. 클래스 간, 모듈 간에는 최소한의 식별자만 주고받아야 한다는 생각에 JwtInfo 대신 userNo(PK)만 주고받도록 변경했습니다.

3. 최소한의 정보를 통해 변경에 유리한 설계 가져가기

controller 에 너무 많이 보내버린 것

위 2가지 문제점 중 먼저 2번 문제점, AuthArgumentResolver 에서 너무 많은 정보를 보내기에
controller 가 변경에 취약한 코드가 됐다는 점을 고치기 위해 최소한의 정보, 즉 식별자만을 내보내기로 수정했습니다.

@Override
public Mono<Object> resolveArgument(MethodParameter parameter, BindingContext bindingContext, ServerWebExchange exchange) {
    String ssoToken = exchange.getRequest().getHeaders().getFirst("Authorization");
    if (ssoToken == null || !ssoToken.startsWith("Bearer ")) {
        return Mono.error(new IllegalArgumentException("Invalid or missing SSO token"));
    }
    try {
        //...

        return Mono.just(userNo); // return JwtInfo -> userNo
    } catch (Exception e) {
        return Mono.error(new IllegalArgumentException("Failed to decode SSO token", e));
    }
}

이렇게 인증과 인가 로직을 별도의 함수로 분리하여 각각의 역할을 명확히 하고,
특정 상황에서는 인증만, 다른 상황에서는 인증과 인가를 모두 처리할 수 있도록 하여 유연성과 재사용성이 크게 향상되었습니다.

AuthArgumentResolver는 JWT 토큰을 해독하여 사용자 정보를 추출하고, @Auth 애너테이션에 정의된 역할이나 인증 요구가 있을 경우 추가적인 인증과 인가 과정을 처리합니다. 이를 통해 컨트롤러의 복잡성을 줄이고, 인증 관련 로직을 한곳에 모아 유지보수를 쉽게 할 수 있었습니다.

(여담인데, 이러한 변경은 Spring Cloud 기반 MSA에서 Spring Gateway가 최소한의 식별자만 전달하는 방식과 유사하다고 생각했습니다. 여기서도 동일한 원칙을 적용해 필요 이상의 정보를 공유하지 않고, 모듈 사용자에게 필요한 책임을 부여하고자 했습니다!)

4. 커스텀 애너테이션 @Auth와 확장성

남아 있던 문제 중 하나는 AuthArgumentResolver의 확장성 문제였습니다.
사용자가
AuthArgumentResolver 모듈을 사용할 때, 애너테이션을 통해 유연하게 사용할 수 있는 구조가 필요했습니다.

@Auth 애너테이션은 컨트롤러 메서드의 파라미터에 사용되며, 필요에 따라 인증과 인가를 유연하게 확장할 수 있도록 설계되었습니다.
기본적으로는 인증만 수행하며, 추가적인 인가가 필요할 경우
@Auth(role = "ADMIN")처럼 추가적인 속성을 설정할 수 있습니다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface Auth {
    String role() default "";
    boolean requireAuthentication() default true;
}

이렇게 인증만 필요하거나, 특정 역할에 따른 인가가 필요한 경우를 선택적으로 구현할 수 있어
코드의 유연성과 확장성이 크게 향상되었습니다.

5. 마무리

이 작업을 통해 기존의 복잡하고 유지보수하기 어려웠던 인증/인가 시스템을 개선할 수 있었습니다. MethodArgumentResolver@Auth 애너테이션을 도입함으로써 인증과 인가 로직을 간결하고 유연하게 유지할 수 있었고, 각 컨트롤러의 복잡성을 줄여 유지보수를 더욱 쉽게 만들었습니다.

이론으로만 배우던, 확장과 변경에 유리하고 사용자가 가져다 쓰기 편한 구조를 회사에서 직접 구현해 볼 수 있었습니다. 이번 개선 작업은 이러한 방향성에 있어 중요한 첫걸음이 되었고, 앞으로도 시스템의 확장성과 유지보수성을 고려한 지속적인 개선을 이어갈 계획입니다.

728x90