올리브영 테크블로그 포스팅 SDUI의 성능 병목을 넘어: 올리브영 로컬 캐시 기반 백엔드 최적화 성공기
Tech

SDUI의 성능 병목을 넘어: 올리브영 로컬 캐시 기반 백엔드 최적화 성공기

Caffeine과 Redis로 완성한 이중 캐싱 전략과 1ms 미만의 응답 속도

2025.11.11

들어가며

빠르게 변화하는 이커머스 환경에서, SDUI(Server-Driven UI)는 선택이 아닌 필수가 되어가고 있습니다.

SDUI는 '서버가 주도하는 사용자 인터페이스'라는 뜻으로, 쉽게 말해 스마트폰 앱이나 웹사이트 화면을 구성하는 요소들(버튼, 이미지, 텍스트 배치 등)을 클라이언트(앱/웹 브라우저)가 알아서 만드는 대신, 서버가 '이렇게 만들어라' 하고 설계도(JSON 데이터)를 전달해 주는 방식입니다.

이를 통해, 과거에는 iOS, Android, Web 등 각 플랫폼이 UI 컴포넌트를 개별적으로 구현해야 했지만, 이제는 서버의 UI 구성을 통해 업데이트 없이도 서비스 화면을 실시간으로 바꿔줄 수 있게 되었습니다.

이는, UI 일관성 확보는 물론, 개발 리소스 절감, 비개발자의 직접적인 UI 수정 가능성 등의 이점을 가져왔습니다.

그러나 SDUI의 특성 상 모든 클라이언트가 서버로부터 UI 구성을 요청·수신하기 때문에 API 응답 속도 및 네트워크 지연이 사용자 경험에 직접적인 영향을 미칩니다. 저희 팀 역시 성능 테스트 과정에서 트래픽 증가에 따른 API 지연 문제를 경험했고, 이에 대한 해결 방안으로 로컬 캐시(Local Cache)를 활용한 최적화 전략을 채택했습니다.

이 글에서는 올리브영이 SDUI를 도입하며 겪었던 문제점과, 로컬 캐시 기반의 Backend API 최적화 전략을 통해 어떻게 성능 병목을 해결하고 안정적인 서비스를 구축했는지 그 경험을 상세히 공유하고자 합니다.

올리브영 개발 조직이 특히 더 신경쓰는 기간은 '올영세일' 인데요. 초당 6만 건이 넘는 트래픽 속에서도 응답 속도 1ms 미만을 달성한 사례 기반 SDUI의 도입부터 운영까지, 저희의 시행착오와 실제 서비스로 검증된 해결책이 여러분의 서비스에 큰 도움되기를 바랍니다.

배경: 올리브영이 SDUI를 선택한 이유

개발자라면 한 번쯤 겪어봤을 겁니다. iOS, Android, Web 등 각 플랫폼에서 동일한 UI를 중복으로 구현해야 하는 비효율, 작은 UI 수정에도 앱스토어 배포 과정을 거치며 중요한 마케팅 기회를 놓치는 답답함. 올리브영 역시 빠르게 변화하는 시장과 고객 요구사항 앞에서 이러한 문제들에 직면했습니다.

우리가 겪었던 핵심적인 어려움은 다음과 같았습니다.

  • 비효율적인 개발: 플랫폼별 UI 중복 구현으로 개발 리소스가 낭비되고, 팀의 피로도가 높았습니다.
  • 느린 반영 속도: UI 수정 시 앱스토어 배포가 필수였기에, 시장 변화에 민첩하게 대응하기 어려웠습니다.
  • 일관성 없는 사용자 경험: 플랫폼 간 미묘한 UI 불일치로 브랜드 경험의 일관성이 저해되었습니다.

즉, '어떻게 하면 더 빠르고, 효율적이며, 일관된 사용자 경험을 제공할 수 있을까?'라는 질문에 대한 답으로 SDUI 도입을 결정했습니다.

서버에서 JSON Schema 기반의 UI 컴포넌트 데이터를 제공하고 클라이언트가 이를 렌더링하는 방식은, 앱 업데이트 없이도 실시간으로 UI를 변경할 수 있는 유연성을 제공했습니다.

우리는 SDUI의 잠재력을 확인하고, 우선적으로 올리브영 앱의 핵심 영역인 탭바테마 드로워에 도입을 시도하며 새로운 기술 여정의 첫걸음을 내디뎠습니다.

