당근베이

🥕[당근베이]💻 history 내역을 남기는 방법은? Trigger, AOP, @EntityListener

개미는뚠뚠 2025. 4. 30. 07:05

 

 

 

https://github.com/f-lab-edu/carrotbay

 

GitHub - f-lab-edu/carrotbay: 당근마켓 + 이베이를 결합해 만든 동네 중고 장터 경매 시스템입니다.

당근마켓 + 이베이를 결합해 만든 동네 중고 장터 경매 시스템입니다. Contribute to f-lab-edu/carrotbay development by creating an account on GitHub.

github.com

 

현재 만들고 있는 carrotbay에서는 history를 남기는 기능을 구현하려고한다. hisotry테이블을 도메인마다 두는 방법과 하나의 history테이블로 여러 테이블의 데이터 변경 사항을 저장 하는 방법이 있다.

후자의 방법을 선택한 이유는 테이블마다 history테이블을 생성 할 경우 유지보수가 어렵고 너무 많은 테이블이 생긴다. 그리고 이미 soft delete로 변경된 데이터를 저장하고있기때문이다. 변경된 데이터 값이 중요한 테이블은 경매(auction)이랑 입찰(bid)테이블인데 해당 테이블들은 hard delete 되지않고 변경 내역이 제일 중요한 입찰(bid) 테이블 같은 경우에는 데이터를 변경하는 게 불가능함으로 이미 테이블에 변경 history가 남도록 설계되어있다. 고로 하나의 history테이블로 여러 테이블의 데이터 변경 사항을 저장 하는 방법을 선택했다. 

history 기능을 구현하기위해 정의한 요구조건 다음과 같다.

 

  • 여러 도메인에서 history 내역을 남길 수 있어야하기때문에 특정 도메인이나 테이블에 종속적이면 안된다.
  • 반복적인 코드가 생성되지않도록 한다.
  • update , delete로 인해 데이터가 변경 됬을 때만 내역을 기록한다.
  • 만약 update, delete기능이 중간에 에러가 생겨 롤백된다면(혹은 커밋되지못한다면) history내역도 저장되서는 안된다.

그럼 이제 어떻게 history 테이블을 구현하면 좋을까?  history 테이블을 구현할 방법으로는 크게 세가지를 생각할 수 있다. Trigger, AOP, JPA @EntityListener이 있다.

 

트리거 Trigger 

트리거는 DB레벨에서 데이터의 변경을 감지하고 자동으로 동작하도록 작성된 프로그램이다. 어플리케이션 코드 수정 없이 DB 레벨에서 자동으로 이력을 저장할 수 있고, 성능부담이 크지않으며 트랜잭션 내에서 처리가 가능하다.

DB레벨에서 동작하기때문에 빠르고 안정적이지만 DB에 의존적이다. 또한 복잡한 로직은 적용하기 어렵고 트리거를 통해 history내역을 저장하려면 history를 저장하려는 모든 테이블마다 똑같은 트리거를 입력해줘야한다.

 

AOP

AOP를 사용하면 어플리케이션 레벨에서 로깅이 가능하며 다양한 조건을 적용할 수 있다. 여러 테이블에 공통적으로 적용 할 수 있기때문에 코드 수정이 간편하다. 트랜잭션 처리를 활용해 보다 정교한 로깅이 가능하다. 다만 트랜잭션에 따라 데이터 변경 및 삭제는 실패했는데 기록은 남는 문제가 있을 수 있어 트랜잭션 처리를 신경써야한다. 

 

JPA @EntityListener

Hibernate의 @PreUpdate 또는 @PreRemove 사용하며  특정 엔티티에 대해 직접 변경 사항을 추적가능하다. 다만 JPA에 대해 의존적이다. 또한 EntityListener은 트랜잭션을 직접 사용할수없어 오류가 발생할 수 있다. 이를 해결하려면 EntityListener 내부에서 트랜잭션을 가진 서비스 빈을 주입해야 하지만 이 과정이 복잡하다. 또한 @PreUpdate에서는 변경 전 데이터를 알기 어렵다. 변경 후 데이터만 접근이 가능하기때문이다. 또 특정 테이블에 종속적이다.여러 테이블의 변경 이력을 공통적으로 처리하고 싶은 요구조건에 맞지않는다.

 

