트랜잭션 격리 수준 설정 | 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
- 일단 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
만 더해 합계를 구한다.
count()
와sum()
메서드를 각각 따로 호출해서 쓰는 방법- 1번 방법이 더 나아보여서 코드를 짜진 않았다.
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.