← Posts

Unit of Work · Design Pattern · JPA

Unit of Work

저장할 때마다 DB에 쓰면 업무의 반쪽만 남을 수 있다. 변경을 한 단위로 모았다가 한 번에 반영하는 패턴.

목차
  1. 여러 Repository에 나눠 저장하면 반쪽이 남는다
  2. Repository마다 저장하면 경계가 없다
  3. 변경을 모았다가 한 번에 반영한다
  4. 무엇이 바뀌었는지 어떻게 아나
  5. 모아두면 쓰기 횟수도 준다
  6. 경계는 업무 하나다
  7. @Transactional 한 줄이 이 일을 한다
  8. 자기 호출은 경계를 못 긋는다
  9. 경계를 넓히면 락을 오래 쥔다
  10. 정리

여러 Repository에 나눠 저장하면 반쪽이 남는다

주문을 하나 만드는 업무를 보자. 재고를 줄이고, 주문을 저장하고, 포인트를 적립한다. 각각을 맡은 Repository가 따로 있다.

java
public Long create(Long userId, Long productId, int quantity) {
    Product product = products.findById(productId);
    product.decreaseStock(quantity);
    products.save(product);                        // ① 재고를 줄여 저장

    Order order = new Order(userId, productId, quantity);
    orders.save(order);                            // ② 주문을 저장

    User user = users.findById(userId);
    user.addPoint(order.getAmount() / 100);
    users.save(user);                              // ③ 포인트를 적립

    return order.getId();
}

세 줄 다 성공하면 문제가 없다. 문제는 ②에서 예외가 났을 때다. ①은 이미 DB에 들어갔다. 재고는 줄었는데 주문은 없다. 팔지도 않은 물건이 사라진 상태로 남는다.

③에서 실패해도 마찬가지다. 재고도 줄고 주문도 생겼는데 포인트만 안 붙는다. 사용자가 항의하면 로그를 뒤져 손으로 맞춰줘야 한다.

“성공한 데까지는 저장된다”는 게 왜 문제인가. 셋은 각각이 업무가 아니라, 셋을 합쳐야 “주문 생성”이라는 업무 하나이기 때문이다.

Repository마다 저장하면 경계가 없다

원인은 save를 부를 때마다 DB에 곧장 쓰고 있다는 것이다.

diagramdiagram

save가 자기 쓰기를 끝내버리니, 어디까지가 한 묶음인지 아무도 모른다. Repository는 자기 대상만 알아서 “주문 생성이 끝났는지”를 판단할 수 없다. 중간에 실패해도 앞의 것을 되돌릴 방법이 없다.

필요한 건 저장 하나하나가 아니라, 이 세 변경을 하나로 묶는 무언가다.

변경을 모았다가 한 번에 반영한다

Unit of Work는 업무 하나가 진행되는 동안 일어난 변경을 모아뒀다가, 끝날 때 한 번에 반영하는 패턴이다. 이름 그대로 “작업의 한 단위”를 객체로 만든 것이다.

바뀌는 건 쓰는 시점이다.

diagramdiagram

save는 “이건 바뀌었다”고 등록만 한다. 실제 쓰기는 업무가 끝나는 시점에 몰아서 한다. 그리고 그 한 번을 하나의 트랜잭션으로 감싼다.

java
public interface UnitOfWork {
    void registerNew(Object entity);       // 새로 생긴 것
    void registerDirty(Object entity);     // 바뀐 것
    void registerDeleted(Object entity);   // 지울 것
    void commit();                         // 모아둔 것을 한 번에 반영
    void rollback();                       // 없던 일로
}

중간에 실패하면 commit()에 도달하지 못한다. 모아둔 변경은 나가지 않고, 사정상 이미 나간 쓰기가 있더라도 rollback()이 트랜잭션을 되돌린다. 재고만 줄어드는 일이 생길 수 없다. 앞의 문제가 여기서 사라진다.

참고

이게 DB 트랜잭션과 무엇이 다른가. 트랜잭션은 DB가 제공하는 “다 되거나 다 안 되거나”라는 보장이고, Unit of Work는 애플리케이션 쪽에서 무엇을 그 한 묶음에 넣을지 관리하는 객체다. “다 되거나 다 안 되거나”를 실제로 지켜주는 건 트랜잭션이고, 이 패턴은 그 묶음에 무엇이 들어갈지를 정한다. 둘은 그래서 겹쳐서 쓴다. 트랜잭션 자체는 트랜잭션과 ACID에 있다.

참고

이 인터페이스는 뼈대만 추린 것이다. 패턴을 정리한 마틴 파울러의 원전에는 바뀌지 않은 객체를 등록하는 registerClean도 있고, 여러 사용자가 같은 데이터를 동시에 고칠 때의 충돌 조정도 이 패턴의 책임으로 들어간다. 여기서는 “모았다 한 번에 반영한다”는 뼈대만 본다.

무엇이 바뀌었는지 어떻게 아나

모아뒀다 쓰려면 무엇이 바뀌었는지를 알아야 한다. 방법이 둘이다.

