조회수 기능 개선하기

민경찬·2024년 11월 9일
75

백엔드

목록 보기
20/22
post-thumbnail

라이켓은 다양한 문화생활 정보를 공유하고 나만의 문화생활 기록을 남길 수 있는 서비스를 제공하고 있습니다.
태그별, 지역별로 관심있는 정보들만 골라보고 쉽게 문화생활을 즐겨보세요.

-> https://liket.site

안녕하세요. LIKET팀의 백엔드 개발자 민경찬입니다. 오늘은 Nest.js에서 조회수 증가 로직을 개선했던 이야기를 해보려고 합니다.


🤔 기존 조회수 증가 로직

기존 조회수 증가 로직은 매우 간단했습니다.

await this.prisma.content.update({
  where: {
    idx,
  },
  data: {
    viewCount: {
      increment: 1,
    },
  },
});

누군가 컨텐츠를 조회하면 viewCount를 1 증가시키는 단순한 구현이었습니다. 하지만 높은 트래픽 상황에서는 어떻게 될까요?

UPDATE 연산 중에는 해당 레코드에 Lock이 걸리면서 요청이 밀려 UPDATE 트랜잭션이 쌓이고, 결과적으로 데이터베이스에 큰 부하가 발생할 수 있습니다.

개선을 해봅시다.

💡 Cache 도입하기

이 문제를 캐시를 사용하여 해결하였습니다.

먼저 그림으로 로직을 이해해보겠습니다.

1. 1번 사용자가 1번 컨텐츠를 조회

2. 1번 컨텐츠 조회수를 메모리에 저장

3. 2번 사용자가 1 컨텐츠를 조회

4. 1번 컨텐츠 조회수를 메모리에서 1증가

5. 일정 시간이 지나면 DB에 UPDATE

6. 이후 메모리에서 컨텐츠 조회수를 삭제

상황은 크게 어렵지 않게 이해할 수 있습니다.

메모리에 조회수를 쌓아 놓은 다음, 일정 시간 이후에 단 한 번만 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에서 지원하는 setTimeoutAPI를 통해 구현하였습니다.

그러나 여기에는 문제점이 있습니다.

  1. 조회마다 setTimeout을 호출함. Task QueueUPDATE 쿼리를 실행하는 콜백 함수가 조회만큼 쌓일 것임.
  2. 조회수가 중복해서 상승할 가능성이 존재함.

이건 단순히 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);
}

코드가 점점 커지고 있습니다.

그러나 아직까지 구현이 끝나지 않았습니다. 다음의 문제점이 남아있거든요!

  1. UPDATE 실패 시 예외처리

모아서 처리했다고는 하지만 UPDATE 트랜잭션이 실패할 수도 있습니다. 그러나 setTimeoutNest.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);
}

에러가 발생했을 때, 보상 트랜잭션이나 에로 로깅을 통해 상황을 적절히 해결할 수 있을 것 같습니다.

❗️ 주의해야할 것

1. 동시성 문제 주의

해당 로직을 구현하며 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 등등 생각해야할 요소가 많아 이보다 더 깊게 고민해야합니다.

2. 중복 조회수 방지

위 로직에서, 한 사용자가 API를 반복 호출하게 되면 조회수가 무한히 증가할 수 있습니다.

조회수 쿨타임 로직이 필요합니다.

아래 테스크 코드에서는 조회수 쿨타임 로직이 있다는 가정하에 진행하겠습니다.

(주제와 벗어난 것 같아 위 코드에서는 소개하지 않았습니다.)

📄 테스트 코드 작성하기

당연히 테스트 코드도 준비해야겠죠!

어떤 상황들을 테스트 해야할지 정리해봅시다.

  1. 1번 사용자가 컨텐츠를 조회한 직후 조회수가 올라가지 않아야함. 2초 뒤에는 조회수가 1 올라간 것이 확인 되어야함.
  2. 1번 사용자가 연속해서 두 번 호출할 경우, 2초 뒤에 조회수가 1이 올라가야함.
  3. 1번 사용자와 2번 사용자가 동시에 호출한 경우, 2초 뒤에 조회수가 2가 올라가야함.
  4. 1번 사용자가 호출한 후, 2초 뒤에 조회수가 1 올라가야함. 그 후, 조회수 쿨타임이 지난 이후 재호출 이후에 2초 뒤 조회수가 다시 한 번 1 올라가야함.

상황들을 잘 정리한 후 테스트 코드를 작성하면 됩니다. 여기서는 한 가지 정도만 소개해보겠습니다.

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함수를 advanceTimersByTimeAPI로 대체하면 됩니다.

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);
});

advanceTimersByTimeAPI의 역할은 타이머의 시간을 원하는 시간만큼 흐르게 해줍니다.

해당 메서드와 모킹을 통해 조회수 쿨타임도 충분히 테스트 할 수 있습니다.

⭐️ 결론

캐시 키 관리, 정적값 변수화, private 메서드화 등등... 주제에 불필요한 요소는 많이 생략했습니다.
필요에 따라 적절히 추가하는 것이 코드 관리에도, 테스트 코드를 작성하는 것에도 유용할 것입니다.

구현에 고민 요소가 많아 상당히 재미있었습니다. 메모리 누수는 일어나지 않는지, 동시성 문제는 해결이 되는지, DB 과부하는 해결이 되었는지 등등 고민할 요소가 많았습니다.

조회수 기능 개선에 대한 아이디어 주신 창준님께 다시 한 번 감사 인사드립니다.

-> 다양한 개발 인사이트를 공유하는 톡방입니다. 놀러들오세용~
https://open.kakao.com/o/gyyGIK6e

4개의 댓글

comment-user-thumbnail
2024년 11월 9일

인사이트 얻어가요. 고마워요

답글 달기
comment-user-thumbnail
2024년 11월 10일

오홍홍 조와용

답글 달기
comment-user-thumbnail
2024년 11월 22일

똑똑한 청년

답글 달기
comment-user-thumbnail
2024년 11월 25일

영리한 청년

답글 달기