유연하고 확장 가능한 배너 기능 구현하기

김선우 · 토스증권 Server Developer
2024년 7월 29일

토스증권에서는 배너를 사용해서 프로모션을 홍보하는데요. 서비스 초기에는 배너를 노출하는 요구사항이 단순했기 때문에 확장성을 크게 고려하지 않은 구조로 빠르게 구현했습니다. 하지만 요구사항은 점점 복잡해졌고 새로운 요구사항이 추가될 때마다 if, else로 인한 들여쓰기가 점점 깊어졌습니다.

이런 상황이 지속되자, 코드의 히스토리를 잘 아는 사람이 아니면 배너 기능을 수정하기가 어려웠고 배너 코드는 두려움의 대상이 되었어요. 점차 다양한 지면에서 복잡한 노출 조건으로 배너를 띄워야 하는 요구사항이 생기기 시작했고, 이를 해결하기 위해 똑똑한 배너, 즉 인텔리전스 시스템을 만들기 시작했습니다.

오늘은 다양한 고객에게 맞춤형 배너를 노출하기 위해 토스증권이 만든 인텔리전스 시스템을 소개해드릴게요.

인텔리전스 시스템

인텔리전스 시스템에서 해결해야 하는 문제를 요약하면 다음과 같아요. 각 문제를 어떻게 해결했는지 자세히 설명드릴게요.

  • 다양한 지면에 노출이 필요합니다. 지면은 언제든 추가될 수 있습니다.
  • 배너에 필요한 정보를 다양한 데이터 소스로부터 가져올 수 있어야 합니다.
  • 노출 조건을 검사하여 유저에게 적절한 배너를 제공해야 합니다.

유연하게 변경하고 추가하는 배너 지면

인텔리전스 배너는 토스증권의 여러 지면에 유연하게 노출될 수 있어야 합니다. 토스증권에서는 아래 보이는 사진처럼 다양한 지면에 목적에 맞는 배너를 내려주고 있습니다.

각 지면에 노출될 인텔리전스 배너에는 공통된 특성도 있지만, 각 지면에 맞춘 커스터마이징이 필요해요. 공유할 부분은 공유하되, 각각의 인텔리전스 배너마다 필요한 부분을 구분할 수 있도록 position이라는 필드를 추가했습니다.

간단해 보이는 방법이지만 이 필드를 추가한 덕분에 재사용 가능한 코드를 쉽게 작성할 수 있어요. 클라이언트가 특정 지면에 표시할 배너를 position 정보와 함께 요청하면, 서버는 내부적으로 해당 position의 인텔리전스를 제공하는 핸들러를 찾아 함수를 실행합니다. 이를 코드로 간략히 표현하면 다음과 같습니다.

enum class IntelligencePosition {
    MAIN,           // 메인 인텔리전스 배너
    ONBOARDING,     // 온보딩 인텔리전스 배너
}

// 인텔리전스 배너 구현체들이 공통으로 사용하는 인터페이스 
interface Intelligence {
    val position: IntelligencePosition
}

// 메인 인텔리전스 배너 구현체 
data class MainIntelligence(
    override val position: IntelligencePosition,
    val spelExpression: String, // 해당 배너 노출 여부를 결정하는데 사용됩니다. 
    val priority: Int // 배너 간 우선순위를 결정하는데 사용됩니다. 
    ...
) : Intelligence

// 온보딩 인텔리전스 배너 구현체 
data class OnboardingIntelligence(
    override val position: IntelligencePosition,
    val spelExpression: String,
    val priority: Int
    ... 
) : Intelligence

// 인텔리전스를 요청할 때 사용 
data class IntelligenceRequest(
    val position: IntelligencePosition
)

interface IntelligenceHandler {
    val position: IntelligencePosition
    
    // UserDataSource는 인텔리전스 생성 및 조건 검사에 필요한 데이터를 들고있습니다. 
    fun get(userDataSource: UserDataSource): Intelligence
}

@Component 
class MainIntelligenceHandler(...) : IntelligenceHandler {
    override val position: IntelligencePosition = IntelligencePosition.MAIN

    override fun get(dataSource: UserDataSource): MainIntelligence {
        // 메인 배너 커스텀 로직 
    }
}

@Component 
class OnboardingIntelligenceHandler(...) : IntelligenceHandler {
    override val position: IntelligencePosition = IntelligencePosition.ONBOARDING

    override fun get(dataSource: UserDataSource): OnboardingIntelligence {
        // 온보딩 배너 커스텀 로직 
    }
}

@Service
class IntelligenceService(handlers: List<IntelligenceHandler>) {
    private val handlerByPosition: Map<IntelligencePosition, IntelligenceHandler> =
        handlers.associateBy { it.position }

