Situation : 반복문을 타는 탈퇴로직
현재 프로젝트 오디에는 회원이 탈퇴할 경우, 다음 로직을 수행합니다.
1) 발송 예정인 알림(notification)을 취소합니다 (PENDING -> DISMISSED)
2) 회원이 참여한 약속에 대하여 deviceToken을 대상으로 알림 발송을 위한 fcmTopic 구독을 해제합니다.
3) 약속참여원의 도착 예정정보 객체(eta)를 삭제합니다
4) 회원이 참여한 약속에 대해 참여자 객체(mate)를 모두 삭제합니다.
그러나, 코드를 살펴보다 눈에 띄는 부분이 있었으니 바로 이 모든 삭제로직을 '반복문'을 통해 삭제하고 있었다는 것입니다.
@Transactional
public void deleteAllByMember(Member member) {
mateRepository.findFetchedAllByMemberId(member.getId())
.forEach(this::withdraw);
}
@Transactional
public void withdraw(Mate mate) {
MeetingLog deletionLog = new MeetingLog(mate, MeetingLogType.MEMBER_DELETION_LOG);
meetingLogService.save(deletionLog);
delete(mate);
}
private void delete(Mate mate) {
notificationService.updateAllStatusToDismissByMateIdAndSendAtAfterNow(mate.getId());
notificationService.unSubscribeTopic(mate.getMeeting(), mate.getMember().getDeviceToken());
etaService.deleteByMateId(mate.getId());
mateRepository.deleteById(mate.getId());
etaSchedulingService.deleteCache(mate);
}
본 글에서는 비효율적인 반복문 쿼리를 in절을 통해 개선한 과정을 서술했습니다.
Task : 반복문을 활용한 비효율적인 쿼리를 최적화하라
이에 재밌는 상황을 가정해보았습니다. 현재 오디 서비스에 있는 회원 81명이 동시에 탈퇴하면 몇 초가 걸릴까?
prod 환경과 비슷한 DB를 마련하기 위해 회원 1명 당 평균 약속 참여수를 통계내어 보았습니다.
select avg(c.people)
from (select member_id, count(*) as people
from mate
group by member_id) as c;

평균적으로 회원 1명당 3.1개의 약속에 참여하였습니다. 이를 반영하여 다음과 같은 DB 환경을 마련하였습니다.
회원(member) : 81명
약속(meeting) : 243개
약속 참여자(mate) : 243명 (회원 당 3개의 약속 참여)
알림(notification) : 회원 당 입장 알림 1개, 약속 출발 알림 1개
약속 참여자 도착 정보(eta) : 약속 참여자(mate) 당 1개
Postman을 통해 81명의 회원이 3개의 약속에서 동시에 탈퇴한 결과, 평균 51ms가 걸리는 것을 알 수 있었습니다.