문제 상황: 성능 병목 현상

SDUI 전환 후 '올영세일'과 같은 대규모 트래픽 상황을 모의하기 위해, 실제 서비스 환경과 유사한 조건에서 다음과 같은 시나리오로 테스트를 진행했습니다.

  • 테스트 도구: nGrinder
  • 테스트 환경: N개의 nGrinder 에이전트
  • 테스트 시나리오:
사용자 수: M명의 가상 사용자 동시 접속
테스트 시간: X초 동안 지속
요청 방식: M명의 가상 사용자가 X초 동안 SDUI API에 대해 요청(Request) → 응답(Response) → 다음 요청(Next Request)을 동기 방식으로 반복 수행했습니다. 이는 실제 사용자가 UI를 로드하고 다음 액션을 수행하는 과정을 충실히 모사하여, 서버가 지속적인 부하를 처리하는 능력을 측정하기 위함입니다.
트래픽 목표: 6월 '올영세일' 피크 트래픽의 2배 수준의 부하 생성
측정 지표: API 응답 시간(Response Time), 초당 처리량(TPS), 네트워크 트래픽 등

이러한 테스트 환경과 시나리오를 통해, SDUI Backend API가 실제 대규모 트래픽 상황에 어떤 성능 변화를 보이는지 명확하게 측정할 수 있었고, 아래와 같은 문제를 확인했습니다.

API 응답 속도 저하
'올영세일' 기준 트래픽으로 성능 테스트 시 트래픽으로 인한 P50 서버 응답 시간이 733ms에 따라서 초기 로딩 시간 증가가 발생했습니다.

네트워크 트래픽 증가
동일한 탭바 및 테마드로워 데이터를 대규모 사용자가 반복적으로 요청하다 보니 서버와 캐시인 Redis 사이 구간의 네트워크 트래픽이 증가했습니다.

사용자 경험 악화
API 응답 속도 저하 및 네트워크 트래픽 증가에 따른 응답 지연에 따른 테마드로워 / 탭바 렌더링이 느려지고 사용자 경험이 악화되는 것을 확인했습니다.

즉, 성능 최적화 없이는 SDUI의 장점이 오히려 단점으로 전환될 수 있음을 확인했습니다.

해결 전략: 이중 캐시의 도입

1차 캐시: Caffeine (Local Cache)  ➡️  네트워크 트래픽 감소 및 API 응답 속도 극대화
2차 캐시: Redis (Remote Cache)  ➡️  DB로의 부하를 최소화하고, 다중 서버 환경에서 캐시 일관성 유지
3차 저장소: Oracle DB (관계형 데이터베이스)  ➡️  백오피스에서 UI 구성 요소를 관리하는 최종 데이터 원천

1. 캐시 정책

동일 파라미터에 대한 요청은 캐시 응답으로 반환하도록 구현했고, API 단위의 키로 관리했습니다.
(예: commonContents:tabbar::ALL:v2)

2. API 응답 구조 개선

캐시 효율성을 극대화하기 위해 API 설계 단계에서 멱등성(Idempotency)을 보장했습니다.
멱등성은 '동일한 요청을 여러 번 보내도 항상 같은 결과가 반환되는 특성'을 말합니다. 마치 리모컨으로 TV를 켤 때 한 번 누르나 여러 번 누르나 TV는 결국 켜지거나 꺼진 상태를 유지하는 것과 같습니다.
이러한 특성을 통해 동일 입력값에 대하여 항상 동일한 출력이 반환되도록 구조화하여 캐시의 유효성을 높였습니다.

3. 캐시 무효화 전략: 실시간성 확보와 안정성 유지를 위한 투 트랙

캐시는 데이터를 빠르게 제공하는 이점이 있지만, 데이터 변경 시 오래된 데이터(Stale Data) 문제가 발생할 수 있습니다.
SDUI는 UI가 실시간으로 변경되어야 하는 요구사항이 강하므로, 데이터 변경에 따른 캐시 무효화 전략이 매우 중요했습니다. 우리는 두 가지 전략을 조합하여 이를 해결했습니다.

