항해 8주차에는 Index와 애플리케이션 이벤트에 대해 알아보았다~!
Index를 적절하게 설정해서 각 쿼리에 대해 부하를 줄일 수 있도록 개선하였다.
그리고 비즈니스를 핸들링 할 수 있도록 선후관계를 파악한 뒤, 애플리케이션 이벤트를 활용해 관심사를 분리해보았다!
목차
1. Index란?
2. 쿼리 문제 분석 및 Index 적용
3. Index 적용 전, 후 성능 비교
4. 트랜잭션 분리 및 전략
5. 이벤트 기반 아키텍처 적용
6. 회고
1. Index란?
Index는 데이터베이스 테이블에 대한 검색 성능의 속도를 높여주는 자료구조이다.
특정 컬럼에 Index를 생성하면, 해당 컬럼의 데이터들을 정렬하여 별도의 메모리 공간에 데이터의 물리적 주소와 함께 저장된다.
또한, Index 생성 시 오름차순으로 정렬하기 때문에 정렬된 주소 체계라고 표현할 수 있다.
인덱스 최적화 필요성
- 빠른 데이터 조회: 인덱스가 없거나 잘못 설계된 경우, 쿼리를 실행할 때 'Full Table Scan'이 발생하여 모든 데이터를 검색해야 하는데 적절한 인덱스를 설정하면 필요한 데이터만 빠르게 조회가 가능하다.
- 조인 성능 개선: 여러 개의 테이블을 조인할 때, 인덱스가 없으면 각 테이블을 풀 스캔해야 하므로 성능이 크게 저하되는데 '외래 키(Foreign Key)' 컬럼에 인덱스를 추가하면 조인 연산 시 검색 속도가 대폭 향상된다.
- 정렬 및 그룹화 최적화 : ORDER BY, GROUP BY가 포함된 쿼리는 테이블을 정렬해야 하므로 성능 저하를 발생시키는데 정렬이 자주 필요한 컬럼에 인덱스를 추가하면 불필요한 정렬 연산을 줄일 수 있다.
인덱스 사용 시 주의사항
- 쓰기(INSERT, UPDATE, DELETE) 성능 저하 : 인덱스가 많을수록 데이터를 추가하거나 변경할 때 인덱스도 함께 갱신해야 하므로 성능이 저하된다.
- 저장 공간 증가 : 인덱스를 추가하면 별도의 저장 공간이 필요하며, 데이터베이스의 크기가 증가할 수 있다.
- 과도한 인덱스 사용은 오히려 성능 저하 : 인덱스 관리를 위해 DB의 약 10%의 저장공간이 필요한데, 너무 많은 인덱스를 추가하면 데이터베이스가 인덱스를 관리하는 오버헤드(부하)가 증가할 수 있다.
2. 쿼리 문제 분석 및 인덱스 적용
☑️ 예약 가능한 날짜 조회
- Schedule 테이블에서 특정 공연(concert_id)의 날짜를 조회한다.
SELECT c.*, s.*
FROM Concert c
JOIN Schedule s
ON c.concert_id = s.concert_id
WHERE c.concert_id = 1;
- 성능 문제
- Join 연산 비용 : Concert과 Schedule을 조인하기 때문에 스케줄이 많은 콘서트에서는 성능이 저하될 수 있다.
- Index 미적용 시 성능 저하 : 'WHERE c.concert_id = 1'에서 적절한 인덱스가 없으면 풀 테이블 스캔(Full Table Scan)이 발생할 가능성이 있다.
- 인덱스 적용
CREATE INDEX idx_concert_id ON Concert (concert_id);
- Concert 테이블의 id 컬럼에 인덱스를 생성하여 'WHERE c.concert_id = 1' 조건을 빠르게 검색할 수 있도록 한다.
CREATE INDEX idx_schedule_concert_id ON Schedule (concert_id);
CREATE INDEX idx_schedule_id ON Schedule (schedule_id);
- Schedule 테이블에서 concert_id를 기준으로 Concert와 JOIN이 발생하므로, concert_id에 대한 인덱스를 추가한다.
☑️ 예약 가능한 좌석 조회
- 특정 schedule_id에 해당하는 Schedule을 조회하면서, 해당 Schedule에 속한 예약 가능(AVAILABLE) 좌석도 함께 가져온다.
SELECT c.*, s.*
FROM Schedule c
JOIN Seat s
ON c.schedule_id = s.schedule_id
WHERE c.schedule_id = 1
AND s.seat_status = 'AVAILABLE';
- 성능 문제
- seat_status 조건 필터링이 인덱스로 최적화되지 않으면 성능 저하가 발생 가능하다.
- 인덱스 적용
CREATE INDEX idx_seat_schedule_id_status ON Seat (schedule_id, seat_status);
- Seat 테이블에서 schedule_id와 seatStatus = 'AVAILABLE' 조건을 함께 검색하므로 복합 인덱스를 적용한다.
- schedule_id로 필터링 후 seatStatus를 빠르게 찾을 수 있다.
3. Index 적용 전, 후 성능 비교
* 대량 데이터
Concert : 100건 | Schedule 10만건 | Seat: 100만건
☑️ 예약 가능한 날짜 조회
Index 적용 전
- 실행 결과
- 실행 ROW 수 : 10만
- 실행 시간 : 700ms
Index 적용 후
- 실행 결과
- 실행 ROW 수 : 10만
- 실행 시간 : 500ms
➡️ Index 적용 전 & 후 실행 시간을 비교해본 결과 700ms → 500ms로 성능이 약 28.57% 개선된 것을 확인할 수 있다.
☑️ 예약 가능한 좌석 조회
Index 적용 전
- 실행 결과
- 실행 ROW 수 : 506,609건
- 실행 시간 : 3060ms
Index 적용 후
- 실행 결과
- 실행 ROW 수 : 506,609건
- 실행 시간 : 1730ms
➡️ Index 적용 전 & 후 실행 시간을 비교해본 결과 3060ms → 1730ms로 성능이 약 43.46% 개선된 것을 확인할 수 있다.
결론적으로 schedule_id, concert_id와 같이 특정 컬럼만 조회할 경우 단일 인덱스가 효과적이다.
그리고 복합 인덱스(schedule_id, seat_status)는 두 조건을 동시에 처리하여 성능을 향상시키고, 단일 인덱스 두 개보다 하나의 복합 인덱스가 더 최적화된 검색을 제공한다.
또한 schedule_id, seat_status 순서로 인덱스를 만들었을 경우, schedule_id가 먼저 조건에 포함될 때만 효율적으로 동작한다.
4. 트랜잭션 분리 및 전략
트랜잭션 관리는 특히 분산 시스템(MSA)에서 매우 중요하다. 여러 서비스가 독립적으로 동작하는 환경에서는 각 서비스 간의 데이터 일관성을 유지하면서도 성능을 최적화할 수 있는 방법이 필요하다. 이를 해결하기 위한 한 가지 전략은 도메인별 트랜잭션 분리이다.
도메인별 트랜잭션 분리 전략을 사용하면 각 도메인에서의 트랜잭션을 독립적으로 관리할 수 있다. 예를 들어, 예약 서비스와 결제 서비스를 각각 독립적인 트랜잭션으로 처리하는 방법을 고려할 수 있다. 아래는 각각의 트랜잭션 흐름이다.
예약 서비스
예약_Tx() {
좌석_상태_점유로_업데이트();
스케줄_잔여_티켓_수_업데이트();
예약_신청();
}
결제 서비스
결제_Tx() {
예약_정보_조회();
잔액_차감();
좌석_상태_비점유로_업데이트();
스케줄_잔여_티켓_수_업데이트();
결제_정보_생성();
유저_대기열_토큰_제거();
데이터_플랫폼_전송(); // 결제 정보 전송 (구현 예정)
}
MSA 환경에서 트랜잭션 일관성 유지 전략
MSA 환경에서는 여러 서비스 간의 트랜잭션을 어떻게 일관성 있게 처리할지 고민해야 한다. 이를 위해 다양한 패턴과 기술들이 적용될 수 있는데, 대표적인 트랜잭션 관리 전략은 Spring Event, SAGA 패턴, Outbox 패턴이다. 각 전략은 다음과 같은 특징을 가지고 있다.
- Spring Event: 단일 서비스 내 비동기 이벤트 처리에 적합하며, 최종 일관성을 제공한다.
- SAGA 패턴: 크로스 서비스 트랜잭션 관리에 효과적이며, 보상 트랜잭션을 통해 부분 실패 시 데이터 일관성을 유지한다.
- Outbox 패턴: 이벤트 전달 보장을 통해 강한 일관성을 제공하지만, 추가적인 스토리지와 프로세스가 필요하다.
5. 이벤트 기반 아키텍처 적용
이벤트 기반 아키텍처(Event-Driven Architecture, EDA)는 시스템에서 발생하는 이벤트를 중심으로 구성된 아키텍처 스타일이다.
이벤트 기반 아키텍처에서는 시스템의 각 구성 요소들이 이벤트를 생성하고, 이를 이벤트 처리기(Event Handler)나 이벤트 소비자(Event Consumer)가 처리하는 방식으로 동작한다.
스프링은 이벤트를 발행하고 구독하는 기능을 제공하고 있는데, 해당 기능은 아래와 같은 기본적인 가이드를 따라 구현한다.
- 이벤트 발행: 이벤트를 발행하려면 ApplicationEventPublisher를 주입받아 사용해야 한다.
이 객체는 특정 이벤트를 발행하고 다른 컴포넌트가 이를 구독할 수 있도록 해준다. - 이벤트 구독: 이벤트를 구독하려면 두 가지 방법이 있다.
- ApplicationListener 인터페이스를 구현하거나,
- @EventListener 애너테이션을 사용하여 특정 이벤트를 처리하는 메서드를 정의할 수 있다.
다음은 좌석 예약 신청 후 데이터 플랫폼에 예약 정보 전달하는 시나리오이다.
예약이 완료된 후 예약 정보를 데이터 플랫폼에 전송하는 이벤트를 발행하고, 이를 다른 컴포넌트가 구독하여 처리하는 구조이다.
@Getter
@RequiredArgsConstructor
public class ReservationSuccessEvent {
private final Long reservationId;
}
예약 성공에 대한 이벤트 객체이고, reservationId 필드로 구성되어 있다.
@Component
@RequiredArgsConstructor
public class ReservationFacade {
private final ConcertService concertService;
private final ReservationService reservationService;
private final ReservationEventPublisher reservationEventPublisher;
@DistributedLock(key = "#reservationParam.scheduleId")
public ReservationResult createSeatReservation(ReservationParam reservationParam) {
// 예약 상태 업데이트
Schedule updatedSchedule = concertService.updateScheduleRemainingTicket(reservationParam.scheduleId(), -1);
// 예약 신청
Reservation savedReservation = reservationService.creatSeatReservation(updatedSeat, reservationParam.userId());
// 예약 성공 이벤트 발행
reservationEventPublisher.success(new ReservationSuccessEvent(savedReservation.getId()));
return new ReservationResult(savedReservation.getId(), updatedSchedule.getId(),
savedReservation.getSeatId(), savedReservation.getUserId(), savedReservation.getReservationState(), savedReservation.getCreatedAt());
}
}
예약 신청에 성공한 뒤, EventPublisher를 통해 예약 신청 이벤트를 발행한다.
@Component
@RequiredArgsConstructor
public class ReservationEventPublisher {
private final ApplicationEventPublisher applicationEventPublisher;
public void success(ReservationSuccessEvent reservationSuccessEvent){
applicationEventPublisher.publishEvent(reservationSuccessEvent);
}
}
applicationEventPublisher의 .publishEvent() 메소드를 통해 이벤트를 발행한다.
@Component
@RequiredArgsConstructor
public class ReservationEventListener {
private final DataPlatformClient dataPlatformClient;
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void reservationDataSendHandler(ReservationSuccessEvent reservationSuccessEvent) {
dataPlatformClient.send(reservationSuccessEvent);
}
}
@EventListener를 확장한 @TransacitionEventListener를 사용하면 트랜잭션 단계 별로 이벤트를 구독(listen)할 수 있다.
'parse = TransactionPhase.AFTER_COMMIT'는 트랜잭션 커밋이 완료된 뒤에 구독하겠다는 의미이다.
트랜잭션이 완료되어 이벤트를 구독하면 데이터플랫폼에 예약 성공 이벤트를 전달한다.
@Slf4j
@Component
public class DataPlatformClient {
public void send(ReservationSuccessEvent reservationSuccessEvent){
log.info("사용자에게 예약 확인 이메일 발송 - 예약 ID: {}", reservationSuccessEvent.getReservationId());
}
}
데이터 플랫폼에서는 간단하게 이벤트 내에 있는 예약 ID를 로그로 찍어주었다.
6. 회고
이번 주는 인덱스를 활용한 성능 최적화와 이벤트 기반 아키텍처 적용에 집중한 한 주였다. 기존 쿼리 실행 계획을 분석하면서 불필요한 Full Table Scan을 줄이는 데 집중했지만, 인덱스를 추가한다고 해서 무조건 성능이 향상되는 것은 아니라는 점을 실감했다. 특히, 선택도가 낮은 컬럼에 인덱스를 생성하면 오히려 성능이 저하될 수 있다는 점을 배웠다.
또한, 기존의 동기 호출 방식을 비동기 이벤트 처리 방식으로 변경하면서 서비스 간 결합도를 낮추고, 병목이 발생하던 구간을 개선할 수 있었다. 하지만 트랜잭션 일관성을 유지하는 것이 예상보다 쉽지 않았고, 이를 해결하기 위해 보상 트랜잭션과 SAGA 패턴을 고려해야 할 필요성을 느꼈다.
이번 주를 돌아보며, 성능 최적화는 단순히 인덱스를 추가하는 것이 아니라 실제 쿼리 실행 계획을 면밀히 분석하고 데이터 분포를 고려해야 한다는 점을 다시 한번 깨달았다. 또한, 이벤트 기반 아키텍처를 적용하면서 트랜잭션 관리와 데이터 정합성 문제를 보다 깊이 이해할 필요가 있음을 절감했다. 다음 주에는 메시지 브로커(Kafka)를 활용한 이벤트 처리 방식을 더 탐구해볼 계획이다! 🔎
'항해 플러스 백엔드 > 대용량 트래픽 처리' 카테고리의 다른 글
[항해] 9주차, Kafka로 메세징 처리하기 (0) | 2025.02.23 |
---|---|
[항해] 7주차, Redis를 활용한 캐싱 & 대기열 시스템 개선 (0) | 2025.02.16 |
[항해] 6주차, 동시성 제어 방식 분석 (0) | 2025.02.16 |