Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs#171/README.md 작성 #172

Merged
merged 22 commits into from
Aug 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5461c1f
docs: README.md 초안 작성
Aug 29, 2024
8f4868c
hotfix: 레디스 설정 중복으로 로딩 실패하는 버그 해결
minnim1010 Aug 30, 2024
1916c9e
Hotfix#/캐시로 인해 페이지가 나오지 않던 문제 수정, 배치업데이트 버그 수정 (#173)
bellringstar Aug 30, 2024
ad54666
hotfix: 티켓 생성 후 구매 요청 시 티켓 정보 없음 문제 해결
minnim1010 Aug 30, 2024
f14e2fb
Merge branch 'develop' of https://github.com/woowa-techcamp-2024/Team…
minnim1010 Aug 30, 2024
5ec2d52
fix: 로그를 계속 읽어 큐가 가득차는 버그 수
Aug 30, 2024
a1dc861
hotfix: 티켓 생성 후 구매 요청 시 티켓 정보 없음 문제 해결
minnim1010 Aug 30, 2024
54f5793
fix: 티켓 디테일 api 수정
Aug 30, 2024
cf97c9d
fix: 티켓 참여 여부 화면 수정
Aug 30, 2024
1395b7d
fix: qr 체크인 이후 구매자 페이지로 이동하는 기능 제거
Aug 30, 2024
f1216d6
fix: 페이지네이션 버그 수
Aug 30, 2024
b5849eb
fix: 페이지네이션 버그 수정
Aug 30, 2024
d0ce72f
fix: 페이지네이션 조건 수
Aug 30, 2024
e9e33f4
fix: 체크인 여부 페이지 수정
Aug 30, 2024
4cfb6fd
fix: festival 엔티티 시간 저장 방식 변
Aug 30, 2024
b27a0c3
fix: 재고 부족 페이지 추가
Aug 30, 2024
171d436
hotfix: 페이지네이션 쿼리 수
Aug 30, 2024
bd8379c
hotfix: 페스티벌 저장 방식 변경
Aug 30, 2024
a7ad6a7
hotfix: 대기순번 로컬스토리지에 저장하도록 수정
Aug 30, 2024
ced97fb
docs: README.md 초안 작성
Aug 29, 2024
eda3a90
docs: README 작성
bellringstar Aug 31, 2024
8937d28
Merge branch 'docs#171' of https://github.com/woowa-techcamp-2024/Tea…
bellringstar Aug 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# 🎫 축제의 민족

## 📌 프로젝트 개요

이 프로젝트는 제한된 리소스 환경(AWS t3.small EC2 인스턴스 2대, RDS, Redis)에서 안정적인 운영을 목표로 하는 고성능 페스티벌 티켓 예매 시스템입니다. 순간적인 대규모 트래픽 상황에서도
안정적으로 동작하도록 설계되었으며, 효율적인 대기열 관리와 비동기 처리를 통해 사용자 경험을 최적화합니다.

### 프로젝트 목표

- 초당 1,000건 이상의 주문 처리
- 평균 응답 시간 100ms 이하
- 시스템 가용성 99.9% 이상

## 🚀 핵심 기능

1. **실시간 대기열 시스템**: Redis 기반의 공정한 대기열 관리
2. **안정적인 결제 처리**: 비동기 방식의 결제 처리 및 실시간 상태 조회
3. **효율적인 재고 관리**: Redis를 활용한 실시간 재고 추적
4. **비동기 주문 처리**: 인메모리 큐 시스템을 통한 주문 처리 최적화

## 🛠 기술 스택