하나는 직접 알려주는 것이다. 위 인터페이스처럼 바꾼 쪽이 registerDirty(order)를 부른다. 단순하지만 부르는 걸 빠뜨리면 그 변경은 조용히 사라진다.

다른 하나는 알아서 알아내는 것이다. 객체를 읽어올 때 원래 값을 복사해 두고, 커밋 시점에 지금 값과 비교한다. 다르면 바뀐 것이다.

diagramdiagram

이 방식이면 부르는 쪽은 값만 바꾸면 된다.

java
Order order = orders.findById(id);
order.cancel();          // save()를 안 불렀다
// 업무가 끝날 때 바뀐 게 감지되어 UPDATE가 나간다

save가 안 보이니 처음엔 이상하게 느껴지는데, 관점을 바꾸면 자연스럽다. **“컬렉션에서 꺼낸 객체를 고쳤다”**는 것이고, 컬렉션의 원소를 고칠 때 다시 넣지는 않는다. Repository를 컬렉션처럼 보는 관점과 정확히 이어진다.

대신 대가가 있다. 원본 사본을 들고 있어야 하니 메모리를 쓰고, 읽어온 객체가 많을수록 비교 비용도 는다.

모아두면 쓰기 횟수도 준다

원자성이 목적이었는데, 모아두는 데서 덤이 하나 따라온다.

같은 주문을 세 번 고치는 코드를 보자.

java
order.setStatus(PAID);
order.setPaidAt(now);
order.setCouponId(couponId);

save가 매번 DB에 쓴다면 UPDATE가 세 번 나간다. 모아뒀다 쓰면 커밋 시점에 한 번이다. 어차피 최종 상태만 저장하면 되기 때문이다.

같은 이유로 INSERT도 묶을 수 있다. 주문 항목 100개를 저장할 때, 하나씩 보내는 대신 모아서 한 번에 보내는 것이다.

properties
# Hibernate에서 쓰기를 모아 보내는 설정
spring.jpa.properties.hibernate.jdbc.batch_size=50

쓰기 자체가 줄어드는 건 아니지만 네트워크를 오가는 횟수가 준다.

주의

이 설정을 켜도 안 묶이는 경우가 있다. 대표적인 게 기본키를 GenerationType.IDENTITY로 받는 엔티티다. 새 행의 키를 DB가 정해주니 INSERT를 그때그때 보내 키를 받아와야 하고, 그래서 모아 보내기가 꺼진다. 저장할 때마다 왕복이 필요하면 이 덤은 사라진다. 설정을 넣었는데 쿼리 수가 그대로면 키 생성 전략부터 보면 된다.

모아 보내기는 공짜가 아니다. 커밋 전까지 변경분이 메모리에 쌓이므로, 한 업무에서 수만 건을 고치면 메모리가 부담이 된다. 대량 처리는 일정 건수마다 끊어 반영하는 편이 낫다.

경계는 업무 하나다

그러면 이 단위는 어디서 시작해서 어디서 끝나나. 업무 하나가 곧 한 단위다.

그래서 경계를 긋는 자리가 서비스 계층이다. 한 업무가 무엇인지 아는 층이 거기뿐이기 때문이다.

diagramdiagram

Repository에 경계를 두면 안 되는 이유가 여기서 분명해진다. Repository는 “주문 하나 저장”까지만 아는데, 그 단위로 커밋하면 처음 문제로 돌아간다. 경계를 아는 층이 경계를 긋는다.

@Transactional 한 줄이 이 일을 한다

실무에서 이 패턴을 손으로 구현하는 일은 드물다. 이미 프레임워크가 하고 있기 때문이다.

Spring과 JPA를 쓰고 있다면, 앞에서 본 것이 전부 이 한 줄 뒤에 있다.

java
@Service
public class OrderService {

    @Transactional
    public Long create(Long userId, Long productId, int quantity) {
        Product product = products.findById(productId);
        product.decreaseStock(quantity);        // save()가 없다

        Order order = new Order(userId, productId, quantity);
        orders.save(order);

        User user = users.findById(userId);
        user.addPoint(order.getAmount() / 100); // 여기도 save()가 없다

        return order.getId();
    }
}

@Transactional이 붙은 메서드가 시작할 때 단위가 열리고, 정상적으로 끝나면 그때 모아둔 변경이 나간다. 예외가 나면 아무것도 안 나간다.

JPA가 어떻게 이 일을 하는지는 앞에서 본 그대로다.

이 글에서 본 것JPA에서 부르는 이름
변경을 모아두는 공간영속성 컨텍스트
원본 사본과 비교해 바뀐 것 찾기변경 감지(dirty checking)
모아뒀다 한 번에 내보내기쓰기 지연 후 flush
반영 확정커밋

그래서 JPA를 쓸 때 save()를 부르지 않아도 값이 저장되는 것이다. 마법이 아니라 이 패턴이 그렇게 동작하도록 만들어져 있는 것이다.

참고

save()가 아예 필요 없는 건 아니다. 새로 만든 객체는 단위가 모르는 상태라 등록해줘야 한다(orders.save(order)). 이미 조회해온 객체는 단위가 이미 알고 있어서 값만 바꾸면 된다. 위 코드에서 productusersave가 없는 이유가 이것이다.

