포스트

[업무일지] 엔티티 리스너를 활용한 히스토리 누락 최소화

계좌 같은 값이 바뀔 때 변경 이력을 남기는 코드를, 값을 바꾸는 곳마다 손으로 부르고 있었습니다. 한 경로에서 그 호출이 빠져 이력이 조용히 비어 있길래, 이력 적재를 통째로 JPA 엔티티 리스너로 옮겼습니다. 이제 어느 경로로 저장하든 이력이 자동으로 남습니다. 대신 리스너를 스프링 빈처럼 쓰지 못하는 대가가 따라왔습니다.

셀러의 정산계좌나 쇼핑몰 계좌 정보가 바뀌면 변경 이력을 따로 테이블에 남깁니다. 누가 언제 어떤 값을 어떻게 바꿨는지가, 나중에 “계좌가 왜 이렇게 바뀌었냐”거나 “지급이 엉뚱한 데로 나갔다”는 문의가 들어왔을 때 되짚어볼 수 있는 사실상 유일한 단서라서, 이 기록이 비어 있으면 그 시점에 무슨 일이 있었는지를 나중에 영영 복원하지 못합니다. Java 21, Spring Boot 3.3.x, Spring Data JPA(Hibernate 6), MariaDB 를 쓰는 서버 몇 개에 걸쳐 있는 이야기입니다.

설명을 위해 엔티티·테이블 이름은 가명입니다. 실제 구조와 이름만 다릅니다.

  • settlement_accounts: 정산계좌
  • settlement_account_histories: 정산계좌 변경 이력
  • sellers: 셀러(사용자). 여기에 사업자 상태 플래그가 있습니다

이력을 남기는 코드를 매번 손으로 불렀다

원래는 이력 적재가 서비스 코드에 흩어져 있었습니다. 값을 바꾸고, 저장하고, 그다음 이력 서비스를 부릅니다.

1
2
3
4
// Java 21, Spring Boot 3.3.x
account.changeNumber(newNumber);
accountRepository.save(account);
accountHistoryService.save(account); // 이 줄을 빼먹으면 이력이 안 남는다

문제는 마지막 줄입니다. 계좌를 바꾸는 경로가 한 군데가 아니거든요. 신청할 때, 마이페이지에서 고칠 때, 배치가 정정할 때. 경로마다 저 호출을 잊지 않고 붙여야 하고, 하나라도 빠지면 그 경로로 바뀐 건 이력이 없습니다. 그리고 이력이 없다는 건 없어 보이지 않습니다. 그냥 조용히 없는 겁니다.

실제로 그런 구멍을 한 번 찾았습니다. 어떤 화면에서 바꾼 계좌만 이력이 통째로 비어 있었는데, 그 경로 하나만 accountHistoryService.save 호출이 빠져 있었고 나머지 경로는 다 멀쩡히 부르고 있어서, 평소엔 아무도 그 구멍을 몰랐고 특정 셀러의 이력을 따로 뒤지다가 우연히 걸린 거였습니다. 고치는 건 한 줄 추가였습니다. 다만 이런 종류의 버그는 “다른 데도 또 있나”를 부르는 버그거든요.

리스너로 옮기니 부를 필요가 없어졌다

그래서 이력 적재를 아예 JPA 엔티티 리스너로 옮겼습니다. 엔티티에 @EntityListeners 를 달고, @PostPersist / @PostUpdate 에서 이력을 저장합니다.

1
2
3
4
5
6
7
// Java 21, Spring Boot 3.3.x, Hibernate 6
@Entity
@Table(name = "settlement_accounts")
@EntityListeners(SettlementAccountHistoryListener.class)
public class SettlementAccount extends BaseEntity {
    // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
public class SettlementAccountHistoryListener {

    @PostPersist
    public void postPersist(SettlementAccount account) {
        saveHistory(account);
    }

    @PostUpdate
    public void postUpdate(SettlementAccount account) {
        saveHistory(account);
    }

    private void saveHistory(SettlementAccount account) {
        // ...
    }
}

이제 계좌를 저장하면 이력이 자동으로 따라옵니다. 어느 경로로 바꾸든 상관없습니다. 신청이든 마이페이지든 배치든, 영속성 컨텍스트를 통해 저장되기만 하면 콜백이 발동하거든요. 호출부에서 이력 서비스를 부를지 말지 고민할 필요가 없어진 겁니다. 빼먹을 수가 없으니까요.

핵심은 이력을 남긴다는 규칙이 값을 바꾸는 사람 손이 아니라 엔티티 정의에 붙었다는 점입니다. 나중에 계좌를 바꾸는 경로가 하나 더 생겨도, 그쪽은 아무것도 안 해도 이력이 남습니다. 빠뜨릴 자리 자체가 없어진 거죠.

계좌 이력 서비스를 이렇게 걷어내고, 쇼핑몰 계좌 매핑이며 정산계좌며 지급 이력이며 이력이 필요한 엔티티를 며칠에 걸쳐 하나씩 같은 방식으로 옮겼는데, 옮길 때마다 호출부 여기저기 흩어져 있던 이력 서비스 호출이 함께 지워져서 오히려 코드가 줄었습니다. 지운 줄이 더 많았어요.

리스너는 스프링 빈이 아니라서 컨텍스트를 정적으로 꺼냈다

여기서 대가를 하나 치릅니다. JPA 엔티티 리스너는 기본적으로 스프링이 관리하는 빈이 아닙니다. Hibernate 가 직접 인스턴스를 만들어 콜백을 부르거든요. 그러니 리스너 필드에 @Autowired 를 달아도 런타임엔 그냥 null 입니다. 이력을 저장하려면 리포지토리가 필요한데, 평범하게 주입받는 길이 막힌 겁니다.

그래서 ApplicationContext 를 정적으로 붙잡아두는 유틸을 하나 뒀습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class SpringBeanUtils implements ApplicationContextAware {

    private static ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext context) {
        applicationContext = context;
    }

