본문 바로가기
Web/Spring

[Spring Security] 3편. 적용 방법 및 엔티티의 생명주기

by 조엘 2022. 8. 28.

안녕하세요! 조엘입니다!

 

1편, 2편에서는 Spring Security의 필요성과 Filter 아키텍처 내부 구조에 대해서 알아봤어요. 

마지막 3편에서는 Spring Security를 프로젝트에 실제로 적용해 깨달은 동작 원리들과, 직접 트러블 슈팅했던 경험에 대해 얘기해볼게요!

 

해당 포스팅은 유저가 자신의 정보를 변경하려는 요청이 있을 경우 일어나는 일들을 서술했어요.

로그인이 되어있는 유저이며, 유저의 로그인 정보는 Http Header에 JWT 토큰으로 관리해요. 

 

해당 코드는 실제 소프트웨어 마에스트로 과정 프로젝트에서 썼던 코드입니다! (코드 리뷰 환영입니다!)

https://github.com/Team-UACC/connectable-backend

 

GitHub - Team-UACC/connectable-backend

Contribute to Team-UACC/connectable-backend development by creating an account on GitHub.

github.com

 

흐름을 같이 따라가다보면, Spring Security가 어떻게 인증 객체를 저장하여 Spring MVC에서 사용하는지 파악할 수 있을 거예요 💪💪

같이 한 번 살펴보시죠!

 

 

Spring Security가 인증 객체를 저장하는 법

OncePerRequestFilter를 상속한 JwtAuthenticationFilter를 정의해요. 

해당 Filter는 사용자의 HttpRequest를 파싱하여 header에 담긴 JWT 토큰을 추출해 인증 처리를 진행해요. 

 

verifyTokenAccordingToPath() 함수에서 jwtProvider.getAuthentication(token)을 통해 Authentication 객체를 만들고, 해당 객체를 SecurityContextHolder의 SecurityContext에 Authentication 객체를 저장해요. 

SecurityContext는 Authentication 객체를 보관할 때 ThreadLocal을 활용하는데요. 

ThreadLocal을 활용하면 쓰레드 단위로 로컬 변수를 사용할 수 있어요! 마치 전역 변수처럼 말이죠!

(그렇다면 해당 쓰레드에서 저장한 변수를 쓰레드가 Spring Security를 넘어 Spring MVC에서도 활용할 수 있게 되겠죠?!)

@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = AuthorizationExtractor.extract(request);
        String path = request.getRequestURI();
        if (!Objects.isNull(token)) {
            verifyTokenAccordingToPath(token, path);
        }
        filterChain.doFilter(request, response);
    }

    private void verifyTokenAccordingToPath(String token, String path) {
        if (path.startsWith("/admin")) {
            jwtProvider.verifyAdmin(token);
            return;
        }
        jwtProvider.verify(token);
        Authentication authentication = jwtProvider.getAuthentication(token);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

 

이번엔 JwtProvider.getAuthentication()의 구현을 봐봅시다.

아래처럼 UserDetailsService.loadUserByUsername()을 통해 UserDetails 객체를 만들어줄게요. 

UsernamePasswordAuthenticationToken을 생성하며 userDetails, "", userDetails.getAuthorities()를 파라피터로 넘겨줘요.  

넘겨받은 파라미터가 어디에 저장되는지 알아볼까요? 

@Component
@RequiredArgsConstructor
public class JwtProvider {

    private final UserDetailsService userDetailsService;

    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(exportClaim(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }
}

 

UsernamePasswordAuthenticationToken은 Authentication 인터페이스를 구현한 클래스인데요. 

방금 사용한 생성자의 코드는 아래와 같아요! 보시다시피 principal과 credentials를 각각 저장하고 있네요.

위의 예시에서는 넘겨받은 UserDetailsprincipal에 저장하는 것을 알 수 있는데요!

저장된 UserDetails는 Authentication.getPrincipal()을 통해 추후에 가져올 수 있겠죠? 

(나중에 UserDetails는 Spring MVC 단에서 가져오니까 기억해주세요!)

public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
        Collection<? extends GrantedAuthority> authorities) {
    super(authorities);
    this.principal = principal;
    this.credentials = credentials;
    super.setAuthenticated(true); // must use super, as we override
}

 

이번엔 UserDetailsService를 구현한 CustomUserDetailsService를 봅시다!

User 엔티티를 조회해 ConnectableUserDetails를 생성해주는 것을 볼 수 있어요. 

@Component
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String klaytnAddress) throws UsernameNotFoundException {
        Optional<User> optionalUser = userRepository.findByKlaytnAddress(klaytnAddress);
        return optionalUser.map(ConnectableUserDetails::new)
                .orElseThrow(() -> new ConnectableException(HttpStatus.BAD_REQUEST, ErrorType.USER_NOT_FOUND));
    }
}

 

