이벤트 기반으로 의존성 낮추기 및 성능 향상

우기·2024년 7월 21일
3

이전 글과 이어지는 내용입니다..

현재 그라운드 플립에선 사용자가 픽셀을 방문할 때 아래와 같은 메소드를 수행하게 된다.

	@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);
		}
	}
  1. x, y 상대 좌표를 입력받아 픽셀 엔티티를 find한다.
  2. (픽셀이 유저에 의해 처음으로 방문된 경우) reverse geocoding을 한다.
  3. 픽셀 테이블에 존재하는 user_id의 컬럼을 update한다.
  4. 레디스 상에서 사용자들의 현재 소유 픽셀을 증감한다.
  5. pixe_user 테이블에 방문 기록을 insert한다.

문제점

  • 하나의 함수가 지나치게 많은 역할을 하고 있다.

  • 꼭 하나의 tx로 묶이지 않아도 되는 로직들이 묶여있다.
    특히 외부 api를 호출하는 reverseGeoCodingService를 호출하는 과정에서 예외가 발생한다면 모든 과정이 롤백되어 버린다.

  • 이로 인해 해당 메소드가 존재하는 PixelService에 의존성이 추가된다.

  • 응답속도 또한 느려진다.

분석

자, 우선 요구 사항에 따라 생각해보자.

사용자가 픽셀을 방문했을 때, "남이 소유하고 있는 픽셀"을 방문한다면 해당 픽셀은 나의 소유가 된다.

  1. x, y 상대 좌표를 입력받아 픽셀 엔티티를 find한다.
  2. (픽셀이 유저에 의해 처음으로 방문된 경우) reverse geocoding을 한다.
  3. 픽셀 테이블에 존재하는 user_id의 컬럼을 update한다.
  4. 레디스 상에서 사용자들의 현재 소유 픽셀을 증감한다.
  5. pixe_user 테이블에 방문 기록을 insert한다.

그렇다면 위의 요구 사항 중 꼭 transaction을 보장해야 하는 부분은 어디일까?
바로 1, 3, 4이다.

Reverse Geocoding Api를 호출하고, 방문 기록을 insert하는 로직은 해당 트랜잭션에 참여 할 이유가 없다.

개선

이벤트 기반 비동기로 처리를 해보았다.

Spring Event는 크게 세 가지 요소로 구성된다.

  1. 이벤트를 통해 전달할 정보를 담은 Event Class

  2. 이벤트를 발행하는 Event Publisher

  3. 이벤트를 수신하는 Event Listener

그렇다면 우선 pixel_user 테이블에 방문 기록을 insert하는 로직을 분리해보자.

  • PixelService에 `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로 개선 가능)

profile
항상 한번 더 생각하는 개발자를 지향합니다!

0개의 댓글