    fun get(request: IntelligenceRequest): Intelligence {
        ... 
		    
        return handlerByPosition[request.position]?.get(dataSource)
            ?: throw IllegalArgumentException("해당 지면의 인텔리전스 핸들러를 찾을 수 없습니다: ${request.position}")
    }
}

우선, 인텔리전스 배너를 그리는 데 필요한 데이터를 제공하는 get 함수를 IntelligenceHandler 인터페이스에 정의했습니다. get 함수의 구현은 각 지면과 관련된 핸들러(MainIntelligenceHandler, OnboardingIntelligenceHandler)에서 담당합니다.

IntelligenceServiceIntelligenceRequestposition을 보고 적절한 IntelligenceHandler을 선택하여 요청을 처리합니다. 새로운 지면이 추가되면 IntelligenceHandler를 구현한 새로운 핸들러만 추가하면 됩니다. 덕분에 지면에 대한 요구사항이 계속 늘어나도 쉽게 새로 추가할 수 있고, 유지보수도 훨씬 쉬워진 것이죠.

다양한 소스에서 불러오는 데이터

다음으로는 인텔리전스 배너에 필요한 데이터를 어떻게 가져올 수 있는지 알아볼게요. 유저의 이름을 표시하거나, 이벤트로 받은 주식명을 보여주는 등 유저 맞춤형 데이터로 인텔리전스 배너를 구성하는데요. 이런 다양한 데이터는 다양한 소스로부터 수집합니다. 이를 위해 다음과 같은 UserDataSource 클래스를 정의할 수 있습니다(실제로는 더 많은 필드가 존재합니다).

data class UserDataSource(
		// 로그인 유저 정보 
    val loginUser: LoginUser?, 

    // 외부 API를 호출해서 정보를 취득해야하는 경우 
    val remoteDelegatedDataSource: Map<RemoteDelegatedType, IntelligenceTargetResponse> = mapOf() 

    ... 
)

data class IntelligenceTargetResponse(
	// 인텔리전스 노출 대상자 여부 
	val target: Boolean,
	
	// 인텔리전스를 생성하는데 필요한 데이터 
	val templateMap: Map<String, String>? = null, 
	
	... 
)

enum class RemoteDelegatedType(val desc: String) {
    API_1("API 1 호출"), 
    API_2("API 2 호출"), 
    API_3("API 3 호출")
    ... 
}

remoteDelegatedDataSource는 외부 API를 호출하여IntelligenceTargetResponse객체를 받아 인텔리전스를 그리는 데 필요한 데이터를 저장하는 필드입니다. 여기서RemoteDelegatedType은 사용하고자하는 외부 API를 식별하기 위한 enum입니다.

UserDataSource는 사용하기 전에 다음과 같이 초기화합니다.

// UserDataSource 초기화 
UserDataSource(
		loginUser = getLoginUser(), 
    remoteDelegatedDataSource = mapOf(
        RemoteDelegatedType.API_1 to callApi1(),
        RemoteDelegatedType.API_2 to callApi2(),
        RemoteDelegatedType.API_3 to callApi3(),
        ...
    )
)

하지만 이 구조에서는UserDataSource를 초기화할 때 모든 데이터를 미리 가져와야 해서 불필요한 외부 API 호출이 발생할 수 있어요. 예를 들어, RemoteDelegatedType.API_1의 응답만 필요한 경우 callApi2()callApi3()는 호출할 필요가 없죠. 이를 개선하기 위해, 필요한 시점에 데이터를 가져올 수 있도록 UserDataSource의 구조를 아래와 같이 변경했습니다.

data class UserDataSource(
		// 로그인된 유저 정보 
    val loginUser: LoginUser?, 

    // 외부 API를 호출해서 정보를 취득해야하는 경우 
    val remoteDelegatedDataSource: Map<RemoteDelegatedType, LazyContext<IntelligenceTargetResponse>> = mapOf()

    ... 
)

data class LazyContext<T>(
    private val builder: suspend CoroutineScope.() -> T
) {
    private var data: T? = null
    private var initialized: Boolean = false

    private fun init(data: T?) {
        init()
        this.data = data
    }

    private fun init() {
        this.initialized = true
    }

    @Synchronized
    fun get(): T? {
        return runCatching {
            if (!initialized) runBlocking(block = builder)
                .also { init(it) } else data
        }.onFailure {
            init()
        }.getOrNull()
    }
}

LazyContext는 인자로 넘겨진 함수(builder)를 바로 실행하지 않고 get() 함수가 호출되는 시점에 실행하게 됩니다. LazyContext를 활용하면 아래와 같이 UserDataSource를 생성할 때 API가 바로 호출되지 않고, 실제로 데이터가 필요한 시점에 API 호출이 이루어지도록 할 수 있습니다. 이런 구조에서는 새로운 API를 호출 비용 부담없이 추가할 수 있고, UserDataSource가 커지더라도 필요한 데이터만 불러올 수 있어요.