자기 호출은 경계를 못 긋는다

이 편리함에는 함정이 하나 붙어 있다. 같은 클래스 안에서 자기 메서드를 부르면 @Transactional이 안 걸린다.

Spring은 트랜잭션을 프록시로 건다. 대상 객체를 감싼 대리인을 만들어 두고, 그 대리인이 메서드 앞뒤에서 트랜잭션을 열고 닫는 것이다. 그래서 이 함정은 프록시를 거쳐 들어올 때만 경계가 생긴다는 말이기도 하다.

java
@Service
public class OrderService {

    public void createAll(List<OrderRequest> requests) {
        for (OrderRequest r : requests) {
            create(r);          // 같은 클래스의 메서드를 그냥 부른다
        }
    }

    @Transactional
    public void create(OrderRequest r) {
        // 트랜잭션이 열려 있을 거라고 믿는 코드
    }
}

createAll을 통해 들어오면 create@Transactional이 동작하지 않는다. Spring이 트랜잭션을 프록시로 거는데, 내부 호출은 프록시를 거치지 않고 곧장 자기 메서드로 가기 때문이다.

diagramdiagram

에러는 안 난다. 그냥 단위가 안 열린 채로 돌아가고, 실패했을 때 반쪽이 남는다. 처음 문제로 조용히 되돌아간 상태다.

빠져나오는 길은 호출을 프록시로 지나가게 만드는 것이다. 트랜잭션이 필요한 메서드를 다른 빈으로 옮겨 주입받아 부르는 게 가장 흔한 방법이다.

참고

프록시가 트랜잭션을 거는 유일한 방식은 아니다. AspectJ로 클래스 자체에 코드를 짜 넣는 방식(위빙)을 쓰면 자기 호출에도 트랜잭션이 걸린다. 다만 Spring의 기본은 프록시이고, 위빙은 별도 설정과 빌드 도구가 필요해 잘 안 쓴다. 기본값을 쓰고 있다면 위 함정은 그대로 유효하다.

주의

함정이 하나 더 있다. Spring이 기본으로 롤백하는 건 **RuntimeExceptionError**뿐이다. IOException 같은 체크 예외를 던지면 오히려 커밋되어 반영이 그대로 확정된다. 필요하면 @Transactional(rollbackFor = Exception.class)로 명시한다.

경계를 넓히면 락을 오래 쥔다

“묶는 게 좋으면 넓게 묶으면 되지 않나” 싶어진다. 여기에 대가가 있다.

단위가 열려 있는 동안 DB 커넥션 하나가 계속 붙잡혀 있다. 경계가 길어질수록 커넥션을 오래 쥐고, 풀에 남는 커넥션이 줄어 다른 요청이 대기한다.

락도 같은 이야기다. 쓰기를 모아뒀다 보내니 락이 잡히는 건 SQL이 나간 뒤부터지만, 한번 잡힌 락은 커밋할 때까지 안 놓는다. 커밋이 늦어지면 그만큼 다른 트랜잭션이 기다리고, 서로 다른 순서로 잠그면 데드락이 된다. 이 대가는 락과 데드락에서 다뤘다.

그래서 흔히 문제가 되는 코드가 이런 모양이다.

java
@Transactional
public void create(...) {
    orders.save(order);

    mailSender.send(...);          // 외부 API 호출. 3초가 걸릴 수도 있다
    paymentClient.charge(...);     // 응답이 늦으면 커넥션도 그만큼 붙잡힌다
}

메일 서버가 느리면 그 시간만큼 커넥션과 락이 묶여 있는다. 게다가 외부 호출은 롤백되지도 않는다. 메일은 이미 나갔는데 트랜잭션만 되돌아가는 상황이 생긴다.

기준은 단순하다. 한 단위에는 같이 되거나 같이 안 돼야 하는 것만 넣는다. 메일 발송은 주문 생성과 같이 취소될 필요가 없으니 밖으로 뺀다. 커밋이 끝난 뒤에 보내면 된다.

정리

Unit of Work란업무 하나 동안의 변경을 모아뒀다 끝날 때 한 번에 반영하는 패턴
왜 쓰나저장할 때마다 쓰면 중간 실패 시 업무의 반쪽만 남는다
어떻게 아나직접 등록하거나, 원본 사본과 비교해 바뀐 것을 찾는다(변경 감지)
같은 객체를 여러 번 고쳐도 쓰기는 한 번. 모아 보내기로 DB 왕복도 준다
경계업무 하나 = 한 단위. 그래서 서비스 계층에 @Transactional이 붙는다
함정자기 호출은 프록시를 건너뛰어 경계가 안 생긴다 · 체크 예외는 기본 롤백이 아니다
대가경계가 길수록 커넥션과 락을 오래 쥔다. 외부 호출은 단위 밖으로

흩어진 변경을 한 덩어리로 만들어 “다 되거나 다 안 되거나”를 지키는 것, 그게 Unit of Work가 하는 일이다.

관련 글