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

2024. 12. 30. 14:01내일배움캠프/Barter

1. 시작

 현재 '키워드 알림 서비스' 의 경우 회원의 관심 키워드를 포함한 등록 물품명을 가진 등록물품으로 교환을 생성하게 되면 해당 키워드를 '관심 키워드' 로 등록한 모든 회원들에게 이벤트 정보를 전달하도록 구현되어 있습니다.

 

하지만 '동일한 키워드를 갖는 회원의 수' 가 많으면 많아질 수록 '교환 생성 요청' 의 수행시간이 길어져 서비스 사용자는 요청에 대한 응답을 받기까지 오랜 시간을 기다려야 합니다. 이는 사용자가 느끼기에 불쾌한 경험으로 이어질 수 있기에 사용자의 교환 생성 요청에 대한 응답을 빠르게 전달할 수 있도록 할 필요가 있습니다.

 

 

2. 테스트 내용

 테스트용 회원 정보와 키워드 정보, 회원별 관심 키워드 정보를 생성해 교환 생성 요청후 응답을 받기까지의 시간을 확인하고자 합니다. '동일한 키워드 정보를 갖는 회원 수' 가 각각 백명, 천명, 만명, 10만명 일때, 응답 소요시간을 확인할 것 입니다. 응답 소요시간은 Postman 에 출력되는 'Response Time' 정보를 활용할 생각입니다.

 

 

3. 테스트 데이터

 테스트 데이터의 경우 MySQL CLC(Command-Line-Client) 을 사용해 생성하였습니다. 아래는 테스트 데이터를 생성하기위 사용한 SQL 문입니다.

// members 테이블에 테스트 회원 생성
DELIMITER $$

CREATE PROCEDURE [프로시저 네임]
BEGIN
DECLARE i INT DEFAULT [반복 시작 값];
WHILE i <= [반복 끝 값] DO
INSERT INTO members (created_at, updated_at, email, nickanme)
VALUES (now(), now(), CONCAT('test', i, '@gmail.com'), CONCAT('테스터', i));
SET i = i + 1;
END WHILE;
END $$

DELIMITER $$
CALL [프로시저 네임];
$$
// 관심 키워드 생성
INSERT INTO favorite_keywords (keyword) VALUE ('테스트');

// 회원 관심 키워드 생성
DELIMITER $$
CREATE PROCEDURE [프로시저 네임]
BEGIN
DECLARE i INT DEFAULT [시작 회원 ID];
WHILE i <= [끝 회원 ID] DO
INSERT INTO member_favorite_keywords (favorite_keyword_id, member_id)
VALUES (1, i);
SET i = i + 1;
END WHILE;
END $$

DELIMITER $$
CALL [프로시저 네임];
$$

 

현재 테스트 목적은 "동일한 키워드를 갖는 회원 수에 따른 수행 속도" 를 알아보기 위함으로 하나의 관심 키워드를 생성해 테스트 회원별로 생성한 관심 키워드를 갖도록 테스트 데이터를 생성하게 되었습니다.

 

 

4. 1차 테스트 결과

  100 1,000 10,000 100,000
Postman 491 ms 2.38 s 19.98 s 3 m 20.40 s

 

 동일한 키워드를 갖는 회원의 수가 100명일 때는 괜찮았지만 1,000명 → 10,000명 → 100,000명 늘어갈 수록 기능을 수행하는데 걸린 시간은 거의 10배씩 늘어났습니다. 테스트 전에는 "그래도 만명까지는 빠르게 처리하지 않을까?" 라는 생각을 가지고 있었지만 애석하게도(?) 당장 천명부터 속도가 현저히 느려진 것을 확인할 수 있었습니다.

 

위 결과를 통해 현재 상태로를 사용자는 교환을 생성할 때 만약 생성하려고 하는 교환에 사용된 등록 물품명을 관심 키워드로 갖는 회원이 천명에 가깝다면 불쾌한 경험을 할 것으로 예상됩니다.

 

 

