[페스티맵] 진행한 프로젝트 메인 페이지 조회 구조 및 성능 개선하기

2024년부터 진행해온 축제 SaaS 서비스 페스티맵은 고객(축제 운영팀)마다 하나의 축제를 등록하고, 각 축제의 메인페이지를 위젯 단위로 구성해 축제 분위기에 맞는 사이트를 만들어주는 서비스입니다.

2024년 상반기, 메트릭 모니터링을 통해 `/home/{festivalId}` API의 응답 시간이 다른 API들보다 눈에 띄게 높다는 것을 발견했습니다. 이를 단일 테이블 상속 전략으로 개선했었는데, 이 글에서는 그 당시 왜 그런 선택을 했고, 그 선택이 어떤 새로운 문제를 낳았으며, 이를 어떻게 해결했는지를 다룰려고 합니다. 나아가 2025년 한 해 동안의 Google Analytics 통계를 기반으로 메인페이지 조회 성능을 한 단계 더 개선한 과정까지 함께 정리해 보겠습니다.

 

1. 단일 테이블 상속 전략 도입 배경

초기에는 메인페이지에 보여지는 여러 위젯들을 타입별로 테이블을 분리하고 있었다. 문제는 메인페이지 API였다. 메인페이지를 한 번 조회하면 Festival과 함께 5종류 위젯을 모두 가져와야 하는데, 각 위젯 테이블이 Festival과 일대다 관계로 연결되어 있으니 N+1 문제가 발생했다.


자연스럽게 Fetch JOIN으로 해결하려 했지만, 여기서 또 다른 벽에 부딪혔다. 여러 일대다 관계를 동시에 Fetch JOIN하면 카테시안 곱(Cartesian Product) 이 발생한다. Hibernate는 이를 `MultipleBagFetchException`으로 차단하고, 설령 `Set`으로 우회하더라도 결과 행이 폭발적으로 늘어나 성능이 오히려 악화된다.

결국 다른 접근이 필요했고, 5개 타입이 공통 필드(`name`, `url`, `festival_id`)를 공유한다는 점에 착안하여 단일 테이블 상속 전략을 도입했다. 하나의 테이블로 합치면 Fetch JOIN 하나로 모든 위젯을 가져올 수 있기 때문이다.

 

 

Widget 통합 및 메인페이지 조회로직 개선 by HeeChanN · Pull Request #101 · festimap-org/waba-backend

#️⃣연관된 이슈 ex) #100 📝작업 내용 문제 상황 2024년 10월 경희대 대학 축제에서는 축제 기간에 메인 페이지 접속 시 페이지 로딩이 느려지는 것을 경험했으며, 이를 모니터링하기위해 prometheus

github.com

 

돌이켜 보면, 당시에는 단일 테이블 상속 전략의 장단점을 충분히 따져보지 않았다. 솔직히 그때는 개발 경험이 부족했고, 문제를 바라보는 시야가 단편적이었다. "N+1이 발생한다 → 테이블을 합치면 된다 → STI가 있네 → 적용하자" 식의 일직선적인 사고 흐름이었다. 당장 눈앞의 문제(N+1)가 해결되니까 또 테스트를 해봤을 때 성능적으로도 개선됬으니까 그걸로 충분하다고 생각했다.

 

하지만 역시, 초기에는 잘 동작했지만, 서비스가 새로운 축제에 도입되며 단일 테이블 상속 전략의 구조적 한계가 하나둘 드러나기 시작했다.

 

2. 단일 테이블 상속 전략의 문제점

 

2-1. NULL 문제

첫 번째 문제는 도입하면서부터 알 수 있는 NULL 컬럼 폭발문제이다. 하나의 테이블에 여러 type의 위젯이 존재하기 때문에 각 위젯마다 필요한 컬럼이 존재했고 그에따라 아래와 같이 null을 갖게 되는 경우가 발생했다.


모든 행에서 자기 타입과 무관한 컬럼들이 NULL로 채워진다. 위젯 타입이 5개뿐인 지금도 이 정도인데, 타입이 늘어날수록 NULL 비율은 가속도로 증가한다는 문제가 존재했다.

 

