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);
}
결론
핵심 원칙
- Service Layer는 순수한 비즈니스 로직에 집중해야 한다.
- 인증 정보는 가능한 한 상위 계층에서 추출하여 전달한다.
- 완벽한 분리 vs 실용성의 균형을 고려한다.
- 팀 컨벤션을 명확히 정의하고 일관성 있게 적용한다.
개인적 권장사항
// 이 정도가 현실적인 균형점
@PostMapping("/posts")
public ResponseEntity<?> createPost(
@RequestBody CreatePostRequestDto dto,
@AuthenticationPrincipal CustomUserDetails userDetails) {
// Controller에서 인증 정보 추출 (허용 범위)
Long userId = userDetails.getUserId();
// Service는 순수한 비즈니스 로직만
return postService.createPost(dto, userId);
}
완벽한 아키텍처는 없다고 생각합니다. 중요한 것은 팀의 상황에 맞는 일관성 있는 기준을 세우고, 그 기준을 명확히 문서화하여 모든 팀원이 따르는 것이죠.