Action : in절을 통해 쿼리 수 아끼기
개선1) 발송 예정 알림 한번에 취소하기
기존의 알림 취소 로직은 한명씩 회원이 참여한 약속의 참여자 객체(mate)를 돌면서 알림을 취소했습니다.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update Notification n set n.status = 'DISMISSED' where n.mate.id = :mateId and n.sendAt > :dateTime")
void updateAllStatusToDismissedByMateIdAndSendAtAfterDateTime(long mateId, LocalDateTime dateTime);
이번엔 in 절을 통해 하나의 쿼리로 회원이 참여한 약속 참여자 목록(List<mate>)을 파라미터로 하나의 쿼리로 알림을 취소하도록 개선하였습니다.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update Notification n set n.status = 'DISMISSED' where n.mate in :mates and n.sendAt > :dateTime")
void updateMatesPendingNotificationsToDismissed(List<Mate> mates, LocalDateTime dateTime);
약속 참여 수인 N개 만큼 날아갔던 알림 취소 쿼리를 단 1개의 쿼리로 알림을 취소할 수 있게 되었습니다.
update
notification n1_0
set
status='DISMISSED'
where
n1_0.mate_id in (?, ?)
and n1_0.send_at>?
개선2) Eta와 Mate도 in절로 개선하기
약속 참여자의 도착예정정보(Eta)와 Mate의 경우도 각각 deleteAllByMateIn과 deleteAll로 바꾸었습니다.
public interface EtaRepository extends JpaRepository<Eta, Long> {
void deleteAllByMateIn(List<Mate> mates);
}
개선3) 스케쥴링 키도 한번에 삭제하기
fcm을 통해 polling trigger를 보내고 있기에 해당 키들도 하나씩 삭제하는 것이 아닌 한번에 삭제가능하도록 개선하였습니다.
public void deleteCache(Mate mate) {
etaSchedulingRedisTemplate.delete(EtaSchedulingKey.from(mate));
}
public void deleteCache(List<Mate> mates) {
List<EtaSchedulingKey> keys = mates.stream()
.map(EtaSchedulingKey::from)
.toList();
etaSchedulingRedisTemplate.deleteAll(keys);
}
그렇게 하여 리팩터링한 코드는 다음과 같습니다.
@Transactional
public void deleteAllByMember(Member member) {
//삭제 로그 저장 ..
deleteAll(memberMates);
}
private void deleteAll(List<Mate> mates) {
//알림 발송 취소
notificationService.updateAllMatesPendingNotificationsToDismissed(mates);
//fcmTopic 구독 취소
mates.forEach(mate
-> notificationService.unSubscribeTopic(mate.getMeeting(), mate.getMember().getDeviceToken()));
//mate에 해당하는 eta 삭제
etaService.deleteByMates(mates);
//mate 목록 삭제
mateRepository.deleteAll(mates);
//스케쥴링 키 삭제
etaSchedulingService.deleteCache(mates);
}
다시한번 전체 회원 탈퇴 시나리오를 2번 테스트해보았습니다.
평균 43ms로 이전의 51ms에 비해 8ms(15.8%) 단축된 모습을 볼 수 있습니다.


의문) 정말 잘 개선한 걸까?
예상을 한대로 잘 날아가는지 쿼리를 보다가 이상한 점을 발견했습니다.
문제1) deleteAll은 쿼리를 반복한다
먼저 보편적으로 알려져 있는 deleteAll의 문제입니다.