2-2. 스키마 경직성

두 번째 문제는 스키마 경직성이다. 이 문제는 페스티맵이 추구하는 SaaS 서비스에서 가장 치명적인 문제이다. 축제마다 필요한 위젯이 다르다. 어떤 축제는 상단 위젯이 필요하고, 어떤 축제는 중간 배너가 필요할 수 있다. 단일 테이블 상속 전략에서 새 위젯 타입을 추가하면 어떻게 될까?

-- 타이머 위젯 추가
ALTER TABLE base_widget ADD COLUMN timer_duration INT;
ALTER TABLE base_widget ADD COLUMN timer_label VARCHAR(255);

-- 투표 위젯 추가
ALTER TABLE base_widget ADD COLUMN vote_options TEXT;
ALTER TABLE base_widget ADD COLUMN vote_deadline DATETIME;


새 타입 하나를 추가할 때마다 `ALTER TABLE`이 필요하다. 운영 중인 테이블에 DDL을 실행하는 것 자체가 리스크이며, 추가된 컬럼은 기존의 모든 위젯 행에 NULL로 들어간다. 위젯 타입이 10개, 20개로 늘어난다면? 테이블은 거대한 NULL 매트릭스가 된다.

2-3. 도메인 제약 불가능

타입별로 필수인 컬럼이 달라도 DB 레벨에서 NOT NULL 제약을 걸 수 없다.

-- SquareWidget은 description이 필수지만...
ALTER TABLE base_widget ALTER COLUMN description SET NOT NULL;
-- → MainWidget도 description을 쓰지만, UpWidget/DownWidget은 쓰지 않으므로 불가!

 

 

 

결국 모든 제약은 애플리케이션 레벨로 올라가야 한다. DB가 데이터 무결성을 보장해주지 못하면 어떻게 될까?

서비스 코드 곳곳에 유효성 검증 로직이 흩어진다. SquareWidget을 생성하는 Service에서 `description`이 비어 있는지 직접 검사해야 하고, MainWidget을 생성하는 Service에서도 또 검사해야 한다. 타입이 늘어날수록 "이 타입에서 이 필드는 필수인가?"를 코드로 일일이 관리해야 하며, 이 검증을 누락하면 NULL이 그대로 저장되어도 DB는 아무런 경고를 주지 않는다. 버그가 발생해도 데이터가 조용히 저장되어 버리기 때문에, 문제를 인지하는 시점은 사용자가 깨진 화면을 보고 나서야 비로소다. DB가 마지막 방어선 역할을 하지 못하는 구조인 셈이다.

 

 

3. 메인 페이지 조회 구조 변경 : JSON 컬럼 도입

 

3-1. 왜 JSON 컬럼이 SaaS 메인페이지에 적합한가?

단일 테이블 상속 전략의 문제를 인식한 뒤 여러 대안을 검토했다. 결론은 MySQL의 JSON 컬럼이었다. 그중 가장 큰 이유는 스키마 유연성이었다.

축제 SaaS 서비스에서는 고객별로 필요한 위젯이 다르다. JSON 컬럼을 사용하면 타입별 속성을 스키마 변경 없이 자유롭게 정의할 수 있다는 점이 가장 큰 이유였다. 메인페이지를 구성할 때 DDL 변경이 없어지고, 코드 배포만으로 새 타입을 추가할 수 있다.

두 번째는 위젯 속성이 조회 위주라는 점이었다. 위젯의 타입별 속성(`image`, `description`, `periodStart` 등)은 생성 시 저장하고, 이후에는 읽기만 한다. 복잡한 업데이트 쿼리가 필요 없으므로 JSON의 쓰기 성능 단점이 거의 드러나지 않는다.

마지막으로 JSON 컬럼의 단점이 드러나지 않는 구조라는 점이었다. 타입별 속성으로 복잡한 쿼리(`JOIN`, `GROUP BY`, 정렬 등)가 필요하면 `JSON_EXTRACT`를 사용해야 하는데, 성능이 일반 컬럼보다 떨어진다. 하지만 현재 동작에서 위젯 속성으로 복잡한 WHERE/JOIN이 불필요했다. 위젯 조회는 `festival_id`와 `widget_type`으로 필터링하면 끝이다. 타입별 속성을 기준으로 `JOIN`하거나 `GROUP BY`할 일이 없으므로, JSON의 쿼리 약점이 부각되지 않는다.