세가지 방법 중 AOP를 사용해 해당 기능을 구현하려고 한다. 이유는 다음과 같다.

 

✅ AOP 선택이유

1. 특정 테이블이나 도메인에 종속적이지않도록 구현 할 수 있다.

. history 테이블은 여러 도메인 및 테이블의 데이터 변경 이력을 저장해야하기때문에 특정 테이블이나 도메인에 종속적이여선 안된다. 이런 조건에서 JPA @EntityListener는 제외된다 JPA @EntityListener는 특정 테이블에 종속적이다. 특정 엔티티에 직접 선언해야 동작하기때문에 여러 테이블에 대한 변경 이력을 공통 로직으로 처리하는데 한계가 있다.

 

2. 중복 코드를 제거 할 수 있다.

트리거의 경우 DB레벨에서 동작하기 때문에 성능도 더 빠르고 트리거를 구현하는 방법도 간단했지만 모든 테이블에 트리거를 생성해야한다는 치명적인 단점이 있었다. 테이블이 늘어날때마다 트리거를 생성해야한다는 점은 유지보수 측면에서 많은 문제를 일으킬 수 있다는 판단이 들어 고려 대상에서 제외했다.

 

그래서 AOP로 구현하기로 했다. 이제 또 다음의 고려사항이 생긴다. 한번 살펴보자.

 

💡 AOP로 기능을 구현 할 시 고려사항 

1. history를 남기는 AOP는 타겟 메소드의 트랜잭션이 모두 완료되어야만 동작해야한다. 즉, 타겟 멧소드가 실패했는데 history가 저장되어서는 안된다.

2. history를 남기다가 예외상황이 발생하더라도 타깃 메소드의 동작은 무사히 작동되어야한다.

 

결론적으로 해당 고려사항을 만족하기위해서는 이를 위해 타겟 메소드와 history를 남기는 AOP의 트랜잭션이 분리될 필요가 있다. 여기서는 두가지 해결 방법을 떠올릴 수 있다. 트랜잭션 전파 속성을 사용하는 것과 event를 발생시키는 것이다.

 

Propagation.NESTED 를 사용하고 AOP 안에서 history 내역을 저장할 것인가

event를 발생시켜 아예 history를 저장하는 부분을 AOP로부터 분리 시킬 것인가. 

 

여기서는 event를 사용하기로했다. 이유는 다음과 같다.

 

 EVENT 방식을 선택한 이유

1. NESTED를 사용해야할 경우 고려해야할 사항이 너무 많으며 어떤 사이드 이펙트가 일어날지 예상하기 어렵다.

스프링 + JPA 조합에서는 NESTED 가 제대로 동작 안 할 수 있다. 즉, NESTED 가 쓰려면 DB 와 JPA 의 지원 여부를 반드시 체크해야한다. 만약 NESTED를 지원하다고 해도 NESTED는 복잡도가 높은 기술이기때문에 고려해야할 사항이 너무 많고 어떤 사이드 이펙트가 일어날 지 예상하기 어렵다. 그에 반해 event는 NESTED에 비해 단순하게 기능을 구현할 수 있다.

2.  event를 쓰는 것이 확장성과 유지보수성이 높으며 비동기도 가능하다.

만약 history저장 시에 로깅 기능도 추가하고 싶다면 리스너 추가만으로 확장이 가능하다. 이런면에서 확장성이 높고 코드가 좀 더 직관적이라 파악하기도 쉽다. 또한 비동기를 사용할 수 있기때문에 메인 트랜잭션에 영향을 미치지않고 독자적으로 작동할 수 있다. 

 

3. 결합도를 낮추고 SPR 원칙을 지킬 수 있다.

AOP의 목적은 부가기능을 추가하는 것이다. AOP의 책임은 히스토리를 남길 필요가 있다는 신호를 감지하고 통지하는 것이지 history를 저장하고 예외처리를 직접 담당하는 것은 책임이 과다하다. 이를 위해 event를 통해 history 내역을 저장하는 책임을 분리하는 것이다. 다만 이 책임 분리는 꼭 event가 아니라도 가능하다. HistoryService을 주입해서 직접 호출해도 되긴하다. 하지만 이 방법으로 SRP는 해결 할 수 있어도 트랜잭션 전파/순서 문제는 해결 하기 어렵다.

 

 


 

 

 