5. 개선

 현재 프로젝트의 교환 생성 기능은 메서드 수행부의 모든 동작을 완료해야 사용자에게 요청에 따른 응답을 전달하고 있습니다. 그렇다보니 '동일한 키워드를 갖는 회원 수' 가 많으면 많을 수록 수행해야 할 동작이 많아 사용자에게 응답을 전달하기까지 오래 걸리는 것이라 판단했습니다.

 

그래서 주 목적(교환 생성)이 아닌 부 목적(키워드 알림)의 경우 다른 쓰레드가 수행하도록 아래와 같이 NotificationService 클래스의 saveKeywordNotification() 을 수정하였습니다.

@Slf4j
@Service
@EnableAsync  // Spring의 비동기 처리를 활성화하는 어노테이션
@RequiredArgsConstructor
public class NotificationService implements MessageListener {

	...

	@Async  // 특정 메서드가 비동기적으로 실행되도록 지정하는 어노테이션
	public void saveKeywordNotification(
		EventKind eventKind, List<Long> memberIds, TradeType tradeType, Long tradeId
	) {
		String completedEventMessage = eventKind.getEventMessage();

		for (Long memberId : memberIds) {
			Notification createdNotification = Notification.createKeywordNotification(
				completedEventMessage, tradeType, tradeId, memberId
			);
			Notification savedNotification = notificationRepository.save(createdNotification);

			PublishMessageDto publishMessage = PublishMessageDto.from(
				eventKind.getEventName(), SendEventResDto.from(savedNotification)
			);
			publishEvent("keyword", publishMessage);
		}
	}

	...
}

 

또한 테스트를 진행하다 보니 MemberFavoriteKeyword 엔티티 조회시 다수의 Members 테이블을 조회하는 쿼리가 발생했는데 이는 MemberFavoriteKeyword 클래스의 member 필드에 '즉시 로딩' 이 적용되어있기 때문이었습니다. 그래서 아래와 같이 '지연 로딩' 을 적용해 주어 발생하는 쿼리들을 해결할 수 있었습니다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "MEMBER_FAVORITE_KEYWORDS")
public class MemberFavoriteKeyword {

	...
	@ManyToOne(fetch = FetchType.LAZY)
	private Member member;
	@ManyToOne(fetch = FetchType.LAZY)
	private FavoriteKeyword favoriteKeyword;

	...
}

 

 

6. 2차 테스트 결과

 앞서 말한 (5) 의 내용으로 코드 개선 후 다시 한 번 교환 생성 요청시 응답을 받기까지의 소요시간을 확인하였습니다. '1차 테스트 결과' 는 '동기' 에 해당하고 '2차 테스트 결과' 는 '비동기' 에 해당합니다. 

  100 1,000 10,000 100,000
동기 491 ms 2.38 s 19.98 s 3 m 20.40 s
비동기 19 ms 38 ms 91 ms 982 ms

 

발생한 알림 정보를 처리하는 메서드를 비동기로 처리하니 확실히 교환 생성 요청의 응답을 받기까지 소요되는 시간이 확실이 줄어들었습니다. 하지만 알림 처리에 대한 부분을 따로 분리했다고 생각했으나 여전히 백명, 10만명일 때의 소요시간 차이가 꽤나 나는 것을 확인 할 수 있었습니다.

 

예상대로라면 '알림' 에 대한 부분을 비동기로 처리해야 했기 때문에 주 목적인 '교환 생성' 에 대한 부분은 차이가 있더라도 정말 근소한 차이가 있어야 하기 때문입니다.

 

 

7. 원인 파악

 생각과 다른 테스트 결과로 인해 왜 예상대로 테스트 결과가 도출되지 않는지 원인을 파악할 필요가 있었습니다. 확인 결과 교환 생성시 Spring Event 를 활용해 해당 요청 수행을 인식하고 수행되는 TradeNotificationEventListener 클래스의 sendNotificationToMember() 가 있었는데 해당 메서드의 수행부에 생성된 교환의 등록 물품명을 키워드로 분류하고 해당 키워드를 관심 키워드로 등록한 모든 회원을 조회하는 로직이 있었기 때문에 '동일한 키워드를 갖는 회원 수' 가 증가함에 따라 응답 소요 시간이 달라진다는 것을 알 수 있었습니다.

