CAP for Java

isolation 기본값 vs 직접 설정 — 데이터 꼬임 #shorts #SAP #CAPforJava

▶ YouTube에서 보기

📖 개요 — 이 글에서 다루는 것

CAP Java 프로젝트에서 트랜잭션은 대부분 프레임워크가 알아서 열고 닫아 주기 때문에, 격리 수준(Isolation Level)을 의식하지 않고 개발하는 경우가 많습니다. 그러다 동시 요청이 몰리는 재고 차감, 채번, 잔액 갱신 로직에서 갱신 유실(Lost Update)이나 팬텀 리드가 터지고 나서야 찾아보게 됩니다. 이 글은 CAP Java 3.x(Spring Boot 3 기반) 환경에서 격리 수준을 설정하는 세 가지 층위와, 실무에서 자주 놓치는 설정 포인트의 원인을 예제로 풀어냅니다.

  • CAP Java의 ChangeSet과 Spring 트랜잭션이 연결되는 구조 이해
  • 커넥션 풀(HikariCP) 레벨의 기본 격리 수준 설정
  • 특정 로직에만 다른 격리 수준을 적용하는 방법과 @Transactional이 무시되는 이유
  • 비관적 잠금(lock())·낙관적 잠금(ETag)과 격리 수준의 실무적 조합

📚 미리 알아두면 좋은 배경

CAP Java 프로젝트를 한 번이라도 빌드해 본 경험(CDS 모델 정의, 이벤트 핸들러 작성)과 Spring Boot의 @Transactional 기본 동작을 알고 있으면 충분합니다. ANSI SQL 표준의 4가지 격리 수준(READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLE) 개념을 어렴풋이라도 기억하고 있다면 핵심 개념 섹션이 훨씬 빠르게 읽힙니다.

🔧 환경 / 버전 / 준비물

  • CAP Java SDK: 3.x 계열 (2.x에서도 개념은 동일하나 패키지 구성이 일부 다름)
  • Spring Boot: 3.x, Java 17 이상
  • @sap/cds-dk: 8.x 이상 (CDS 컴파일용)
  • DB: 로컬 개발은 H2 2.x, 운영은 SAP HANA Cloud — 두 DB의 격리 수준 동작 차이가 이 글의 핵심 트러블슈팅 포인트 중 하나입니다
  • 커넥션 풀: HikariCP (Spring Boot 기본)

프로젝트는 mvn archetype:generate -DarchetypeArtifactId=cds-services-archetype으로 생성한 표준 구조를 가정합니다. 예제 도메인은 부품 물류 창고의 재고 예약 서비스를 새로 만들어 사용합니다.

💡 핵심 개념 — ChangeSet, 그리고 격리 수준이 결정되는 3개의 층

CAP Java에서 하나의 OData 요청은 ChangeSet이라는 단위로 감싸입니다. ChangeSet은 "이 안에서 일어난 DB 변경은 전부 성공하거나 전부 롤백된다"는 논리적 괄호이며, 내부적으로 Spring의 트랜잭션 매니저와 연결되어 실제 DB 트랜잭션이 됩니다. 비유하자면 ChangeSet은 회의실 예약, 격리 수준은 방음 등급입니다. 방음 등급은 예약이 확정되기 전에 정해야 하며, 회의가 이미 시작된 뒤 요청하면 반영되지 않습니다 — 이것이 뒤에서 다룰 @Transactional(isolation=...)이 무시되는 현상의 본질입니다.

격리 수준이 결정되는 층은 세 곳입니다.

층위설정 위치적용 범위
1. DB 기본값HANA Cloud 세션 기본값 (일반적으로 READ COMMITTED)아무 설정도 없을 때
2. 커넥션 풀spring.datasource.hikari.transaction-isolation풀에서 나가는 모든 커넥션
3. 트랜잭션 단위@Transactional 또는 TransactionTemplate + 새 트랜잭션해당 로직 블록만

실무에서 자주 놓치는 원인은 두 가지입니다. 첫째, 3번 층은 트랜잭션이 새로 시작될 때만 유효한데 CAP 핸들러는 이미 ChangeSet(=활성 트랜잭션) 안에서 호출되므로, 전파 속성을 바꾸지 않으면 격리 수준 지정이 조용히 무시됩니다. 둘째, HANA는 MVCC 기반이라 READ UNCOMMITTED를 지원하지 않고 읽기가 쓰기를 막지 않는 스냅샷 방식이라, 다른 RDB에서의 락 경험이 통하지 않습니다. 로컬 H2는 동작이 달라 "로컬에선 됐는데 운영에선 다르다"는 상황도 생깁니다.

💻 실전 코드 3단계

1단계 — 도메인 모델과 풀 레벨 기본 격리 수준

