Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* module-movie: 영화와 관련된 작업을 합니다.
* module-theater: 상영관과 관련된 작업을 합니다.
* module-screening: 상영 정보와 관련된 작업을 합니다.
* module-reservation: 예약과 관련된 작업을 합니다.
* module-userr: 회원 정보와 관련된 작업을 합니다.
* module-common: Auditing 등 모든 모듈에 적용될 작업을 합니다.
* module-api: 현재 상영 중인 영화 조회 API 등 영화, 상영관, 상영 정보 외의 api와 관련된 작업을 합니다.

Expand Down Expand Up @@ -31,7 +33,22 @@

[성능 테스트 보고서](https://alkaline-wheel-96f.notion.site/180e443fee6880caac97deb79ed284d9)

* leaseTime: 응답시간이 10초 정도 걸려 10초로 설정했습니다.
* waitTime: 설정한 leaseTime보다 좀 더 기다릴 수 있도록 설정했습니다.
* leaseTime: http_req-duration의 avg값은 44.1ms이고 max가 1.27s기 때문에 max 값까지 다룰 수 있도록 2초로 설정했습니다.
* waitTime: leaseTime 보다 약간 길게 두어 4초로 설정했습니다.

[분산 락 테스트 보고서](https://alkaline-wheel-96f.notion.site/187e443fee68800cbbcef4041b8d55b8)


### Jacoco Report
* module-common
![module-common](https://github.com/user-attachments/assets/2d0b9445-4f8f-4d72-be15-62b2e00a74f2)

* module-reservation
![module-reservation](https://github.com/user-attachments/assets/ffccbf18-362d-4131-bbb8-331419977791)

* module-screening
![mocule-screening](https://github.com/user-attachments/assets/d958a296-e285-468f-9dc4-78c939a2ca5a)

* module-api
![5주차 module-api](https://github.com/user-attachments/assets/188c3f25-048c-42a8-afaf-b6e4c8f1dca8)

79 changes: 79 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ subprojects { // 모든 하위 모듈들에 적용
apply plugin: 'java-library'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'jacoco' // Jacoco 플러그인 추가

jacoco {
// JaCoCo 버전
toolVersion = '0.8.8'
}

configurations {
compileOnly {
Expand All @@ -44,9 +50,82 @@ subprojects { // 모든 하위 모듈들에 적용
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'mysql:mysql-connector-java:8.0.33'
compileOnly 'org.projectlombok:lombok'
annotationProcessor "org.projectlombok:lombok"
}

tasks.named('test') {
useJUnitPlatform()
finalizedBy 'jacocoTestReport' // test가 끝나면 jacocoTestReport 동작
}

def excludedPackages = [
'**/dto/**', // dto 패키지 제외
'**/config/**', // config 패키지 제외
'**/exception/**', // exception 패키지 제외
'**/domain/**', // domain 패키지 제외
'**/aop/**', // aop 패키지 제외
'**/*Application*', // Application 클래스 제외
'**/Q*' // QueryDSL 자동 생성 클래스 제외
]

def Qdomains = ('A'..'Z').collect { "**/Q${it}*" }
def allExcludes = excludedPackages + Qdomains

// jacoco report 설정
jacocoTestReport {
reports {
html.required = true
xml.required = true
csv.required = false
html.outputLocation = layout.buildDirectory.dir("reports/jacoco/test/html")
}

afterEvaluate {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: allExcludes)
}))
}

// jacocoTestReport가 끝나면 jacocoTestCoverageVerification 동작
dependsOn test
finalizedBy 'jacocoTestCoverageVerification'
}

// jacoco 커버리지 검증 설정
jacocoTestCoverageVerification {
violationRules {
rule {
enabled = true // 커버리지 적용 여부
element = 'CLASS' // 커버리지 적용 단위

// 라인 커버리지 설정
// 적용 대상 전체 소스 코드들을 한줄 한줄 따졌을 때 테스트 코드가 작성되어 있는 줄의 빈도
// 테스트 코드가 작성되어 있는 비율이 90% 이상이어야 함
limit {
counter = 'INSTRUCTION'
value = 'COVEREDRATIO'
minimum = 0.30
}

// 브랜치 커버리지 설정
// if-else 등을 활용하여 발생되는 분기들 중 테스트 코드가 작성되어 있는 빈도
// 테스트 코드가 작성되어 있는 비율이 90% 이상이어야 함
limit {
counter = 'BRANCH'
value = 'COVEREDRATIO'
minimum = 0.30
}

excludes = allExcludes
}
}

afterEvaluate {
// 제외 규칙을 classDirectories에 적용
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: allExcludes)
}))
}
}
}
5 changes: 4 additions & 1 deletion init-scripts/01-create-table.sql
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,7 @@ CREATE TABLE seat (

ALTER TABLE seat MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0;

INSERT INTO users (name, age) VALUES ("123", 29);
INSERT INTO users (name, age) VALUES ("user1", 20);
INSERT INTO users (name, age) VALUES ("user2", 21);
INSERT INTO users (name, age) VALUES ("user3", 22);
INSERT INTO users (name, age) VALUES ("user4", 23);
5 changes: 5 additions & 0 deletions module-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ dependencies {
implementation project(':module-common')
implementation project(':module-screening')
implementation project(':module-reservation')
implementation project(':module-movie')
implementation project(':module-user')
implementation project(':module-theater')

implementation 'org.springframework.boot:spring-boot-starter-webflux'

compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package hellojpa.controller;

import hellojpa.dto.RateLimitResponseDto;
import hellojpa.dto.ReservationRequestDto;
import hellojpa.service.ReservationService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
public class ReservationController {

private final ReservationService reservationService;

@PostMapping("/reservation/movie")
public ResponseEntity<RateLimitResponseDto<String>> reserveSeats(@Valid @RequestBody ReservationRequestDto requestDto) {
reservationService.reserveSeats(requestDto);
return ResponseEntity.ok(RateLimitResponseDto.success(null));
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
package hellojpa.controller;

import hellojpa.dto.ReservationDto;
import hellojpa.dto.RateLimitResponseDto;
import hellojpa.dto.ScreeningDto;
import hellojpa.dto.SearchCondition;
import hellojpa.service.ReservationService;
import hellojpa.service.ScreeningService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDate;
Expand All @@ -20,20 +18,13 @@
public class ScreeningController {

private final ScreeningService screeningService;
private final ReservationService reservationService;

@GetMapping("/screening/movies")
public List<ScreeningDto> getCurrentScreenings(@Valid @ModelAttribute SearchCondition searchCondition) {
public RateLimitResponseDto<List<ScreeningDto>> getCurrentScreenings(@Valid @ModelAttribute SearchCondition searchCondition) {
log.info("ModelAttribute.title: {}", searchCondition.getTitle());
log.info("ModelAttribute.genre: {}", searchCondition.getGenre());

return screeningService.findCurrentScreenings(LocalDate.now(), searchCondition);
List<ScreeningDto> currentScreenings = screeningService.findCurrentScreenings(LocalDate.now(), searchCondition);
return RateLimitResponseDto.success(currentScreenings);
}

@PostMapping("/reservation/movie")
public ResponseEntity<String> reserveSeats(@Valid @RequestBody ReservationDto requestDto) {
reservationService.reserveSeats(requestDto);
return ResponseEntity.ok("좌석 예약이 완료되었습니다.");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package hellojpa.controller;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import hellojpa.dto.RateLimitResponseDto;
import hellojpa.dto.ReservationRequestDto;
import hellojpa.service.ReservationRateLimitService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
@Transactional
class ReservationControllerTest {

@LocalServerPort
private int port;

@Autowired
private ObjectMapper objectMapper;

@Autowired
private ReservationRateLimitService reservationRateLimitService;

private WebTestClient webTestClient;
private final int THREAD_COUNT = 10; // 동시에 요청할 스레드 개수
private final ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);

@BeforeEach
void setUp() {
this.webTestClient = WebTestClient.bindToServer().baseUrl("http://localhost:" + port).build();
//reservationRateLimitService.resetAllLimits();
}

@Test
void 예약_정상처리() {
// Given
ReservationRequestDto requestDto1 = new ReservationRequestDto(1L, 1L, List.of(14L, 15L));

// When
var response = webTestClient.post()
.uri("/reservation/movie")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestDto1)
.exchange();

// Then
response.expectStatus().isOk()
.expectBody(RateLimitResponseDto.class)
.value(res -> {
assertThat(res.getStatus()).isEqualTo(200);
assertThat(res.getCode()).isEqualTo("success");
assertThat(res.getMessage()).isEqualTo("요청에 성공했습니다.");
});
}

@Test
void 예약_예외처리() {
// Given
ReservationRequestDto requestDto1 = new ReservationRequestDto(2L, 1L, List.of(1L, 3L));

// When
var response = webTestClient.post()
.uri("/reservation/movie")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestDto1)
.exchange();

// Then
response.expectStatus().isBadRequest()
.expectBody(RateLimitResponseDto.class)
.value(res -> {
assertThat(res.getStatus()).isEqualTo(400);
});
}

@Test
void RateLimit_초과시_예약_차단() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate Limiter가 동시 요청이 있을 시에도 잘 작동하는지와 관련된 테스트 코드를 하나 더 작성해보세요!

// Given
ReservationRequestDto requestDto1 = new ReservationRequestDto(3L, 1L, List.of(1L, 2L));
ReservationRequestDto requestDto2 = new ReservationRequestDto(3L, 1L, List.of(3L, 4L, 5L));


webTestClient.post()
.uri("/reservation/movie")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestDto1)
.exchange();

// When
var response = webTestClient.post()
.uri("/reservation/movie")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestDto2)
.exchange();

// Then
response.expectStatus().isEqualTo(HttpStatus.TOO_MANY_REQUESTS)
.expectBody(RateLimitResponseDto.class)
.value(res -> {
assertThat(res.getStatus()).isEqualTo(429);
assertThat(res.getCode()).isEqualTo("RATE_LIMIT_EXCEEDED");
});
}

@Test
void 동시_예약_테스트() throws InterruptedException, ExecutionException, JsonProcessingException {
// Given - 동일한 좌석을 동시에 예약하려는 요청들 생성
Long userId = 4L;
Long screeningId = 1L;
List<Long> seatIds = List.of(25L); // 같은 좌석을 여러 요청이 시도

List<Callable<WebTestClient.ResponseSpec>> tasks = new ArrayList<>();
for (int i = 0; i < THREAD_COUNT; i++) {
tasks.add(() -> webTestClient.post()
.uri("/reservation/movie")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new ReservationRequestDto(userId, screeningId, seatIds))
.exchange());
}

// When - 동시에 실행
List<Future<WebTestClient.ResponseSpec>> futures = executorService.invokeAll(tasks);

// Then - 결과 검증
int successCount = 0;
int failCount = 0;

for (Future<WebTestClient.ResponseSpec> future : futures) {
WebTestClient.ResponseSpec response = future.get();

// 서버에서 실제 응답된 Content-Type과 Body를 출력하여 확인
String responseBody = response.expectBody(String.class).returnResult().getResponseBody();
System.out.println("응답 Body: " + responseBody);

// JSON 파싱하여 DTO로 변환
ObjectMapper objectMapper = new ObjectMapper();
RateLimitResponseDto rateLimitResponse = objectMapper.readValue(responseBody, RateLimitResponseDto.class);

HttpStatus status = HttpStatus.valueOf(rateLimitResponse.getStatus());

if (status.equals(HttpStatus.OK)) {
successCount++;
} else {
failCount++;
}
}

System.out.println("성공한 예약 개수: " + successCount);
System.out.println("실패한 예약 개수: " + failCount);

// 하나 이상의 요청이 성공하고, 일부 요청은 실패해야 함 (좌석 중복 방지 로직이 동작해야 함)
assertThat(successCount).isGreaterThan(0);
assertThat(failCount).isGreaterThan(0);
}
}
Loading