ConnectableUserDetails는 아래와 같은데요. User 엔티티를 필드로 가지게 설계했어요. 

@RequiredArgsConstructor
@Getter
public class ConnectableUserDetails implements UserDetails {

    private final User user;

    // interface methods
}

 

----- 여기까지가 Spring Security 관련 코드 -----

 

자! 이제 Spring MVC 단에서 Spring Security를 통해 저장해뒀던 정보를 가져와볼까요? 

modifyUser() 함수에서 @AuthenticationPrincipal을 통해 ConnectableUserDetails 객체를 가져오는 것을 볼 수 있어요. 

Spring Security에서 ConnectableUserDetails는 어디에 저장해뒀었죠? 

바로 Authentication 객체의 principal 변수에 저장해뒀었어요! (다시 가져다가 쓴다고 했죠?!)

@AuthenticationPrincipal은 Authentication.getPrincipal() 을 수행하는 어노테이션이에요. 

 

Spring Security에서 저장한 Authentication 객체의 정보를 Spring MVC에서 쓸 수 있게 되었어요!

(앞서 위에서 설명한 ThreadLocal에 저장해서 가능한 일입니다!)

@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    @PutMapping
    public ResponseEntity<UserModifyResponse> modifyUser(@AuthenticationPrincipal ConnectableUserDetails userDetails,
                                                         @RequestBody @Validated(ValidationSequence.class) UserModifyRequest userModifyRequest) {
        UserModifyResponse userModifyResponse = userService.modifyUserByUserDetails(userDetails, userModifyRequest);
        return ResponseEntity.status(HttpStatus.OK).body(userModifyResponse);
    }
}

 

UserService에서 이제 ConnectableUserDetails.getUser()를 통해 User 엔티티를 가져와요. 

가져온 User 엔티티에 대해 modifyInformation 함수를 호출해 엔티티 내부의 필드 정보를 바꿔줘요!

우린 JPA를 사용하니, 트랜잭션이 종료되는 시점에 변경 감지를 통해 update 쿼리가 나갈 것이에요!

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    @Transactional
    public UserModifyResponse modifyUserByUserDetails(ConnectableUserDetails userDetails, UserModifyRequest userModifyRequest) {
        User user = userDetails.getUser();
        user.modifyInformation(userModifyRequest.getNickname(), userModifyRequest.getPhoneNumber());
        return UserModifyResponse.ofSuccess();
    }
}

라고... 예측했습니다만 변경 감지가 일어나지 않았습니다! 🥲🥲

 

 

변경 감지가 왜 안 일어나는거지?

처음엔 변경 감지가 안 일어나는 게 당황스럽더라고요. 한동안 꽤나 삽질도 했습니다!

제 상식에서는 너무 당연히 변경 감지가 일어났어야 했는데 말이죠..! 🧐🧐

(이건 제가 그동안 항상 Spring MVC 단에서 코드를 작성해왔기 때문입니다)

그래서 QueryDsl을 통해 직접 update 쿼리를 날려주는 형식으로 해결을 해왔는데요! 이제 문제를 알았습니다 🙌🏻🙌🏻

 

문제 해결의 키워드를 먼저 얘기해보자면 준영속, OSIV 입니다!

해결에 큰 힌트 주신 "Entity Lifecycle을 고려해 코드를 작성하자 1, 2편" 을 참고에 링크 넣어둘게요!

 

