✅ 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

RabbitMQ와 Kafka 모두 메시지 브로커이다. 하지만 목적과 동작 방식이 꽤 다르다.

쉽게 말하자면

  • Kafka : 대규모 데이터를 실시간 스트리밍
    • 실시간 로그 수집, 모니터링, 이벤트 스트리밍
    • 대규모 트래픽 처리
    • 인스타그램 피드, 넷플릭스 추천 시스템 등에 적합
    • 장기 저장을 목표
  • RabbitMQ : 큐에 담아 하나씩 처리
    • 이메일 인증, 결제 처리, 알림 전송 같은 소규모 작업에 적합
    • 트랜잭션 보장(한 번만 전달)
    • MSA간 데이터 전달
    • 단기 저장을 목표

 

'TIL' 카테고리의 다른 글

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

DB인덱싱은 데이터베이스에서 검색 속도를 높이기 위해 특정 컬럼에 대한 데이터를 따로 저장해주는 방식이다.

쉽게 말하면 책의 목차 같은것이다.

 

인덱스 동작 방식

  1. 특정 컬럼에 대해 정렬된 자료 구조(B-Tree, Hash 등)을 생성
  2. 컬럼 값과 해당 값이 저장된 레코드의 위치(주소)를 저장
  3. 검색 시 전체 테이블을 뒤지는 게 아니라, 인덱스만 조회해서 더 빠르게 원하는 데이터를 찾을 수 있다.

 

예를 들어 피자 가게에 1000명의 고객 명단이 있다. 컬럼으로는 손님 이름, 전화번호, 주소, 성별 등이 있다.

이 때 홍길동의 전화번호를 찾는다고 가정하면

 

인덱스가 없는 경우 : 손님 명단 처음부터 끝까지 하나씩 다 뒤져서 홍길동을 찾는다. -> 시간이 엄청 오래 걸림

 

인덱스가 있는 경우 : 가게 사장이 고객이름 순서대로 정리된 전화번호부(목차) 를 따로 만든다.

 

  • 강감찬 → 010-1111-2222
  • 김영희 → 010-3333-4444
  • 홍길동 → 010-1234-5678

바로 인덱스에서 홍길동을 찾아 전화번호만 딱 가져오면 된다. -> 시간이 매우 빠름

 

생각해보면 인덱스도 결국 모든 고객이름을 조회해야한다고 생각할 수 있다. 하지만 인덱스는 특별한 구조로 되어있다.

예를 들어 B-Tree라는 구조로 되어있다면. B-Tree는 루트를 먼저 찾고, 찾는 값이 크면 오른쪽 노드, 작으면 왼쪽 노드 이런식으로 가지를 타며 찾아가는 구조이다.

이런식으로 정렬되어있다면 홍길동을 그냥 찾으려면 5번만에 찾고, B-Tree로 되어있다면 3번만에 찾는다.

지금은 데이터가 5개라서 효율적이지 않지만 만약 데이터가 100만개라면 차원이 달라진다.

'TIL' 카테고리의 다른 글

QueryDSL을 활용한 동적 쿼리 최적화  (0) 2025.03.31
RabbitMQ 와 Kafka  (0) 2025.03.07
Cache  (0) 2025.03.05
Redis  (0) 2025.03.04
Spring Security의 @AuthenticationPrincipal 이해하기  (0) 2025.02.25

Cache는 본래 CPU 내부의 작은 영역으로 정말 빈번히 접근하게 되는 데이터를 저장해두는 임시기억 장치이다. 기본적으로 영속성을 위해 파일시스템(디스크)에 저장하고, 빠른 활용을 위해 메모리(RAM)에 저장한다면, 정말 많이 사용되는 휘발성 데이터가 캐시에 저장된다. 

 

이러한 캐시의 목적과 방식을 적용해 빈번하게 접근하게 되는 데이터베이스의 데이터를 Redis 등의 인메모리 데이터베이스에 저장함으로서 데이터를 조회하는데 걸리는 시간과 자원을 감소시키는 기술을 캐싱이라고 한다.

 

웹 브라우저에서는 자주 바뀌지 않는 이미지 등을 브라우저 캐시에 저장해 페이지 로드를 줄이는 것도 캐싱의 일종이다. 이는 RESTful 설계 원칙 중에서 응답이 캐싱이 가능한지 명시해야 한다는 제약사항으로도 나타난다.

 

'TIL' 카테고리의 다른 글

RabbitMQ 와 Kafka  (0) 2025.03.07
DB인덱싱  (0) 2025.03.06
Redis  (0) 2025.03.04
Spring Security의 @AuthenticationPrincipal 이해하기  (0) 2025.02.25
restdocs  (0) 2025.02.20

Redis : In-memory DB

 

RDS DB는 영속성을 제공하는데 목적을 두고있다. 이는 데이터가 사라지지 않게 하기 위해 파일시스템(SSD, HDD 등)에 저장한다는 의미이다. 컴퓨터가 저장되어도 데이터가 사라지지 않지만 기본적으로 데이터를 읽고 쓰는데 오래걸린다.

 

하지만 Redsi는 메모리, 즉 RAM에 데이터를 저장하기 때문에 복작한 입출력 과정이 필요하지 않다. RDS보다 빠르지만 언제든 사라질 수 있는 데이터를 다룬다. 특정 게시글의 조회수와 같이 빠르게 업데이트 되는 데이터, 또는 사용자 세션, 장바구니와 같은 시간이 지나면 삭제되는 데이터 등을 저장하기 위해 가장 많이 사용되어온 DB이다.

 

Redis는 NoSQL DB이다.

