✅ RabbitMQ 기반 비동기 아키텍처 도입 성능 개선 사례


1. 기술 도입 배경

기존 시스템은 주문 생성 API를 시작으로, 배달 생성 → 배달 경로 계산 → 슬랙 메시지 전송까지 총 4단계의 API 호출이 순차적으로 진행되는 구조였다.
JMeter를 활용한 성능 테스트 결과, 동기식 처리 흐름에서 평균 처리량은 약 26건/초(26 TPS) 수준으로 확인되었다.

그러나 트래픽이 증가하는 상황에서 각 API 호출이 순차적으로 진행됨에 따라 병목현상이 발생했고, 시스템 전체의 확장성에 한계가 있었다.
이에 따라 각 단계를 비동기화하여 병렬로 처리하고, **메시지 지향 미들웨어(RabbitMQ)**를 도입해 안정적이고 효율적인 비즈니스 플로우를 구성하고자 했다.


2. RabbitMQ 적용 과정

✅ 아키텍처 변경 사항

  • 기존:
    사용자 → 주문생성 API → (동기 호출) 배달생성 → 경로계산 → 슬랙알림
  • 변경 후:
    사용자 → 주문생성 API → RabbitMQ로 메시지 전송
    컨슈머 1: 배달생성 → 메시지 발행
    컨슈머 2: 경로계산 → 메시지 발행
    컨슈머 3: 슬랙알림 전송

✅ 기술 적용 흐름

  • 주문생성 API에서 메시지를 order.queue에 발행
  • 메시지를 받은 컨슈머가 각 단계의 처리를 담당하며, 완료 시 다음 큐로 메시지 전달
  • 전체 흐름은 메시지 기반의 엄격한 순차 실행을 유지하면서도, 비동기 처리를 통해 API 간 종속 시간을 제거

✅ 테스트 환경 구성

  • JMeter로 TPS 100 이상 유입 테스트 진행
  • 처리 완료 기준은 order의 status가 배송완료로 변경되기 까지
  • 처리 완료 로그 또는 DB 기록 기반으로 최종 처리량 산정

3. 해결한 문제 및 성과

🎯 주요 성과

항목적용 전적용 후개선폭
평균 처리량 (Throughput) 26 TPS 87 TPS ▲ 234% 증가
병목 발생률 높음 (API 순차 지연) 매우 낮음 (비동기 큐 병렬 소비) 해소
API 대기 시간 순차적 누적 발생 컨슈머 기반 비동기 처리 감소

🧠 해결한 문제

  • 병목 현상 해소:
    비동기 구조로 API 간 대기시간 제거 → 시스템 응답 시간 대폭 개선
  • 확장성 확보:
    컨슈머 수 증가를 통해 자연스러운 수평 확장이 가능해짐
  • 유지보수 유리:
    각 처리 단계가 독립적인 서비스 단위로 분리되어, 향후 개별 개선 및 로깅, 모니터링이 용이

💡 추가 성능 개선 및 RabbitMQ 사용 이유

  • 확장성:
    트래픽 증가 시 컨슈머 인스턴스 수만 늘리면 대응 가능
  • 유연한 장애 대응:
    특정 단계 장애 발생 시 해당 큐만 지연되고, 전체 서비스는 영향 없음
  • 비동기 기반 구조 확장성:
    이후에도 재고확인, 결제처리, 배송추적 등 다양한 플로우에 유연하게 연결 가능
  • 비즈니스 로직 분리:
    서비스 간 강결합을 줄이고, 각 단계를 이벤트 중심으로 전환함으로써 **도메인 중심 아키텍처(Domain-driven architecture)**로의 전환 기반 확보RabbitMQ 적용 전 평균 처리량

  • RabbitMQ 적용 전 평균 처리량

 

  • RabbitMQ 적용 후 처리량

 

  • 메시징 아키텍쳐

 

'TIL' 카테고리의 다른 글