@Slf4j(topic = "TradeNotificationEventListener")
@Component
@RequiredArgsConstructor
public class TradeNotificationEventListener {

	...

	@EventListener
	public void sendNotificationToMember(TradeNotificationEvent event) {
    
    	// 2차 테스트에서 '동일한 키워드를 갖는 회원수' 에 따라 교환 생성 응답시간이 다른 이유
		List<String> keywords = KeywordHelper.extractKeywords(event.getProductName());
		List<FavoriteKeyword> existsKeywords = favoriteKeywordRepository.findByKeywordIn(keywords);
		List<MemberFavoriteKeyword> keywordMembers = memberFavoriteKeywordRepository
			.findByFavoriteKeywordIn(existsKeywords);

		...
	}
}

 

그래서 비동기 처리를 위한 @Async 어노테이션의 지정 위치를 아래와 같이 옮겨 교환 생성 요청 수행과 이벤트 처리 수행을 분리하였습니다.

@Slf4j(topic = "TradeNotificationEventListener")
@Component
@EnableAsync  // Spring의 비동기 처리를 활성화하는 어노테이션
@RequiredArgsConstructor
public class TradeNotificationEventListener {

	private final FavoriteKeywordRepository favoriteKeywordRepository;
	private final MemberFavoriteKeywordRepository memberFavoriteKeywordRepository;
	private final NotificationService notificationService;

	@Async  // 특정 메서드가 비동기적으로 실행되도록 지정하는 어노테이션
	@EventListener
	public void sendNotificationToMember(TradeNotificationEvent event) {
		List<String> keywords = KeywordHelper.extractKeywords(event.getProductName());
		List<FavoriteKeyword> existsKeywords = favoriteKeywordRepository.findByKeywordIn(keywords);
		List<MemberFavoriteKeyword> keywordMembers = memberFavoriteKeywordRepository
			.findByFavoriteKeywordIn(existsKeywords);

		List<Long> memberIds = keywordMembers.stream()
			.map(keywordMember -> keywordMember.getMember().getId()).toList();
		notificationService.saveKeywordNotification(
			EventKind.KEYWORD, memberIds, event.getType(), event.getTradeId()
		);
	}
}

 

위와 같이 비동기 처리 지점을 변경하였기에 2차 테스트 전 개선 사항에 적용한 부분은 이전 상태로 되돌렸습니다. (비동기 처리 적용 취소)

 

 

8. 3차 테스트 결과

 앞서 말한 (7) 의 내용으로 코드 개선 후 다시 한 번 교환 생성 요청시 응답을 받기까지의 소요시간을 확인했습니다.

  100 1,000 10,000 100,000
1차 테스트 491 ms 2.38 s 19.98 s 3 m 20.40 s
2차 테스트 19 ms 38 ms 91 ms 982 ms
3차 테스트 13 ms 18 ms 15 ms 11 ms

 

테스트 결과 사용자는 교환 생성 요청을 하게된 경우 '동일한 키워드를 갖는 회원수' 에 상관 없이 빠르게 응답을 받을 수 있게 된 것을 확인할 수 있었습니다. 2차 테스트 전 개선한 것처럼 알림 정보를 처리하는 쪽에 비동기 처리를 적용할 것이 아니라 이벤트 리스너쪽에 비동기 처리를 적용하는 것이 사용자가 느끼기에 더 좋은 개선이었다는 것을 확인할 수 있었습니다.

 

 

9. 마치며

 현재 사용자의 교환 생성 요청에 대한 응답 소요 시간을 최적화 하기위해 비동기 처리를 적용하였지만 여전히 문제는 남아있습니다. 바로 비동기 처리된 알림 정보의 저장(RDB, MySQL)과 실시간 전달에 대한 부분인데 해당 기능 수행의 최적화에 대한 부분입니다.