UserDataSource(
    remoteDelegatedDataSource = mapOf(
        RemoteDelegatedType.API_1 to LazyContext { callApi1() },
        RemoteDelegatedType.API_2 to LazyContext { callApi2() },
        RemoteDelegatedType.API_3 to LazyContext { callApi3() },
        ...
    )
)

복잡한 노출 조건

토스증권의 배너 노출 조건은 매우 다양하고, 새로운 기능이 추가될 때마다 조건도 함께 늘어납니다. 아래는 토스증권에서 실제로 사용하는 몇 개의 배너 노출 조건인데요. 이 3개 외에도 수 많은 노출 조건이 있습니다.

  • 휴장일일 경우, 휴장일 배너를 보여주기
  • 유저가 이벤트 대상자라면, 이벤트 관련 배너를 표시하기
  • 배너 노출 횟수가 N회 미만인 유저에게만 배너를 보여주기

유연하고 확장 가능한 노출 로직을 어떻게 효과적으로 구현할 수 있을까요? 커스텀 DSL(domain-specific language)을 사용할 수도 있지만 이는 자칫 유지보수가 어려운 코드를 만들 수가 있습니다. 그래서 토스증권에서는 인텔리전스의 노출 로직을 DSL 대신, 이미 많은 레퍼런스와 충분히 검증된 SpEL(Spring Expression Language)을 사용하기로 결정했습니다. SpEL은 런타임에 객체에 대한 쿼리 및 조작을 지원하는 표현 언어(expression language)로 Spring에서 지원하는 기능입니다.

인텔리전스의 노출 여부를 결정하는 로직을 담은 인터페이스와 그 구현체들을 아래와 같이 정의할 수 있습니다.

// 인텔리전스 노출 로직을 구현하는 클래스의 공통 (marker) 인터페이스 
interface IntelligenceCondition

// 유저 id가 짝수인 경우에만 인텔리전스를 노출 
@Component("isUserIdEven")
class IsUserIdEven : IntelligenceCondition {

    fun check(userDataSource: UserDataSource): Boolean {
        return userDataSource.loginUser?.userId?.rem(2)?.toInt() == 0
    }
}

// 외부 API를 호출해서 인텔리전스 노출 대상자인지를 판별 
@Component("remoteDelegatedCondition")
class RemoteDelegatedCondition : IntelligenceCondition {
    fun check(
        userDataSource: UserDataSource,
        target: RemoteDelegatedType
    ): Boolean {
        val response = userDataSource.remoteDelegatedDataSource[target]?.get()
        return response?.target == true
    }
}

인텔리전스 배너의 노출 로직(IntelligenceCondition)을 구현하는 클래스들은 check 함수를 제공해요. 예를 들어 위 코드에 보이는 IsUserIdEven 클래스의 check 함수는 유저 ID가 짝수인지 확인해요. 하지만 각 클래스의 check 함수는 인자가 다르기 때문에 IntelligenceCondition인터페이스에 check를 정의하지 않았습니다. 이러한 check 함수는 나중에 리플렉션을 통해 호출될 예정입니다.

이와 같이 조건을 정의하고 SpEL로 검사하려면 몇 가지 준비 작업이 필요합니다. 먼저, 노출 로직을 검사하는 데 사용할 object를 정의해보겠습니다.

object ExpressionParser {
    val context: StandardEvaluationContext = StandardEvaluationContext()
    val evaluator: SpelExpressionParser = SpelExpressionParser()
}

StandardEvaluationContext는 SpEL 파싱에 필요한 컨텍스트를 제공하는 데 사용됩니다. SpelExpressionParser는 SpEL 파싱에 사용되며, 스레드에 안전하기 때문에 Kotlin object에서 공유하여 사용할 수 있습니다.

인텔리전스 배너 노출 로직을 검사할 때 필요한 데이터를 저장할 수 있는 클래스를 아래와 같이 정의해보겠습니다.

data class RootObject(
    val dataSource: UserDataSource,
    ... 
) {
    fun generateArgs(vararg params: Class<*>): Array<Any> {
        return listOf(UserDataSource::class.java)
            .filter { params.contains(it) }
            .mapNotNull {
                when (it) {
                    UserDataSource::class.java -> dataSource
                    ...
                    else -> null
                }
            }.toTypedArray()
    }
}

다음으로는 ExpressionParser가 인텔리전스의 노출 조건을 인식할 수 있도록 설정하는 방법에 대해 알아보겠습니다.

