[Project]꽃 향기만 남기고 갔단다

기 원·2025년 5월 26일

꽃 향기만 남기고 갔단다

0. 목차

1. 프로젝트 소개

2. 핵심 기술 요약

3. 동시성 제어

4. 대용량 데이터 처리

5. 캐싱

6. 성과 및 회고

1. 프로젝트 소개

🌸 프로젝트 개요

프로젝트 명 : 온라인 꽃 쇼핑몰 "꽃 향기만 남기고 갔단다"

팀 명 :💰1조 벌자

이름직책역할
기 원팀장가게 도메인, 분위기 메이커, 1조 대장
김 하경부 팀장상품 도메인
김 광민팀원주문 도메인
정 기백팀원인증/인가, 유저 도메인
안 유빈팀원쿠폰 도메인

📡 기술 스택

Language: Java 17

Framework: Spring Boot 3.4.4

ORM: Spring Boot JPA

Database: MySQL, H2

Cache: Redis

Security: Spring Security, JWT

API Docs: Swagger

Version Control: Git, GitHub

Test Tool: k6, Postman, Java Test Code

🎨 와이어 프레임

스크린샷 2025-05-26 오전 9.23.08.png

☁️ ERD

image.png

📝 API 명세

스크린샷 2025-05-26 오전 9.40.47.png

2. 핵심 기술 요약

동시성 제어

도메인상세 내용제어 전략
User회원 가입 시 이메일/닉네임 중복 제어분산 락 (Redis)
Store가게 생성 시 가게 명 중복 제어분산 락 (Redis)
Coupon쿠폰 발급 시 재고 감소 제어분산 락 (Redis)
Order주문 시 상품 재고 감소 제어비관적 락 → 고도화 : 분산 락 (Redis)

대용량 데이터 처리

도메인상세 내용처리 전략key
Store가게 검색 속도 개선복합 인덱스 + Redis 직렬화 최적화store_name + deleted
Coupon유저별 발급 가능 쿠폰 조회 최적화복합 인덱스 + EXISTS + DTO Projectionuser_id + coupon_id + is_used

캐싱

도메인상세 내용캐싱 전략저장소비고
Store인기 가게 리스트조회 수 기반 자동 캐싱Redis (카운터 기반)실시간 조회 수를 누적하여 동적으로 반영
Flower인기 검색어(오늘/이번 달/올해 기준) 검색 결과스케줄 기반 사전 캐싱Redis프로그램 시작 시 + 매 시간 주기로 인기 검색어 미리 캐싱

3. 동시성 제어

🎟️ 쿠폰 발급 시 재고 감소 제어

📌 배경

  • 한정 수량 쿠폰이기 때문에 동시에 많은 사용자가 발급을 시도할 경우, → 재고가 음수로 떨어지거나, → 중복 발급이 발생할 위험 존재!
  • 비관적 락 / 낙관적 락만으로는 분산 환경에서의 정확한 동시성 제어에 한계가 있음

💡 해결 전략

  • Redis 기반 분산 락 사용 → 쿠폰 ID 단위로 락을 획득해 재고 감소 과정에서의 충돌 방지 → 재고 음수화 방지 → 수평 확장 용이 (트래픽 증가에도 유연한 대응 가능)

🧑‍💻 구현 코드

    @Override
    public UserCouponIssueResponseDto issueUserCoupon(Long storeId, Long couponId, CustomUserPrincipal principal) {

        User user = validateActivateUser(principal.getUsername());
        Long discountCouponId = discountCouponRepository.findById(couponId)
            .orElseThrow(() -> new ApiException(ErrorStatus.COUPON_NOT_FOUND)).getId();
        // 쿠폰ID 기반으로 락 키 생성
        String lockKey = "coupon-lock:id: " + discountCouponId;
        log.info("lockKey = {}", lockKey);
        RLock lock = redissonClient.getLock(lockKey);
        boolean isLocked;

        try {
			      // 락 획득 시도
            isLocked = lock.tryLock(3000, 3000, TimeUnit.MILLISECONDS);

						// 락 획득 실패 예외처리
            if (!isLocked) {
                log.warn("[Coupon - 쿠폰 발급] 락 획득 실패, couponId: {}", discountCouponId);
                throw new ApiException(ErrorStatus.COUPON_BAD_REQUEST);
            }
						
						// 락 획득 후에만 실제 쿠폰 발급 로직 수행 
            return userCouponTransactionalService.issueUserCoupon(storeId, couponId, user);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.warn("[Coupon - 쿠폰 발급] 락 인터럽트", e);
            throw new ApiException(ErrorStatus.STORE_BAD_REQUEST);
        } finally { // 락 해제
            if (lock.isHeldByCurrentThread()) {
                try {
                    lock.unlock();
                } catch (Exception e) {
                    log.error("[Coupon - 쿠폰 발급] 락 해제 실패", e);
                }
            }
        }
    }
  • 쿠폰 ID 기반으로 락 키 생성
    • "coupon-lock:id:123" 형태로 고유하게 생성
  • tryLock()으로 락 획득을 시도함
    • 첫 번째 인자 waitTime: 락을 기다릴 최대 시간 (3초)
    • 두 번째 인자 leaseTime: 락이 자동으로 해제될 시간 (3초)
    • → 락이 일정 시간 후 자동으로 풀려 데드 락 방지
  • 락 획득 실패 시 예외 처리 → 재고가 동시에 소진 중일 경우 안정적 대응
  • 동시성 환경에서도 정확히 하나의 요청만 쿠폰을 발급

