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
146 changes: 146 additions & 0 deletions .claude/docs/features/onboarding-duplicate-prevention.md
Original file line number Diff line number Diff line change
@@ -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()` 메서드에 중복 체크 로직 추가 |
124 changes: 124 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
│ └── <feature>/
│ ├── request/
│ └── response/
├── auth/ # OAuth2 & JWT authentication
├── global/
│ ├── config/ # Spring configurations
│ ├── filter/ # JwtFilter
│ ├── lock/ # @DistributedLock annotation & AOP
│ └── common/ # ApiResponse<T>, 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<T>` 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/#<issue>/<name>`: Feature branches (e.g., `feature/#4/login`)
- `hotfix/<description>`: 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`
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/side/onetime/repository/UserRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@

public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {
Optional<User> findByName(String name);

User findByProviderId(String providerId);

boolean existsByProviderId(String providerId);

void withdraw(User user);

@Query("""
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/side/onetime/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading