Spring Security 그렇게 쓰지 마세요!

WIZ·2023년 11월 14일
14

Spring Security 는 Spring 으로 개발하는 어플리케이션 위에서 인증/인가를 구현하는데 도움을 주는 프레임워크다.

하지만 Spring Security 에 너무 의존적인 프로그래밍을 하거나 Spring Security 라는 프레임워크를 제대로 이해하지 못하고 사용하는 사례들을 너무 많이 접해 관련해서 내 생각을 정리해보려한다.

지극히 개인적인 생각을 담은 포스팅임으로 너무 과몰입하지 않고, 이렇게 생각하는 사람도 있구나 정도로 가볍게 읽으면 좋을 것 같다.


문제를 들춰보자


내가 Spring Security 를 사용하는 다양한 사례들을 접하면서 느낀 문제점은 아래 3가지다.

  1. Spring Security 에 의해서 내가 만드는 서비스의 기능(비즈니스 로직)이 숨겨진다.
  2. Spring Security 를 제대로 이해하지 못하고 사용한다.
  3. 너무 과하게 Spring Security 를 의존하려고 한다.

이러한 문제는 현재 많이 이야기되고 있는 한국 취업시장의 개발 기술 블로그 문제에서 파생되는 대표적인 사례라고 생각한다. 기술 블로그가 스펙으로 여겨져 너도 나도 기술 블로그를 작성하고 있는 지금 본인의 생각을 담지 않고 다른 블로그를 참고해 작성하는 블로그들이 많아지고 있는 것의 영향으로 보인다.

그럼 차례로 위에서 소개한 문제점에 대해서 이야기해보자.


Spring Security 에 의해서 비즈니스 로직이 숨겨진다.


Spring Security 를 이용해 인증 로직을 구현할 때 대표적으로 떠오르는 인증에는 로그인과 JWT 인증이 있다. 이 중에 로그인을 예로 들어보자. 로그인을 Spring Security 를 통해 구현하고자 하면 아래와 같이 Spring Security Filter 를 이용할 수 있다.

public class LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public LoginAuthenticationFilter() {
        super("/auth/login");
    }
    
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
	    // 이하 내용 생략..
    }
}

Spring Security 에서 제공하는 AbstractAuthenticationProcessingFilter 를 이용해 로그인이라는 인증을 처리하는 Filter 를 만든 것이다. 위 Filter 를 타는 사용자 요청을 지정하기 위해 AbstractAuthenticationProcessingFilter 의 생성자를 통해 /auth/login 문자열을 전달한다.

여기서 문제는 무엇일까?

로그인은 시스템의 중요한 기능중 하나다. 하지만 Spring Security 쪽 코드를 보지 않는다면 /auth/login 이라는 엔드포인트는 어디에도 노출되지 않는다.

만약 Spring Security 에 대한 이해도가 떨어져 super 생성자를 통해 전달된 URL 이 해당 Filter 를 타게 만든다는 것을 이해하지 못한다면 더욱 로그인이라는 기능의 엔드포인트를 찾는데 어려움을 겪을 것이다. (Swagger 에도 기본적으론 노출되지 않아 협업하는 입장에서도 혼란스러울 수 있다)

다시말해 내가 개발하고 있는 어플리케이션의 UseCase 중 하나인 로그인이 Spring Security 라는 프레임워크 내부로 숨겨져 잘 드러나지 않는다는 것이다.

클린 아키텍처라는 책에서는 이와 관련해서 아래와 같이 말하고 있다.

프레임워크는 선택사항이지 절대 프레임워크가 UseCase 를 결정하면 안되고, UseCase 가 어플리케이션의 중심이 되어야 한다. 이를 지키지 않으면 해당 어플리케이션은 UseCase 를 소리치지 않고 프레임워크에 대해서 소리치게 될 것이다. (21장. 소리치는 아키텍처)

나는 개발하고 있는 어플리케이션의 UseCase 가 감춰지지 않는 선에서 Spring Security 를 매우 제한적으로 사용하는게 좋다고 생각한다.

개발을 함에 있어서 모든 결정은 결국 주어진 상황에서 Trade-off 가 반드시 필요하다고 생각한다. 따라서 이 경우에도 무조건 나쁘다!! 가 아니라 어떤 부정적인 효과가 있는지 인지하고 이를 통해 Trade-off 할 수 있는 능력을 가지는게 좋을 것이다.


Spring Security 를 제대로 이해하지 못하고 사용한다.