<p align="center">
<img src="https://img.shields.io/badge/java-%23ED8B00.svg?style=for-the-badge&logo=java&logoColor=white" alt="Java" />
<img src="https://img.shields.io/badge/spring-%236DB33F.svg?style=for-the-badge&logo=spring&logoColor=white" alt="Spring" />
<img src="https://img.shields.io/badge/MySQL-00000F?style=for-the-badge&logo=mysql&logoColor=white" alt="MySQL" />
<img src="https://img.shields.io/badge/redis-%23DD0031.svg?style=for-the-badge&logo=redis&logoColor=white" alt="Redis" />
<img src="https://img.shields.io/badge/gradle-02303A?style=for-the-badge&logo=gradle&logoColor=white" alt="Gradle" />
<img src="https://img.shields.io/badge/grafana-%23F46800.svg?style=for-the-badge&logo=grafana&logoColor=white" alt="Grafana" />
<img src="https://img.shields.io/badge/Prometheus-E6522C?style=for-the-badge&logo=Prometheus&logoColor=white" alt="Prometheus" />
<img src="https://img.shields.io/badge/k6-7D64FF?style=for-the-badge&logo=k6&logoColor=white" alt="k6" />
</p>

## 📐 아키텍처

![아키텍처.png](img.png)

## 🔧 핵심 컴포넌트

### 1. 대기열 시스템 (WaitOrderService)

- 원리: 청크 기반의 대기열 관리 시스템
- 대기열 관리:
- Redis Set을 사용하여 각 티켓에 대한 대기열 유지
- 사용자 진입 시 현재 대기열의 크기를 기반으로 대기 순서 할당
- 입장 관리:
- passChunkSize 단위로 입장 가능한 범위 관리
- 주기적으로 입장 가능 범위를 passChunkSize만큼 증가시켜 순차적 입장 허용
- 입장 가능 여부 확인:
- 사용자의 대기 순서가 현재 입장 가능 범위 내에 있는지 확인
- 재고 여부도 함께 체크하여 입장 가능 여부 결정

### 2. 결제 시스템 (PurchaseFacadeService, PaymentService)

- 원리: 비동기 결제 처리 및 상태 관리
- 결제 프로세스:
- 결제 요청 시 UUID 기반의 결제 ID 생성 후 비동기로 결제 처리 시작
- CompletableFuture를 사용하여 비동기 결제 처리 구현
- 상태 관리:
- Caffeine 캐시를 사용하여 결제 상태 관리
- 결제 ID를 키로 사용하여 결제 정보 및 상태 저장
- 결제 완료 후 처리:
- 결제 성공 시 QueueService를 통해 구매 정보 처리
- 결제 실패 시 CompensationService를 통해 재고 및 상태 롤백

### 3. 재고 관리 (TicketStockCountRedisRepository)

- 원리: Redis를 활용한 실시간 재고 관리
- 재고 관리:
- Redis에 각 티켓의 재고 수량 저장
- 원자적 감소 연산을 사용하여 동시성 문제 해결
- 재고 동기화:
- TicketScheduleService를 통해 주기적으로 Redis의 재고 정보 업데이트
- 판매 시작 전 또는 진행 중인 티켓의 재고 정보만 Redis에 유지

### 4. 주문 처리 (QueueService)

- 원리: 인메모리 큐를 활용한 비동기 주문 처리
- 주문 접수:
- InMemoryQueue에 구매 데이터(PurchaseData) 저장
- 큐가 가득 찼을 경우 지수 백오프를 적용한 재시도 로직 구현
- 주문 처리:
- 주기적으로(5초마다) 큐에서 배치 단위로 주문 데이터 처리
- 배치 크기는 큐의 현재 크기에 따라 동적으로 조정
- 데이터 일관성:
- 주문 정보와 체크인 정보를 트랜잭션으로 묶어 일괄 처리
- JDBC batch update를 사용하여 데이터베이스 연산 최적화
- 장애 복구:
- 서버 재시작 시 로그 파일을 분석하여 미처리된 주문 복구
- 처리 실패한 주문에 대한 재시도 메커니즘 구현

## 📈 성능 최적화 전략

1. **인메모리 캐싱 및 데이터 관리**
- Redis를 활용한 대기열, 재고, 결제 상태 관리
- Caffeine 캐시를 이용한 애플리케이션 레벨 캐싱
- 캐시 워밍으로 콜드 스타트 문제 해결 및 초기 응답 시간 개선