💻 기능 구현

 

// PutMapping을 감지
@Around("@annotation(org.springframework.web.bind.annotation.PutMapping)")
    public Object logUpdate(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            Long entityId = extractIdFromUrl();
            JpaRepository<?, Long> repository = resolveRepository(joinPoint);
            Long userId = extractUserIdFromArguments(joinPoint);
            String beforeUpdateStr = fetchEntityAsJson(repository, entityId);

            Object result = joinPoint.proceed();

            String afterUpdateStr = fetchEntityAsJson(repository, entityId);
            String entityName = getEntityName(repository);

            if (StringUtils.isNoneBlank(beforeUpdateStr, afterUpdateStr)) {
                HistoryRequestDto.CreateHistoryDto dto = HistoryRequestDto.CreateHistoryDto.builder()
                        .tableName(entityName)
                        .operation(UPDATE_SUFFIX)
                        .beforeValue(beforeUpdateStr)
                        .afterValue(afterUpdateStr)
                        .entityId(entityId)
                        .createBy(userId)
                        .build();
                eventPublisher.publishEvent(dto);
            }

            return result;

        } catch (Exception e) {
            log.error("HistoryLoggingAdvice 처리 중 오류 발생", e);
            throw e;
        }
    }

	// 들고온 entity를 json으로 변환
    public String fetchEntityAsJson(JpaRepository<?, Long> repository, Long id) {
        return repository.findById(id)
                .map(this::convertEntityToJson)
                .orElse(null);
    }

	// /api/test/{test_id}라고 가정하고 수정하는 타겟 엔티티의 id값을 가져오는 함수
    public Long extractIdFromUrl() {
        try {
            String uri = request.getRequestURI();
            String idStr = uri.substring(uri.lastIndexOf("/") + 1);
            return Long.parseLong(idStr);
        } catch (Exception e) {
            log.warn("URL 기반 ID 추출 실패: {}", e.getMessage());
            return null;
        }
    }

	// 해당하는 repository를 찾아오는 함수 
    public JpaRepository<?, Long> resolveRepository(ProceedingJoinPoint joinPoint) {
        String entityName = joinPoint.getTarget().getClass().getSimpleName().replace(CONTROLLER_SUFFIX, "");
        String repositoryBeanName = decapitalize(entityName) + REPOSITORY_SUFFIX;

        try {
            return (JpaRepository<?, Long>) context.getBean(repositoryBeanName);
        } catch (Exception e) {
            throw new IllegalStateException("HistoryLoggingAdvice : Repository 빈을 찾을 수 없습니다: " + repositoryBeanName, e);
        }
    }

    public String decapitalize(String str) {
        return Character.toLowerCase(str.charAt(0)) + str.substring(1);
    }

	// @LoginUser으로 가져오는 userId를 조회하는 함수
    public Long extractUserIdFromArguments(ProceedingJoinPoint joinPoint) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Object[] args = joinPoint.getArgs();
        Parameter[] parameters = method.getParameters();

        for (int i = 0; i < parameters.length; i++) {
            if (parameters[i].isAnnotationPresent(LoginUser.class) && args[i] instanceof Long) {
                return (Long) args[i];
            }
        }
        return null;
    }

	// 엔티티에 있는 값을 꺼내서 Map으로 변환 (map으로 변환하는 이유는 json으로 변환하기 위해서)
    public Map<String, Object> convertEntityToMap(Object entity) {
        Map<String, Object> result = new HashMap<>();
        Object realEntity = Hibernate.unproxy(entity);

        for (Field field : realEntity.getClass().getDeclaredFields()) {
            field.setAccessible(true);
            try {
                Object value = field.get(realEntity);
                // Enum일 경우 
                if (value instanceof Enum<?>) {
                    result.put(field.getName(), ((Enum<?>) value).name());

                // 연관관계를 맺은 엔티티의 경우 따로 처리
                } else if (value != null && value.getClass().getPackageName().startsWith("com.carrotbay.domain")) {
                    Object unproxiedValue = Hibernate.unproxy(value);
                    for (Field declaredField : value.getClass().getDeclaredFields()) {
                        if (declaredField.isAnnotationPresent(Id.class)) {
                            declaredField.setAccessible(true);
                            String fieldName = field.getClass().getSimpleName() + "_" + field.getName();
                            result.put(fieldName, declaredField.get(unproxiedValue));
                            break;
                        }
                    }
                } else {
                    result.put(field.getName(), String.valueOf(value));
                }
            } catch (Exception e) {
                log.warn("HistoryLoggingAdvice : 엔티티 필드 변환 중 오류: {}", e.getMessage());
            }
        }
        return result;
    }

	// map타입으로 변경한 entity를 -> json으로 변경
    public String convertEntityToJson(Object entity) {
        try {
            return objectMapper.writeValueAsString(convertEntityToMap(entity));
        } catch (JsonProcessingException e) {
            throw new RuntimeException("HistoryLoggingAdvice : 엔티티 JSON 변환 실패", e);
        }
    }

	// repository가 의존하고 있는 entitly를 찾은 후 엔티티명을 받아오는 함수
    public String getEntityName(JpaRepository<?, Long> repository) {
        ResolvableType resolvableType = ResolvableType.forClass(Repository.class, repository.getClass());
        Class<?> entityClass = resolvableType.getGeneric(0).resolve();

        if (entityClass == null) {
            throw new IllegalStateException("HistoryLoggingAdvice : Repository에서 의존하고있는 entity가 없습니다.");
        }

        return entityClass.getSimpleName();
    }
