서론
물류 시스템에서 "누가 이 배송을 담당할 것인가"는 서비스의 효율성을 결정짓는 기초적이면서도 중요한 요소이다.
Delivery-Signal 프로젝트는 허브 간 이동부터 최종 배송까지의 전 과정을 다룬다. 이때 수많은 주문이 실시간으로 쏟아지는 상황에서 관리자가 일일이 배송 기사를 지정하는 것은 불가능하며, 특정 기사에게만 업무가 몰리는 상황 또한 방지되어야 한ㄷ.ㅏ
따라서 아래와 같은 목표를 설정하였다
- 모든 배송 담당자에게 업무를 균등하게 분배해야 한다. (특정 담당자에게 일이 몰리는 일이 없어야 한다)
- 주문 발생 시 시스템이 즉시 배송 담당자를 주문건에 매칭하도록 이를 자동화해야 한다.
- 담당자가 퇴사하거나 삭제되어도 배송 담당자 매칭 절차(순번 할당)에 이상이 없어야 한다.
이를 해결하기 위해 가장 일반적인 라운드 로빈(Round-Robin) 방식을 채택했으나, 우리 프로젝트에서는 "삭제된 담당자의 순번은 재배열하지 않는다"는 특수한 사항이 있었다. 이번 글에서는 배송 담당자 순번 할당에 대한 구현과 함께 이러한 요구사항을 어떻게 풀어내기로 하였는지 정리해보았다.
본론
순번 로직의 동작 방식
순번 로직은 "다음 차례 담당자"를 결정하기 위한 시스템이다.
| 순번 | 담당자 (ID) | 상태 |
| 0 | 김 배송 (100) | 활성화 |
| 1 | 이 담당 (101) | 활성화 |
| 2 | 박 물류 (102) | 활성화 |
| 3 | 최 기사 (103) | 활성화 |
- 배송 1 발생: 순번 0 (김 배송)에게 배정된다
- 배송 2 발생: 순번 1 (이 담당)에게 배정된다
- 배송 3 발생: 순번 2에게 배정된다
- 배송 4 발생: 순번 3에게 배정된다
- 배송 5 발생: 다음 순번이 없으므로, 다시 순번 0 (김 배송)에게 돌아가 배정된다
분산 환경에서의 상태 일관성 문제
MSA 환경에서는 여러 배송 서비스 인스턴스가 동시에 돌아간다. "누가 마지막으로 배정받았는지?"라는 상태 정보가 인스턴스마다 다르면 안 되기 때문에, 이를 관리할 저장소가 필요한 상태였다.
REDIS 기반의 배송 순번 할당 시스템 구축(라운드 로빈 방식)
새로 들어온 주문 건의 배송 담당자를 설정할 때, 누가 마지막으로 배정되었는지에 대한 정보(LAST SEQUENCE)를 기준으로 관리한다.
- Last Sequence 관리: 직전 시퀀스 정보를 REDIS에 저장
- 직전 순번 조회: 배송 주문 발생 시 Redis에서 직전 배정 순번을 조회
- 다음 담당자 배정: REDIS 조회를 통해 다음 담당자를 순차적으로 배정
- Last Sequence 갱신: Redis의 마지막 시퀀스 정보(LAST SEQUENCE)를 갱신
배정 시마다 DB에 ‘시퀀스 정보' 테이블을 별도로 두어 조회하는 대신, REDIS를 활용하여 상태를 관리함으로써 DB의 쓰기(WRITE) 부하를 최소화하였다.
배정 로직 개념(순환 배정)
일반적인 라운드 로빈은 (직전 순번 + 1) % 전체 인원 수와 같은 모듈러 연산을 사용하여 순환적으로 동작한다.
예를 들면, 아래와 같이 동작하게 된다.
배송 순번을 기준으로 순환하는 방식 : (0 -> 1 -> ... N -> 0)

배송 담당자가 추가(입사)되고 삭제(퇴사)될 경우
그러나 우리 프로젝트에서는 "새로운 담당자는 가장 마지막 순번으로 설정된다"는 것과 "삭제된 담당자의 순번은 재배열하지 않는다"는 특수한 요구사항이 있다.
| 규칙 | 로직 |
| 새로운 담당자는 가장 마지막 순번 | 새로운 담당자를 추가할 때, 현재 활성화된 담당자의 가장 큰 순번 + 1로 지정하여 기존 순서에 영향을 주지 않고 순환에 포함시킨다. |
| 삭제된 담당자의 순번은 재배열되지 않음 | 순번은 고유한 할당 기준점 역할을 하며, 삭제 시 중간에 구멍이 생기더라도 나머지 담당자의 순번은 유지되어야 한다. (예: 순번 1번 담당자 삭제 -> 순번 0, 2, 3은 그대로 유지) |
삭제된 순번을 건너뛰는 배정 로직 구현 (배송 순번 비연속성)
새롭게 추가된 배송 담당자를 가장 마지막 순번으로 할당하는 것은 큰 문제가 되지 않으나, 삭제된 순번을 건너뛰는 로직을 구현해야 하는 요구사항에 직면하게 되었다.
예를 들어, 담당자 순번이 [0, 1, 2]였다가 2번 담당자가 삭제되고 5번 담당자가 새로 들어온다면, 활성 순번은 [0, 1, 5]와 같이 불연속적인 상태가 된다.
따라서 위에서 앞서 쓴 배송 담당자 배정 로직 방식과도 조금 더 차이가 생긴다. 발표자료에 쓰인 아래 표를 통해 좀 더 확실히 이해할 수 있다.

