Post

트랜잭션 격리 수준 설정 | 403? 409? | 맛집 평점 업데이트 구현하기

matjum의 맛집 리뷰 API를 구현하며...

트랜잭션 격리 수준 설정 | 403? 409? | 맛집 평점 업데이트 구현하기

1. 트랜잭션 격리 수준 설정

리뷰가 생성되면 맛집의 평점을 업데이트 하는 게 요구사항이었는데, 이 두 작업을 하나의 트랜잭션으로 묶으려고 하다 공부한 내용이다.

READ UNCOMMITTED

  • 가장 낮은 격리 수준이다.
  • 커밋되지 않은 데이터에도 접근이 가능해 데이터 정합성 문제가 발생할 수 있지만 가장 빠르다.
  • 일부 행이 제대로 조회되지 않더라도 괜찮은 거대한 양의 데이터를 어림잡아 집계하는 데에 쓰일 수 있다.

READ COMMITTED

  • 가장 많이 사용되는 격리 수준으로, PostgreSQL의 기본 격리 수준이다.
  • 커밋 완료된 데이터에 대해서만 조회가 가능하다.
  • 한 트랜잭션이 접근한 행을 다른 트랜잭션이 수정할 수 있다.

REPEATABLE READ

  • MySQL의 기본 격리 수준이다.
  • MVCC를 이용해 한 트랜잭션 내에서 항상 동일한 결과를 보장한다.
  • 한 트랜잭션이 접근한 행을 다른 트랜잭션이 수정할 수 없도록 막는다.
  • 새로운 레코드가 추가되는 경우엔 수정을 막지 않아 유령 읽기가 생길 수 있다.

MVCC(Multi-Version Concurrency Control, 다중버전 동시성 제어)

  • 일반적인 RDBMS는 변경 전의 레코드를 undo 공간에 백업해 두어서 변경 전과 후의 데이터가 모두 존재하게 된다.
  • 동일한 레코드에 대해 여러 버전의 데이터가 존재한다고 하여 이를 MVCC라고 한다.

유령 읽기(Phantom Read)

  • 한 트랜잭션 내에서 동일한 쿼리를 보냈을 때 조회 결과가 다른 경우. 즉, 동일한 조회 쿼리를 보냈지만 처음과 다르게 없던 데이터가 보이거나 있었던 데이터가 사라지는 것을 의미한다.
  • 이를 방지하려면 쓰기 방지(Write Lock, Exclusive Lock)을 걸어야 한다.

SERIALIZABLE

  • 가장 엄격한 격리 수준이다.
  • 여러 트랜잭션이 동일한 레코드에 동시 접근할 수 없어 데이터 부정합 문제가 생기지 않는다.
  • 트랜잭션이 순차적으로 처리되어야 하므로 동시 처리 성능이 매우 떨어지고, 교착 상태(데드락)이 일어날 확률이 높다.

그래서 어떻게 썼나요?

안 썼다. 푸하하.

  • 위에 썼듯이 리뷰 생성맛집 평점 업데이트 이 두 작업을 하나의 트랜잭션으로 묶으려고 했다.
  • 트랜잭션에서 한 작업이 실패하면 그 트랜잭션 내의 모든 작업이 취소된다. (= 롤백. 트랜잭션 내에서 명시적으로 커밋하지 않았다는 전제 하에 트랜잭션 시작 시점으로 돌아간다.)
  • 따라서, 한 트랜잭션으로 묶었을 때, 평점 업데이트가 실패하면 리뷰 생성도 취소된다. 이렇게 생각하니 둘은 논리적으로 같은 맥락에 있을 뿐 꼭 같이 처리되어야 하는 작업이 아니란 걸 깨달았다.
  • 또한 완전히 즉각적인 평점 반영이 필요한가? 라는 의문도 함께 들어 평점 업데이트 코드엔 await 키워드를 붙이지 않았다.
    • 트랜잭션을 공부하다보니 DB 부하에 대한 관점으로 생각하게 되어 이런 결정을 했는데, 유의미한 영향이 있는지는 사실 잘 모르겠다..ㅎ

참고

2. 403? 409?

문제