약속참여자를 deleteAll을 통해 삭제하면 SimpleJpaRepository 내부적으로는 반복문을 돌며 delete를 호출합니다.
@Transactional
public void deleteAll(Iterable<? extends T> entities) {
Assert.notNull(entities, "Entities must not be null");
for(T entity : entities) {
this.delete(entity);
}
}
실제 쿼리를 보아도 다음과 같이 반복문을 타는 모습을 볼 수 있습니다.
Hibernate:
UPDATE
mate
SET
deleted_at = NOW()
WHERE
id = ?
Hibernate:
UPDATE
mate
SET
deleted_at = NOW()
WHERE
id = ?
deleteAllInBatch를 고려하였으나, 내부적으로 soft delete를 @SQLDelete를 통해 처리하고 있었기 때문에 영속성 컨텍스트를 경유하지 않는 배치 쿼리에 경우 hard delete가 된다는 문제가 있었습니다.
문제2) 도착 예정정보의 deleteAllByMateIn 도 조회 -> 반복 쿼리가 나간다.
약속 참여자의 도착예정정보(Eta) 삭제 쿼리도 비슷한 문제가 있었습니다.
public interface EtaRepository extends JpaRepository<Eta, Long> {
void deleteAllByMateIn(List<Mate> mates);
}
실제 쿼리를 보니 조회 -> 반복문을 통해 soft delete를 하고 있었습니다.
Hibernate:
select
e1_0.id,
e1_0.deleted_at,
e1_0.first_api_call_at,
e1_0.is_arrived,
e1_0.is_missing,
e1_0.last_api_call_at,
e1_0.mate_id,
e1_0.remaining_minutes
from
eta e1_0
where
(
e1_0.deleted_at is NULL
)
and e1_0.mate_id in (?, ?)
Hibernate:
UPDATE
eta
SET
deleted_at = NOW()
WHERE
id = ?
Hibernate:
UPDATE
eta
SET
deleted_at = NOW()
WHERE
id = ?
이쯤되니 Soft Delete 자체에 대한 고찰이 필요해보였습니다.
현재 오디 프로젝트에서는 @SQLDelete와 @FilterDef를 활용해 Soft Delete를 구현하고 있습니다.
@Filter(name = "deletedMateFilter", condition = "deleted_at IS NULL")
@FilterDef(name = "deletedMateFilter")
@SQLDelete(sql = "UPDATE mate SET deleted_at = NOW() WHERE id = ?")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Mate {
...
}
여기서 @SQLDelete는 엔티티의 Delete 쿼리를 오버라이드 하는 것으로 AbstractEnttiyPersister의 buildDeleteCoordinator에 의해 감지되고 대체됩니다. 즉, 엔티티가 영속화 상태일 때만 delete 쿼리의 대체가 일어납니다.
public abstract class AbstractEntityPersister implements InFlightEntityMappingType, EntityMutationTarget, LazyPropertyInitializer, PostInsertIdentityPersister, FetchProfileAffectee, DeprecatedEntityStuff {
protected DeleteCoordinator buildDeleteCoordinator() {
return (DeleteCoordinator)(this.softDeleteMapping == null ? new DeleteCoordinatorStandard(this, this.factory) : new DeleteCoordinatorSoft(this, this.factory));
}
}
이러한 사실은 의도한 soft delete를 delete prefix를 통해 진행하려면 영속성 컨텍스트에 로드하여 영속화 하는 과정이 필수라는 것을 의미합니다. 따라서 엔티티의 조회 -> 삭제쿼리의 순서를 자연스럽게 유도합니다.
그러나, 조회 -> 삭제의 경우 동시성, 데드락의 위험을 올리는 전형적인 패턴입니다.
조회 이후, 삭제까지의 과정에서 서로 다른 트랜잭션간의 동시 접근 시 서로가 다른 상태의 레코드를 다룰 수 있으며,
조회 시 공유락 획득 이후, 삭제에 배타락을 획득해야하기 때문에 다른 종류의 락을 2번 얻는 과정에서 트랜잭션의 순서가 꼬이면 서로 다른 트랜잭션이 락 해제를 기다리는 데드락 상태에 빠지기 쉽습니다. (실제 디베이트 타이머 서비스 운영과정에서 비슷한 이슈가 있기도 했었음)
따라서 영속성 유무와 상관없이 바로 soft delete를 update 하는 쿼리로 대응해보기로 했습니다.
개선4) soft delete를 영속화 없이 한방쿼리로 삭제하기
먼저 eta 객체의 경우, 다음과 같이 soft delete를 진행하였습니다. 영속화된 객체가 전후로 사용되는 맥락이 없기 때문에 clearAutomatically와 flushAtomatically option은 false를 유지했습니다.
@Modifying
@Query("UPDATE Eta e SET e.deletedAt = CURRENT_TIMESTAMP WHERE e.mate IN :mates AND e.deletedAt IS NULL")
void softDeleteAllByMateIn(@Param("mates") List<Mate> mates);
다음으로 mate의 경우도 비슷하게 한방쿼리로 soft delete 할 수 있도록 하였습니다.
@Modifying
@Transactional
@Query("UPDATE Mate m SET m.deletedAt = CURRENT_TIMESTAMP WHERE m IN :mates AND m.deletedAt IS NULL")
void softDeleteAllByMateIn(@Param("mates") List<Mate> mates);
그렇게 deleteAll 및 영속화를 위한 조회-> 삭제과정의 반복쿼리 없이 한방쿼리들로 한번 더 개선한 로직은 다음과 같습니다.
private void deleteAll2(List<Mate> mates) {
notificationService.updateAllMatesPendingNotificationsToDismissed(mates);
mates.forEach(mate
-> notificationService.unSubscribeTopic(mate.getMeeting(), mate.getMember().getDeviceToken()));
etaService.deleteByMates2(mates);
mateRepository.softDeleteAllByMateIn(mates);
etaSchedulingService.deleteCache(mates);
}
다시한번 개선된 쿼리 로직에서 81명의 회원이 한번에 나간다면 몇초가 걸릴까요?

