한국어 | English | 日本語
8.8년차 Web Application Developer 웹 개발자
기술·개발
engineering
웹 프론트엔드 및 백엔드 개발에 관련된 내용들을 주로 다룹니다

Spring Security: SecurityContextHolder 의 Thread 공유 전략

병렬 처리를 위해 도입한 parallelStream 내에서 Spring Security의 세션 정보가 간헐적으로 사라지는 기이한 버그를 마주했다. '슈뢰딩거의 고양이'처럼 새로고침할 때마다 결과가 달라지는 이 현상의 원인을 파헤치고, SecurityContextHolder가 멀티스레드 환경에서 세션 데이터를 공유하는 세 가지 전략을 살펴본다.
리스트 필터링 로직에 parallelStream을 적용했을 때, 일부 스레드에서만 권한 확인이 실패하는 원인을 분석한다. SecurityContextHolder의 기본 전략인 MODE_THREADLOCAL이 하위 스레드와 컨텍스트를 공유하지 못해 발생하는 문제를 진단하고, 이를 해결하기 위한 MODE_INHERITABLETHREADLOCAL 등 공유 모드별 특징과 주의사항을 학습한다.

다수 정보를 리스트로 조회하는 페이지에서 현재 로그인한 유저가 가진 권한에 따라 일부 정보를 보여주지 않도록하는 처리가 필요했습니다. 그래서 먼저 리스트를 API 로부터 가져온 뒤, 현재 Spring Security 로그인 세션에 저장되어있는 권한을 통해 일부 정보를 필터링하여 최종적으로 조회 페이지에 렌더링하도록 작업하였었습니다.

하지만 이상하게 리스트에 노출되는 Row 가 총 10개라면 2 ~ 3개 약 1/4 에 해당하는 Row 만 해당 ‘세션 권한 필터링’ 로직이 적용되었고 나머지 3/4 에 대해서는 적용되지 않는 버그를 발견하였습니다. 심지어 1/4 에 해당하는 2 ~ 3개는 변칙적으로 계속 변경되는것이었습니다. 예를 들면 새로고침 한번에 2번째 3번째 Row 에만 ‘세션 권한 필터링’ 이 적용되었다가, 새로고침을 한번 더 하면 5번째 6번째 Row 에 ‘세션 권한 필터링’이 적용되는것입니다. 마치 슈뢰딩거의 고양이처럼요…

구현은 다음과 같았습니다.

List<SomeInformation> list = someApi.retreive(condition);
list.parallelStream()
    .forEach(each -> {
        if (!SecurityHelper.hasRole("ROLE_CAN_SEE_SENSITIVE_NUMBERS")) {
            each.setSensitiveNumber1(null);
            each.setSensitiveNumber2(null);
        }
    })
public class SecurityHelper {

    public static boolean hasRole(String role) {
        SecurityContext context = SecurityContextHolder.getContext();
        if (Objects.isNull(context))) {
            return false;
        }
        Authentication authentication = context.getAuthentication();
        if (Objects.isNull(auth))) {
            return false;
        }
        for (GrantAuthority eachAuthority : authentication.getAuthorities()) {
            if (role.equals(eachAuthority.getAuthority())) {
                return true;
            }
        }
        return false;
    }
}

실제로는 테스트했던 로그인 계정에 ROLE_CAN_SEE_SENSITIVE_NUMBERS 권한이 있었기 때문에, 리스트의 모든 Row 들에 sensitiveNumber1, 2 모두 정상 노출되는것이 맞습니다. 하지만 1/4 만 노출되는건 아무리 생각해도 이상하여 parallelStream.forEach 내부에 로그를 추가하였더니 아래와 같은 결과가 나왔습니다.

INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-3] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-2] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-7] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-1] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [http-nio-80-exec-3] [TEST] hasRole: true
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-4] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-5] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-6] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-2] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [http-nio-80-exec-3] [TEST] hasRole: true

보아하니 ForkJoinPool 즉, ParallelStream 실행을 위해 할당된 하위 Thread(ForkJoinPool.commonPool-worker-1~7) 에서는 hasRole이 비정상적으로 false 값을 반환하고, 메인 Thread(http-nio-80-exec-3) 에서는 hasRole이 정상적으로 true 값을 반환하는걸 알 수 있습니다.

