본문 바로가기
Project/[DAISO] 안성 온라인 WMS

[안성 온라인 WMS] 물류센터 DB 커넥션 풀 완전 정복 — DB부터 Redis까지

by IT 고래 2026. 5. 22.

물류센터 DB 커넥션 풀 완전 정복 — DB부터 Redis까지

WMS(창고관리시스템)를 운영하다 보면 피크 타임마다 DB가 버벅이는 경험을 하게 된다.
원인의 대부분은 커넥션 풀 설정 미스와 동시성 처리 부재다.
물류센터 현장 환경에 맞게 커넥션 풀부터 Redis 활용까지, 실제 운영 WMS 코드를 기반으로 정리한다.

 


1. 물류센터 동시 접속자 구성

물류센터는 일반 사무 환경과 다르다.
PDA, 자동화 설비, 관리 PC가 동시에 DB에 붙기 때문에 접속 패턴이 복잡하다.

클라이언트특징DB 접속 패턴
PDA (현장 작업자) 입고/출고/재고 이동 시 짧은 트랜잭션 반복 초당 다수 요청, 빠른 응답 필수
관리 PC (사무) 주문 확인, 재고 조회, 리포트 간헐적이지만 무거운 쿼리
자동화 설비 (DAS·소터·AGV) 컨베이어, 소터 등 설비 연동 주기적 폴링, 실시간성 중요
배치 프로그램 마감, 정산, 집계 대량 데이터 처리, 장시간 커넥션 점유
WMS 앱 서버 위 모든 요청을 중계 커넥션 풀 집중 관리 포인트

동시 접속자 추정 공식

 
 
총 커넥션 수 = (PDA 수 × 동시 요청 비율) + (PC 수 × 동시 요청 비율) + 설비 수 + 배치 여유분

예시 — 중형 물류센터 (작업자 100명 기준):
  PDA 80대 × 30%          = 24
  관리 PC 20대 × 20%      = 4
  설비 연동 (DAS·소터·AGV) = 10
  배치 여유분              = 10
  ─────────────────────────────
  최소 필요 커넥션         = 약 48개
  권장 설정                = 60~80개 (피크 × 1.5 버퍼)

피크 타임 주의: 오전 출고 집중 시간(09:00~11:00), 오후 입고 마감(15:00~17:00)에 트래픽이 2~3배 급등한다. 평균이 아닌 피크 기준으로 설계해야 한다.


2. 커넥션 풀(Connection Pool)이란

DB 연결은 생성 비용이 크다. 커넥션을 매번 새로 만들면 TCP 핸드셰이크 + 인증 과정이 반복되어 응답이 느려진다.

 
 
커넥션 풀 없이:
  PDA 피킹 요청 → DB 연결 생성 → 쿼리 실행 → 연결 종료  (매번 반복, 느림)

커넥션 풀 사용:
  미리 연결 N개 확보 → PDA 요청 시 빌려쓰고 → 반납  (재사용, 빠름)

핵심 설정값 (HikariCP 기준)

설정설명물류센터 권장값

 

minimumIdle 유휴 시 최소 유지 커넥션 수 10~20
maximumPoolSize 최대 커넥션 수 60~80
connectionTimeout 커넥션 획득 대기 시간 3,000ms (3초)
idleTimeout 유휴 커넥션 유지 시간 600,000ms (10분)
maxLifetime 커넥션 최대 수명 1,800,000ms (30분)
keepaliveTime 커넥션 유지 확인 주기 60,000ms (1분)
 
 
yaml
# application.yml — HikariCP 설정 예시
spring:
  datasource:
    hikari:
      minimum-idle: 10
      maximum-pool-size: 60
      connection-timeout: 3000
      idle-timeout: 600000
      max-lifetime: 1800000
      keepalive-time: 60000

3. 물류센터에 적합한 DB 스펙

커넥션 풀 수는 DB 서버 스펙과 직결된다. 아무리 풀을 크게 잡아도 DB가 받아낼 수 없으면 소용없다.

규모별 권장 스펙 (PostgreSQL / Aurora 기준)

 