캐싱을 통한 성능 개선  (0) 2025.04.01
QueryDSL을 활용한 동적 쿼리 최적화  (0) 2025.03.31
RabbitMQ 와 Kafka  (0) 2025.03.07
DB인덱싱  (0) 2025.03.06
Cache  (0) 2025.03.05

캐싱을 활용한 최적 경로 조회 성능 개선

1. 기술 도입 배경

허브 간 최적 경로를 계산하는 API는 외부 API(hub-service) 호출과 복잡한 경로 계산 로직이 포함되어 있어 응답 시간이 길고 서버 부하가 컸음.
동일한 출발지와 도착지에 대해 반복 요청이 많은 상황에서, 중복 연산을 줄이기 위한 캐시 기반 성능 개선이 필요했음.

  • 복잡한 계산 로직으로 인한 응답 지연
  • 동일한 요청이 빈번하게 발생
  • 불필요한 DB/네트워크 트래픽 증가
    → 성능 최적화를 위해 Spring Cache 기반 캐싱 도입

2. 캐싱 적용 과정

✅ Spring Cache 적용

  • @Cacheable(value = "optimalRoutes", key = "#originHubId + ':' + #destinationHubId") 적용
  • origin과 destination 허브 ID 조합을 캐시 key로 설정
  • 동일 요청 시, 계산 없이 캐시된 결과 반환

✅ 트랜잭션과의 조합

  • @Transactional과 함께 사용하여 DB 접근 일관성 유지
  • 캐시는 메서드 진입 시점에 적용됨 (side effect 없음)

✅ 예외 처리 및 안전성 확보

  • 허브 정보가 없을 경우 예외 처리하여 캐시 오염 방지
  • 외부 API 실패 시에도 안정적으로 작동하도록 구성

3. 해결한 문제 및 성과

✅ 중복 계산 제거로 성능 향상

  • 평균 응답 시간: 66ms → 13ms로 단축
  • 약 80% 성능 개선

✅ 가독성 높은 캐시 로직

  • 비즈니스 로직에 영향을 주지 않는 방식으로 캐시 구현
  • 키 조합 방식으로 유연한 캐싱 가능

✅ 서버 부하 감소 및 안정성 향상

  • 트래픽 급증 시에도 캐시 덕분에 처리량 유지
  • 외부 시스템 부하 분산

✅ 향후 확장 고려

  • Redis로 캐시 스토리지를 변경해 분산 환경 대응 가능
  • TTL(Time To Live), 캐시 무효화 전략 도입 여지 확보

✅ 예외 처리

  • Hub 정보가 존재하지 않을 경우 RuntimeException 처리
  • 실패한 요청이 캐시에 저장되지 않도록 설계

4. 핵심 코드 예시

@Cacheable(value = "optimalRoutes", key = "#originHubId + ':' + #destinationHubId")
@Transactional
public List<Map<String, UUID>> findOptimalRoute(UUID originHubId, UUID destinationHubId) {
    var origin = hubService.getHubById(originHubId).getBody().getData();
    var destination = hubService.getHubById(destinationHubId).getBody().getData();
    if (origin == null || destination == null) {
        throw new RuntimeException("허브 정보를 찾을 수 없습니다.");
    }
    return hubRouteDomainService.findOptimalRoute(
        hubRouteRepository.findAll(), originHubId, destinationHubId
    );
}

'TIL' 카테고리의 다른 글

RabbitMQ 기반 비동기 아키텍처 도입 성능 개선 사례  (0) 2025.04.02
QueryDSL을 활용한 동적 쿼리 최적화  (0) 2025.03.31
RabbitMQ 와 Kafka  (0) 2025.03.07
DB인덱싱  (0) 2025.03.06
Cache  (0) 2025.03.05

 

QueryDSL을 활용한 동적 쿼리 최적화

1. 기술 도입 배경

기존 프로젝트에서는 JPA의 @Query 또는 Criteria API를 사용하여 복잡한 쿼리를 작성했으나,

  • 가독성이 떨어지고 유지보수가 어려웠음.
  • 동적 쿼리 작성 시 코드가 길어지고 복잡해짐.
  • 여러 필터 조건을 유연하게 적용하기 어려웠음.

