본문 바로가기
개발진행목록/스터디카페 통합 조회 플랫폼

[studyhub] 카페 상세 조회 캐싱

by o3oppp 2026. 10. 5.

카페 단건 조회 API

// CafeController
@GetMapping("/{cafeId}")
public ResponseEntity<CafeDetailResponse> searchCafeDetail(@PathVariable Long cafeId) {
    return ResponseEntity.ok(cafeService.searchDetail(cafeId));
}
// CafeService
@Transactional(readOnly = true)
public CafeDetailResponse searchDetail(Long cafeId) {
    Cafe cafe = validateCafe(cafeId);
    return CafeDetailResponse.from(cafe);
}

private void validateOwner(Long memberId) {
    CafeErrorCode errorCode = switch (ownerValidator.validate(memberId)) {
        case NOT_FOUND -> CafeErrorCode.OWNER_NOT_FOUND;
        case WITHDRAWN -> CafeErrorCode.OWNER_WITHDRAWN;
        case NOT_OWNER_ROLE -> CafeErrorCode.NOT_OWNER_ROLE;
        case VALID -> null;
    };
    if (errorCode != null) {
        throw new BusinessException(errorCode);
    }
}

단건 조회부터 캐싱하는 이유

  • 단건 조회는 cafeId 하나로 캐시 키를 만들 수 있다.
  • 검색은 지역, 키워드, 운영 시간 등 조건이 여러 개라 키를 만드는 방법이 복잡하다.
  • 단순한 것부터 적용하기로 했다.

함께 해결된 문제

  • 카페 등록 API는 응답 헤더에 Location: /cafe/{id}를 넣고 있다.
  • 그런데 /cafe/{id} API가 없어서 클라이언트가 이 주소로 요청하면 404가 발생한다.
  • 단건 조회 API를 만들면서 이 주소가 실제로 동작하게 되었다.
// 수정 전
public class CafeDetailResponse {
    private CafeSearchResponse cafe;
    private List<CafeImage> images;   // 엔티티
    private List<Seat> seats;         // 엔티티
    ...
}
// 수정 후
public class CafeDetailResponse {
    private CafeSearchResponse cafe;
    private List<CafeImageResponse> images;
    private List<SeatResponse> seats;
    ...
}

응답 DTO에서 엔티티를 제거한 이유

  • 처음에는 응답 DTO에 엔티티 리스트를 그대로 넣었다.
  • API 호출 시 순환 참조 문제가 발생하였다.
  • Seat는 cafe 필드로 Cafe를 참조한다.
  • Cafe는 seats 필드로 Seat 목록을 참조한다.
  • JSON으로 바꿀 때 Cafe -> seats -> Seat -> cafe -> seats -> ... 가 반복되어 끝나지 않았다.

DTO로 바꿔서 해결한 이유

  • SeatResponse에는 cafe 필드가 없으므로 다시 카페로 돌아가는 참조가 생기지 않는다.
  • @JsonIgnore로 특정 필드를 빼는 방법도 있다. 하지만 엔티티가 화면에 어떻게 보여질지까지 알게 되므로 쓰지 않았다.
  • 또한, 엔티티를 그대로 내보내면 엔티티에 필드가 추가될 때마다 API 응답도 같이 바뀐다.
  • DTO를 사용하여 응답 형식을 따로 관리하는 방식으로 진행하였다.

RedisConfig

토큰 블랙리스트와 Refresh Token은 문자열만 저장해서 StringRedisTemplate로 충분했다. 카페 상세 응답은 객체이므로 JSON으로 바꿔서 저장하는 설정이 필요하다.

@Configuration
@EnableCaching
public class RedisConfig {
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofHours(1))
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.json()));

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(config)
            .build();
    }
}

@EnableCaching이 필요한 이유

  • 해당 어노테이션이 없으면 @Cacheable을 붙여도 아무 동작도, 에러도 발생하지 않는다.