@Component
@RequiredArgsConstructor
public class EntityUpdateEventListener {

    private final HistoryService historyService;

    @EventListener // ApplicationEventPublisher 를 통해 이벤트가 발행되면 해당 메서드가 실행됨
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // 트랜잭션이 성공적으로 커밋된 이후에 이벤트 처리
    public void handleEntityUpdated(HistoryRequestDto.CreateHistoryDto dto) {
        historyService.saveHistory(dto.toEntity());
    }
}

 

여기서 주의해할 점은 @TransactionEventListener를 쓸때 메인 트랜잭션 이후 실행되니까 트랜잭션 새로 열리는 것처럼 보이지만 실제로는 트랜잭션이 없는 상태로 실행된다. (Non-Transactional Context) 고로 handelEntityUpdated에서 트랜잭션을 선언해줘야한다.

 

위의 코드에 트랜잭션이 없는 것은 service 단에서 이미 트랜잭션을 선언했기때문이다. serivce단에서 트랜잭션을 선언한 이유는 관심사를 분리하기 위해서이다. 이벤트 핸들러는 이벤트를 받고 무엇을 할지 결정하는 곳이고 서비스는 실제 비즈니스 로직을 처리하는 곳임으로 비즈니스 로직을 처리하는 서비스 단에서 선언하는 게 응집도가 높다. 또한 이벤트 핸들러가 아니라, 나중에 다른 곳에서 historyService.saveHistory() 를 호출하고 싶을때 이벤트 핸들러에만 트랜잭션을 선언하면 다른 곳에서 호출 할때 트랜잭션이 보장 안 된다. 또한 트랜잭션 범위를 이벤트 핸들러에서 관리해야하고 서비스 로직 수정시 핸들러까지 봐야하는 단점이 생긴다.

 

💡 코드 리팩토링

 

이렇게 코드를 짰지만 하나 마음에 걸리는 부분이 있다. convertEntityToMap() 부분이다. 이 부분을 다시 한번 살펴보자 .

 

