카페 단건 조회 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형태로 저장된다.::는 _캐시 이름과 키 사이에 자동으로 붙는 구분자_이다.
동작 순서
- 메서드가 호출되면 Spring이 먼저 Redis에서
cafe::{cafeId}를 찾는다. - 값이 있으면 메서드 안의 코드를 실행하지 않고 Redis 값을 바로 반환한다. (DB 조회 쿼리 X)
- 값이 없으면 메서드를 실행하고, 반환값을 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_DELETEDsearchDetail()에 삭제 여부를 확인하는 코드를 넣었지만, 캐시에 값이 있으면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을 사용하여 해결하였다.
'개발진행목록 > 스터디카페 통합 조회 플랫폼' 카테고리의 다른 글
| [studyhub] 카페 검색 결과 캐싱 (0) | 2026.10.19 |
|---|---|
| [studyhub] Refresh Token 저장소를 Redis로 옮기기 (0) | 2026.10.05 |
| [studyhub] Redis 토큰 블랙리스트 구현 (1) | 2026.09.19 |
| [studyhub] ArchUnit으로 모듈 경계 검증하기 (0) | 2026.09.14 |
| [studyhub] 중복 예약 방지와 좌석 이동 기능 구현 (0) | 2026.09.13 |