규모 동시 작업자 CPU RAM 권장 max_connections (동시 작업 PC 수) 넥션 풀 설정
소형 ~50명 4코어 16GB 200 30~50
중형 50~200명 8코어 32GB 500 60~100
대형 200명+ 16코어+ 64GB+ 1,000+ PgBouncer 필수
 
 
max_connections 권장값 = RAM(GB) × 25 ~ 50

예) 32GB RAM → max_connections = 800~1,600
    실제 운영 권장 = 500 (나머지는 OS·배치 여유)

Oracle SE2의 함정: SE2는 소켓당 최대 16 CPU 스레드만 허용한다. 서버가 40코어여도 16개만 일하기 때문에 커넥션이 아무리 많아도 병목이 발생한다. PostgreSQL은 이 제약이 없어 서버 자원을 풀로 활용할 수 있다.

Aurora PostgreSQL 권장 인스턴스

인스턴스 vCPU RAM 적합한 규모
db.r6g.large 2 16GB 소형 센터, 개발 환경
db.r6g.xlarge 4 32GB 중형 센터 (작업자 100명 이하)
db.r6g.2xlarge 8 64GB 대형 센터, 다중 설비 연동
db.r6g.4xlarge 16 128GB 멀티 센터 통합 운영

4. 동시성 처리 — 재고가 음수가 되는 이유

물류센터에서 가장 흔한 버그 중 하나가 재고 음수다. 실제 WMS 재고 테이블(TWORK_INFO_IVATXLC)의 가용재고(AVQTY)를 예로 들면:

 
 
TWORK_INFO_IVATXLC의 AVQTY(가용재고) = 10

PDA 작업자 A (피킹 8개): SELECT AVQTY → 10 확인
PDA 작업자 B (피킹 5개): SELECT AVQTY → 10 확인  ← 동시에!

작업자 A: UPDATE SET AVQTY = 10 - 8 = 2
작업자 B: UPDATE SET AVQTY = 10 - 5 = 5  ← A의 작업을 모름!

결과: AVQTY = 5로 기록 (실제 잔여는 -3이어야 함)

이걸 해결하는 방법이 락(Lock) 이다.


5. 비관적 락 vs Redis 분산락

비관적 락 (Pessimistic Lock)

"어차피 충돌날 거야 — 미리 잠가버리자"

데이터를 읽을 때부터 다른 트랜잭션이 접근 못하도록 DB 행을 직접 잠근다.

 
 
sql
-- 실제 WMS: 할당 처리를 위한 재고 행 LOCK
-- ObAllocateReleaseManager.java → selectIvatxLockByAllocate
SELECT CTKEY, OWKEY, IVATKEY, LCKEY, ICKEY, IVSERIALNO
  FROM TWORK_INFO_IVATXLC
 WHERE CTKEY   = #{ctkey}
   AND OWKEY   = #{owkey}
   AND ICKEY   = #{ickey}
   AND IVATKEY = #{ivatkey}
   AND LCKEY   = #{lckey}
   AND TOTQTY  > 0
   FOR UPDATE;  -- 이 순간부터 다른 트랜잭션은 대기

-- 이후 가용재고(AVQTY) 차감 UPDATE 실행
UPDATE TWORK_INFO_IVATXLC
   SET AVQTY = AVQTY - #{alqty},
       ALQTY = ALQTY + #{alqty}  -- 할당수량 증가
 WHERE CTKEY = #{ctkey} AND IVATKEY = #{ivatkey} AND LCKEY = #{lckey};

항목 내용
장점 데이터 정합성 100% 보장
단점 대기 트랜잭션 증가 → 처리량 감소
적합한 상황 재고 차감(AVQTY), 로케이션 점유 등 충돌 빈도가 높은 작업

Redis 분산락 (낙관적 동시성 제어)

"충돌 안 날 거야 — 락을 걸되 DB가 아닌 Redis에서 관리하자"

WMS에서는 @Version 방식 대신 Redis 기반 @DistributedLock 으로 비즈니스 로직 전체를 보호한다. Spring AOP 어노테이션 방식이라 락 관리와 비즈니스 로직이 깔끔하게 분리된다.

 
 