// 엔티티에 있는 값을 꺼내서 Map으로 변환 (map으로 변환하는 이유는 json으로 변환하기 위해서)
    public Map<String, Object> convertEntityToMap(Object entity) {
        Map<String, Object> result = new HashMap<>();
        Object realEntity = Hibernate.unproxy(entity);

        for (Field field : realEntity.getClass().getDeclaredFields()) {
            field.setAccessible(true);
            try {
                Object value = field.get(realEntity);
                // Enum일 경우 
                if (value instanceof Enum<?>) {
                    result.put(field.getName(), ((Enum<?>) value).name());

                // 연관관계를 맺은 엔티티의 경우 따로 처리
                } else if (value != null && value.getClass().getPackageName().startsWith("com.carrotbay.domain")) {
                    Object unproxiedValue = Hibernate.unproxy(value);
                    for (Field declaredField : value.getClass().getDeclaredFields()) {
                        if (declaredField.isAnnotationPresent(Id.class)) {
                            declaredField.setAccessible(true);
                            String fieldName = field.getClass().getSimpleName() + "_" + field.getName();
                            result.put(fieldName, declaredField.get(unproxiedValue));
                            break;
                        }
                    }
                } else {
                    result.put(field.getName(), String.valueOf(value));
                }
            } catch (Exception e) {
                log.warn("HistoryLoggingAdvice : 엔티티 필드 변환 중 오류: {}", e.getMessage());
            }
        }
        return result;
    }

 

convertEntityToMap()은 리플렉션을 사용해 엔티티에 있는 속성들을 들고와 map으로 변환하고 있다. 엔티티를 알고 있다면 DTO로 변환하여 json으로 다시 변환하는 게 베스트 겠지만 history AOP 기능은 어떤 엔티티가 들어올지 알수 없다. 또한 엔티티를 바로 json으로 변환할 경우에는 연관관계로 매핑되어있는 객체를 무시하거나 아예 무한루프에 빠질 수 있다. 연관관계로 매핑되어있는 객체의 id 값만 저장할 수가 없다. 그래서 이를 만족하는 방법을 찾기위해서 리플렉션을 썼지만 리플렉션은 비용이 높은 API다. 이쯤되면 사실 "굳이 Json으로 저장해야할까? 그냥 String으로 저장하면 이럴 필요 없는데... " 하는 의문이 들긴 하지만 이 의문에 대해서는 밑의 아쉬운 점에서 다시 한번 이야기하기로 하고 지금은 리팩토링에 집중하자.

 

convertEntityToMap()은 리플렉션을 사용해 엔티티의 속성 값을 Map으로 변환하고있다. 엔티티를 json타입으로 저장하기위해서는 DTO 변환이 이상적이지만 history AOP 기능에서는 어떤 엔티티가 들어올지 알 수 없으므로 컴파일 타임에 DTO로 변환할 수 없다. 또한 엔티티를 바로 JSON으로 변환할 경우 연관관계로 연결된 객체들이 함께 직렬화되어 순환 참조 문제나 불필요한 데이터 노출이 발생할 수 있다. 연관관계로 매핑되어있는 객체의 id 값만 저장할 수가 없다. 리플렉션은 런타임 비용이 큰 API이지만 이러한 범용적 요구사항을 만족하기 위해 불가피하게 사용했다. 리플렉션 비용을 좀 더 줄여보자.

 

// 엔티티를 Map<String, Object> 형태로 변환하는 유틸 메서드
public Map<String, Object> convertEntityToMap(Object entity) {
    Map<String, Object> result = new HashMap<>();

    // Hibernate Proxy 를 실제 엔티티 인스턴스로 변환 (지연 로딩 방지)
    Object realEntity = Hibernate.unproxy(entity);
    Class<?> entityClass = realEntity.getClass();

    // 1. 엔티티 클래스별로 필드를 캐싱해서 가져오기
    List<Field> fields = fieldCache.computeIfAbsent(entityClass, cls -> {
        Field[] declaredFields = cls.getDeclaredFields();
        for (Field field : declaredFields) {
            field.setAccessible(true); // private 필드 접근 허용
        }
        return Arrays.asList(declaredFields);
    });

    // 2. 필드 하나씩 순회
    for (Field field : fields) {
        try {
            Object value = field.get(realEntity); // 필드 값 가져오기

            // (1) Enum 타입이면 Enum name() 으로 저장
            if (value instanceof Enum<?>) {
                result.put(field.getName(), ((Enum<?>) value).name());
            }
            // (2) 우리 도메인 패키지의 다른 엔티티면 ID 값만 저장
            else if (value != null && value.getClass().getPackageName().startsWith("com.carrotbay.domain")) {
                Object unproxiedValue = Hibernate.unproxy(value);
                Class<?> valueClass = unproxiedValue.getClass();

                // 2-1. 연관 엔티티의 ID 필드 캐싱
                Field idField = idFieldCache.computeIfAbsent(valueClass, cls -> {
                    for (Field declaredField : cls.getDeclaredFields()) {
                        if (declaredField.isAnnotationPresent(Id.class)) {
                            declaredField.setAccessible(true);
                            return declaredField;
                        }
                    }
                    return null; // ID 필드 없으면 null 반환
                });

                // 2-2. ID 필드가 있으면 <필드명>_id 형태로 저장
                if (idField != null) {
                    String fieldName = field.getName() + "_id";
                    result.put(fieldName, idField.get(unproxiedValue));
                }
            }
            // (3) 나머지 일반 필드는 String 값으로 저장
            else {
                result.put(field.getName(), String.valueOf(value));
            }

        } catch (Exception e) {
            // 필드 값 접근 중 에러 발생 시 로깅
            log.warn("HistoryLoggingAdvice : 엔티티 필드 변환 중 오류: {}", e.getMessage());
        }
    }

    return result;
}

 

 

