Redis 캐시 도입 (1/3)

다음 편: [옷장을 부탁해] 2. 캐시 패턴과 무효화 전략

피드 목록 조회 API는 "옷장을 부탁해"에서 가장 많이 호출되는 엔드포인트다. 한 번 호출에 DB 쿼리가 100회 넘게 나가는데, 사용자 수가 늘어나면 이 숫자가 응답 시간을 직접 끌어올린다. 왜 이렇게 많은 쿼리가 발생하는지, 어디를 어떤 전략으로 줄여야 하는지 정리한다.

피드 조회의 현실

getFeedsByCursor()는 커서 기반 페이지네이션으로 피드를 가져오는 메서드다. 내부에서 일어나는 일을 순서대로 따라가면 문제가 보인다.

호출 체인은 이렇다.

// FeedServiceImpl.java
public FeedGetResponse getFeedsByCursor(FeedGetRequest request, WeatherFitUserDetails userDetails) {
    List<Feed> feeds = feedRepository.findWithCursor(request);    // 1회
    User loginUser = getUserOrThrow(userDetails.getUserId());     // 1회

    return new FeedGetResponse(
        feeds.stream()
            .map(f -> this.toFeedDto(f, loginUser))               // 피드 N개 × ?
            .toList(),
        // ...
    );
}

findWithCursor()가 피드 목록을 한 번에 가져오는 것까지는 괜찮다. 문제는 .map(f -> this.toFeedDto(f, loginUser))에서 시작된다. 피드 하나당 toFeedDto()가 호출되고, 그 안에서 또 여러 Repository를 호출하고, 각 Repository 결과에 대해 또 다시 쿼리가 나간다.

실제로 쿼리가 어떤 순서로 날아가는지 보면 문제가 명확해진다.

sequenceDiagram autonumber participant S as FeedService box 피드 레벨 participant FR as FeedRepo participant FLR as Like/Comment end box 옷 레벨 participant ClR as Clothes/Attr end participant S3 as S3Service rect rgb(232, 248, 232) Note over S, FR: 초기 조회 S->>FR: findWithCursor() FR-->>S: 피드 20개 end rect rgb(255, 230, 230) Note over S, S3: 피드 1개당 반복 (×20) S->>FR: findAllByFeed() FR-->>S: FeedClothes 목록 S->>FLR: countByFeed() — 좋아요 수 S->>FLR: countByFeed() — 댓글 수 S->>FLR: existsByFeedAndLikedUser() loop 옷 1개마다 S->>ClR: findById() — Clothes S->>ClR: findByClothes() — 속성 목록 loop 속성 1개마다 S->>ClR: findByClothesAttributeType() end S->>S3: getUrl(imageKey) S3-->>S: Presigned URL end end

분홍 영역이 피드 1개당 반복되는 구간이다. 피드 20개를 조회하면 이 영역 전체가 20번 돌아간다. 옷이 2~3개씩 달려 있으면 내부 루프까지 합쳐서 쿼리 횟수가 기하급수적으로 늘어난다.

피드 20개, 피드당 옷 평균 2.5개, 옷당 속성 평균 2개를 기준으로 계산하면 다음과 같다.

쿼리호출 위치횟수 공식20개 기준
findWithCursor()피드 목록 조회11
getUserOrThrow()로그인 유저 조회11
findAllByFeed()toFeedDtoN20
countByFeed() (좋아요)toFeedDtoN20
countByFeed() (댓글)toFeedDtoN20
existsByFeedAndLikedUser()toFeedDtoN20
findById() (옷)getFeedClothesDtoN × M50
findByClothes() (속성)getFeedClothesDtoN × M50
findByClothesAttributeType()getClothesAttributeWithDefDtoN × M × P100
getUrl() (Presigned URL)getFeedClothesDtoN × M50
합계약 132회

단 한 번의 API 호출에 DB 쿼리 80회 + 네트워크 I/O 50회가 발생한다. 피드에 달린 옷이나 속성이 많아지면 이 숫자는 더 올라간다.

N+1 쿼리가 생기는 구조

N+1 문제의 진원지는 toFeedDto() 메서드다. 코드를 한 줄씩 뜯어보면 어디서 쿼리가 터지는지 정확히 보인다.

private FeedDto toFeedDto(Feed feed, User loginUser) {
    return FeedDto.from(
        feed,
        feedClothesRepository.findAllByFeed(feed).stream()
            .map(this::getFeedClothesDto).toList(),   // ① + ②③④⑤
        feedLikeRepository.countByFeed(feed),          // ⑥
        commentRepository.countByFeed(feed),           // ⑦
        feedLikeRepository.existsByFeedAndLikedUser(feed, loginUser)  // ⑧
    );
}

이 메서드 하나에서 네 종류의 쿼리가 나간다.

피드 레벨 쿼리

feedClothesRepository.findAllByFeed(feed) — 이 피드에 연결된 옷 목록을 가져온다. 피드마다 1회씩, 20개면 20회.