📊 결과

image.png

4. 대용량 데이터 처리

🧾 발급 가능한 쿠폰 목록 조회

📌 배경

  • 유저가 발급 가능한 쿠폰 목록을 조회하기 위해 → DiscountCoupon 테이블과 UserCoupon 테이블을 조인하여 필터링 수행
  • 유저 수 × 쿠폰 수가 많아질수록 → 조회 성능 급격히 저하

💡 해결 전략

  • DB 인덱스 최적화
    • user_id + coupon_id + is_used 복합 인덱스 설정 → 조인 및 서브쿼리 성능 개선
  • DTO Projection 적용
    • 조회 대상 컬럼만 선택적으로 가져와 불필요한 데이터 로딩 방지

👩‍💻 구현 코드

1. DB 인덱스 최적화

// DiscountCoupon : 가게별 쿠폰 + 삭제 여부로 필터링
@Table(name = "discount_coupon", indexes = @Index(name = "idx_discount_coupon_store_deleted ", columnList = "store_id, is_deleted"))

// UserCoupon : (user, coupon) 유일성 보장 + 미사용 쿠폰만 필터링
@Table(name = "user_coupon", uniqueConstraints = {
    @UniqueConstraint(columnNames = {"user_id", "coupon_id"})
}, indexes = {
    @Index(name = "idx_user_coupon_user_coupon_used", columnList = "user_id, coupon_id, is_used")
})
 2. DTO Projection
 
    @Query("""
    SELECT new com.example.springplusteamproject.domain.coupon.dto.response.IssuableUserCouponResponseDto(
        dc.id, dc.couponName, dc.discount, dc.issuedAt, dc.expiresAt, dc.stock
    ) 
    FROM DiscountCoupon dc
    WHERE dc.store.id = :storeId
      AND dc.isDeleted = false
      AND NOT EXISTS (
          SELECT 1
          FROM UserCoupon uc
          WHERE uc.discountCoupon.id = dc.id
            AND uc.user.id = :userId
            AND uc.isUsed = false
      )
    """)
    List<IssuableUserCouponResponseDto> findIssuableCouponDtoList(
        @Param("userId") Long userId,
        @Param("storeId") Long storeId
    );

📊 결과

적용 항목효과
복합 인덱스 적용대용량 환경에서도 효율적 조회
EXISTS 사용인덱스 활용 최적화, 최대 43% 성능 개선
DTO Projection필요한 필드만 조회, 최대 68% 성능 개선

image.png

5. 캐싱

🛒  인기 가게 조회

📌 배경

  • 인기 가게 리스트는 조회 빈도가 매우 높음
  • 하지만 실시간으로 자주 변경되지는 않음 → 매번 DB에서 조회 시 성능 저하 및 부하 발생

💡 해결 전략

  • Redis 캐싱 활용
    • Redis를 단순 캐시가 아닌 카운터 저장소로 사용
      • 각 가게에 대한 조회 수 증가를 Redis에서 처리
      • 인기 순위는 사용자 액션(조회수)에 따라 실시간으로 변동
      • 추후 DB 저장 방식으로 변환 용이
  • 따라서, 정적인 캐싱이 아닌 동적인 캐싱 구조 설계 → 사용자 행동 기반의 실시간 인기 가게 목록 제공

