[니꺼, 내꺼] 키워드 알림 - 성능 테스트 (2)

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차 테스트 때와는 다르게 확연하게 속도가 빨라진 것을 확인할 수 있었습니다.