들어가며
고객이 장바구니에 상품 X를 담고 있을 때 첫 구매자거나 최근 한 달 내 구매 금액이 10만 원 이상이면서 등급이 VIP라면 쿠폰을 노출해주세요
위와 같이 복잡한 조건을 평가해야 한다면 어떻게 구현 해야할까? 요구사항이 많지 않을 때는 간단히 if
를 사용하여 빠르게 구현할 수 있지만, 제품이 성숙해질수록 요구사항이 복잡해지기 마련이다.
- 다양한 상황에 대한 조건을 평가해야 한다.
- 관리자가 개발자에 의존하지 않고 편집할 수 있길 원한다.
- 런타임 도중에 조건을 변경할 수 있어야 한다.
- 평가 데이터는 실시간으로 변할 수 있다.
이런 요구사항을 충족하기 위해선 단순히 if
문을 사용해 조건을 평가하는 것만으로는 부족하다. 이를 해결하기 위한 강력한 패턴 중 하나가 바로 조건 트리(Condition Tree)라고 할 수 있다.
이번 글에서는 조건 트리가 무엇인지, 어떻게 설계하고 구현할 수 있는지에 대해 설명할 것이다. 조건 트리는 마케팅, 개인화, 권한 관리 등 다양한 곳에서 사용될 수 있다.
예를 들어, 마케팅에서는 특정 조건을 만족하는 사용자에게만 쿠폰을 발급하거나, 개인화된 추천 상품을 제공하는 데 사용될 수 있다. 또한, 권한 관리에서는 특정 조건을 만족하는 사용자에게만 특정 기능이나 리소스에 대한 접근 권한을 부여하는 데 사용될 수 있다.
조건을 추상화 하는 방법
언뜻 복잡한 조건을 추상화한다는 것이 매우 복잡하고 어려운 일처럼 보일 수 있다. 하지만 침착하게 접근하면 생각보다 간단하다. 모든 추상화는 필요한 것을 찾아 필요한 것을 뽑아내는 것부터 시작한다. 다음 조건이 있다고 가정해보자.
나이가 18세 이상이면서 첫 구매거나 VIP인 경우
위 조건은 복잡해 보이지만, 사실 '조건식'과 '조건 연산자' 두 가지 요소로 나눌 수 있다. 문장에서 조건식만 추상화 한다면 다음과 같다.
[나이가 18세 이상]이면서 [첫 구매]거나 [VIP]인 경우
- 나이가 18세 이상 (age >= 18)
- 첫 구매 (purchaseCount == 0)
- VIP (level == VIP)
그럼 이번에는 조건 연산자만 추상화 해보자.
나이가 18세 이상이면서(AND) 첫 구매거나(OR) VIP인 경우
- AND
- OR
두 관점을 결합하여 문장을 추상화하면 다음과 같다
age >= 18 AND (purchaseCount == 0 OR level == VIP)
이제 조건을 평가하기만 하면 된다. 하지만 조건 연산자엔 우선 순위가 있을 수 있다. 이런 경우 어떻게 처리할까?
알고리즘을 열심히 공부했다면 수식을 트리 형태로 표현하고 재귀 호출로 계산하는 방법을 본적이 있을 것이다. 이와 비슷한 방법으로 조건을 트리 형태로 표현할 수 있다.
AND
┌────┴─────────┐
(age ≥ 18) OR
┌────────┴─────────┐
(purchaseCount == 0) (level == VIP)
위와 같이 트리를 만들었다면 재귀적으로 평가할 수 있다. 트리의 각 노드는 조건식과 조건 연산자로 구성되어 있으며, 자식 노드를 가질 수 있다. 자식 노드가 없을 경우 Leaf 노드라고 부르며, 자식 노드가 있을 경우 Composite 노드라고 부른다.
- Composite 노드
AND, OR, NOT과 같은 복합 조건을 나타내며 자식 노드를 가질 수 있다. - Leaf 노드
단일 조건을 나타내며 자식 노드를 가지지 않는다. 특정 속성(attribute)과 연산자(operator), 값(value)이 결합된 식을 포함한다. 예를 들어 "사용자의 나이 >= 18"과 같은 조건을 표현할 수 있다.
이러한 구조는 Composite 패턴을 활용한 것으로, 단순한 조건부터 복잡한 조건까지 일관된 방식으로 표현하는 것이 가능하다.
앞서 문장을 추상화하고 트리라는 구조로 표현한 것처럼 추상적, 구조적인 사고는 다양한 곳에 활용할 수 있다. 이러한 감각을 익혀나가면 복잡한 문제를 해결하는 데 큰 도움이 되므로 기회가 된다면 연습해보길 권장한다.
모델 설계
조건 트리에 대해 이해했다면 모델 설계와 구현은 어렵지 않다. 조건 트리는 Composite 패턴을 활용했기 때문에 거의 그대로 설계할 수 있다. 다만, Leaf 노드에서 속성과 식을 어떻게 표현할지만 고민하면 된다. 그리고 조건 트 리를 평가하기 위한 로직도 필요하다.
먼저 조건 트리에 대한 도식을 그려보자.