무언가 ParallelStream 과 SecurityContextHolder 혼용이 문제인것으로 보입니다.

SecurityContextHolder 의 Thread 간 공유 모드

ParallelStream 의 Thread 에서 hasRole = false 가 반환됐던 1차 원인은 SecurityContext context = SecurityContextHolder.getContext() 호출시 null이 반환되고 있었습니다. 반면 메인 Thread 에서 SecurityContextHolder.getContext() 호출시에는 정상적으로 세션 데이터를 가져올 수 있었고, hasRole 에 알맞은 비교 로직까지 수행할 수 있었습니다. 알아보니 아래와 같은 사실을 발견했습니다.

SecurityContextHolder는 SecurityContext 로그인 세션 정보를 어떤 레벨의 Thread 까지 공유할지
모드를 지정하도록 되어있습니다. 기본값으로는 MODE_THREADLOCAL 로써
SecurityContext 정보는 “메인 Thread” 에서만 볼 수 있습니다.

총 공유 모드는 3가지로 나뉘어져있습니다.

기본 모드는 MODE_THREADLOCAL 였기에, 아무런 설정도 하지 않았던 서버에서는 메인 Thread(http-nio-80-exec-3)에서만 SecurityContext 가 반환되었던고, 나머지 하위 Thread(ForkJoinPool.commonPool-worker-1~7)에서는 null 이 반환되었던것입니다.

Conclusion

SecurityContextHolder 의 기본 설정은 SecurityContext 정보를 Local Thread 만 공유하도록 되어있기 때문에 SecurityContextHolder 를 직접 하위 Thread 안에서 호출하여 사용하는것보다, 메인 Thread 에서 호출하여 해당 값을 하위 Thread 에서 참조하도록 하는것이, 성능적으로나 가시적으로도 더 깔끔한 코드가 될것입니다.

ParallelStream 혹은 Async 관련된 기능을 사용 시 하위 Thread 에서 SecurityContextHolder 를 사용해야하는 경우가 있다면 SecurityContextHolder 의 공유 모드를 MODE_INHERITABLETHREADLOCAL 로 낮추는것을 고려해야합니다.



Spring Security: SecurityContextHolder 의 Thread 공유 전략
Author
Aaron
Posted on
Licensed Under
CC BY-NC-SA 4.0
CC BY-NC-SA 4.0
같은 카테고리 내 다른 글들
최근에 게시된 글들
Giscus CSS 커스터마이징으로 알아보는 브라우저의 보안 정책들
블로그를 새로 만들면서 기존 Hexo 블로그에서 사용하던 Discus 를 더 이상 사용하지 않고 현재 가장 편리하게 쓸 수 있는 Giscus 를 사용하게되었다. Giscus 댓글 컴포넌트를 나의 블로그 CSS 테마에 맞추기 위해 커스터마이즈한 CSS 만들어 외부에서 Giscus 에 주입하려하였다. 어째서인지 마음만큼 잘 되지 않았고, 헤매던 와중에 프론트엔드 개발자라면 질리도록 보는 CORS 뿐만 아니라 Mixed Content 와 PNA (Private Network Access) 에 대한 보안 정책들이 엉켜서 동작되지 않았던 이유들을 설명하고, Giscus 내 CSS 테마를 올바르게 적용하는 법에 대해서도 안내한다.
LLM 필터가 앗아가는 대화의 근육과 소통의 양식
대화의 무례함을 거르고 정제된 답변을 내놓는 LLM 도구가 일상화된 시대, 우리는 더 사려 깊은 대화를 하고 있는 것일까? 실시간 소통에서 오는 수많은 실패를 통해 다듬어져야 할 대화의 능력이 외부 도구에 의존하며 퇴화하고 있는 현상과, 그것이 가져올 사회적 불안 및 세대적 행동 양식의 변화를 고찰한다.
시니어 채용 시 유리한 연봉협상 시점과 전략
연봉협상은 단순히 숫자를 주고받는 과정이 아니라 심리적인 타이밍의 싸움이다. 사측의 입장에서 후보자가 계산적인 태도로 변하기 쉬운 최종 합격 이후보다, 채용 절차의 초기 단계부터 차근차근 협상을 진행하는 것이 왜 더 효율적이고 솔직한 자원 공유를 이끌어낼 수 있는지 분석한다.
토스트 예시 메세지