이전 글과 이어지는 내용입니다..
현재 그라운드 플립에선 사용자가 픽셀을 방문할 때 아래와 같은 메소드를 수행하게 된다.
@Transactional
public void occupyPixel(PixelOccupyRequest pixelOccupyRequest) {
Long communityId = Optional.ofNullable(pixelOccupyRequest.getCommunityId()).orElse(-1L);
Long occupyingUserId = pixelOccupyRequest.getUserId();
Pixel targetPixel = pixelRepository.findByXAndY(pixelOccupyRequest.getX(), pixelOccupyRequest.getY())
.orElseThrow(() -> new AppException(ErrorCode.PIXEL_NOT_FOUND));
updatePixelAddress(targetPixel);
updatePixelOwnerUser(targetPixel, occupyingUserId);
PixelUser pixelUser = PixelUser.builder()
.pixel(targetPixel)
.community(communityRepository.getReferenceById(communityId))
.user(userRepository.getReferenceById(occupyingUserId))
.build();
pixelUserRepository.save(pixelUser);
}
private void updatePixelAddress(Pixel targetPixel) {
if (targetPixel.getAddress() == null) {
String address = reverseGeoCodingService.getAddressFromCoordinates(targetPixel.getCoordinate().getX(),
targetPixel.getCoordinate().getY());
targetPixel.updateAddress(address);
}
}
private void updatePixelOwnerUser(Pixel targetPixel, Long occupyingUserId) {
Long originalOwnerUserId = targetPixel.getUserId();
if (Objects.equals(originalOwnerUserId, occupyingUserId)) {
return;
}
targetPixel.updateUserId(occupyingUserId);
if (originalOwnerUserId == null) {
rankingService.increaseCurrentPixelCount(occupyingUserId);
} else {
rankingService.updateRankingAfterOccupy(occupyingUserId, originalOwnerUserId);
}
}
하나의 함수가 지나치게 많은 역할을 하고 있다.
꼭 하나의 tx로 묶이지 않아도 되는 로직들이 묶여있다.
특히 외부 api를 호출하는 reverseGeoCodingService를 호출하는 과정에서 예외가 발생한다면 모든 과정이 롤백되어 버린다.
이로 인해 해당 메소드가 존재하는 PixelService에 의존성이 추가된다.
응답속도 또한 느려진다.
자, 우선 요구 사항에 따라 생각해보자.
사용자가 픽셀을 방문했을 때, "남이 소유하고 있는 픽셀"을 방문한다면 해당 픽셀은 나의 소유가 된다.
- x, y 상대 좌표를 입력받아 픽셀 엔티티를 find한다.
- (픽셀이 유저에 의해 처음으로 방문된 경우) reverse geocoding을 한다.
- 픽셀 테이블에 존재하는 user_id의 컬럼을 update한다.
- 레디스 상에서 사용자들의 현재 소유 픽셀을 증감한다.
- pixe_user 테이블에 방문 기록을 insert한다.
그렇다면 위의 요구 사항 중 꼭 transaction을 보장해야 하는 부분은 어디일까?
바로 1, 3, 4이다.
Reverse Geocoding Api를 호출하고, 방문 기록을 insert하는 로직은 해당 트랜잭션에 참여 할 이유가 없다.
이벤트 기반 비동기로 처리를 해보았다.
Spring Event는 크게 세 가지 요소로 구성된다.
이벤트를 통해 전달할 정보를 담은 Event Class
이벤트를 발행하는 Event Publisher
이벤트를 수신하는 Event Listener
그렇다면 우선 pixel_user 테이블에 방문 기록을 insert하는 로직을 분리해보자.
ApplicationEventPublisher
를 주입하고 이벤트를 발행한다.public class PixelService {
private final ApplicationEventPublisher eventPublisher;
// 중략...
@Transactional
public void occupyPixel(PixelOccupyRequest pixelOccupyRequest) {
Long occupyingUserId = pixelOccupyRequest.getUserId();
Long communityId = Optional.ofNullable(pixelOccupyRequest.getCommunityId()).orElse(-1L);
Pixel targetPixel = pixelRepository.findByXAndY(pixelOccupyRequest.getX(), pixelOccupyRequest.getY())
.orElseThrow(() -> new AppException(ErrorCode.PIXEL_NOT_FOUND));
updatePixelAddress(targetPixel);
updatePixelOwnerUser(targetPixel, occupyingUserId);
eventPublisher.publishEvent(new PixelUserInsertEvent(targetPixel.getId(), occupyingUserId, communityId));
}
}
Event Class
를 만든다.@AllArgsConstructor
@Getter
public class PixelUserInsertEvent {
private Long pixelId;
private Long userId;
private Long communityId;
}
Event Listener
를 만든다.@Service
@RequiredArgsConstructor
public class PixelEventListener {
private final PixelUserRepository pixelUserRepository;
@EventListener
@Transactional
@Async
public void insertPixelUserHistory(PixelUserInsertEvent pixelUserInsertEvent) {
pixelUserRepository.save(
pixelUserInsertEvent.getPixelId(),
pixelUserInsertEvent.getUserId(),
pixelUserInsertEvent.getCommunityId()
);
}
}
우선 스레드 A에서 occupyPixel에 대한 tx를 시작한다.
메소드의 마지막 라인에서 eventPublisher.publishEvent()를 수행한다.
이 때, 새로운 스레드 B에서 insertPixelHistory()에 대한 트랜잭션을 시작한다.
스레드 B와 관계없이 스레드 A의 occupyPixel()의 tx는 Commit된다.
예상대로 각기 다른 스레드, 다른 tx로 동작하는 것을 볼 수 있다.
이제 updatePixelAddress도 분리해보자.
// PixelService
private void updatePixelAddress(Pixel targetPixel) {
if (targetPixel.getAddress() == null) {
eventPublisher.publishEvent(new PixelAddressUpdateEvent(targetPixel));
}
}
@AllArgsConstructor
@Getter
public class PixelAddressUpdateEvent {
private Pixel pixel;
}
//PixelEventListener
@EventListener
@Transactional
@Async
public void updatePixelAddress(PixelAddressUpdateEvent pixelAddressUpdateEvent) {
Pixel targetPixel = pixelAddressUpdateEvent.getPixel();
String address = reverseGeoCodingService.getAddressFromCoordinates(targetPixel.getCoordinate().getX(),
targetPixel.getCoordinate().getY());
targetPixel.updateAddress(address);
pixelRepository.save(targetPixel);
}
외부 Api를 호출하는 updatePixelAddress가 비동기적으로 동작하므로, 응답시간을 약 70ms 단축할 수 있다.
Insert 또한 비동기적으로 작동하여 성능이 개선됐다.
최종적으로 200건 동시 테스트 시 평균 262ms에서 145ms로 감소시켰다.
PixelServie에서 의존하던 ReverseGeoCodingService를 의존하지 않아도 된다.
occupyPixel()이 하던 일을 분리함으로서 테스트 코드의 작성도 용이해졌다.
occupyPixel()이 최소 2개, 최대 3개의 스레드를 사용하게 된다.
스레드 간 영속성 컨텍스트가 공유되지 않아 SELECT 쿼리가 추가적으로 발생한다.
(native query로 개선 가능)