물론 데이터 무결성 검증을 DB가 아닌 애플리케이션에서 해야 한다는 것은 여전히 남겨진 문제다. 이 부분은 Properties 클래스의 생성자에서 필수 필드 검증을 수행하고, 잘못된 값이 들어오면 객체 생성 자체를 차단하는 방식으로 보완할 수 있다. DB 제약만큼 강력하지는 않지만, JSON의 타입별 속성이 반드시 Properties 객체를 거쳐야만 저장되는 구조이므로 검증 누락 가능성을 최소화할 수 있다.

이러한 단점에도 불구하고, 메인페이지 구성 정보를 축제별로 유연하게 관리하는 용도라면 스키마 유연성의 이점이 훨씬 크다고 판단하여 JSON 컬럼 도입을 결정하게 되었다.

3-2. 왜 NoSQL(MongoDB)이 아닌 JSON 컬럼인가?

"스키마 유연성이 필요하다면 MongoDB를 쓰는 게 낫지 않나?" 라는 질문이 나올 수 있다. 처음에는 당연히 NoSQL 데이터베이스를 도입해야하나 고민했지만, 아래와 같은 이유로 선택을 하지 않았다.



또한 WidgetItem과의 FK 관계도 한목 하였다. 위젯은 하위에 `WidgetItem`이라는 자식 엔티티를 갖고 있고, 이 관계는 FK로 참조 무결성을 보장한다. MongoDB로 위젯을 옮기면 이 FK가 깨지고, 두 데이터스토어 사이의 정합성을 직접 관리해야 한다. 분산 트랜잭션이라는 거대한 복잡도를 떠안을 이유가 없었다.

 

따라서 결론은 유연해야 할 부분(타입별 속성)만 JSON으로 빼고, 나머지(공통 필드, 관계)는 RDB의 강점을 그대로 유지하는 것이 이 상황에서 가장 합리적인 선택이었다고 생각한다.

 

4. 캐시 도입 배경 : GA 통계가 보여준 것

2025년 PV

 

JSON 컬럼 도입으로 스키마 유연성 문제는 해결했다. 새 위젯 타입을 추가할 때 더 이상 운영 DB를 건드릴 필요가 없고, NULL 컬럼 폭발도 사라졌다. 그렇다면 메인페이지 API 성능은 어떨까?

2025년 한 해 동안의 Google Analytics 통계를 확인해보니, 총 51만 PV 중 메인 홈(`/`)이 31.7만(62%)을 차지하고 있었다. 메인페이지는 모든 사용자가 처음 접근하는 페이지이므로, 응답 속도가 곧 사용자 경험이다.

메인 홈 데이터의 특성을 정리하면 이렇다:

  1. 극단적인 Read-Heavy: 관리자가 위젯을 수정할 때만 데이터가 변경된다. 하루 수 회 수준 (거의 없음)
  2. 스파이크 트래픽: 축제 기간에 동일 데이터에 DB 쿼리 수천 건/분이 반복된다.
  3. 이미 쿼리 최적화 완료: JSON 마이그레이션 + JOIN FETCH + 복합 인덱스로 단건 쿼리 자체는 최적화된 상태. 병목은 쿼리 자체가 아니라 쿼리 횟수다.


극단적인 Read:Write 비율 + 짧은 지연 허용(관리자 수정 후 수 초 내 반영이면 충분) → 캐시 도입의 최적 조건

 

메인 홈 데이터의 경우 변경 빈도가 낮아 데이터 최신성 부담이 거의 없고, 수 초 수준의 지연만 허용되면 되므로 캐시 무효화 전략도 단순하게 가져갈 수 있다고 생각했다.

 

초기 인프라 구조 (No Redis)

 

현재 구조는 위와 같다. 클라이언트의 모든 요청이 ALB를 거쳐 EC2 인스턴스에 도달하고, 인스턴스는 매 요청마다 MySQL에 직접 쿼리를 날린다. 