1
2
3
4
5
6
7
8
  // 사용자가 이미 해당 맛집에 리뷰를 작성했는지 확인하고, 이미 작성했다면 에러를 발생시킵니다.
  private async isAlreadyReviewed(memberId: string, restaurantId: string): Promise<void> {
    const review = await this.reviewRepository.findOneBy({ memberId, restaurantId });

    if (review !== null) {
      throw new ConflictException('already reviewed');
    }
  }
  • 요구사항엔 없었지만 논리적으로 있어야 할 것 같아 위와 같은 예외처리를 추가했다.
  • 그러면서 이 HTTP Status Code를 403으로 해야할지 409로 해야할지 고민이었다.

해결

  • 403(Forbidden)의 예로는 관리자 권한이 없는 사용자가 관리자 페이지에 접근하려는 경우, 권한과 관련된 경우
  • 409(Conflict)의 예로는 이미 존재하는 아이디로 가입하려는 경우, 클라이언트에서 해결 가능한 경우
  • 이미 리뷰를 작성한 경우, 리소스 충돌에 대한 예외처리이기 때문에 409로 결정했다.
  • 근데 지금 보니까 함수명이 약간 거슬린다…

참고

3. 맛집 평점 업데이트 구현하기

허무함주의

문제

1
2
3
4
5
6
7
8
  private async updateRestaurantRating(id: string): Promise<void> {
	
    // 여기 들어갈 로직

    // 소수점 한 자리만 나오도록
    const updatedRating = (sum / count).toFixed(1);
    this.restaurantRepository.update({ id: id }, { rating: +updatedRating });
  }
  • 리뷰 생성 API에서 호출할 맛집 평점 업데이트 함수이다.
  • 평균을 계산하기 위해 합계(SUM)와 전체 레코드 수(COUNT)가 필요했다.
  • 팀 컨벤션이 Repository API를 우선시하는거라 어떻게 해야하나 고민이었다.

방법s

  1. 일단 COUNT 하고 코드단에서 SUM을 구하는 방법
    1
    2
    3
    4
    5
    
       const [reviews, count] = await this.reviewRepository.findAndCount({
       select: ['rating'],
       where: { id },
     });
     const sumOfRatings = reviews.reduce((acc, cur) => (acc += cur.rating), 0);
    
    • findAndCount()는 조건에 맞는 레코드들과 총 레코드 수를 반환한다.
    • 배열 순회 메서드인 reduce()를 활용해 각 리뷰 레코드의 rating만 더해 합계를 구한다.
  2. count()sum() 메서드를 각각 따로 호출해서 쓰는 방법
    • 1번 방법이 더 나아보여서 코드를 짜진 않았다.
  3. QueryBuilder를 사용하는 방법
    1
    2
    3
    4
    5
    6
    7
    8
    
      const { sum, count } = (
       await this.reviewRepository
         .createQueryBuilder('review')
         .select('COUNT(*) as count, SUM(rating) as sum')
         .where('review.restaurantId = :id', { id })
         .groupBy('review.restaurantId')
         .execute()
     )[0];
    
    • 엔티티를 가져오는게 아니기 때문에 getMany()가 아닌 execute()를 사용한다. SELECT한 결과는 0번째 인덱스로 온다.
    • DB에서 할 수 있는 건 DB에서 하자! 라는 마음으로 QueryBuilder를 사용하는 걸로 스스로 결론냈었는데…

결론

1
2
3
4
5
6
7
8
  // 맛집의 모든 리뷰 기록을 조회하고, 평균을 계산하여 평점을 업데이트 합니다.
  private async updateRestaurantRating(id: string): Promise<void> {
    const averageOfRatings = await this.reviewRepository.average('rating', { restaurantId: id });
    // 소수점 한 자리까지만 나오도록
    const updatedRating = +averageOfRatings.toFixed(1);

    this.restaurantRepository.update({ id }, { rating: updatedRating });
  }

팀 회의에서 위 내용의 TIL 리뷰를 하고… 팀원분께서 알려주신 내용

  • average()라는 Repository API가 있었다.
  • 사용해본 기억도 있는데 저 순간엔 아무런 생각도 나지 않았다. ㄱ-
  • 근데… 더 찾아봤으면 분명히 찾았을텐데… 나에게 약간 실망하엿음.ㅋ 문서 꼼꼼히 읽기~!

참고

This post is licensed under CC BY 4.0 by the author.