놀랍게도 운영환경과 유사한 DB환경에서 똑같이 43ms로 soft delete를 원샷쿼리로 대체하기 이전과 그리 큰 차이를 보여주지 않았습니다.
Result : 최적화가 되었으나, 정말 중요한 개선일까?
반복문 쿼리를 In절 원샷쿼리로 최적화하면서 81명의 전체 회원이 동시에 탈퇴했을 때의 응답시간은 다음과 같았습니다.
기존 로직 : 51ms

1차 개선(in 절로 변경) : 43ms (15%개선)

2차 개선(soft delete 원샷 쿼리) : 43ms(15%개선)

이번엔 최적화 효과를 조금 더 극대화하여 보기위해 prod 환경과 유사한 DB 환경이 아니라 조금은 삭제할 객체가 많도록 세팅하여 테스트해보았습니다.
회원(member) : 81명
약속(meeting) : 243개
약속 참여자(mate) : 19683명 (회원 당 243개의 약속 참여)
알림(notification) : 참여자 1명 당 입장 알림 1개, 약속 출발 알림 1개 (39366개)
약속 참여자 도착 정보(eta) : 약속 참여자(mate) 당 1개
그 결과는 다음과 같았습니다.
기존 로직 : 554ms

1차 개선(in 절로 변경) : 342ms(38%개선)

2차 개선(soft delete 원샷 쿼리) : 143ms(74%개선)

확실히 데이터가 많으니 최적화 효과도 더 잘 드러나는 듯 했습니다. 다만, 실제 운영환경에서는 243개의 약속에 참여한 사람은 거의 드물기에 1차 개선 정도로도 큰 무리가 없다고 판단했습니다.
남겨진 질문 : 유의미한 개선일까?
이번 쿼리 최적화는 온전히 제 호기심으로 인해 진행한 태스크입니다. 따라서 서비스적으로 유의하지 않습니다.
이 글의 제목인 [오디] 전체 회원이 동시에 탈퇴하는 속도 단축하기가 이질적으로 느껴지는 것도 그 이유입니다. 서비스 운영자들은 탈퇴하지 않게 하기 위해 노력하지, 탈퇴경험을 자연스럽게 만들려 노력하지는 않습니다.
회원 탈퇴가 조금 오래 걸리면 어떻습니까? 그게 서비스적으로 정말 중요한 개선일까요? 그렇지 않다고 생각합니다. 실제 서비스에서는 평균 51ms가 걸렸던 회원 탈퇴를 43ms 정도로 줄였을 뿐입니다. 중요한 건 8ms 를 줄이는 것이 아니라, 8ms가 크게 느껴지는 서비스의 포인트를 찾는 태도라는 점을 되뇌입니다.
그럼에도 최적화를 하며 잘못 짜여진 로직들을 상당수 발견할 수 있었고 쿼리를 보며 하나씩 최적화를 해나아가는 과정은 기술적으로 재미있었습니다. 특히 Soft Delete에 대해 영속화가 필요하다는 단점을 인식할 수 있는 경험이었습니다.
'프로젝트 > 오디' 카테고리의 다른 글
| [오디 - 폴링 로직 리팩터링] 1. Warm up Code로 CPU 스파이크 해결하기 feat) JIT Compiler (2) | 2025.09.21 |
|---|---|
| [오디 - 폴링 로직 리팩터링] 0. 실험 설계 (0) | 2025.09.19 |
| JsonDeserializer 커스텀으로 외부 API 에러 핸들링하기 (0) | 2024.12.22 |
| FCM 알림 비동기 + 이벤트 리스닝으로 리팩터링 하기 - 2편(테스트) (2) | 2024.12.03 |
| FCM 알림 비동기 + 이벤트 리스닝으로 리팩터링 하기 - 1편 (1) | 2024.12.03 |