5. 첫 번째 시도 : 글로벌 캐시 도입 (Redis, ElastiCache Valkey)

Festimap은 다중 인스턴스 구조로 운영되고 있다. ALB 뒤에 Auto Scaling 그룹이 구성되어 있어, 축제 기간에 트래픽이 몰리면 인스턴스가 자동으로 늘어난다.

이 때, 각 인스턴스가 독립적으로 로컬 캐시를 들고 있으면, 인스턴스 간 데이터 정합성을 보장할 수 없다. 인스턴스 A에서 관리자가 위젯을 수정해도 인스턴스 B의 캐시에는 여전히 이전 데이터가 남아 있기 때문이다. 따라서 첫 번째 단계로는 모든 인스턴스가

하나의 외부 캐시를 공유하는 글로벌 캐시 방식을 선택했다.

 

5-1. Self-hosted vs AWS Managed Service

Redis를 직접 EC2에 올려 운영하는 방법도 있지만, 현재 프로젝트에서 Redis 서버의 모니터링, 보안 패치, 장애 대응까지 직접 관리하는 것은 현실적이지 않았다. 따라서, AWS ElastiCache를 선택하면 이런 관리 오버헤드를 제거하고 개발에 집중할 수 있다고 판단했다. 또한 직접 Redis 환경을 구축했을 때 운영 비용은 월 $12의 ElastiCache 요금보다 훨씬 크다고 생각했다.

 

5-2. Redis vs Valkey

2024년 3월, Redis Labs가 라이선스를 SSPL에서 RSAL로 변경하면서 클라우드 벤더들의 Redis 호스팅이 제한되었다. 이에 AWS는 Redis의 오픈소스 포크인 Valkey를 ElastiCache의 기본 엔진으로 권장하기 시작했다.

 

동일 스펙에서 Valkey가 Redis보다 약 20% 저렴하다. 가장 저렴한 조합은 Graviton 기반 `cache.t4g.micro` + Valkey로,

월 $14.02다. 여기에 1년 예약 인스턴스를 적용하면 시간당 $0.013까지 내려가 월 ~$9.5 수준이 된다. 참고로 AWS 프리 티어를 활용하면 `cache.t3.micro` 기준 첫 12개월은 무료다 (말도 안돼~ 프리티어 최고!).

 

최종적으로 `cache.t3.micro`를 선택해 시작했다. 현재로서는 프리티어 사양으로도 충분히 동작한다고 생각하였다.

 

5-3. 적용방식

Spring의 `@Cacheable`을 활용하면 기존 서비스 코드를 거의 건드리지 않고 캐시를 적용할 수 있다.

 

@Cacheable(value = "home", key = "#festivalId")
public HomeDto getMainPage(Long festivalId) {
    // Festival + Widget JOIN FETCH + 공지 + 실종자 조회
}

캐시 무효화는 도메인 이벤트 기반으로 설계했다. 위젯, 공지, 실종자 서비스의 mutation 메서드에서 `HomeCacheEvictEvent`를 발행하고, `@TransactionalEventListener(phase = AFTER_COMMIT)`으로 트랜잭션 커밋 후에만 evict를 수행한다. 트랜잭션이 롤백되면 캐시를 무효화할 필요가 없기 때문이다.

TTL은 5분으로 설정했다. 이 값은 다음 두 가지 기준에서 도출했다.

 

 

Cache Validity - Database Caching Strategies Using Redis

This whitepaper is for historical reference only. Some content might be outdated and some links might not be available. Cache Validity You can control the freshness of your cached data by applying a time to live (TTL) or expiration to your cached keys. Aft

docs.aws.amazon.com

 

 

Cache optimization: Strategies to cut latency and cloud cost

A practical guide to cache optimization: TTLs, eviction, cache-aside, write-through, edge caching, and AI/semantic caching to cut latency and cost.

redis.io

TTL을 결정할 때 두 가지 질문을 던지라고 권장한다: 데이터가 얼마나 자주 변하는가, 그리고 오래된 데이터를 반환했을 때의 위험은 무엇인가. Redis 공식 블로그에서도 데이터 변경 빈도에 따라 TTL 범위를 제시하는데, 거의 변하지 않는 데이터(참조 데이터, 설정값 등)는 수 시간~수 일, 가끔 변하는 데이터(사용자 프로필, API 응답)는 5~60분을 권장한다.

 

