물류센터 DB 커넥션 풀 완전 정복 — DB부터 Redis까지
WMS(창고관리시스템)를 운영하다 보면 피크 타임마다 DB가 버벅이는 경험을 하게 된다.
원인의 대부분은 커넥션 풀 설정 미스와 동시성 처리 부재다.
물류센터 현장 환경에 맞게 커넥션 풀부터 Redis 활용까지, 실제 운영 WMS 코드를 기반으로 정리한다.
1. 물류센터 동시 접속자 구성
물류센터는 일반 사무 환경과 다르다.
PDA, 자동화 설비, 관리 PC가 동시에 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분) |
# 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 행을 직접 잠근다.
-- 실제 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 어노테이션 방식이라 락 관리와 비즈니스 로직이 깔끔하게 분리된다.
// 실제 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) 두 곳에만 집중 적용되어 있다.
① 재고 로케이션 이동 — 출발·도착 로케이션을 동시에 잠가 이동 중 이중 차감 방지
-- 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
② 총량피킹 상세 — 패킹·출고완료 처리 시 동일 피킹 상세 중복 처리 방지
-- 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 조합으로 데드락 방지
-- 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초
// ObWaveMgmtService.java
@DistributedLock(key = "#requestDTO.getParam(\"wahdkey\")",
waitTime = 10, leaseTime = 290)
public ResponseDTO obWaveDlvrySbm(RequestDTO requestDTO) { ... }
처리 중 서버 장애 시 다른 요청이 290초간 블로킹된다. 실제 최대 처리 시간을 측정해 상수로 명시하고 모니터링 알람을 추가하는 것이 좋다.
// 개선안
private static final int MAX_WAVE_PROCESS_SEC = 290; // 측정 근거 명시
@DistributedLock(key = "...", waitTime = 10, leaseTime = MAX_WAVE_PROCESS_SEC)
② FOR UPDATE의 OR 조건
-- 현재: 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 설정
# 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를 치지 않도록 캐싱한다.
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 트랜잭션이 원자성을 보장하는 가장 확실한 방법이기 때문이다.
// 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/로봇소터에 분류 계획을 순차 전송할 때 동시 전송을 방지한다.
// 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 기준으로 락 → 동일 웨이브에 중복 전송 명령이 오면 대기 후 상태 재확인
④ 운송장 일련번호 보호
배송사별 운송장 번호는 순번이므로 중복 발행 시 택배 추적 오류가 발생한다.
// 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 접근 자체를 줄이는 것이 가장 현실적인 해결책이다.
'Project > [DAISO] 안성 온라인 WMS' 카테고리의 다른 글
| [안성 온라인 WMS_트러블슈팅] 주문 처리량 API 비지니스 로직 수정 (0) | 2026.05.19 |
|---|---|
| [안성 온라인 WMS] 동시성 처리 비관적 락 vs 낙관적 락 (0) | 2026.05.19 |
| [안성 온라인 WMS] WBS 및 일정 관리 (0) | 2026.05.19 |
| [안성 온라인 WMS] 구축 배경 및 아키텍쳐 (0) | 2026.05.19 |