이번 포스팅에서는 `StudyWithMe`에서 부족했던 나의 트랜잭션의 작동 지식에 관해 포스팅 하려고 한다.
`StudyWithMe`에서 좋아요, 이용후기 작업 시,
`Database Lock`으로 스터디 룸에 관한 동시성 문제를 대비했다.
엥? 좋아요, 이용후기인데 왜 스터디 룸 동시성 문제를 해결해?
(이유는 스터디 룸에 스터디 룸 좋아요, 이용후기 개수/평점에 대해 스터디 룸으로 반정규화를 해놨기 때문에
동시 접근 시 제대로 저장되지 않을 수 있기 때문이다.)
그럼 왜 반정규화를 했는데?
목록 조회같은 많은 스터디 룸 조회 작업이나 상세 조회 작업에서 매번 좋아요, 이용후기에 관해서
집계 함수를 사용하는 것은 비용이 클 것으로 판단했기 때문이다.
그래서 미리 스터디 룸에 반정규화를 해놓았다.
StudyRoom
@Entity
@Getter
@SQLDelete(sql = "UPDATE study_room SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at is null")
@Table(name = "study_room")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class StudyRoom extends BaseTimeEntity {
...
@Column(name = "like_count", nullable = false)
private int likeCount;
@Column(name = "review_count", nullable = false)
private int reviewCount;
@Column(name = "average_rating", nullable = false)
private double averageRating;
...
// 스터디 룸 반정규화 관련 메소드
public void likeStudyRoom() {
this.likeCount++;
}
public void unLikeStudyRoom(){
this.likeCount = Math.max(0, this.likeCount - 1);
}
public void updateAverageRating(int oldRating, int newRating) {
this.averageRating += (double) (newRating - oldRating) / this.reviewCount;
}
public void deleteReviewStudyRoom(int rating) {
this.averageRating = (this.averageRating * this.reviewCount - rating) / --this.reviewCount;
}
...
}
좋아요 생성 작업
@Transactional
public CreateStudyRoomLikeResponse createLike(Long studyRoomId, UUID userId) {
validateExistsLike(studyRoomId, userId);
StudyRoom studyRoom = validateStudyRoomWithLock(studyRoomId); // <- 이 부분
User user = userRepository.getReferenceById(userId);
StudyRoomLike studyRoomLike = StudyRoomLike.of(studyRoom, user);
likeRepository.save(studyRoomLike);
studyRoom.addLike();
return CreateStudyRoomLikeResponse.from(studyRoomLike);
}
간단하게 로직을 설명하자면,
- validateExistsLike()
- 일단 한 명당 하나의 좋아요만 가능하기 때문에 해당 스터디 룸에 관해 유저의 좋아요가 존재하는지 확인한다.
- validateStudyRoomWithLock()
- 스터디 룸이 존재하는지 확인하고 존재한다면 `PESSIMISTIC_WRITE`으로 `Lock`을 걸어 가져온다.
- 스터디 룸 좋아요를 생성해 저장한다.
- `studyRoom.addLike()`
- 스터디 룸의 좋아요 개수를 증가시킨다.
위와 같이 스터디 룸에 관한 좋아요, 이용후기에 관한 반정규화를 처리함으로 동시성 상황에 대해 `Lock`으로 대비를 했다.
그럼 코드를 작성했으니.. 잘 돌아가는지 확인을 해봐야겠지?
`K6`, `Locust`, `JMeter`와 같이 성능 테스트 툴을 사용해서 동시성 문제를 확인할 수도 있겠지만,
위의 상황에서는 관련 테스트 코드를 작성해 쉽게 확인할 수 있을 것으로 생각해 관련 테스트 코드를 작성했다.
그래서 초기 작성한 테스트 코드는 아래와 같다.
StudyRoomCommandServiceTest
여기서 `IntegrationContainerSupporter`는 `TestContainers` 작업을 위해 작성해 놓은 추상 클래스이다.
해당 클래스에서는 테스트 환경을 위한 `PostgreSQL`과 `Redis` 컨테이너 설정이 들어가 있다.
IntegrationContainerSupporter
@DataJpaTest(
includeFilters = {
@Filter(type = FilterType.ANNOTATION, value = {Service.class}),
}
)
@ActiveProfiles("test")
@Testcontainers
@Import(JpaConfig.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public abstract class IntegrationContainerSupporter {
private static final String REDIS_IMAGE = "bitnami/valkey:8.0.1";
private static final int REDIS_PORT = 6379;
private static final String REDIS_PASSWORD = "password";
private static final String POSTGRES_IMAGE = "postgres:17";
private static final GenericContainer REDIS_CONTAINER;
@Container
@ServiceConnection
static PostgreSQLContainer POSTGRES_CONTAINER = new PostgreSQLContainer(DockerImageName.parse(POSTGRES_IMAGE));
static {
REDIS_CONTAINER = new GenericContainer(DockerImageName.parse(REDIS_IMAGE))
.withExposedPorts(REDIS_PORT)
.withReuse(true)
.withEnv("VALKEY_PASSWORD", "password");
REDIS_CONTAINER.start();
}
@DynamicPropertySource
public static void overrideProps(DynamicPropertyRegistry registry){
// Redis
registry.add("redis.host", REDIS_CONTAINER::getHost);
registry.add("redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(REDIS_PORT)));
registry.add("redis.password", () -> REDIS_PASSWORD);
}
}
public class StudyRoomCommandServiceTest extends IntegrationContainerSupporter {
private static final int THREAD_COUNT = 100;
@Autowired private StudyRoomCommandService commandService;
@Autowired private StudyRoomLikeRepository likeRepository;
@Autowired private StudyRoomRepository studyRoomRepository;
@Autowired private UserRepository userRepository;
private StudyRoom studyRoom;
private ExecutorService executorService;
private CountDownLatch countDownLatch;
@BeforeEach
void setUp(){
User user = userRepository.saveAndFlush(UserFixture.createRoomAdmin());
studyRoom = studyRoomRepository.saveAndFlush(StudyRoomFixture.createStudyRoomWithoutId(user));
}
@AfterEach
void cleanup() {
likeRepository.deleteAllInBatch();
studyRoomRepository.deleteAllInBatch();
userRepository.deleteAllInBatch();
}
@Test
@DisplayName("스터디 룸 좋아요 동시성 테스트에 성공한다.")
void studyRoom_like_concurrency_test_Success() throws InterruptedException {
//given
List<UUID> userUuids = createTestUsers(THREAD_COUNT);
executorService = Executors.newFixedThreadPool(THREAD_COUNT);
countDownLatch = new CountDownLatch(THREAD_COUNT);
// when
for(int i = 0; i < THREAD_COUNT; i++) {
final UUID uuid = userUuids.get(i);
executorService.submit(() -> {
try {
commandService.createLike(studyRoom.getId(), uuid);
} catch (Exception e){
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
});
}
executorService.shutdown();
countDownLatch.await();
// then
int likeCount = likeRepository.countStudyRoomLikeByStudyRoom(studyRoom);
assertThat(likeCount).isEqualTo(100); // 사용자마다 한 번씩 좋아요가 생성되어야 함
studyRoom = studyRoomRepository.findById(studyRoom.getId()).get();
assertThat(studyRoom.getLikeCount()).isEqualTo(100);
}
@Test
@DisplayName("스터디 룸 좋아요 취소 동시성 테스트에 성공한다.")
void studyRoom_dislike_concurrency_test_Success() throws InterruptedException {
//given
List<UUID> userUuids = createTestUsers(THREAD_COUNT);
executorService = Executors.newFixedThreadPool(THREAD_COUNT);
countDownLatch = new CountDownLatch(THREAD_COUNT);
for(UUID uuid : userUuids) {
commandService.createLike(studyRoom.getId(), uuid);
}
//when
for(int i = 0; i < THREAD_COUNT; i++) {
final UUID uuid = userUuids.get(i);
executorService.submit(() -> {
try {
commandService.unLike(studyRoom.getId(), uuid);
} catch (Exception e){
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
});
}
executorService.shutdown();
countDownLatch.await();
// then
int likeCount = likeRepository.countStudyRoomLikeByStudyRoom(studyRoom);
assertThat(likeCount).isEqualTo(0); // 최종적으로 좋아요가 모두 취소되어야 함
studyRoom = studyRoomRepository.findById(studyRoom.getId()).get();
assertThat(studyRoom.getLikeCount()).isEqualTo(0);
}
private List<UUID> createTestUsers(int size) {
List<UUID> userUuids = new ArrayList<>();
for (int i = 0; i < size; i++) {
User user = UserFixture.createUserWithUUID();
user = userRepository.save(user);
userUuids.add(user.getId());
}
return userUuids;
}
}
코드를 보면
- `THREAD_COUNT`를 100개로 설정하고
- 100명의 사용자를 만들고
- `ExecutorService`로 100개의 스레드를 만들어서 `createLike`, `unLike`를 수행한다.
- 그렇다면 좋아요 개수는 100개 혹은 0개가 나와야 한다.
근데 위 코드의 결과는 아래와 같다.(createLike 기준)
com.jj.swm.global.exception.GlobalException: StudyRoom Not Found
이었다.
엥? 동시성이고 말고 `StudyRoom Not Found`가 나온다고?
위 오류 메시지는 말 그대로 `StudyRoom`을 찾지 못했을 경우 나오는것이다.
??... 위의 테스트 코드에서도 나와있지만, 분명히 DB에 `StudyRoom`을 저장을 한다.
그럼 왜 이런 현상이 발생하는 것일까..?
그래서 값은 잘 전달되나 확인해보기 위해서 아래와 같이 로그를 작성했다.
@Transactional
public CreateStudyRoomLikeResponse createLike(Long studyRoomId, UUID userId) {
validateExistsLike(studyRoomId, userId);
log.info("userId: {}, studyRoomId: {}", userId, studyRoomId);
StudyRoom studyRoom = validateStudyRoomWithLock(studyRoomId);
User user = userRepository.getReferenceById(userId);
StudyRoomLike studyRoomLike = StudyRoomLike.of(studyRoom, user);
likeRepository.save(studyRoomLike);
studyRoom.likeStudyRoom();
return CreateStudyRoomLikeResponse.from(studyRoomLike);
}
결과
StudyRoomCommandService : userId: 380882a2-60c2-4a37-aa94-2db65483ddec, studyRoomId: 1
당연하게도 전달이 잘 된다.
그럼 음... 왜? `StudyRoom`을 저장했는데도 왜 `StudyRoom`을 찾을 수 없지?
라는 생각이 들어 로그도 찍어보고 디버그도 해보며 곰곰이 생각을 해본 결과.. 아!!
`ExecutorService`를 사용했기에 `StudyRoom` 저장되는 트랜잭션과 다른 스레드에서 동작하므로, 같은 트랜잭션이 아니구나!
즉, 정리하자면
- `IntegrationContainerSupporter` 내부의 `@DataJpaTest`로 각 테스트 메서드에 관해 `@Transactional` 적용
- 테스트 메서드 시작 전 `@BeforeEach`로 `StudyRoom`을 저장함(Transaction #1)
- `executorService`에서 `createLike()`를 수행하는 스레드 생성 작업
- 각 스레드의 트랜잭션(Transaction #N)
가 되는 것이다.
아직 `StudyRoom`을 저장하는 트랜잭션이 끝나지 않았는데,
다른 트랜잭션에서 저장되지 않은 `StudyRoom`을 조회하므로 찾지 못하는 오류가 발생하는 것이다!
아.. 굉장히 단순한 내용인데 내가 놓치고 있었구나 싶다.
그럼 어떻게 해결하는데?
문제 상황을 진짜 간단히 정리하자면,
다른 트랜잭션에서 아직 커밋되지 않은 `StudyRoom`을 조회하는 것이 문제다.
그럼 다르게 말하면 커밋되어 있으면 문제가 없는 것이다.
그렇기에 커밋이 되어 있도록 하기 위해 시도할 수 있는 방법은 여러 방법이 있겠지만 내가 생각한 방법은 아래 2가지이다.
- `EntityManager`를 사용해서 `flush()`를 하는 방법
- `@Transactional` 전파 옵션을 적절히 사용하는 방법
여기서 `1번`의 경우에는 `EntityManager`에 관한 의존성 주입을 해주면 된다.
하지만, 나는 굳이 `EntityManager` 의존성 필드를 굳이 추가하고 싶지 않아,
`2번` 방식을 선택하는 것으로 하였다.
`2번`을 사용함에 따라 아래와 같이 전파 속성을 `@Transactional(Propagation.NOT_SUPPORTED)`으로 했다.
@Test
@DisplayName("스터디 룸 좋아요 동시성 테스트에 성공한다.")
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void studyRoom_like_concurrency_test_Success() throws InterruptedException {
//given
List<UUID> userUuids = createTestUsers(THREAD_COUNT);
executorService = Executors.newFixedThreadPool(THREAD_COUNT);
countDownLatch = new CountDownLatch(THREAD_COUNT);
// when
for(int i = 0; i < THREAD_COUNT; i++) {
final UUID uuid = userUuids.get(i);
executorService.submit(() -> {
try {
commandService.createLike(studyRoom.getId(), uuid);
} catch (Exception e){
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
});
}
executorService.shutdown();
countDownLatch.await();
// then
int likeCount = likeRepository.countStudyRoomLikeByStudyRoom(studyRoom);
assertThat(likeCount).isEqualTo(100); // 사용자마다 한 번씩 좋아요가 생성되어야 함
studyRoom = studyRoomRepository.findById(studyRoom.getId()).get();
assertThat(studyRoom.getLikeCount()).isEqualTo(100);
}
음..? `Propagation.NOT_SUPPORTED`는 생소하다. 뭘까?
`@Transactional(Propagation.NOT_SUPPORTED)`
- 기존에 트랜잭션이 있다면 중단되고 비트랜잭션 상태에서 실행
- Spring은 트랜잭션이 없는 상태에서 실행되는 작업(비트랜잭션)은 자동 커밋 모드로 동작한다.
- 트랜잭션 컨텍스트가 없으므로, 데이터베이스 드라이버의 기본 설정인 `Auto-Commit Mode`가 적용
- 즉, 데이터베이스 작업(INSERT, UPDATE, DELETE)이 실행되면 해당 작업은 즉시 반영된다.
라고 할 수 있다.
그렇기에 위의 전파 속성을 사용하면 `DB`에 바로 `StudyRoom`이 저장되게 되어,
`createLike`에서는 해당 `StudyRoom`을 인식할 수 있으므로 아래와 같이 테스트를 성공하는 것을 볼 수 있다.
결과
(확실히 TestContainers를 사용하니까 테스트 속도는 느리다.)
성공!
그러나, 우리는 `Propagation.NOT_SUPPORTED`와 같은 전파 옵션을 사용할 때 주의해야 한다.
나는 테스트 환경에서 `TestContainers`의 DB를 사용했기에 DB에 바로 적용이 되어도 문제가 없었다.
하지만, 실제 DB에서 Spring이 도와주는 강력한 트랜잭션 관리 작업을 무시하도록 작업한다면?
롤백이 되지 않아, 데이터의 무결성이 완전히 망가저버릴 수 있다.
그렇기에 실제 비즈니스 로직에서는 사용하지 않는 것을 추천한다.
Spring이 제공하는 안전한 트랜잭션 환경을 누리도록 하자.
정리
이번 포스팅에서 작성한 내용은 사실, 내가 "테스트를 한다"에 매몰되어,
스레드 간의 트랜잭션 정보 불일치를 고려하지 않아 삽질(?)한 나의 기록이다.
만약에 같은 문제를 겪어 이 포스팅을 보시는 분들에게 도움이 되었으면 한다.
'프로젝트 > StudyWithMe' 카테고리의 다른 글
[StudyWithMe] 영속성 컨텍스트와 관련된 퀴즈 풀어보실 분? (0) | 2024.12.23 |
---|---|
[StudyWithMe] 프로젝트에서의 쿼리 고민 일기(계속 추가 예정) (0) | 2024.12.10 |
[StudyWithMe] 페이징 시 Offset 방식 대신 Cursor 방식 적용(약 111배 성능 향상) (0) | 2024.12.07 |
[StudyWithMe] 스터디 룸 UPDATE API 작성 시 했던 고민들 (0) | 2024.12.06 |
[StudyWithMe] Flyway의 다양한 활용과 조심 (0) | 2024.12.06 |