@Configuration
class ExpressionParserInitializer(
    beanFactory: ListableBeanFactory
) {
    init {
        val context = ExpressionParser.context
        context.setBeanResolver(BeanFactoryResolver(beanFactory))
        context.typeConverter = StandardTypeConverter()
        // SpEL에서 사용할 추가적인 변수 설정.
        context.setVariables(
            mapOf(
                "remoteDelegatedType" to RemoteDelegatedType::class.java
            )
        )

        // SpEL에서 사용할 IntelligenceCondition 구현체들의 check 함수를 추가
        val conditionByName = beanFactory.getBeansOfType(IntelligenceCondition::class.java)
            .map { (k, v) -> k to (v to v.javaClass.methods.firstOrNull { method -> method.name == "check" }) }
            .toMap()
        context.addMethodResolver(reflectiveMethodResolver(conditionByName))
    }

    private fun reflectiveMethodResolver(
        objAndMethodByName: Map<String, Pair<Any, Method?>>,
    ) = object : ReflectiveMethodResolver() {
        override fun resolve(
            context: EvaluationContext,
            targetObject: Any,
            name: String,
            argumentTypes: MutableList<TypeDescriptor>
        ): MethodExecutor? {
            val target = objAndMethodByName[name] ?: return null
            val (obj, targetMethod) = target
            return targetMethod?.let { method ->
                MethodExecutor { _, root, arguments ->
                    val rootObject = root as? RootObject
                    val args =
                        arrayOf(*(rootObject?.generateArgs(*method.parameterTypes) ?: arrayOf()), *arguments)
                    TypedValue(
                        try {
                            method.invoke(obj, *args)
                        } catch (e: Exception) {
                            throw e
                        }
                    )
                }
            }
        }
    }
}

ExpressionParserInitializer 클래스는 SpEL(Spring Expression Language)을 활용해 인텔리전스 노출 조건을 검사하는 데 필요한 추가적인 변수(setVariables)와 함수(addMethodResolver)를 설정합니다. 위 설정을 추가하면 remoteDelegatedCondition(#remoteDelegatedType.API_1)와 같은 SpEL 표현을 평가할 수 있습니다. 마지막으로reflectiveMethodResolver를 사용하여 위에서 정의한 IntelligenceCondition 구현체들의 check 함수를 통해 SpEL을 평가할 수 있도록 설정하였습니다.

이제 remoteDelegatedCondition(#remoteDelegatedType.API_1)isUserIdEven() 같은 SpEL 표현을 손쉽게 검사할 수 있습니다. 이러한 SpEL 문자열을 검사하는 kotlin object를 아래와 같이 정의할 수 있습니다.

object SpelExpressionEvaluator {
    fun evaluate(spelExpr: String, userDataSource: UserDataSource): Boolean {
        return runCatching {
            ExpressionParser.evaluator.parseExpression(spelExpr)
                .getValue(ExpressionParser.context, RootObject(userDataSource), Boolean::class.java) ?: false
        }.onFailure {
            println("SpEL 문자열을 평가하는데 실패하였습니다: $spelExpr, $it")
        }.getOrElse {
            false
        }
    }
}

이 작업을 마치면, 다음과 같이 SpEL 문자열을 이용하여 인텔리 노출을 쉽게 제어할 수 있습니다.

@Component
class MainIntelligenceHandler : IntelligenceHandler {
    override val position: IntelligencePosition = IntelligencePosition.MAIN

    override fun get(userDataSource: UserDataSource): MainIntelligence {
        val intelligence1 = MainIntelligence(
            position = IntelligencePosition.MAIN,
            spelExpression = "true",
            priority = 1
        )
        val intelligence2 = MainIntelligence(
            position = IntelligencePosition.MAIN,
            spelExpression = "false",
            priority = 2
        )
        val intelligence3 = MainIntelligence(
            position = IntelligencePosition.MAIN,
            spelExpression = "remoteDelegatedCondition(#remoteDelegatedType.API_1)",
            priority = 3
        )

        return listOf(intelligence1, intelligence2, intelligence3)
            .filter { SpelExpressionEvaluator.evaluate(it.spelExpression, userDataSource) }
            .maxByOrNull { it.priority }
            ?: throw IllegalArgumentException("노출 가능한 인텔리가 존재하지 않습니다: $position")
    }
}

마무리

이렇게 인텔리전스 시스템을 구현했기 때문에 이제 새로운 지면이 필요하면 Intelligence 구현체를 추가하고, 새로운 데이터소스가 필요하면 UserDataSource를 활용하고, 새로운 노출 로직이 필요하면 IntelligenceCondition 구현체를 추가하면 됩니다. 계속해서 if, else를 사용해 요구사항을 구현하는 것보다 코드 가독성도 높아졌고 변경 가능성이 높은 부분을 확장 가능하게 구현한 덕분에 예상하지 못한 요구사항이 들어와도 빠르게 대응할 수 있게 되었습니다.

이 글이 다양한 요구사항을 수용할 수 있는 기능을 개발해야 하는 서버 개발자분들께 조금이라도 도움이 됐길 바랍니다.

댓글 0댓글 관련 문의: toss-tech@toss.im
연관 콘텐츠
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.