2. **비동기 및 병렬 처리**
- CompletableFuture를 이용한 비동기 결제 처리
- 주문 처리를 위한 커스텀 인메모리 큐 구현
- 배치 처리를 통한 데이터베이스 쓰기 최적화

3. **데이터베이스 최적화**
- Skip Lock 적용으로 동시성 제어 및 성능 향상 (`SELECT ... FOR UPDATE SKIP LOCKED`)
- 커넥션 풀 최적화 (HikariCP 튜닝)
- 인덱스 최적화 및 쿼리 튜닝
- JDBC 배치 업데이트를 통한 벌크 연산 효율화

4. **대기열 시스템 설계**
- Redis Sorted Set을 활용한 공정한 대기열 관리
- 청크 단위의 입장 처리로 시스템 부하 분산
- 동적 대기열 크기 조정으로 리소스 활용 최적화

5. **네트워크 및 응답 최적화**
- JSON 페이로드 최소화로 네트워크 부하 감소
- 응답 데이터 압축 적용
- Keep-Alive 연결 활용으로 연결 비용 감소

6. **시스템 안정성 및 복구 전략**
- 적절한 타임아웃 설정 및 서킷 브레이커 패턴 적용
- 지수 백오프를 활용한 재시도 로직 구현
- 장애 상황 대비 복구 메커니즘 구현 (로그 기반 복구)

7. **모니터링 및 성능 분석**
- Grafana와 Prometheus를 활용한 실시간 시스템 모니터링
- 사용자 정의 메트릭을 통한 비즈니스 로직 모니터링
- 성능 병목 지점 식별 및 지속적인 개선

## 👥 팀 소개

저희는 **팀 twoDari**입니다. 사용자와 축제를 이어주는 다리 역할을 합니다.