다시한번 말하지만 나는 Spring Security 사용을 반대하는 입장이 아니다. 오히려 잘 알고 활용한다면 믿을 수 없을만큼 편하게 개발할 수 있는 환경이 제공되는 프레임워크라고 생각한다.

내가 생각하는 문제는 Spring Security 에 대해서 제대로 이해하지 않고 인터넷에 떠도는 예제 코드를 그대로 사용한다는 것에 있다. 여기에 대해 2가지 예시를 소개하려 한다.

첫 번째는 UserDetailsService, UserDetails 에 대한 것이고,
두 번째는 OncePerRequestFilter, AbstractAuthenticationProcessingFilter 에 대한 것이다.

첫 번째 이야기

Spring Security Filter 를 이용해 로그인 인증을 구현하는데 UserDetailsService, UserDetails 개념이 필수적일까?

아니다!!!

하지만 구글에 검색해서 나오는 Spring Security 로그인 인증 예제의 꽤나 높은 비중이 UserDetailsServiceUserDetails 를 협력에 참여시킨다. UserDetailsServiceAbstractUserDetailsAuthenticationProvider 의 구현체인 DaoAuthenticationProvider 와 협력하기 위한 객체이다.

하지만 앞서 말한 포스팅들에서는 AbstractUserDetailsAuthenticationProvider 의 구현체를 사용하지 않고 직접 AuthenticationProvider 의 구현체를 만들어 사용하면서 UserDetailsService 의 구현체를 만들어 협력에 참여시킨다.

심지어 자기가 개발하고 있는 환경과 맞지 않다면 자기가 개발하고 있는 환경을 이러한 프레임워크 구조에 맞추기도 한다. UserDetailsService 가 가지는 책임 loadUserByUsername 가 내가 만들고 있는 로그인이라는 협력과 맞지 않는 메소드 이름이더라도 그냥 감안하고 사용한다.

직접 AuthenticationProvider 의 구현체를 만들었다면 굳이 UserDetailsService 의 구현체를 이용해 사용자 정보를 읽어올 필요가 전혀 없다. 이미 프로젝트에 구현되어 있을 UserService 를 이용해 사용자 정보를 읽어오면 된다.

이러한 방식의 사용은 점점더 안좋은 상황으로 코드를 만들어버린다.

UserDetailsService 의 구현체를 만들게 되면 loadUserByUsername 를 오버라이딩 하게되고 이 메소드는 UserDetails 를 반환해야한다. 이로인해 Domain Entity 인 UserUserDetails 를 구현하도록 만들기도한다. Domain Entity 까지 프레임워크인 Spring Security 에 의존하는 구조가 되는 것이다.

UserDetails 인터페이스는 isAccountNonExpired, isAccountNonLocked, isCredentialsNonExpired 등 다양한 추상메소드를 포함하고 있어 Spring Security 에 오염된 Domain Entity 는 필요하지도 않은 이러한 메소드들을 오버라이딩 해야한다.

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDetailsEntity implements UserDetails {

    private long memberId;
    private String email;
    private String password;
    private List<MemberGrade> grade;

    public UserDetailsEntity(Member member){
        this.memberId = member.getMemberId();
        this.email = member.getEmail();
        this.password = member.getPassword();
        this.grade = List.of(member.getGrade());
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
       return this.grade;
    }

    @Override
    public String getPassword() {
        return this.password;
    }

    @Override
    public String getUsername() {
        return this.email;
    }

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

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

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

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

두 번째 이야기

JWT 인증을 Spring Security 로 구현하는데 정작 Filter 를 구현하는데 있어서는 OncePerRequestFilter 를 사용하는 사례도 굉장히 많이 보인다. OncePerRequestFilter 는 Spring Security 가 아니라 Spring Web 이 제공하는 Filter 이다.

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";

    private final AuthenticationManager authenticationManager;
    private final SecurityFilterSkipMatcher securityFilterSkipMatcher;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager, SecurityFilterSkipMatcher securityFilterSkipMatcher){
        this.authenticationManager = authenticationManager;
        this.securityFilterSkipMatcher = securityFilterSkipMatcher;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String jwt = resolveToken(request,AUTHORIZATION_HEADER);
        try {
            JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authenticationManager.authenticate(new JwtAuthenticationToken(jwt));
            if(jwtAuthenticationToken.isAuthenticated()) {
                if (!(request.getRequestURI().equals("/auth/reIssue/token"))) {
                    SecurityContextHolder.getContext().setAuthentication(jwtAuthenticationToken);
                }
            }
        } catch(AuthenticationException authenticationException) {
            SecurityContextHolder.clearContext();
        }
        filterChain.doFilter(request,response);
    }

	// 이하 생략...
}

