2025. 1. 2. 10:13ㆍ내일배움캠프/Barter
1. 시작
현재 비동기 처리된 '키워드 알림 처리' 의 수행 속도가 '동일한 키워드를 갖는 회원수' 에 따라 느려지고 있어 최적화가 필요하다고 생각했습니다. 그 이유는 관심 키워드를 등록한 회원들에 대한 알림 정보를 저장과 전달을 완벽하게 동시에 할 수는 없어도 그 차이가 적어야 한다고 생각했기 때문입니다.
현재 프로젝트에는 특히 '나눔' 이라는 교환 서비스가 존재하는데 다른 회원보다 알림 정보를 늦게 받게 될 경우 이미 소진된다면 이 또한 사용자에게 불쾌한 경험이 될 수 있을거라 생각했기 때문입니다.
2. 테스트 내용
이전 게시글을 통해 추가해둔 테스트 정보를 활용할 생각이며, 현재 비동기 처리가 되어있는 TradeNotificationEventListener 클래스의 sendNotificationToMember() 메서드에 Spring 에서 제공하는 StopWatch 를 활용해 해당 메서드의 수행시간을 '동일한 키워드를 갖는 회원수' 별로 측정해볼 생각입니다.
@Slf4j(topic = "TradeNotificationEventListener")
@Component
@EnableAsync
@RequiredArgsConstructor
public class TradeNotificationEventListener {
...
@Async
@EventListener
public void sendNotificationToMember(TradeNotificationEvent event) {
StopWatch stopWatch = new StopWatch();
stopWatch.start(); // 측정 시작
// 메서드 수행부
stopWatch.stop(); // 측정 종료
log.info(stopWatch.prettyPrint()); // 측정 결과 로그 출력
}
}
3. 1차 테스트 결과
100 | 1,000 | 10,000 | 100,000 | |
1차 테스트 | 1.057 s | 10.572 s | 98.336 s | 956.571 s |
현재 상태를 '동일한 키워드를 갖는 회원수' 별로 측정한 결과 비동기 처리 전의 '교환 생성 요청' 에 대한 응답 소요 시간과 비슷한 결과를 확인할 수 있었습니다. 이 시간을 줄여 사용자들의 불쾌한 경험을 최소한으로 할수 있지 않을까 싶습니다.
4. 개선
먼저 하나의 작업을 수행하고 다른 작업을 수행하는 기존의 for 문을 수정할 필요가 있었습니다. 그래서 작업 수행을 벙렬로 처리할 수 있게 '병렬 스트림' 을 사용하게 되었습니다. 병렬 스트림을 적용한 내용은 클래스 별로 아래와 같습니다.
public class TradeNotificationEventListener {
...
@Async
@EventListener
public void sendNotificationToMember(TradeNotificationEvent event) {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
...
List<Long> memberIds = keywordMembers.parallelStream()
.map(keywordMember -> keywordMember.getMember().getId()).toList();
...
stopWatch.stop();
log.info(stopWatch.prettyPrint());
}
}
- sendNotificationToMember() : 수행부 중 '키워드 알림을 받을 회원 ID' 를 선별하는 부분에서 parallelStream() 을 사용해 해당 작업을 병렬로 처리하였습니다.
public class NotificationService implements MessageListener {
...
@Transactional
public void saveKeywordNotification(
EventKind eventKind, List<Long> memberIds, TradeType tradeType, Long tradeId
) {
String completedEventMessage = eventKind.getEventMessage();
List<Notification> keywordNotifications = new ArrayList<>();
Set<PublishMessageDto> keywordMessages = new HashSet<>();
memberIds.parallelStream().forEach(memberId -> {
Notification createdNotification = Notification.createKeywordNotification(
completedEventMessage, tradeType, tradeId, memberId
);
synchronized (keywordNotifications) {
keywordNotifications.add(createdNotification);
}
PublishMessageDto publishMessage = PublishMessageDto.from(
eventKind.getEventName(), SendEventResDto.from(createdNotification)
);
synchronized (keywordMessages) {
keywordMessages.add(publishMessage);
}
});
notificationRepository.bulkInsert(keywordNotifications);
publishAllEvent("keyword", keywordMessages);
}
private void publishEvent(String channel, PublishMessageDto data) {
try {
String jsonData = objectMapper.writeValueAsString(data);
redisTemplate.convertAndSend(channel, jsonData);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
private void publishAllEvent(String channel, Set<PublishMessageDto> data) {
data.parallelStream().forEach(message -> {
try {
String jsonData = objectMapper.writeValueAsString(message);
redisTemplate.convertAndSend(channel, jsonData);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
});
}
...
}
- saveKeywordNotification() : 키워드 알림을 전달할 회원ID 목록(= memberIds)을 통해 키워드 알림을 저장하고 전달하는 부분을 병렬로 처리하여 수행속도를 높였습니다. 단, 병렬 처리 작업이 모두 이루어진 keywordNotifications, keywordMessages 를 사용해야 하므로 synchronized() 를 사용해 병렬처리 작업이 끝난 후, 다음 수행을 하도록 하였습니다.
병렬처리 외에도 속도 개선을 위해서 다량의 INSERT 쿼리를 한 번에 처리할 필요가 있었습니다. 기존에는 DB 에 저장할 때, 알림 정보마다 INSERT 쿼리를 보내고 있었는데 이는 애플리케이션 서버와 DB 사이에 정보를 주고 받는 과정이 전달해야하는 알림 정보 개수만큼 반복되게 됩니다.
이 과정을 줄이게되면 속도 개선에 도움이 될거라 판단하였고 Spring JDBC 를 통해 Bulk Insert 를 아래와 같으 구현하였습니다.
public interface NotificationJdbcRepository {
void bulkInsert(List<Notification> notifications);
}
@Repository
@RequiredArgsConstructor
public class NotificationJdbcRepositoryImpl implements NotificationJdbcRepository {
private final JdbcTemplate jdbcTemplate;
@Override
@Transactional
public void bulkInsert(List<Notification> notifications) {
String sql = "INSERT INTO "
+ "notifications (created_at, updated_at, is_read, member_id, message, notification_type, trade_id, trade_type)"
+ " VALUES (now(), now(), ?, ?, ?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, notifications, notifications.size(),
(PreparedStatement ps, Notification notification) -> {
ps.setBoolean(1, notification.isRead());
ps.setLong(2, notification.getMemberId());
ps.setString(3, notification.getMessage());
ps.setString(4, notification.getNotificationType().name());
ps.setLong(5, notification.getTradeId());
ps.setString(6, notification.getTradeType().name());
});
}
}
// 새로 생성한 NotificationJdbcRepository 상속
public interface NotificationRepository extends JpaRepository<Notification, Long>, FlushNotificationRepository {
...
}
5. 2차 테스트 결과
앞서 말한 (4) 의 내용으로 코드 개선 후 다시 한 번 키워드 알림 처리 속도를 측정하였습니다.
100 | 1,000 | 10,000 | 100,000 | |
1차 테스트 | 1.057 s | 10.572 s | 98.336 s | 956.571 s |
2차 테스트 | 0.018 s | 0.087 s | 0.803 s | 8.209 s |
알림 정보 저장 및 전달에 대한 작업을 병렬로 수행하고 DB 에 저장할 알림 정보를 한 번에 전달하기 1차 테스트 때와는 다르게 확연하게 속도가 빨라진 것을 확인할 수 있었습니다.
'내일배움캠프 > Barter' 카테고리의 다른 글
[니꺼, 내꺼] 키워드 알림 - 성능 테스트 (1) (0) | 2024.12.30 |
---|---|
[니꺼, 내꺼] 4주차 - 알림 개선에 Redis pub/sub 를 선택한 이유 (0) | 2024.12.26 |
[니꺼, 내꺼] 3주차 - 왜, 추가 API 가 필요한가? (0) | 2024.12.20 |
[니꺼, 내꺼] 2주차 - 문제 해결 과정 설명 (0) | 2024.12.13 |
[니꺼, 내꺼] 2주차 - 의사결정 과정 설명 (0) | 2024.12.13 |