diff --git a/.claude/docs/features/onboarding-duplicate-prevention.md b/.claude/docs/features/onboarding-duplicate-prevention.md new file mode 100644 index 00000000..19702eea --- /dev/null +++ b/.claude/docs/features/onboarding-duplicate-prevention.md @@ -0,0 +1,146 @@ +# 온보딩 API 중복 호출 방어 로직 + +## 1. 현재 문제점 + +### 1.1 에러 로그 분석 +```json +{ + "@timestamp": "2025-12-19T22:05:30", + "message": "Duplicate entry '100119476322276872610' for key 'users.UK6jdo1l976be85wv43w6x6e6x2'", + "level": "ERROR" +} +``` + +### 1.2 문제 상황 +- **API**: `POST /api/v1/users/onboarding` +- **원인**: 프론트엔드에서 온보딩 API를 빠르게 중복 호출 (버튼 더블클릭, 네트워크 재시도 등) +- **결과**: `provider_id` unique constraint 위반으로 500 에러 발생 +- **발생 패턴**: 같은 유저가 1초 내 2회 호출 + +### 1.3 현재 코드의 한계 +```java +// UserService.java - 현재 코드 +@Transactional +public OnboardUserResponse onboardUser(OnboardUserRequest request) { + String registerToken = request.registerToken(); + jwtUtil.validateToken(registerToken); + User newUser = createUserFromRegisterToken(request, registerToken); + userRepository.save(newUser); // 중복 시 DB 레벨에서 에러 발생 + // ... +} +``` + +- 저장 전 중복 체크 로직 없음 +- DB unique constraint에만 의존하여 500 에러 반환 +- 클라이언트가 적절한 에러 메시지를 받지 못함 + +--- + +## 2. 구현 완료 사항 + +### 2.1 방어 로직 추가 +저장 전 `provider_id`로 기존 유저 존재 여부를 확인하고, 이미 가입된 경우 409 Conflict 반환. + +```java +// UserService.java - 수정 코드 +@Transactional +public OnboardUserResponse onboardUser(OnboardUserRequest request) { + String registerToken = request.registerToken(); + jwtUtil.validateToken(registerToken); + + // 중복 가입 방어 로직 + String providerId = jwtUtil.getClaimFromToken(registerToken, "providerId", String.class); + if (userRepository.existsByProviderId(providerId)) { + throw new CustomException(UserErrorStatus._ALREADY_REGISTERED_USER); + } + + User newUser = createUserFromRegisterToken(request, registerToken); + userRepository.save(newUser); + // ... +} +``` + +### 2.2 에러 코드 추가 +```java +// UserErrorStatus.java +_ALREADY_REGISTERED_USER(HttpStatus.CONFLICT, "USER-007", "이미 가입된 유저입니다."), +``` + +### 2.3 응답 형식 +```json +{ + "is_success": false, + "code": "USER-007", + "message": "이미 가입된 유저입니다.", + "payload": null +} +``` + +--- + +## 3. 기술적 고려사항 + +### 3.1 409 Conflict vs 200 OK + +| 방식 | 장점 | 단점 | +|------|------|------| +| **409 Conflict** | RESTful 표준 준수, 명확한 에러 상태 표현 | 클라이언트에서 에러 핸들링 필요 | +| **200 OK + 기존 토큰 반환** | 클라이언트 구현 단순, 멱등성 보장 | 의미적으로 모호함 | + +**선택: 409 Conflict** +- 온보딩은 최초 1회만 수행되어야 하는 명확한 요구사항 +- 프론트엔드에서 중복 호출 자체를 막아야 하므로 명시적 에러가 적절 +- 가이드 조회 로그(`_IS_ALREADY_VIEWED_GUIDE`)도 동일한 패턴 사용 중 + +### 3.2 Race Condition 대응 전략 + +| 전략 | 적용 여부 | 이유 | +|------|-----------|------| +| **Application-level 체크** | O (1차) | 대부분의 중복 호출 방어, 명확한 에러 메시지 | +| **DB Unique Constraint** | O (2차, 기존) | 최종 방어선, 동시성 문제 해결 | +| **Distributed Lock** | X | 오버엔지니어링, 온보딩은 빈번한 작업 아님 | + +**이유**: +- 온보딩은 유저당 1회만 발생하는 저빈도 작업 +- DB unique constraint가 이미 존재하여 race condition 발생 시에도 데이터 정합성 보장 +- 분산 락은 결제, 재고 관리 등 고빈도 동시성 작업에 적합 + +--- + +## 4. 사이드이펙트 분석 + +### 4.1 영향 범위 +- `UserService.onboardUser()` 메서드만 수정 +- `UserErrorStatus` 열거형에 새 에러 코드 추가 +- 기존 API 스펙 변경 없음 (에러 응답 코드만 변경: 500 → 409) + +### 4.2 하위 호환성 +- 기존에 500 에러를 받던 케이스가 409로 변경됨 +- 프론트엔드에서 409 에러 핸들링 필요 (이미 가입된 상태이므로 로그인 유도 등) + +### 4.3 테스트 필요 사항 +- [ ] 정상 온보딩 시나리오 (신규 유저) +- [ ] 중복 온보딩 시나리오 (이미 가입된 provider_id) +- [ ] 동시 호출 시나리오 (race condition 테스트) + +--- + +## 5. 참고 자료 + +### Best Practices +- [Designing Idempotent APIs in Spring Boot](https://dev.to/devcorner/designing-idempotent-apis-in-spring-boot-2fhi) +- [REST API Idempotency](https://restfulapi.net/idempotent-rest-apis/) +- [409 Conflict 사용 가이드](https://dev.to/jj/solving-the-conflict-of-using-the-http-status-409-2iib) + +### 관련 이슈 +- 프론트엔드 중복 호출 방지: `[FE] 온보딩 & 가이드 조회 중복 호출로 인한 오류` + +--- + +## 6. 변경 파일 목록 + +| 파일 | 변경 내용 | +|------|-----------| +| `UserErrorStatus.java` | `_ALREADY_REGISTERED_USER` 에러 코드 추가 | +| `UserRepository.java` | `existsByProviderId()` 메서드 추가 | +| `UserService.java` | `onboardUser()` 메서드에 중복 체크 로직 추가 | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..07bbc182 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,124 @@ +# CLAUDE.md - OneTime Backend + +This file provides guidance for Claude Code when working with this codebase. + +## Project Overview + +OneTime is a Spring Boot-based backend API for a collaborative event scheduling application. Users can create time-based events, participate in scheduling, and provide time availability. Supports both authenticated (OAuth2) and anonymous user participation. + +## Tech Stack + +- **Language**: Java 17 +- **Framework**: Spring Boot 3.3.2 +- **Database**: MySQL 8.0 with Spring Data JPA, QueryDSL 5.0 +- **Cache**: Redis with Redisson 3.46.0 (distributed locking) +- **Security**: Spring Security, OAuth2 (Google, Kakao, Naver), JWT (JJWT 0.12.2) +- **Cloud**: AWS S3 (Spring Cloud AWS 3.1.1), CodeDeploy +- **Documentation**: Spring REST Docs 3.0.0, SpringDoc OpenAPI 2.1.0 +- **Build**: Gradle 8.x + +## Common Commands + +```bash +# Build +./gradlew clean build # Full build with tests +./gradlew build -x test # Build without tests + +# Run +./gradlew bootRun --args='--spring.profiles.active=local' + +# Test +./gradlew test # Run all tests + +# Documentation +./gradlew openapi3 # Generate OpenAPI spec +./gradlew asciidoctor # Generate AsciiDoc docs + +# Docker +docker build -t onetime-backend . +docker run -p 8090:8090 onetime-backend +``` + +## Project Structure + +``` +src/main/java/side/onetime/ +├── controller/ # REST API endpoints (@RestController) +├── service/ # Business logic layer (@Service) +├── repository/ # Data access layer (JpaRepository, QueryDSL) +├── domain/ # JPA entities with Soft Delete pattern +│ └── enums/ # Status enums (Status, EventStatus, etc.) +├── dto/ # DTOs organized by feature +│ └── / +│ ├── request/ +│ └── response/ +├── auth/ # OAuth2 & JWT authentication +├── global/ +│ ├── config/ # Spring configurations +│ ├── filter/ # JwtFilter +│ ├── lock/ # @DistributedLock annotation & AOP +│ └── common/ # ApiResponse, status codes, BaseEntity +├── exception/ # CustomException, GlobalExceptionHandler +├── infra/ # External integrations (Everytime client) +└── util/ # Utility classes (JwtUtil, S3Util, etc.) +``` + +## Code Conventions + +### Architecture +- Layered architecture: Controller → Service → Repository → Domain +- RESTful API with `/api/v1/` prefix +- Generic response wrapper: `ApiResponse` with `onSuccess()`, `onFailure()` + +### Naming +- Controllers: `*Controller` +- Services: `*Service` +- Repositories: `*Repository` +- DTOs: `*Request`, `*Response` in feature-based packages +- Entities: PascalCase without suffix + +### Patterns +- **Soft Delete**: `@SQLDelete`, `@SQLRestriction` with `Status` enum (ACTIVE, DELETED) +- **Distributed Locking**: `@DistributedLock` annotation for race condition prevention +- **DTO Conversion**: `toEntity()` methods, static factory `of()` methods +- **Error Handling**: Domain-specific error status enums (e.g., `EventErrorStatus`) +- **Dependency Injection**: Constructor injection with `@RequiredArgsConstructor` + +### Database +- Hibernate with fetch join for N+1 prevention +- QueryDSL for complex queries with custom repository implementations +- `@Transactional` for transaction management + +## Commit Convention + +Format: `[type]: description (#issue-number)` + +Types: +- `[feat]`: New feature +- `[fix]`: Bug fix +- `[refactor]`: Code refactoring +- `[docs]`: Documentation + +Example: `[feat] : 가이드 확인 여부를 조회/저장/삭제한다 (#300)` + +## Branch Strategy + +- `main`: Production +- `develop`: Development integration (base for features) +- `release/v*`: Release candidates (e.g., `release/v1.2.3`) +- `feature/#/`: Feature branches (e.g., `feature/#4/login`) +- `hotfix/`: Emergency fixes + +## Testing + +- JUnit 5 with Spring Boot Test +- MockMvc for controller integration tests +- Spring REST Docs for API documentation generation +- Test config uses port 8091 + +## Key Configuration + +- Main config: `application.yaml` +- Profiles: `local`, `dev`, `prod` +- Server port: 8090 (default) +- Swagger UI: `/swagger-ui.html` diff --git a/src/main/java/side/onetime/controller/EventController.java b/src/main/java/side/onetime/controller/EventController.java index 3571a4b2..2ee279c3 100644 --- a/src/main/java/side/onetime/controller/EventController.java +++ b/src/main/java/side/onetime/controller/EventController.java @@ -118,20 +118,6 @@ public ResponseEntity>> getFilteredMostPos return ApiResponse.onSuccess(SuccessStatus._GET_FILTERED_MOST_POSSIBLE_TIME, getFilteredMostPossibleTimes); } - /** - * 유저 참여 이벤트 목록 조회 API. - * - * 이 API는 인증된 유저가 참여한 모든 이벤트 목록을 조회합니다. 유저의 참여 상태, 이벤트 정보 등이 포함됩니다. - * - * @return 유저가 참여한 이벤트 목록 - */ - @GetMapping("/user/all") - public ResponseEntity>> getUserParticipatedEvents() { - - List getUserParticipatedEventsResponses = eventService.getUserParticipatedEvents(); - return ApiResponse.onSuccess(SuccessStatus._GET_USER_PARTICIPATED_EVENTS, getUserParticipatedEventsResponses); - } - /** * 유저 참여 이벤트 목록 조회 API. * @@ -144,7 +130,7 @@ public ResponseEntity>> getU * @param createdDate 마지막으로 조회한 이벤트 생성일 * @return 유저가 참여한 이벤트 목록 및 페이지(커서) 정보가 포함된 응답 DTO */ - @GetMapping("/user/all/v2") + @GetMapping("/user/all") public ResponseEntity> getParticipatedEventsByCursor( @RequestParam(value = "size", defaultValue = "2") @Min(1) int size, @RequestParam(value = "cursor", required = false) LocalDateTime createdDate diff --git a/src/main/java/side/onetime/dto/event/response/GetUserParticipatedEventsResponse.java b/src/main/java/side/onetime/dto/event/response/GetUserParticipatedEventsResponse.java deleted file mode 100644 index affac00c..00000000 --- a/src/main/java/side/onetime/dto/event/response/GetUserParticipatedEventsResponse.java +++ /dev/null @@ -1,36 +0,0 @@ -package side.onetime.dto.event.response; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.fasterxml.jackson.databind.annotation.JsonNaming; -import side.onetime.domain.Event; -import side.onetime.domain.EventParticipation; -import side.onetime.domain.enums.Category; -import side.onetime.domain.enums.EventStatus; - -import java.util.List; -import java.util.UUID; - -@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) -@JsonInclude(JsonInclude.Include.NON_NULL) -public record GetUserParticipatedEventsResponse( - UUID eventId, - Category category, - String title, - String createdDate, - int participantCount, - EventStatus eventStatus, - List mostPossibleTimes -) { - public static GetUserParticipatedEventsResponse of(Event event, EventParticipation eventParticipation, int participantCount, List mostPossibleTimes) { - return new GetUserParticipatedEventsResponse( - event.getEventId(), - event.getCategory(), - event.getTitle(), - String.valueOf(event.getCreatedDate()), - participantCount, - eventParticipation.getEventStatus(), - mostPossibleTimes - ); - } -} diff --git a/src/main/java/side/onetime/exception/status/UserErrorStatus.java b/src/main/java/side/onetime/exception/status/UserErrorStatus.java index b9385d12..82f44d34 100644 --- a/src/main/java/side/onetime/exception/status/UserErrorStatus.java +++ b/src/main/java/side/onetime/exception/status/UserErrorStatus.java @@ -15,6 +15,7 @@ public enum UserErrorStatus implements BaseErrorCode { _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "USER-004", "인증된 사용자가 아닙니다."), _IS_ALREADY_VIEWED_GUIDE(HttpStatus.CONFLICT, "USER-005", "이미 조회한 가이드입니다."), _NOT_FOUND_GUIDE(HttpStatus.NOT_FOUND, "USER-006", "가이드를 찾을 수 없습니다."), + _ALREADY_REGISTERED_USER(HttpStatus.CONFLICT, "USER-007", "이미 가입된 유저입니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/side/onetime/global/common/status/SuccessStatus.java b/src/main/java/side/onetime/global/common/status/SuccessStatus.java index 651bdfa1..91957b82 100644 --- a/src/main/java/side/onetime/global/common/status/SuccessStatus.java +++ b/src/main/java/side/onetime/global/common/status/SuccessStatus.java @@ -18,7 +18,6 @@ public enum SuccessStatus implements BaseCode { _GET_PARTICIPANTS(HttpStatus.OK, "200", "참여자 조회에 성공했습니다."), _GET_MOST_POSSIBLE_TIME(HttpStatus.OK, "200", "가장 많이 되는 시간 조회에 성공했습니다."), _GET_FILTERED_MOST_POSSIBLE_TIME(HttpStatus.OK, "200", "필터링한 참여자의 시간 조회에 성공했습니다."), - _GET_USER_PARTICIPATED_EVENTS(HttpStatus.OK, "200", "유저 참여 이벤트 목록 조회에 성공했습니다."), _GET_PARTICIPATED_EVENTS(HttpStatus.OK, "200", "유저 참여 이벤트 목록 조회에 성공했습니다."), _REMOVE_USER_CREATED_EVENT(HttpStatus.OK, "200", "유저가 생성한 이벤트 삭제에 성공했습니다."), _MODIFY_EVENT(HttpStatus.OK, "200", "이벤트 수정에 성공했습니다."), diff --git a/src/main/java/side/onetime/repository/UserRepository.java b/src/main/java/side/onetime/repository/UserRepository.java index 6a4c4384..2656d008 100644 --- a/src/main/java/side/onetime/repository/UserRepository.java +++ b/src/main/java/side/onetime/repository/UserRepository.java @@ -12,7 +12,11 @@ public interface UserRepository extends JpaRepository, UserRepositoryCustom { Optional findByName(String name); + User findByProviderId(String providerId); + + boolean existsByProviderId(String providerId); + void withdraw(User user); @Query(""" diff --git a/src/main/java/side/onetime/service/EventService.java b/src/main/java/side/onetime/service/EventService.java index a0570253..3acafd1b 100644 --- a/src/main/java/side/onetime/service/EventService.java +++ b/src/main/java/side/onetime/service/EventService.java @@ -518,45 +518,6 @@ private boolean isDateFormat(String range) { return Character.isDigit(range.charAt(0)); } - /** - * 유저 참여 이벤트 반환 메서드. - * 인증된 유저가 참여한 모든 이벤트 목록을 조회하며, 각 이벤트에 대한 세부 정보를 반환합니다. - * - * @return 유저가 참여한 이벤트 목록 - */ - @Transactional(readOnly = true) - public List getUserParticipatedEvents() { - User user = userRepository.findById(UserAuthorizationUtil.getLoginUserId()) - .orElseThrow(() -> new CustomException(UserErrorStatus._NOT_FOUND_USER)); - - List participations = eventParticipationRepository.findAllByUserWithEvent(user); - - // 캐시 맵 선언 - Map participantsCache = new HashMap<>(); - Map> mostPossibleCache = new HashMap<>(); - - return participations.stream() - .sorted(Comparator.comparing((EventParticipation ep) -> ep.getEvent().getCreatedDate()).reversed()) - .map(ep -> { - Event event = ep.getEvent(); - String eventId = event.getEventId().toString(); - - // 캐시 또는 메서드 실행 - GetParticipantsResponse participants = participantsCache.computeIfAbsent( - eventId, this::getParticipants); - List mostPossibleTimes = mostPossibleCache.computeIfAbsent( - eventId, this::getMostPossibleTime); - - return GetUserParticipatedEventsResponse.of( - event, - ep, - participants.users().size() + participants.members().size(), - mostPossibleTimes - ); - }) - .collect(Collectors.toList()); - } - /** * 유저 참여 이벤트 목록 조회 메서드. * @@ -598,7 +559,6 @@ public GetParticipatedEventsResponse getParticipatedEventsByCursor(int size, Loc return GetParticipatedEventsResponse.of(userParticipatedEvents, pageCursorInfo); } - /** * 유저가 생성한 이벤트 삭제 메서드. * 인증된 유저가 생성한 특정 이벤트를 삭제합니다. diff --git a/src/main/java/side/onetime/service/UserService.java b/src/main/java/side/onetime/service/UserService.java index 9cee27d5..cc82acee 100644 --- a/src/main/java/side/onetime/service/UserService.java +++ b/src/main/java/side/onetime/service/UserService.java @@ -41,6 +41,12 @@ public class UserService { public OnboardUserResponse onboardUser(OnboardUserRequest request) { String registerToken = request.registerToken(); jwtUtil.validateToken(registerToken); + + String providerId = jwtUtil.getClaimFromToken(registerToken, "providerId", String.class); + if (userRepository.existsByProviderId(providerId)) { + throw new CustomException(UserErrorStatus._ALREADY_REGISTERED_USER); + } + User newUser = createUserFromRegisterToken(request, registerToken); userRepository.save(newUser); diff --git a/src/test/java/side/onetime/event/EventControllerTest.java b/src/test/java/side/onetime/event/EventControllerTest.java index 62bdf49e..d2233177 100644 --- a/src/test/java/side/onetime/event/EventControllerTest.java +++ b/src/test/java/side/onetime/event/EventControllerTest.java @@ -382,73 +382,6 @@ public void getFilteredMostPossibleTimes() throws Exception { )); } - @Test - @DisplayName("유저 참여 이벤트 목록을 조회한다.") - public void getUserParticipatedEvents() throws Exception { - // given - List response = List.of( - new GetUserParticipatedEventsResponse( - UUID.randomUUID(), - Category.DATE, - "Sample Event", - "2024.11.13", - 10, - EventStatus.CREATOR, - List.of( - new GetMostPossibleTime("2024.11.13", "10:00", "10:30", 5, List.of("User1", "User2"), List.of("User3")) - ) - ) - ); - - Mockito.when(eventService.getUserParticipatedEvents()).thenReturn(response); - - // when - ResultActions resultActions = this.mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/events/user/all") - .header(HttpHeaders.AUTHORIZATION, "Bearer sampleToken") - .accept(MediaType.APPLICATION_JSON)); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.is_success").value(true)) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.message").value("유저 참여 이벤트 목록 조회에 성공했습니다.")) - .andExpect(jsonPath("$.payload[0].event_id").exists()) - .andExpect(jsonPath("$.payload[0].title").value("Sample Event")) - - // docs - .andDo(MockMvcRestDocumentationWrapper.document("event/get-user-participated-events", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - resource( - ResourceSnippetParameters.builder() - .tag("Event API") - .description("유저가 참여한 이벤트 목록을 조회한다.") - .responseFields( - fieldWithPath("is_success").type(JsonFieldType.BOOLEAN).description("성공 여부"), - fieldWithPath("code").type(JsonFieldType.STRING).description("응답 코드"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지"), - fieldWithPath("payload").type(JsonFieldType.ARRAY).description("참여 이벤트 목록"), - fieldWithPath("payload[].event_id").type(JsonFieldType.STRING).description("이벤트 ID"), - fieldWithPath("payload[].category").type(JsonFieldType.STRING).description("이벤트 카테고리"), - fieldWithPath("payload[].title").type(JsonFieldType.STRING).description("이벤트 제목"), - fieldWithPath("payload[].created_date").type(JsonFieldType.STRING).description("이벤트 생성일"), - fieldWithPath("payload[].participant_count").type(JsonFieldType.NUMBER).description("참여자 수"), - fieldWithPath("payload[].event_status").type(JsonFieldType.STRING).description("이벤트 참여 상태"), - fieldWithPath("payload[].most_possible_times").type(JsonFieldType.ARRAY).description("가장 많이 가능한 시간대"), - fieldWithPath("payload[].most_possible_times[].time_point").type(JsonFieldType.STRING).description("날짜 또는 요일"), - fieldWithPath("payload[].most_possible_times[].start_time").type(JsonFieldType.STRING).description("시작 시간"), - fieldWithPath("payload[].most_possible_times[].end_time").type(JsonFieldType.STRING).description("종료 시간"), - fieldWithPath("payload[].most_possible_times[].possible_count").type(JsonFieldType.NUMBER).description("가능한 참여자 수"), - fieldWithPath("payload[].most_possible_times[].possible_names").type(JsonFieldType.ARRAY).description("참여 가능한 유저 이름 목록"), - fieldWithPath("payload[].most_possible_times[].impossible_names").type(JsonFieldType.ARRAY).description("참여 불가능한 유저 이름 목록") - ) - .responseSchema(Schema.schema("GetUserParticipatedEventsResponseSchema")) - .build() - ) - )); - } - @Test @DisplayName("유저 참여 이벤트 목록을 조회한다.") public void getParticipatedEventsByCursor() throws Exception { @@ -474,7 +407,7 @@ public void getParticipatedEventsByCursor() throws Exception { Mockito.when(eventService.getParticipatedEventsByCursor(anyInt(), any(LocalDateTime.class))).thenReturn(response); // when - ResultActions resultActions = this.mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/events/user/all/v2") + ResultActions resultActions = this.mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/events/user/all") .header(HttpHeaders.AUTHORIZATION, "Bearer sampleToken") .param("size", String.valueOf(size)) .param("cursor", createdDate)