메인홈 데이터의 변경 빈도는 하루 수 회 수준이다. 관리자가 데이터를 수정하면 도메인 이벤트가 즉시 캐시를 무효화하므로, TTL은 이벤트가 누락되었을 때를 대비한 안전망 역할이다. Redis 공식 블로그의 기준에서 가끔 변하는 데이터에 해당하는 5~60분 범위의 상한인 TTL 5분으로 설정했다. 대부분의 갱신은 도메인 이벤트가 처리하고, TTL은 혹시 모를 상황에서 최대 5분 안에 자동 만료되는 보험이다.

 

글로벌 캐시 구조

 

5-4. 글로벌 캐시의 구조적 한계

k6를 사용해 같은 VPC 내 EC2 Spot에서 1,000 VU 부하 테스트를 수행했다(ramping-vus, 0 → 200 → 1,000 → 0, 약 5.5분).

글로벌 캐시 도입 효과

Redis 캐시 도입만으로 p95가 434ms로 떨어졌다. Throughput도 2배 이상 증가했다. DB 직접 쿼리 대비 확실한 개선이라고 볼 수 있다. 

기존 인프라 테스트
글로벌 캐시 도입

 

하지만 CloudWatch 메트릭을 확인해보니, 이 구조가 갖는 한계도 함께 드러났다.

 

 

CloudWatch 로그

 

캐시 히트여도 매 요청마다 Redis로 네트워크 왕복이 발생하고, 응답 JSON을 매번 역직렬화해야 한다. 인스턴스가 늘어나면 그만큼 네트워크 트래픽이 증가해 글로벌 캐시 자체가 병목으로 전환될 수 있는 수치였다.

6. Caffeine 로컬 캐시 + Redis Pub/Sub

6-1. 채널톡 기술 블로그에서 얻은 인사이트

글로벌 캐시의 한계를 확인한 시점에서, 채널 톡의 로컬 캐시 도입기https://channel.io/ko/team/blog/articles/tech-distributed-cache-1-67a392c5를 읽게 되었다. 

 

이 블로그의 핵심 아이디어는 Redis를 캐시 저장소가 아닌 무효화 메시징 채널로만 사용하는 것이었다. 읽기 경로에서 네트워크

비용을 완전히 제거하고, 캐시 무효화 신호만 Redis Pub/Sub으로 전파하는 구조다.

 

당연하게도 이 구조가 유리하다. 글로벌 캐시는 캐시 히트여도 네트워크 왕복 + JSON 역직렬화가 매 요청마다 발생한다. 

 

반면 로컬 캐시는 같은 JVM 힙에 객체 참조로 들고 있으므로 조회 비용이 사실상 0이다. 대신 다중 인스턴스 환경에서 캐시 정합성이라는 새로운 문제가 생기는데, 이것을 Redis Pub/Sub이 해결한다. 읽기는 네트워크를 타지 않고, 쓰기(무효화)만 네트워크를 타는 구조로 메인홈처럼 읽기가 압도적으로 많은 워크로드에서는 이상적인 분리라고 생각이 들었다.

 

6-2. 설계 과정에서 고민한 것들