먼저 재고 예약 도메인을 CDS로 정의합니다.

namespace logi.wms;
using { cuid, managed } from '@sap/cds/common';

entity MaterialStocks : cuid, managed {
  materialCode : String(18);
  plantCode    : String(4);
  onHandQty    : Decimal(15,3);   // 실물 재고
  reservedQty  : Decimal(15,3);   // 예약 수량
}

entity PickOrders : cuid, managed {
  materialCode : String(18);
  requestedQty : Decimal(15,3);
  status       : String(10) default 'NEW';
}
using { logi.wms as wms } from '../db/schema';

service StockReservationService {
  entity Stocks as projection on wms.MaterialStocks;
  entity Orders as projection on wms.PickOrders;
  action reserveStock(materialCode : String, qty : Decimal) returns String;
}

애플리케이션 전체의 기본 격리 수준은 HikariCP에서 지정하는 것이 일반적으로 권장됩니다. TRANSACTION_ 접두어를 포함한 JDBC 상수 이름을 정확히 써야 한다는 점에 주의하세요.

# srv/src/main/resources/application.yaml
spring:
  datasource:
    hikari:
      transaction-isolation: TRANSACTION_READ_COMMITTED
      maximum-pool-size: 10

2단계 — 실무 시나리오: 재고 차감에 REPEATABLE_READ 적용 (에러 처리·로깅 포함)

재고 조회 후 차감 사이에 다른 트랜잭션이 끼어들면 갱신 유실이 발생합니다. 특정 액션만 격리 수준을 올리려면 새 트랜잭션을 시작해야 하며, CAP 핸들러 안이라면 Propagation.REQUIRES_NEW가 핵심입니다.

@Component
@ServiceName("StockReservationService")
public class ReserveStockHandler implements EventHandler {

  private static final Logger log =
      LoggerFactory.getLogger(ReserveStockHandler.class);

  private final PersistenceService db;
  private final StockTxService stockTx;   // 아래 별도 빈

  public ReserveStockHandler(PersistenceService db, StockTxService stockTx) {
    this.db = db;
    this.stockTx = stockTx;
  }

  @On(event = "reserveStock")
  public void onReserve(ReserveStockContext ctx) {
    try {
      String slipId = stockTx.reserveWithIsolation(
          ctx.getMaterialCode(), ctx.getQty());
      ctx.setResult(slipId);
      ctx.setCompleted();
    } catch (ServiceException e) {
      log.warn("재고 예약 거절: material={}, reason={}",
          ctx.getMaterialCode(), e.getMessage());
      throw e;   // 4xx로 전파
    }
  }
}
@Service
public class StockTxService {

  private final PersistenceService db;

  public StockTxService(PersistenceService db) { this.db = db; }

  // 핵심: REQUIRES_NEW 없이 isolation만 지정하면 "조용히" 무시된다
  @Transactional(propagation = Propagation.REQUIRES_NEW,
                 isolation   = Isolation.REPEATABLE_READ,
                 timeout     = 10)
  public String reserveWithIsolation(String materialCode, BigDecimal qty) {
    Row stock = db.run(
        Select.from("logi.wms.MaterialStocks")
              .where(s -> s.get("materialCode").eq(materialCode)))
        .first()
        .orElseThrow(() -> new ServiceException(
            ErrorStatuses.NOT_FOUND, "자재 {0} 재고가 없습니다", materialCode));

    BigDecimal available = ((BigDecimal) stock.get("onHandQty"))
        .subtract((BigDecimal) stock.get("reservedQty"));
    if (available.compareTo(qty) < 0) {
      throw new ServiceException(
          ErrorStatuses.CONFLICT, "가용 재고 부족: {0}", available);
    }

    db.run(Update.entity("logi.wms.MaterialStocks")
        .where(s -> s.get("materialCode").eq(materialCode))
        .data("reservedQty",
              ((BigDecimal) stock.get("reservedQty")).add(qty)));
    return UUID.randomUUID().toString();
  }
}

주의: @Transactional은 프록시 기반이라 같은 클래스 내부 자기 호출에는 적용되지 않습니다. 위처럼 별도 빈으로 분리해야 안전합니다. 또한 REQUIRES_NEW 트랜잭션은 바깥 ChangeSet과 독립적으로 커밋되므로, 바깥 요청이 나중에 롤백돼도 이 블록의 커밋은 남는다는 점을 설계에 반영해야 합니다.

3단계 — 프로덕션: 비관적 잠금 + 재시도 + 테스트

HANA는 MVCC라서 REPEATABLE_READ에서도 두 트랜잭션이 같은 스냅샷을 읽고 각자 갱신을 시도할 수 있습니다. 갱신 유실을 막으려면 격리 수준과 별개로 행 잠금(SELECT ... FOR UPDATE)을 병행하는 것이 일반적입니다. CAP CQL에서는 lock()으로 표현합니다.

