| 이름 | 직책 | 역할 |
|---|---|---|
| 기 원 | 팀장 | 가게 도메인, 분위기 메이커, 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
| 도메인 | 상세 내용 | 제어 전략 |
|---|---|---|
| User | 회원 가입 시 이메일/닉네임 중복 제어 | 분산 락 (Redis) |
| Store | 가게 생성 시 가게 명 중복 제어 | 분산 락 (Redis) |
| Coupon | 쿠폰 발급 시 재고 감소 제어 | 분산 락 (Redis) |
| Order | 주문 시 상품 재고 감소 제어 | 비관적 락 → 고도화 : 분산 락 (Redis) |
| 도메인 | 상세 내용 | 처리 전략 | key |
|---|---|---|---|
| Store | 가게 검색 속도 개선 | 복합 인덱스 + Redis 직렬화 최적화 | store_name + deleted |
| Coupon | 유저별 발급 가능 쿠폰 조회 최적화 | 복합 인덱스 + EXISTS + DTO Projection | user_id + coupon_id + is_used |
| 도메인 | 상세 내용 | 캐싱 전략 | 저장소 | 비고 |
|---|---|---|---|---|
| Store | 인기 가게 리스트 | 조회 수 기반 자동 캐싱 | Redis (카운터 기반) | 실시간 조회 수를 누적하여 동적으로 반영 |
| Flower | 인기 검색어(오늘/이번 달/올해 기준) 검색 결과 | 스케줄 기반 사전 캐싱 | Redis | 프로그램 시작 시 + 매 시간 주기로 인기 검색어 미리 캐싱 |
@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);
}
}
}
}
"coupon-lock:id:123" 형태로 고유하게 생성tryLock()으로 락 획득을 시도함waitTime: 락을 기다릴 최대 시간 (3초)leaseTime: 락이 자동으로 해제될 시간 (3초)DiscountCoupon 테이블과 UserCoupon 테이블을 조인하여 필터링 수행user_id + coupon_id + is_used 복합 인덱스 설정 → 조인 및 서브쿼리 성능 개선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% 성능 개선 |
// 인기 가게 조회수 기준 정렬 및 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명의 유저가 동시 요청 |
| 항목 | DB 커서 조회 | Redis 캐시 조회 |
|---|---|---|
| 평균 응답 | 347ms | 238ms |
| 최대 응답 | 804ms | 595ms |
| 95% 응답 | 457ms | 317ms |
| 요청 처리량 | ||
| (req/s) | 2,761 | 4,184 |
| 총 요청 수 | 28,506 | 41,657 |
| 실패율 | 1.8% | 1.2% |
| 항목 | DB 커서 조회 | Redis 캐시 조회 |
|---|---|---|
| 평균 응답 | 1.6s | 1.06s |
| 최대 응답 | 13.5s | 13.5s |
| 95% 응답 | 3.37s | 2.34s |
| 요청 처리량(req/s) | 1,774 | 2,155 |
| 총 요청 수 | 108,000 | 150,845 |
| 실패율 | 5.6% | 5.6% |
캐시는 빠르지만, 갱신 타이밍이나 Redis saturation 시 실패율이 증가하였음
1. 캐시가 응답 시간, 처리량 면에서 전 구간 우세
2. Redis saturation 시점(갱신 or 트래픽 픽크)에 실패율 급증
3. 캐시가 빠르지만 구조적 보완 필요 → 추후 개선
Double Buffering
→ 캐시 갱신 중 충돌 방지 (기존 캐시 유지 + 신규 캐시 준비 후 전환)
Lazy TTL / Fallback
→ TTL 만료 시 캐시 miss 최소화 및 DB fallback 후 재캐싱
Redis 구조 확장
→ Cluster: 샤딩 통한 수평 확장
→ Sentinel: 자동 Failover로 고가용성
→ Pipeline: 명령어 일괄 처리로 속도 향상
⚠️ Null 응답 + DB fallback
→ 캐시 miss 시 null 반환 → DB 조회 후 재캐싱 (병목 최소화)
createOrder가 조회 ~ 주문 생성까지 과도한 책임 수행 → SRP 위반@Transactional + REQUIRES_NEW 중첩 호출 → 재고는 차감됐지만 주문은 롤백되는 트랜잭션 충돌 → 락 해제 누락 시 Deadlock 위험✔ 락 → 트랜잭션 → 커밋 → 락 해제 흐름 보장
lockAndCreateOrder 호출 → 락 먼저 획득@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();
}
}
}
@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);
}
이번 프로젝트 “꽃향기만 남기고 갔단다”는 실제 쇼핑몰 서비스 구조를 기반으로,
동시성 제어, 대용량 데이터 처리, 캐싱 구조 고도화 등
실무 중심의 백엔드 핵심 기술을 직접 다뤄볼 수 있었던 경험이었습니다.
또한, 기술적인 고도화 외에도
팀원 간 자유로운 소통, 정기적인 회의, 문서 기반 협업을 통해
실제 개발 협업에서의 유연한 커뮤니케이션과 협업 역량의 중요성을 체감할 수 있었습니다.
다만,
등은 다음 프로젝트에서 개선해야 할 과제로 남았습니다.
이번 프로젝트는 단순한 기능 구현을 넘어,
서비스의 확장성과 안정성을 고려한 기술 설계와 협업 경험을 모두 담아낸 소중한 기회였습니다.