이러한 문제를 해결하기 위해 QueryDSL을 도입하여 가독성과 유지보수성을 향상하고, 동적 쿼리를 효율적으로 작성하는 방식을 적용함.


2. QueryDSL 적용 과정

  1. QueryDSL 환경 설정
    • build.gradle에 QueryDSL 관련 의존성을 추가하고, JPAQueryFactory 빈을 설정하여 사용함.
  2. 동적 쿼리 구현
    • 검색 필터(예: 사용자 이름, 카테고리, 작성일 등)를 조합하여 동적으로 데이터를 조회하는 기능을 구현.
    • where() 절을 활용하여 조건이 있을 때만 쿼리에 추가하는 방식 적용.
  3. 페이징 및 성능 최적화
    • 페이징을 위한 count 조회는 fetchOne()을 사용하여 불필요한 데이터 조회를 최소화함.

3. 해결한 문제 및 성과

가독성 및 유지보수성 향상

  • 기존 @Query 기반의 복잡한 SQL을 QueryDSL로 변환하여 가독성을 개선.
    동적 필터링 구현
  • 검색 조건(이름, 날짜, 카테고리 등)을 조합하여 유연하게 검색할 수 있도록 개선.
    쿼리 성능 최적화
  • QueryDSL의 fetchJoin()을 활용하여 N+1 문제를 해결하고, 필요한 컬럼만 Projections.constructor()로 가져와 성능 개선.

 페이징 처리

  • offset()과 limit()을 활용하여 데이터 페이징 처리.
@Override
public Page<ReviewDetailResponseDto> findAllReviews(ReviewSearchCondition condition, Pageable pageable) {
    int pageSize = pageable.getPageSize() > 0 ? pageable.getPageSize() : 10;

    // 리뷰 목록 조회 쿼리 (fetchJoin을 통한 N+1문제 해결)
    List<ReviewDetailResponseDto> content = queryFactory
            .select(Projections.constructor(ReviewDetailResponseDto.class,
                    review.id,
                    order.id,
                    menu.id,
                    restaurant.id,
                    review.title,
                    review.content,
                    review.rating,
                    menu.name,
                    restaurant.name,
                    review.createdBy,
                    review.createdAt
            ))
            .from(review)
            .join(review.order, order).fetchJoin()
            .join(order.menu, menu).fetchJoin()
            .join(menu.restaurant, restaurant).fetchJoin()
            .where(
                    review.isDeleted.isFalse(),
                    keywordContains(condition.keyword()),
                    createdAtBetween(condition.startDate(), condition.endDate())
            )
            .orderBy(
                    condition.isAsc() ? review.createdAt.asc() : review.createdAt.desc(),
                    condition.isAsc() ? review.updatedAt.asc() : review.updatedAt.desc()
            )
            .offset(pageable.getOffset())
            .limit(pageSize)
            .fetch();

    // 총 레코드 수 조회 (fetchJoin 없이 실행하여 성능 최적화)
    Long total = queryFactory
            .select(review.count())
            .from(review)
            .join(review.order, order)
            .join(order.menu, menu)
            .join(menu.restaurant, restaurant)
            .where(
                    review.isDeleted.isFalse(),
                    keywordContains(condition.keyword()),
                    createdAtBetween(condition.startDate(), condition.endDate())
            )
            .fetchOne();

    return new PageImpl<>(content, pageable, total != null ? total : 0);
}

 

각각의 코드들(JPAQueryFactory, fetchjoin, projections.constructor , offset, fetchone 등 )의 사용이유들은 좀더 조사해서 추가 작성 필요.

'TIL' 카테고리의 다른 글

RabbitMQ 기반 비동기 아키텍처 도입 성능 개선 사례  (0) 2025.04.02
캐싱을 통한 성능 개선  (0) 2025.04.01
RabbitMQ 와 Kafka  (0) 2025.03.07
DB인덱싱  (0) 2025.03.06
Cache  (0) 2025.03.05

