난이도 ⭐️⭐️
작성 날짜 2024.12.19
포인트의 변화는 민감한 비즈니스 로직이다
🤔
포인트 증감을 안정적으로 관리하고, 오류에 대처할 수 있는 방법은 무엇일까?
우선, 포인트는 말 그대로 증가와 감소의 로직이 동반되어야 하고, 값의 증가와 감소는 독립되어야 한다.
증가와 감소의 순서가 보장되어야 함은 물론이고 서로가 영향을 받지 않아야 한다.
낙관적 락은 버전을 사용하기 때문에 트랜잭션 자체의 처리는 빠르지만, 문제가 발생했을 때 에러를 직접 핸들링 해야 하며, 작업의 완료를 보장하지 않는다.
우리는 다른 작업이 끝나기를 기다리고, 포인트 증감 로직 모두가 작업을 완료해야 하기 때문에 비관적 락을 사용할 것이다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
@Query("SELECT m FROM Member m WHERE m.id = :memberId")
Optional<Member> findByMemberIdForUpdate(Long memberId);
데드락을 방지하기 위해 timeout을 설정하였다.
Update를 위해 락을 건 상태로 Member를 조회하고, 멤버 수정이 끝나면 업데이트를 한 후 락을 해제한다.
포인트는 Member 객체의 필드로 들어가 있는 내용이기 때문에, Main 서버에서 증감을 관리하고 있다.
다른 서버에서 발생시킨 포인트 증감이거나, 같은 서버 내부에서 발생시킨 포인트 증감 이벤트인 대부분의 경우, 문제가 생긴다.
우리는 결합도를 낮추기 위해 '이벤트'를 활용하는데, 서비스 자체는 이벤트를 발생시키고 자체 트랜잭션은 종료하기 때문에 오류가 발생했을 때 발생의 원인에 대한 롤백이 불가능하다.
예를 들어, 레이싱 신청 과정에서 포인트 감소가 실패한 경우, 레이싱 신청은 취소되어야 한다. 그러나 레이싱 신청은 Map 서버에서, 포인트 감소는 Main 서버에 있기 때문에 이벤트(메시징)으로 연결될 수 밖에 없다.
SAGA 패턴

각자 다른 트랜잭션이지만, 마치 하나의 트랜잭션인 것 처럼 동작하는 패턴이다.
public class MemberPointChangeFacade {
private final MemberService memberService;
private final PointLogService pointLogService;
private final PointChangeFailEventPublisher pointChangeFailEventPublisher;
@Transactional
public void makePointChange(Long memberId, PointChangeType pointChangeType, int pointAmount, PointChangeReason pointChangeReason, Long reasonId) {
try {
// 멤버 포인트 변화
memberService.updateMemberPoint(memberId, pointChangeType, pointAmount);
// 로그 기록
pointLogService.savePointLog(pointChangeReason, memberId, pointChangeType, pointAmount, reasonId);
} catch (Exception e) {
// 실패 이벤트 publish
pointChangeFailEventPublisher.publish(memberId, pointChangeType, pointAmount, pointChangeReason, reasonId);
throw new PointChangeFailException();
}
}
}
포인트 변화에 관련된 로직을 묶어주는 Facade 클래스이다. 포인트 증감이나 포인트 로그 기록에 실패한 경우 pointChangeFailEvent를 발생시킨다.
이벤트 핸들러에서는 이 이벤트를 캐치해 Kafka에 새로운 메시지를 전송한다.
public class RacingRollbackStrategy implements RollbackStrategy {
private final KafkaProducerService kafkaProducerService;
@Override
public void execute(Long memberId, PointChangeType pointChangeType, int pointAmount, PointChangeReason pointChangeReason, Long reasonId) {
kafkaProducerService.sendMessage(KafkaTopic.alarm,
new RacingPointChangedFailedMessage(
memberId,
PointChangedType.valueOf(pointChangeType.name()),
pointAmount,
common.kafka_message.PointChangeReason.valueOf(pointChangeReason.name()),
reasonId
)
);
}
}
전송되는 데이터에는 롤백 시 필요한 데이터를 담아 보낸다.
이제 Map 서버에서는 해당 메시지를 Consume하고, 롤백하거나 상황에 맞는 로직으로 처리한다.
@Transactional
public void rollbackRacing(Long memberId, PointChangedType pointChangedType, Integer pointAmount, PointChangeReason reason, Long racingId){
Racing racing = racingQueryRepository.findById(racingId)
.orElseThrow(() -> new RacingException(NO_SUCH_RACING));
racing.specifyError();
}
MSA를 도입하니 생각해야 될 부분이 많은 것 같다!
민감할 수 있는 로직이다보니 한 줄을 짜도 한 번씩 더 생각하면서 코딩해야겠다.
내가 모르는 기술이 아직 너무 많다ㅠㅠ