Rush Deal 선착순 타임딜 프로젝트를 진행하면서,
MSA 환경에서 주문 시스템과 포인트 시스템에 통신을 하는 가장 먼저 마주한 질문은 이것이었다.
"포인트 차감은 동기로 할까? 비동기로 할까?"
"그렇다면 그럼 적립은?"
단순히 “비동기가 좋다”는 접근보다는, 실제 비즈니스 요구사항과 기술적 트레이드오프를 면밀히 분석했습니다.
그 결과, 상황별로 통신 방식을 다르게 선택하는 하이브리드 전략을 채택하게 되었습니다.
이 글에서는 포인트 시스템을 구현하며 동기/비동기 통신 방식을 어떻게 결정했고,
각 선택이 어떤 영향을 미쳤는지에 대해 공유하려 합니다.
1. 프로젝트 상황
Framework: Spring Boot 3.5.8 / Spring Cloud 2025.0.0
Language: Java 21
Architecture: Microservices Architecture (Netflix Eureka)
1-1. 상황: 포인트 시스템의 4가지 시나리오 상황
포인트 서비스는 단순해 보이지만, 주문 라이프사이클에 따라 각기 다른 성격의 트랜잭션을 처리해야 했다.
제가 맡은 포인트 시스템의 핵심 시나리오는 다음과 같습니다.
| 시나리오 | 발생 시점 | 비즈니스 요구사항 | 핵심 질문 |
| ① 포인트 사용 | 주문 생성 시 | 즉시 잔액 검증 필요. 잔액 부족 시 주문 불가. | 즉시성 필수? (O) |
| ② 포인트 차감 | 결제 완료 시 | 결제와 동시에 차감. 실패 시 결제 롤백 필요. | 정합성 필수? (O) |
| ③ 포인트 적립 | 주문 완료 시 | 구매 확정 후 보상 지급. 약간 지연되어도 무방. | 즉시성 필수? (X) |
| ④ 포인트 환불 | 주문 취소 시 | 취소 후 포인트 복구. 실패 시 재시도하면 됨. | 정합성 필수? (X) |
이 요구사항들을 분석하며 내린 결론은 "모든 통신을 하나로 통일할 수 없다"는 것이었습니다.
2. 문제 정의: 통신 방식 선택의 판단 기준
시나리오마다 통신 방식을 달리 가져가야 한다.
그래서 먼저, 어떤 기준으로 동기와 비동기를 구분할지부터 정리해보았다.
2-1. 동기 통신이 필요한 경우
"사용자가 즉시 알아야 하는가?"
1. 즉각적인 피드백
예를 들어, 사용자가 포인트로 결제를 시도했는데 잔액이 부족하다면,
“잔액이 부족합니다”라는 메시지는 결제 버튼을 누르자마자 떠야 한다.
만약 결제가 끝나고 알림이 알려준다면, 이는 매우 나쁜 사용자 UX일거다.
2. 강한 트랜잭션 일관성
주문 생성과 포인트 차감은 사실상 하나의 트랜잭션처럼 움직여야 한다.
둘 중 하나라도 실패하면 전체가 롤백되어야 하니까이런 경우엔 동기 통신이 맞다.
2-2. 비동기 통신이 적합한 경우
"결과적으로만 맞으면 되는가(Eventual Consistency)?"
1. 실시간 응답이 불필요한 경우
주문 결제 후 포인트 적립은 주문 완료 후 수 초~수 분 지연되어도 무방하다사용자는 "적립 예정" 상태만 확인할 수 있으면 충분하기 때문이다.
2. 재시도 가능한 작업
일시적인 장애로 실패하더라도 나중에 재처리할 수 있다면 비동기 통신이 적합하다. 단, 멱등성(Idempotency) 이 보장되어야 안전하다.같은 요청이 여러 번 들어가도 데이터가 꼬이지 않아야 하기 때문이다.
3. 서비스 간 결합도를 낮추고 싶은 경우
포인트 서비스 장애가 주문 서비스까지 멈추게 해서는 안된다.두 서비스가 독립적으로 동작해야 시스템 안정성을 높일 수 있다.
결국, “즉시성이 필요한가?” “결과만 맞으면 되는가?” 이 두 가지 질문이 통신 방식을 결정하는 핵심 기준이다.
3. 해결 과정: 하이브리드 통신 전략 설계
3-1. 포인트 사용/차감 → 동기 통신 (OpenFeign)
사용자가 주문 버튼을 누르는 순간, 포인트 잔액을 확인하고 차감하는 프로세스는 HTTP 동기 호출로 구현
구현 코드
[Order Service] Feign Client 동기 호출
@FeignClient(name = "user-service")
public interface PointFeignClient {
@PostMapping("/api/v1/points/deduct")
PointDeductResponse deductPoint(@RequestBody PointDeductRequest request);
}
[Order Service] Adapter에서 즉시 응답 처리 주문 로직 내에서 포인트를 차감하고, 그 결과를 즉시(Synchronously) 판단하여 주문 진행 여부를 결정한다.
@Override
public boolean deductPoint(Long userId, BigDecimal amount, UUID sagaId, String reason) {
try {
log.info("포인트 차감 요청: userId={}, amount={}", userId, amount);
PointDeductRequest request = PointDeductRequest.builder()
.userId(userId)
.amount(amount)
.sagaId(sagaId.toString())
.reason(reason)
.build();
PointDeductResponse response = feignClient.deductPoint(request);
if (response.isSuccess()) {
log.info("포인트 차감 성공: remainingPoint={}", response.getRemainingPoint());
return true; // ✅ 즉시 성공 여부 반환
} else {
log.warn("포인트 차감 실패: {}", response.getMessage());
return false; // ❌ 실패 시 주문 중단
}
} catch (Exception e) {
log.error("포인트 차감 중 오류 발생", e);
throw new RuntimeException("포인트 차감 실패", e);
}
}
[Point Service] 동시성 제어를 위한 분산락 (Redisson) 동기 통신으로 요청이 들어오더라도, 따닥(중복 클릭) 이슈나 동시 주문으로 인한 '마이너스 통장' 사태를 막기 위해 Redisson 분산락을 적용했다.
@Service
@RequiredArgsConstructor
public class PointService {
private final RedissonClient redissonClient;
private final PointDomainService pointDomainService;
public void usePoints(UsePointCommand command) {
executeWithLock(command.userId(), () -> {
PointHistory history = pointDomainService.createPendingUseHistory(
UserId.of(command.userId()),
OrderId.of(command.orderId()),
Point.of(command.amount())
);
pointHistoryRepository.save(history);
});
}
// 🔒 Redisson 분산락으로 동시 요청 직렬화
private void executeWithLock(Long userId, Runnable businessLogic) {
String lockKey = "point:lock:" + userId;
RLock lock = redissonClient.getLock(lockKey);
try {
if (!lock.tryLock(10, 5, TimeUnit.SECONDS)) {
throw new BusinessException(PointErrorCode.LOCK_ACQUISITION_FAILED);
}
businessLogic.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("서버 인터럽트");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
왜 동기 통신을 선택했는가?
포인트 차감 로직은 결합도가 높아지는 단점이 있음에도 불구하고 동기 통신을 선택했다.
그 이유는 명확하다. 사용자 입장에서는 “즉시성”이 중요하기 때문이다.
즉각적인 잔액 검증 필수
- 비동기로 처리할 경우: "주문 성공" 알림 후 1분 뒤에 "잔액 부족으로 취소" 알림이 가는 최악의 UX 발생.
- 동기 처리 시: "포인트가 2,000원 부족합니다"라는 피드백을 결제 시점에 즉시 제공
트랜잭션 일관성
- 주문 생성과 포인트 차감은 논리적으로 하나의 트랜잭션으로 묶어야한다
- 둘 중 하나라도 실패하면 전체 프로세스가 롤백되어야 하기 때문이다
사용자 경험
- "주문 완료했는데 포인트 차감 안 됨" → 고객 불만
- 실시간 피드백이 신뢰감을 높임
동기 통신의 리스크 대응
문제점: Point Service 장애 시 Order Service도 함께 장애 전파
해결책:
- Feign Client에 타임아웃 설정 (3초) - 장애 시 빠르게 실패하도록 설정
- Circuit Breaker 패턴 적용 (Resilience4j) - 장애 전파를 차단
- Fallback 메서드 구현 - 예외 발생 시 자연스러운 처리
3-2. 포인트 적립/환불 → 비동기 통신 (Kafka)
결제가 확정된 후 사용자에게 보상을 지급하는 '포인트 적립' 프로세스는 Kafka 이벤트 기반의 비동기 처리로 구현
구현 코드
[Order Service] Kafka Producer로 이벤트 발행 주문 서비스는 "주문이 완료되었다"는 사실만 Kafka에 던지고(Publish), 즉시 사용자에게 응답을 반환하여 대기 시간을 최소화한다.
// 주문 완료 시 이벤트 발행
kafkaTemplate.send("point.earn.requested",
new PointEarnRequestedEvent(
userId,
orderId,
earnAmount,
sagaId
)
);
[Point Service] 신뢰성 있는 수신 (Manual Acknowledgment) 이벤트 유실을 막기 위해 수동 커밋(Manual Commit) 방식을 적용했다. 비즈니스 로직이 완전히 성공했을 때만 오프셋을 커밋하여 데이터 안정성을 보장한다.
@Component
@RequiredArgsConstructor
public class PointEventConsumer {
private final PointService pointService;
private final ObjectMapper objectMapper;
@KafkaListener(topics = "point.earn.requested", groupId = "point-service-group")
public void consumePointEarnRequested(
@Payload String message,
Acknowledgment acknowledgment
) throws Exception {
PointEarnRequestedEvent event = objectMapper.readValue(
message,
PointEarnRequestedEvent.class
);
CreatePendingPointCommand command = new CreatePendingPointCommand(
event.userId(),
event.orderId(),
event.finalAmount(),
event.sagaId() // 🔑 멱등성 보장용 Saga ID
);
pointService.createPendingPoint(command);
acknowledgment.acknowledge(); // ✅ 수동 커밋
}
}
[Point Service] 멱등성 보장 로직 Kafka는 '최소 한 번 전송(At-Least-Once)'을 보장하므로, 동일한 메시지가 두 번 올 수 있다. 이를 방어하기 위해 2중 검증 로직을 도메인 서비스에 구현
@Service
public class PointDomainService {
public PointHistory createPendingEarnHistory(
UserId userId,
OrderId orderId,
SagaId sagaId,
Point amount
) {
// 🛡️ 중복 적립 방지 1: SagaId 기반 멱등성 검증
validateSagaIdempotency(sagaId);
// 🛡️ 중복 적립 방지 2: OrderId 기반 중복 체크
validateNotAlreadyEarned(orderId);
Point currentBalance = getCurrentBalance(userId);
return PointHistory.createPendingEarn(
userId, orderId, amount, currentBalance, sagaId
);
}
private void validateSagaIdempotency(SagaId sagaId) {
if (queryRepository.existsBySagaId(sagaId.getId())) {
throw new BusinessException(PointErrorCode.DUPLICATE_POINT_EARN);
}
}
private void validateNotAlreadyEarned(OrderId orderId) {
if (queryRepository.existsEarnedHistoryForOrder(orderId.getId())) {
throw new BusinessException(PointErrorCode.DUPLICATE_POINT_EARN);
}
}
}
왜 비동기 통신을 선택했는가?
반대로, 포인트 적립/환불 프로세스는 비동기로 전환했다.
여기서 목표는 사용자 경험보다는 시스템의 유연성과 안정성이다.
실시간 응답 불필요
사용자는 적립 완료를 기다리지 않음
사용자: 주문 완료 → "500원 적립 예정" 표시 시스템: (백그라운드) 수 초~수 분 후 실제 적립
재시도 가능
Kafka의 재시도 메커니즘 덕분에 일시적인 장애는 자동으로 복구된다.
또한 Point Service가 잠시 멈추더라도 Order Service에는 아무런 영향을 주지 않죠.
이로써 각 서비스가 독립적으로 스케일링할 수 있다.
비동기 통신의 리스크 대응
문제점 1: Kafka At-Least-Once 전송으로 이벤트 중복 가능
해결책:
- SagaId 기반 멱등성 검증
- OrderId 중복 적립 체크
문제점 2: 이벤트 유실 가능성
해결책:
- Kafka 수동 커밋 (enable-auto-commit: false)
- 처리 성공 후에만 acknowledgment.acknowledge()
4. 결과
만약 모든 프로세스를 단일 동기 통신으로 구현했다면 겪었을 문제들과, 하이브리드 전략을 통해 개선된 점을 비교해 보았다
| 구분 | Before (All Sync) | After (Hybrid Strategy) |
| 결합도 | ❌ 포인트 적립 실패 시 주문까지 롤백됨 (불필요한 결합) | ✅ 주문-적립 완전 분리, 적립 실패해도 주문은 성공 |
| 가용성 | ❌ Point Service 장애 시 Order Service 전체 마비 | ✅ Point Service가 죽어도 주문 접수는 정상 동작 (격리) |
| 성능 | ❌ 대량 주문 시 적립 로직 대기로 응답 지연 발생 | ✅ 적립을 비동기로 미뤄 API 응답 속도 획기적 개선 |
| 데이터 | ❌ 일시적 장애 시 적립 데이터 유실 가능성 높음 | ✅ Kafka 재시도 및 멱등성 보장으로 누락/중복 0건 |
동기 통신을 선택해야 하는 경우"지금 당장 확인해야 하는가?
- 즉각적인 피드백 필수: 사용자에게 성공/실패 여부를 바로 팝업으로 띄워야 할 때.
- 강한 트랜잭션 일관성: 호출 결과에 따라 메인 로직을 분기(성공/실패)하거나 롤백해야 할 때
- 예시: 포인트 차감, 재고 확인, 결제 승인, 로그인 인증
비동기 통신을 선택해야 하는 경우
"나중에 처리되어도 괜찮은가?"
- 실시간 응답 불필요: 사용자가 결과를 기다릴 필요가 없을 때 (백그라운드 처리).
- 재시도 가능한 작업: 일시적 실패가 허용되며, 나중에 다시 시도해서 성공하면 되는 경우.
- 느슨한 결합 (Decoupling): 타 서비스의 장애가 내 서비스로 전파되는 것을 막고 싶을 때.
- 대량 처리 (Throttling): 트래픽 폭주 시 큐에 쌓아두고 천천히 처리하고 싶을 때.
- 예시: 포인트 적립, 알림(카카오톡/이메일) 발송, 로그 수집, 통계 데이터 집계
5. 결론
MSA에서 "동기 vs 비동기" 논쟁의 정답은 "비즈니스 요구사항에 따라 다르다"이다.
“비동기가 좋다” 말은 어떤상황에서는 틀릴 수 있다.
실무에서는 UX, 데이터 정합성, 시스템 안정성을 모두 고려한 균형 잡힌 설계가 필요하다.
이번 포인트 시스템 프로젝트를 통해 배운 핵심을 요약하면 이렇다.
- 포인트 차감/사용: 동기 → 즉시 검증
- 포인트 적립/환불: 비동기 → 안정적 처리
- 분산락: 동시성 제어
- 멱등성: 중복 방지
이 하이브리드 전략 덕분에 사용자 경험을 해치지 않으면서도, 시스템 안정성을 확보할 수 있었다.
6. 참고
- https://systorage.tistory.com/entry/%EB%A7%88%EC%9D%B4%ED%81%AC%EB%A1%9C%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98MSA%EC%97%90%EC%84%9C-%EB%AA%A8%EB%93%88-%EA%B0%84-%ED%86%B5%EC%8B%A0-%EB%B0%A9%EB%B2%95#2.%20%EB%B9%84%EB%8F%99%EA%B8%B0%20%ED%86%B5%EC%8B%A0%20(Asynchronous%20Communication)-1
- https://microservices.io/patterns/data/saga.html
'스파르타 자바 심화 4기 RushCrew Project' 카테고리의 다른 글
| [트러블 슈팅] 로그아웃 API 구현 과정과 개선 기록 (0) | 2025.12.06 |
|---|---|
| [트러블 슈팅] Java 21 업그레이드 후 Eureka DNS 해석 실패 (1) | 2025.12.02 |
| [기획] 이벤트 스토밍 도입기 (0) | 2025.11.28 |