java
// 실제 WMS: 출고완료 확정 — obhdkey 기준 분산락
// ObPckgPrcsManager.java:46
@DistributedLock(key = "#param.get(\"obhdkey\")",
                 waitTime = 10, leaseTime = 15, timeUnit = TimeUnit.SECONDS)
@Transactional
public void outboundHeaderConfirm(Map<String, Object> param,
                                  List<Map<String, Object>> inList) {

    // 1. 락 안에서 현재 상태 재확인
    Map<String, Object> obHdMap =
            obManager.selectOutboundHeaderByOutboundHeaderKey(param);

    if (!List.of(WmsConstant.WMS_OBSTATUS_PK_IST,
                 WmsConstant.WMS_OBSTATUS_PK_PART,
                 WmsConstant.WMS_OBSTATUS_PK_CNFRM,
                 WmsConstant.WMS_OBSTATUS_PAK_NO)
             .contains(MapUtil.getStr(obHdMap, "obstatuscd"))) {
        throw new MsgException("NO_ORDER_TO_PRCS");
    }

    // 2. 패킹 처리
    for (Map<String, Object> inMap : inList) {
        obPackingManager.outboundDetailPacking(inMap);
    }
    obPackingManager.updateOutboundHeaderByPacking(param);
    obManager.obStatusCdUpdate(param);

    // 3. 출고 확정 처리
    for (Map<String, Object> inMap : inList) {
        obConfirmManager.outboundDetailConfirm(inMap);
    }
    obConfirmManager.updateOutboundHeaderByConfirm(param);

    // 4. OMS에 결과 반환
    obConfirmManager.outboundOrderConfirm(param);
}
// 동일 obhdkey로 동시 요청 시 → 두 번째 요청은 10초 대기 후 락 획득
// 락 획득 후 상태 재확인 → 이미 확정된 상태면 MsgException → 중복 처리 차단

항목 내용
장점 DB 락 없이 서비스 계층 전체 보호 → 처리량 높음
단점 Redis 장애 시 락 기능 불가, waitTime 초과 시 요청 실패
적합한 상황 출고 확정·주문 분리·웨이브 관리 등 다단계 프로세스

물류센터 상황별 락 전략 선택

작업충돌 빈도권장 락이유

 

작업 충돌 빈도 권장 락이유
재고 차감 (피킹) 높음 비관적 (FOR UPDATE) AVQTY 음수 절대 불가
로케이션 점유 높음 비관적 (FOR UPDATE) 두 작업자 동시 점유 방지
출고 확정 중간 Redis 분산락 다단계 프로세스 원자성
웨이브 관리 중간 Redis 분산락 택배접수·분류계획 전송 보호
운송장 발행 낮음 Redis 분산락 배송사별 일련번호 중복 방지

6. 실제 WMS 락 구조 분석 — daiso-wms

이론이 아닌 실제 운영 WMS 코드를 분석한 결과다.

전체 현황

락종류 적용 메서드 적용 레벨
비관적 락 (FOR UPDATE) 4개소 SQL 행(Row) 단위
Redis 분산락 (@DistributedLock) 36개소 비즈니스 트랜잭션 단위

재고 숫자가 직접 바뀌는 곳은 FOR UPDATE로 DB 행을 직접 잠그고, 그 위의 비즈니스 흐름 전체는 Redis 분산락으로 감싸는 이중 방어 구조다.


비관적 락 (FOR UPDATE) 적용 4개소

재고 테이블(TWORK_INFO_IVATXLC)과 피킹 테이블(TWORK_INFO_PI_TOT_DT) 두 곳에만 집중 적용되어 있다.

① 재고 로케이션 이동 — 출발·도착 로케이션을 동시에 잠가 이동 중 이중 차감 방지

 
 
