Spring Security 관련 고민
Spring security에 JWT를 도입하였다. Jwt Authentication Filter 구성 시 SecurityContextHolder에 Member 객체를 전달해야 했다.
Jwt는 OncePerRequest로 설정되어 있었기에 모든 요청마다 Member 객체를 ContextHolder에 전달하려다 보니 매 요청마다 토큰 validation과 DB 조회를 통한 사용자 validation이 동시에 발생하였다.
첫번째 결론
DB 조회를 최소화 해야 시스템의 안정성과 성능 상의 오버헤드를 줄일 수 있다고 판단했다.
그에 따라 auth filter의 역할을 JWT 자체에 대한 validation으로 한정하고 사용자 validation은 controller 단에서 처리하고, 사용자 validation을 담당하는 component를 구성하여 사용하면 OOP 관점에서 SRP 원칙을 고수할 수 있겠다고 생각했다.
component를 사용하는 것보다 AOP를 지향하는 것은 어떨까 생각하였으나 Annotation 기반 설계는 자동화에는 매우 용이하나 method 보다 활용도가 떨어졌다.
프로젝트 특성상 엔티티(테이블)의 구조가 계층적이었고, 엔티티에 따라 데이터 사용 권한 validation 방식이 상이했다.
결국 Jwt auth filter에서 SecurityContextHolder를 사용할 수 밖에 없고, 이는 내가 생각했던 auth filter의 SRP 원칙에 어긋났다.
결론적으로,
1. AuthUtil 컴포넌트로 사용자 validation 역할을 분리했다.
2. JwtAuthenticationFilter에서는 토큰 자체의 Validation만 진행하기로 하고 JWT에 필요한 모든 정보를 포함하기로 했다.
3. SecurityContextHolder에는 실제 Member 객체가 아닌 JWT에 포함된 정보만 갖는 MemberClaim 객체를 사용하기로 했다.
두번째 결론
사용자의 데이터 소유권 validation에 DB 조회는 애초에 필요 없다는 것을 깨달았다.
JWT는 서버에서 generate 되는데, 그럼 DB 조회 후 생성된 Member 객체의 UUID는 JWT의 subject에 포함될 것이고, JWT verify를 통과했다면 변조 되지 않음이 보장되므로 당연히 유효한 사용자의 UUID일 것이다(로그아웃이나 토큰 Replay와 같은 예외는 별개의 문제)
결론적으로,
1. AOP 지향적으로 사용자 validation을 설계하고 현재 인증된 사용자에 대해선 Service 레이어에서 조회를 할 것이므로 새로운 컴포넌트는 불필요하다.
세번째 결론
사용자의 아이템 ID의 경우 DB 조회 및 join 등 복잡한 validation 로직이 필요한데 이의 경우는 AOP로 처리하기엔 한계가 있었다.
엔티티가 추가됨에 따라 사용자 권한 validation을 위한 aspect 코드와 새로운 annotation을 정의하는 것은 확장성과 유지보수성에 치명적이었다.
그래서 다소 우아하진 않지만 서비스별로 해당 서비스가 주로 다루는 엔티티에 관한 데이터 소유권 validation을 진행하는 private 메서드를 정의했다. 차후 리팩토링 시, 이들을 모아서 util 컴포넌트로 모아 OOP 원칙을 고수할 수 있도록 해야겠다.
결론적으로,
Service 단에서 소유권 Validation 로직을 다뤄야 한다.
네번째 결론
@PreAuthorize, @PostAuthorize, @Secured 어노테이션의 존재를 깨달았다.
@PreAuthorize를 통해 AOP 기반으로 메서드 실행 전 사용자의 해당 데이터에 대한 소유권을 먼저 검증한 다음 로직을 진행할 수 있다.
...
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig { ... }
해당 어노테이션을 사용하기 위해선 SecurityConfig에 다음 어노테이션을 추가한다
public abstract class AccessHandler {
public final boolean ownershipCheck(UUID resourceId) {
return isResourceOwner(resourceId);
}
public final UUID getCurrentMemberId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return ((Member) auth.getPrincipal()).getId();
}
abstract boolean isResourceOwner(UUID resourceId);
}
@Component
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class VocabAccessHandler extends AccessHandler {
private final VocabRepository vocabRepository;
@Override
boolean isResourceOwner(UUID resourceId) {
return vocabRepository.findById(resourceId)
.map(Vocab::getMember)
.filter((m) -> getCurrentMemberId().equals(m.getId()))
.isPresent();
}
}
AccessHandler abstract를 정의하고 각 Entity에 맞게 검증 로직을 구현한다
@Transactional(readOnly = true)
@PreAuthorize("@vocabAccessHandler.ownershipCheck(#vId)")
public VocabDto getVocabById(UUID vId) {
Vocab vocab = vocabRepository.findById(vId)
.orElseThrow(() -> new ApiException(NO_VOCAB));
return VocabDto.fromEntity(vocab);
}
데이터에 대한 트랜재션이 2번 중복되어 효율은 떨어질 수 있으나 단 하나의 조회를 늘림으로써 클린코드, 검증과 비즈니스 로직의 역할 분리, AOP 패턴을 사용할 수 있으니 이 정도의 오버헤드는 나쁘지 않은 trade-off 라고 생각된다
'IT > Spring Boot' 카테고리의 다른 글
[Spring boot] Unit Test에 관해 (0) | 2025.04.10 |
---|---|
[Spring Boot] 스프링부트 리팩토링 팁 (0) | 2024.09.27 |
[Spring Boot] 스프링 부트 SpEL(Spring Expression Language) (0) | 2024.09.27 |
[Spring Boot] 스프링부트 Spring Resource (0) | 2024.09.27 |
[Spring Boot] 스프링 부트 Validation (0) | 2024.09.27 |