Skip to content
Open
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
Binary file added .DS_Store
Binary file not shown.
26 changes: 23 additions & 3 deletions http/movie.http
Original file line number Diff line number Diff line change
@@ -1,7 +1,27 @@
### 현재 상영 중인 영화 조회
GET http://localhost:8080/api/movies/now-playing?theaterId=2&genre=SF
GET http://localhost:8080/api/movies/now-playing
Content-Type: application/json

### 장르별 영화 조회 (구현 예정)
GET http://localhost:8080/api/movies/genre?theaterId=1&genre=ACTION
### 특정 상영관 영화 조회
GET http://localhost:8080/api/movies/now-playing?theaterId=1
Content-Type: application/json

### 영화 제목 조회
GET http://localhost:8080/api/movies/now-playing?title=범죄도시 4
Content-Type: application/json

### 특정 상영관 + 영화 제목 조회
GET http://localhost:8080/api/movies/now-playing?theaterId=1&title=범죄도시 4
Content-Type: application/json

### 장르별 영화 조회
GET http://localhost:8080/api/movies/now-playing?genre=ACTION
Content-Type: application/json

### 상영관 + 제목 조회
GET http://localhost:8080/api/movies/now-playing?theaterId=1&title=범죄도시 4
Content-Type: application/json

### 상영관 + 제목 조회
GET http://localhost:8080/api/movies/now-playing?theaterId=1&title=범죄도시 4
Content-Type: application/json
Binary file added k6-scripts/.DS_Store
Binary file not shown.
100 changes: 6 additions & 94 deletions k6-scripts/movie-api-test.js
Original file line number Diff line number Diff line change
@@ -1,98 +1,10 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';

// 성능 요구사항에 따른 RPS 계산
// 실제 테스트에서는 N 값을 적절하게 설정 (예: 5000 DAU)
const N = 10; // DAU
const dailyAccessPerUser = 2;
const dailyTotalAccess = N * dailyAccessPerUser;
const avgRPS = dailyTotalAccess / 86400;
const peakRPS = avgRPS * 10; // 피크 시간대 RPS

// 테스트 구성
import { sleep } from 'k6';
export const options = {
// 단계별 부하 증가 프로필
stages: [
{ duration: '1m', target: Math.ceil(N * 0.2) }, // 워밍업: DAU의 20%
{ duration: '2m', target: Math.ceil(N * 0.5) }, // 증가: DAU의 50%
{ duration: '5m', target: N }, // 피크: 전체 DAU
{ duration: '2m', target: Math.ceil(N * 0.5) }, // 감소: DAU의 50%
{ duration: '1m', target: 0 }, // 정리: 0명
],

// 성능 임계값 설정
thresholds: {
'http_req_duration': ['p(95)<200'], // 95% 요청의 응답 시간이 200ms 이하
'http_req_failed': ['rate<0.01'], // 실패율 1% 이하
},
vus: 10,
duration: '30s',
};

// 영화 제목 샘플 데이터
const movieTitles = new SharedArray('movie titles', function() {
return [
'범죄도시 4',
'기생수: 더 그레이',
'서복',
'노량: 죽을 때까지',
'듄: 파트 2',
'귀멸의 칼날'
];
});

// 장르 샘플 데이터
const genres = new SharedArray('genres', function() {
return [
'ACTION',
'DRAMA',
'SF',
'ANIMATION'
];
});

// 극장 ID 샘플
const theaterIds = new SharedArray('theater ids', function() {
// 1부터 20까지의 극장 ID 생성
return Array.from({ length: 20 }, (_, i) => i + 1);
});