@Service
public class StockLockService {

  private final PersistenceService db;
  private final TransactionTemplate txTemplate;

  public StockLockService(PersistenceService db,
                          PlatformTransactionManager tm) {
    this.db = db;
    this.txTemplate = new TransactionTemplate(tm);
    txTemplate.setPropagationBehavior(
        TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    txTemplate.setIsolationLevel(
        TransactionDefinition.ISOLATION_READ_COMMITTED); // 락이 있으니 과도하게 올리지 않음
  }

  public void reserveSafely(String materialCode, BigDecimal qty) {
    int attempts = 0;
    while (true) {
      try {
        txTemplate.executeWithoutResult(status -> {
          // FOR UPDATE WAIT 5 — 최대 5초 대기 후 락 타임아웃
          Row stock = db.run(
              Select.from("logi.wms.MaterialStocks")
                    .where(s -> s.get("materialCode").eq(materialCode))
                    .lock(5))
              .single();
          // ... 검증 후 Update (2단계와 동일 로직)
        });
        return;
      } catch (CdsLockTimeoutException e) {
        if (++attempts >= 3) throw new ServiceException(
            ErrorStatuses.CONFLICT, "재고 잠금 경합이 지속됩니다");
        // 지수 백오프 후 재시도
        try { Thread.sleep(200L * attempts); }
        catch (InterruptedException ie) {
          Thread.currentThread().interrupt(); throw e;
        }
      }
    }
  }
}

외부 UI 동시 편집에는 격리 수준 대신 낙관적 잠금이 더 적합한 경우가 많습니다. CDS에서 @odata.etag 어노테이션을 modifiedAt에 달면 CAP이 If-Match 기반 충돌 감지를 처리해 줍니다. 동시성 검증은 스레드 풀로 동일 자재를 20회 동시 예약해 "정확히 재고만큼만 성공"하는지 통합 테스트로 확인하는 것이 정석입니다.

성능 관점에서는 SERIALIZABLE 남용을 피하고(HANA에서 경합·롤백 비용이 큼), 잠금 범위를 좁히기 위해 materialCode + plantCode 조회가 인덱스를 타는지 확인하는 것이 좋습니다. 보안 관점에서는 REQUIRES_NEW 블록 안에서도 UserInfo 기반 권한 검증이 유지되는지 테스트에 포함하는 것을 권장합니다.

⚠️ 흔한 실수 / 트러블슈팅 FAQ

Q1. @Transactional(isolation = Isolation.SERIALIZABLE)을 붙였는데 아무 변화가 없습니다.

가장 흔한 케이스입니다. CAP 핸들러는 이미 ChangeSet 트랜잭션 안에서 실행되므로, 기본 전파 속성(REQUIRED)은 기존 트랜잭션에 참여만 하고 격리 수준 지정을 무시합니다. propagation = REQUIRES_NEW로 새 트랜잭션을 열어야 실제로 적용됩니다.

Q2. 로컬 H2에서는 재현되던 동작이 HANA Cloud에서 다르게 나옵니다.

H2와 HANA는 격리 수준 구현 방식이 다릅니다. HANA는 MVCC 스냅샷 방식이라 READ UNCOMMITTED를 지원하지 않고, 읽기가 쓰기를 블로킹하지 않습니다. 격리·잠금이 관건인 로직은 실제 HANA 인스턴스에 붙여 검증하세요.

Q3. transaction-isolation 설정이 반영되지 않습니다.

READ_COMMITTED처럼 접두어 없이 쓰면 HikariCP가 인식하지 못합니다. JDBC 상수명 그대로 TRANSACTION_READ_COMMITTED 형식이어야 하며, 오타 시 기동 로그에 경고가 남습니다.

Q4. lock() 사용 후 간헐적으로 요청이 멈춥니다.

타임아웃 없는 lock()은 무한 대기할 수 있습니다. lock(초)로 대기 시간을 지정하고, 여러 자재를 잠글 때는 동일한 정렬 순서로 잠가 데드락을 예방하세요.

🚀 이어서 살펴보면 좋은 주제

  • 낙관적 동시성 제어: @odata.etag와 If-Match 헤더 기반 충돌 처리 — UI 동시 편집 시나리오의 정석
  • Outbox 패턴: ChangeSet 커밋과 이벤트 발행의 원자성 보장 (CAP Java Persistent Outbox)
  • 멀티테넌시 환경의 커넥션 풀 전략: 테넌트별 DataSource와 격리 수준 상속 관계
  • HANA 락 모니터링: M_BLOCKED_TRANSACTIONS 뷰로 운영 중 경합 추적

댓글 0

아직 댓글이 없습니다.