관리자 페이지 데이터 변경 기록 구현(1)

서론

관리자 페이지 담당자의 요청으로 인해, 데이터 생성, 수정, 삭제 기록을 남길 수 있는 기능이 추가되어야 했다. 이전에는 변경 기록이 없었기 때문에 데이터가 누가, 언제, 어떻게 변경되었는지를 추적하기 어려웠다. 이 새로운 기능은 관리자 페이지만 적용되며, 관리자는 변경 이력을 쉽게 추적하고 나아가 필요한 경우 잘못 변경된 데이터를 복구할 수 있는 길을 마련했다.

첫 번째 선택 사항: Spring Envers

데이터 변경 이력 관리를 위해 제일 먼저 도입하려고 했던 툴은 Spring Envers 였다. Spring Envers는 Hibernate에서 제공하는 기능으로, 데이터 변경 이력을 추적하고 저장할 수 있는 강력한 도구이다. Envers는 엔티티에 대한 각 변경마다 새로운 버전을 생성하여 데이터 이력을 기록하는데, 이를 통해 필요할 때 이전 버전의 데이터를 쉽게 추적하고 복원할 수 있는 장점이 있었다.

그런데 Spring Envers를 도입하는데 망설여진 첫번째 이유는 모든 엔티티의 감사 엔티티를 생성한다는 것이었다. 감사 테이블은 원본 엔티티 테이블의 이름에 _AUD를 붙여 생성하는데, DB에 500개의 Entity가 존재한다면 500개의 감사 엔티티가 추가적으로 생성되는 것이었다. 즉, 대량의 데이터를 저장하면 성능 문제가 발생할 수 있다는 것이다.

두 번째는 데이터 변경 이력 관리를 위한 테이블에는 시간, 수정자와 같은 추가 정보가 필요하였는데, Hibernate Envers를 사용할 때 감사 테이블에 사용자 정의 컬럼을 추가하는 것은 기본적으로 지원되지 않았다. 리비전 엔티티를 사용자 정의를 하여 감사 테이블이 아니라 별도의 리비전 정보 테이블에 추가적인 정보를 저장할 수 있었지만, 이를 위해서는**RevisionListener인터페이스를 구현하고,@RevisionEntity**어노테이션을 사용하여 리비전 엔티티를 사용자 정의 해야 하는 과정이 필요했다. 이는 별도의 테이블에 저장되어서 이 정보를 감사 테이블과 직접 연결하려면 리비전 번호를 사용하여 두 테이블을 조인해야 했으며 기존의 DB 테이블 이상의 테이블을 생성해 효율적인 방법이라 생각되지 않았다.

두 번째 선택사항: Hibernate CustomEventListener

Hibernate Custom EventListener는 Hibernate의 이벤트 모델을 활용해 개발자가 직접 이벤트 처리 로직을 구현하는 방법이다. Spring Envers에 비해 간편화, 자동화에 제약이 있었지만 특정 엔티티 또는 특정 상황에 대한 이벤트 처리 로직을 세밀하게 조절할 수 있다는 장점이 있어서 Hibernate CustomEventListener를 이용하여 데이터 변경 기록 구현하였다.

구현

  1. 로그를 저장할 Entity 생성
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "logs__admin_log")
public class AdminLogs {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(columnDefinition = "BIGINT UNSIGNED")
    @Comment("백오피스 로그 id")
    private BigInteger id;

    @Convert(converter = LogTypeConverter.class)
    @Comment("로그 타입 1. 생성 2. 수정 3. 삭제")
    private LogType logType;

    @Convert(converter = ControllerTypeConverter.class)
    @Comment("사용 컨트롤러 타입")
    private ControllerType controllerType;

    @Column(name = "request_url")
    private String baseUrl;

    @Column(name = "end_point")
    private String endPoint;

    @Column(name = "entity_name")
    private String entityName;

    @Column(name = "user_code")
    private String userCode;

    @Column(name = "logs", columnDefinition = "TEXT")
    @Convert(converter = ListToStringConverter.class)
    private List<String> logs;

    @CreationTimestamp
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy/MM/dd HH:mm:ss", timezone = "Asia/Seoul")
    @Column(nullable = false, columnDefinition = "TIMESTAMP(6) DEFAULT NOW(6)")
    @Comment("생성일")
    private Timestamp createdDateTime;
}

