본문 바로가기
스프링/JPA

히스토리 관리 - 데이터 변경 감지 - 심화

by 공부 안하고 싶은 사람 2026. 3. 3.
반응형

🎯 결론부터

✅ Listener는 엔티티 1개당 1번씩 호출됩니다.
❌ 여러 엔티티를 한 번에 묶어서 호출하지 않습니다.

즉,

  • 엔티티 3개가 변경되면
  • onPostUpdate()3번 호출됩니다

→ 하나로 묶어서 보고 싶다.


🔥 언제 호출되냐?

핵심은 flush 시점입니다.

Hibernate는:

  1. 트랜잭션 중 엔티티 변경
  2. flush 시점에 Dirty Checking 수행
  3. 변경된 엔티티 목록 수집
  4. 각각에 대해 SQL 실행
  5. 각각에 대해 PostUpdateEvent 발생

📌 예시

memo1.setMemo("A");
memo2.setMemo("B");
memo3.setMemo("C");

이 시점에서는 아무 일도 안 일어납니다.

 

flush 발생하면:

Dirty Checking 시작 → memo1 변경 감지 → memo2 변경 감지 → memo3 변경 감지

UPDATE memo1
PostUpdateEvent(memo1)
 
UPDATE memo2
PostUpdateEvent(memo2)
 
UPDATE memo3
PostUpdateEvent(memo3)
 

👉 Listener는 3번 호출됩니다.


🔥 그럼 “한 번에 모아서 처리”는 못 하나?

기본 PostUpdateEventListener는:

❌ 엔티티 단위 호출

하지만 이런 방법은 있습니다:

ThreadLocal에 모아서 commit 시점 처리

리스너에서:

ThreadLocal<List<Change>>

에 모아두고

트랜잭션 완료 시점에 한 번에 저장.

{ "transactionId": "...", "changes": [ A, B, C ] }

 


1️⃣ 성능 문제는 없나?

✅ 기본 전제

  • ThreadLocal은 스레드별 저장소
  • Spring 트랜잭션 = 보통 1 스레드
  • 요청당 하나의 ThreadLocal List

👉 일반적인 웹 환경에서는 동시성 충돌 없음

⚠️ 하지만 주의할 점

① 메모리 증가 가능성

하나의 트랜잭션에서:

  • 엔티티 1000개 변경
  • JSON 1000개 누적

👉 List가 커질 수 있음

하지만 보통 실무 트랜잭션은 수십 개 수준
→ 큰 문제 없음

② 반드시 clear() 해야 함

ThreadLocal은 제거하지 않으면:

  • WAS thread pool 재사용 시
  • 이전 요청 데이터가 남을 수 있음

👉 afterCompletion에서 remove() 필수

③ @Async 사용하면 깨짐

ThreadLocal은 스레드 단위

@Async

사용하면 다른 스레드로 이동
→ AuditContext 공유 안 됨


2️⃣ 동시성 문제는?

❌ 없음 (기본 구조에서는)

이유:

  • ThreadLocal은 스레드별 저장
  • 다른 요청은 다른 스레드

즉:

Thread-1 → TX1 → AuditContext-1 Thread-2 → TX2 → AuditContext-2

완전히 분리됨

 


코드 추가

@Component
public class AuditCollector {
    private static final ThreadLocal<List<Object>> CHANGE_LIST = ThreadLocal.withInitial(ArrayList::new);

    public void add(Object change) {
        CHANGE_LIST.get().add(change);
    }

    public List<Object> getChangeList() {
        return CHANGE_LIST.get();
    }

    public boolean isEmpty() {
        return CHANGE_LIST.get().isEmpty();
    }

    public void clear() {
        CHANGE_LIST.remove();
    }
}
 
Slf4j
@Component
@RequiredArgsConstructor
public class AuditTransactionHandler {
    private final AuditCollector collector;
    private final ObjectMapper objectMapper;

    private static final String SYNC_KEY = "AUDIT_TX_SYNC";

    public void registerIfNeeded() {

        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            return;
        }

        if (TransactionSynchronizationManager.getResource(SYNC_KEY) != null) {
            return; // 이미 등록됨
        }

        TransactionSynchronizationManager.bindResource(SYNC_KEY, true);

        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {

                @Override
                public void afterCommit() {
                    if (collector.isEmpty()) {
                        return;
                    }

                    try {

                        Map<String, Object> txRoot = new LinkedHashMap<>();
                        txRoot.put("changedAt", LocalDateTime.now());
                        txRoot.put("entities", collector.getChangeList());

                        String json = objectMapper.writeValueAsString(txRoot);

                        // 🔥 여기서 DB 저장 or Kafka 발행
                        log.info("AUDIT TX: {}", json);

                    } catch (Exception e) {
                        log.error("Audit commit error", e);
                    }
                }

                @Override
                public void afterCompletion(int status) {
                    collector.clear();
                    TransactionSynchronizationManager.unbindResource(SYNC_KEY);
                }
            }
        );
    }
}
 
@Component
@RequiredArgsConstructor
@Slf4j
public class AuditEventListener implements PostUpdateEventListener {

    ... 중략
  
    // =========================
    // 최종 JSON 생성 (공통)
    // =========================
    private void buildFinalJson(
        String entityName,
        Object entityId,
        Map<String, Map<String, Object>> changes
    ) {

        if (changes == null || changes.isEmpty()) {
            return;
        }

        Map<String, Object> root = new LinkedHashMap<>();
        root.put("entity", entityName);
        root.put("entityId", String.valueOf(entityId));
        root.put("changedAt", LocalDateTime.now());
        root.put("changes", changes);

        auditCollector.add(root); // ThreadLocal에 저장
        auditTransactionHandler.registerIfNeeded(); ; // 트랜잭션 동기화 등록
    }

    ... 중략
}
 
 
결과
{
  "changedAt": "2026-03-03T11:07:29",
  "entities": [
    {
      "entity": "TestEntity",
      "entityId": "142",
      "changedAt": "2026-03-03T11:07:29",
      "changes": {
        "updatedAt": {
          "before": "2026-03-03T11:03:41",
          "after": "2026-03-03T11:07:29"
        },
        "memo": {
          "before": "변경 감지 테스트_final13333333",
          "after": "변경 감지 테스트_final"
        }
      }
    },
    {
      "entity": "TestEntity",
      "entityId": "140",
      "changedAt": "2026-03-03T11:07:29",
      "changes": {
        "updatedAt": {
          "before": "2026-03-03T11:03:41",
          "after": "2026-03-03T11:07:29"
        },
        "memo": {
          "before": "변경 감지 테스트_final13333333_test",
          "after": "변경 감지 테스트_final_test"
        }
      }
    }
  ]
}
 
 
 

주의사항

1.afterCommit()은 @Transactional이 없으면 호출 안됨

왜 afterCommit이 호출 안 되냐?

Spring 내부 구조:

TransactionSynchronizationManager.registerSynchronization(...)

이건:

활성화된 트랜잭션이 있을 때만 동작

@Transactional이 없으면:

TransactionSynchronizationManager.isSynchronizationActive() → false

그래서 등록 자체가 안 됨.

 

2. beforeCommit()보다 afterCommit()을 사용해야

repository.save()

findById -> save -> findById -> save 이런 구조를 사용할 수 있다.

반응형

'스프링 > JPA' 카테고리의 다른 글

히스토리 관리 - 데이터 변경 감지  (0) 2026.02.25
Querydsl  (0) 2021.08.24
Spring-Data-Jpa  (0) 2021.08.06
Envers / spring-data-envers  (0) 2021.08.03
JPA 최적화, Hint  (0) 2021.06.04

댓글