개요
특정 사용자가 어떤 데이터를 읽을 때 이 사용자가 데이터 읽을 수 있는지 판별하는 비즈니스 요구사항이 있을 수 있다.
대표적으로 사용자의 권한, 데이터 값에 따라 읽기 정책이 정해지는 두가지 방식이 있는데, 차이가 존재한다.
사용자의 권한은 데이터 값에 상관없는 정책이다.
만약 사용자의 권한이 부족하다면, 데이터를 읽을 필요도 없이 필터 단에서 요청을 거절하면되기 때문에 단순하다고 볼 수 있다.
반면 데이터 값에 따라 읽기 정책이 정해진다면 데이터를 읽어봐야만 이 사용자가 읽을 수 있는 데이터인지 알 수 있다.
이러면 읽기 비즈니스가 복잡할 때 여러 테이블과 조인하거나 외부 상태를 체크하는 등 로직이 복잡해질 수 있고,
정책이 추가될 때 기존 로직을 분석하거나 기능 구현 중 실수로 이전 정책 로직들이 변경될 수 있다.
그래서 이번 포스팅에서는 복잡한 정책을 쉽게 풀어나갈 수 있도록 하는 방법을 설명하고자 합니다.
읽기 정책이 5가지가 있고 이 중 하나라도 만족한다면 읽을 수 있다는 정책이 있다고 가정하자.
절차지향으로 하나씩 체크하면서 작성해 볼 수 있고, 정책마다 private 함수로 분리시켜 볼 수 있다.
위 방법도 틀리지는 않았지만
- 5가지 정책 중 3번째 정책만 잘 동작하는지 테스트하고 싶을 때 문제
- 5가지 정책 중 10개로 추가될 때 복잡도 문제
- 유효성 검사를 위해 필요한 의존성이 한곳으로 모이는 문제
이런 문제로 각 정책을 책임지는 클래스를 만들어봄으로 써 OCP를 지키고 정책을 쉽게 추가/제거 할 수 있도록 만드는 것이 좋아 보인다.
ShortCircuit
'쇼트서킷'은 주로 논리 연산자에서 사용되는 개념이다. 특정 조건이 충족되면 나머지 조건들을 평가하지 않고 바로 결과를 반환하는 것을 의미한다. 이 개념은 효율성과 성능을 향상시키기 위해 많이 사용하고 있다.
5개의 조건 중 하나라도 통과되면 더 이상 남은 정책을 검사하지 않아도 되기 때문에 위 개념을 적용해야 한다.
구현
요구사항
- 정책이 하나라도 성공하면 나머지 정책 검사를 생략한다.
- 실패하면 예외를 던지고 다음 정책을 검사한다.
- 모든 정책이 실패하면 제일 먼저 실패한 예외를 던진다.
interface Policy<T> {
fun validate(target: T)
}
// 정책 구현체1
@Component
class StringPolicy1 : Policy<String> {
override fun validate(target: String) {
TODO("Not yet implemented")
}
}
// 정책 구현체2
@Component
class StringPolicy2 : Policy<String> {
override fun validate(target: String) {
TODO("Not yet implemented")
}
}
정책마다 구현해야 할 Policy 인터페이스와 예제에서 사용할 문자열 정책(StringPolicy1, StringPolicy2) 구현체들이다.
평가 대상은 비즈니스마다 다를 수 있기에 타입을 제네릭으로 선언하였다.
정책 평가 클래스 정의
abstract class ShortCircuitPolicies<T>(
private val policies: List<Policy<T>>, // 순서가 정해진 정책 리스트 의존 주입
) {
fun validate(target: T) {
val exceptions = mutableListOf<Exception>()
for (policy in policies) {
try {
policy.validate(target) // 검증
return // 검증이 정상적으로 종료되면 정책 평가를 종료한다.
} catch (e: Exception) {
exceptions.add(e) // 검증에 실패하면 예외를 저장하고 다음 정책을 수행한다.
}
}
throw exceptions.first() // 모든 정책이 실패하면 제일먼저 발생한 예외를 던지면서 종료한다.
}
}
// 정책 평가 구현체
@Component
class StringShortCircuitPolicies(
policies: List<Policy<String>>
) : ShortCircuitPolicies<String>(policies)
각각의 Policy를 평가할 수 있는 일급컬렉션 ShortCircuitPolicies를 만든다.
Policy<T> 타입의 Bean이 여러개라면 리스트로 의존 주입을 받을 수 있다.
정책 순서가 중요하다면 정책 구현체에 @Order 애너테이션을 사용하여 순서를 정의할 수 있다.
테스트
테스트를 위해 문자열 체크 정책 리스트를 만들어봤다. 순서는 다음과 같다.
- 정책1: 공백이 포함되어 있다면 예외를 던진다.
- 정책2: 5글자 초과 시 예외를 던진다.
- 정책3: '.' 으로 끝나지 않으면 예외를 던진다.
kotest와 mockk를 사용하여 테스트 코드를 작성해 봤다.
// 문자열 정책 평가 클래스
private class StringShortCircuitPolicies<String>(
polices: List<Policy<String>>
) : ShortCircuitPolicies<String>(polices)
// 정책1
private class StringPolicy1 : Policy<String> {
override fun validate(target: String) {
require(!target.contains(" "))
}
}
// 정책2
private class StringPolicy2 : Policy<String> {
override fun validate(target: String) {
require(target.length <= 5)
}
}
// 정책3
private class StringPolicy3 : Policy<String> {
override fun validate(target: String) {
require(target.endsWith('.'))
}
}
@DisplayName("정책 테스트")
class ShortCircuitPoliciesTest : DescribeSpec({
val policy1 = spyk(StringPolicy1())
val policy2 = spyk(StringPolicy2())
val policy3 = spyk(StringPolicy3())
val stringPolicies = listOf(policy1, policy2, policy3)
describe("문자열 정책") {
val shortCircuitPolicies = StringShortCircuitPolicies(stringPolicies)
context("케이스 1") {
val target = "무궁화 꽃이 피었습니다"
it("모든 정책이 실패한다.") {
shouldThrow<IllegalArgumentException> {
shortCircuitPolicies.validate(target) // 검증 함수 호출
}
verifySequence {
policy1.validate(target)
policy2.validate(target)
policy3.validate(target)
}
}
}
context("케이스 2") {
val target = "무궁화"
it("첫번째 정책에서 성공한다.") {
shouldNotThrowAny {
shortCircuitPolicies.validate(target)
}
verify(exactly = 1) {
policy1.validate(target)
}
verify(exactly = 0) {
policy2.validate(target)
policy3.validate(target)
}
}
}
context("케이스 3") {
val target = "무궁화 "
it("두번째 정책에서 성공한다.") {
shouldNotThrowAny {
shortCircuitPolicies.validate(target)
}
verifySequence {
policy1.validate(target)
policy2.validate(target)
}
verify(exactly = 0) {
policy3.validate(target)
}
}
}
context("케이스 4") {
val target = "무궁화 꽃이 피었습니다."
it("마지막 정책만 성공한다.") {
shouldNotThrowAny {
shortCircuitPolicies.validate(target)
}
verifySequence {
policy1.validate(target)
policy2.validate(target)
policy3.validate(target)
}
}
}
}
afterEach {
clearMocks(policy1, policy2, policy3)
}
})
케이스 1
- 입력값: ‘무궁화 꽃이 피었습니다’
- 기대값
- 정책1 ❌ → 정책2 ❌ → 정책3 ❌ (모두 실패)
케이스 2
- 입력값: ‘무궁화’
- 기대값
- 정책1 ✅ (첫번째 정책 평가 후 종료)
케이스 3
- 입력값: ‘무궁화 ’
- 기대값
- 정책1 ❌ → 정책2 ✅
케이스 4
- 입력값: ‘무궁화 꽃이 피었습니다.’
- 기대값
- 정책1 ❌ → 정책2 ❌ → 정책3 ✅

마무리
나름 OCP를 지켜 정책을 정의하는 방법에 대해서 간략하게 소개해 봤다.
테스트 코드에서 봤듯이 읽기 요구사항 정책이 추가되어도 Policy 정책만 구현하면 되기 때문에 쉽게 반영할 수 있다.
또한 정책을 책임지는 클래스가 각각 있으니 테스트 코드 작성도 쉬워진다.
위 상황은 단순한 검증 평가일 때 쉽게 사용 가능하지만,
상태에 따라 순서가 달라지거나 정책에 따라 데이터가 변경되는 경우에는 복잡해 질 수 있다.
이럴 때는 책임 연쇄 패턴을 적용하여 정책 체인을 만들어 볼 수 있고, 스프링의 https://spring.io/projects/spring-statemachine을 사용하여 정책을 만드는 것도 하나의 방법일 것 같다.