개발기록

어노테이션 기반 정책 접근 제어 시스템: 핵심 로직과 구현 본문

아키텍처

어노테이션 기반 정책 접근 제어 시스템: 핵심 로직과 구현

Danuvibe 2024. 9. 16. 17:23

1. 서론

SaaS(Software as a Service) 애플리케이션에서 사용자의 구독 플랜에 따라 기능 접근을 동적으로 제어하는 것은 매우 중요합니다. 이 글에서는 Kotlin과 Spring Framework를 사용하여 어노테이션 기반의 정책 접근 제어 시스템을 구현하는 방법에 대해 자세히 알아보겠습니다. 특히 핵심 로직과 구현 세부사항에 초점을 맞추겠습니다.

2. 시스템 설계 개요

우리의 시스템은 다음과 같은 주요 컴포넌트로 구성됩니다:

  1. 커스텀 어노테이션 (@PlanPolicy)
  2. 인터셉터 (PolicyInterceptor)
  3. 정책 서비스 (PolicyService)
  4. 데이터 모델 (User, Plan, Feature, PlanFeature)

각 컴포넌트의 역할과 상호작용을 살펴보겠습니다.

3. 데이터 모델 설계

먼저, 시스템의 기반이 되는 데이터 모델을 정의합니다:

@Entity
data class User(
    @Id val id: Long,
    val username: String,
    @ManyToOne val plan: Plan
)

@Entity
data class Plan(
    @Id val id: Long,
    val name: String
)

@Entity
data class Feature(
    @Id val id: Long,
    val name: String
)

@Entity
data class PlanFeature(
    @Id @GeneratedValue val id: Long,
    @ManyToOne val plan: Plan,
    @ManyToOne val feature: Feature
)

이 모델은 사용자, 플랜, 기능, 그리고 플랜과 기능의 관계를 표현합니다.

4. 커스텀 어노테이션 설계

접근 제어가 필요한 메소드에 적용할 커스텀 어노테이션을 정의합니다:

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class PlanPolicy(val feature: String)

이 어노테이션은 특정 기능에 대한 접근 정책을 지정합니다.

5. 인터셉터 구현

PolicyInterceptor는 @PlanPolicy 어노테이션이 적용된 메소드의 실행을 가로채고 접근 권한을 확인합니다:

@Component
class PolicyInterceptor(private val policyService: PolicyService) : HandlerInterceptor {

    override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
        if (handler is HandlerMethod) {
            val planPolicy = handler.getMethodAnnotation(PlanPolicy::class.java)
            if (planPolicy != null) {
                val feature = planPolicy.feature
                val userId = request.userPrincipal.id // Assuming custom UserPrincipal
                if (!policyService.hasAccess(userId, feature)) {
                    throw AccessDeniedException("Access denied for feature: $feature")
                }
            }
        }
        return true
    }
}

6. 정책 서비스 구현

PolicyService는 접근 권한 확인 및 관리의 핵심 로직을 담당합니다:

@Service
class PolicyService(
    private val planFeatureRepository: PlanFeatureRepository,
    private val userRepository: UserRepository,
    private val planRepository: PlanRepository,
    private val featureRepository: FeatureRepository
) {
    fun hasAccess(userId: Long, featureName: String): Boolean {
        val user = userRepository.findById(userId)
            .orElseThrow { UserNotFoundException("User not found: $userId") }
        return planFeatureRepository.existsByPlanAndFeatureName(user.plan, featureName)
    }

    @Transactional
    fun updateUserPlan(userId: Long, planId: Long) {
        val user = userRepository.findById(userId)
            .orElseThrow { UserNotFoundException("User not found: $userId") }
        val plan = planRepository.findById(planId)
            .orElseThrow { PlanNotFoundException("Plan not found: $planId") }
        user.plan = plan
        userRepository.save(user)
    }

    @Transactional
    fun addFeatureToPlan(planId: Long, featureName: String) {
        val plan = planRepository.findById(planId)
            .orElseThrow { PlanNotFoundException("Plan not found: $planId") }
        val feature = featureRepository.findByName(featureName)
            ?: Feature(name = featureName).also { featureRepository.save(it) }
        planFeatureRepository.save(PlanFeature(plan = plan, feature = feature))
    }
}

