[업무일지] 호출부가 빠뜨려도 남도록 JPA 엔티티 리스너로 옮긴 변경 이력
계좌 같은 값이 바뀔 때 변경 이력을 남기는 코드를, 값을 바꾸는 곳마다 손으로 부르고 있었습니다. 한 경로에서 그 호출이 빠져 이력이 조용히 비어 있길래, 이력 적재를 통째로 JPA 엔티티 리스너로 옮겼습니다. 이제 어느 경로로 저장하든 이력이 자동으로 남습니다. 다만 이력 저장 실패를 catch 로 삼켜도 본체 트랜잭션까지 지켜지지는 않고, 벌크 업데이트에는 리스너가 붙지 않습니다.
셀러의 정산계좌나 쇼핑몰 계좌 정보가 바뀌면 변경 이력을 따로 테이블에 남깁니다. 정보보호 관리체계 인증(ISMS) 심사를 준비하면서 개인정보 변경 이력이 빠짐없이 남는지도 같이 보고 있고요. 누가 언제 어떤 값을 어떻게 바꿨는지가, 나중에 “계좌가 왜 이렇게 바뀌었냐”거나 “지급이 엉뚱한 데로 나갔다”는 문의가 들어왔을 때 되짚어볼 수 있는 사실상 유일한 단서라서, 이 기록이 비어 있으면 그 시점에 무슨 일이 있었는지를 나중에 영영 복원하지 못합니다. Java 17·21, Spring Boot 3.2·3.3, Spring Data JPA(Hibernate 6), MariaDB 를 쓰는 서버 몇 개에 걸쳐 있는 이야기입니다. 서버마다 버전이 조금씩 다릅니다.
설명을 위해 엔티티·테이블 이름은 가명입니다. 실제와 다른 건 이름뿐입니다.
settlement_accounts: 정산계좌settlement_account_histories: 정산계좌 변경 이력sellers: 셀러(사용자). 여기에 사업자 상태 플래그가 있습니다
이력을 남기는 코드를 매번 손으로 불렀다
원래는 이력 적재가 서비스 코드에 흩어져 있었습니다. 값을 바꾸고, 저장하고, 그다음 이력 서비스를 부릅니다.
1
2
3
4
// Spring Boot 3.x
account.changeNumber(newNumber);
accountRepository.save(account);
accountHistoryService.save(account); // 이 줄을 빼먹으면 이력이 안 남는다
문제는 마지막 줄입니다. 계좌를 바꾸는 경로가 한 군데가 아니거든요. 신청할 때, 마이페이지에서 고칠 때, 배치가 정정할 때. 경로마다 저 호출을 잊지 않고 붙여야 하고, 하나라도 빠지면 그 경로로 바뀐 건 이력이 없습니다. 그리고 이력이 없다는 건 없어 보이지 않습니다. 그냥 조용히 없는 겁니다.
실제로 그런 구멍을 한 번 찾았습니다. 어떤 화면에서 바꾼 계좌만 이력이 통째로 비어 있었는데, 그 경로 하나만 accountHistoryService.save 호출이 빠져 있었고 나머지 경로는 다 멀쩡히 부르고 있어서, 평소엔 아무도 그 구멍을 몰랐고 특정 셀러의 이력을 따로 뒤지다가 우연히 걸린 거였습니다. 고치는 건 한 줄 추가였습니다. 다만 이런 종류의 버그는 “다른 데도 또 있나”를 부르는 버그거든요.
리스너로 옮기니 부를 필요가 없어졌다
그래서 이력 적재를 아예 JPA 엔티티 리스너로 옮겼습니다. 엔티티에 @EntityListeners 를 달고, @PostPersist / @PostUpdate 에서 이력을 저장합니다.
1
2
3
4
5
6
7
// Spring Boot 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) {
// ...
}
}
이제 계좌를 저장하면 이력이 자동으로 따라옵니다. 어느 경로로 바꾸든 상관없습니다. 신청이든 마이페이지든 배치든, 영속성 컨텍스트를 통해 저장되기만 하면 콜백이 발동하거든요. 호출부에서 이력 서비스를 부를지 말지 고민할 필요가 없어진 겁니다. 빼먹을 수가 없으니까요.
핵심은 이력을 남긴다는 규칙이 값을 바꾸는 사람 손이 아니라 엔티티 정의에 붙었다는 점입니다. 나중에 계좌를 바꾸는 경로가 하나 더 생겨도, 그쪽은 아무것도 안 해도 이력이 남습니다.
계좌 이력 서비스를 이렇게 걷어내고, 이력이 필요한 다른 엔티티(쇼핑몰 계좌 매핑, 지급 이력)도 며칠에 걸쳐 같은 방식으로 옮겼습니다. 옮길 때마다 흩어져 있던 이력 서비스 호출이 같이 지워져서 코드가 오히려 줄었습니다. 지운 줄이 더 많았어요.
리포지토리는 이미 있던 정적 유틸로 꺼냈다
리스너에서 이력을 저장하려면 리포지토리가 필요합니다. 여기서는 서버에 이미 있던 유틸을 그대로 썼습니다. 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);
}
}
솔직히 이 유틸 자체가 냄새나는 코드입니다. 정적 필드에 컨텍스트를 담아두고 아무 데서나 꺼내 쓰는 건 스프링이 권하지 않는 모양이거든요. 정석은 따로 있습니다. 스프링 부트는 Hibernate 의 BeanContainer 를 SpringBeanContainer 로 채워 두기 때문에, 리스너 필드에 @Autowired 를 달면 Hibernate 가 리스너를 만들 때 그대로 주입됩니다. @Component 를 붙이지 않아도요. 정적 조회는 이미 있던 유틸을 쓴 선택이었고, 냄새는 안고 갑니다.
catch 로 감싸도 본체 트랜잭션은 못 지킨다
try/catch 로 감싸고 실패하면 로그만 남기게 했습니다. 이력 때문에 계좌 변경이라는 본 작업이 막히면 안 된다고 봤거든요.
그런데 이 catch 가 지켜 주는 범위는 생각보다 좁습니다. historyRepository.save 는 기본 전파로 본체 트랜잭션에 참여해서, 그 안에서 예외가 나면 여기서 삼켜도 트랜잭션에는 이미 rollback-only 가 찍힙니다. 조회 API 에서 롤백 에러를 냈던 구조와 같죠. flush 시점에 나는 오류는 아예 이 catch 밖에서 나고요. 조용히 넘어가는 건 리포지토리를 못 꺼내거나 이력 객체를 만들다 실패하는 경우 정도입니다. JPA 명세도 생명주기 콜백 안에서는 EntityManager 나 쿼리를 부르지 말라고 적어 둡니다. 이 방식은 명세가 권하는 모양도 아닙니다.
벌크 업데이트나 네이티브 쿼리에는 리스너가 안 붙는다
이 자동 이력은 영속성 컨텍스트를 통해 저장될 때만 붙습니다. @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() 로 저장합니다.
거꾸로 이력이 필요 없는 값이면 벌크가 낫습니다. 위 예의 사업자 상태 플래그가 그런 경우인데, 배치가 매일 벌크 업데이트로 갱신하지만 그건 지금 상태만 나타내는 값이라 변경 궤적을 따로 남길 필요가 없어서 일부러 그렇게 둔 겁니다. 경계는 하나입니다. 현재 상태만 나타내는 값은 벌크로 빠르게, 변경의 궤적을 남겨야 하는 엔티티는 영속성 컨텍스트를 통해서.
이력이 필요한 엔티티에 누가 성능 때문에 벌크 업데이트나 네이티브 upsert 를 붙이면 그 경로는 이력이 안 남습니다. 예외도 안 나고 테스트도 초록불이라 눈에 잘 안 띄죠. 지금은 그걸 코드가 막아 주지 못하고 사람이 기억해야 합니다.