티스토리 뷰
1. 회고 카드(Section)와 회고 보드(Retrospective)
우리 프로젝트는 1개의 회고 보드에 여러 개의 회고 카드를 작성할 수 있다. 즉, 회고 보드와 회고 카드는 1:N 관계이다. 따라서 회고 보드를 조회하면 작성된 모든 회고 카드를 조회할 수 있다.
2. 새로운 회고 카드(Section) 생성하기
우리 프로젝트는 회고 보드(retrospective)에 작성된 모든 회고 카드(section)를 DB에서 조회한다.
GET /sections를 호출하여 모든 회고 카드를 조회하는 것을 확인할 수 있다.
새로운 회고 카드를 등록하면 POST /sections 요청을 통해 DB에 새로운 회고 카드를 등록한다.
생성된 회고 카드를 포함하는 모든 회고 카드를 가져오기 위해서 다시 GET /sections를 호출하고 있다.
3. 내가 생각했던 로직이 아니었다.
처음에 나는 새로운 회고 카드(Section)가 생성될 때 다음과 같이 처리될 것으로 예상했다.
- 회고 보드(Retrospective)에 작성된 모든 회고 카드(Section)를 조회한다.
- 새로운 회고 카드를 생성한다.
- 1번에서 조회한 데이터를 그대로 사용하면서, 새로 생성된 회고 카드만 화면에 추가로 띄운다.
그러나 실제로는 3번처럼 처리되지 않고, DB에서 다시 모든 회고 카드를 조회하는 방식으로 구현되어 있었다.
여기에서 성능 개선의 필요성을 느꼈는데, 이유는 조회 쿼리에 여러 개의 join이 포함되어 있었기 때문이다.
Join 테이블이 많으면 처리할 데이터가 많아지므로 응답 시간이 길어지는데, 우리 프로젝트는 빈번히 조회 쿼리를 발생하고 있기 때문에 성능 문제가 발생할 것으로 생각했다.
Hibernate:
select
s1_0.section_id,
s1_0.user_id,
u1_0.username,
s1_0.content,
s1_0.like_cnt,
ts1_0.section_name,
s1_0.created_date,
u1_0.thumbnail,
ai1_0.action_item_id,
ai1_0.created_date,
ai1_0.retrospective_id,
ai1_0.section_id,
ai1_0.team_id,
ai1_0.updated_date,
ai1_0.user_id,
kt1_0.kudos_target_id,
kt1_0.created_date,
kt1_0.section_id,
kt1_0.updated_date,
kt1_0.user_id
from
section s1_0
left join
action_item ai1_0
on ai1_0.section_id=s1_0.section_id
left join
kudos_target kt1_0
on kt1_0.section_id=s1_0.section_id
left join
comment c1_0
on s1_0.section_id=c1_0.section_id
join
user u1_0
on u1_0.user_id=s1_0.user_id
join
template_section ts1_0
on ts1_0.id=s1_0.template_section_id
where
s1_0.retrospective_id=?
group by
s1_0.section_id
order by
s1_0.like_cnt desc,
s1_0.created_date desc
4. Redis 캐싱을 사용하게 된 이유
조회한 모든 회고 카드(Section)를 Redis 캐시에 저장하고 조회 쿼리가 발생할 때마다 DB가 아닌 캐시 되어 있는 데이터를 반환할 것이다.
4-1. Spring Cache
Spring Cache는 애플리케이션에서 캐싱을 쉽게 사용할 있도록 지원하는 스프링 프레임워크의 기능이다.
메서드의 호출 결과를 캐시에 저장하고 동일한 인자로 메서드를 다시 호출하면 캐시 된 결과를 받을 수 있다. @Cacheable, @CachePut, @CacheEvict 애너테이션을 사용하여 캐시 조회 및 저장이 가능하다.
- @Cacheable : 메서드 반환 결과를 캐시에 저장하고, 이후에 같은 파라미터로 호출될 경우 DB를 거치지 않고 캐시 데이터를 반환한다.
- @CachePut : 메서드 실행 결과를 캐시에 저장한다.
- @CacheEvict : 캐시에 저장된 데이터를 삭제한다.
스프링 캐시의 구현체는 다음과 같다.
- 로컬 캐시 : Ehcache, Caffeine
- JVM 내부에서만 캐싱을 처리하며, 다른 애플리케이션 인스턴스와 공유되지 않는다.
- 분산 캐시 : Redis, Memcached
- 여러 애플리케이션 인스턴스에서 캐시를 공유할 수 있으며, 서버 간에 캐시 데이터를 유지할 수 있다.
우리 프로젝트는 1대의 인스턴스를 사용하기 때문에 로컬 캐시를 사용해도 되지만, 추후에 인스턴스를 확장할 수도 있기 때문에 분산 캐시를 사용하는 게 적절할 것이라 생각했다. 분산 캐시 중에서도 Redis를 사용하기로 하였는데 그 이유는 다음과 같다.
- Redis를 사용해 본 경험이 있기 때문에 작업에 어려움이 없다.
- 2022~2023년에도 꾸준히 Redis가 사용되고 있다.
5. 기존 코드를 수정한다.
5-1. 회고 카드(Section) 조회 API
@Cacheable 애너테이션을 사용하여 getSections() 메서드가 반환하는 데이터를 캐시 데이터에 저장한다.
127.0.0.1:6379> keys *
1) "sectionsInRetrospective-cache::100"
@Service
@RequiredArgsConstructor
@Slf4j
public class SectionService {
private final SectionRepository sectionRepository;
private final RetrospectiveRepository retrospectiveRepository;
private final ApplicationEventPublisher eventPublisher;
... 생략
// 회고 카드 전체 조회
@Transactional(readOnly = true)
@Cacheable(value = SectionCacheRepository.CACHE_KEY, key = "#request.retrospectiveId", cacheResolver = "customCacheResolver")
public List<GetSectionsResponseDto> getSections(GetSectionsRequestDto request) {
Retrospective findRetrospective = getRetrospective(request.getRetrospectiveId());
// 개인 회고 조회 시에 팀 정보가 필요 없다.
validatePersonalRetrospective(findRetrospective, request.getTeamId());
// 다른 팀의 회고 보드를 조회 할 수 없다
validateTeamAccess(request.getTeamId(), findRetrospective);
// 회고 카드 전체 조회
return sectionRepository.getSectionsAll(request.getRetrospectiveId());
}
}
5-2. 회고 카드(Section) 쓰기 작업 API
처음에는 쓰기 작업(생성, 수정, 삭제)이 발생하면 캐시 된 데이터를 가져와 수정하려고 했다. Redis에 저장된 데이터를 역직렬화하여 List 또는 Set으로 변환한 뒤, 해당 데이터에서 쓰기 작업이 발생한 회고 카드(Section)를 찾아 수정하는 방식이었다.
그러나 컬렉션에 담긴 데이터 중에서 수정할 회고 카드(Section)를 찾는 것도 결국 시간이 발생하기 때문에 좋지 않은 방법이라고 생각했다. 또한 DB에 저장된 데이터와 캐시된 데이터의 일관성이 일치할 것이란 보장도 없다.
데이터 일관성 문제를 해결하기 위해 읽기 전략으로는 Look Aside 패턴을, 쓰기 전략으로는 Write Around 패턴을 사용하였다.
- Look Aside 패턴 : 캐시 히트가 발생하면 캐시된 데이터를 반완하고, 캐시 미스가 발생하면 DB에서 조회하여 캐시에 저장 후 반환한다.
- Write Around 패턴 : 쓰기 작업이 발생하면 캐시된 데이터를 무효화한다.
아래는 트랜잭션 내에서 회고 카드(Section)가 생성될 때, 트랜잭션이 commit 되면 이벤트 리스너를 실행하도록 하였다.
이벤트 리스너는 캐시 되어 있는 데이터를 지우는 역할을 한다. => Write Around 패턴
@Service
@RequiredArgsConstructor
@Slf4j
public class SectionCacheListener {
private final CacheRepository<List<GetSectionsResponseDto>> cacheRepository;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void updateCacheWithNewSection(SectionCacheUpdateEvent event) {
String cacheKey = String.format("%s::%d", cacheRepository.getCacheKey(),
event.getRetrospectiveId());
try {
// 캐싱된 데이터를 가져온다.
List<GetSectionsResponseDto> cachingData = cacheRepository.getCacheDate(cacheKey);
// 캐싱된 값이 존재할 경우 생성된 회고 카드를 추가한다.
if (cachingData != null && !cachingData.isEmpty()) {
cachingData.add(GetSectionsResponseDto.from(event.getSection(), event.getUser()));
cacheRepository.saveCacheData(cacheKey, cachingData);
}
} catch (Exception ex) { // TODO 커스텀 예외 생성한다.
// 캐싱되어 있는 데이터를 삭제한다.
cacheRepository.deleteCacheData(cacheKey);
log.error("캐싱 데이터 갱신 중 오류가 발생했습니다.", ex);
}
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void deleteCacheHandle(SectionCacheDeleteEvent event) {
String cacheKey = String.format("%s::%d", cacheRepository.getCacheKey(),
event.getRetrospectiveId());
cacheRepository.deleteCacheData(cacheKey);
}
}
@Service
@RequiredArgsConstructor
@Slf4j
public class SectionService {
private final SectionRepository sectionRepository;
private final RetrospectiveRepository retrospectiveRepository;
private final ApplicationEventPublisher eventPublisher;
... 생략
// 회고 카드 생성 API
@Transactional
public CreateSectionResponseDto createSection(User user, CreateSectionDto request) {
Retrospective findRetrospective = getRetrospective(request.getRetrospectiveId());
TemplateSection findTemplateSection = getTemplateSection(request.getTemplateSectionId());
/**
* 회고 템플릿이 일치하는지 확인한다.
* 회고 템플릿이 일치하지 않으면 예외를 발생시킨다.
*/
validateTemplateMatch(findRetrospective, findTemplateSection);
// 회고 카드 생성
Section createSection = createSection(request.getSectionContent(), findTemplateSection,
findRetrospective, user);
Section saveSection = sectionRepository.save(createSection);
// 캐싱된 데이터에 새로운 회고 카드를 추가한다.
eventPublisher.publishEvent(
new SectionCacheDeleteEvent(request.getRetrospectiveId()));
// Entity를 Dto로 변환하여 반환한다.
return convertCreateSectionResponseDto(request, createSection);
}
}
6. 개선 결과
6-1. 성능 개선 전
매번 DB에서 조회 쿼리를 발생하여 데이터를 조회한다.
1000개의 데이터를 불러오는데 71ms가 소요된다.
6-2. 성능 개선 후
캐시되어 있는 데이터가 존재할 경우 캐시 된 데이터를 반환한다.
1000개의 데이터를 불러오는데 24ms가 소요된다.