7. 접근 제어 로직 적용

컨트롤러 메소드에 @PlanPolicy 어노테이션을 적용하여 접근 제어를 구현합니다:

@RestController
@RequestMapping("/api/premium")
class PremiumController {

    @GetMapping("/content")
    @PlanPolicy(feature = "PREMIUM_CONTENT")
    fun getPremiumContent(): ResponseEntity<String> {
        return ResponseEntity.ok("This is premium content")
    }
}

8. 성능 최적화

성능 향상을 위해 캐싱을 적용할 수 있습니다. Spring의 @Cacheable 어노테이션을 사용하여 메소드 레벨 캐싱을 구현합니다:

@Service
class PolicyService(
    // ... other dependencies
    private val cacheManager: CacheManager
) {
    @Cacheable(value = ["userFeatures"], key = "#userId + ':' + #featureName")
    fun hasAccess(userId: Long, featureName: String): Boolean {
        // Existing logic
    }

    @CacheEvict(value = ["userFeatures"], key = "#userId + ':*'")
    @Transactional
    fun updateUserPlan(userId: Long, planId: Long) {
        // Existing logic
    }

    @CacheEvict(value = ["userFeatures"], allEntries = true)
    @Transactional
    fun addFeatureToPlan(planId: Long, featureName: String) {
        // Existing logic
    }
}

9. 예외 처리

커스텀 예외를 정의하여 더 명확한 에러 처리를 구현합니다:

class UserNotFoundException(message: String) : RuntimeException(message)
class PlanNotFoundException(message: String) : RuntimeException(message)
class FeatureNotFoundException(message: String) : RuntimeException(message)

그리고 전역 예외 핸들러를 구현하여 일관된 에러 응답을 제공합니다:

@ControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(AccessDeniedException::class)
    fun handleAccessDeniedException(ex: AccessDeniedException): ResponseEntity<ErrorResponse> {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
            .body(ErrorResponse("Access Denied", ex.message))
    }

    @ExceptionHandler(UserNotFoundException::class, PlanNotFoundException::class, FeatureNotFoundException::class)
    fun handleNotFoundException(ex: RuntimeException): ResponseEntity<ErrorResponse> {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse("Not Found", ex.message))
    }

    // Other exception handlers...
}

data class ErrorResponse(val error: String, val message: String?)

10. 테스트 전략

단위 테스트와 통합 테스트를 통해 시스템의 정확성을 검증합니다:

@SpringBootTest
class PolicyServiceTest {

    @Autowired
    private lateinit var policyService: PolicyService

    @MockkBean
    private lateinit var planFeatureRepository: PlanFeatureRepository

    @Test
    fun `hasAccess should return true for user with access`() {
        every { planFeatureRepository.existsByPlanAndFeatureName(any(), any()) } returns true

        val result = policyService.hasAccess(1L, "PREMIUM_CONTENT")

        assertTrue(result)
    }

    // More tests...
}

결론

어노테이션 기반의 정책 접근 제어 시스템은 선언적 프로그래밍의 장점을 활용하여 비즈니스 로직과 접근 제어 로직을 효과적으로 분리합니다. 이는 코드의 가독성과 유지보수성을 크게 향상시킵니다.

이 시스템의 핵심 강점은 다음과 같습니다:

  1. 유연성: 새로운 플랜이나 기능을 쉽게 추가할 수 있습니다.
  2. 재사용성: @PlanPolicy 어노테이션을 통해 접근 제어 로직을 쉽게 재사용할 수 있습니다.
  3. 분리의 원칙: 접근 제어 로직이 비즈니스 로직과 분리되어 있어 각각을 독립적으로 수정할 수 있습니다.
  4. 확장성: 새로운 접근 제어 정책을 쉽게 추가할 수 있습니다.

향후 개선 방향으로는 더 복잡한 접근 제어 규칙의 지원, 동적 정책 변경 기능 추가, 그리고 접근 로그 분석을 통한 인사이트 도출 등이 있을 수 있습니다.

이 시스템을 구현할 때는 항상 성능, 보안, 그리고 사용자 경험을 균형있게 고려해야 합니다. 지속적인 모니터링과 사용자 피드백을 통한 개선이 시스템의 성공을 위해 중요합니다.

Comments