| 이름 | 역할 | 주요 기여 | GitHub |
|-----|-----|--------------------------------------|--------------------------------------------------|
| 김현종 | 백엔드 | MySQL비동기 처리, 배치 처리, 프론트엔드 화면 구성 | [@bellringstar](https://github.com/bellringstar) |
| 김규원 | 백엔드 | 캐싱전략, Redis를 사용한 대기열, 프론트엔드 화면 구성 | [@kkyu0718](https://github.com/kkyu0718) |
| 김현준 | 백엔드 | devops, 스케쥴링 작업, 다양한 모듈 간 연계 및 성능 개선 | [@HyeonJun0530](https://github.com/HyeonJun0530) |
| 박민지 | 백엔드 | Redis를 사용한 대기열 로직, 프로젝트 모듈화 | [@minnim1010](https://github.com/minnim1010) |

## 📊 성능 테스트 결과

![test_result.png](img_1.png)
40 changes: 0 additions & 40 deletions backend/README.md

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,8 @@
import static com.wootecam.festivals.domain.festival.util.FestivalValidConstant.TITLE_BLANK_MESSAGE;
import static com.wootecam.festivals.domain.festival.util.FestivalValidConstant.TITLE_SIZE_MESSAGE;

import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.wootecam.festivals.domain.festival.entity.Festival;
import com.wootecam.festivals.domain.member.entity.Member;
import com.wootecam.festivals.global.utils.CustomLocalDateTimeSerializer;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Future;
import jakarta.validation.constraints.NotBlank;
Expand All @@ -33,12 +31,10 @@ public record FestivalCreateRequest(@NotBlank(message = TITLE_BLANK_MESSAGE)
@Size(max = MAX_DESCRIPTION_LENGTH, message = DESCRIPTION_SIZE_MESSAGE)
String description,

@JsonSerialize(using = CustomLocalDateTimeSerializer.class)
@NotNull(message = START_TIME_NULL_MESSAGE)
@Future(message = START_TIME_FUTURE_MESSAGE)
LocalDateTime startTime,

@JsonSerialize(using = CustomLocalDateTimeSerializer.class)
@NotNull(message = END_TIME_NULL_MESSAGE)
@Future(message = END_TIME_FUTURE_MESSAGE)
LocalDateTime endTime) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import com.wootecam.festivals.domain.festival.util.FestivalValidator;
import com.wootecam.festivals.domain.member.entity.Member;
import com.wootecam.festivals.global.audit.BaseEntity;
import com.wootecam.festivals.global.utils.DateTimeUtils;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
Expand Down Expand Up @@ -74,8 +73,8 @@ private Festival(Member admin, String title, String description, String festival
this.title = title;
this.description = description;
this.festivalImg = festivalImg;
this.startTime = DateTimeUtils.normalizeDateTime(startTime);
this.endTime = DateTimeUtils.normalizeDateTime(endTime);
this.startTime = startTime;
this.endTime = endTime;
this.festivalPublicationStatus =
festivalPublicationStatus == null ? FestivalPublicationStatus.PUBLISHED : festivalPublicationStatus;
this.festivalProgressStatus =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ public interface FestivalRepository extends JpaRepository<Festival, Long> {
)
FROM Festival f
JOIN f.admin a
WHERE (f.startTime > :startTime OR (f.startTime = :startTime AND f.id < :id))
WHERE (f.startTime > :startTime OR (f.startTime = :startTime AND f.id > :id))
AND f.isDeleted = false
AND f.festivalPublicationStatus != 'DRAFT'
AND f.startTime > :now
ORDER BY f.startTime ASC, f.id ASC
ORDER BY f.startTime ASC, f.id DESC
""")
List<FestivalListResponse> findUpcomingFestivalsBeforeCursor(@Param("startTime") LocalDateTime startTime,
@Param("id") long id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
Expand All @@ -44,6 +45,7 @@ public class FestivalService {
* @return 생성된 축제의 ID를 포함한 응답 DTO
*/
@Transactional
@CacheEvict(value = "festivalsFirstPage")
public FestivalIdResponse createFestival(FestivalCreateRequest requestDto, Long adminId) {
Member admin = memberRepository.findById(adminId)
.orElseThrow(() -> new ApiException(GlobalErrorCode.INVALID_REQUEST_PARAMETER, "유효하지 않는 멤버입니다."));
Expand Down Expand Up @@ -99,29 +101,28 @@ public FestivalResponse getFestivalDetail(Long festivalId) {
value = "festivalsFirstPage",
key = "#cursorTime + '_' + #cursorId + '_' + #pageSize",
condition = "#cursorTime == null && #cursorId == null && #pageSize > 0"
) public KeySetPageResponse<FestivalListResponse> getFestivals(LocalDateTime cursorTime,
)
public KeySetPageResponse<FestivalListResponse> getFestivals(LocalDateTime cursorTime,
Long cursorId,
int pageSize) {
LocalDateTime now = DateTimeUtils.normalizeDateTime(LocalDateTime.now());
Pageable pageRequest = PageRequest.of(0, pageSize + 1);

List<FestivalListResponse> festivals = festivalRepository.findUpcomingFestivalsBeforeCursor(
cursorTime != null ? cursorTime : now,
cursorId != null ? cursorId : Long.MAX_VALUE,
cursorId != null ? cursorId : 0L, // 변경: Long.MAX_VALUE 대신 0L 사용
now,
pageRequest);

boolean hasNext = festivals.size() > pageSize;
List<FestivalListResponse> pageContent = hasNext ? festivals.subList(0, pageSize) : festivals;

LocalDateTime nextCursorTime = null;
Long nextCursorId = null;
Cursor nextCursor = null;
if (hasNext) {
FestivalListResponse lastFestival = pageContent.get(pageContent.size() - 1);
nextCursorTime = lastFestival.startTime();
nextCursorId = lastFestival.festivalId();
nextCursor = new Cursor(lastFestival.startTime(), lastFestival.festivalId());
}

return new KeySetPageResponse<>(pageContent, new Cursor(nextCursorTime, nextCursorId), hasNext);
return new KeySetPageResponse<>(pageContent, nextCursor, hasNext);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,9 @@ public Purchase createPurchase(Member member) {
.purchaseStatus(PurchaseStatus.PURCHASED)
.build();
}

public boolean isSaleOnTime(LocalDateTime now) {
return (startSaleTime.isEqual(now) || startSaleTime.isBefore(now)) && ((endSaleTime.isEqual(now)
|| endSaleTime.isAfter(now)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.wootecam.festivals.domain.ticket.entity.TicketStock;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
Expand All @@ -16,13 +17,16 @@ public class TicketStockJdbcRepository {
private final JdbcTemplate jdbcTemplate;

public void saveTicketStocks(List<TicketStock> ticketStocks) {
String sql = "INSERT INTO ticket_stock (ticket_id) VALUES (?)";
String sql = "INSERT INTO ticket_stock (ticket_id, created_at, updated_at) VALUES (?, ?, ?)";
Timestamp now = new Timestamp(System.currentTimeMillis());

jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int index) throws SQLException {
TicketStock ticketStock = ticketStocks.get(index);
ps.setLong(1, ticketStock.getTicket().getId());
ps.setTimestamp(2, now);
ps.setTimestamp(3, now);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@
import com.wootecam.festivals.domain.ticket.dto.TicketResponse;
import com.wootecam.festivals.domain.ticket.entity.Ticket;
import com.wootecam.festivals.domain.ticket.entity.TicketStock;
import com.wootecam.festivals.domain.ticket.repository.CurrentTicketWaitRedisRepository;
import com.wootecam.festivals.domain.ticket.repository.TicketInfoRedisRepository;
import com.wootecam.festivals.domain.ticket.repository.TicketRepository;
import com.wootecam.festivals.domain.ticket.repository.TicketStockCountRedisRepository;
import com.wootecam.festivals.domain.ticket.repository.TicketStockJdbcRepository;
import com.wootecam.festivals.global.exception.type.ApiException;
import com.wootecam.festivals.global.utils.TimeProvider;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -32,6 +38,12 @@ public class TicketService {
private final FestivalRepository festivalRepository;
private final TicketCacheService ticketCacheService;

private final TicketInfoRedisRepository ticketInfoRedisRepository;
private final TicketStockCountRedisRepository ticketStockCountRedisRepository;
private final CurrentTicketWaitRedisRepository currentTicketWaitRedisRepository;

private final TimeProvider timeProvider;

/**
* 티켓 생성
*
Expand All @@ -40,6 +52,7 @@ public class TicketService {
* @return 생성된 티켓의 ID
*/
@Transactional
@CacheEvict(value = "ticketList", key = "#festivalId")
public TicketIdResponse createTicket(Long festivalId, TicketCreateRequest request) {
log.debug("티켓 생성 요청 - 축제 ID: {}", festivalId);

Expand All @@ -60,6 +73,17 @@ public TicketIdResponse createTicket(Long festivalId, TicketCreateRequest reques
TicketIdResponse response = new TicketIdResponse(newTicket.getId());
log.debug("티켓 생성 완료 - 티켓 ID: {}", response.ticketId());

LocalDateTime now = timeProvider.getCurrentTime();
if (newTicket.isSaleOnTime(now) || now.plusMinutes(10).isAfter(newTicket.getStartSaleTime())
|| now.minusMinutes(1).isAfter(newTicket.getStartSaleTime())) {
ticketInfoRedisRepository.setTicketInfo(newTicket.getId(), newTicket.getStartSaleTime(),
newTicket.getEndSaleTime());
currentTicketWaitRedisRepository.addCurrentTicketWait(newTicket.getId());
ticketStockCountRedisRepository.setTicketStockCount(newTicket.getId(), (long) newTicket.getQuantity());

log.debug("티켓 정보 redis 업데이트 완료 - 티켓 ID: {}, 판매 시작 시각: {}, 판매 종료 시각: {}", newTicket.getId(),
newTicket.getStartSaleTime(), newTicket.getEndSaleTime());
}
return response;
}

Expand Down
Loading
Loading