어떤 기록 데이터를 남기냐에 따라 Entity 구성이 달라지겠지만 위와 같이 Entity를 생성했다.

  • logType: CREATE, UPDATE, DELETE중 어떤 작업을 수행했는지 저장한다.
  • controllerType: 사실 어떤 페이지에서 작업이 이루었는지 체크하고 싶었지만, 프론트에서 요청을 받아서 진행하는 작업이 아니었기 때문에 어떤 Controller에서 작업이 이루어졌는지 간편하게 체크 및 필터링 하기 위해서 생성한 속성이다.
  • baseUrl: 어떤 호스트에서 작업이 이루어졌는지 확인하기 위한 속성이다.
  • endPoint: /v1/alarm 처럼 어떤 endPoint에서 작업이 이루어졌는지 확인하기 위한 속성이다.
  • entityName: 어떤 Entity에서 변화가 일어났는지 확인하기 위한 속성이다.
  • userCode: 어떤 유저가 수정했는지 알기 위한 속성이다.
  • logs: 관리자 페이지 담당자가 확인하기 위한 값이다. 변경 내용을 알기 쉽게하기 위해 문자열로 저장하였다.
  1. Hibernate의 CustomEventListener 구현

먼저, Hibernate의 CustomEventListener를 구현하기 위해 다음과 같은 클래스를 생성한다.

public class CustomAuditEventListener implements PostInsertEventListener, PostUpdateEventListener, PostDeleteEventListener {

    @Override
    public void onPostInsert(PostInsertEvent event) {
        // 새로운 데이터가 추가될 때 실행되는 로직을 작성한다.
    }

    @Override
    public void onPostUpdate(PostUpdateEvent event) {
        // 데이터가 업데이트될 때 실행되는 로직을 작성한다.
    }

    @Override
    public void onPostDelete(PostDeleteEvent event) {
        // 데이터가 삭제될 때 실행되는 로직을 작성한다.
    }
}

위 코드에서는 PostInsertEventListener, PostUpdateEventListener, PostDeleteEventListener를 구현하여 각각 새로운 데이터 추가, 데이터 업데이트, 데이터 삭제 시 실행되는 로직을 작성할 수 있다.

DELETE 예시

@Component
@RequiredArgsConstructor
public class CustomPostDeleteEventListener implements PostDeleteEventListener {
    @Override
    public void onPostDelete(PostDeleteEvent event) {
        String hardwareName = NetworkUtils.getLocalMacAddress();
        Object entity = event.getEntity();

        List<String> logs = new ArrayList<>();
        String logEntry = hardwareName + " 기기에서 "  + event.getId() + "번 "+ entity.getClass().getSimpleName() + "이 삭제됨.";
        logs.add(logEntry);

        adminLogsRepository.save(AdminLogs.builder()
                .logType(LogType.DELETE)
                .controllerType(RequestContextHolder.getCurrentControllerType())
                .baseUrl(RequestContextHolder.getCurrentBaseUrl())
                .endPoint(RequestContextHolder.getCurrentEndpoint())
                .entityName(entity.getClass().getSimpleName())
                .userCode(hardwareName)
                .logs(logs)
                .build());
    }

    @Override
    public boolean requiresPostCommitHanding(EntityPersister persister) {
        return false;
    }
}

DELETE의 내부 로직은 간단하다. PostDeleteEvent 객체에서 entity, id, 클래스 이름을 가져와 로그에 저장하면 되어서 어렵지 않은 로직이다. 그러나, Embedded class와 같은 클래스에 Entity가 매핑되어 있기 때문에 UPDATE의 경우 코드가 복잡해진다.

UPDATE 예시