기본적으로 Spring MVC에서는 Open Session In View를 Interceptor 단으로 설정하는데요. 

요청이 Interceptor를 통과할 때 영속성 컨텍스트의 관리를 시작하고, 

응답이 Interceptor를 통과할 때 영속성 컨텍스트를 종료시키는 방법이에요. 

 

한번 Spring MVC 에서의 요청/응답 흐름을 한 번 볼까요? 

 

Spring MVC 요청/응답 사이클

 

Spring Security는 앞서 2편에서 언급했던 것처럼 Filter 기반으로 인증/인가 처리를 진행해요. 

Filter는 보다시피 Interceptor 보다 앞서서 처리가 되는데요! 

 

그동안의 삽질은 Filter 단에서 조회해온 엔티티에 대해 변경 감지가 일어나지 않는다며 속상해하고 있던 것이었어요!

Filter 단에서 조회한 엔티티는 조회되는 시점에 트랜잭션이 적용되는 부분에서만 영속성 컨텍스트의 관리를 받아요. 

조회 시점의 트랜잭션이 종료된 이후에는 영속성 컨텍스트의 관리를 벗어난 "준영속" 상태의 엔티티가 되는 거죠. 

준영속이 된 엔티티의 경우, 영속성 컨텍스트의 혜택을 받을 수 없어요.

이 말은 즉 1차 캐시 및 변경 감지의 혜택을 받을 수 없다는 것이죠!

 

요청이 Interceptor 단으로 들어오면 새로운 엔티티 매니저를 만들어서 OSIV를 적용시켜요. 

따라서 Spring MVC에서 @AuthenticationPrincipal 로 받아온 준영속화된 User에게 변경 감지는 불가능한 일입니다!

 

해결 방법은 크게 2가지인데요. 

1. OSIV의 범위를 Filter로 확장한다. 

2. Service Layer에서 User 엔티티를 조회하는 로직으로 리팩터링한다. 

 

저희 팀은 2번을 채택했는데요.

Filter 까지 OSIV를 열어두면 너무 DB 커넥션을 오래 물고 있지 않을까에서의 이유도 있고,

인증/인가 로직에서 엔티티를 가지고 있는 로직이 리팩터링 되는 것이 맞다고 판단해서의 이유도 있어요!

 

코드 개선은 아래 PR에서 확인하실 수 있습니다!

https://github.com/Team-UACC/connectable-backend/pull/78

 

refactor: user details 수정 by joelonsw · Pull Request #78 · Team-UACC/connectable-backend

작업 내용 UserDetails가 klaytnAddress만을 들고 있도록 리팩터링 오전에 설명했던 것 처럼, Spring Security에서 UserDetails에 User 엔티티를 담아봤자 Spring MVC에 도착하고 나서는 영속성 컨텍스트의 혜택을

github.com

 

긴 글 읽어주셔서 감사합니다! 질문 환영이에요 :) 

 

 

참고

Entity Lifecycle을 고려해 코드를 작성하자 1, 2편

- https://tecoble.techcourse.co.kr/post/2020-08-31-entity-lifecycle-1/

- https://tecoble.techcourse.co.kr/post/2020-09-20-entity-lifecycle-2/

 

ThreadLocal

- https://madplay.github.io/post/java-threadlocal

 

Spring Security TIL

- https://github.com/joelonsw/TIL/blob/master/concepts/Spring%20Security.md

 

OSIV (Open Session In View)

- https://ykh6242.tistory.com/entry/JPA-OSIVOpen-Session-In-View%EC%99%80-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94

 

@Transcational 기점으로 EntityManger가 만들어지며, 이는 ThreadLocal에 저장됨

- https://stackoverflow.com/questions/42074270/should-there-be-an-entitymanager-per-thread-in-spring-hibernate

 

 

 

반응형

'Web > Spring' 카테고리의 다른 글

[Spring Security] 2편. 아키텍처  (0) 2022.08.20
[Spring Security] 1편. 인증/인가 & 소개  (0) 2022.08.15

댓글