    public static <T> T getBean(Class<T> beanClass) {
        if (applicationContext == null) return null;
        return applicationContext.getBean(beanClass);
    }
}

리스너 안에서는 이렇게 꺼내 씁니다.

1
2
3
4
5
6
7
8
9
10
11
12
private void saveHistory(SettlementAccount account) {
    try {
        var historyRepository = SpringBeanUtils.getBean(SettlementAccountHistoryRepository.class);
        if (historyRepository == null) {
            log.error("이력 리포지토리를 가져올 수 없습니다.");
            return;
        }
        historyRepository.save(SettlementAccountHistory.of(account));
    } catch (Exception e) {
        log.error("이력 적재 실패. id: {}", account.getId(), e);
    }
}

try/catch 로 감싸고 실패하면 로그만 남깁니다. 의도한 겁니다. 이력 적재가 실패했다고 본체 트랜잭션을 같이 굴려버리면, 계좌 변경이라는 진짜 작업이 이력 때문에 막히니까요. 이력은 부차적인 기록이고, 본체가 우선입니다. 그래서 이력 저장은 조용히 실패하도록 뒀습니다.

솔직히 이 유틸 자체가 냄새나는 코드입니다. 정적 필드에 컨텍스트를 담아두고 아무 데서나 꺼내 쓰는 건 스프링이 하지 말라는 그것이거든요. 더 정석적인 길이 없는 건 아닙니다. Hibernate 의 BeanContainer 를 스프링이 SpringBeanContainer 로 채워주도록 두면(Spring Boot 는 이걸 알아서 등록해줍니다) 리스너를 @Component 로 만들어 리포지토리를 주입받을 수 있거든요. 그걸 알면서도 정적 조회를 골랐습니다. 리스너가 꺼내는 게 리포지토리 하나뿐이라, 컨테이너 설정을 건드리는 것보다 이게 단순하다고 봤습니다. 대신 이 정적 조회가 냄새라는 건 안고 갑니다.

벌크 업데이트나 네이티브 쿼리에는 리스너가 안 붙는다

하나만 알아두면 됩니다. 이 자동 이력은 영속성 컨텍스트를 통해 저장될 때만 붙습니다. @Modifying 벌크 업데이트나 네이티브 쿼리는 그 컨텍스트를 거치지 않고 SQL 로 직행하거든요.

1
2
3
@Modifying(clearAutomatically = true)
@Query("UPDATE Seller s SET s.statusCode = :code WHERE s.id = :id")
void updateBusinessStatus(@Param("id") Long id, @Param("code") String code);

이렇게 나가는 쿼리는 더티 체킹도 없고, 엔티티 인스턴스도 안 만들고, 생명주기 콜백도 안 부릅니다. @PostUpdate 가 안 뜨니 이력도 안 남고요. 그래서 이력을 남겨야 하는 엔티티는 벌크로 갈기지 않고 save() 로 저장합니다.

거꾸로 이력이 필요 없는 값이면 벌크가 낫습니다. 위 예의 사업자 상태 플래그가 그런 경우인데, 배치가 매일 벌크 업데이트로 갱신하지만 그건 지금 상태만 나타내는 값이라 변경 궤적을 따로 남길 필요가 없어서 일부러 그렇게 둔 겁니다. 경계는 하나입니다. 현재 상태만 나타내는 값은 벌크로 빠르게, 변경의 궤적을 남겨야 하는 엔티티는 영속성 컨텍스트를 통해서. 리스너로 이력을 자동화했다고 이 경계까지 사라지는 건 아니라서, 이력이 필요한 엔티티에 벌크 업데이트를 붙일 땐 한 번 멈춰서 생각하면 됩니다.

맺음말

이력을 리스너로 옮긴 건 잘한 선택이었습니다. 호출부에서 이력 서비스를 빼먹던 실수가 사라졌으니까요. 값을 바꾸는 사람이 이력을 신경 쓰지 않아도 되고, 새 경로가 생겨도 이력이 저절로 따라옵니다. 빠뜨릴 자리를 없앤 게 이 작업의 전부입니다.

대가는 둘 남았습니다. 하나는 리스너에서 리포지토리를 정적으로 꺼내 쓰는 겁니다. SpringBeanContainer 로 주입받는 더 정석적인 길을 두고 단순함을 이유로 정적 조회를 골랐는데, 그래도 깔끔한 코드는 아닙니다. 다른 하나는 이력 실패를 삼키는 겁니다. 본체 트랜잭션을 지키려고 그렇게 뒀는데, 그러면 이력이 조용히 빌 때 그걸 알아챌 방법이 로그뿐입니다.

그리고 앞서 적은 벌크 업데이트 경계는 계속 안고 가야 합니다. 자동이라고 해서 모든 쓰기에 붙는 게 아니니까요. 이력이 필요한 엔티티에 누가 성능 때문에 벌크 업데이트나 네이티브 upsert 를 붙이면 그 경로는 이력이 안 남는데, 예외도 안 나고 테스트도 초록불이라 눈에 잘 안 띕니다. 지금은 그걸 코드가 막아주지 못하고 사람이 기억해야 합니다. 빠뜨릴 자리를 없앴다면서 딱 한 군데는 남겨둔 셈이죠.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.