RedisConnectionFactory를 직접 만들지 않은 이유

  • application.yml에 spring.data.redis설정이 있으면 Spring Boot가 자동으로 만든다.
  • 직접 만들면 같은 설정이 두 곳에 생긴다.
  • 메서드 파라미터로 받으면 Spring Boot가 만든 것을 그대로 사용한다.

키 저장 방식을 문자열로 지정한 이유

  • 지정하지 않으면 키가 바이트 형태로 저장되어 redis-cli에서 키를 조회하면 \xac\sex\x00\x05...처럼 읽을 수 없는 글자로 나온다.
  • StringRedisSerializer를 지정하면 cafe::1처럼 읽을 수 있는 형태로 저장된다.

TTL을 1시간으로 둔 이유

  • 카페 정보가 바귀면 @CacheEvict로 캐시를 바로 지우므로 TTL이 없어도 정상 동작한다.
  • 하지만 나중에 수정 API를 추가하면서 @CacheEvict를 빠뜨릴 수 있다. 그런 경우에도 1시간이 지나면 캐시가 자동으로 지워지고 새 데이터를 다시 읽는다.

RedisSerializer.json()을 호출한 이유

  • 처음에는 GenericJackson2JsonRedisSerializer를 사용하여쓴ㄴ데, "4.0부터 deprecated, 향후 삭제 예정"이라는 경고가 나왔다.
  • Spring Data Redis 4.0부터 Jackson 3을 기본으로 쓰기 때문이다.
  • 대신 쓰라는 GenericJacksonJsonRedisSerializer는 기본 생성자가 없어서 new로 바로 만들 수 없다.
  • RedisSerializer.json()을 호출하면 설정이 끝난 객체를 바로 받을 수 있어서 사용하였다.

@Cacheable

// CafeService
@Cacheable(value = "cafe", key = "#cafeId")
@Transactional(readOnly = true)
public CafeDetailResponse searchDetail(Long cafeId) {
    Cafe cafe = validateCafe(cafeId);
    return CafeDetailResponse.from(cafe);
}
  • value는 캐시 이름, key는 캐시 키다.
  • #cafeId는 메서드 파라미터 cafeId의 값을 뜻한다.
  • Redis에는 cafe::1 형태로 저장된다. ::는 _캐시 이름과 키 사이에 자동으로 붙는 구분자_이다.

동작 순서

  1. 메서드가 호출되면 Spring이 먼저 Redis에서 cafe::{cafeId}를 찾는다.
  2. 값이 있으면 메서드 안의 코드를 실행하지 않고 Redis 값을 바로 반환한다. (DB 조회 쿼리 X)
  3. 값이 없으면 메서드를 실행하고, 반환값을 Redis에 저장한다.

서비스 메서드에 붙인 이유

  • 컨트롤러에 붙이면 ResponseEntity 객체 전체가 저장된다. HTTP 상태 코드, 헤더는 캐시할 대상이 아니다.
  • 서비스가 반환하는 DTO만 저장하는 것이 맞다고 판단하였다.

같은 클래스 안에서 호출하면 캐시가 동작하지 않는다

  • @Cacheable은 Spring이 클래스를 한 번 감싼 객체(프록시)를 통해 동작한다.
  • 같은 클래스의 다른 메서드에서 searchDetail()을 호출하면 이 감싼 객체를 거치지 않으므로 캐시를 사용하지 않는다.
  • @Transactional도 같은 이유로 같은 클래스 안에서 호출하면 적용되지 않는다.

@CacheEvict

@CacheEvict(value = "cafe", key = "#cafeId")
@Transactional
public void deleteCafe(Long cafeId, Long memberId) {
    validateOwner(memberId);
    Cafe cafe = validateCafe(cafeId);
    if (!cafe.isOwnedBy(memberId)) {
        throw new BusinessException(CafeErrorCode.NOT_CAFE_OWNER);
    }
    cafe.delete();
}