👩‍💻 구현 코드

    // 인기 가게 조회수 기준 정렬 및 TOP 10 추출
    @Override
    public List<StoreResponseDto> getPopularStoresByView() {
        Set<String> keys = longRedisTemplate.keys("store:viewcount:*");

        return keys.stream()
            .map(k -> {
                Long id = Long.parseLong(k.replace("store:viewcount:", ""));
                Object raw = longRedisTemplate.opsForValue().get(k);
                Long count = (raw instanceof Long) ? (Long) raw
                    : (raw instanceof Integer) ? ((Integer) raw).longValue()
                    : 0L;
                return Map.entry(id, count);
            })
            .sorted((a, b) -> Long.compare(b.getValue(), a.getValue()))
            .limit(10)
            .map(e -> storeRepository.findByIdAndDeletedFalse(e.getKey()).orElse(null))
            .filter(Objects::nonNull)
            .map(StoreResponseDto::fromEntity)
            .toList();
    }
    
		// 인기 가게 리스트를 10분마다 Redis에 캐싱
    @Scheduled(initialDelay = 0, fixedRate = 600_000)
    public void updatePopularStoresCache() {
        List<StoreResponseDto> topStores = getPopularStoresByView();
        objectRedisTemplate.opsForValue().set("popular:stores:view", topStores, CACHE_TTL);
        log.info("[Store - 캐싱]인기 상점 캐시 갱신 완료 ({}개)", topStores.size());
    }

📊 결과

  • 테스트 설계
    항목내용
    목적커서 기반 DB조회 vs Redis 캐시 기반 인기 상점 조회 성능 비교
    실행 환경Local
    도구k6
    테스트 대상1,000 ~ 10,000명의 유저의 동시 요청 시나리오
    부하 단계1단계 : 1~10 초간 1,000명의 유저가 동시 요청, 2단계 : 11~20 초간 5,000명의 유저가 동시 요청, 3단계 : 21~30 초간 10,000명의 유저가 동시 요청
    1 단계 부하 테스트 결과 요약
    항목DB 커서 조회Redis 캐시 조회
    평균 응답347ms238ms
    최대 응답804ms595ms
    95% 응답457ms317ms
    요청 처리량
    (req/s)2,7614,184
    총 요청 수28,50641,657
    실패율1.8%1.2%
    1 ~3 단계 부하 테스트 결과 요약
    항목DB 커서 조회Redis 캐시 조회
    평균 응답1.6s1.06s
    최대 응답13.5s13.5s
    95% 응답3.37s2.34s
    요청 처리량(req/s)1,7742,155
    총 요청 수108,000150,845
    실패율5.6%5.6%

캐시는 빠르지만, 갱신 타이밍이나 Redis saturation 시 실패율이 증가하였음
1. 캐시가 응답 시간, 처리량 면에서 전 구간 우세
2. Redis saturation 시점(갱신 or 트래픽 픽크)에 실패율 급증
3. 캐시가 빠르지만 구조적 보완 필요 → 추후 개선

6. 성과 및 회고

👩‍💻 추후 개선 내용


✅ Redis 캐싱 구조 개선

‼문제점

  • Redis는 빠르고 확장성 높지만 TTL 만료, 동시 갱신 충돌, SPOF, 부하 집중 시 불안정

💡 개선 방향

  • Double Buffering

    → 캐시 갱신 중 충돌 방지 (기존 캐시 유지 + 신규 캐시 준비 후 전환)

  • Lazy TTL / Fallback

    → TTL 만료 시 캐시 miss 최소화 및 DB fallback 후 재캐싱

  • Redis 구조 확장

    → Cluster: 샤딩 통한 수평 확장

    → Sentinel: 자동 Failover로 고가용성

    → Pipeline: 명령어 일괄 처리로 속도 향상

  • ⚠️ Null 응답 + DB fallback

     → 캐시 miss 시 null 반환 → DB 조회 후 재캐싱 (병목 최소화)

✅ 재고 감소 Redis 리팩토링

‼문제점

  • createOrder조회 ~ 주문 생성까지 과도한 책임 수행 → SRP 위반
  • 내부에서 @Transactional + REQUIRES_NEW 중첩 호출 → 재고는 차감됐지만 주문은 롤백되는 트랜잭션 충돌 → 락 해제 누락 시 Deadlock 위험

💡 개선 방향

✔ 락 → 트랜잭션 → 커밋 → 락 해제 흐름 보장

  1. Controller에서 lockAndCreateOrder 호출 → 락 먼저 획득
  2. Service에서 단일 @Transactional재고 차감부터 주문 생성까지 일괄 처리

👩‍💻 구현 코드

  • 락을 먼저 획득