@Component
@RequiredArgsConstructor
public class CustomPostUpdateEventListener implements PostUpdateEventListener 
    private final AdminLogsRepository adminLogsRepository;

    @Override
    public void onPostUpdate(PostUpdateEvent event) {
        // 백오피스가 아니라면 로그를 저장하지 않음
        if (!isAdminRequest()) return;

        String hardwareName = NetworkUtils.getLocalMacAddress();
        Object entity = event.getEntity();
        Object[] oldState = event.getOldState();
        Object[] newState = event.getState();
        String[] properties = event.getPersister().getPropertyNames();
        List<String> logs = new ArrayList<>();
        for (int i = 0; i < properties.length; i++) {
            if (!Objects.equals(oldState[i], newState[i]) && !properties[i].equals("updatedDateTime")) {
                if (newState[i] instanceof Collection<?> newCollection) {
                    if (oldState[i] instanceof Collection<?> oldCollection) {
                        Iterator<?> oldIterator = oldCollection.iterator();
                        Iterator<?> newIterator = newCollection.iterator();
                        int index = 0;

                        while (oldIterator.hasNext() && newIterator.hasNext()) {
                            Object oldElement = oldIterator.next();
                            Object newElement = newIterator.next();
                            processObjectForLogging(entity, oldElement, newElement, event, properties, i, index, hardwareName, logs);
                            index++;
                        }
                    }
                } else if (oldState[i] != null && newState[i] != null && oldState[i].getClass().getAnnotation(Embeddable.class) != null) {
                    processObjectForLogging(entity, oldState[i], newState[i], event, properties, i, null, hardwareName, logs);
                } else {
                    String logEntry = generateLogEntry(entity, event, properties[i], null, null, oldState[i], newState[i], hardwareName);
                    logs.add(logEntry);
                }
            }
        }

        if (!logs.isEmpty()) {
            adminLogsRepository.save(AdminLogs.builder()
                    .logType(LogType.UPDATE)
                    .controllerType(RequestContextHolder.getCurrentControllerType())
                    .baseUrl(RequestContextHolder.getCurrentBaseUrl())
                    .endPoint(RequestContextHolder.getCurrentEndpoint())
                    .entityName(entity.getClass().getSimpleName())
                    .userCode(hardwareName)
                    .logs(logs)
                    .build());
        }
    }

    private void processObjectForLogging(Object entity, Object oldState, Object newState, PostUpdateEvent event, String[] properties, int propertyIndex, Integer collectionIndex, String hardwareName, List<String> logs) {
        if (oldState instanceof LocalDate || oldState instanceof LocalTime || oldState instanceof LocalDateTime) {
            if (!oldState.equals(newState)) {
                String logEntry = generateLogEntry(entity, event, properties[propertyIndex], properties[propertyIndex], collectionIndex, oldState, newState, hardwareName);
                logs.add(logEntry);
                System.out.println(logEntry);
            }
        }
        else {
            Field[] fields = oldState.getClass().getDeclaredFields();
            for (Field field : fields) {
                if (!field.getDeclaringClass().getName().startsWith("co.dalicious")) {
                    continue;
                }
                try {
                    field.setAccessible(true);
                    Object oldValue = field.get(oldState);
                    Object newValue = field.get(newState);

                    if (oldValue instanceof Number && newValue instanceof Number) {
                        BigDecimal oldBigDecimal = BigDecimal.valueOf(((Number) oldValue).doubleValue()).stripTrailingZeros();
                        BigDecimal newBigDecimal = BigDecimal.valueOf(((Number) newValue).doubleValue()).stripTrailingZeros();
                        if (!oldBigDecimal.equals(newBigDecimal)) {
                            String logEntry = generateLogEntry(entity, event, properties[propertyIndex], field.getName(), collectionIndex, oldValue, newValue, hardwareName);
                            logs.add(logEntry);
                        }
                    } else if (!Objects.equals(oldValue, newValue)) {
                        String logEntry = generateLogEntry(entity, event, properties[propertyIndex], field.getName(), collectionIndex, oldValue, newValue, hardwareName);
                        logs.add(logEntry);
                        System.out.println(logEntry);
                    }

                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    private String generateLogEntry(Object entity, PostUpdateEvent event, String propertyName, String fieldName, Integer collectionIndex, Object oldValue, Object newValue, String hardwareName) {
        StringBuilder sb = new StringBuilder();
        sb.append(hardwareName).append(" 기기에서 ")
                .append(entity.getClass().getSimpleName()).append(" ")
                .append(event.getId()).append("번 ")
                .append(propertyName);
        if (collectionIndex != null) {
            sb.append("[").append(collectionIndex).append("]");
        }
        if (fieldName != null) {
            sb.append("의 ").append(fieldName);
        }
        sb.append(" 값이 ").append('"').append(oldValue).append('"')
                .append("에서 ").append('"').append(newValue).append('"')
                .append("로 변경.");
        return sb.toString();
    }
    @Override
    public boolean requiresPostCommitHanding(EntityPersister persister) {
        return false;
    }

    @Override
    public boolean requiresPostCommitHandling(EntityPersister persister) {
        return PostUpdateEventListener.super.requiresPostCommitHandling(persister);
    }
}

구현하면서 닥친 몇 가지 문제가 있었는데

  1. BigDecimal객체를 이용하면서 같은 값인데, 12000.00 → 12000로 변경과 같은 로그들이 생성되었다.
  2. Embedded 객체를 수정할 때에는 내부 값이 아니라 @f23os4 와 같은 객체 주소값이 출력되어 어떤 내용이 변경되었는지 로그로 확인할 수 없었다.
  3. 자동으로 업데이트 되는 updatedDateTime 속성이 변경될 때에도 무의미하게 로그를 저장하고 있었다.
  4. LocalDate/LocalTime/LocalDateTIme에서 field.setAccessible(*true*); 메소드가 에러가 났다, 따라서 예외 처리로 따로 빼주었다.

여러 예외 사항을 처리하다보니 코드가 복잡해졌다. 이는 Hibernate CustomLister를 구현하면서 겪게되는 필연적인 문제인 것 같다. 핵심은 로그다.

 String logEntry = hardwareName + " 기기에서 " + entity.getClass().getSimpleName() + " " + event.getId() + "번 " + properties[i] + "의 " + field.getName() + "값이 " + '"' + oldValue + '"' + "에서 " + '"' + newValue + '"' + "로 변경.";

UPDATE에서 사용해야할 핵심 메소드는 DELETE와 마찬가지로 entity.getClass().getSimpleName() event.getId() 와 Entity 객체 필드에 접근할 수 있게 만드는 field.setAccessible(true); 그리고 변경된 값을 알기 위한 event.getOldState();event.getNewState();다. 생성도 수정과 같은 방식으로 진행하면 된다.

  1. HibernateListenerConfigurer 구현
@Component
@RequiredArgsConstructor
public class HibernateListenerConfigurer {
    private final CustomPostUpdateEventListener customPostUpdateEventListener;
    private final CustomPostInsertEventListener customPostInsertEventListener;
    private final CustomPostDeleteEventListener customPostDeleteEventListener;

    @PersistenceUnit
    private EntityManagerFactory emf;

    @PostConstruct
    protected void init() {
        SessionFactoryImpl sessionFactory = emf.unwrap(SessionFactoryImpl.class);
        EventListenerRegistry registry = sessionFactory.getServiceRegistry().getService(EventListenerRegistry.class);
        registry.getEventListenerGroup(EventType.POST_INSERT).appendListener(customPostInsertEventListener);
        registry.getEventListenerGroup(EventType.POST_UPDATE).appendListener(customPostUpdateEventListener);
        registry.getEventListenerGroup(EventType.POST_DELETE).appendListener(customPostDeleteEventListener);
    }
}

마지막으로 생성한 Listener를 이용하여 HibernateListenerConfigurer를 구현하면 된다.

결론

사실 아직 미흡한 부분이 많고, 수정해야할 사항도 많지만 데이터 변경 이력 구현을 하면서 배운게 많다. 구현하면서 정리한 사실은 아래와 같다.

  • 데이터 변경 이력을 추적하고 저장할 수 있는 기능을 추가할 때, Spring Envers와 Hibernate Custom EventListener를 고려할 수 있다.
  • Spring Envers는 Hibernate에서 제공하는 기능으로, 데이터 변경 이력을 추적하고 저장할 수 있는 강력한 도구이다. 그러나 대량의 데이터를 저장하면 성능 문제가 발생할 수 있으며, 감사 테이블에 사용자 정의 컬럼을 추가하는 것은 기본적으로 지원되지 않는다.
  • Hibernate Custom EventListener는 Hibernate의 이벤트 모델을 활용해 개발자가 직접 이벤트 처리 로직을 구현하는 방법이다. Spring Envers에 비해 간편화, 자동화에 제약이 있지만 특정 엔티티 또는 특정 상황에 대한 이벤트 처리 로직을 세밀하게 조절할 수 있다는 장점이 있다.
  • 데이터 변경 이력을 남기기 위해서는 데이터 변경 이력을 저장할 Entity를 생성하고, Hibernate의 CustomEventListener를 구현해야 한다.
  • CustomEventListener에서는 PostInsertEventListener, PostUpdateEventListener, PostDeleteEventListener를 구현하여 각각 새로운 데이터 추가, 데이터 업데이트, 데이터 삭제 시 실행되는 로직을 작성할 수 있다.

다음 회차에는 구현한 HibernateListenerConfigurer을 관리자 페이지에서만 적용할 수 있게 수정하는 방법을 공유해보겠다.

관리자 페이지 데이터 변경 기록 구현(1)

댓글 남기기

Scroll to top