카페 삭제 시 캐시도 지우는 이유

  • @CacheEvict는 Redis에 저장된 캐시를 삭제한다. 이것이 없으면 다음과 같은 일이 발생한다.
    10:00  카페 1번 상세 조회 → DB에서 읽고 Redis에 cafe::1 저장
    10:05  카페 1번 삭제 → DB의 status가 DELETED로 바뀜
    10:06  카페 1번 상세 조회 → Redis에 cafe::1이 있으므로 그 값을 그대로 반환
         → 삭제된 카페 정보가 정상적으로 조회됨
    11:00  TTL 1시간이 지나 cafe::1이 자동 삭제됨
    11:01  카페 1번 상세 조회 → 그제서야 ALREADY_DELETED
  • searchDetail()에 삭제 여부를 확인하는 코드를 넣었지만, 캐시에 값이 있으면 searchDetail() 안의 코드가 실행되지 않는다.
  • 삭제할 때 cafe::1 키를 같이 지우면 다음 조회에서 Redis에 값이 없으므로 searchDetail()이 실행되고 ALREADYT_DELETED가 반환된다.
  • 삭제가 실패하면 캐시도 지워지지 않는다.

value와 key를 @Cacheable과 똑같이 맞춘 이유

@Cacheable(value = "cafe", key = "#cafeId")    // 조회: cafe::1 저장
@CacheEvict(value = "cafe", key = "#cafeId")   // 삭제: cafe::1 삭제
  • Redis 키는 value와 key를 합쳐서 cafe::1 형태로 만들어진다.
  • 둘 중 하나라도 다르면 다른 이름의 키를 지우게 되며, 에러는 발생하지 않는다.
  • #cafeId는 메서드 파라미터 이름을 가리킨다. deleteCafe()의 파라미터 이름도 cafeId 이므로 같은 값이 들어간다.

카페 등록에는 붙이지 않은 이유

  • 새로 등록한 카페는 새 ID를 받는다. 그 ID로 저장된 캐시는 아직 없으므로 지울 것이 없다.
  • 단, 검색 결과를 캐싱하게 되면 등록할 때도 캐시를 지워야 한다. 새 카페가 기존 검색 결과에 포함되어야 하기 때문이다.

트러블 슈팅

두 번째 조회에서 에러가 나던 문제

Could not read JSON: Cannot construct instance of
`com.studyhub.cafe.dto.response.CafeDetailResponse`
(no Creators, like default constructor, exist)
  • 첫 번째 조회에서는 정상이었다. DB에서 읽어 응답하고, Redis에 JSON으로 저장했다.
  • 두 번째 조회에서 Redis의 JSON을 다시 CafeDetailResponse 객체로 바꾸다가 실패했다.
  • Jackson이 JSON을 객체로 바꾸려면 객체를 만들 생성자가 필요하다.
  • 응답 DTO는 private 생성자와 from() 메서드를 통해서만 객체 생성이 가능하였다.
  • Jackson은 파라미터가 있는 private 생성자는 사용하지 않는다.
  • @NoArgsConstructor(access = AccessLevel.PRIVATE를 추가해서 해결했다.
  • private 이므로 외부에서는 여전히 from()으로만 객체를 만들 수 있다.

바깥 DTO만 고쳤을 때 같은 에러가 다시 난 문제

  • CafeDetailResponse에만 기본 생성자를 추가하였더니 에러 메시지의 클래스 이름이 바뀌어 같은 에러가 발생하였다.
  • 연관된 DTO 모두에 기본 생성자를 추가하여 해결하였다.

삭제 응답은 성공인데 DB에 반영되지 않던 문제

public void deleteCafe(Long cafeId, Long memberId) {   // @Transactional 없음
    Cafe cafe = validateCafe(cafeId);
    cafe.delete();
}
  • @Transactional이 없으면 findById()의 트랜잭션이 조회 직후 끝난다.
  • 그 뒤에 cafe.delete()로 상태를 바꿔도 JPA가 변경을 감지하지 않아 UPDATE 쿼리가 실행되지 않는다.
  • 응답은 204로 성공이지만 DB의 status는 ACTIVE 그대로였다.
  • @Transactional을 사용하여 해결하였다.