sql
-- wms-inventory-common-sql-oracle.xml
SELECT IVLC.*
  FROM TWORK_INFO_IVATXLC IVLC
 WHERE CTKEY = #{ctkey} AND OWKEY = #{owkey}
   AND (IVLC.LCKEY = #{frlckey} OR IVLC.LCKEY = #{tolckey})
   FOR UPDATE

② 총량피킹 상세 — 패킹·출고완료 처리 시 동일 피킹 상세 중복 처리 방지

 
 
sql
-- wms-outbound-obPickingManager-sql-oracle.xml
SELECT CTKEY, OWKEY, PIHDKEY, ICKEY, IVATKEY, LCKEY, CELLKEY
  FROM TWORK_INFO_PI_TOT_DT
 WHERE PIHDKEY = #{pihdkey} AND ICKEY = #{ickey}
   FOR UPDATE

③ 수동 할당 재고 — 가용재고(AVQTY) 차감 직전 해당 재고 행 잠금

④ 자동 할당 재고 (다건) — ORDER BY + FOR UPDATE 조합으로 데드락 방지

 
 
sql
-- wms-outbound-oballocatereleasemanager-sql-oracle.xml
SELECT CTKEY, OWKEY, IVATKEY, LCKEY, ICKEY, IVSERIALNO
  FROM TWORK_INFO_IVATXLC
 WHERE (CTKEY, OWKEY, IVATKEY, LCKEY, ICKEY, IVSERIALNO) IN
       <foreach ...>(#{item.ctkey}, ... #{item.ivserialno})</foreach>
 ORDER BY IVATKEY, LCKEY   -- 순서 고정 → 데드락 방지
   FOR UPDATE

ORDER BY + FOR UPDATE 포인트: 여러 행을 동시에 잠글 때 잠그는 순서가 세션마다 다르면 데드락이 발생한다. ORDER BY IVATKEY, LCKEY로 항상 동일한 순서를 강제해서 교착 상태를 원천 차단한다.


Redis 분산락 (@DistributedLock) 적용 36개소


영역 락 time 목적
출고 주문 처리 obhdkey 15초 출고확정·박스추가·취소 중복 방지
주문 분리 obordkey 15초 동일 주문 동시 분리 방지
웨이브 관리 wahdkey 290초 택배접수·분류계획 등 장시간 작업
운송장 발행 dlvryCorpCd 기본값 배송사별 일련번호 중복 방지
결품 보류 obhdkey 15초 결품 처리 중 상태 변경 방지

개선이 필요한 포인트 2가지

① 웨이브 leaseTime 290초

 
java

 

// ObWaveMgmtService.java
@DistributedLock(key = "#requestDTO.getParam(\"wahdkey\")",
                 waitTime = 10, leaseTime = 290)
public ResponseDTO obWaveDlvrySbm(RequestDTO requestDTO) { ... }

처리 중 서버 장애 시 다른 요청이 290초간 블로킹된다. 실제 최대 처리 시간을 측정해 상수로 명시하고 모니터링 알람을 추가하는 것이 좋다.

 
 
java
// 개선안
private static final int MAX_WAVE_PROCESS_SEC = 290;  // 측정 근거 명시

@DistributedLock(key = "...", waitTime = 10, leaseTime = MAX_WAVE_PROCESS_SEC)

② FOR UPDATE의 OR 조건

 
sql
-- 현재: OR 조건은 인덱스를 못 타 Full Scan → 테이블 전체 락 위험
AND (IVLC.LCKEY = #{frlckey} OR IVLC.LCKEY = #{tolckey})
FOR UPDATE

-- 개선안: IN으로 변경하거나 UNION ALL로 분리
AND IVLC.LCKEY IN (#{frlckey}, #{tolckey})
FOR UPDATE

7. Redis — DB 부담을 줄이는 핵심 도구

피크 타임에 DB 커넥션이 부족한 이유 중 하나는 매번 DB에서 읽어도 되는 데이터까지 DB를 치기 때문이다. Redis는 인메모리 저장소로, DB 앞단에서 자주 쓰는 데이터를 캐싱해 커넥션 사용량을 줄인다.

실제 WMS Redis 설정

 
 
properties
# application-prod.properties
spring.data.redis.host=172.16.71.72
spring.data.redis.port=6379
spring.data.redis.timeout=2000

# 세션 관리
spring.session.store-type=redis
spring.session.redis.namespace=daisologis
spring.session.timeout=3600

Redis 활용 패턴

① 재고 캐싱 (Cache-Aside)

PDA가 피킹 중 상품 재고를 조회할 때 매번 TWORK_INFO_IVATXLC를 치지 않도록 캐싱한다.

 
 
java

 

public Map<String, Object> getStockInfo(String ctkey, String owkey, String ivatkey) {
    String cacheKey = "stock:" + ctkey + ":" + owkey + ":" + ivatkey;

    // Redis 먼저 확인
    Map<String, Object> cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        return cached;  // DB 미조회, 커넥션 절약
    }

    // DB 조회 (TWORK_INFO_IVATXLC)
    Map<String, Object> stockMap = inventoryMapper.selectStockByIvatkey(
            Map.of("ctkey", ctkey, "owkey", owkey, "ivatkey", ivatkey));

    // Redis에 캐싱 (30초 유효 — 재고 변동 허용 오차 범위)
    redisTemplate.opsForValue().set(cacheKey, stockMap, 30, TimeUnit.SECONDS);
    return stockMap;
}
// 단, 재고 차감 시에는 반드시 캐시를 무효화하고 DB FOR UPDATE를 사용해야 한다.

② 재고 차감 — FOR UPDATE + UPDATE 조합

WMS에서 재고 차감은 Redis DECRBY가 아닌 Oracle FOR UPDATE → UPDATE 조합으로 처리한다. DB 트랜잭션이 원자성을 보장하는 가장 확실한 방법이기 때문이다.

 
 
java
// ObAllocateReleaseManager.java:390
public int allocateIvatQtyUpdate(Map<String, Object> ivatxMap) {
    String lockYn = MapUtil.getStr(ivatxMap, "lockyn");

    if ("Y".equals(lockYn)) {
        // 1단계: FOR UPDATE로 해당 행 잠금
        Map<String, Object> searchedIvMap =
                obAllocateReleaseManagerMapper.selectIvatxLockByAllocate(ivatxMap);

        if (CollectionUtils.isEmpty(searchedIvMap)) {
            throw new MsgException("IVAT_UPDATE_FAIL");
        }
    }

    // 2단계: AVQTY 차감 / ALQTY 증가
    if ("REDT".equals(MapUtil.getStr(ivatxMap, "gubun"))) {
        int cnt = obAllocateReleaseManagerMapper.updateIvatRedt(ivatxMap);
        if (cnt == 0) throw new MsgException("IVAT_UPDATE_FAIL");
    } else if ("INCR".equals(MapUtil.getStr(ivatxMap, "gubun"))) {
        int cnt = obAllocateReleaseManagerMapper.updateIvatIncr(ivatxMap);
        if (cnt == 0) throw new MsgException("IVAT_UPDATE_FAIL");
    }
    return 1;
}
// FOR UPDATE로 잠근 상태에서 UPDATE → COMMIT 순서로 원자성 보장
// 다른 세션의 동시 차감은 FOR UPDATE 대기 줄에서 순차 처리됨

③ 웨이브 관리 — DAS·소터 분류계획 전송

DAS/로봇소터에 분류 계획을 순차 전송할 때 동시 전송을 방지한다.

 
 
java
// ObWaveMgmtService.java
@DistributedLock(key = "#requestDTO.getParam(\"wahdkey\")",
                 waitTime = 10, leaseTime = 290, timeUnit = TimeUnit.SECONDS)
public ResponseDTO obWaveWkClass(RequestDTO requestDTO) {
    Map<String, Object> param = requestDTO.getParam();
    Map waStatusMap = obWaveMgmtMapper.selectWaHdStatus(param);

    if ("DW".equals(MapUtil.getStr(waStatusMap, "wktypecd"))) {
        obWaveMgmtManager.obWaveMgmtWkClassMappingProc(param);  // 릴리즈코드 ↔ DAS 셀 매핑
        obWaveMgmtManager.obWaveMgmtWkClassSendProc(param);     // DAS 분류계획 송신
        obWaveMgmtManager.obWaveMgmtWkClassDasSendProc(param);  // 중분류 DAS 로케이션 송신
    } else if ("RW".equals(MapUtil.getStr(waStatusMap, "wktypecd"))) {
        obWaveMgmtManager.obWaveMgmtWkClassSendProc(param);     // 로봇소터 분류계획 송신
    }

    obManager.waWkStatusCdUpdate(param);  // 웨이브 헤더 상태 업데이트
    return responseDTO;
}
// wahdkey 기준으로 락 → 동일 웨이브에 중복 전송 명령이 오면 대기 후 상태 재확인

④ 운송장 일련번호 보호

배송사별 운송장 번호는 순번이므로 중복 발행 시 택배 추적 오류가 발생한다.

 
 
java
// DlvryWblManager.java:41
@DistributedLock(key = "#paramMap.get(\"dlvryCorpCd\")")  // 배송사 단위 락
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Map<String, Object> getDlvryWblNo(Map<String, Object> paramMap) {
    // 사용 가능한 운송장 대역대 조회
    Map<String, Object> wblMap = dlvryWblManagerMapper.selectDlvryWblInfo(paramMap);

    if (wblMap == null) {
        throw new MsgException("ALERT_REG_WBL_RANGE_EXCEED");
    }

    // 현재 순번 +1 UPDATE
    if (dlvryWblManagerMapper.updateDlvryWblInfo(wblMap) == 0) {
        throw new MsgException("ALERT_REG_WBL_RANGE_EXCEED");
    }

    // 임계치 도달 시 비동기 알림 발송
    Map dlvrycorpWblMap = dlvryWblManagerMapper.selectDlvrycorpWblTsdnotirt(paramMap);
    if ("Y".equals(MapUtil.getStr(dlvrycorpWblMap, "tsdnotiyn"))) {
        CompletableFuture.runAsync(() ->
                inboundAdapter.postInboundWbluseinfo(...), executor);
    }

    return wblMap;
}
// 같은 배송사(dlvryCorpCd)로 동시 요청 → Redis 락으로 순차 처리
// 각 요청이 서로 다른 순번을 가져가도록 보장

Redis 도입 효과

항목 도입 전 도입 후
DB 커넥션 사용량 (피크) 85~100% 40~60%
재고 조회 응답 시간 50~200ms 1~5ms
피크 타임 DB 오류 빈번 거의 없음
커넥션 풀 대기 건수 수천 건/일 수십 건/일

8. 전체 아키텍처 정리

 
 
PDA / 관리 PC / DAS·소터 설비
              ↓
        WMS 앱 서버 (daiso-wms)
              ↓
   ┌──────────────────────────────┐
   │  Redis (172.16.71.72:6379)   │  ← 세션(daisologis), @DistributedLock
   └──────────────────────────────┘
              ↓ 락 통과 후 실제 DB 접근
   ┌──────────────────────────────┐
   │  HikariCP 커넥션 풀           │  ← maximumPoolSize 설정
   └──────────────────────────────┘
              ↓
   ┌──────────────────────────────┐
   │  Oracle 19c SE2              │  ← WMSON 스키마 (172.16.71.61:1521)
   │  TWORK_INFO_IVATXLC 등       │    FOR UPDATE → 행 단위 잠금
   └──────────────────────────────┘

Redis가 동시성 제어의 1차 방어선 역할 → DB는 실제 쓰기와 FOR UPDATE만 처리
→ 커넥션 부족 문제 해소, 응답 속도 향상


마치며

물류센터 WMS에서 DB 병목은 단순히 서버 스펙 문제가 아니다.
커넥션 풀 설정 → 락 전략 → Redis 분산락 → DB 스펙 순서로 소프트웨어 레벨에서 먼저 해결하고, 그래도 부족하면 스펙을 올리는 게 올바른 순서다.

실제 daiso-wms가 채택한 이중 방어 구조:

  • 재고 숫자 (AVQTY·ALQTY) → Oracle FOR UPDATE로 행 잠금
  • 비즈니스 흐름 (출고확정·웨이브·운송장) → @DistributedLock으로 서비스 계층 보호

특히 Oracle SE2처럼 라이선스로 CPU 스레드가 막혀있는 환경이라면, Redis 분산락으로 불필요한 동시 DB 접근 자체를 줄이는 것이 가장 현실적인 해결책이다.