백엔드프론트엔드

Caffeine API 캐싱 + DB 인덱스 V10 + 약관 재동의 + a11y 폴리쉬 — POP-SPOT v2.19 · v2.20 (+v2.20.1/2)

김동현
··4분 읽기

v2.19 성능·컴플라이언스 — Caffeine API 캐싱으로 상위 팝업 조회 p95 230ms→63ms, V10 마이그레이션 복합 인덱스, 약관 재동의 모달, OAuth state 강화. v2.20 UI 토큰·signup honeypot·a11y. v2.20.1/2 핫픽스 포함.

운영 안정을 높이는 한 달 속의 세 번째 배치. v2.18·v2.18.1 이 눈에 보이는 UX 였다면 v2.19·v2.20 은 성능·일관성·법적 의무·접근성 조정.

이 글에서 다루는 것

  • Caffeine 과 Spring @Cacheable 을 엮어 API 계층 캐싱
  • DB 인덱스 V10 — 자주 쓰는 쿼리의 복합 인덱스
  • 약관 재동의 모달 — PIPA 변경 시 기존 회원 동의 필요
  • OAuth state 강화 — CSRF 방어
  • v2.20 — UI 토큰, signup honeypot, a11y aria-label
  • v2.20.1/2 — v2.19 CacheConfig 좀비 누락, Spotless 재포맷
  • 모르는 단어 한 줄로

    용어한 줄 설명
    Caffeine자바용 로컬 메모리 캐시 라이브러리. Spring @Cacheable 과 조합하면 메서드 결과 자동 캐싱
    p95수행 시간의 95번째 백분위
    복합 인덱스여러 컬럼을 묶은 DB 인덱스. WHERE 에 여러 조건 있을 때 이득
    honeypot사람 눈에 안 보이는 필드. 봇이 자동 입력하면 차단
    a11yaccessibility. 스크린 리더·키보드 사용자를 고려한 접근성

    v2.19 — 성능·컴플라이언스

    Caffeine API 캐싱

    java
    // v2.19 — CacheConfig.java (신규). Caffeine 메모리 캠시 제조
    @Configuration
    @EnableCaching
    public class CacheConfig {
      @Bean
      public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager(
            "topPopups", "popupDetail", "mapMarkers"
        );
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1_000)
            .expireAfterWrite(Duration.ofMinutes(5))
            .recordStats()
        );
        return manager;
      }
    }

    해석 — 한 줄씩

  • @EnableCaching — 이 어노테이션 없으면 @Cacheable 무시. v2.20.1 핫픽스의 원인
  • CaffeineCacheManager("topPopups", ...) — 캐시 이름 미리 등록. @Cacheable("topPopups") 다음에 쓴다
  • maximumSize(1_000) — 1000 엔트리 넘으면 LRU 제거. OOM 방지
  • expireAfterWrite(5분) — 5 분 후 만료. 데이터 신선도와 일관성 트레이드오프
  • recordStats() — hit/miss 수집. 어드민 메트릭으로 노출
  • 파일: popspot-backend/src/main/java/com/popspot/service/PopupStoreService.java

    java
    // v2.19 / v2.20.1 — PopupStoreService.findTopByRegion()
    @Cacheable(value = "topPopups", key = "#region + ':' + #limit")
    public List<PopupStoreResponse> findTopByRegion(String region, int limit) {
      return repository.findVisibleTopByRegion(region, limit);
    }

    해석

  • key = "#region + ':' + #limit" — SpEL 으로 파라미터 조합을 키로. 수동:20 과 강남:10 각각 별도 캐시
  • 메서드 시그니처 변경 없이 어노테이션 하나로 캐싱 도입
  • 결과: 상위 팝업 조회 p95 = 230ms → 63ms. 캬시 적중 시 1~2ms.

    DB 인덱스 V10

    파일: popspot-backend/src/main/resources/db/migration/V10__indexes.sql

    sql
    -- v2.19 — V10__indexes.sql
    CREATE INDEX idx_popup_status_visible_end_date
      ON popup_store (status, visible, end_date)
      WHERE status IN ('ACTIVE','UPCOMING') AND visible = true;
    
    CREATE INDEX idx_wishlist_user_popup
      ON wishlist (user_id, popup_id);
    
    CREATE INDEX idx_notification_user_read
      ON notification (user_id, read_at);

    해석

  • idx_popup_status_visible_end_date — 부분 인덱스 (WHERE 절 포함). 조건에 맞는 row 만 인덱스해서 크기 절약 + 조회 빠름
  • (user_id, popup_id) 순서 — 위시리스트는 한 사용자의 팝업 목록 조회가 주라 user_id 먼저가 유리
  • (user_id, read_at) — 알림은 내 안 읽은 개수 쿼리가 트래픽 주력
  • 약관 재동의 시스템

    파일: popspot-backend/src/main/java/com/popspot/entity/User.java

    java
    // v2.19 — User 엔티티에 동의 버전 추적 컬럼 추가
    @Column
    private String agreedTermsVersion;
    @Column
    private String agreedPrivacyVersion;

    파일: popspot-frontend/src/lib/termsVersion.ts + popspot-frontend/src/app/page.tsx (모달 표시)

    typescript
    // v2.19 — 프론트의 현재 약관 버전 상수
    const CURRENT_TERMS = '2026-05-25';
    const CURRENT_PRIVACY = '2026-05-25';
    
    const needsReconfirm =
      user.agreedTermsVersion !== CURRENT_TERMS ||
      user.agreedPrivacyVersion !== CURRENT_PRIVACY;
    
    {needsReconfirm && <ModalBlock>약관이 변경되었음. 재동의 필요</ModalBlock>}

    해석

  • 약관 보관 장소는 정적 파일. 현재 버전을 소스코드 상수로 둔 다음 사용자 동의 버전과 비교
  • 맞지 않으면 ModalBlock 으로 강제 닫기 차단. 동의하기 전엔 아무 행동 불가
  • OAuth state 강화

    파일: popspot-backend/src/main/java/com/popspot/service/OAuthStateService.java

    java
    // v2.19 — OAuthStateService.issueState(). CSRF 방어·Redis 기반 1회용
    public String issueState() {
      byte[] bytes = new byte[32];
      secureRandom.nextBytes(bytes);
      String state = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    
      redisTemplate.opsForValue().set(
          "oauth:state:" + state, "valid",
          Duration.ofMinutes(5)
      );
      return state;
    }
    
    public boolean consumeState(String state) {
      Boolean deleted = redisTemplate.delete("oauth:state:" + state);
      return Boolean.TRUE.equals(deleted);
    }

    해석

  • secureRandom + 32 바이트 — 추측 불가능한 랜덤
  • Redis 에 5 분 TTL 보관 — 서버 재기동해도 분실 안 됨
  • consumeState — 1 회용. 다시 쓰면 false. 공격자가 state 가로채도 다시 쓸 수 없음

  • v2.20 — a11y 와 UI 폴리쉬

    honeypot

    파일: popspot-frontend/src/app/signup/page.tsx 안의 honeypot 입력란

    typescript
    {/* v2.20 — signup 폼 내 honeypot 필드. 봇 탐지용 */}
    <input
      type="text"
      name="company"
      tabIndex={-1}
      autoComplete="off"
      style={{
        position: 'absolute',
        left: '-9999px',
        width: '1px',
        height: '1px',
        opacity: 0,
      }}
    />

    해석

  • position: absolute; left: -9999px — 시각적으로 화면 밖으로. 일반 사용자는 접근 불가
  • tabIndex={-1} — 탭 키보드로도 못 들어감
  • 봇은 보통 모든 input 을 채움 → company 에 값이 있으면 서버가 조용히 400 반환. 구분 메시지 안 되돌려주면 봇이 항변하기 어려움

  • v2.20.1 — CacheConfig 좀비 누락 핫픽스

    증상

    v2.19 배포 후 p95 개선 안 됨. 어드민 캬시 메트릭 보니 hit 가 0.

    원인

    PopupStoreService 에 @Cacheable 은 붙였는데 CacheConfig 의 빈이 등록되지 않아 Spring 이 명령을 조용히 무시. 메서드 시그니처가 v2.18 의 findVisibleTopByRegion 과 달라 교체 과정에서 @Cacheable 이 명시적 wiring 을 놓치몈.

    수정

    파일: popspot-backend/src/main/java/com/popspot/service/PopupStoreService.java

    java
    // v2.19 / v2.20.1 — PopupStoreService.findTopByRegion()
    @Cacheable(value = "topPopups", key = "#region + ':' + #limit")
    public List<PopupStoreResponse> findTopByRegion(String region, int limit) {
      return repository.findVisibleTopByRegion(region, limit);
    }

    Spring AOP 가 제대로 잡고 재배포.


    v2.20.2 — Spotless JavaDoc reflow 5 번째 핫픽스

    v1.4 이후 주기적으로 터지는 일이다. 한국어 멀티라인 JavaDoc 이 google-java-format 재포맷에 잡음. 8 파일의 주석을 100 컬럼 이내로 콤팩트화.

    교훈 — 주석은 가능하면 한 줄로. 여러 줄 쓸 때도 각 줄이 쿠렌 이내로 잘리게 미리 끊어 두면 재포맷 충돌이 적다.


    관련 글

  • 이전 — v2.18, 출시 직후 UX 보강
  • 다음 — v2.20.3, SEO 봇 인덱싱 함정 + RSS 2.0
  • 공유

    댓글