일반적인 RDS DB는 SQL을 사용한다.

SELECT * FROM users;

 

Redis를 비롯한 NoSQL DB는 (일반적으로) SQL을 사용하지 않는다.

SET greeting "Hello, Redis!"
GET greeting

 

NoSQL은 현재는 Not only SQL을 의미한다. 

기술이 발전함에 따라 데이터가 증가하고, 비정형데이터가 많아지고, 확장성과 유연성이 떨어져서 NoSQL이 생겨난것이다.

NoSQL DB의 종류 몇가지

  • Key-Value: 가장 단순한 형태의 데이터베이스로, Key에 Value를 저장하는 형태입니다. JSON, Python의 Dictionary, Java의 Map의 형태로 데이터를 관리한다고 생각할 수 있습니다.
  • Document: 객체를 표현하는 Document라는 단위로 데이터를 저장하는 형태입니다. Key - Value에서 발전했다고 볼 수 있으며, JSON, XML 등 복잡한 데이터를 저장하고 관리합니다.
  • Column-Family: 각 Row의 Column이 고정되어있지 않고, 필요한 데이터 Column을 이름, 데이터, Timestamp와 함께 저장하는 형태의 데이터베이스 입니다.

 

'TIL' 카테고리의 다른 글

DB인덱싱  (0) 2025.03.06
Cache  (0) 2025.03.05
Spring Security의 @AuthenticationPrincipal 이해하기  (0) 2025.02.25
restdocs  (0) 2025.02.20
n+1을 해결하는 여러가지 방식의 장단점  (0) 2025.02.19

@AuthenticationPrincipal 어노테이션은 Spring Security에서 인증된 사용자 정보를 손쉽게 접근할 수 있도록 도와주는 기능입니다. Spring Security를 사용하여 인증 및 인가를 처리할 때, 인증된 사용자의 정보를 서비스 계층이나 컨트롤러에서 쉽게 가져오고자 할 때 유용합니다. 이 어노테이션은 보통 Principal 객체를 주입하는 방식과 유사하지만, 더 직관적이고 간편하게 사용할 수 있도록 도와줍니다.

1. @AuthenticationPrincipal이란?

@AuthenticationPrincipal은 Spring Security의 Authentication 객체에서 현재 인증된 사용자의 정보를 직접 가져올 수 있게 해주는 어노테이션입니다. Authentication 객체는 사용자가 로그인 시 저장된 인증 정보를 담고 있으며, 이를 통해 로그인한 사용자의 이름, 권한, 역할 등을 가져올 수 있습니다.

2. 사용 예시

2.1. 기본적인 사용

@AuthenticationPrincipal을 사용하면 인증된 사용자 객체를 쉽게 주입받을 수 있습니다. 보통 사용자 정보를 담고 있는 클래스는 UserDetails 인터페이스를 구현합니다. 예를 들어, 사용자 정보를 CustomUserDetails 클래스에 담고 있다면 다음과 같이 사용할 수 있습니다.

@Controller
public class MyController {

    @GetMapping("/profile")
    public String getUserProfile(@AuthenticationPrincipal CustomUserDetails userDetails) {
        // 인증된 사용자 정보 사용
        System.out.println("Authenticated user: " + userDetails.getUsername());
        return "profile";
    }
}

2.2. UserDetailsService와 함께 사용

@AuthenticationPrincipal을 사용하려면 보통 UserDetailsService를 사용하여 사용자 정보를 로드하는 방식과 함께 사용됩니다. CustomUserDetailsService 클래스를 구현하여 사용자 정보를 로드하고, Spring Security에서 제공하는 AuthenticationManager를 통해 인증을 처리하는 방식입니다.

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));
        return new CustomUserDetails(user);
    }
}

3. 장점

  • 간결성: @AuthenticationPrincipal을 사용하면 Authentication 객체를 직접 다룰 필요 없이, 인증된 사용자 정보를 쉽게 주입받을 수 있습니다.
  • 유연성: UserDetails를 구현한 클래스에 따라 사용자 정보를 다르게 설정하고 사용할 수 있습니다.
  • 보안 강화: 인증된 사용자 정보만을 주입받기 때문에 보안 측면에서도 유리합니다.

4. 주의할 점

  • @AuthenticationPrincipal은 주로 Principal 객체가 아닌 인증된 사용자의 상세 정보를 담고 있는 객체를 주입받기 위해 사용됩니다. 따라서 CustomUserDetails와 같은 클래스를 UserDetails 인터페이스로 구현해 사용해야 합니다.
  • 인증되지 않은 사용자가 접근할 수 있는 경로에서는 @AuthenticationPrincipal을 사용할 수 없으므로, 적절한 예외 처리가 필요합니다.

5. 결론

Spring Security에서 @AuthenticationPrincipal 어노테이션은 인증된 사용자 정보를 쉽게 가져올 수 있는 매우 유용한 도구입니다. 이를 사용하면 코드가 간결해지고, Authentication 객체를 직접 다루지 않고도 인증된 사용자의 상세 정보를 손쉽게 다룰 수 있습니다. 특히 사용자 인증 및 관리가 중요한 애플리케이션에서 이 어노테이션을 활용하면 보다 깔끔하고 안전한 코드 작성이 가능합니다.

'TIL' 카테고리의 다른 글

Cache  (0) 2025.03.05
Redis  (0) 2025.03.04
restdocs  (0) 2025.02.20
n+1을 해결하는 여러가지 방식의 장단점  (0) 2025.02.19
data.sql을 활용한 초기 데이터 설정  (0) 2025.02.14

+ Recent posts