라이켓은 다양한 문화생활 정보를 공유하고 나만의 문화생활 기록을 남길 수 있는 서비스를 제공하고 있습니다.
태그별, 지역별로 관심있는 정보들만 골라보고 쉽게 문화생활을 즐겨보세요.
안녕하세요. LIKET팀의 백엔드 개발자 민경찬입니다. 오늘은 Nest.js
에서 조회수 증가 로직을 개선했던 이야기를 해보려고 합니다.
기존 조회수 증가 로직은 매우 간단했습니다.
await this.prisma.content.update({
where: {
idx,
},
data: {
viewCount: {
increment: 1,
},
},
});
누군가 컨텐츠를 조회하면 viewCount
를 1 증가시키는 단순한 구현이었습니다. 하지만 높은 트래픽 상황에서는 어떻게 될까요?
UPDATE
연산 중에는 해당 레코드에 Lock
이 걸리면서 요청이 밀려 UPDATE
트랜잭션이 쌓이고, 결과적으로 데이터베이스에 큰 부하가 발생할 수 있습니다.
개선을 해봅시다.
이 문제를 캐시를 사용하여 해결하였습니다.
먼저 그림으로 로직을 이해해보겠습니다.
상황은 크게 어렵지 않게 이해할 수 있습니다.
메모리에 조회수를 쌓아 놓은 다음, 일정 시간 이후에 단 한 번만 UPDATE
를 수행하는 것이죠.
public increaseViewCount(userIdx: number, contentIdx: number) {
// 현재 메모리에 저장된 조회수 1 증가시키기
const this.addViewCountFromMemoryStore(contentIdx);
// 2초 뒤에 UPDATE 하기
...
// 현재 메모리에 저장된 조회수 0으로 만들기
...
}
우선, 메모리에 저장된 조회수를 1증가 로직이 필요합니다.
나머지 로직도 구현해보겠습니다.
public increaseViewCount(userIdx: number, contentIdx: number) {
// 현재 메모리에 저장된 조회수 1 증가시키기
const this.addViewCountFromMemoryStore(contentIdx);
// 2초 뒤에 UPDATE 하기
setTimeout(async () => {
// 메모리에 저장된 조회수 가져오기
const viewCount = this.getContentViewCount(contentIdx);
// 현재 메모리에 저장된 컨텐츠 조회수 0으로 만들기
this.redisService.del(this.getContentViewCountCacheKey(contentIdx));
// DB에 `UPDATE` 쿼리 실행하기
await this.contentRepository.increaseContentViewCountByIdx(
contentIdx,
viewCount,
);
}, 2 * 1000);
}
단순하게 NodeJS
에서 지원하는 setTimeout
API를 통해 구현하였습니다.
그러나 여기에는 문제점이 있습니다.
setTimeout
을 호출함. Task Queue
에 UPDATE
쿼리를 실행하는 콜백 함수가 조회만큼 쌓일 것임.이건 단순히 UPDATE
연산을 2초 미룬 꼴입니다.
setTimeout
이 이미 호출된 상태라면 2초 동안은 setTimeout
을 호출하지 않아야합니다.
/**
* 조회수 증가 콜백 함수가 현재 대기중인 상태인지 확인하는 변수.
* key값은 컨텐츠 인덱스이며 value는 setTimeout의 리턴 값이다.
*/
public static SET_TIMEOUT_MAP: Record<number, NodeJS.Timeout> = {};
위 문제점을 해결하기 위하여, 각 컨텐츠 별로 조회수 증가 콜백 함수가 대기중인지를 담아놓을 변수를 선언하였습니다.
딱 떠오르시죠. 이제, 위 코드에서 if
문 하나를 추가하면 됩니다.
public increaseViewCount(userIdx: number, contentIdx: number) {
// 현재 메모리에 저장된 조회수 1 증가시키기
const this.addViewCountFromMemoryStore(contentIdx);
// UPDATE 콜백 함수가 대기 중인지 확인
if (!this.isContentUpdateCallbackWait(contentIdx)) {
// setTimeout의 리턴 값은 반드시 맵에 넣어놓기
Service.SET_TIMEOUT_MAP[contentIdx] = setTimeout(async () => {
// 콜백이 실행되자마자 맵에서 대기 상태임을 삭제
delete Service.SET_TIMEOUT_MAP[contentIdx];
// 메모리에 저장된 조회수 가져오기
const viewCount = this.getContentViewCount(contentIdx);
// 현재 메모리에 저장된 컨텐츠 조회수 0으로 만들기
this.redisService.del(this.getContentViewCountCacheKey(contentIdx));
// DB에 `UPDATE` 쿼리 실행하기
await this.contentRepository.increaseContentViewCountByIdx(
contentIdx,
viewCount,
);
}
}, 2 * 1000);
}
코드가 점점 커지고 있습니다.
그러나 아직까지 구현이 끝나지 않았습니다. 다음의 문제점이 남아있거든요!
모아서 처리했다고는 하지만 UPDATE
트랜잭션이 실패할 수도 있습니다. 그러나 setTimeout
은 Nest.js
에서 제공하는 에러 처리 바운더리에서 벗어나있습니다. (동기적으로 작동하지 않거든요.)
try catch
가 필요한 시점입니다.
public increaseViewCount(userIdx: number, contentIdx: number) {
// 현재 메모리에 저장된 조회수 1 증가시키기
const this.addViewCountFromMemoryStore(contentIdx);
// 이벤트가 돌아가고 있는지 확인
if (!this.isContentUpdateCallbackWait(contentIdx)) {
// setTimeout의 리턴 값은 반드시 맵에 넣어놓기
Service.SET_TIMEOUT_MAP[contentIdx] = setTimeout(async () => {
try {
// 콜백이 실행되자마자 맵에서 대기 상태임을 삭제
delete Service.SET_TIMEOUT_MAP[contentIdx];
// 메모리에 저장된 조회수 가져오기
const viewCount = this.getContentViewCount(contentIdx);
// 현재 메모리에 저장된 컨텐츠 조회수 0으로 만들기
this.redisService.del(this.getContentViewCountCacheKey(contentIdx));
// DB에 `UPDATE` 쿼리 실행하기
await this.contentRepository.increaseContentViewCountByIdx(
contentIdx,
viewCount,
);
} catch(err) {
// 보상 트랜잭션
// 로깅 등등...
}
}
}, 2 * 1000);
}
에러가 발생했을 때, 보상 트랜잭션이나 에로 로깅을 통해 상황을 적절히 해결할 수 있을 것 같습니다.
해당 로직을 구현하며 async/await
을 남발했다가는 동시성 문제로 인해 조회수가 손실되거나 중복해서 삽입될 수도 있습니다.
await
키워드를 단위로 Micro Task Queue
에 들어가기 때문에 충분히 동시성 문제가 발생할 수 있습니다.
예시로 이해해봅시다.
// 메모리에서 컨텐츠 조회수 가져오기
const contentViewCount = await this.getContentViewCount(contentIdx);
// (조회수 + 1)로 Set하기
await this.redisService.set(
this.getContentViewCountCacheKey(contentIdx),
contentViewCount + 1,
);
컨텐츠 조회수를 가져와서+1
을 하는 코드입니다.
다음의 순서로 처리하기를 기대할 것입니다.
그러나 거의 동시에 위 함수가 호출된다면 다음과 같은 상황이 발생할 수도 있습니다.
이 경우, 조회수 한 번은 누락됩니다.
실제 구현에서는 메모리
, DB
, setTimeout
등등 생각해야할 요소가 많아 이보다 더 깊게 고민해야합니다.
위 로직에서, 한 사용자가 API를 반복 호출하게 되면 조회수가 무한히 증가할 수 있습니다.
조회수 쿨타임 로직이 필요합니다.
아래 테스크 코드에서는 조회수 쿨타임 로직이 있다는 가정하에 진행하겠습니다.
(주제와 벗어난 것 같아 위 코드에서는 소개하지 않았습니다.)
당연히 테스트 코드도 준비해야겠죠!
어떤 상황들을 테스트 해야할지 정리해봅시다.
상황들을 잘 정리한 후 테스트 코드를 작성하면 됩니다. 여기서는 한 가지 정도만 소개해보겠습니다.
it('Increase view count - login user', async () => {
const loginUser = loginUsers.user2;
// 첫 번째 조회
const response = await request(app.getHttpServer())
.get('/content/1')
.set('Authorization', `Bearer ${loginUser.accessToken}`)
.expect(200);
// 첫 조회수는 0
expect(response.body.viewCount).toBe(0);
// 2초 뒤에
await wait(2)
// 2번 째 조회
const secondResponse = await request(app.getHttpServer())
.get('/content/1')
.expect(200);
// 2초 뒤, 조회수는 1
expect(secondResponse.body.viewCount).toBe(1);
});
그러나 해당 코드라인이 상당히 불편합니다.
await wait(2)
테스트 케이스 하나에 2초를 기다려야하니까 말이죠. 배치 처리 간격을 길게 두면 테스트 케이스를 돌릴 때 마다 기다림의 시간은 더욱 길어질 것입니다.
이를 제어하는 수단으로 Jest
에서는 useFakeTimers
라는 API를 제공합니다.
jest.useFakeTimers({
advanceTimers: true,
});
테스트 코드가 수행되기 전마다 useFakeTimers
를 호출해줍니다.
그 후, wait
함수를 advanceTimersByTime
API로 대체하면 됩니다.
it('Increase view count - login user', async () => {
const loginUser = loginUsers.user2;
const response = await request(app.getHttpServer())
.get('/content/1')
.set('Authorization', `Bearer ${loginUser.accessToken}`)
.expect(200);
expect(response.body.viewCount).toBe(0);
// 타이머를 2초만큼 빠르게 돌리기
jest.advanceTimersByTime(2 * 1000); // <-- 변경된 코드라인
const secondResponse = await request(app.getHttpServer())
.get('/content/1')
.expect(200);
expect(secondResponse.body.viewCount).toBe(1);
});
advanceTimersByTime
API의 역할은 타이머의 시간을 원하는 시간만큼 흐르게 해줍니다.
해당 메서드와 모킹을 통해 조회수 쿨타임도 충분히 테스트 할 수 있습니다.
캐시 키 관리, 정적값 변수화, private 메서드화 등등... 주제에 불필요한 요소는 많이 생략했습니다.
필요에 따라 적절히 추가하는 것이 코드 관리에도, 테스트 코드를 작성하는 것에도 유용할 것입니다.
구현에 고민 요소가 많아 상당히 재미있었습니다. 메모리 누수는 일어나지 않는지, 동시성 문제는 해결이 되는지, DB 과부하는 해결이 되었는지 등등 고민할 요소가 많았습니다.
조회수 기능 개선에 대한 아이디어 주신 창준님께 다시 한 번 감사 인사드립니다.
-> 다양한 개발 인사이트를 공유하는 톡방입니다. 놀러들오세용~
https://open.kakao.com/o/gyyGIK6e
인사이트 얻어가요. 고마워요