3-1. 백오피스(Back Office) 연동을 통한 즉시 무효화
백오피스에서 SDUI 관련 데이터(예: 탭바 메뉴, 테마 드로워 컨텐츠)를 수정하면, 해당 변경 이벤트를 감지하여 관련된 Redis 캐시를 즉시 삭제하도록 구현했습니다.
이로써 관리자가 UI를 변경하는 즉시 사용자에게 최신 UI가 반영될 수 있도록 했습니다.
또한, 백오피스의 접근은 사내 내부 네트워크에서만 가능하고, 데이터 등록/수정/삭제는 명확한 인증/인가 프로토콜을 통해 등록된 특정 관리자 계정만 데이터 조작을 가능케 제한하여서 캐시 오염 시나리오를 방지하였습니다.

3-2. 주기적인 배치(Batch) 작업을 통한 캐시 갱신
혹시 모를 캐시 누락이나 시스템 오류로 인한 데이터 불일치를 방지하고, 캐시의 안정성을 확보하기 위해 하루에 한 번(일 1회 주기) 배치(Batch) 작업을 실행했습니다.
이 배치는 단순히 캐시를 비우는 것을 넘어, 주요 SDUI 데이터를 미리 조회하여 캐시를 신규 데이터로 사전 캐싱(Pre-warming) 하는 역할도 수행합니다. 이는 시스템 부하가 적은 새벽 시간대에 실행하여, 주간 피크 타임에 콜드 캐시(Cold Cache)로 인한 응답 지연이 발생하는 것을 최소화했습니다.

이 두 가지 전략을 통해 우리는 UI 변경의 실시간성과 캐시 데이터의 안정성이라는 두 마리 토끼를 모두 잡을 수 있었습니다.

4. 아키텍쳐 및 Caffeine 설정

올리브영 SDUI Backend API는 Spring Boot와 Kotlin 기반으로 개발되었으며, 클라이언트 요청 처리부터 데이터 조회까지 효율적으로 분배하기 위해 다음과 같은 계층 구조를 가집니다. 각 계층은 명확한 책임을 가지며, 특히 캐시 전략이 서비스 로직과 유기적으로 결합되어 있습니다.

우리는 Caffeine을 1차 로컬 캐시로 활용하여 JVM 메모리 내에서 고성능 캐싱을 구현했습니다. 다음은 CacheConfig에 정의된 Caffeine 캐시 설정의 주요 부분입니다.

@Configuration
@EnableCaching
class CacheConfig {

    @Bean
    fun localCacheManager(): CacheManager {
        val cacheManager = SimpleCacheManager()
        val caches = listOf(
            CaffeineCache(
                CaffeineCacheKeys.LOCAL_TABBAR_CACHE_KEY,
                Caffeine.newBuilder()
                    .expireAfterWrite(TTL_TEN_SECONDS, TimeUnit.SECONDS)
                    .build()
            ),
            CaffeineCache(
                CaffeineCacheKeys.LOCAL_THEMEDRAWER_CACHE_KEY,
                Caffeine.newBuilder()
                    .expireAfterWrite(TTL_TEN_SECONDS, TimeUnit.SECONDS)
                    .build()
            )
        )

        cacheManager.setCaches(caches)
        return cacheManager
    }
    // RedisCacheManager 설정 등
}
  • expireAfterWrite(TTL_TEN_SECONDS, TimeUnit.SECONDS):

캐시 데이터가 마지막 쓰기(생성/업데이트) 시점으로부터 10초가 지나면 만료되도록 설정했습니다.
올리브영 트래픽 특성 상 피크 타임의 트래픽만 무사히 넘기면 되기 때문에 TTL을 짧게 설정했습니다.
또한, 클라우드 환경 특성 상 로컬 캐시에 대한 중앙 관리가 어려운 점이 있어, 짧은 TTL을 이용하여 데이터 갱신 시 최신 데이터 반영 시간을 최소화하고자 했습니다.

5. 코드 예시: 캐시 계층별 역할

TabbarController, IntegratedSduiService, TabbarService는 각각의 역할을 분리하여 단일 책임 원칙(SRP)을 준수하고, 캐시 로직과 비즈니스 로직을 명확히 구분합니다.

TabbarController (클라이언트 요청 처리)

@RequestMapping("/tabbar/information", method = [RequestMethod.GET])
fun fetchTabbarInformation(@RequestParam(value = "tabbarType") tabbarType: String): TabbarResponse {
    return integratedSduiService.fetchTabbarInformation(tabbarType)
}