위 코드는 튜터링 해주고 있는 학생의 코드를 참고해 작성해본 샘플 코드이다.
JWT 인증에 해당 Filter 를 사용하면 안된다는 것을 말하는게 아니다.

OncePerRequestFilter 를 통해 JWT 인증을 구현했으면 Spring Security 를 의존하지 말아야 하는데 정작 그건 또 아니다. 정작 이런식으로 개발하는 개발자들은 대부분 본인이 Spring Security 를 사용하는줄 알고 어떻게든 해당 프레임워크에 잘못 작성된 코드를 끼워넣기 위해 노력한다.

Spring Security 위에서 동작하는 Filter 를 구현하고 싶다면 AbstractAuthenticationProcessingFilter 추상 클래스를 상속받은 구현체를 만들어야 한다. 그래야 Authentication Object, ProviderManager, AuthenticationProvider 와 같은 Spring Security 가 만들어 놓은 아키텍처 위에서 내가 원하는 협력을 자연스럽게 만들어 나갈 수 있다.


Spring Security 는 프레임워크로써 굉장히 많은 부분에서 편리한 구조를 제공한다.

Security Filter, Authentication Object, ProviderManager, AuthenticationProvider, SecurityContext 등 다양한 구성요소를 통해 반복되는 개발을 최소화할 수 있는 아키텍처를 제공한다. 이러한 아키텍처 위해서 개발하면 본인도 모르게 이미 잘 만들어진 객체지향적인 설계를 따르게 된다는 큰 장점도 존재한다.

이러한 구조를 이해하고 그 위에서 내가 필요한 협력을 만들어나갈 수 있다면 Spring Security 는 굉장히 강력한 도구가 될 것이라고 생각한다.


너무 과하게 Spring Security 를 의존하려고 한다.


Spring Security 가 많은 부분에서 편리한 기능을 제공하고 있는 것은 맞다. 하지만 내가 개발하려는 어플리케이션에서 필요없는 요구사항까지도 제공하고 있다는 것을 잊지 말아야 한다.

필요 없는 기능이라면 과감하게 제외할 수 있어야하고 적절히 필요한 영역에 대해서만 Spring Security 를 사용할 수 있어야 한다. 절대 내 어플리케이션을 만들어가는데 있어서 프레임워크가 주가 되어서는 안된다.

최근 취준생 분들과 Spring Security 에 대해서 이야기해보면 Spring Security 에 요구사항을 맞춰간다는 느낌을 많이 받는다. 이렇게 개발한 결과물은 너무 프레임워크 의존적이고 위에서 말한 어플리케이션 요구사항이 Spring Security 라는 프레임워크 내부로 꽁꽁 숨어버리는 문제가 발생하게 된다.

심지어는 개발하고 있는 어플리케이션에는 전혀 필요없는 요구사항인데 Spring Security 에서 제공하고 있다는 이유만으로 요구사항을 추가하기도 한다.

프레임워크인 Spring Security 를 사용함에 있어 내가 필요한 영역에 대해서 제한적으로 사용해야 하고, 항상 너무 의존적으로 개발하고 있지는 않은지 경계하는 것이 중요하다고 생각한다.


결론


내가 이번 포스팅을 통해서 전달하고 싶은 것은 프레임워크에 대해서 제대로 이해하고 내가 필요한 곳에 적절히 활용할 때 프레임워크가 비로소 강력한 힘을 발휘할 것이라는 것이다.

만약, 주변에서 사용하니까.. 채용공고에서 우대사항에 존재하니까.. 이러한 이유로 무작정 프레임워크를 프로젝트에 적용하게된다면 언젠가 그 프레임워크가 본인의 발목을 잡게될 것이다. 프레임워크를 사용해서 얻는 이득보다 오히려 불필요한 코드들이 늘어나고 에러가 발생했을 때 그 흐름을 따라가지 못해 고생하는 일이 빈번해질 것이다. (실화를 바탕으로..)

4개의 댓글

comment-user-thumbnail
2024년 7월 29일

AbstractAuthenticationProcessingFilter 를 사용한 이유가 뭔가요? GenericBeanFilter 상속 받아서 2번 호출 되는 문제 있지않나요?

1개의 답글
comment-user-thumbnail
2024년 10월 2일

오,, 양질의 글 잘 읽고갑니다! Spring Security 도입에 대해서 고민을 하던 찰나였는데, 좋은 의견 듣고 가요!

답글 달기