트랜잭션·데이터 정합성 · Data Integrity

목록 조회 객체를 그대로 저장하면 데이터가 사라지는 이유

목록 응답 객체를 전체 주문처럼 저장하면 화면에 없던 값이 null로 덮일 수 있습니다. 식별자와 변경 의도만 받고 서버에서 현재 상태를 다시 읽는 방법을 살펴봅니다.

JavaSpringData IntegrityRegression TestingLegacy System

목록의 한 행은 전체 데이터가 아닙니다

관리 화면의 목록 API는 보통 화면에 필요한 값만 조회합니다. 주문 번호, 고객명, 상태처럼 표에 보이는 필드만 담으면 응답이 단순해지고 조회 비용도 줄일 수 있습니다. 이런 객체를 조회용 투영 객체(projection)라고 볼 수 있습니다.

문제는 조회용 객체를 수정 API의 저장 대상으로 다시 사용할 때 생깁니다. 화면에 없던 필드는 객체에 들어오지 않기 때문에 null이나 기본값으로 남습니다. 저장 쿼리가 모든 컬럼을 갱신한다면 사용자가 바꾸려 하지 않은 값까지 사라질 수 있습니다.

아래 내용은 이 저장 방식이 왜 위험한지 설명하기 위해 만든 일반 기술 예시입니다. 특정 프로젝트의 장애 기록은 아니며 이름과 코드는 단순화했습니다.

부분 객체가 기존 값을 덮는 과정

목록 화면이 다음 객체를 서버로 보낸다고 가정해 보겠습니다.

public record ReceiptRow(
        Long orderId,
        String status) {
}

ReceiptRow에는 접수 화면에 필요한 두 값만 있습니다. 그런데 이를 전체 주문 객체로 변환해 저장하면 다른 필드는 채울 방법이 없습니다.

Order order = new Order();
order.setId(row.orderId());
order.setStatus(row.status());

orderRepository.update(order);

이때 update가 주문의 모든 컬럼을 갱신한다면 부가 옵션, 배송 방법, 결과 수신 설정처럼 ReceiptRow에 없던 값도 null로 저장될 수 있습니다. 컴파일 오류도 없고 상태 변경도 성공하기 때문에, 상태만 확인하는 테스트에서는 문제를 발견하기 어렵습니다.

목록 행은 화면에 보여 줄 데이터이고, 전체 주문은 현재 상태를 나타내는 데이터입니다. 필드 모양이 비슷해도 서로 대신 사용할 수 없습니다.

숨은 필드를 늘리는 방법은 오래 버티기 어렵습니다

화면에 저장용 숨은 필드를 추가해 전체 값을 다시 보내는 방법도 떠올릴 수 있습니다. 당장은 기존 저장 메서드를 그대로 사용할 수 있지만 다음 문제가 남습니다.

  • 새 저장 필드가 생길 때마다 화면과 API를 함께 수정해야 합니다.
  • 오래 열린 화면의 값이 서버의 최신 상태보다 뒤처질 수 있습니다.
  • 사용자가 변경하면 안 되는 값까지 클라이언트 요청에 포함됩니다.
  • 목록 조회 형식과 저장 규칙이 강하게 결합됩니다.

클라이언트가 현재 상태 전체를 되돌려주는 방식보다, 무엇을 바꾸려는지만 전달하는 편이 입력의 의미가 분명합니다.

식별자와 변경 의도만 명령으로 받습니다

접수 작업이라면 명령 객체에는 주문 식별자와 접수에 필요한 값만 담을 수 있습니다.

public record CompleteReceiptCommand(
        Long orderId,
        String requestedBy) {
}

서비스는 식별자로 현재 주문을 다시 읽고, 접수 규칙이 허용하는 필드만 변경합니다.

@Transactional
public void completeReceipt(CompleteReceiptCommand command) {
    Order current = orderRepository.findById(command.orderId())
        .orElseThrow(OrderNotFoundException::new);

    current.validateReceiptAvailable();
    current.completeReceipt(command.requestedBy());
}

이 방식에서는 DB에서 읽은 현재 주문이 저장의 기준이 됩니다. 명령에 포함되지 않은 값은 기존 상태로 남고, 어떤 필드가 접수 과정에서 바뀌는지도 도메인 메서드에 드러납니다.

다만 서버에서 다시 조회한다고 동시성 문제가 자동으로 해결되지는 않습니다. 조회한 뒤 다른 요청이 같은 주문을 바꿀 수 있다면 버전 컬럼을 이용한 낙관적 잠금이나 필요한 범위의 행 잠금을 함께 검토해야 합니다.

모든 컬럼을 다시 읽어야 하는 것은 아닙니다

변경 범위가 명확하면 전체 엔티티를 조회하는 대신 조건이 포함된 전용 업데이트도 사용할 수 있습니다.

UPDATE orders
   SET receipt_status = :next_status,
       received_by = :requested_by,
       received_at = CURRENT_TIMESTAMP
 WHERE order_id = :order_id
   AND receipt_status = :expected_status;

이 방식은 갱신할 컬럼과 허용되는 이전 상태를 SQL에 명시합니다. 영향 행 수가 0이면 주문이 없거나 상태가 이미 바뀐 경우로 구분할 수 있습니다. 반면 변경 전에 여러 업무 규칙을 검사해야 한다면 현재 상태를 읽고 도메인 로직을 거치는 편이 이해하기 쉬울 수 있습니다.

선택 기준은 단순합니다. 입력 객체가 표현하는 변경 의도와 저장 메서드가 기대하는 데이터 범위가 일치해야 합니다.

상태 변경과 데이터 보존을 함께 검증합니다

회귀 테스트가 상태 전이만 확인하면 관련 없는 필드의 손실을 놓칠 수 있습니다. 다음 불변 조건을 함께 검사하는 편이 좋습니다.

@Test
void receipt_completion_preserves_unrelated_order_values() {
    Order before = savedOrderWithOptions();
    String deliveryMethod = before.getDeliveryMethod();
    String resultRecipient = before.getResultRecipient();
    Set<ServiceOption> serviceOptions = Set.copyOf(before.getServiceOptions());

    receiptService.completeReceipt(
        new CompleteReceiptCommand(before.getId(), "operator")
    );

    Order after = orderRepository.findById(before.getId()).orElseThrow();
    assertThat(after.getReceiptStatus()).isEqualTo(COMPLETED);
    assertThat(after.getDeliveryMethod()).isEqualTo(deliveryMethod);
    assertThat(after.getResultRecipient()).isEqualTo(resultRecipient);
    assertThat(after.getServiceOptions()).containsExactlyInAnyOrderElementsOf(serviceOptions);
}

일괄 처리라면 서로 다른 조건의 주문을 섞고, 한 주문의 실패가 다른 주문에 어떤 영향을 주는지도 정해야 합니다. 전체 롤백인지 건별 처리인지에 따라 트랜잭션 범위와 사용자에게 보여 줄 결과가 달라집니다.

저장 객체의 의미를 먼저 확인합니다

레거시 저장 메서드를 재사용할 때는 타입 이름보다 숨은 전제를 먼저 봐야 합니다. 이 메서드가 전체 상태를 기대하는지, 일부 필드만 갱신하는지, 요청에 없는 값을 어떻게 처리하는지 확인해야 합니다.

목록 행은 읽기 모델이고 접수 요청은 명령입니다. 두 역할을 분리하면 클라이언트가 보내지 않은 값을 실수로 덮는 위험을 줄이고, 변경 가능한 필드와 검증 조건도 코드에서 더 분명하게 표현할 수 있습니다.