diff --git a/.gitignore b/.gitignore index 17c6fb1..655fb8d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,13 +20,12 @@ bin/ init/ docker-compose-monitoring.yml lock-test.yaml -prometheus.yml reservation-lookup-performance-test.yaml result.json result.json.html ### Spring Boot 환경설정 파일 무시 (보안 필수) ### - +.env # 혹시 최상위에 뒀을 경우를 대비해 추가 application-dev.yml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1e6dcc9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Form PASS is an event reservation system built with Spring Boot 3.4.12 and Java 17. It allows hosts to manage events, schedules, and reservations with form validation and check-in functionality. + +## Build Commands + +```bash +# Run all tests +./gradlew clean test + +# Build without tests +./gradlew clean build -x test + +# Run locally (requires MySQL on localhost:3306) +./gradlew bootRun + +# Run a specific test +./gradlew test --tests "TestClassName" + +# Build production JAR (produces ticket-form.jar) +./gradlew bootJar +``` + +## Architecture + +**Layered Structure:** +``` +controller/ → REST API endpoints (guest/ and host/ packages) +service/ → Business logic +repository/ → JPA data access with custom queries +domain/ → JPA entities +dto/ → Request/Response objects +global/ → Cross-cutting concerns (config, security, exception, mail, image, util) +``` + +**Key Domain Models:** +- `Host` → Event organizer account +- `Event` → Event definition with unique eventCode +- `EventSchedule` → Time slots with capacity (uses pessimistic locking) +- `Reservation` → Booking with QR token for check-in +- `FormQuestion` / `FormAnswer` → Custom form fields per event + +## Security + +- JWT Bearer Token 인증 (Authorization 헤더), 30분 만료 +- Public endpoints: `/api/auth/**`, `/api/events/**`, `/api/reservations/**` +- Protected endpoints: `/api/host/**` (requires HOST role) +- Guest phone numbers are encrypted in database using `EncryptionUtils` + +## Testing + +- Test profile uses H2 in-memory database (see `application-test.yml`) +- Tests run with: `./gradlew clean test` +- Swagger UI: `http://localhost:8080/swagger-ui.html` + +## Key Technical Details + +- **Concurrency**: `EventSchedule` uses `@Lock(LockModeType.PESSIMISTIC_WRITE)` to prevent overselling +- **Email Verification**: Uses Caffeine cache for storing verification codes +- **File Uploads**: AWS S3 presigned URLs for direct client uploads +- **Async**: `@EnableAsync` enabled for background tasks like email sending + +## CI/CD + +- **CI**: Pull requests to main trigger `./gradlew clean test` +- **CD**: Push to main builds JAR and deploys to EC2 via SSH diff --git a/src/main/java/com/example/reservation_solution/controller/HealthCheckController.java b/src/main/java/com/example/reservation_solution/controller/HealthCheckController.java new file mode 100644 index 0000000..35b3d5c --- /dev/null +++ b/src/main/java/com/example/reservation_solution/controller/HealthCheckController.java @@ -0,0 +1,24 @@ +package com.example.reservation_solution.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.util.Map; + +@Tag(name = "Health Check", description = "서버 상태 확인 API") +@RestController +public class HealthCheckController { + + @Operation(summary = "헬스 체크", description = "서버 상태를 확인합니다.") + @GetMapping("/health") + public ResponseEntity> healthCheck() { + return ResponseEntity.ok(Map.of( + "status", "UP", + "timestamp", LocalDateTime.now().toString() + )); + } +} diff --git a/src/main/java/com/example/reservation_solution/controller/host/AuthController.java b/src/main/java/com/example/reservation_solution/controller/host/AuthController.java index 3a22286..a8085d0 100644 --- a/src/main/java/com/example/reservation_solution/controller/host/AuthController.java +++ b/src/main/java/com/example/reservation_solution/controller/host/AuthController.java @@ -4,16 +4,12 @@ import com.example.reservation_solution.dto.LoginResponse; import com.example.reservation_solution.dto.SignupRequest; import com.example.reservation_solution.global.docs.LoginDocs; -import com.example.reservation_solution.global.docs.LogoutDocs; import com.example.reservation_solution.global.docs.SignupDocs; -import com.example.reservation_solution.global.security.CookieUtils; import com.example.reservation_solution.service.AuthService; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -39,19 +35,6 @@ public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { @PostMapping("/login") public ResponseEntity login(@Valid @RequestBody LoginRequest request) { String token = authService.generateLoginToken(request); - ResponseCookie cookie = CookieUtils.createAccessTokenCookie(token); - LoginResponse response = LoginResponse.success(request.email()); - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .body(response); - } - - @LogoutDocs - @PostMapping("/logout") - public ResponseEntity logout() { - ResponseCookie cookie = CookieUtils.deleteAccessTokenCookie(); - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .build(); + return ResponseEntity.ok(LoginResponse.of(token, request.email())); } } diff --git a/src/main/java/com/example/reservation_solution/dto/LoginResponse.java b/src/main/java/com/example/reservation_solution/dto/LoginResponse.java index d392af6..79ba6de 100644 --- a/src/main/java/com/example/reservation_solution/dto/LoginResponse.java +++ b/src/main/java/com/example/reservation_solution/dto/LoginResponse.java @@ -1,20 +1,10 @@ package com.example.reservation_solution.dto; -/** - * 로그인 성공 응답 DTO - * JWT 토큰은 HttpOnly 쿠키로 전송되므로 응답 본문에는 성공 메시지만 포함 - */ public record LoginResponse( - String message, + String accessToken, String email ) { - /** - * 로그인 성공 응답 생성 - * - * @param email 로그인한 사용자 이메일 - * @return LoginResponse 객체 - */ - public static LoginResponse success(String email) { - return new LoginResponse("로그인이 성공적으로 완료되었습니다.", email); + public static LoginResponse of(String accessToken, String email) { + return new LoginResponse(accessToken, email); } } diff --git a/src/main/java/com/example/reservation_solution/global/config/SecurityConfig.java b/src/main/java/com/example/reservation_solution/global/config/SecurityConfig.java index 5a008ca..9f96ebc 100644 --- a/src/main/java/com/example/reservation_solution/global/config/SecurityConfig.java +++ b/src/main/java/com/example/reservation_solution/global/config/SecurityConfig.java @@ -37,6 +37,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/actuator/**").permitAll() + .requestMatchers("/health").permitAll() .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/h2-console/**").permitAll() .requestMatchers("/api/events/**").permitAll() @@ -57,6 +58,7 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(List.of( "http://localhost:3000", + "http://localhost:3001", "https://www.form-pass.life", "https://form-pass.life", "https://form-pass-client.vercel.app" diff --git a/src/main/java/com/example/reservation_solution/global/security/CookieUtils.java b/src/main/java/com/example/reservation_solution/global/security/CookieUtils.java deleted file mode 100644 index 41b8728..0000000 --- a/src/main/java/com/example/reservation_solution/global/security/CookieUtils.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.example.reservation_solution.global.security; - -import org.springframework.http.ResponseCookie; -import org.springframework.stereotype.Component; - -import java.time.Duration; - -import org.springframework.http.ResponseCookie; -import java.time.Duration; - -public class CookieUtils { - - public static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken"; - private static final String COOKIE_PATH = "/"; - private static final Duration COOKIE_MAX_AGE = Duration.ofSeconds(1800); // 30분 - - public static ResponseCookie createAccessTokenCookie(String token) { - return ResponseCookie.from(ACCESS_TOKEN_COOKIE_NAME, token) - .httpOnly(true) // 자바스크립트에서 접근 불가 (XSS 방지) - .secure(true) // HTTPS 환경에서만 전송 (필수) - .path(COOKIE_PATH) // 모든 경로에서 쿠키 전송 - .maxAge(COOKIE_MAX_AGE) // 30분 후 만료 - .sameSite("None") // 서로 다른 도메인 간 요청 허용 (CORS) - .build(); - } - - public static ResponseCookie deleteAccessTokenCookie() { - return ResponseCookie.from(ACCESS_TOKEN_COOKIE_NAME, "") - .httpOnly(true) - .secure(true) - .path(COOKIE_PATH) - .maxAge(0) // 즉시 만료시켜 브라우저에서 삭제 - .sameSite("None") - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/reservation_solution/global/security/JwtAuthenticationFilter.java b/src/main/java/com/example/reservation_solution/global/security/JwtAuthenticationFilter.java index 320127d..5a6428e 100644 --- a/src/main/java/com/example/reservation_solution/global/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/reservation_solution/global/security/JwtAuthenticationFilter.java @@ -2,7 +2,6 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -32,32 +31,7 @@ protected void doFilterInternal(HttpServletRequest request, filterChain.doFilter(request, response); } - private String resolveToken(HttpServletRequest request) { - String tokenFromCookie = resolveTokenFromCookie(request); - if (tokenFromCookie != null) { - return tokenFromCookie; - } - return resolveTokenFromHeader(request); - } - - private String resolveTokenFromCookie(HttpServletRequest request) { - Cookie[] cookies = request.getCookies(); - if (cookies == null) { - return null; - } - for (Cookie cookie : cookies) { - if (CookieUtils.ACCESS_TOKEN_COOKIE_NAME.equals(cookie.getName())) { - String token = cookie.getValue(); - if (StringUtils.hasText(token)) { - return token; - } - } - } - return null; - } - - private String resolveTokenFromHeader(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 83a47b3..235ad3d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,7 +2,7 @@ spring: application: name: ticket-form profiles: - active: local + active: dev jwt: expiration: 1800000 # 30분 \ No newline at end of file diff --git a/src/test/java/com/example/reservation_solution/service/ReservationConcurrencyTest.java b/src/test/java/com/example/reservation_solution/service/ReservationConcurrencyTest.java deleted file mode 100644 index a34a47d..0000000 --- a/src/test/java/com/example/reservation_solution/service/ReservationConcurrencyTest.java +++ /dev/null @@ -1,330 +0,0 @@ -package com.example.reservation_solution.service; - -import com.example.reservation_solution.domain.*; -import com.example.reservation_solution.dto.FormAnswerRequest; -import com.example.reservation_solution.dto.ReservationRequest; -import com.example.reservation_solution.repository.*; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.assertj.core.api.Assertions.assertThat; - - -@SpringBootTest -@ActiveProfiles("test") -class ReservationConcurrencyTest { - - @Autowired - private ReservationService reservationService; - - @Autowired - private EventRepository eventRepository; - - @Autowired - private EventScheduleRepository eventScheduleRepository; - - @Autowired - private HostRepository hostRepository; - - @Autowired - private FormQuestionRepository formQuestionRepository; - - @Autowired - private ReservationRepository reservationRepository; - - private Host host; - private Event event; - private EventSchedule schedule; - private FormQuestion requiredQuestion; - - @BeforeEach - void setUp() { - reservationRepository.deleteAll(); - formQuestionRepository.deleteAll(); - eventScheduleRepository.deleteAll(); - eventRepository.deleteAll(); - hostRepository.deleteAll(); - - // 호스트 생성 - host = Host.builder() - .email("concurrency-test@example.com") - .password("password") - .name("동시성 테스트 호스트") - .role(Role.HOST) - .build(); - hostRepository.saveAndFlush(host); - - // 이벤트 생성 - event = Event.builder() - .host(host) - .title("동시성 테스트 이벤트") - .description("동시성 테스트 설명") - .location("동시성 테스트 장소") - .eventCode("CONCUR001") - .build(); - event.updateVisibility(true); - eventRepository.saveAndFlush(event); - - // 스케줄 생성 (정원 10명) - schedule = EventSchedule.builder() - .startTime(LocalDateTime.now().plusDays(1)) - .endTime(LocalDateTime.now().plusDays(1).plusHours(2)) - .maxCapacity(10) - .build(); - event.addSchedule(schedule); - eventScheduleRepository.saveAndFlush(schedule); - - // 필수 질문 생성 - requiredQuestion = FormQuestion.builder() - .questionText("동시성 테스트 필수 질문") - .questionType(QuestionType.TEXT) - .isRequired(true) - .build(); - event.addQuestion(requiredQuestion); - formQuestionRepository.saveAndFlush(requiredQuestion); - } - - @AfterEach - void tearDown() { - // 테스트 후 데이터 정리 - reservationRepository.deleteAll(); - formQuestionRepository.deleteAll(); - eventScheduleRepository.deleteAll(); - eventRepository.deleteAll(); - hostRepository.deleteAll(); - } - - @Test - @DisplayName("동시성 테스트 - 20명이 동시에 1장씩 예약 시도, 정원 10명이면 10명만 성공") - void concurrentReservation_10Capacity_20Users() throws InterruptedException { - // given - int threadCount = 20; // 20명이 동시에 예약 시도 - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); - CountDownLatch latch = new CountDownLatch(threadCount); - - AtomicInteger successCount = new AtomicInteger(0); - AtomicInteger failCount = new AtomicInteger(0); - - // when - 20개의 스레드에서 동시에 예약 시도 - for (int i = 0; i < threadCount; i++) { - final int userId = i; - executorService.submit(() -> { - try { - List answers = List.of( - new FormAnswerRequest(requiredQuestion.getId(), "답변" + userId) - ); - - ReservationRequest request = new ReservationRequest( - schedule.getId(), - "게스트" + userId, - "0101234" + String.format("%04d", userId), - 1, - answers - ); - - reservationService.createReservation(request); - successCount.incrementAndGet(); - } catch (Exception e) { - failCount.incrementAndGet(); - } finally { - latch.countDown(); - } - }); - } - - latch.await(); // 모든 스레드가 완료될 때까지 대기 - executorService.shutdown(); - - // then - assertThat(successCount.get()).isEqualTo(10); // 정원 10명만 성공 - assertThat(failCount.get()).isEqualTo(10); // 나머지 10명은 실패 - - // DB에서 실제 저장된 예약 수 확인 - List reservations = reservationRepository.findAll(); - assertThat(reservations).hasSize(10); - - // 스케줄의 예약 카운트 확인 - EventSchedule updatedSchedule = eventScheduleRepository.findById(schedule.getId()).get(); - assertThat(updatedSchedule.getReservedCount()).isEqualTo(10); - assertThat(updatedSchedule.getAvailableSeats()).isEqualTo(0); - } - - @Test - @DisplayName("동시성 테스트 - 10명이 동시에 2장씩 예약 시도, 정원 10명이면 5명만 성공") - void concurrentReservation_10Capacity_10UsersWithTwoTickets() throws InterruptedException { - // given - int threadCount = 10; // 10명이 동시에 예약 시도 - int ticketCountPerUser = 2; // 각 사용자는 2장씩 예약 - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); - CountDownLatch latch = new CountDownLatch(threadCount); - - AtomicInteger successCount = new AtomicInteger(0); - AtomicInteger failCount = new AtomicInteger(0); - - // when - 10개의 스레드에서 동시에 2장씩 예약 시도 - for (int i = 0; i < threadCount; i++) { - final int userId = i; - executorService.submit(() -> { - try { - List answers = List.of( - new FormAnswerRequest(requiredQuestion.getId(), "답변" + userId) - ); - - ReservationRequest request = new ReservationRequest( - schedule.getId(), - "게스트" + userId, - "0101234" + String.format("%04d", userId), - ticketCountPerUser, - answers - ); - - reservationService.createReservation(request); - successCount.incrementAndGet(); - } catch (Exception e) { - failCount.incrementAndGet(); - } finally { - latch.countDown(); - } - }); - } - - latch.await(); // 모든 스레드가 완료될 때까지 대기 - executorService.shutdown(); - - // then - assertThat(successCount.get()).isEqualTo(5); // 정원 10명 / 2장 = 5명 성공 - assertThat(failCount.get()).isEqualTo(5); // 나머지 5명은 실패 - - // DB에서 실제 저장된 예약 수 확인 - List reservations = reservationRepository.findAll(); - assertThat(reservations).hasSize(5); - - // 스케줄의 예약 카운트 확인 - EventSchedule updatedSchedule = eventScheduleRepository.findById(schedule.getId()).get(); - assertThat(updatedSchedule.getReservedCount()).isEqualTo(10); - assertThat(updatedSchedule.getAvailableSeats()).isEqualTo(0); - - // 각 예약의 티켓 수 확인 - reservations.forEach(reservation -> - assertThat(reservation.getTicketCount()).isEqualTo(2) - ); - } - - @Test - @DisplayName("동시성 테스트 - 15명이 동시에 예약 시도, 정원 10명이면 처음 10명만 성공") - void concurrentReservation_RaceCondition() throws InterruptedException { - // given - int threadCount = 15; - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); - CountDownLatch latch = new CountDownLatch(threadCount); - - AtomicInteger successCount = new AtomicInteger(0); - - // when - for (int i = 0; i < threadCount; i++) { - final int userId = i; - executorService.submit(() -> { - try { - List answers = List.of( - new FormAnswerRequest(requiredQuestion.getId(), "답변" + userId) - ); - - ReservationRequest request = new ReservationRequest( - schedule.getId(), - "게스트" + userId, - "0109999" + String.format("%04d", userId), - 1, - answers - ); - - reservationService.createReservation(request); - successCount.incrementAndGet(); - } catch (Exception e) { - // 예외 발생 시 무시 (정원 초과 등) - } finally { - latch.countDown(); - } - }); - } - - latch.await(); - executorService.shutdown(); - - // then - 정확히 10명만 예약 성공해야 함 (정원 초과 방지) - assertThat(successCount.get()).isEqualTo(10); - - EventSchedule updatedSchedule = eventScheduleRepository.findById(schedule.getId()).get(); - assertThat(updatedSchedule.getReservedCount()).isEqualTo(10); - - // 실제 DB에 저장된 예약도 10건 - List allReservations = reservationRepository.findAll(); - assertThat(allReservations).hasSize(10); - } - - @Test - @DisplayName("동시성 테스트 - 정원이 거의 찬 상태에서 동시 예약 시도") - void concurrentReservation_AlmostFull() throws InterruptedException { - // given - 정원 10명 중 8명이 이미 예약됨 (잔여 좌석 2명) - EventSchedule scheduleFromDb = eventScheduleRepository.findById(schedule.getId()).get(); - for (int i = 0; i < 8; i++) { - scheduleFromDb.incrementReservedCount(); - } - eventScheduleRepository.saveAndFlush(scheduleFromDb); - - int threadCount = 5; // 5명이 동시에 1장씩 예약 시도 - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); - CountDownLatch latch = new CountDownLatch(threadCount); - - AtomicInteger successCount = new AtomicInteger(0); - - // when - for (int i = 0; i < threadCount; i++) { - final int userId = i; - executorService.submit(() -> { - try { - List answers = List.of( - new FormAnswerRequest(requiredQuestion.getId(), "답변" + userId) - ); - - ReservationRequest request = new ReservationRequest( - schedule.getId(), - "후발주자" + userId, - "0108888" + String.format("%04d", userId), - 1, - answers - ); - - reservationService.createReservation(request); - successCount.incrementAndGet(); - } catch (Exception e) { - // 정원 초과 예외 - } finally { - latch.countDown(); - } - }); - } - - latch.await(); - executorService.shutdown(); - - // then - 잔여 좌석 2명만큼만 성공 - assertThat(successCount.get()).isEqualTo(2); - - EventSchedule updatedSchedule = eventScheduleRepository.findById(schedule.getId()).get(); - assertThat(updatedSchedule.getReservedCount()).isEqualTo(10); // 8 + 2 = 10 - assertThat(updatedSchedule.getAvailableSeats()).isEqualTo(0); - } -}