따라서 아래 1, 2단계에 걸쳐 비연속 순번 배정 로직을 구현하였다. 이를 좀 더 명확하게 글로 표현하자면 DB 쿼리를 활용한 2단계 탐색 로직이라고 말할 수 있다.
1단계: 다음 순번 탐색
- (직전 순번보다 큰) 활성 순번을 탐색하고, 그중 가장 작은 값 찾기
- Redis에서 가져온 직전 순번보다 크면서, 현재 활성화된 담당자 중 가장 작은 순번(Limit 1)을 찾는다.
- 삭제된 순번(구멍)을 건너뛰고 다음 담당자에게 바로 연결하는 방식이다.
예시: 직전 순번이 1이고 활성 순번이 0, 1, 5라면, 1보다 큰 5를 찾아 배정
2단계: 순환 판단 및 배정
- 1단계에서 다음 순번을 못 찾으면(마지막 순번까지 도달했다면) 순환이 필요한 시점
- 전체 활성 순번 중 가장 작은 값의 순번(대부분 0번)을 찾음
- 마지막 순번에서 처음 순번으로 순환
예시: 직전 순번이 5라면, 5보다 큰 순번이 없으므로 다시 0번으로 돌아간다
배송 담당자 순번 로직의 DDD 적용
배송 담당자 순번 관리는 비즈니스 행위를 포함하므로, DDD의 Application 서비스와 Domain Entity를 활용하여 로직을 분리하는 방식이 권장된다.
- 엔티티는 개별 객체의 '상태(내 순번)'만 관리할 뿐, 전체 배송 담당자들의 현황이나 순서 흐름은 알 수 없다
- 리포지토리(Repository) 는 데이터베이스에 접근하여 전체 데이터 중 '현재 가장 마지막 순번이 무엇인지' 조회하는 역할을 맡는다.
- 서비스(Application Service) 는 리포지토리가 찾아온 정보를 바탕으로 '다음 순번 결정 규칙(라운드 로빈 등)'을 수행하여 실제 배정을 한다.
그래서 이렇게 데이터 조회(Repository), 비즈니스 흐름 제어(Service), 상태 저장(Entity)으로 책임을 분리해야 유지보수성과 유연성이 높아진다.
Domain Layer
현재 DeliveryManager(배송 담당자) 엔티티는 순번을 가지고 있는 객체이지, 순번을 결정하는 주체가 아니다.
순번을 결정하는 로직은 "행위"와 관련되므로, 이는 Application 서비스가 담당하도록 하는 것이 좋다. 그렇지만 최대 순번을 조회하는 것은 Repository의 책임이다.
DeliveryManagerRepository
public interface DeliveryManagerRepository {
/**
* DDD 원칙
* 순수한 도메인 개념만 표현
* 구현체는 인프라스트럭처 계층에서 제공
*/
// ... (기존 메서드 유지)
/**
* 현재 배송 담당자 수
* @return
*/
Long countActiveManagers();
/**
* 다음 배정될 순번의 배송 담당자를 조회 (순환 로직/라운드로빈)
* lastSequence : 직전에 배정된 담당자의 순번
* @param lastSequence
* @return
*/
Optional<DeliveryManager> findNextActiveManager(int lastSequence);
}
Application Layer (비즈니스 흐름 및 트랜잭션)
Application 서비스는 최대 순번 조회와 다음 순번 결정 로직을 실행하고, 이를 바탕으로 DeliveryManager를 생성/수정한다.
DeliveryManagerService (배송 담당자 등록 시)
등록 시에는 가장 큰 순번 + 1을 부여하는 로직이 필요하다.
@Slf4j
@Service
public class DeliveryManagerService {
// ...
@Transactional
public ManagerQueryResponse registerManager(Long userId, CreateDeliveryManagerCommand command) {
permissionValidator.hasRegisterPermission(command.hubId(), userId);
if (command.type() == DeliveryManagerType.PARTNER_DELIVERY && command.hubId() == null) {
throw new IllegalStateException("업체 배송 담당자는 소속 허브 ID가 필수입니다.");
}
// [순번 로직] 새로운 담당자 순번 결정: (가장 큰 순번 + 1)
// 새로운 배송 담당자가 추가되면 가장 마지막 순번으로 설정
Integer maxActiveSequence = deliveryManagerRepository.findMaxActiveSequence().orElse(-1);
int newSequence = maxActiveSequence + 1;
deliveryManagerRepository.findActiveById(command.managerId())
.ifPresent(deliveryManager -> {
throw new IllegalStateException("이미 등록되어 있는 배송 담당자입니다.");
});
DeliveryManager manager = DeliveryManager.create(command.managerId(),
command.hubId(),
command.slackId(),
command.type(),
newSequence,
userId);
DeliveryManager savedManager = deliveryManagerRepository.save(manager);
return getManagerResponse(savedManager);
}
}
DeliveryManagerService, DeliveryAssignmentService (다음 배송 담당자 배차)
아래 코드에서 하는 작업들은 다음과 같다.
- Redis 상태 조회: Redis에서 직전에 배정된 배송 담당자의 순번(Last Sequence)을 조회한다.
- 순환 배정 수행: DB 쿼리를 이원화(다음 순번 탐색, 순환 탐색)하여 조회된 순번을 기반으로 DB에서 다음 순번의 활성 담당자를 탐색하며, 삭제된 순번은 건너뛰고 마지막 순번 이후에는 다시 0번으로 순환하는 라운드 로빈 로직을 수행한다.
- 순번 갱신 및 반환: 배정된 담당자의 순번을 다시 Redis에 저장하여 다음 배정 요청을 대비하고, 최종적으로 배정된 담당자의 ID를 반환한다
@Slf4j
@Service
public class DeliveryManagerService {
private final DeliveryManagerRepository deliveryManagerRepository;
private final DeliveryAssignmentService deliveryAssignmentService;
/**
* 배송 담당자 순번을 관리하고 다음 담당자를 배정
*/
@Transactional
public Long assignNextDeliveryManager(Long currUserId) {
// 활성 담당자 수 확인 (순환 로직 기반)
Long activeCount = deliveryManagerRepository.countActiveManagers();
if (activeCount == 0) {
throw new IllegalStateException("현재 배정 가능한 배송 담당자가 없습니다.");
}
DeliveryManager nextManager = deliveryAssignmentService.getNextManagerForAssignment();
permissionValidator.hasAssignPermission(nextManager.getHubId(), currUserId);
// 배정된 담당자 ID 반환
return nextManager.getManagerId();
}
}
/**
* 배송 배정 로직(순번 관리, 다음 담당자 조회 등)을 위한 도메인 서비스
* Domain Layer에 위치하며, Infrastructure Layer가 이를 구현
*/
public interface DeliveryAssignmentService {
/**
* 다음 배송 순번에 해당하는 활성 배송 담당자를 순환 방식으로 조회
*/
DeliveryManager getNextManagerForAssignment();
}
- 삭제된 순번을 건너뛰고 다음 순번을 찾는 쿼리(findNextBySequenceGreaterThan)를 정의
- 순환이 필요할 때 가장 작은 순번을 찾는 쿼리(findFirstActiveManager)를 통해 라운드 로빈을 지원
public interface JpaDeliveryManagerRepository extends JpaRepository<DeliveryManager, Long> {
// Repository Interface 구현을 위한 JPA 쿼리 메서드
// deletedAt이 null인 데이터만 조회
Optional<DeliveryManager> findByManagerIdAndDeletedAtIsNull(Long id);
// 가장 마지막 배송 순번
@Query("SELECT MAX(dm.deliverySequence) FROM DeliveryManager dm WHERE dm.deletedAt IS NULL")
Optional<Integer> findMaxDeliverySequence();
// 다음 순번에 해당하는 활성 담당자를 찾는 쿼리 (순환 로직 처리)
// @Query(value = """
// SELECT * FROM delivery_manager dm
// WHERE dm.deleted_at IS NULL
// AND dm.delivery_sequence >= :nextSequenceToFind
// ORDER BY dm.delivery_sequence ASC
// LIMIT 1
// """, nativeQuery = true)
// Optional<DeliveryManager> findNextActiveManagerFromSequence(@Param("nextSequenceToFind") int nextSequenceToFind);
// 삭제되지 않은 배송 담당자 수
Long countByDeletedAtIsNull();
// lastSequence보다 크면서 가장 작은 순번(바로 다음 순번)을 가진 담당자 조회
@Query(value = "SELECT dm.* FROM p_delivery_managers dm WHERE dm.deleted_at IS NULL AND dm.sequence > :lastSequence ORDER BY dm.sequence ASC LIMIT 1", nativeQuery = true)
Optional<DeliveryManager> findNextBySequenceGreaterThan(@Param("lastSequence") int lastSequence);
// 다음 배송 담당자가 없으면 순번 0부터 시작하는 가장 작은 순번의 담당자 조회
@Query(value = "SELECT dm.* FROM p_delivery_managers dm WHERE dm.deleted_at IS NULL ORDER BY dm.sequence ASC LIMIT 1", nativeQuery = true)
Optional<DeliveryManager> findFirstActiveManager();
}
- Domain Repository 인터페이스를 구현하여 인프라 계층(JPA)과 도메인 계층을 연결하는 어댑터
- findNextBySequenceGreaterThan 호출 후 결과가 없으면 findFirstActiveManager를 호출하는 로직을 수행
- '삭제된 순번은 건너뛰고 순환한다'는 비즈니스 요구사항 반영
@Component
public class DeliveryManagerRepositoryImpl implements DeliveryManagerRepository {
// JpaRepository를 주입받아 사용 (어댑터 역할)
private final JpaDeliveryManagerRepository jpaRepository;
public DeliveryManagerRepositoryImpl(JpaDeliveryManagerRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public DeliveryManager save(DeliveryManager manager) {
return jpaRepository.save(manager);
}
@Override
public Optional<DeliveryManager> findActiveById(Long id) {
return jpaRepository.findByManagerIdAndDeletedAtIsNull(id);
}
@Override
public Optional<Integer> findMaxActiveSequence() {
return jpaRepository.findMaxDeliverySequence();
}
@Override
public Long countActiveManagers() {
return jpaRepository.countByDeletedAtIsNull();
}
@Override
public Optional<DeliveryManager> findNextActiveManager(int lastSequence) {
Optional<DeliveryManager> nextManager = jpaRepository.findNextBySequenceGreaterThan(
lastSequence);
if (nextManager.isPresent()) {
return nextManager;
}
// 다음 배정된 배송 담당자가 없으면 순환 (다시 순번 0부터 시작)
return jpaRepository.findFirstActiveManager();
}
}
- Redis를 활용하여 직전 배정 순번(LAST_SEQUENCE_KEY)을 관리하고 조회하는 구현체
- Redis에서 가져온 순번을 기반으로 Repository를 호출하여 실제 담당자 엔티티를 찾음
@Slf4j
@Component
public class DeliveryAssignmentRedisService implements DeliveryAssignmentService {
private final RedisTemplate<String, Object> redisTemplate;
private final DeliveryManagerRepository deliveryManagerRepository;
// 레디스에 순번을 저장할 때 사용할 키(key)
private static final String LAST_SEQUENCE_KEY = "delivery:last-sequence";
public DeliveryAssignmentRedisService(RedisTemplate<String, Object> redisTemplate,
DeliveryManagerRepository deliveryManagerRepository) {
this.redisTemplate = redisTemplate;
this.deliveryManagerRepository = deliveryManagerRepository;
}
@Override
public DeliveryManager getNextManagerForAssignment() {
// 레디스에서 직전 순번 조회
int lastSequence = getLastAssignedSequence();
// 다음 배송 담당자 조회
DeliveryManager nextManager = deliveryManagerRepository.findNextActiveManager(lastSequence)
.orElseThrow(() -> new IllegalStateException("배정 로직 오류: 다음 담당자를 찾을 수 없습니다."));
// 배정된 담당자의 순번을 Redis에 업데이트 (마지막 순번 업데이트)
redisTemplate.opsForValue().set(LAST_SEQUENCE_KEY, String.valueOf(nextManager.getDeliverySequence()));
return nextManager;
}
// 순번을 안전하게 가져오기 (Redis 장애 대비 및 NULL 처리)
private int getLastAssignedSequence() {
String lastSequenceStr = (String) redisTemplate.opsForValue().get(LAST_SEQUENCE_KEY);
if (lastSequenceStr == null) {
return -1; // 초기값
}
try {
return Integer.parseInt(lastSequenceStr);
} catch (NumberFormatException e) {
log.error("[Redis] 순번 KEY({})의 값이 유효하지 않습니다. 기본값 -1 반환.", LAST_SEQUENCE_KEY, e);
return -1;
}
}
}
'Project' 카테고리의 다른 글
| [RUSH_CREW] 2. DDD에서의 VO(Value Object) (0) | 2025.12.02 |
|---|---|
| [RUSH_CREW] 1. 타임딜 서비스 개요 (feat. DDD Bounded Context) (0) | 2025.11.26 |
| [DELIVERY_SIGNAL] Circuit Breaker 구현(Resilience4j) (0) | 2025.11.21 |
| [DELIVERY_SIGNAL] DDD 계층 구조 적용 (0) | 2025.11.06 |
| [SURFING THE GANGWON] 기상청 API 호출 성능 최적화 (0) | 2025.10.01 |