기존에는 매번 getDeclaredFields() 를 호출해서 클래스의 필드를 가져오고 각 필드마다 setAccessible(true) 로 접근 권한을 열어주는 작업을 반복하고 있었다. 이는 성능 저하를 불러 올 수 있는 포인트다. 그래서 필드 정보를 캐싱하는 방법으로 리팩토링을 진행했다.

 

클래스 타입을 키로 하고 해당 클래스의 필드 목록을 값으로 갖는 fieldCache 를 구현해서 한 번 클래스의 필드를 조회하고 나면 이후부터는 캐시된 데이터를 사용하여 빠르게 필드에 접근할 수 있게 했다. 이렇게 캐싱하면서 불필요한 리플렉션 호출을 없앨 수 있다. 또한 연관 엔티티의 식별자(ID) 값을 가져올 때도 동일한 문제가 있었다. 엔티티마다 ID 필드를 찾기 위해 매번 모든 필드를 검사하는 방식이었기 때문에 마찬가지로 반복적인 리플렉션이 발생했다. 그래서 ID 필드 역시 idFieldCache 로 캐싱하도록 개선했다.

 

💻  테스트 코드 작성

 

이제 이 코드가 잘 작동하는지 테스트 코드를 작성해보자. AOP 기능을 테스트하기 위해 통합 테스트를 사용했다.통합 테스트를 사용한 이유는 다음과 같다.

Pointcut 을 @PutMapping 메서드에 적용했기 때문에 실제 HTTP 요청 흐름을 따라가야 AOP 가 동작하는지 확인할 수 있다. 따라서 통합 테스트를 통해 실제 웹 요청을 보내고 Spring Container 가 Bean 을 초기화하면서 프록시를 적용한 상태에서 AOP 가 정상적으로 동작하는지 검증하는 것이 필요하다.


또한 AOP 가 정상적으로 작동하려면 프록시가 필요하기 때문이다.
Spring Container 는 AOP 포인트컷이 적용된 Bean 을 생성할 때 프록시로 감싸서 생성하는데 이 프록시가 있어야 AOP 가 메서드 호출을 감지하고 Advice 를 실행할 수 있다. 반면, Mockito 와 같은 Mock 객체는 스프링이 관리하는 실제 Bean 이 아니기 때문에 프록시로 감쌀 수 없으며, 따라서 AOP 가 적용되지 않는다. 참고로 만약 메서드만 따로 호출하는 Service 레이어의 AOP 라면 @MockitoSpyBean을  사용해서 가짜 빈이 아니라 스프링 빈으로 감싸서 프록시를 적용시킬 수 있다.

 