위 도식은 조건 트리의 기본 구조를 나타낸다. 각 모델이 어떤 역할을 하는지 살펴보자.
ConditionNode
: 조건 트리의 기본 노드로, Leaf 노드와 Composite 노드에 대한 인터페이스다.LeafCondition
: Leaf 노드로, 단일 조건을 나타낸다. 속성(attribute), 연산자(operator), 값(value)을 포함한다.CompositeCondition
: Composite 노드로, 복합 조건을 나타낸다. 자식 노드를 가질 수 있으며, AND, OR, NOT과 같은 논리 연산자를 포함한다.
위와 같이 조건 트리 모델을 설계할 수 있다. 하지만 해당 모델은 데이터베이스 환경이 고려되지 않았다. 만약 RDB를 사용한다면 객체-관계 불일치1로 인해 별도로 엔티티 모델을 설계해야 한다.2 다음은 조건 트리 모델을 데이터베이스에 저장하기 위한 엔티티 모델이다.

조건 트리 자체가 크게 복잡하지 않으므로 ConditionNodeEntity
하나로 모든 노드를 표현할 수 있다. Leaf 노드와 Composite 노드를 구분하기 위해 NodeType
을 추가했고 parent_id
를 통해 부모 노드를 참조할 수 있도록 했다. 이를 통해 트리 구조를 표현할 수 있다.
구현
설계는 일종의 계획이다. 계획을 세웠으니 이제 실제 구현을 해보자. 이 글에서는 Kotlin을 사용하여 조건 트리를 구현할 것이다. 그리고 특정 프레임워크를 고려하지 않으므로 일종의 의사 코드라고 생각하고 보는 것을 추천한다. 실제 구현은 사용하는 프레임워크에 맞게 조정해야 한다.
모델 구현
앞서 소개한 예시처럼 사용자 정보에 대한 속성을 기반으로 조건 트리 모델을 만들어보자.
sealed interface ConditionalNode{
val id: Long?
}
data class LeafCondition(
override val id: Long?,
val attribute: String,
val operator: ConditionOperator,
val value: String,
val valueType: String
) : ConditionalNode {
enum class ConditionOperator {
EQ,
NEQ,
GT,
GTE,
LT,
LTE
}
}
data class CompositeCondition(
override val id: Long?,
val logic: LogicalOperator,
val children: List<ConditionalNode>
) : ConditionalNode {
enum class LogicalOperator {
AND,
OR,
NOT
}
}
위 코드는 조건 트리의 기본 구조를 나타낸다. ConditionalNode
를 인터페이스로 정의하여 LeafCondition
과 CompositeCondition
을 구현했다. 앞서 설계한 내용과 크게 다르지 않다.
Evaluator 구현
이어서 조건 트리를 평가하기 위한 ConditionEvaluator
를 구현해보자.
fun interface AttributeResolver {
fun resolve(attribute: String): Any?
}
class ConditionEvaluator {
fun evaluate(condition: ConditionalNode, resolver: AttributeResolver): Boolean {
return when (condition) {
is LeafCondition -> evaluateLeaf(condition, resolver)
is CompositeCondition -> {
val results = condition.children.map { evaluate(it, resolver) }
when (condition.logic) {
CompositeCondition.LogicalOperator.AND -> results.all { it }
CompositeCondition.LogicalOperator.OR -> results.any { it }
CompositeCondition.LogicalOperator.NOT -> results.singleOrNull()?.not() ?: false
}
}
}
}
// ...
}
ConditionEvaluator
는 조건 트리를 평가하는 역할을 한다. evaluate
메서드는 조건 노드의 타입에 따라 적절한 평가 로직을 호출한다. Leaf 노드인 경우 evaluateLeaf
메서드를 호출하여 조건식을 평가하고, Composite 노드인 경우 자식 노드를 재귀적으로 평가한다.
여기서 AttributeResolver
는 평가할 속성에 대한 값을 제공하는 인터페이스다. 이를 통해 조건 트리에서 사용되는 속성(attribute)을 동적으로 제공할 수 있다. evaluateLeaf
메서드는 다음과 같이 구현할 수 있다.
class ConditionEvaluator {
// ...
@Suppress("UNCHECKED_CAST")
private fun evaluateLeaf(leaf: LeafCondition, resolver: AttributeResolver): Boolean {
// 1. 평가할 속성 값을 조회하고 속성이 없다면 조건을 만족하지 않음
val actual = resolver.resolve(leaf.attribute) ?: return false
// 2. String 타입인 조건 값을 변환
val expected: Any? = try {
convertValue(leaf.value, leaf.valueType)
} catch (e: Exception) {
return false
}
// 3. 변환된 조건 값과 실제 속성 값을 비교 평가
return when (leaf.operator) {
LeafCondition.ConditionOperator.EQ -> actual == expected
LeafCondition.ConditionOperator.NEQ -> actual != expected
LeafCondition.ConditionOperator.GT,
LeafCondition.ConditionOperator.GTE,
LeafCondition.ConditionOperator.LT,
LeafCondition.ConditionOperator.LTE -> {
if (actual !is Comparable<*> || expected !is Comparable<*>) return false
if (actual::class != expected::class) return false
val left = actual as Comparable<Any>
val right = expected as Comparable<Any>
when (leaf.operator) {
LeafCondition.ConditionOperator.GT -> left > right
LeafCondition.ConditionOperator.GTE -> left >= right
LeafCondition.ConditionOperator.LT -> left < right
LeafCondition.ConditionOperator.LTE -> left <= right
else -> false
}
}
}
}
// String 타입인 조건 값을 변환하는 메서드
private fun convertValue(value: String, type: String): Any? {
if (value == null || type == null) return null
return when (type) {
"Long" -> value.toLongOrNull()
"Int" -> value.toIntOrNull()
"Boolean" -> value.toBooleanStrictOrNull()
"Double" -> value.toDoubleOrNull()
"String" -> value
else -> throw IllegalArgumentException("Unsupported type: $type")
}
}
}
내용이 장황하지만 하나씩 살펴보면 어렵지 않다. evaluateLeaf
메서드는 다음과 같은 순서로 진행된다.
AttributeResolver
를 통해 평가할 속성 값을 조회한다. 만약 속성이 없다면 조건을 만족하지 않는 것으로 간주한다.- 조건 값을
String
타입으로 사용하므로convertValue
메서드를 통해 변환한다. 변환할 수 없다면 조건을 만족하지 않는 것으로 간주한다. - 변환된 조건 값과 실제 속성 값을 비교하여 평가한다.
여기까지 구현했다면 조건 트리를 평가할 수 있는 기본적인 구조는 완성됐다. 하지만 아직 부족한 점이 있다. 바로 조건 트리를 데이터베이스에 저장하고 불러오는 기능이다.
엔티티 모델 구현
조건 트리를 RDB 데이터베이스에 저장하기 위해서는 엔티티 모델을 구현해야 한다. 앞서 설계한 도식에 따라 ConditionNodeEntity
를 구현해보자.
@Table(name = "condition_nodes")
data class ConditionNodeEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
val targetId: Long? = null, // 조건이 적용되는 대상 ID
val parentId: Long? = null,
val type: NodeType,
// LEAF
val attribute: String? = null,
val operator: LeafCondition.ConditionOperator? = null,
val value: String? = null,
val valueType: String? = null,
// COMPOSITE
val logic: CompositeCondition.LogicalOperator? = null
) {
enum class NodeType {
LEAF,
COMPOSITE
}
}
모델 자체가 간단하므로 쉽게 구현할 수 있다. 이제 이어서 데이터베이스에 저장된 조건 트리를 불러와 실제 조건 트리로 변환하는 로직을 구현해보자.
Tree Loader 구현
조건 트리를 데이터베이스에서 불러와 트리를 구성하기 위해 ConditionTreeLoader
를 구현해보자.
class ConditionTreeLoader(
private val conditionNodeRepository: ConditionNodeRepository
) {
fun load(targetId: Long): ConditionalNode? {
// 1. 대상에 걸린 모든 조건 노드를 조회
val allNodes = conditionNodeRepository.findByTargetId(targetId)
val nodeMap = allNodes.associateBy { it.id }
// 2. 부모 노드가 없는 조건 노드를 찾는다.
val root = allNodes.firstOrNull { it.parentId == null } ?: return null
// 3. 조건 트리를 구성하여 반환
return buildTree(root, nodeMap)
}
// 재귀적으로 트리를 구성하는 메서드
private fun buildTree(root: ConditionNodeEntity, nodeMap: Map<Long, ConditionNodeEntity>): ConditionalNode {
return when (root.type) {
ConditionNodeEntity.NodeType.LEAF -> {
LeafCondition(
id = root.id,
attribute = root.attribute!!,
operator = root.operator!!,
value = root.value!!,
valueType = root.valueType!!.let {
when (it) {
"Long" -> Long::class
"Int" -> Int::class
"Boolean" -> Boolean::class
"Double" -> Double::class
"String" -> String::class
else -> throw IllegalArgumentException("Unsupported type: $it")
}
}
)
}
ConditionNodeEntity.NodeType.COMPOSITE -> {
val children = nodeMap.values.filter { it.parentId == root.id }
CompositeCondition(
id = root.id,
logic = root.logic!!,
// 재귀적으로 자식 노드를 구성
children = children.map { buildTree(it, nodeMap) }
)
}
}
}
}
먼저 루트 노드를 찾고, 그 노드의 자식 노드를 재귀적으로 구성하여 트리를 만든다. 이때, 성능 최적화를 위해 nodeMap
을 구성하면 부모 노드에 대한 자식 노드를 쉽게 찾을 수 있다.
사용 예시
이제 실제로 데이터베이스에서 조건 트리를 불러와 평가하는 예시를 살펴보자. 다음은 데이터베이스 없이 조건 트리를 평가하는 예시다.
fun main() {
// 1. 먼저 조건 트리를 구성한다.
val condition = CompositeCondition(
id = null,
logic = CompositeCondition.LogicalOperator.AND,
children = listOf(
LeafCondition(
id = null,
attribute = "age",
operator = LeafCondition.ConditionOperator.GTE,
value = "18",
valueType = Long::class
),
LeafCondition(
id = null,
attribute = "purchaseCount",
operator = LeafCondition.ConditionOperator.GT,
value = "5",
valueType = Long::class
)
)
)
// 2. 평가할 속성 값을 제공하는 AttributeResolver를 생성한다.
val resolver = AttributeResolver { attr ->
when (attr) {
"age" -> 20
"purchaseCount" -> 10
"level" -> "VIP"
else -> null
}
}
// 3. 조건 트리를 평가한다.
val evaluator = ConditionEvaluator()
val result = evaluator.evaluate(condition, resolver)
// 4. 평가 결과를 출력한다.
println("Evaluation Result: $result") // true
}
다음으로 데이터베이스를 사용하여 조건 트리를 평가하는 예시를 살펴보자. 만약 Spring을 사용한다면 ConditionTreeLoader
와 ConditionEvaluator
를 Bean으로 등록하여 DI를 통해 사용할 수 있다. 다음 코드는 Spring을 사용한 예시다.
@Service
class CouponConditionService(
private val conditionTreeLoader: ConditionTreeLoader,
private val conditionEvaluator: ConditionEvaluator
) {
fun canExpose(targetId: Long): Boolean {
// 1. 조건 트리 로더를 통해 조건 트리를 불러온다.
val conditionTree = conditionTreeLoader.load(targetId)
// 2. 평가할 속성 값을 제공하는 AttributeResolver 구현
val attributeResolver = AttributeResolver { attribute ->
when (attribute) {
"age" -> 20
"purchaseCount" -> 3
"level" -> "SILVER"
else -> null
}
}
// 2. 조건 트리를 평가한다.
return conditionEvaluator.evaluate(conditionTree, attributeResolver)
}
}
Rule Engine과 차이점
조건 트리는 얼핏 보면 Rule Engine과 유사해 보일 수 있다. 둘 다 어떤 조건을 평가하고, 그 결과를 바탕으로 후속 동작을 수행한다. 하지만 실제로는 구조도, 역할도, 활용 범위도 전혀 다르다.
조건 트리는 하나의 조건 트리만을 평가한다. 즉, 하나의 조건 트리는 하나의 Boolean 결과값(true 또는 false)을 반환한다. 반면, Rule Engine은 수십 개에서 수천 개의 규칙을 동시에 평가한다. Rule Engine은 여러 개의 규칙이 동시에 만족될 수 있기 때문에, 어떤 규칙을 우선 실행할지 결정하기 위한 우선순위, 충돌 해소 전략 등의 개념이 필요하다.
그리고 조건 트리는 단순히 조건을 평가하는 데 중점을 두지만, Rule Engine IF 조건이 만족되면 THEN에 해당하는 액션을 실행한다는 형식을 따르며, Rule Engine은 이 조건들을 모두 탐색하고 실행 순서를 조율한다.
정리하자면 조건 트리는 주로 다음과 같은 상황에서 사용된다.
- 단일 조건식으로 판단해야 할 때
- 복잡한 조건을 관리자 화면에서 간단히 조정하고 싶을 때
- 특정 시점에만 평가되며, 즉시 결과를 얻고 싶을 때
반면, Rule Engine은 다음과 같은 상황에서 유용하다.
- 수백 개 이상의 규칙을 동시에 평가해야 할 때
- 규칙 간의 의존성이나 추론(Chaining)이 필요한 경우
- IF-THEN 구조로 규칙에 기반한 복잡한 비즈니스 로직을 구현해야 할 때
마치며
이번 글에서는 조건이라는 요구사항을 추상화하는 방법과 조건 트리 모델을 설계하는 방법에 대해 설명했다. 조건 트리는 복잡한 조건을 평가하기 위한 강력한 도구로, 다양한 분야에서 활용될 수 있다.
또한 조건 트리는 관리자가 직접 조건을 편집할 수 있는 유연성을 제공한다. 또한, 런타임 도중에 조건을 변경할 수 있어 다양한 상황에 대응할 수 있다.
이처럼 잘 설계된 모델은 제품을 좀 더 유연하고 확장 가능하게 만들어준다.