feedLikeRepository.countByFeed(feed) — 좋아요 수 집계. 단순한 SELECT COUNT(*) 쿼리지만 피드마다 별도로 날린다.

commentRepository.countByFeed(feed) — 댓글 수 집계. 마찬가지로 피드마다 1회.

feedLikeRepository.existsByFeedAndLikedUser(feed, loginUser) — 현재 사용자가 이 피드에 좋아요를 눌렀는지 확인한다. 역시 피드마다 1회.

여기까지만 해도 피드 20개 × 4쿼리 = 80회다.

옷 레벨 쿼리 — NMP 폭발

진짜 문제는 getFeedClothesDto()에서 발생한다.

private FeedClothesDto getFeedClothesDto(FeedClothes fc) {
    Clothes c = clothesRepository.findById(fc.getClothes().getId())
        .orElseThrow();                                              // ①
    List<ClothesAttribute> byClothes =
        clothesAttributeRepository.findByClothes(c);                 // ②

    List<ClothesAttributeWithDefDto> defDtoList = byClothes.stream()
        .map(this::getClothesAttributeWithDefDto)                    // ③ × 속성 개수
        .toList();

    String imageKey = fc.getClothes().getImageKey();
    String url = imageKey == null ? null : s3Service.getUrl(imageKey); // ④ 네트워크 I/O
    return FeedClothesDto.from(fc, url, defDtoList);
}

findById()로 Clothes 엔티티를 다시 조회한다. FeedClothes에서 이미 Clothes ID를 알고 있는데 영속성 컨텍스트에 없으면 매번 DB를 찌른다.

findByClothes()로 옷의 속성 목록을 가져온다. 옷 하나당 1회.

그리고 getClothesAttributeWithDefDto() 안에서 또 한 번 터진다.

private ClothesAttributeWithDefDto getClothesAttributeWithDefDto(ClothesAttribute c) {
    ClothesAttributeType clothesAttributeType = c.getOption().getClothesAttributeType();
    List<String> selectableValues =
        selectableValueRepository.findByClothesAttributeType(clothesAttributeType)  // ⑤
            .stream()
            .map(SelectableValue::getOption)
            .toList();
    // ...
}

selectableValueRepository.findByClothesAttributeType()속성 하나마다 호출된다. 옷이 2개이고 속성이 각 2개라면 이것만 4회. 피드 20개에 걸치면 순식간에 수십 회가 쌓인다.

이것이 N × M × P 쿼리 폭발이다. N은 피드 수, M은 피드당 옷 수, P는 옷당 속성 수. 세 차원이 곱해지기 때문에 데이터가 조금만 늘어도 쿼리 수가 급격히 증가한다.

N+1의 핵심은 "루프 안의 쿼리"

for문이나 stream.map() 안에서 Repository를 호출하면 N+1이 발생한다. 코드 리뷰에서 이 패턴을 발견하면 바로 경고를 울려야 한다.

캐시 vs fetch join

N+1 쿼리가 전부 같은 성격인 건 아니다. 어떤 쿼리는 캐시로 해결하는 게 맞고, 어떤 쿼리는 fetch join으로 해결해야 한다. 이 판단을 잘못하면 캐시 무효화 지옥에 빠지거나, 불필요하게 복잡한 JOIN을 만들게 된다.

판단 기준은 다음 다이어그램으로 정리할 수 있다.