참고로, 테스트를 작성할 때 주의해야 할 점은 @MockBean 대신 @SpyBean 을 사용해야 한다는 점이다. @MockBean 은 순수하게 Mockito 가 생성하는 가짜 객체(Mock)를 Spring Bean 으로 등록한다. 이 때문에 스프링 컨테이너는 이를 단순한 Mock 객체로 인식하고, AOP 프록시를 적용하지 않는다. 반면 @SpyBean 은 실제 구현체(실제 객체)를 Spring Bean 으로 등록하면서, 필요한 부분만 Mockito 를 통해 가짜로(stub/mock) 처리할 수 있도록 지원한다. 결국 @SpyBean 은 실제 객체 기반이기 때문에 Spring 컨테이너는 이를 일반 Bean 처럼 처리하고 AOP 포인트컷이 적용된다면 프록시로 감싸서 빈을 초기화한다. 따라서 AOP 프록시 기능이 정상적으로 적용되어 AOP 동작을 테스트할 수 있게 된다. 즉, @SpyBean 은 실제 객체를 기반으로 하되, mock 처리가 필요한 부분만 mock 으로 동작하게 하므로 Spring Bean 등록 → 프록시 적용 → AOP 적용 이라는 정상적인 흐름이 가능하다.

 

@SpringBootTest
@AutoConfigureMockMvc
class HistoryLoggingIntegrationTest extends DummyObject {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper om;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private AuctionRepository auctionRepository;

    @MockitoBean
    private HistoryService historyService;

    private User user;
    private Auction auction;

    @MockitoSpyBean
    AuctionService auctionService;

    @BeforeEach
    void setUp() {
        User testUser = newUser("auction");
        user = userRepository.save(testUser);
        Auction testAuction = newAuction(user);
        auction = auctionRepository.save(testAuction);
    }

    @AfterEach
    void cleanup() {
        auctionRepository.delete(auction);
        userRepository.delete(user);
    }

