요약: 이 글은 배포 일정과 릴리즈를 분리하고 유저 타겟팅을 적용하는 등의 이점을 가진 피처 플래그를 개발하며 직면한 문제 해결 여정을 다룹니다. 어드민 서버와 서빙 서버를 분리했지만, 데이터 동기화 문제가 발생했습니다. 이를 해결하기 위해 Redis Pub/Sub을 활용하고, 안정성을 보장하기 위해 polling을 백업 플랜으로 도입하는 등 트레이드오프를 고려한 아키텍처 설계 경험까지 공유합니다.
💡 리뷰어 한줄평
tei.fxpark Client 요청에 대한 성능과 실시간 어드민 데이터 반영에 대한 두 마리 토끼를 잡기 위한 많은 고민의 흔적이 느껴집니다. 목적에 맞게 애플리케이션의 관심사를 분리하여 서로 다른 목적의 애플리케이션을 어떻게 동기화시킬 수 있을지에 대해서 좋은 참고 자료가 될 수 있으니 글을 읽어보고 같이 고민해 보면 좋겠습니다.
will.109 ‘데이터 동기화에 대해 이런 식으로도 문제를 해결할 수 있구나’라는 것을 알 수 있어서 흥미로웠습니다. 비슷한 고민을 하시는 분들께 도움이 될 거라 생각됩니다.
시작하며
안녕하세요 데이터서비스팀에서 A/B Test1와 원활한 배포 및 장애 대응을 위한 피처 플래그 플랫폼(이하 피처 플래그)을 개발하고 있는 혀니입니다.
저희 팀에서는 카카오페이의 피처 플래그를 추가적인 코드 변경 및 배포 없이 런타임에 특정 기능을 켜고 끌 수 있는 기술
2로 정의하였습니다.
피처 플래그에서 관리하는 하나의 기능은 토글 스위치 처럼 ON
이거나 OFF
상태를 가질 수 있습니다. 피처 플래그를 사용하는 A서비스에서 개발한 신규 기능이 피처 플래그 ON
상태일 때만 동작하도록 구현되어 있다고 가정해 보겠습니다.
if (featureFlagClient.isOn()) {
// A 서비스의 신규 기능
}
신규 기능 수행 중 에러가 지속적으로 발생한다면 ON
상태에서 OFF
상태로 변경3하는 것만으로도 해당 로직이 수행되지 않도록 제어할 수 있습니다.
하지만 이러한 상태 변경이 실시간으로 이뤄지지 않으면 어떻게 될까요? 피처 플래그 사용자에게 최신의 ON/OFF 응답을 내릴 때까지 에러는 지속될 것입니다. 이처럼 피처 플래그는 ON/OFF 상태를 포함한 다양한 설정 정보가 실시간으로 사용자에게 반영돼야 합니다.
이 글에서는 피처 플래그 개발 과정에서 발생한 다양한 문제 중, 특히 두 서버 간의 실시간 데이터 동기화에 대한 해결 과정을 소개하고자 합니다.
예제 코드는 kotlin, Spring MVC, Spring WebFlux, Coroutine 등을 활용하여 작성됩니다. 관련 개념을 알고 있다면 내용을 이해하는데 큰 도움이 될 수 있습니다.
요구사항 정의
먼저 피처 플래그를 기획할 당시 서버, 클라이언트(안드로이드, IOS) 등 다양한 곳에서 사용할 것으로 예상하여 높은 트래픽 처리
에 용이한 아키텍처를 고려하였습니다.
카카오페이에서 피처 플래그를 활용할 수 있는 사례 기반으로 요구사항을 정리해 보았습니다.
- 런타임에 특정 기능을 켜고 끌 수 있으므로 배포일정과 릴리즈 일정을 분리할 수 있습니다.
- 플랫폼 내에서 설정으로 제공하는 배포 비율 조정4, 유저 타겟팅5 등을 활용하여 기능을 적용할 대상을 제어할 수 있습니다.
- 신규 기능 배포 후 이슈 발생 시 코드 수정/배포 없이 런타임에 기능을 OFF 하여 빠르게 대응할 수 있습니다.
이러한 요구사항을 바탕으로 크게 두 개의 개념적인 서버가 등장했습니다.
어드민 서버
: 복잡한 피처 플래그의 설정 정보 생성 및 수정하며 데이터의 영속성 관리서빙 서버
: 사용자에게 실시간으로 빠른 응답을 내려주며 원활한 트래픽 처리
1. 설정 정보 관리를 위한 어드민 서버
먼저 피처 플래그의 설정 정보 관리를 위한 어드민 서버는 Tomcat
, Spring MVC
, Spring Data JPA
, MySQL
등을 활용하여 개발하였습니다.
피처 플래그는 단순히 특정 기능의 ON/OFF 제어뿐만 아니라 배포 비율 조정4, 유저 타겟팅5 등 다양한 설정 정보를 제공하기 때문에 매우 복잡한 로직을 가지고 있습니다.
DB에서 제공되는 트랜잭션
과 코드를 직관적으로 작성하기 용이한 동기적 처리 방식
은 복잡한 비즈니스 로직을 처리하기 위한 어드민 서버 개발에 적합하게 활용될 수 있습니다.
이러한 어드민 서버는 요청 당 하나의 스레드를 할당하여 DB 영속성과 관련된 동기적인 행위를 집약적으로 모아 처리하는 역할을 담당하게 됩니다.
그렇다면 이렇게 복잡하게 설정된 정보는 어떻게 활용되어 사용자에게 ON/OFF 결과로 전달될까요?
2. 사용자에게 적절한 응답을 내려줄 서빙 서버
피처 플래그의 설정 정보를 기반으로 런타임에 사용자에게 실시간으로 적절한 응답을 내려 줄 서빙 서버입니다.
이러한 서빙 서버는 Netty
, Spring WebFlux
, coroutine
등으로 이루어진 비동기 서버입니다.
서빙 서버는 피처 플래그 어드민 서버 운영자가 설정한 피처 플래그 설정 정보를 기반으로 런타임 중 적절한 연산을 통해 응답을 내려줍니다.
사용자에게 실시간으로 빠른 응답을 내려주기 위해 대부분의 로직이 스레드 블로킹을 최소화6하여 동작하도록 구성하였습니다.
어떻게 블로킹 요소를 최소화하여 높은 트래픽 처리가 가능할까요? 바로 피처 플래그의 설정 정보 관리를 위해 사용하는 DB에 의존하지 않는 것입니다. 데이터 정합성 측면과 트랜잭션 등의 이점을 가졌지만 I/O 병목7이 많은 DB(MySQL)를 서빙 서버가 직접적으로 의존하지 않으므로써 이를 해소할 수 있습니다.
서버 독립 장/단점 분석
위와 같이 두 개의 목적을 기반으로 서버를 구성하면 다음과 같은 장점과 단점이 있습니다.
장점
먼저 살펴보겠습니다.
명확한 책임 분리
: 어드민 서버는 피처 플래그를 설정 및 관리하는데 집중할 수 있습니다. 서빙 서버는 런타임에 사용자 요청을 분석하고 설정 정보를 기반으로 응답을 내려줍니다. 각각의 역할에 집중할 수 있기 때문에 독립적인 기능 개발 및 배포가 가능합니다.리소스 효율화
: 어드민 서버는 서빙 서버에 비하여 가벼운 트래픽을 처리하고 설정 데이터 관리에만 집중하였기 때문에 비교적 적은 리소스로 운영이 가능합니다.유연한 확장성
: 높은 트래픽을 처리하는 서빙 서버는 사용자 요청이 늘어날수록스케일 아웃
을 통해 확장에 용이한 구조를 만들 수 있습니다.
물론 단점
도 존재합니다.
데이터 동기화
: 서빙 서버는 어드민 서버의 설정 정보를 획득해야 합니다. 두 서버는 물리적으로 분리되어 있기 때문에 서빙 서버는 추가적인 통신을 통해 최신 데이터를 획득해야 합니다.높은 러닝 커브
: 어드민 서버와 서빙 서버는 다양한 기술 스택을 활용하기 때문에 개발자 역량에 따라 아키텍처의 동작 방식을 이해하는데 오랜 시간이 걸릴 수 있습니다.
몇 가지 단점 중 데이터 동기화
관점에서 많은 고민을 했습니다.
서빙 서버는 최신 데이터를 가진 DB
에 직접 접근하여 플래그의 변경 사항을 읽는 것이 아니라
어드민 서버 요청을 통해 획득
하고 이를 기반으로 사용자의 요청 정보를 판단하여 적절한 ON/OFF 응답을 내려줘야 했기 때문입니다.
독립된 두 서버의 데이터 연동
물리적으로 독립된 서빙 서버와 어드민 서버의 데이터 연동에는 다양한 방법이 존재합니다. 적용 가능한 방법의 장단점을 비교해 보고 피처 플래그의 요구사항에 적합한 방법을 찾아보았습니다.
방법 1. 어드민 서버 직접 호출
가장 간단한 방법은 서빙 서버에서 어드민 서버로 필요한 순간에 매번 요청하는 것입니다.
다만 이러한 방법은 결국 서빙 서버에서 어드민 서버의 트래픽 전이
로 이뤄질 수 있습니다.
어드민 서버는 사용자의 요청을 직접적으로 받지 않기 때문에 서빙 서버보다 적은 리소스로 운영할 수 있습니다.
만약 서빙 서버가 트래픽 대응을 위해 스케일 아웃을 진행한다면 어떻게 될까요?
자연스럽게 트래픽은 어드민 서버로 흘러들어가 부담은 배로 늘어날 것입니다.
또한 이러한 방식은 서빙 서버가 DB의 직접적인 의존을 제거하여 I/O 병목을 최소화하고자 했지만 동기적으로 처리되는 어드민 서버를 직접 의존하기 때문에 병목 지점이 DB에서 어드민 서버로 바뀐 것과 다름없게 됩니다.
방법 2. 어드민 서버 polling + 로컬 캐시
그다음은 주기적인 polling
을 통해 어드민 서버의 설정 정보를 로컬 캐시
8에 저장하는 것입니다.
polling은 앞선 방법보다는 트래픽 전이를 낮출 수 있는 방법입니다. 하지만 polling 주기마다 최신 설정 정보를 어드민 서버에서 획득하여 저장하기 때문에 다음 polling 전에 수정된 최신 설정 정보가 서빙에 반영되지 않는다는 단점이 있습니다.
polling을 활용하게 되면 어드민 설정 변경 시점과 상관없이 다음 polling이 일어날 때까지 장애가 유지될 것입니다.
또 다른 측면으로는 스케일 아웃을 통해 서빙 서버의 개수가 늘어나면 늘어날수록 polling 주기마다 어드민 서버에 요청이 몰리기 때문에 부하가 늘어나게 됩니다.
이러한 부하를 줄이기 위해 polling 주기를 늘리게 되면 주기의 최대 시간이 늘어나 최신 데이터 반영이 더욱 늦어지게 됩니다.
그렇다고 다수의 서빙 서버마다 polling 주기를 특정한 범위 내로 랜덤 하게 가져간다면 내부 로컬 캐시
에는 각기 다른 버전의 설정 정보를 가질 수 있기 때문에
사용자는 매 요청 마다 서로 다른 응답
을 바라볼 수도 있는 문제가 발생할 수 있습니다.
결국 polling 방식은 설정 변경에 빠른 반영이 필요한 경우
명확한 한계를 가지게 됩니다.
방법 3. 메시지 브로커 + 로컬 캐시
직접 호출, polling 방식은 결국 빈도수의 차이일 뿐 서빙 서버가 어드민 서버를 직접 의존하여 최신 정보를 획득해야 합니다. 그렇다면 관점을 바꿔서 어드민 서버가 설정 변경이 일어날 때 변경 사항을 서빙 서버에게 전달하는 방법은 어떨까요?
이를 메시지 브로커
를 통해 해소할 수 있습니다. 메시지 브로커는 서로 다른 시스템 간의 비동기 통신을 위한 중간 매개체
입니다.
publisher
가 메시지를 보내면, 브로커는 이를 저장하고 구독한 subscriber
에게 전달합니다.
메시지 큐나 토픽을 통해 메시지를 분류하고 관리하여 시스템 간 결합도를 낮추고,
시스템 확장성과 유연성을 높이는 데 기여합니다.
메시지 브로커를 도입하면 어드민 서버와 서빙 서버의 물리적인 의존성을 제거하고 유연성을 확보할 수 있습니다.
여기에 로컬 캐시까지 조합하게 되면 어떻게 될까요?
어드민 서버에 설정 정보 변경이 일어나게 되면 서빙 서버는 이를 subscribe 한 뒤 로컬 캐시에 적재하여 추가적인 어드민 서버 호출 없이
특정 피처 플래그의 설정 정보의 최신성을 보장할 수 있게 됩니다.
피처 플래그의 선택
독립된 두 서버 간 데이터 연동에는 다양한 방법이 있지만 두 가지 측면을 중점적으로 고려하였습니다.
- 어드민 서버의 트래픽 전이 방지
- 실시간 변경 사항 반영
위 두 가지 측면을 고려해 서버 간 데이터 연동은 메시지 브로커 기술로 선택했습니다. 메시지 브로커를 활용하면 서버 간 물리적인 의존성을 끊고 변경 사항을 통지받을 수 있기 때문입니다.
대표적인 메시지 브로커의 종류에는 Redis Pub/Sub
, RabbitMQ
, Apache Kafka
등이 있습니다. 전부를 다를 수 없지만 각각의 특징과 장단점을 정리하면 아래와 같습니다.
Redis Pub/Sub
: Redis는 In-memory 기반의 빠른 응답 속도를 제공하며 Pub/Sub 기능을 활용할 수 있습니다. 하지만 메시지 브로커가 일반적으로 제공하는 메시지 영속성, 손실 방지 등을 보장하지 않아 순서가 중요하지 않고 손실에 대한 허용도가 높은 상황에 적합할 수 있습니다.Redis Pub/Sub
은 보편적인 메시지 브로커의 기능을 제공하는 대신가볍고 빠른 메시지 전달에 특화된 기능
을 제공한다고 볼 수 있습니다.RabbitMQ
:AMQP
프로토콜을 기반으로 한 전통적인 메시지 브로커입니다. 다양한 메시지 라우팅 방식(직접 전달, 팬아웃, 토픽 기반 등)을 지원하며, 재시도, Dead-letter Exchange 등 풍부한 기능을 제공합니다. 단, 제한된 확장성 때문에 대규모 분산 시스템 환경에 적합하지 않을 수 있습니다.Apache Kafka
:분산 로그 스트리밍 플랫폼
으로 높은 처리량과 내구성을 가졌습니다. 메시지를 파티션으로 나누어 저장하고, 복제를 통해 내결함성을 높입니다. 스트림 처리에 최적화되어 있으며, 실시간 데이터 처리 파이프라인 구축에 많이 사용됩니다. 단, 복잡한 설정과 운영이 동반되며 다양한 기능 지원 때문에 러닝 커브가 높습니다.
각각의 기술마다 장단점을 가지고 있지만 최종적으로 Redis Pub/Sub
을 활용하기로 결정하였습니다.
- 이미 플랫폼 내부에서 Redis를 활용하고 있어 추가적인 인프라 구성을 하지 않아도 됩니다.
- 어드민 설정 정보 변경 통지를 위한
가벼운 형태의 이벤트
입니다. - 빈번한 수정이 아니므로 메시지 유실에 대한 가능성이 적습니다.
Redis Pub/Sub 적용하기
어드민 서버와 서빙 서버가 Redis Pub/Sub
을 통해 어떻게 변경 사항을 통지받을 수 있는지 코드로 알아보려 합니다.
1. 어드민 서버
아래는 현재 Flag 상태를 가진 간단한 FeatureFlagEntity
입니다. toggle()
함수를 통해 내부 flag
값을 변경할 수 있습니다.
이외에도 피처 플래그의 다양한 설정 정보를 포함하고 있지만 Redis Pub/Sub 적용이라는 핵심 내용 전달을 위해 생략합니다.
@Entity
class FeatureFlag(
@Column(nullable = false)
var flag: Boolean
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0L
fun toggle(flag: Boolean) {
this.flag = flag
}
}
DB 접근을 위한 FeatureFlagRepository
입니다.
interface FeatureFlagRepository: JpaRepository<FeatureFlag, Long>
toggle()
함수가 성공적으로 수행되면 RedisTemplate
의 convertAndSend()
를 통해 약속한 채널에 message를 보냅니다.
@Component
class FeatureFlagPublisher(
private val template: RedisTemplate<String, FeatureFlagChangedEvent>
) {
fun publish(channel: String, message: FeatureFlagChangedEvent) {
template.convertAndSend(channel, message) // 1
}
companion object {
const val CHANNEL_NAME = "feature_flag_changes"
}
}
data class FeatureFlagChangedEvent(
val id: Long,
val flag: Boolean
)
public Long convertAndSend(String channel, Object message)
: 메시지를 특정 채널로 전달하여 해당 채널을 구독 중인 클라이언트에게 전달할 수 있습니다.
이제 publish
를 위한 준비는 완료되었습니다! toggle 이후 약속한 채널을 통해 변경된 이벤트 정보를 전달합니다.
@Service
class FeatureFlagService(
private val featureFlagRepository: FeatureFlagRepository,
private val featureFlagPublisher: FeatureFlagPublisher
) {
@Transactional
fun toggle(id: Long, flag: Boolean) {
val foundFeatureFlag = featureFlagRepository.findById(id)
.orElseThrow()
foundFeatureFlag.toggle(flag)
featureFlagPublisher.publish(
channel = FeatureFlagPublisher.CHANNEL_NAME,
message = FeatureFlagChangedEvent(foundFeatureFlag.id, foundFeatureFlag.flag)
)
}
}
2. 서빙 서버
서빙 서버는 어드민 서버에서 통지한 메시지를 subscribe
해야 합니다. 먼저 Redis Pub/Sub
의 이벤트 수신을 위한 messageListenerContainer
를 Bean
으로 정의합니다.
@Configuration
class RedisConfig(
private val localCacheManager: LocalCacheManager
) {
// 1
@Bean
fun messageListenerContainer(
connectionFactory: ReactiveRedisConnectionFactory,
objectMapper: ObjectMapper
): ReactiveRedisMessageListenerContainer {
return ReactiveRedisMessageListenerContainer(connectionFactory).apply {
receiveMessages(this, ChannelTopic.of(CHANNEL_NAME), objectMapper)
}
}
// 2
private fun receiveMessages(
messageListenerContainer: ReactiveRedisMessageListenerContainer,
channel: ChannelTopic,
objectMapper: ObjectMapper
) {
val channelSerializer = createChannelSerializer()
val messageSerializer = createValueSerializer(objectMapper)
// 3
messageListenerContainer.receive(listOf(channel), channelSerializer, messageSerializer)
.flatMap {
val event = it.message
mono {
localCacheManager.refresh(event.id, event.flag) // 5
} // 4
}
.onErrorContinue { e, _ ->
log.warn { "redis subscribe error: ${e.message}" }
} // 6
.subscribe()
}
// ...
companion object {
const val CHANNEL_NAME = "feature_flag_changes"
}
}
data class FeatureFlagChangedEvent(
val id: Long,
val flag: Boolean
)
주요 로직에 대해 간단히 살펴볼게요.
ReactiveRedisMessageListenerContainer
: Spring Data Redis에서 제공하는 Reactive Redis Pub/Sub의 subscribe를 위한 메시지 리스너입니다. 비동기적으로 특정 채널에서 메시지를 수신할 수 있습니다.receiveMessages()
: 해당 함수 내에서 실질적인 메시지 수신 처리가 일어납니다.messageListenerContainer.receive(listOf(channel), channelSerializer, messageSerializer)
: 먼저 subscribe 할 channelTopic과 channel 및 message에 대한 serializer를 전달받습니다.mono { localCacheManager.refresh(event.id, event.flag) }
: Coroutine 블록 내에서suspend
함수인localCacheManager.refresh()
를 호출하기 위한 목적입니다.mono {}
는 코루틴 내에서 실행된 작업의 결과를Mono
형태로 만들어 줍니다. 해당 기능을 통해 Reactor의 Mono와 suspend 함수를 자연스럽게 통합할 수 있습니다.9localCacheManager.refresh(event.id, event.flag)
: 로컬 캐시를 최신화하기 위한 로직이 담겨 있습니다.onErrorContinue
10: 스트림에서 발생한 에러를 처리(로깅 등)하면서 전체 스트림의 흐름이 중단되지 않고 계속 실행하도록 합니다. 만약 Redis 서버의 네트워크 문제나 메시지 처리 중 에러가 발생하게 된다면 무시한 뒤 스트림 처리가 지속될 수 있도록 도와줍니다. 에러가 발생하여도 이후 메시지 수신을 위해서는 스트림이 유지되어야 하기 때문입니다.
위와 같이 간단한 Bean 정의를 통해 Redis Pub/Sub을 활용하여 최신의 어드민 설정 정보를 subscribe하고 해당 정보를 기반으로 로컬 캐시를 최신화할 수 있습니다.
Redis Pub/Sub의 한계
Redis Pub/Sub
은 단순하고 빠르게 적용 가능한 장점이 있지만 명확한 단점과 한계도 존재합니다.
Redis Pub/Sub은 메시지를 영구적으로 저장하거나 보관하지 않습니다. 만약 서빙 서버가 모종의 이유로 메시지를 수신하지 못했다면 재전송이나 재처리에 어려움이 있습니다.
또한 메시지 순서를 보장하지 않습니다. 만약 다수의 운영자가 동시에 같은 피처 플래그를 수정하게 될 경우 어드민 서버에서 동시에 Redis로 publish 할 수 있습니다. 이런 상황에서는 네트워크 상황에 따라 순서가 결정될 수 있습니다.
단순한 채널과 별도 상태를 관리하지 않는 간단한 메시지의 경우에는 적합할 수 있습니다.
하지만 피처 플래그가 다양한 요구사항 충족을 위해 서빙 서버로 전달하는 메시지에 더욱 많은 정보를 포함해야 하거나,
재처리 및 순서 제어에 대한 로직이 필요하다면 등 보다 더 많은 기능을 제공하는 RabbitMQ
나 Apache Kafka
통해 이를 해소해야 합니다.
백업 플랜
피처 플래그는 Redis Pub/Sub을 통해 어드민 서버의 변경 사항을 서빙 서버가 통지받을 수 있는 아키텍처를 구성하였습니다. 이제는 트래픽 전이 없이 안정적으로 최신 정보를 반영할 수 있게 되었습니다.
하지만 Redis를 전적으로 의존하는 구조를 만들게 되었습니다. 만약 Redis 서버가 의도와 다르게 동작하지 않으면 어떻게 될까요? 최악의 경우 설정에 따라 로컬 캐시에서 관리되는 피처 플래그 설정 정보가 만료되는 즉시 장애로 전파될 수 있습니다.
이러한 상황을 방지하기 위해 백업 플랜으로 polling을 도입하였습니다. 일정 주기로 어드민 서버의 API를 호출하여 최신 설정을 확보하며 대응할 수 있도록 하였습니다.
응답을 내리지 못하는 상황보다는 지연될 수 있지만 최신에 가까운 응답
을 내릴 수 있는 것이 더욱 적절하다 판단했기 때문입니다.
tickerChannel 활용한 로컬 캐시 최신화
tickerChannel
11이란 주기적인 신호를 생성하기 위한 coroutine channel
입니다. 일정 간격을 통해 로직을 처리해야 하는 polling
을 개발하는데 유용하게 활용될 수 있습니다.
아래는 tickerChannel
을 생성하기 위한 ticker
함수입니다. ticker
함수는 지정된 초기 지연 이후 후속 요소를 생성하는 channel을 만듭니다.
public fun ticker(
delayMillis: Long, // 1
initialDelayMillis: Long = delayMillis, // 2
context: CoroutineContext = EmptyCoroutineContext, // 3
mode: TickerMode = TickerMode.FIXED_PERIOD // 4
): ReceiveChannel<Unit> {
require(delayMillis >= 0) { "Expected non-negative delay, but has $delayMillis ms" }
require(initialDelayMillis >= 0) { "Expected non-negative initial delay, but has $initialDelayMillis ms" }
return GlobalScope.produce(Dispatchers.Unconfined + context, capacity = 0) { // 5
when (mode) {
TickerMode.FIXED_PERIOD -> fixedPeriodTicker(delayMillis, initialDelayMillis, channel)
TickerMode.FIXED_DELAY -> fixedDelayTicker(delayMillis, initialDelayMillis, channel)
}
}
}
delayMillis
: 각 작업 사이의 시간 간격을 밀리초 단위로 지정합니다. 반드시 0 이상의 값을 가져야 합니다.initialDelayMillis
: 처음 작업이 시작되기 전까지 대기 시간을 밀리초 단위로 지정합니다. 기본값은delayMillis
이며 이 값 또한 0 이상의 값을 가져야 합니다.context
: coroutine이 실행되는 컨텍스트를 지정합니다. 기본값은EmptyCoroutineContext
입니다.mode
: 요소가 수신되지 않을 때의 동작을 지정합니다. 기본값은TickerMode.FIXED_PERIOD
입니다.GlobalScope.produce(Dispatchers.Unconfined + context, capacity = 0)
: tickerChannel 생성에 가장 핵심이 되는 부분입니다.GlobalScope
12: 전체 애플리케이션 생명주기와 동일한CoroutineScope
입니다.GlobalScope
는 오래 실행되거나 취소되지 않을 경우메모리 누수
가 발생할 수 있습니다. 적합한 사용 사례로는 애플리케이션 생명주기의 전체 기간 동안 활성 상태를 유지해야 하는 경우입니다. polling을 진행할 tickerChannel의 경우 애플리케이션이 종료될 때까지 지속하여 반복적인 작업(로컬 캐시 최신화)을 처리해야 하므로 적합할 수 있습니다.produce
13: 새로운 코루틴을 실행하여 채널에 값을 보내는 방식으로 값 스트림을 생성하고, 코루틴에 대한 참조를ReceiveChannel
형태로 반환합니다. 이 반환된 객체를 사용하여 이 코루틴에서 생성된 요소들을수신
할 수 있습니다.Dispatchers.Unconfined
14: 자신을 실행시킨 스레드에서 즉시 실행하도록 만든 Dispatchers입니다. 만약 일시 중단 이후 수행될 경우재개된 스레드
에서 실행되도록 합니다.capacity
: 생성할 채널의 버퍼 크기를 지정합니다. 0은 채널에 버퍼를 사용하지 않는 것을 의미합니다. producer가 값을 생성하면 subscriber가 값을 가져갈 때까지 기다립니다.
이제 해당 함수를 활용하여 polling
로직을 적용해 볼까요? 몇 가지 특징에 대해 알아보겠습니다.
@Component
class CacheRefreshScheduler(
@Value("\${cache.refresh.interval}")
private var refreshInterval: Long,
private val localCacheManager: LocalCacheManager
) : CoroutineScope { // 1
// 2
override val coroutineContext: CoroutineContext
get() = Dispatchers.Default + SupervisorJob()
// 3
private val tickerChannel = ticker(
delayMillis = refreshInterval,
initialDelayMillis = 0L,
mode = TickerMode.FIXED_DELAY
)
// 4
init {
launch {
tickerChannel.consumeEach { localCacheManager.refreshAll() } // 5
}
}
@PreDestroy
fun stopScheduler() {
tickerChannel.cancel()
}
}
CoroutineScope
:CacheRefreshScheduler
은 CoroutineScope을 구현합니다. 클래스 내부에서 지정한 coroutineContext를 기반으로 coroutine을 호출할 수 있습니다.@Component
를 지정하였기 때문에 스프링 빈으로 생명주기가 제어됩니다.override val coroutineContext: CoroutineContext
: CoroutineScope에서 활용할 coroutineContext를 지정합니다. 예제에서는Dispatchers.Default
와SupervisorJob()
의 조합을 활용하였습니다.DispaDispatchers.Default
: CPU 작업에 적합한 기본 디스패처입니다. refresh 작업 내에 I/O 작업이 많다면Dispatchers.IO
을 고려해 볼 수 있습니다.SupervisorJob()
: 자식 coroutine 작업의 실패가 부모나 다른 자식에 영향을 끼치지 않습니다. cache의 갱신 작업은 독립적으로 실행해야 하므로 적합할 수 있습니다.
tickerChannel
: 일정 주기마다 특정 신호를 전달합니다. 설정 정보를 통해 전달받은 interval 주기로 고정된 delay(TickerMode.FIXED_DELAY
)를 기반으로 동작합니다.init
:CacheRefreshScheduler
빈이 생성되는 시점에 생성자를 통해 launch를 호출하여 비동기 작업을 수행합니다. coroutine은 위에서 지정한coroutineContext
를 기반으로 동작합니다.tickerChannel.consumeEach { ... }
: tickerChannel에서 생성한 주기적인 신호를 소비합니다. 신호가 발생할 때마다 로컬 캐시가 refresh 됩니다.
위와 같이 tickerChannel을 설정하게 되면 Redis Pub/Sub이 동작하지 않아도 애플리케이션 내에서 주기적인 polling이 동작하여 어드민 서버의 최신 정보를 로컬 캐시에 refresh 하므로 사용자에게 에러가 아닌 최신에 가까운 응답
을 내릴 수 있습니다.
마치며
지금까지 피처 플래그의 어드민 - 서빙 서버 구조의 아키텍처를 적용하며 독립된 두 서버의 데이터 최신화 과정에서 직면한 문제를 해결하기 위한 과정을 다루었습니다.
어드민 서버와 서빙 서버는 각각의 목적에 따라 분리되었습니다. 하지만 직접 호출과 polling 등을 고려하게 되면 목적과 다르게 적은 리소스를 가진 어드민 서버로의 트래픽 전이
가 이뤄질 수 있습니다.
이를 해소하기 위해 메시지 브로커를 고려하였습니다. 메시지 브로커는 어드민 서버와 서빙 서버의 직접적인 의존성 없이 변경 사항을 통지할 수 있습니다. 다양한 종류의 메시지 브로커가 존재하지만 현재 처한 상황에 맞춰 추가적인 인프라 고려 없이 빠르게 적용할 수 있는 Redis Pub/Sub을 활용하였습니다.
매번 모든 선택이 정답이 될 순 없습니다. polling 방식은 어드민 서버로의 트래픽 전이를 막을 수 있지만 polling 주기에 따른 실시간성을 보장할 수 없습니다. Redis Pub/Sub 또한 마찬가지입니다. 기존에 구성된 어드민, 서빙 서버가 아닌 추가적인 인프라에 의존하게 되었습니다. 보다 더 안정적인 서비스를 위해 백업 플랜을 고려하게 되었습니다.
결국 모든 선택은 트레이프오프를 동반하게 됩니다. 같은 문제에 직면하더라도 서비스의 특성을 잘 고려하여 적합한 기술 및 방법을 고민하고 충분한 근거를 통해 선택한다면 보다 더 안정적이고 신뢰할 수 있는 서비스를 만들 수 있다고 생각합니다. 글에서 다룬 유사한 문제에 직면한 개발자들에게 한 줄기 방향성이 제시되었길 바라봅니다.
Footnotes
-
위키피디아에 따르면 Feature Toggle이라는 키워드를 통해 관련 개념을 설명하고 있습니다. ↩
-
저희 팀 내부에서는
ON
에서OFF
로toggle
한다고 표현하고 있습니다. ↩ -
비동기 서버는 이벤트 루프를 기반으로 스레드가 작업이 완료될 때까지 기다리지 않고 다음 작업을 처리할 수 있습니다. 작업 내에 블로킹 요소가 있다면 다른 요청은 대기해야 하므로 동시성 처리 원활하지 않을 수 있습니다. ↩
-
MySQL과 같은 관계형 데이터베이스(RDB)가 I/O 병목이 많은 이유는 비교적 느린 속도를 가진 디스크 스토리지에 의존하고 있기 때문입니다. ↩
-
서버 내에 메모리를 활용한 캐시를 일컫습니다. 대표적으로 활용할 수 있는 라이브러리로 caffeine이 있습니다. ↩
-
refresh() 함수가 suspend인 이유는 로컬 캐시 및 refresh를 활용하는 대부분의 로직이 coroutine으로 작성되어 활용하고 있기 때문입니다. ↩