Spring 7기 프로젝트/뉴스피드 팀 프로젝트

[트러블슈팅] Spring Security와 Service Layer 사이의 경계선 찾기

JuNo_12 2025. 5. 29. 15:00

"Service Layer에서 CustomUserDetails를 사용하는 것이 올바른 아키텍처일까?"

 

문제의 시작

 

JWT 기반 인증 시스템을 구축하면서 팀원분이 다음과 같은 코드를 작성했습니다.

@Service
@RequiredArgsConstructor
public class PostService {
    
    private final PostRepository postRepository;
    private final UserRepository userRepository;

    public PostResponseDto createPost(CreatePostRequestDto dto, CustomUserDetails userDetails) {
        // Service Layer에서 인증 객체를 직접 사용
        String email = userDetails.getUsername();
        User user = userRepository.findByEmail(email)
            .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
            
        Post post = Post.create(dto.getTitle(), dto.getContent(), user);
        return PostResponseDto.from(postRepository.save(post));
    }
}

 

팀원과 코드 리뷰를 하던 중, "Service Layer에서 CustomUserDetails를 사용하는 것이 올바른가?" 라는 의문이 들었습니다.


계층 아키텍처의 원칙

전통적인 Spring MVC 아키텍처

Presentation Layer (Controller)
    ↓
Business Layer (Service)  
    ↓
Data Access Layer (Repository)
    ↓
Database

각 계층은 명확한 책임을 가져야 한다:

  • Controller: HTTP 요청/응답, 인증 정보 처리
  • Service: 비즈니스 로직, 트랜잭션 관리
  • Repository: 데이터 접근

 

Spring Security의 위치

Spring Security는 횡단 관심사(Cross-cutting Concern)로, 모든 계층에 영향을 미친다.

┌─────────────────────────────────────┐
│        Spring Security Filter      │
├─────────────────────────────────────┤
│ Controller ← CustomUserDetails 사용  │
├─────────────────────────────────────┤
│ Service ← 여기서 사용해도 될까? 🤔    │
├─────────────────────────────────────┤  
│ Repository                          │
└─────────────────────────────────────┘

문제점 분석

1. 계층 간 결합도 증가

// ❌ Service가 Security 객체에 의존
@Service
public class PostService {
    public void updatePost(Long postId, UpdatePostRequestDto dto, CustomUserDetails userDetails) {
        String email = userDetails.getUsername(); // Security 계층 의존!
        User user = userRepository.findByEmail(email)...
    }
}

 

문제점:

  • Service가 Spring Security에 강하게 결합
  • 인증 방식 변경 시 Service 코드도 수정 필요
  • 단위 테스트 시 CustomUserDetails Mock 객체 필요

2. 불필요한 성능 오버헤드

// JWT에 userId가 있는데 매번 email로 DB 조회
User user = userRepository.findByEmail(userDetails.getUsername()); // 불필요한 DB 조회

// 이미 JWT 토큰에 있는 정보
Long userId = userDetails.getUserId(); // 이걸 바로 쓰면 되는데...

해결 방안

방안 1: Controller에서 인증 정보 추출

// Controller - 인증 정보를 비즈니스 데이터로 변환
@PostMapping("/posts")
public ResponseEntity<ApiResponse<PostResponseDto>> createPost(
        @Valid @RequestBody CreatePostRequestDto dto,
        @AuthenticationPrincipal CustomUserDetails userDetails) {
    
    // 인증 정보 추출 (Controller의 책임)
    Long userId = userDetails.getUserId();
    
    // Service에는 순수한 비즈니스 데이터만 전달
    PostResponseDto response = postService.createPost(dto, userId);
    
    return ResponseEntity.ok(new ApiResponse<>("게시물 생성 완료", response));
}

// Service - 인증 의존성 완전 제거
@Service
public class PostService {
    
    public PostResponseDto createPost(CreatePostRequestDto dto, Long userId) {
        // 순수한 비즈니스 로직만
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
            
        Post post = Post.create(dto.getTitle(), dto.getContent(), user);
        return PostResponseDto.from(postRepository.save(post));
    }
}

 

방안 2: 현재 방식 유지하되 개선

// Service에서 UserDetails를 받되 바로 필요한 정보만 추출
@Service
public class PostService {
    
    public PostResponseDto createPost(CreatePostRequestDto dto, CustomUserDetails userDetails) {
        // 인증 정보를 바로 비즈니스 데이터로 변환
        Long userId = userDetails.getUserId();
        
        User user = userRepository.findById(userId) // email 대신 ID로 조회
            .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
            
        Post post = Post.create(dto.getTitle(), dto.getContent(), user);
        return PostResponseDto.from(postRepository.save(post));
    }
}

더 나아가기: Controller도 완전 분리?

갑자기 든 생각인데, 

"Controller에서 userDetails.getUserId()를 하는 것도 결국 인증 객체에 직접 접근하는 것 아닌가?"

엄격한 분리를 위한 고급 기법

AOP를 활용한 자동 주입

@Aspect
@Component
public class UserIdInjectionAspect {
    
    @Around("@annotation(InjectCurrentUserId)")
    public Object injectUserId(ProceedingJoinPoint joinPoint) throws Throwable {
        // SecurityContext에서 userId 추출
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        CustomUserDetails userDetails = (CustomUserDetails) auth.getPrincipal();
        Long userId = userDetails.getUserId();
        
        // 메서드 파라미터에 userId 주입
        Object[] args = joinPoint.getArgs();
        for (int i = 0; i < args.length; i++) {
            if (args[i] instanceof UserIdHolder) {
                ((UserIdHolder) args[i]).setUserId(userId);
            }
        }
        
        return joinPoint.proceed(args);
    }
}

// 사용법
@PostMapping("/posts")
@InjectCurrentUserId
public ResponseEntity<?> createPost(@RequestBody CreatePostRequestDto dto, UserIdHolder userInfo) {
    // userId가 자동으로 주입됨
    Long userId = userInfo.getUserId();
    return postService.createPost(dto, userId);
}

 

인터셉터 활용

@Component
public class UserIdInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth != null && auth.getPrincipal() instanceof CustomUserDetails) {
            CustomUserDetails userDetails = (CustomUserDetails) auth.getPrincipal();
            request.setAttribute("currentUserId", userDetails.getUserId());
        }
        return true;
    }
}

// Controller에서 사용
@PostMapping("/posts")  
public ResponseEntity<?> createPost(HttpServletRequest request, @RequestBody CreatePostRequestDto dto) {
    Long userId = (Long) request.getAttribute("currentUserId");
    return postService.createPost(dto, userId);
}

결론

핵심 원칙

  1. Service Layer는 순수한 비즈니스 로직에 집중해야 한다.
  2. 인증 정보는 가능한 한 상위 계층에서 추출하여 전달한다.
  3. 완벽한 분리 vs 실용성의 균형을 고려한다.
  4. 팀 컨벤션을 명확히 정의하고 일관성 있게 적용한다.

개인적 권장사항

// 이 정도가 현실적인 균형점
@PostMapping("/posts")
public ResponseEntity<?> createPost(
    @RequestBody CreatePostRequestDto dto,
    @AuthenticationPrincipal CustomUserDetails userDetails) {
    
    // Controller에서 인증 정보 추출 (허용 범위)
    Long userId = userDetails.getUserId();
    
    // Service는 순수한 비즈니스 로직만
    return postService.createPost(dto, userId);
}

 

완벽한 아키텍처는 없다고 생각합니다. 중요한 것은 팀의 상황에 맞는 일관성 있는 기준을 세우고, 그 기준을 명확히 문서화하여 모든 팀원이 따르는 것이죠.