[문제 링크]

https://school.programmers.co.kr/learn/courses/30/lessons/87377

 

프로그래머스

SW개발자를 위한 평가, 교육, 채용까지 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프

programmers.co.kr

[난이도]

- Level 2

 

[알고리즘]

- 구현

- 배열

 

[코드]

import java.util.*;

class Solution {
    public String[] solution(int[][] line) {
        long maxX = Long.MIN_VALUE;
        long maxY = Long.MIN_VALUE;
        long minX = Long.MAX_VALUE;
        long minY = Long.MAX_VALUE;

        List<long[]> list = new ArrayList<>();

        for (int i = 0; i < line.length - 1; i++) {
            for (int j = i + 1; j < line.length; j++) {
                long A = line[i][0];
                long B = line[i][1];
                long E = line[i][2];
                long C = line[j][0];
                long D = line[j][1];
                long F = line[j][2];

                long denominator = A * D - B * C;

                if (denominator == 0) continue;  // 평행 또는 동일 직선

                long numeratorX = B * F - E * D;
                long numeratorY = E * C - A * F;

                // 정수 교차점인지 확인
                if (numeratorX % denominator != 0 || numeratorY % denominator != 0) continue;

                long x = numeratorX / denominator;
                long y = numeratorY / denominator;

                maxX = Math.max(maxX, x);
                maxY = Math.max(maxY, y);
                minX = Math.min(minX, x);
                minY = Math.min(minY, y);

                list.add(new long[]{x, y});
            }
        }

        int width = (int)(maxX - minX + 1);
        int height = (int)(maxY - minY + 1);

        char[][] graph = new char[height][width];
        for (char[] row : graph) {
            Arrays.fill(row, '.');
        }

        for (long[] point : list) {
            int x = (int)(point[0] - minX);
            int y = (int)(maxY - point[1]);  // y축 뒤집기

            graph[y][x] = '*';
        }

        String[] answer = new String[height];
        for (int i = 0; i < height; i++) {
            StringBuilder sb = new StringBuilder();
            for (int j = 0; j < width; j++) {
                sb.append(graph[i][j]);
            }
            answer[i] = sb.toString();
        }

        return answer;
    }
}

 

[풀이]

단순 구현 문제이다. 중요한점은 int가 아닌 long타입으로 변환해서 풀어야한다는것이다. 그 이유는 int*int가 int의 범위를 벗어나는 경우가 있어 테스트케이스29번을 통과하지 못하기 때문이다.

[문제 링크]

https://school.programmers.co.kr/learn/courses/30/lessons/388351

[난이도]

- Level 1

 

[알고리즘]

- 구현

 

[코드]

class Solution {
    public int solution(int[] schedules, int[][] timelogs, int startday) {
        int answer = 0;
        boolean flag = false;
        for(int i=0;i<schedules.length;i++){
            int time = function(schedules[i]);
            flag = false;
            for(int j=0;j<7;j++){
               if(j == 7-startday || j == 6-startday){
                   continue;
               }
                if(6-startday == -1){
                    if(j==6){
                        continue;
                    }
                }

                
                if(timelogs[i][j]>time){
                    flag = true;
                    break;
                }
            }
            if(!flag){
                answer++;
            }
        }
        return answer;
    }
    static int function(int time){
        int min = time%100;
        int hour = time/100;
        int newMin = min+10;
        if(newMin>=60){
            newMin -=60;
            hour++;
        }
        int newTime = hour*100 + newMin;
        return newTime;
    }
}

 

[풀이]

단순 구현 문제이다. 토요일과 일요일이 위치할 배열을 고르는 법은 6 - startday, 7-startday이다. 중요한점은 startday가 7일때를 고려해야한다. 이때 토요일은 배열의 마지막에 위치하기 때문에 한 번 더 if문으로 해결했다.

 

10분이 지난시간이 60분을 넘길때를 고려해주어야 한다. 

+ Recent posts