TabbarController는 클라이언트의 HTTP 요청을 받아 IntegratedSduiService를 호출하는 게이트웨이 역할을 합니다.

IntegratedSduiService (1차 로컬 캐시 담당)

@Cacheable(
    cacheManager = "localCacheManager",
    value = ["localTabbar"],
    key = "{#tabbarType}",
    unless = "#result == null"
)
fun fetchTabbarInformation(tabbarType: String): TabbarResponse {
    return tabbarService.getTabbarData(tabbarType)
}

IntegratedSduiService는 SDUI에 대한 통합 비즈니스 로직을 담당하며, 여기에 1차 로컬 캐시(Caffeine)를 적용했습니다.
@Cacheable은 로컬 캐시에 데이터가 존재하면 네트워크 호출 없이 즉시 반환하여 가장 빠른 응답을 제공하는 어노테이션입니다. 만약 로컬 캐시에 데이터가 없다면, 하위 계층인 tabbarService를 호출하여 다음 캐시 계층으로 조회를 위임합니다. 단, unless = "#result == null" 조건을 통해 null 응답은 제외하고 유효 데이터만 캐싱합니다.

TabbarService (2차 원격 캐시 및 DB 조회 담당)

@Cacheable(
    cacheManager = "redisCacheManager",
    value = ["redisTabbar"],
    key = "{#tabbarType}",
    unless = "#result == null"
)
fun getTabbarData(tabbarType: String): TabbarResponse {
    return tabbarRepository.getTabbarData(tabbarType)
}

TabbarService는 2차 원격 캐시(Redis)를 담당합니다.
로컬 캐시에 데이터가 없을 경우 redisCacheManager를 통해 Redis에서 데이터를 조회합니다.
Redis에도 데이터가 없다면 최종적으로 tabbarRepository를 호출하여 Oracle DB에서 데이터를 가져옵니다.
이처럼 캐시 계층을 명확히 분리함으로써, DB 부하를 최소화하고 다중 서버 환경에서도 캐시 데이터의 일관성을 효과적으로 유지할 수 있도록 설계했습니다.

결과: 성과 지표

SDUI, 그리고 로컬 캐시의 성과 지표는 9월 '올영세일'에서 명확하게 드러났습니다.

세일 기간 평균 응답 시간
2025년 9월 '올영세일' 첫째날 평균 응답 시간 (출처: DataDog APM 대시보드)
세일 기간 최대 요청 수
2025년 9월 '올영세일' 첫째날 최대 요청 수 (출처: DataDog Infrastructure 모니터링)

최대 초당 63.3k TPS (Transactions Per Second)를 견뎠으며, 이는 1초에 약 6만 3천 건의 요청을 처리했다는 의미입니다.
P90 Response Time은 최대 1ms가 되지 않는 결과를 보여줬습니다. P90은 '전체 요청 중 90%가 이 시간 안에 처리되었다'는 성능 지표인데요, 대부분의 사용자가 1ms보다 훨씬 빠른 시간 안에 응답을 받았다는 압도적인 성능 개선을 의미합니다.
뿐만 아니라, iOS, Android, Web 등 모든 플랫폼에서 동일한 탭바와 테마드로워 화면을 볼 수 있었습니다.

결론 및 향후 계획

본 사례를 통해 로컬 캐시가 SDUI 도입의 안정성과 성능을 동시에 보장하는 핵심 요소임을 확인했습니다.
성능 테스트를 기반으로 한 문제 인식과 그에 대한 해결책을 제안하는 과정을 통해서 단순히 이론이나 설계로만 답을 찾는 경험이 아닌 실제 데이터 기반의 답을 찾는 경험을 한 점은 인상 깊었습니다.
향후에는 SDUI를 적용하는 영역을 점차 확대할 예정이며, 다른 API 영역에도 로컬 캐시 도입을 적극적으로 검토할 예정입니다.
올리브영의 사례가 여러분의 서비스 최적화에 실질적인 영감이 되길 바라며, 궁금한 점이나 비슷한 경험이 있다면 댓글로 공유해 주세요.

SDUICacheKotlin
올리브영 테크 블로그 작성 SDUI의 성능 병목을 넘어: 올리브영 로컬 캐시 기반 백엔드 최적화 성공기
🏎️
XD |
Back-end Engineer
레XX 날개를 달아줘요!