https://bugglebuggle.tistory.com/37
💻 history 내역을 남기는 방법은? Trigger, AOP, @EntityListener
https://github.com/f-lab-edu/carrotbay GitHub - f-lab-edu/carrotbay: 당근마켓 + 이베이를 결합해 만든 동네 중고 장터 경매 시스템입니다.당근마켓 + 이베이를 결합해 만든 동네 중고 장터 경매 시스템입니다. Co
bugglebuggle.tistory.com
이전 기존의 history 내역 기능을 어떻게 구현했는지에 대해 작성했다.(위의 링크 참고) history내역 기능을 구현하면서 아쉬운 부분들이 있었다.(아래의 캡쳐 참고 ) 이 부분들을 포함해서 추가로 코드 리뷰를 통해 지적 받은 부분들을 리팩토링해보겠다. ( 코드 리뷰 전체 글 )
네이밍 변경, 다양한 url 에서 id값을 추출 할 수 있도록 로직 변경, null 처리 등의 자잘한 리팩토링을 내버려두고 크게 리팩토링을 한 부분을 정리해보겠다.
1. PutMapping에 AOP 적용보다는 실제 업데이트가 이루어지는 update메서드에 AOP를 적용
기존에는 PutMapping에 AOP를 적용하고 있었다. 이럴 경우 PutMapping 결과, 아무런 변경이 생기지 않을 수가 있으니 http 요청을 포인트컷으로 하기보다 직접적인 업데이트가 발생하는 update 메소드로 하는 것이 더 좋겠다는 코드 리뷰를 받았고 이에 따라 메서드에 AOP를 적용하기위해 리팩토링했다.
다만 여기서 update메서드에 포인트 컷을 설정하기보다 새로운 어노테이션을 정의하고, 해당 어노테이션이 적용된 메소드에 AOP가 작동하도록 로직을 수정했다. 이유는 다음과 같다.
1. 메서드 이름을 기준으로 포인트컷을 설정할 경우, 정해진 네이밍 규칙(코딩 컨벤션)을 지키지 않으면 AOP가 정상적으로 작동하지 않아 휴먼 에러가 발생할 가능성이 있다.
2. 커스텀 어노테이션을 정의함으로써 좀 더 코드상에서 명확하게 히스토리 내역을 남기는 기능들을 확인 할 수 있어 코드가 좀 더 명확해지고 가독성이 올라간다.
// 어노테이션 생성
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogUpdateHistory {
}
// AOP 포인트 컷 설정
@Around("@annotation(com.carrotbay.common.handler.annotation.LogUpdateHistory)")
public Object logUpdate(ProceedingJoinPoint joinPoint) throws Throwable {
// 실제 사용
@Transactional
@LogUpdateHistory
public AuctionResponseDto.ModifyResponseDto modifyAuction(Long userId, Long auctionId,
AuctionRequestDto.ModifyAuctionDto modifyDto) {
🥲 기존 문제점
1. PutMapping에 AOP를 적용하여 PutMapping 결과, 아무런 변경이 생기지 않아도 history 내역이 생성된다.
🥳 개선된 점
1. 실제로 update가 발생하는 메서드에 AOP를 적용하여 아무런 변경이 생기지 않아도 history 내역이 생성되는 경우를 방지할 수 있다.
2. 커스텀 어노테이션을 정의함으로써 좀 더 코드상에서 명확하게 히스토리 내역을 남기는 기능들을 확인 할 수 있어 코드가 좀 더 명확해지고 가독성이 올라간다.
2.convertEntityToMap() 에서의 리플랙션 사용
기존의 코드의 경우에는 연관관계를 맺은 엔티티의 경우 id값만 저장하고 싶다는 요구조건을 만족하기위해서 Entity -> json으로 바로 변환이 어려웠다. 그래서 리플렉션을 사용했으나 이부분이 마음에 걸렸다. 코드 리뷰를 통해 멘토님께 의견을 구한 결과 entity를 json으로 변경하고 거기서 데이터 후처리를 통해 연관관계를 맺은 엔티티를 찾은 후 id값만 남기도록 하는 방식으로 변경하기로 했다. 리팩토링한 코드는 다음과 같다.
먼저, JSON을 후처리하는 코드는 HistoryLoggingAdvice의 책임이 아니라고 판단하여, 해당 로직은 별도의 유틸 클래스로 분리하였다.
public class JsonUtil {
public static void getRelatedEntitiesToId(JsonNode jsonNode) {
if (jsonNode.isObject()) {
jsonNode.fieldNames().forEachRemaining(field -> {
JsonNode value = jsonNode.get(field);
if (value.isObject()) {
JsonNode idNode = value.get("id");
if (idNode != null) {
((ObjectNode)value).removeAll();
((ObjectNode)value).set("id", idNode);
}
getRelatedEntitiesToId(value);
}
});
}
}
}
해당 util을 호출하여 HistoryLoggingAdvice에서 찾은 entity를 json으로 변경해주었다.
public String getJsonEntityById(JpaRepository<?, Long> repository, Long id) {
String result = repository.findById(id)
.map(this::convertEntityToJson)
.orElse(null);
if (result == null) {
log.error("getJsonEntityById : 엔티티를 JSON으로 변환하는 중 오류가 발생했습니다.");
}
return result;
}
public String convertEntityToJson(Object entity) {
String result = null;
try {
String json = objectMapper.writeValueAsString(entity);
JsonNode rootNode = objectMapper.readTree(json);
JsonUtil.getRelatedEntitiesToId(rootNode);
result = objectMapper.writeValueAsString(rootNode);
} catch (JsonProcessingException e) {
log.error("convertEntityToJson : 엔티티를 JSON으로 변환하는 중 오류가 발생했습니다.");
}
return result;
}
리팩토링한 코드와 기존의 코드를 비교해보자. 밑은 기존의 코드다.
// 엔티티를 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;
}
🥲 기존 문제점
기존 코드는 리플렉션을 이용해 엔티티의 필드 값을 직접 꺼내 Map으로 변환하는 방식였다. 이 방식은 다음과 같은 문제점이 있었다.
1. 리플렉션 사용으로 인한 성능 저하 이슈가 존재했다. 리플렉션은 런타임에 클래스 메타데이터를 탐색하므로 일반적인 메서드 호출보다 느리다. 대량의 엔티티를 처리할 경우 성능 이슈가 발생할 수 있다.
2. 복잡한 로직으로 인해 가독성 낮았다.
3. 책임 분리가 명확하지 않았다. JSON 직렬화와 연관관계 처리 로직이 한 메서드에 뒤섞여 있어, 관심사의 분리가 되지 않았다.
🥳 개선된 점
1. ObjectMapper를 사용하여 JSON으로 변환함으로써 표준화된 방식으로 직렬화를 수행하고, Jackson의 최적화된 내부 처리 덕분에 리플렉션보다 성능 부담이 적어졌다.
2. 후처리 로직의 분리하여 관심사 분리했다. 연관 엔티티를 ID만 남기도록 가공하는 로직은 JsonUtil로 분리되어 HistoryLoggingAdvice의 책임이 명확해졌다. 유지보수나 재사용 측면에서 훨씬 유리하다.
3. 코드가 훨씬 간결해져 가독성과 유지보수성 향상되었다.
+
전체 코드는 git에서 확인할 수 있다.
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
'당근베이' 카테고리의 다른 글
🥕[당근베이]💻 history 내역을 남기는 방법은? Trigger, AOP, @EntityListener (0) | 2025.04.30 |
---|---|
🥕[당근베이] 락을 건 자원은 하나인데 왜 데드락이 걸리지? 🧐 경매 기능에서 동시에 입찰이 발생할때 어떻게 해결해야할까? (0) | 2025.03.25 |