    @DisplayName("PutMapping 호출 시 AOP를 사용하여 history 내역을 저장한다.")
    @Test
    void 히스토리_저장() throws Exception {
        String title = "Test 4자 이상 입니다.";
        String content = "test";

        // given
        AuctionRequestDto.ModifyAuctionDto auctionDto = AuctionRequestDto.ModifyAuctionDto.builder()
                .title(title)
                .content(content)
                .endDate(LocalDateTime.now())
                .instantPrice(100)
                .minimumPrice(2999)
                .build();
        String requestBody = om.writeValueAsString(auctionDto);
        MockHttpSession session = new MockHttpSession();
        session.setAttribute("USER_ID", user.getId());

        doReturn(null).when(auctionService).modifyAuction(any(), any(), any());
        doNothing().when(historyService).saveHistory(any(History.class));

        // when
        mockMvc.perform(MockMvcRequestBuilders.put("/api/auctions/" + auction.getId())
                .session(session)
                .content(requestBody)
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(MockMvcResultMatchers.status().is2xxSuccessful());

        // then
        ArgumentCaptor<History> captor = ArgumentCaptor.forClass(History.class);
        verify(historyService, times(1)).saveHistory(captor.capture());
        verify(historyService).saveHistory(captor.capture());
        History history = captor.getValue();
        assertEquals(auction.getId(), history.getEntityId());
        assertEquals("Auction", history.getTableName());
        assertEquals("update", history.getOperation());
        assertTrue(history.getBeforeValue().contains(auction.getContent()));
        assertTrue(history.getAfterValue().contains(content));
    }
}

 

 

 

 

이렇게 putMapping이 호출 될 때마다 history 내역을 저장하는 AOP 기능을 구현하고 테스트해보았다. 하지만 아직 몇가지 아쉬운 점이 남는다.

 

💡 개선 되어야할  점

 

1.  Repository를 찾는 방식

현재 repository를 contorller의 이름을 통해 찾고있다. 즉 Repository이름이 엔티티명 + Repository일 것이라는 가정을 전제로 짜여진 코드인데 누군가 실수로 코드 컨벤션을 어기고 엔티티명 + RestRepository 이런식으로 Repository명을 작성한다면 에러가 발생할 것이다.

@RepositoryBinding(UserRepository.class)

 

이렇게 Repository  컨트롤러에 명시적으로 어노테이션으로 지정하는 방법을 고려해봤으나 이것도 여전히 휴먼 에러가 발생할 수 있는 부분이라 좀 더 생각해봐야할 것 같다.

 

2.  id값을 가져오는 방식

현재 PUT 매핑 URL 은 api/test/{test_id} 를 가정하고 사용하고 있다. 따라서 URL 패턴이나 파라미터가 컨벤션에 맞지 않게 작성된다면, 휴먼 에러가 발생할 수 있는 위험이 존재한다. 지금처럼 간단한 구조에서는 큰 문제가 없겠지만, 기능이 점점 많아지고 도메인이 복잡해질수록 이러한 실수는 빈번하게 발생할 수 있다. 결국 이 부분은 개발자의 주의에만 의존할 것이 아니라, 시스템적으로(기능적으로) 이러한 오류를 사전에 방지할 수 있는 방법을 마련하는 것이 장기적인 유지보수 관점에서 더욱 안전하다.

 

3. convertEntityToMap() 에서의 리플랙션 사용

convertEntityToMap() 에서는 엔티티를 JSON 으로 변환하기 위해 reflection 을 사용하고 있다. reflection 은 런타임 비용이 높은 API 이기 때문에 성능적으로 아쉬움이 있지만, 어떤 엔티티가 들어올지 컴파일 타임에 알 수 없는 상황에서 동적으로 필드를 접근하고, 연관관계 객체는 ID 만 추출하는 등의 요구사항을 만족시키기 위해서는 현재로서는 reflection 을 사용하는 방법이 가장 현실적이라고 판단했다. 다만, 성능 개선 여지가 있는지 다른 방법도 계속 고려하는 편이 좋을 것 같다.

 

기획적으로 바꿀 수 있다면 굳이 json 데이터가 아니라 String으로 저장, 혹은 연관관계의 데이터는 무시하고 json으로 변환 등의 방법도 고려하면 reflection을 사용하지않고 엔티티를 저장 할 수 있을 것 같아서 생각해볼 지점인 것 같다.

 

여기서 잠깐  🤚  굳이 json으로 저장해야할까?

사실 히스토리 데이터를 저장할 때, 단순히 변경 이력을 남기는 용도로만 사용한다면 굳이 복잡하게 JSON 으로 저장할 필요 없이 그냥 문자열(String)로 저장하는 게 가장 간단하고 깔끔한 방법일 수 있다. 문자열로 저장하면 구현도 간단하고 저장 공간도 덜 차지할 수 있으며, 별도의 직렬화/역직렬화 과정 없이 그대로 로그처럼 남기기 좋기 때문이다. 하지만 시스템이 커지고, 단순히 "이력을 남긴다" 수준을 넘어서서 이력 데이터 자체를 분석하거나 조회하는 경우를 고려하면 상황이 달라진다.

 

예를 들어, 변경된 필드를 기준으로 검색하거나, 특정 필드가 어떻게 변경되어 왔는지 추적하거나, 향후 빅데이터 분석 시스템 (예: ELK, BigQuery, Redshift) 에 연동할 계획이 있다면 구조화된 데이터로 저장하는 것이 유리하다. JSON 은 가볍게 구조화된 데이터를 표현하면서도 유연하게 필드를 추가하거나 변경할 수 있어, 확장성과 활용도가 높다.

 

그래서 처음에는 단순하게 문자열로 저장하는 방향을 고민했지만 장기적으로 히스토리 데이터를 더 유의미하게 활용할 가능성을 고려해서 JSON 형태로 저장하는 방향으로 결정했다. 이렇게 하면 당장은 필요하지 않더라도 나중에 변화 이력 분석, 필드별 통계 수집, 이상 탐지 등 다양한 데이터 활용이 가능해진다.

 

 

 

 

위에서 말한 아쉬운 점들을 리팩토링한 글을 첨부한다.

 

https://bugglebuggle.tistory.com/38

 

[🥕당근베이] history 내역 생성 기능을 리팩토링해보자 😤

https://bugglebuggle.tistory.com/37 💻 history 내역을 남기는 방법은? Trigger, AOP, @EntityListenerhttps://github.com/f-lab-edu/carrotbay GitHub - f-lab-edu/carrotbay: 당근마켓 + 이베이를 결합해 만든 동네 중고 장터 경매 시

bugglebuggle.tistory.com