public Flower lockAndDecreaseStock(OrderRequestDTO dto, CustomUserPrincipal principal) {
        String lockKey = "order-lock:user:" + principal.getId();
    RLock lock = redissonClient.getFairLock(lockKey);

    try {
        if (!lock.tryLock(500, -1, TimeUnit.MILLISECONDS)) {
            throw new RuntimeException(락 획득 실패: " + lockKey);
        }

        return orderService.createOrder(dto, principal);

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new ApiException(ErrorStatus.ORDER_BAD_REQUEST);
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}
  • Create 호출
@Transactional
public OrderResponseDTO createOrder(
OrderRequestDTO requestDTO, CustomUserPrincipal principal
) {
    //유저 조회
    User user = userRepository.findById(principal.getId())
        .orElseThrow(() -> new ApiException(ErrorStatus.ORDER_USER_NOT_FOUND));

    List<OrderItem> orderItems = new ArrayList<>();
    //전체 금액
    int priceTotal = 0;
    Long discount = 0L;
    for(OrderItemRequestDTO item : requestDTO.getItems()) {
        // 꽃 Id(상품Id)
        Long flowerId = item.getFlowerId();
        //수량
        int quantity = item.getQuantity();

        Flower flower = flowerRepository.findById(flowerId)
            .orElseThrow(() -> new ApiException(ErrorStatus.ORDER_FLOWER_NOTFOUND));
	        log.info("재고 감소전 >>> {}", flower.getStock());
	        flower.decreaseStock(quantity);
	        log.info("재고 감소후 >>> {}", flower.getStock());

        //단가
        int unitPrice = foundFlower.getPrice();
        int subTotal = unitPrice * quantity;
        priceTotal += subTotal;

        Price price = Price.of(subTotal, discount);
        OrderItem orderItem = OrderItem.of(null, foundFlower, quantity, price);
        orderItems.add(orderItem);
    }
    UserCoupon foundUserCoupon = null;

    //쿠폰이있으면
    if(requestDTO.getUserCouponId() != null) {
            foundUserCoupon = userCouponRepository
	            .findById(requestDTO.getUserCouponId())
	            .orElseThrow(() -> 
		            new ApiException(ErrorStatus.ORDER_COUPON_NOTFOUND));

        //본인확인
        if(!foundUserCoupon.getUser().getId().equals(user.getId())){
            throw new ApiException(ErrorStatus.ORDER_COUPON_OWNER_MISMATCH);
        }

        //중복 방지
        if(foundUserCoupon.isUsed()){
            throw new ApiException(ErrorStatus.ORDER_COUPON_ALREADY_USED);
        }

        //할인
        discount = foundUserCoupon.getDiscountCoupon().getDiscount();
        //dirty
        foundUserCoupon.useCoupon();

    }
    //가격 저장
    Price price = Price.of(priceTotal,discount);

    //주문 저장
    Order order = Order.of(user, price, orderItems);

    if(foundUserCoupon != null){
        order.setUserCoupon(foundUserCoupon);
    }
    Order savedOrder = orderRepository.save(order);

    //결제 API

    //dirty PAID
    order.updateStatus();
    return OrderResponseDTO.from(savedOrder);
}

🧾최종 정리

이번 프로젝트 “꽃향기만 남기고 갔단다”는 실제 쇼핑몰 서비스 구조를 기반으로,

동시성 제어, 대용량 데이터 처리, 캐싱 구조 고도화

실무 중심의 백엔드 핵심 기술을 직접 다뤄볼 수 있었던 경험이었습니다.

  • Redis 기반 분산 락비관적 락을 통해 재고, 쿠폰 발급 등의 동시성 문제 해결
  • 복합 인덱스 + DTO Projection으로 대용량 데이터 조회 성능 최적화
  • Redis 캐시를 단순 저장소가 아닌 실시간 인기 카운터로 활용 → k6 기반 부하 테스트를 통해 성능 수치화 및 병목 파악, 개선 포인트 도출

또한, 기술적인 고도화 외에도

팀원 간 자유로운 소통, 정기적인 회의, 문서 기반 협업을 통해

실제 개발 협업에서의 유연한 커뮤니케이션협업 역량의 중요성체감할 수 있었습니다.

다만,

  • 일정 관리 미흡
  • 트러블 슈팅 및 기술 회고의 부족
  • 코드 주석 작성의 일관성 부족

등은 다음 프로젝트에서 개선해야 할 과제로 남았습니다.

이번 프로젝트는 단순한 기능 구현을 넘어,

서비스의 확장성과 안정성고려기술 설계협업 경험을 모두 담아낸 소중한 기회였습니다.


profile
노력하고 있다니까요?

0개의 댓글