export default function() {
// 랜덤 극장 선택
const theaterId = theaterIds[Math.floor(Math.random() * theaterIds.length)];

// 시나리오별 비율 설정 (사용자 행동 패턴 시뮬레이션)
const scenario = Math.random();

let response;

// 시나리오 1: 기본 조회 (50%)
if (scenario < 0.5) {
response = http.get(`http://localhost:8080/api/movies/now-playing?theaterId=${theaterId}`);
}
// 시나리오 2: 제목 검색 (20%)
else if (scenario < 0.7) {
const title = movieTitles[Math.floor(Math.random() * movieTitles.length)];
response = http.get(`http://localhost:8080/api/movies/now-playing?theaterId=${theaterId}&title=${encodeURIComponent(title)}`);
}
// 시나리오 3: 장르 필터링 (20%)
else if (scenario < 0.9) {
const genre = genres[Math.floor(Math.random() * genres.length)];
response = http.get(`http://localhost:8080/api/movies/now-playing?theaterId=${theaterId}&genre=${genre}`);
}
// 시나리오 4: 제목 + 장르 검색 (10%)
else {
const title = movieTitles[Math.floor(Math.random() * movieTitles.length)];
const genre = genres[Math.floor(Math.random() * genres.length)];
response = http.get(`http://localhost:8080/api/movies/now-playing?theaterId=${theaterId}&title=${encodeURIComponent(title)}&genre=${genre}`);
}

// 응답 검증
check(response, {
'status is 200': (r) => r.status === 200,
'response time < 200ms': (r) => r.timings.duration < 200,
});

// 사용자 행동 시뮬레이션을 위한 대기 시간
// 피크 시간대 요청률을 맞추기 위해 적절히 조정
sleep(Math.random() * 3 + 1); // 1-4초 사이 무작위 대기
export default function () {
http.get('http://localhost:8080/api/movies/now-playing');
sleep(1);
}
9 changes: 8 additions & 1 deletion module-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,12 @@ dependencies {

// API 관련 의존성
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
}

// core 모듈의 리소스를 api 모듈로 복사
processResources {
from(project(':module-core').sourceSets.main.resources) {
include 'application.yml'
rename 'application.yml', 'core-application.yml'
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
package com.hanghae.module.api.controller;

import com.hanghae.module.api.dto.request.MovieRequest;
import com.hanghae.module.api.dto.response.MovieResponse;
import com.hanghae.module.common.dto.MovieDTO;
import com.hanghae.module.common.enums.Genre;
import com.hanghae.module.core.service.MovieService;
import jakarta.validation.constraints.Size;
import com.hanghae.module.domain.service.MovieService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
Expand All @@ -23,12 +21,10 @@ public class MovieController {

@GetMapping("/now-playing")
public ResponseEntity<List<MovieResponse>> getNowPlayingMovies(
@RequestParam(required = true) Long theaterId,
@Size(max = 255, message = "영화 제목은 255자를 초과할 수 없습니다.")
@RequestParam(required = false) String title,
@RequestParam(required = false) Genre genre
MovieRequest request
) {
List<MovieDTO> movies = movieService.findAllNowPlayingMovies(theaterId, title, genre);
List<MovieDTO> movies = movieService.findAllNowPlayingMovies(request.getTheaterId(), request.getTitle(),
request.getGenre());

List<MovieResponse> response = movies.stream()
.map(MovieResponse::from)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.hanghae.module.api.controller;

import com.hanghae.module.api.dto.request.ReservationRequest;
import com.hanghae.module.api.dto.response.ReservationResponse;
import com.hanghae.module.core.facade.ReservationFacade;
import com.hanghae.module.domain.model.Reservation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/reservations")
@RequiredArgsConstructor
public class ReservationController {

private final ReservationFacade reservationFacade;

@PostMapping
public ResponseEntity<ReservationResponse> reserve(@Valid @RequestBody ReservationRequest request) {
Reservation reservation = reservationFacade.reserve(request.getUserId(), request.getScreeningId(), request.getSeatNumbers());
return ResponseEntity.status(HttpStatus.CREATED).body(ReservationResponse.from(reservation));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.hanghae.module.api.dto.request;

import com.hanghae.module.common.enums.Genre;
import jakarta.validation.constraints.Size;
import lombok.*;

@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MovieRequest {
private Long theaterId;

@Size(max = 255, message = "영화 제목은 255자를 초과할 수 없습니다.")
private String title;

private Genre genre;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.hanghae.module.api.dto.request;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.List;

@Getter
@NoArgsConstructor
public class ReservationRequest {

@NotNull(message = "사용자 ID는 필수 입력값입니다.")
private Long userId;

@NotNull(message = "상영 ID는 필수 입력값입니다.")
private Long screeningId;

@NotEmpty(message = "최소 1개 이상의 좌석을 선택해야 합니다.")
@Size(max = 5, message = "최대 5개까지 좌석 예약이 가능합니다.")
private List<String> seatNumbers;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.hanghae.module.api.dto.response;

import com.hanghae.module.common.enums.ReservationStatus;
import com.hanghae.module.domain.model.Reservation;
import com.hanghae.module.domain.model.ReservationSeat;
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;

@Getter
@Builder
public class ReservationResponse {
private Long reservationId;
private Long userId;
private Long screeningId;
private List<String> seatNumbers;
private ReservationStatus status;
private LocalDateTime createdAt;

public static ReservationResponse from(Reservation reservation) {
return ReservationResponse.builder().
reservationId(reservation.id()).
userId(reservation.user()).
screeningId(reservation.screening()).
seatNumbers(reservation.seatNumbers()).
status(reservation.status()).
createdAt(reservation.createdAt()).
build();
}
}
2 changes: 1 addition & 1 deletion module-api/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ spring:
jpa:
show-sql: true
hibernate:
ddl-auto: update
ddl-auto: create-drop
properties:
hibernate:
format_sql: true # SQL 로깅 시 포맷팅
Expand Down
Loading