Repository: ohDelivery/OhDelivery
Pull request #98: 커맨드 디자인 패턴 적용
Author: login: ekdh0858
Labels: name: enhancement
description: New feature or request
Created: 2025-04-22T13:17:35Z | Merged: 2025-04-24T05:40:41Z

## ✨ 변경 타입
* [ ] 신규 기능 추가/수정
* [ ] 버그 수정
* [x] 리팩토링
* [ ] 설정
* [ ] 비기능 (주석 등 기능에 영향을 주지 않음)

## ❗️ 관련 이슈 링크
Close #


## 🚀 변경 내용
* 간단히라도 작성해주세요.


## 💡 추가 설명

* 코드 리뷰 시 특별히 참고해야 할 부분이 있다면 언급해주세요.
* 궁금한 점이나 논의하고 싶은 내용이 있다면 작성해주세요.

## 📚 참고 자료 (선택 사항)

* 관련 자료 링크



Diff:
diff --git a/common/src/main/java/com/ohdelivery/common/feign/request/ClientGetNearRidersRequest.java b/common/src/main/java/com/ohdelivery/common/feign/request/ClientGetNearRidersRequest.java
new file mode 100644
index 0000000..21556c3
--- /dev/null
+++ b/common/src/main/java/com/ohdelivery/common/feign/request/ClientGetNearRidersRequest.java
@@ -0,0 +1,16 @@
+package com.ohdelivery.common.feign.request;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class ClientGetNearRidersRequest {
+
+  private final Double sLat;
+  private final Double sLon;
+
+  public static ClientGetNearRidersRequest of(Double storeLatitude, Double storeLongitude) {
+    return new ClientGetNearRidersRequest(storeLatitude, storeLongitude);
+  }
+}
diff --git a/common/src/main/java/com/ohdelivery/common/feign/response/ClientGetRiderResponse.java b/common/src/main/java/com/ohdelivery/common/feign/response/ClientGetRiderResponse.java
new file mode 100644
index 0000000..e08e63f
--- /dev/null
+++ b/common/src/main/java/com/ohdelivery/common/feign/response/ClientGetRiderResponse.java
@@ -0,0 +1,21 @@
+package com.ohdelivery.common.feign.response;
+
+import java.util.UUID;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class ClientGetRiderResponse {
+
+  private final UUID id;
+  private final long rider_id;
+  private final String slack_id;
+  private final String status;
+  private final Double latitude;
+  private final Double longitude;
+
+  public boolean isAvailable() {
+    return status != null && "AVAILABLE".equalsIgnoreCase(status);
+  }
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/common/command/CommandFactory.java b/match-service/src/main/java/com/ohdelivery/service/match/common/command/CommandFactory.java
new file mode 100644
index 0000000..af06204
--- /dev/null
+++ b/match-service/src/main/java/com/ohdelivery/service/match/common/command/CommandFactory.java
@@ -0,0 +1,8 @@
+package com.ohdelivery.service.match.common.command;
+
+/*
+  TODO : 나중에 커맨드 로깅같은거 할때 필요한 메서드들 여기에 넣기
+ */
+public interface CommandFactory {
+
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/common/command/CommandInvoker.java b/match-service/src/main/java/com/ohdelivery/service/match/common/command/CommandInvoker.java
new file mode 100644
index 0000000..8a14333
--- /dev/null
+++ b/match-service/src/main/java/com/ohdelivery/service/match/common/command/CommandInvoker.java
@@ -0,0 +1,42 @@
+package com.ohdelivery.service.match.common.command;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class CommandInvoker implements AutoCloseable {
+
+  private final ThreadLocal<Deque<MatchingCommand<?>>> commandHistoryHolder =
+      ThreadLocal.withInitial(ArrayDeque::new);
+
+  @Override
+  public void close() {
+    commandHistoryHolder.remove();
+  }
+
+  private final Deque<MatchingCommand<?>> executedCommands = new ArrayDeque<>();
+
+  public <R> R invoke(MatchingCommand<R> command) {
+    try {
+      R result = command.execute();
+      executedCommands.push(command);
+      return result;
+    } catch (Exception e) {
+      rollback();
+      throw e;
+    }
+  }
+
+  private void rollback() {
+    while (!executedCommands.isEmpty()) {
+      MatchingCommand<?> command = executedCommands.pop();
+      try {
+        command.undo();
+      } catch (Exception e) {
+        log.warn("Undo failed for command {}: {}", command.getClass().getSimpleName(),
+            e.getMessage(), e);
+      }
+    }
+  }
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/common/command/MatchingCommand.java b/match-service/src/main/java/com/ohdelivery/service/match/common/command/MatchingCommand.java
new file mode 100644
index 0000000..12eea51
--- /dev/null
+++ b/match-service/src/main/java/com/ohdelivery/service/match/common/command/MatchingCommand.java
@@ -0,0 +1,11 @@
+package com.ohdelivery.service.match.common.command;
+
+/*
+  매칭쪽(match-service에 matching) 커맨드 패턴 적용 위한 command 인터페이스
+ */
+public interface MatchingCommand<T> {
+
+  T execute();
+
+  void undo();
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/common/DeliveryClientService.java b/match-service/src/main/java/com/ohdelivery/service/match/common/feign/DeliveryClientService.java
similarity index 90%
rename from match-service/src/main/java/com/ohdelivery/service/match/common/DeliveryClientService.java
rename to match-service/src/main/java/com/ohdelivery/service/match/common/feign/DeliveryClientService.java
index f54829a..673acef 100644
--- a/match-service/src/main/java/com/ohdelivery/service/match/common/DeliveryClientService.java
+++ b/match-service/src/main/java/com/ohdelivery/service/match/common/feign/DeliveryClientService.java
@@ -1,4 +1,4 @@
-package com.ohdelivery.service.match.common;
+package com.ohdelivery.service.match.common.feign;
 
 import com.ohdelivery.common.feign.GetDeliveryResponse;
 import com.ohdelivery.common.response.ApiResponse;
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/common/feign/RiderClientService.java b/match-service/src/main/java/com/ohdelivery/service/match/common/feign/RiderClientService.java
new file mode 100644
index 0000000..a85bbd7
--- /dev/null
+++ b/match-service/src/main/java/com/ohdelivery/service/match/common/feign/RiderClientService.java
@@ -0,0 +1,23 @@
+package com.ohdelivery.service.match.common.feign;
+
+import com.ohdelivery.common.feign.request.ClientGetNearRidersRequest;
+import com.ohdelivery.common.feign.response.ClientGetRiderResponse;
+import com.ohdelivery.common.response.ApiResponse;
+import java.util.List;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+
+
+@FeignClient(name = "match-service")
+public interface RiderClientService {
+
+  @GetMapping("/api/riders/nearRiders")
+  ApiResponse<List<ClientGetRiderResponse>> getAllNearRider(
+      @RequestBody ClientGetNearRidersRequest request);
+
+
+  @GetMapping("/api/riders/user/{userId}")
+  ApiResponse<ClientGetRiderResponse> getRiderByUserId(@PathVariable("userId") long riderId);
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/CreateMatchingCommand.java b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/CreateMatchingCommand.java
new file mode 100644
index 0000000..4b835b6
--- /dev/null
+++ b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/CreateMatchingCommand.java
@@ -0,0 +1,97 @@
+package com.ohdelivery.service.match.matching.application.command;
+
+import com.ohdelivery.common.feign.request.ClientGetNearRidersRequest;
+import com.ohdelivery.common.feign.response.ClientGetRiderResponse;
+import com.ohdelivery.service.match.common.command.MatchingCommand;
+import com.ohdelivery.service.match.common.feign.RiderClientService;
+import com.ohdelivery.service.match.matching.application.MatchingEventPublisher;
+import com.ohdelivery.service.match.matching.application.dto.request.CreateMatchingRequest;
+import com.ohdelivery.service.match.matching.domain.Matching;
+import com.ohdelivery.service.match.matching.domain.repository.MatchingRepository;
+import java.util.List;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Slf4j
+public class CreateMatchingCommand implements MatchingCommand<Matching> {
+
+  private final CreateMatchingRequest request;
+  private final MatchingRepository matchingRepository;
+  private final RiderClientService riderService;
+  private final MatchingEventPublisher matchingEventPublisher;
+  private UUID matchingId;
+  private UUID deliveryId;
+
+  @Override
+  @Transactional
+  public Matching execute() {
+    deliveryId = request.getDeliveryId();
+
+    // 중복 매칭 체크
+    if (matchingRepository.findByDeliveryId(deliveryId).isPresent()) {
+      throw new IllegalArgumentException("해당 배달에 대한 매칭이 이미 존재합니다.");
+    }
+
+    // 매칭 생성
+    Matching matching = Matching.create(deliveryId);
+    matchingRepository.save(matching);
+
+    matchingId = matching.getId();
+
+    // feign - 주변 라이더 조회
+    List<ClientGetRiderResponse> nearbyRiders = riderService.getAllNearRider(
+        ClientGetNearRidersRequest.of(request.getStoreLatitude(), request.getStoreLongitude())
+    ).getData();
+
+    // 이벤트 발행: 매칭 생성 완료 -> 알람 해당 라이더들에게 보낼때 소비
+    matchingEventPublisher.matchingCreatedEvent(
+        getRidersSlackIds(nearbyRiders),
+        getRidersIds(nearbyRiders),
+        matching.getId(),
+        request.getFee(),
+        request.getStoreName(),
+        request.getStoreAddress(),
+        request.getTargetAddress(),
+        request.getOrderRequest()
+    );
+    log.info("매칭 생성 완료: {}", matching.getId());
+    return matching;
+  }
+
+  @Override
+  @Transactional
+  public void undo() {
+    deliveryId = request.getDeliveryId();
+    if (matchingId == null) { // execute() 실행안하고 undo 호출 || save 전에 에러 났을때 deliveryId가 null
+
+      // 실패 시 이벤트 발행 -> 배달 삭제할때 소비됨
+      matchingEventPublisher.matchingCreateFailedEvent(deliveryId);
+      log.info("매칭 생성 취소(Undo): deliveryId={}", deliveryId);
+      return;
+    }
+
+    matchingRepository.findByDeliveryId(deliveryId).ifPresent(matching -> {
+      matching.cancel();
+      matchingRepository.save(matching);
+
+      matchingEventPublisher.matchingCreateFailedEvent(deliveryId);
+
+      log.info("매칭 생성 취소(Undo): matchingId={}", matching.getId());
+    });
+  }
+
+  private List<String> getRidersSlackIds(List<ClientGetRiderResponse> riders) {
+    return riders.stream()
+        .map(ClientGetRiderResponse::getSlack_id)
+        .toList();
+  }
+
+  private List<Long> getRidersIds(List<ClientGetRiderResponse> riders) {
+    return riders.stream()
+        .map(ClientGetRiderResponse::getRider_id)
+        .toList();
+  }
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/DeleteMatchingCommand.java b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/DeleteMatchingCommand.java
new file mode 100644
index 0000000..9df5aa5
--- /dev/null
+++ b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/DeleteMatchingCommand.java
@@ -0,0 +1,56 @@
+package com.ohdelivery.service.match.matching.application.command;
+
+import com.ohdelivery.service.match.common.command.MatchingCommand;
+import com.ohdelivery.service.match.matching.application.exception.MatchingNotFoundException;
+import com.ohdelivery.service.match.matching.domain.Matching;
+import com.ohdelivery.service.match.matching.domain.repository.MatchingRepository;
+import java.time.LocalDateTime;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Slf4j
+public class DeleteMatchingCommand implements MatchingCommand<Void> {
+
+  private final UUID deliveryId;
+  private final MatchingRepository matchingRepository;
+
+  private Matching backupMatching; // undo를 위한 백업
+
+  @Override
+  @Transactional
+  public Void execute() {
+    Matching matching = matchingRepository.findByDeliveryId(deliveryId)
+        .orElseThrow(MatchingNotFoundException::new);
+
+    if (!matching.isUpdatable()) {
+      throw new IllegalArgumentException("Matching is not deletable");
+    }
+
+    // Undo를 위해 백업
+    backupMatching = matching.clone(); // 또는 deep copy. clone()은 도메인에서 구현 필요
+
+    LocalDateTime now = LocalDateTime.now();
+    String createdBy = "system";
+    matching.delete(now, createdBy);
+
+    matchingRepository.save(matching);
+    log.info("매칭 삭제 완료: deliveryId={}, matchingId={}", deliveryId, matching.getId());
+    return null;
+  }
+
+  @Override
+  @Transactional
+  public void undo() {
+    if (backupMatching == null) {
+      log.warn("Undo 실패: 백업된 매칭 정보 없음 (deliveryId={})", deliveryId);
+      return;
+    }
+
+    // 백업한 상태로 복구
+    matchingRepository.save(backupMatching);
+    log.info("매칭 삭제 취소(Undo): 복원된 matchingId={}", backupMatching.getId());
+  }
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/MatchingCommandFactory.java b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/MatchingCommandFactory.java
new file mode 100644
index 0000000..8670a10
--- /dev/null
+++ b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/MatchingCommandFactory.java
@@ -0,0 +1,48 @@
+package com.ohdelivery.service.match.matching.application.command;
+
+import com.ohdelivery.common.passport.Passport;
+import com.ohdelivery.service.match.common.command.CommandFactory;
+import com.ohdelivery.service.match.common.feign.RiderClientService;
+import com.ohdelivery.service.match.matching.application.MatchingEventPublisher;
+import com.ohdelivery.service.match.matching.application.dto.request.AssignRiderRequest;
+import com.ohdelivery.service.match.matching.application.dto.request.CreateMatchingRequest;
+import com.ohdelivery.service.match.matching.domain.repository.MatchingRepository;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.redisson.api.RedissonClient;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class MatchingCommandFactory implements CommandFactory {
+
+  private final MatchingRepository matchingRepository;
+  private final RiderClientService riderService;
+  private final MatchingEventPublisher matchingEventPublisher;
+  private final RedissonClient redissonClient;
+
+  public CreateMatchingCommand createMatchingCommand(CreateMatchingRequest request) {
+    return new CreateMatchingCommand(request, matchingRepository, riderService,
+        matchingEventPublisher);
+  }
+
+  public UpdateMatchingCommand updateMatchingCommand(
+      UUID matchingId,
+      AssignRiderRequest request,
+      Passport currentUser
+  ) {
+    return new UpdateMatchingCommand(
+        matchingId,
+        request,
+        currentUser,
+        matchingRepository,
+        riderService,
+        matchingEventPublisher,
+        redissonClient
+    );
+  }
+
+  public DeleteMatchingCommand deleteMatchingCommand(UUID deliveryId) {
+    return new DeleteMatchingCommand(deliveryId, matchingRepository);
+  }
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/UpdateMatchingCommand.java b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/UpdateMatchingCommand.java
new file mode 100644
index 0000000..5f33d7b
--- /dev/null
+++ b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/UpdateMatchingCommand.java
@@ -0,0 +1,107 @@
+package com.ohdelivery.service.match.matching.application.command;
+
+import com.ohdelivery.common.feign.response.ClientGetRiderResponse;
+import com.ohdelivery.common.passport.Passport;
+import com.ohdelivery.common.passport.RoleType;
+import com.ohdelivery.service.match.common.command.MatchingCommand;
+import com.ohdelivery.service.match.common.feign.RiderClientService;
+import com.ohdelivery.service.match.matching.application.MatchingEventPublisher;
+import com.ohdelivery.service.match.matching.application.dto.request.AssignRiderRequest;
+import com.ohdelivery.service.match.matching.application.exception.MatchingNotFoundException;
+import com.ohdelivery.service.match.matching.domain.Matching;
+import com.ohdelivery.service.match.matching.domain.repository.MatchingRepository;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Slf4j
+public class UpdateMatchingCommand implements MatchingCommand<Void> {
+
+  private final UUID matchingId;
+  private final AssignRiderRequest request;
+  private final Passport currentUser;
+  private final MatchingRepository matchingRepository;
+  private final RiderClientService riderService;
+  private final MatchingEventPublisher matchingEventPublisher;
+  private final RedissonClient redissonClient;
+
+  private UUID previousRiderId; // undo용
+
+  @Override
+  @Transactional
+  public Void execute() {
+    String lockKey = "matching:" + matchingId.toString();
+    RLock lock = redissonClient.getLock(lockKey);
+    RoleType role = currentUser.getRoleType();
+    long userId = Long.parseLong(currentUser.getUserId());
+
+    if (role != RoleType.MASTER && userId != request.getRiderId()) {
+      throw new IllegalArgumentException("MASTER가 아니면 본인만 할당 가능합니다.");
+    }
+
+    try {
+      boolean isLocked = lock.tryLock(5, 10, TimeUnit.SECONDS);
+      if (!isLocked) {
+        throw new IllegalStateException("매칭에 대한 락 획득 실패: " + matchingId);
+      }
+
+      Matching matching = matchingRepository.findById(matchingId)
+          .orElseThrow(MatchingNotFoundException::new);
+
+      if (!matching.isUpdatable()) {
+        throw new IllegalArgumentException("매칭은 수정할 수 없는 상태입니다.");
+      }
+
+      ClientGetRiderResponse rider = riderService.getRiderByUserId(request.getRiderId()).getData();
+      UUID riderId = rider.getId();
+      boolean status = rider.isAvailable();
+      if (!status) {
+        throw new IllegalArgumentException("라이더는 할당 가능한 상태가 아닙니다.");
+      }
+
+      // undo를 위한 기존 값 저장
+      previousRiderId = matching.getRiderId();
+
+      matching.assignRider(riderId);
+      matchingRepository.save(matching);
+
+      matchingEventPublisher.matchingCompletedEvent(matching.getDeliveryId(), riderId);
+      log.info("라이더 할당 완료: matchingId={}, riderId={}", matchingId, riderId);
+      return null;
+
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      throw new RuntimeException("락 획득 중 인터럽트 발생", e);
+    } finally {
+      if (lock.isHeldByCurrentThread()) {
+        lock.unlock();
+      }
+    }
+  }
+
+  @Override
+  public void undo() {
+    try {
+      Matching matching = matchingRepository.findById(matchingId)
+          .orElseThrow(MatchingNotFoundException::new);
+
+      // 이전 상태로 되돌리기 TODO : 물어보기
+      if (previousRiderId != null) {
+        matching.assignRider(previousRiderId); // 원래 있던 rider로 복원
+      } else {
+        matching.assignRider(null);
+      }
+
+      matchingRepository.save(matching);
+      log.info("라이더 할당 취소(Undo): matchingId={}, 복원된 riderId={}", matchingId, previousRiderId);
+
+    } catch (Exception e) {
+      log.warn("라이더 할당 undo 실패: matchingId={}, error={}", matchingId, e.getMessage());
+    }
+  }
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/matching/application/exception/MatchingCreateException.java b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/exception/MatchingCreateException.java
new file mode 100644
index 0000000..f0322b4
--- /dev/null
+++ b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/exception/MatchingCreateException.java
@@ -0,0 +1,13 @@
+package com.ohdelivery.service.match.matching.application.exception;
+
+public class MatchingCreateException extends RuntimeException {
+
+  public MatchingCreateException(String message) {
+    super(message);
+  }
+
+  @Override
+  public synchronized Throwable fillInStackTrace() {
+    return this;
+  }
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/matching/application/exception/MatchingUpdateException.java b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/exception/MatchingUpdateException.java
new file mode 100644
index 0000000..132b27e
--- /dev/null
+++ b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/exception/MatchingUpdateException.java
@@ -0,0 +1,8 @@
+package com.ohdelivery.service.match.matching.application.exception;
+
+public class MatchingUpdateException extends RuntimeException {
+
+  public MatchingUpdateException(String s) {
+    super(s);
+  }
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/matching/application/service/MatchingDeleteException.java b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/service/MatchingDeleteException.java
new file mode 100644
index 0000000..d739931
--- /dev/null
+++ b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/service/MatchingDeleteException.java
@@ -0,0 +1,8 @@
+package com.ohdelivery.service.match.matching.application.service;
+
+public class MatchingDeleteException extends RuntimeException {
+
+  public MatchingDeleteException(String s) {
+    super(s);
+  }
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/matching/application/service/MatchingServiceImpl.java b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/service/MatchingServiceImpl.java
index 8689be6..e299832 100644
--- a/match-service/src/main/java/com/ohdelivery/service/match/matching/application/service/MatchingServiceImpl.java
+++ b/match-service/src/main/java/com/ohdelivery/service/match/matching/application/service/MatchingServiceImpl.java
@@ -3,24 +3,25 @@
 import com.ohdelivery.common.feign.GetDeliveryResponse;
 import com.ohdelivery.common.passport.Passport;
 import com.ohdelivery.common.passport.RoleType;
-import com.ohdelivery.service.match.common.DeliveryClientService;
-import com.ohdelivery.service.match.matching.application.MatchingEventPublisher;
+import com.ohdelivery.service.match.common.command.CommandInvoker;
+import com.ohdelivery.service.match.common.feign.DeliveryClientService;
+import com.ohdelivery.service.match.matching.application.command.CreateMatchingCommand;
+import com.ohdelivery.service.match.matching.application.command.DeleteMatchingCommand;
+import com.ohdelivery.service.match.matching.application.command.MatchingCommandFactory;
+import com.ohdelivery.service.match.matching.application.command.UpdateMatchingCommand;
 import com.ohdelivery.service.match.matching.application.dto.request.AssignRiderRequest;
 import com.ohdelivery.service.match.matching.application.dto.request.CreateMatchingRequest;
 import com.ohdelivery.service.match.matching.application.dto.response.GetMatchingResponse;
 import com.ohdelivery.service.match.matching.application.dto.response.SearchMatchingResponse;
+import com.ohdelivery.service.match.matching.application.exception.MatchingCreateException;
 import com.ohdelivery.service.match.matching.application.exception.MatchingNotFoundException;
+import com.ohdelivery.service.match.matching.application.exception.MatchingUpdateException;
 import com.ohdelivery.service.match.matching.domain.Matching;
 import com.ohdelivery.service.match.matching.domain.repository.MatchingRepository;
 import com.ohdelivery.service.match.rider.application.service.RiderService;
-import com.ohdelivery.service.match.rider.domain.model.Rider;
-import java.time.LocalDateTime;
-import java.util.List;
 import java.util.UUID;
-import java.util.concurrent.TimeUnit;
 import lombok.RequiredArgsConstructor;
-import org.redisson.api.RLock;
-import org.redisson.api.RedissonClient;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.PageRequest;
 import org.springframework.data.domain.Pageable;
@@ -28,51 +29,30 @@
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
 public class MatchingServiceImpl implements MatchingService {
 
   private final MatchingRepository matchingRepository;
-  private final MatchingEventPublisher matchingEventPublisher;
   private final RiderService riderService;
   private final DeliveryClientService deliveryService;
-  private final RedissonClient redissonClient;
+
+  private final MatchingCommandFactory matchingCommandFactory;
+  private final CommandInvoker commandInvoker;
 
   @Override
   @Transactional
   public UUID createMatching(CreateMatchingRequest request) {
-    UUID deliveryId = request.getDeliveryId();
-
-    // 이미 존재하는 매칭 확인 후 예외 처리
-    if (matchingRepository.findByDeliveryId(deliveryId).isPresent()) {
-      throw new IllegalArgumentException("해당 배달에 대한 매칭이 이미 존재합니다.");
-    }
-
-    Matching matching = Matching.create(deliveryId);
-    matchingRepository.save(matching);
-
-    List<Rider> nearbyRiders;
     try {
-      nearbyRiders = riderService.getRidersByLocation(
-          request.getStoreLatitude(),
-          request.getStoreLongitude());
+      CreateMatchingCommand command = matchingCommandFactory.createMatchingCommand(request);
+      Matching matching = commandInvoker.invoke(command);
+      log.info("매칭 생성 성공: 배달Id:{}, 매칭Id{}", matching.getDeliveryId(), matching.getId());
+      return matching.getId();
     } catch (Exception e) {
-      matchingEventPublisher.matchingCreateFailedEvent(deliveryId);
-      throw new RuntimeException(e);
+      log.error("매칭 생성 실패: {}", e.getMessage());
+      throw new MatchingCreateException("매칭 생성 중 오류 발생: " + e.getMessage());
     }
-
-    matchingEventPublisher.matchingCreatedEvent(
-        getRidersSlackIds(nearbyRiders),
-        getRidersIds(nearbyRiders),
-        matching.getId(),
-        request.getFee(),
-        request.getStoreName(),
-        request.getStoreAddress(),
-        request.getTargetAddress(),
-        request.getOrderRequest()
-    );
-
-    return matching.getId();
   }
 
   @Override
@@ -89,68 +69,24 @@ public GetMatchingResponse getMatching(UUID matchingId) {
   @Override
   @Transactional
   public void updateMatching(UUID matchingId, AssignRiderRequest request, Passport currentUser) {
-    // 매칭 ID를 기반으로 고유한 락 키 생성
-    String lockKey = "matching:" + matchingId.toString();
-    RLock lock = redissonClient.getLock(lockKey);
-    RoleType role = currentUser.getRoleType();
-    long userId = Long.parseLong(currentUser.getUserId());
-
-    if (role != RoleType.MASTER) {
-      if (userId != request.getRiderId()) {
-        throw new IllegalArgumentException("MASTER가 아니면 본인만 할당 가능합니다.");
-      }
-    }
-
     try {
-      // 락을 획득 시도 (최대 대기 시간: 5초, 락 유지 시간: 10초)
-      boolean isLocked = lock.tryLock(5, 10, TimeUnit.SECONDS);
-
-      if (!isLocked) {
-        throw new IllegalStateException("매칭에 대한 락 획득 실패: " + matchingId);
-      }
-
-      Matching matching = matchingRepository.findById(matchingId)
-          .orElseThrow(() -> new MatchingNotFoundException());
-
-      if (!matching.isUpdatable()) {
-        throw new IllegalArgumentException("매칭은 수정할 수 없는 상태입니다.");
-      }
-      UUID riderId = riderService.getRiderByuserId(request.getRiderId()).getId();
-
-      if (!riderService.checkAssignAvailable(riderId)) {
-        throw new IllegalArgumentException("라이더는 할당 가능한 상태가 아닙니다.");
-      }
-
-      matching.assignRider(riderId);
-      matchingRepository.save(matching);
-
-      UUID deliveryID = matching.getDeliveryId();
-      matchingEventPublisher.matchingCompletedEvent(deliveryID, riderId);
-
-    } catch (InterruptedException e) {
-      Thread.currentThread().interrupt();
-      throw new RuntimeException("락 획득 중 인터럽트 발생", e);
-    } finally {
-      // 항상 락 해제 (finally 블록에서 처리)
-      if (lock.isHeldByCurrentThread()) {
-        lock.unlock();
-      }
+      UpdateMatchingCommand command = matchingCommandFactory.updateMatchingCommand(matchingId,
+          request, currentUser);
+      commandInvoker.invoke(command);
+    } catch (Exception e) {
+      log.error("매칭 수정 실패: {}", e.getMessage());
+      throw new MatchingUpdateException("매칭 수정 중 오류 발생: " + e.getMessage());
     }
   }
 
   @Override
   public void deleteMatching(UUID deliveryId) {
-//    배달 취소 기능 -> 배달 취소 시 매칭도 없어져야 하는부분
-    Matching matching = matchingRepository.findByDeliveryId(deliveryId)
-        .orElseThrow(() -> new MatchingNotFoundException());
-
-    if (matching.isUpdatable()) {
-      LocalDateTime now = LocalDateTime.now();
-      String createdBy = "system";
-      matching.delete(now, createdBy);
-      matchingRepository.save(matching);
-    } else {
-      throw new IllegalArgumentException("Matching is not deletable");
+    try {
+      DeleteMatchingCommand command = matchingCommandFactory.deleteMatchingCommand(deliveryId);
+      commandInvoker.invoke(command);
+    } catch (Exception e) {
+      log.error("매칭 삭제 실패: {}", e.getMessage());
+      throw new MatchingDeleteException("매칭 삭제 중 오류 발생: " + e.getMessage());
     }
   }
 
@@ -168,7 +104,7 @@ public Page<SearchMatchingResponse> searchMatchings(
     long userId = Long.parseLong(currentUser.getUserId());
 
     if (currentUser.getRoleType() == RoleType.RIDER) {
-      UUID riderId = riderService.getRiderByuserId(userId).getId();
+      UUID riderId = riderService.getRiderByUserId(userId).getId();
       return matchingRepository.findByRiderId(riderId, pageable)
           .map(SearchMatchingResponse::from);
     }
@@ -177,16 +113,4 @@ public Page<SearchMatchingResponse> searchMatchings(
     return matchingRepository.findAll(pageable)
         .map(SearchMatchingResponse::from);
   }
-
-  private List<String> getRidersSlackIds(List<Rider> riders) {
-    return riders.stream()
-        .map(Rider::getSlackId)
-        .toList();
-  }
-
-  private List<Long> getRidersIds(List<Rider> riders) {
-    return riders.stream()
-        .map(Rider::getRiderId)
-        .toList();
-  }
 }
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/matching/domain/Matching.java b/match-service/src/main/java/com/ohdelivery/service/match/matching/domain/Matching.java
index 44e5d6e..0635ce0 100644
--- a/match-service/src/main/java/com/ohdelivery/service/match/matching/domain/Matching.java
+++ b/match-service/src/main/java/com/ohdelivery/service/match/matching/domain/Matching.java
@@ -6,6 +6,7 @@
 import jakarta.persistence.GeneratedValue;
 import jakarta.persistence.Id;
 import jakarta.persistence.Table;
+import java.time.LocalDateTime;
 import java.util.UUID;
 import lombok.AccessLevel;
 import lombok.AllArgsConstructor;
@@ -49,4 +50,23 @@ public boolean isUpdatable() {
   }
 
 
+  public void cancel() {
+    if (isUpdatable()) {
+      LocalDateTime now = LocalDateTime.now();
+      String userName = "system";
+      this.delete(now, userName);
+    }
+  }
+
+  public Matching clone() {
+    Matching copy = new Matching();
+    copy.id = this.getId();
+    copy.deliveryId = this.getDeliveryId();
+    copy.riderId = this.getRiderId();
+    copy.setCreatedAt(this.getCreatedAt());
+    copy.setCreatedBy(this.getCreatedBy());
+    copy.setUpdatedAt(this.getUpdatedAt());
+    copy.setUpdatedBy(this.getUpdatedBy());
+    return copy;
+  }
 }
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/matching/infrastructure/config/SagaConfig.java b/match-service/src/main/java/com/ohdelivery/service/match/matching/infrastructure/config/SagaConfig.java
new file mode 100644
index 0000000..68a8688
--- /dev/null
+++ b/match-service/src/main/java/com/ohdelivery/service/match/matching/infrastructure/config/SagaConfig.java
@@ -0,0 +1,14 @@
+package com.ohdelivery.service.match.matching.infrastructure.config;
+
+import com.ohdelivery.service.match.common.command.CommandInvoker;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class SagaConfig {
+
+  @Bean
+  public CommandInvoker commandInvoker() {
+    return new CommandInvoker();
+  }
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/rider/application/dto/request/GetNearRidersRequest.java b/match-service/src/main/java/com/ohdelivery/service/match/rider/application/dto/request/GetNearRidersRequest.java
new file mode 100644
index 0000000..cb10c4f
--- /dev/null
+++ b/match-service/src/main/java/com/ohdelivery/service/match/rider/application/dto/request/GetNearRidersRequest.java
@@ -0,0 +1,10 @@
+package com.ohdelivery.service.match.rider.application.dto.request;
+
+import lombok.Getter;
+
+@Getter
+public class GetNearRidersRequest {
+
+  Double sLat;
+  Double sLon;
+}
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/rider/application/service/RiderService.java b/match-service/src/main/java/com/ohdelivery/service/match/rider/application/service/RiderService.java
index 9359c76..3a49861 100644
--- a/match-service/src/main/java/com/ohdelivery/service/match/rider/application/service/RiderService.java
+++ b/match-service/src/main/java/com/ohdelivery/service/match/rider/application/service/RiderService.java
@@ -5,7 +5,6 @@
 import com.ohdelivery.service.match.rider.application.dto.request.UpdateRiderStatusRequest;
 import com.ohdelivery.service.match.rider.application.dto.response.GetRiderResponse;
 import com.ohdelivery.service.match.rider.application.dto.response.UpdateRiderStatusResponse;
-import com.ohdelivery.service.match.rider.domain.model.Rider;
 import java.util.List;
 import java.util.UUID;
 
@@ -29,7 +28,7 @@ public interface RiderService {
 
   boolean checkAssignAvailable(UUID riderId);
 
-  List<Rider> getRidersByLocation(Double sLat, Double sLon);
+  List<GetRiderResponse> getRidersByLocation(Double sLat, Double sLon);
 
-  GetRiderResponse getRiderByuserId(long riderId);
+  GetRiderResponse getRiderByUserId(long riderId);
 }
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/rider/application/service/RiderServiceImpl.java b/match-service/src/main/java/com/ohdelivery/service/match/rider/application/service/RiderServiceImpl.java
index 0c5bc1d..13d7abc 100644
--- a/match-service/src/main/java/com/ohdelivery/service/match/rider/application/service/RiderServiceImpl.java
+++ b/match-service/src/main/java/com/ohdelivery/service/match/rider/application/service/RiderServiceImpl.java
@@ -124,7 +124,7 @@ public boolean checkAssignAvailable(UUID id) {
   }
 
   @Override
-  public List<Rider> getRidersByLocation(Double sLat, Double sLon) {
+  public List<GetRiderResponse> getRidersByLocation(Double sLat, Double sLon) {
     List<Rider> riders = riderRepository.findAllByStatus(RiderStatus.AVAILABLE);
     riders = riders.stream()
         .filter(rider -> {
@@ -136,11 +136,13 @@ public List<Rider> getRidersByLocation(Double sLat, Double sLon) {
     if (riders.isEmpty()) {
       throw new AvailableRiderNotFoundException();
     }
-    return riders;
+    return riders.stream()
+        .map(GetRiderResponse::from)
+        .collect(Collectors.toList());
   }
 
   @Override
-  public GetRiderResponse getRiderByuserId(long riderId) {
+  public GetRiderResponse getRiderByUserId(long riderId) {
     Rider rider = riderRepository.findByRiderId(riderId)
         .orElseThrow(() -> new RiderNotFoundException());
     return new GetRiderResponse(
diff --git a/match-service/src/main/java/com/ohdelivery/service/match/rider/presentation/RiderController.java b/match-service/src/main/java/com/ohdelivery/service/match/rider/presentation/RiderController.java
index 870e792..8a2003f 100644
--- a/match-service/src/main/java/com/ohdelivery/service/match/rider/presentation/RiderController.java
+++ b/match-service/src/main/java/com/ohdelivery/service/match/rider/presentation/RiderController.java
@@ -5,6 +5,7 @@
 import com.ohdelivery.common.response.ApiResponse;
 import com.ohdelivery.common.response.SuccessCode;
 import com.ohdelivery.service.match.rider.application.dto.request.CreateRiderRequest;
+import com.ohdelivery.service.match.rider.application.dto.request.GetNearRidersRequest;
 import com.ohdelivery.service.match.rider.application.dto.request.UpdateRiderRequest;
 import com.ohdelivery.service.match.rider.application.dto.request.UpdateRiderStatusRequest;
 import com.ohdelivery.service.match.rider.application.dto.response.GetRiderResponse;
@@ -101,4 +102,28 @@ public ResponseEntity<ApiResponse<Void>> deleteMatching(@PathVariable UUID id) {
         SuccessCode.COMMON_SUCCESS.getMessage()
     ));
   }
+
+  @GetMapping("/nearRiders")
+  @RoleCheck(RoleType.MASTER)
+  public ResponseEntity<ApiResponse<List<GetRiderResponse>>> getAllNearRider(
+      @RequestBody GetNearRidersRequest request) {
+    List<GetRiderResponse> response = riderService.getRidersByLocation(request.getSLat(),
+        request.getSLon());
+    return ResponseEntity.ok(ApiResponse.success(
+        SuccessCode.COMMON_SUCCESS.getCode().toString(),
+        SuccessCode.COMMON_SUCCESS.getMessage(),
+        response
+    ));
+  }
+
+  @GetMapping("/user/{userId}")
+  public ResponseEntity<ApiResponse<GetRiderResponse>> getRiderByUserId(
+      @PathVariable("userId") long userId) {
+    GetRiderResponse response = riderService.getRiderByUserId(userId);
+    return ResponseEntity.ok(ApiResponse.success(
+        SuccessCode.COMMON_SUCCESS.getCode().toString(),
+        SuccessCode.COMMON_SUCCESS.getMessage(),
+        response
+    ));
+  }
 }


Reviews:
user: login: dannyseo9202
body: 태훈님 전반적으로 잘 구현해주신 것 같습니다, 수고 많으셨습니다 ~!! ㅎㅎ 
시간 나실 때 커맨드 별 테스트 코드도 한 번 작성해보시면 좋을 것 같습니다.
수고하셨습니다 ~!! (_ _) 👍 
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-23T02:29:26Z
user: login: sinryuji
body: 고생하셨습니다!!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T12:51:09Z

Review comments:
diff_hunk: @@ -0,0 +1,11 @@
+package com.ohdelivery.service.match.common.command;
+
+/*
+  매칭쪽(match-service에 matching) 커맨드 패턴 적용 위한 command 인터페이스
+ */
+public interface MatchingCommand<T> {
path: match-service/src/main/java/com/ohdelivery/service/match/common/command/MatchingCommand.java
user: login: dannyseo9202
body: 태훈님 해당 부분은 MatchingCommand로 쓰시다가 나중에 혹시 라이더 애그리거트에서도 커맨드 패턴이 적용되어야 할 경우 Commant<T>로 바꿔주시면 됩니다 :) ~!
created_at: 2025-04-23T02:23:06Z
author_association: NONE
diff_hunk: @@ -0,0 +1,97 @@
+package com.ohdelivery.service.match.matching.application.command;
+
+import com.ohdelivery.common.feign.request.ClientGetNearRidersRequest;
+import com.ohdelivery.common.feign.response.ClientGetRiderResponse;
+import com.ohdelivery.service.match.common.command.MatchingCommand;
+import com.ohdelivery.service.match.common.feign.RiderClientService;
+import com.ohdelivery.service.match.matching.application.MatchingEventPublisher;
+import com.ohdelivery.service.match.matching.application.dto.request.CreateMatchingRequest;
+import com.ohdelivery.service.match.matching.domain.Matching;
+import com.ohdelivery.service.match.matching.domain.repository.MatchingRepository;
+import java.util.List;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Slf4j
+public class CreateMatchingCommand implements MatchingCommand<Matching> {
+
+  private final CreateMatchingRequest request;
+  private final MatchingRepository matchingRepository;
+  private final RiderClientService riderService;
+  private final MatchingEventPublisher matchingEventPublisher;
+  private UUID matchingId;
+  private UUID deliveryId;
+
+  @Override
+  @Transactional
+  public Matching execute() {
+    deliveryId = request.getDeliveryId();
+
+    // 중복 매칭 체크
+    if (matchingRepository.findByDeliveryId(deliveryId).isPresent()) {
+      throw new IllegalArgumentException("해당 배달에 대한 매칭이 이미 존재합니다.");
+    }
+
+    // 매칭 생성
+    Matching matching = Matching.create(deliveryId);
+    matchingRepository.save(matching);
+
+    matchingId = matching.getId();
+
+    // feign - 주변 라이더 조회
+    List<ClientGetRiderResponse> nearbyRiders = riderService.getAllNearRider(
+        ClientGetNearRidersRequest.of(request.getStoreLatitude(), request.getStoreLongitude())
+    ).getData();
+
+    // 이벤트 발행: 매칭 생성 완료 -> 알람 해당 라이더들에게 보낼때 소비
+    matchingEventPublisher.matchingCreatedEvent(
+        getRidersSlackIds(nearbyRiders),
+        getRidersIds(nearbyRiders),
+        matching.getId(),
+        request.getFee(),
+        request.getStoreName(),
+        request.getStoreAddress(),
+        request.getTargetAddress(),
+        request.getOrderRequest()
+    );
+    log.info("매칭 생성 완료: {}", matching.getId());
+    return matching;
+  }
+
+  @Override
+  @Transactional
+  public void undo() {
path: match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/CreateMatchingCommand.java
user: login: dannyseo9202
body: 👍 undo 구현도 좋네요 ~!
created_at: 2025-04-23T02:25:10Z
author_association: NONE
diff_hunk: @@ -0,0 +1,48 @@
+package com.ohdelivery.service.match.matching.application.command;
+
+import com.ohdelivery.common.passport.Passport;
+import com.ohdelivery.service.match.common.command.CommandFactory;
+import com.ohdelivery.service.match.common.feign.RiderClientService;
+import com.ohdelivery.service.match.matching.application.MatchingEventPublisher;
+import com.ohdelivery.service.match.matching.application.dto.request.AssignRiderRequest;
+import com.ohdelivery.service.match.matching.application.dto.request.CreateMatchingRequest;
+import com.ohdelivery.service.match.matching.domain.repository.MatchingRepository;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.redisson.api.RedissonClient;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class MatchingCommandFactory implements CommandFactory {
path: match-service/src/main/java/com/ohdelivery/service/match/matching/application/command/MatchingCommandFactory.java
user: login: dannyseo9202
body: 👍 태훈님이 구현해 보신 것 처럼,
우리가 흔히 사용하는 ~팩토리는 생성에 대한 책임을 담당하며, 생성에 필요한 디펜던시를 한 곳에서 효율적으로 관리할 수 있는 장점이 있습니다.
created_at: 2025-04-23T02:27:07Z
author_association: NONE

PR comments:
user: login: ekdh0858
created_at: 2025-04-22T13:41:00Z
body: @lumen4284 리뷰 부탁드립니다 🙏
author_association: CONTRIBUTOR