flowchart TD A{"쿼리 결과가
단순 스칼라 값?"} B{"자주 변하는
데이터?"} C["캐시 적합"] D["fetch join 적합"] E{"연관 엔티티
로딩?"} F["캐시 + 짧은 TTL"] G["fetch join 적합"] A -->|"예: count, exists"| B A -->|"아니오: 엔티티 목록"| E B -->|"아니오"| C B -->|"예"| F E -->|"예"| G E -->|"아니오"| D style A fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style B fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style E fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style C fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style F fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style D fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style G fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000

초록 노드가 각 쿼리의 최종 해결 전략이다. 이 분기를 프로젝트의 실제 쿼리에 대입하면 명확해진다.

캐시가 적합한 쿼리

  • feedLikeRepository.countByFeed(feed) — 좋아요 수. 결과가 정수 하나. 좋아요가 눌릴 때만 변한다.
  • commentRepository.countByFeed(feed) — 댓글 수. 마찬가지로 정수 하나.
  • feedLikeRepository.existsByFeedAndLikedUser(feed, loginUser) — 좋아요 여부. boolean 하나. 해당 유저가 좋아요를 토글할 때만 변한다.

이 세 가지는 단순 스칼라 값이고, 변경 시점이 명확하다. 캐시에 넣어두고 좋아요/댓글 이벤트가 발생할 때만 무효화하면 된다.

fetch join이 적합한 쿼리

  • feedClothesRepository.findAllByFeed(feed) — 피드에 연결된 옷 목록. 엔티티 컬렉션이다.
  • clothesRepository.findById(clothesId) — 옷 엔티티 조회.
  • clothesAttributeRepository.findByClothes(clothes) — 옷의 속성 목록.
  • selectableValueRepository.findByClothesAttributeType(type) — 선택 가능한 값 목록.

이것들은 연관 엔티티 로딩이다. 처음 피드를 조회할 때 JOIN으로 한 번에 가져오는 게 효율적이다. 캐시에 넣으려면 엔티티 전체를 직렬화해야 하고, 옷 정보가 바뀔 때마다 관련된 모든 피드의 캐시를 찾아서 무효화해야 한다. 관리 비용이 이득보다 크다.

캐시 vs fetch join 오판의 대가

연관 엔티티를 캐시에 넣으면 무효화 범위가 걷잡을 수 없이 넓어진다. 옷 하나의 속성이 바뀌면 그 옷이 포함된 모든 피드의 캐시를 찾아서 날려야 하는데, 이건 캐시가 해결할 문제가 아니다.

Presigned URL의 숨은 비용

N+1만큼 눈에 잘 띄지는 않지만, Presigned URL 생성도 심각한 병목이다. DB 쿼리가 아니라 네트워크 I/O이기 때문에 더 느릴 수 있다.

S3ServiceImpl.getUrl()의 코드를 보면 매번 AWS에 서명 요청을 보내는 구조다.

public String getUrl(String key) {
    GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
        .signatureDuration(Duration.ofSeconds(s3Properties.presignedUrlExpirationTime()))
        .getObjectRequest(GetObjectRequest.builder()
            .bucket(s3Properties.bucket())
            .key(key)
            .build())
        .build();

    return s3Presigner.presignGetObject(presignRequest).url().toString();
}

s3Presigner.presignGetObject()가 호출될 때마다 AWS SDK는 자격 증명을 확인하고, 서명을 생성하고, URL을 조립한다. 네트워크 지연이 발생하는 외부 호출이다.

이 호출이 어디서 일어나는지 보면 비용이 체감된다.

sequenceDiagram autonumber participant S as FeedService participant GCD as getFeedClothesDto participant S3 as S3Service participant AWS as AWS S3 rect rgb(255, 230, 230) Note over S, AWS: 피드 1개 · 옷 3개 기준 loop 옷 1개마다 S->>GCD: getFeedClothesDto(fc) GCD->>S3: getUrl(imageKey) S3->>AWS: Presign 요청 Note right of AWS: 자격 증명 확인
서명 생성
URL 조립 AWS-->>S3: Presigned URL S3-->>GCD: URL 문자열 GCD-->>S: FeedClothesDto end end

분홍 영역의 루프가 옷 개수만큼 반복된다. 피드 20개에 옷이 평균 2.5개라면 50회의 Presign 요청이 날아간다. DB 쿼리는 밀리초 단위지만, 네트워크 I/O는 수십 밀리초가 기본이다.

Presigned URL은 일정 시간 동안 유효하다는 특성이 있다. 만료 시간이 10분이면, 같은 이미지 키에 대해 10분 안에 생성된 URL은 사실상 같은 역할을 한다. 이건 캐시에 완벽하게 맞는 패턴이다.

Presigned URL 캐시의 TTL 전략

URL 만료 시간보다 TTL을 짧게 설정해야 한다. 만료 시간이 10분이면 TTL은 8분 정도로 잡아서, 클라이언트가 받은 URL이 최소 2분은 유효하도록 여유를 둔다.

자주 하는 실수

모든 N+1을 캐시로 해결하려는 것

"쿼리가 많이 나가니까 전부 캐시에 넣자"는 가장 위험한 접근이다. 연관 엔티티 로딩까지 캐시로 해결하면 무효화 로직이 서비스 전체에 퍼진다. 옷 하나 수정했는데 어떤 피드 캐시를 날려야 하는지 추적하다 보면, 캐시 없는 것보다 더 복잡한 시스템이 된다. 집계 데이터는 캐시, 연관 엔티티는 fetch join — 이 구분을 먼저 하고 시작해야 한다.

[!DANGER] Presigned URL을 영구 캐시하는 것

Presigned URL에는 만료 시간이 있다. TTL 없이 캐시하면 만료된 URL을 클라이언트에 내려주게 되고, 이미지가 깨진다. 반드시 URL 만료 시간보다 짧은 TTL을 설정해야 한다. 그리고 이미지가 교체되면 캐시도 함께 무효화해야 한다는 점을 잊지 말아야 한다.

[!DANGER] fetch join으로 모든 연관 관계를 한 번에 가져오는 것

fetch join도 만능이 아니다. 여러 컬렉션을 동시에 fetch join하면 카테시안 곱이 발생해서 결과 행이 폭발한다. Feed → FeedClothesFeed → FeedLike를 동시에 fetch join하면 옷 3개 × 좋아요 50개 = 150행이 되는 식이다. 컬렉션 fetch join은 한 번에 하나씩만, 나머지는 @BatchSize나 별도 쿼리로 분리하는 게 안전하다.