채널톡 블로그에서 배운 점과 Festimap 상황에 맞게 주의해서 결정한 것들이 있다.

 

  1. 키 무효화 vs 키+값 동기화 : 무효화 시 키만 날리고, 다음 조회에서 DB 로드하는 방식을 선택했다. 새 값을 함께 전파하면 직렬화 비용이 추가되고, 두 인스턴스가 거의 동시에 수정하면 race condition이 발생할 수 있다. 키만 무효화하면 항상 DB에서 최신 데이터를 로드하므로 정합성이 보장된다. 관리자 변경이 하루 수 회 수준이니 이 DB 히트 비용은 무시할 수 있다.
  2. 메시지 유실 대비 두 가지 안전망 : Redis Pub/Sub은 At-most-once(최대 한번) 전달이라 메시지가 유실될 수 있다. 이를 보완하기 위해 두 가지 안전망을 설계했다.
    • Caffeine TTL 1분: 메시지가 유실되어도 최대 1분 후 자동 만료된다. 캐시 무효화를  포함한 부하 테스트에서 분당 12회 무효화를 수행해도 성능 차이가 거의 없었으므로 TTL을 짧게 잡아도 안전하다고 판단했다. 다만 5초 수준까지 줄이면 캐시 재생성이 너무 빈번해지므로, 메시지 유실 시에도 메인홈 데이터가 1분 정도 지연되는 것은 사용자 경험에 큰 영향이 없다는 점을 고려해 1분으로 설정했다.
    • 재연결 시 전체 캐시 클리어: Redis 연결이 끊겼다가 복구되면, 끊긴 동안 놓친 메시지가 있을 수 있으므로 전체 캐시를 비운다.

Pub/Sub + Caffeine 캐시 구조

이 구조에서 변한 핵심 코드는 `DistributedCacheManager`다. Caffeine 캐시를 내부에 두고 다음과 같이 동작한다.

읽기 경로 (get)

  • Caffeine 히트 → 바로 반환
  • Caffeine 미스 → DB 조회 → Caffeine에 저장 → 반환
  • Redis Pub/Sub은 관여하지 않음

무효화 경로 (invalidate)

  • 관리자가 데이터를 수정하면 도메인 이벤트 발생
  • 로컬 Caffeine 무효화 + Redis Pub/Sub으로 다른 인스턴스에 무효화 신호 전파

 

7. 부하테스트 비교 : 최종 결과

테스트 환경

  • 도구: k6 (ramping-vus, 0 → 200 → 1,000 → 0, 약 5.5분)
  • 실행 위치: 같은 VPC 내 EC2 Spot
  • 대상: ALB → EC2 2대 → MySQL + ElastiCache Valkey

Read-Only 테스트

Caffeine + Pub/Sub 도입

 

Read/Write : 캐시 무효화 발생 테스트

1,000 VU 읽기 트래픽과 분당 12회 관리자 캐시 무효화를 동시에 수행했다.

캐시 무효화 테스트

 

eviction 횟수가 거의 동일한 조건에서, Throughput은 29% 증가하고 p95는 39% 감소했다. 무효화가 발생해도 성능 영향이 미미한 이유는, eviction 직후 첫 요청만 DB로 가고 나머지는 곧바로 Caffeine 히트를 이어가기 때문이다.

 

CloudWatch Valkey 메트릭 비교 (캐시 무효화 테스트)

글로벌 캐시 메트릭
Caffeine + Pub/Sub

 

8. 마무리

이 개선 작업을 진행하면서 두 가지 종류의 배움이 있었다.

JSON 컬럼 도입은 직접 부딪혀야 알 수 있는 배움이었다. 단일 테이블 상속 전략의 한계를 글로만 읽었을 때는 와닿지 않았다. 실제로 NULL 컬럼이 늘어나는 것을 보고, 새 위젯 타입을 추가할 때마다 ALTER TABLE을 써야 하는 상황을 겪고 나서야 이 구조는 SaaS에 맞지 않는다는 판단이 섰다.

반면 Caffeine + Pub/Sub 도입은 남의 경험에서 배운 것이었다. 글로벌 캐시의 CloudWatch 지표를 보며 "읽기 경로에서 네트워크를 제거해야 한다"는 문제 인식까지는 도달했지만, 구체적으로 어떻게 해결할지는 채널톡 기술 블로그를 읽고 나서야 그림이 그려졌다. Redis를 캐시 저장소가 아닌 무효화 채널로만 쓴다는 발상, 키 무효화 vs 값 동기화의 트레이드오프, TTL과 Pub/Sub의 역할 분담같은 설계 판단들을 처음부터 혼자 도출하기는 어려웠을 것이다.

직접 부딪혀야 배우는 것과, 남의 경험에서 배울 수 있는 것. 둘 다 필요하다. 기술 블로그를 꾸준히 읽는 습관이 실제 프로젝트의 의사결정 품질에 영향을 준다는 것을 경험한 시간이었다.