1. 문제 정의
관심 물품으로 설정한 물품의 가격이 변동되었을 때, AOP로 알림을 보내려고 함.
하지만 service에서는 제대로 oldPrice와 newPrice가 인식되지만 AOP에서는 oldPrice가 newPrice의 값과 일치하게 인식되는 문제 발생.
@AfterReturning(value = "execution(* com.sprarta.sproutmarket.domain.item.service.ItemService.updateContents(..))", returning = "itemResponse", argNames = "joinPoint,itemResponse")
public void notifyUsersAboutPriceChange(JoinPoint joinPoint, Object itemResponse) {
Object[] args = joinPoint.getArgs();
Long itemId = (Long) args[0]; // itemId 추출
ItemContentsUpdateRequest itemContentsUpdateRequest = (ItemContentsUpdateRequest) args[1]; // 요청 객체 추출
// DB 에서 아이템 조회
Item item = itemRepository.findByIdOrElseThrow(itemId);
int oldPrice = item.getPrice();
int newPrice = itemContentsUpdateRequest.getPrice();
// 가격이 변경되었는지 확인
log.info("Item ID: {}, Old Price: {}, New Price: {}", itemId, oldPrice, newPrice);
if (oldPrice != newPrice) {
log.info("Price has changed for Item ID: {}. Notifying interested users...", itemId);
List<User> interestedUsers = interestedItemService.findUsersByInterestedItem(itemId);
// 관심 상품 사용자들에게 알림 전송
for (User user : interestedUsers) {
String notificationMessage = "관심 상품의 가격이 변경되었습니다. 새로운 가격: " + newPrice;
log.info("Sending notification to User ID: {} with message: {}", user.getId(), notificationMessage); // 전송할 메시지 로그
sendNotification(user.getId(), notificationMessage);
}
log.info("Notifications sent to {} users about the price change for Item ID: {}", interestedUsers.size(), itemId);
} else {
log.info("No price change for Item ID: {}. No notifications sent.", itemId);
}
}
Hibernate:
/* SELECT
u
FROM
User u
WHERE
u.email = :email */ select
u1_0.id,
u1_0.address,
u1_0.created_at,
u1_0.email,
u1_0.modified_at,
u1_0.nickname,
u1_0.password,
u1_0.phone_number,
u1_0.profile_image_url,
u1_0.rate,
u1_0.status,
u1_0.user_role,
u1_0.username
from
users u1_0
where
u1_0.email=?
Hibernate:
/* SELECT
i
FROM
Item i
WHERE
i.id = :id
AND i.seller.id = :userId */ select
i1_0.id,
i1_0.category_id,
i1_0.created_at,
i1_0.description,
i1_0.item_sale_status,
i1_0.modified_at,
i1_0.price,
i1_0.user_id,
i1_0.status,
i1_0.title
from
items i1_0
where
i1_0.id=?
and i1_0.user_id=?
2024-11-07T00:18:54.182+09:00 INFO 9896 --- [sprout-market] [nio-8080-exec-3] c.s.s.domain.item.service.ItemService : Updated Item ID: 8, New Price: 2001
Hibernate:
select
u1_0.id,
u1_0.address,
u1_0.created_at,
u1_0.email,
u1_0.modified_at,
u1_0.nickname,
u1_0.password,
u1_0.phone_number,
u1_0.profile_image_url,
u1_0.rate,
u1_0.status,
u1_0.user_role,
u1_0.username
from
users u1_0
where
u1_0.id=?
2024-11-07T00:18:54.200+09:00 INFO 9896 --- [sprout-market] [nio-8080-exec-3] c.s.s.d.n.aspect.NotificationAspect : Item ID: 8, Old Price: 2001, New Price: 2001
2024-11-07T00:18:54.200+09:00 INFO 9896 --- [sprout-market] [nio-8080-exec-3] c.s.s.d.n.aspect.NotificationAspect : No price change for Item ID: 8. No notifications sent.
Hibernate:
/* update
for com.sprarta.sproutmarket.domain.item.entity.Item */update items
set
category_id=?,
description=?,
item_sale_status=?,
modified_at=?,
price=?,
user_id=?,
status=?,
title=?
where
id=?
2024-11-07T00:19:09.120+09:00 INFO 9896 --- [sprout-market] [MessageBroker-1] o.s.w.s.c.WebSocketMessageBrokerStats : WebSocketSession[0 current WS(0)-HttpStream(0)-HttpPoll(0), 0 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(0)-CONNECTED(0)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], sockJsScheduler[pool size = 1, active threads = 1, queued tasks = 0, completed tasks = 0]
결국 변화가 없다고 감지하여 알림이 가지 않는 상황 발생.
2. 사실 수집
Hibernate:
/* SELECT
u
FROM
User u
WHERE
u.email = :email */ select
u1_0.id,
u1_0.address,
u1_0.created_at,
u1_0.email,
u1_0.modified_at,
u1_0.nickname,
u1_0.password,
u1_0.phone_number,
u1_0.profile_image_url,
u1_0.rate,
u1_0.status,
u1_0.user_role,
u1_0.username
from
users u1_0
where
u1_0.email=?
Hibernate:
/* SELECT
i
FROM
Item i
WHERE
i.id = :id
AND i.seller.id = :userId */ select
i1_0.id,
i1_0.category_id,
i1_0.created_at,
i1_0.description,
i1_0.item_sale_status,
i1_0.modified_at,
i1_0.price,
i1_0.user_id,
i1_0.status,
i1_0.title
from
items i1_0
where
i1_0.id=?
and i1_0.user_id=?
Hibernate:
/* update
for com.sprarta.sproutmarket.domain.item.entity.Item */update items
set
category_id=?,
description=?,
item_sale_status=?,
modified_at=?,
price=?,
user_id=?,
status=?,
title=?
where
id=?
Hibernate:
select
u1_0.id,
u1_0.address,
u1_0.created_at,
u1_0.email,
u1_0.modified_at,
u1_0.nickname,
u1_0.password,
u1_0.phone_number,
u1_0.profile_image_url,
u1_0.rate,
u1_0.status,
u1_0.user_role,
u1_0.username
from
users u1_0
where
u1_0.id=?
이 쿼리를 볼 때, 사용자를 조회, 아이템 조회, 업데이트, 사용자 조회까지는 되는데
그 뒤에 가격 변동 후 알림 발송 로직, 이벤트 발생, 알림 전송이 제대로 되지 않고 있음.
3. 원인 추론
가격이 업데이트된 후 Item 객체가 다시 로드되지 않기 때문이라고 판단. @Transactional이 적용된 메서드에서 가격 변경이 발생하고, 그 후에 Aspect에서 가격을 가져올 때는 이미 트랜잭션이 커밋되지 않아 oldPrice가 올바르게 업데이트되지 않는 상황.
4. 조치 및 방안 검토
@Transactional 메서드 내에서 가격 변경 후 flush()를 호출하여 즉시 데이터베이스에 반영한 후,
가격 변경 이벤트를 발행하여 관심 있는 사용자에게 알림을 전송합니다.
트랜잭션 커밋 후에 알림 전송 로직이 실행되므로, 변경된 가격을 올바르게 인식하고 알림을 보낼 수 있게 됨.
1. updateContents
@Transactional
public ItemResponse updateContents(Long itemId, ItemContentsUpdateRequest itemContentsUpdateRequest, CustomUserDetails authUser) {
Item item = itemRepository.findByIdAndSellerIdOrElseThrow(itemId, User.fromAuthUser(authUser).getId());
int oldPrice = item.getPrice(); // 이전 가격
item.changeContents(
itemContentsUpdateRequest.getTitle(),
itemContentsUpdateRequest.getDescription(),
itemContentsUpdateRequest.getPrice()
);
// flush()를 호출하여 변경 사항을 데이터베이스에 반영
entityManager.flush();
int newPrice = item.getPrice(); // 이제 변경된 가격을 가져옴
// 로그 추가: 가격 변경 감지
log.info("Updated Item ID: {}, Old Price: {}, New Price: {}", itemId, oldPrice, newPrice);
// 가격이 변경된 경우 이벤트 발행
if (oldPrice != newPrice) {
eventPublisher.publishEvent(new PriceChangeEvent(itemId, newPrice));
}
return ItemResponse.builder()
.title(item.getTitle())
.price(item.getPrice())
.description(item.getDescription())
.nickname(item.getSeller().getNickname())
.build();
}
가격 가져오기: oldPrice는 item.getPrice()를 통해 기존 가격을 가져오고
가격 변경: changeContents 메서드 호출로 가격을 변경.
flush() 호출: entityManager.flush()를 사용하여 변경 사항을 데이터베이스에 즉시 반영. 이렇게 하면 Aspect에서 oldPrice와 newPrice를 올바르게 비교할 수 있게 됨.
이벤트 발행: 가격이 변경된 경우, 가격 변경 이벤트를 발행하여 관심 있는 사용자에게 알림을 보낼 준비.
2. PriceChangeEvent
@Getter
public class PriceChangeEvent {
private final Long itemId;
private final int newPrice;
public PriceChangeEvent(Long itemId, int newPrice) {
this.itemId = itemId;
this.newPrice = newPrice;
}
}
이벤트 클래스: 가격 변경 이벤트를 나타내는 간단한 DTO. 가격이 변경된 아이템의 ID와 새 가격을 담고 있고,
이건 DB와 관련 없는 DTO이기 때문에 @Entity 등의 어노테이션을 붙이고 연관관계를 만들 필요가 없음.
3. handlePriceChangeEvent
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePriceChangeEvent(PriceChangeEvent event) {
Long itemId = event.getItemId();
int newPrice = event.getNewPrice();
List<User> interestedUsers = interestedItemService.findUsersByInterestedItem(itemId);
for (User user : interestedUsers) {
sendNotification(user.getId(), "관심 상품의 가격이 변경되었습니다. 새로운 가격: " + newPrice);
}
}
이벤트 리스너: 가격 변경 이벤트를 처리. @TransactionalEventListener 어노테이션을 사용하여 트랜잭션 커밋 후에 이 메서드가 실행되도록 설정. 이로 인해 가격 변경이 확실히 데이터베이스에 반영된 후에 알림을 전송.
알림 전송: 관심 있는 사용자 목록을 가져오고, 각 사용자에게 가격 변경 알림을 전송.
5. 결과 관찰
flush를 적용 시키고 @TransactionalEventListener 어노테이션을 사용하니,
Hibernate:
/* SELECT
u
FROM
User u
WHERE
u.email = :email */ select
u1_0.id,
u1_0.address,
u1_0.created_at,
u1_0.email,
u1_0.modified_at,
u1_0.nickname,
u1_0.password,
u1_0.phone_number,
u1_0.profile_image_url,
u1_0.rate,
u1_0.status,
u1_0.user_role,
u1_0.username
from
users u1_0
where
u1_0.email=?
Hibernate:
/* SELECT
i
FROM
Item i
WHERE
i.id = :id
AND i.seller.id = :userId */ select
i1_0.id,
i1_0.category_id,
i1_0.created_at,
i1_0.description,
i1_0.item_sale_status,
i1_0.modified_at,
i1_0.price,
i1_0.user_id,
i1_0.status,
i1_0.title
from
items i1_0
where
i1_0.id=?
and i1_0.user_id=?
Hibernate:
/* update
for com.sprarta.sproutmarket.domain.item.entity.Item */update items
set
category_id=?,
description=?,
item_sale_status=?,
modified_at=?,
price=?,
user_id=?,
status=?,
title=?
where
id=?
2024-11-07T12:57:41.016+09:00 INFO 29484 --- [sprout-market] [nio-8080-exec-6] c.s.s.domain.item.service.ItemService : Updated Item ID: 8, Old Price: 2008, New Price: 2009
Hibernate:
select
u1_0.id,
u1_0.address,
u1_0.created_at,
u1_0.email,
u1_0.modified_at,
u1_0.nickname,
u1_0.password,
u1_0.phone_number,
u1_0.profile_image_url,
u1_0.rate,
u1_0.status,
u1_0.user_role,
u1_0.username
from
users u1_0
where
u1_0.id=?
Hibernate:
/* SELECT
ii
FROM
InterestedItem ii
WHERE
ii.item.id = :itemId */ select
ii1_0.id,
ii1_0.item_id,
ii1_0.user_id
from
interested_item ii1_0
where
ii1_0.item_id=?
2024-11-07T12:57:41.027+09:00 INFO 29484 --- [sprout-market] [nio-8080-exec-6] c.s.s.d.i.service.InterestedItemService : Found 1 interested users for item 8
제대로 적용이 되는 모습!
+ 부가적으로 flush를 사용한 이유
1. 트랜잭션의 지연 로딩
- JPA에서는 기본적으로 모든 변경 사항을 지연 로딩. 즉, 실제로 데이터베이스에 반영되기 전에 변경 사항이 메모리 내에서 관리하게 됨.
- flush()를 호출하지 않으면, 트랜잭션이 커밋될 때까지 데이터베이스에 아무런 변경 사항이 반영되지 않음. 이로 인해 트랜잭션 내에서 값이 변경된 이후에 다른 메서드나 Aspect에서 이 값을 참조할 경우, 이전 값이 그대로 유지될 수 있게 됨.
2. 데이터의 일관성
- flush()를 사용하여 데이터베이스와 애플리케이션의 상태를 일관되게 유지할 수 있게 됨. 예를 들어, 가격이 변경된 후 해당 가격을 기준으로 후속 작업을 수행해야 할 때, flush()를 통해 변경된 가격을 즉시 데이터베이스에 반영함으로써, 이후 로직에서 최신 상태의 데이터를 참조할 수 있음.
3. 이벤트 발생 시점 조정
- 이벤트를 발행하기 전에 flush()를 호출하면, 이벤트 핸들러가 이 변경 사항을 기반으로 올바르게 동작하게 됨. 즉, 가격이 변경된 후 관련된 이벤트를 발행할 때, flush()를 통해 데이터베이스에 반영된 가격을 기반으로 이벤트를 생성할 수 있음.
4. 성능
- flush()는 성능에 영향을 줄 수 있지만, 데이터의 일관성과 정확성을 보장하기 위해 필요할 때 사용됨. 일반적으로 필요한 상황에서만 호출하여 성능 저하를 최소화해야 함.
'개발일지' 카테고리의 다른 글
[기술적 의사결정] RabbitMQ vs. Kafka? | S3 이미지 업로드 및 URL 저장 RabbitMQ 도입 (0) | 2024.11.08 |
---|---|
[개발일지] KPT 회고 | 잘하고10조의 뉴스피드 프로젝트 제작 회고 (4) | 2024.09.09 |
[개발일지] 깃 리드미 꾸미기 | 깃 리드미 작성법 | 깃 리드미 이모티콘 삽입 | 깃 리드미 마크다운 문법 (2) | 2024.09.06 |
[개발일지] Spring Boot 프로젝트 | 할일 관리 프로젝트 | 3 Layer Architecture 구현 (0) | 2024.08.23 |
[개발일지] 자바 첫 프로젝트를 마무리하며 (0) | 2024.08.12 |