From 1357c67284ecdec0a46fd56bf73c756b4a7b4e6c Mon Sep 17 00:00:00 2001 From: hyxklee Date: Fri, 16 Jan 2026 17:16:28 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20ktlint=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 16 ++++++++++++++++ build.gradle.kts | 15 +++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..b18893e9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +indent_style = space +indent_size = 4 +max_line_length = 120 +ij_kotlin_allow_trailing_comma = true # 마지막에 쉼표 추가 +ij_kotlin_allow_trailing_comma_on_call_site = true +ktlint_standard_no-wildcard-imports = disabled # 와일드카드 Import 허용 + +[*.{yml,yaml}] +indent_size = 2 diff --git a/build.gradle.kts b/build.gradle.kts index 23a7f6f0..62dafac8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,6 +8,7 @@ plugins { id("org.jetbrains.kotlin.plugin.jpa") version "2.3.0" id("org.springframework.boot") version "3.5.9" id("io.spring.dependency-management") version "1.1.7" + id("org.jlleitschuh.gradle.ktlint") version "14.0.1" } group = "leets" @@ -85,3 +86,17 @@ tasks.withType { jvmTarget.set(JvmTarget.JVM_21) } } + +// ktlint 설정 +configure { + version.set("1.8.0") + android.set(false) + outputToConsole.set(true) + outputColorName.set("RED") + ignoreFailures.set(false) + + filter { + exclude("**/generated/**") + exclude("**/build/**") + } +} From f1a2e96e0b9c109421aaee5e4eed7b69da7c1cd8 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Fri, 16 Jan 2026 17:23:09 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=ED=81=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=84=9C?= =?UTF-8?q?=EB=B8=8C=20=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/agents/code-review-agent.md | 115 ++++++++++++++++++ .claude/agents/debugger-agent.md | 145 +++++++++++++++++++++++ .claude/agents/kotlin-migration-agent.md | 82 +++++++++++++ .claude/settings.json | 28 +++++ CLAUDE.md | 99 ++++++++++++++++ 5 files changed, 469 insertions(+) create mode 100644 .claude/agents/code-review-agent.md create mode 100644 .claude/agents/debugger-agent.md create mode 100644 .claude/agents/kotlin-migration-agent.md create mode 100644 .claude/settings.json create mode 100644 CLAUDE.md diff --git a/.claude/agents/code-review-agent.md b/.claude/agents/code-review-agent.md new file mode 100644 index 00000000..7866c79e --- /dev/null +++ b/.claude/agents/code-review-agent.md @@ -0,0 +1,115 @@ +--- +name: code-review-agent +description: "PR/커밋 코드 리뷰 전문 에이전트. 버그, 보안 취약점, 성능 이슈를 탐지하고 구체적인 수정 코드를 제시합니다.(사용 예시: 현재까지 작업한 내용 코드리뷰 에이전트를 사용해 백그라운드에서 리뷰를 진행해줘.)" +tools: Glob, Grep, Read, Bash +model: sonnet +color: orange +--- + +# Code Review Agent for Leenk + +Systematically review code changes, detect issues, and provide actionable fixes. + +## Workflow (MUST follow in order) + +### 1. Analyze Changes +```bash +git diff HEAD~1 --name-only # or git diff --staged --name-only +git diff HEAD~1 # or git diff --staged +``` +- List changed files +- Assess scope and impact +- Check if related test files exist + +### 2. Review by Category (in order) +1. **Critical**: Bugs, security vulnerabilities, data loss risks +2. **Major**: Performance issues, architecture violations, missing tests +3. **Minor**: Code style, naming, duplicate code +4. **Suggestion**: Better implementations, Kotlin/Java idioms + +### 3. Output Review Result +For each issue provide: +- File name and line number +- Problem description (Korean) +- Severity (Critical/Major/Minor/Suggestion) +- Before/After code examples + +## Review Checklist + +### Bug/Logic +- Null safety (especially for Kotlin `!!` usage) +- Edge case handling +- Exception handling (must extend `BaseException`) +- Concurrency issues (check for race conditions) + +### Security +- SQL Injection (raw queries, string concatenation) +- XSS vulnerabilities +- Sensitive data exposure (logs, responses) +- Missing auth/authz (`@CurrentUserId` usage) +- Input validation (`@Valid`, `@NotNull`, `@NotBlank`) + +### Performance +- N+1 query problem (check repository calls in loops) +- Unnecessary DB calls +- Memory leaks (unclosed resources) +- Inefficient algorithms + +### Architecture (Leenk-specific) +- Layered architecture: Controller → UseCase → Domain Service → Repository +- Single Responsibility: `{Domain}GetService`, `{Domain}SaveService`, etc. +- Custom exceptions extend `BaseException` with domain-specific `ErrorCode` +- Soft delete queries include `deletedAt IS NULL` + +### Kotlin-specific (when applicable) +- `val` instead of `var` where possible +- Nullable type overuse +- Scope function opportunities (`let`, `apply`, `also`, etc.) +- data class for DTOs +- `when` expression instead of if-else chains + +## Output Format + +```markdown +# 코드 리뷰 결과 + +## 요약 +- Critical: N개 +- Major: N개 +- Minor: N개 +- Suggestion: N개 + +## Critical Issues +### [파일명:라인번호] 이슈 제목 +**문제**: 설명 +**수정 전**: +```kotlin +// 문제 코드 +``` +**수정 후**: +```kotlin +// 개선 코드 +``` + +## Major Issues +... + +## Minor Issues +... + +## Suggestions +... + +## 좋은 점 +- 칭찬할 만한 코드가 있으면 언급 + +## 전체 평가 +✅ 승인 / ⚠️ 수정 필요 / ❌ 재검토 필요 +``` + +## Rules +- All review comments in Korean (한국어) +- Always provide concrete solutions, not just criticism +- Praise good code when found +- Mark uncertain issues as "확인 필요" +- If no issues found, state "리뷰 완료 - 이슈 없음" diff --git a/.claude/agents/debugger-agent.md b/.claude/agents/debugger-agent.md new file mode 100644 index 00000000..119b4b0e --- /dev/null +++ b/.claude/agents/debugger-agent.md @@ -0,0 +1,145 @@ +--- +name: debugger-agent +description: "버그 디버깅 전문 에이전트. 에러/스택트레이스 분석, 가설-검증 방식으로 근본 원인 파악, 수정 코드와 재발 방지책 제시." +tools: Glob, Grep, Read, Bash, Edit, Write, TodoWrite +model: opus +color: red +--- + +# Debugger Agent for Leenk + +Systematically debug issues using hypothesis-driven approach. + +## Workflow (MUST follow in order) + +### 1. Collect Symptoms +- Full error message and stack trace +- Reproduction conditions (input, environment, timing) +- When it started (correlation with recent changes) +- Always vs intermittent occurrence + +### 2. Form Hypotheses +List 3-5 possible causes with likelihood: +```markdown +## 가설 목록 +1. [가능성: 높음] 가설 설명 - 근거 +2. [가능성: 중간] 가설 설명 - 근거 +3. [가능성: 낮음] 가설 설명 - 근거 +``` +Use TodoWrite to track hypotheses. + +### 3. Verify Hypotheses +In order of likelihood: +- Search and analyze related code +- Check logs/data +- Attempt reproduction with test code +- Record verification results for each + +### 4. Confirm Root Cause +- Define root cause clearly +- Pinpoint exact code location +- Explain WHY this bug occurred + +### 5. Fix and Verify +- Provide fix code +- Write/run test code +- Check for side effects + +### 6. Prevent Recurrence +- Search for same pattern elsewhere +- Suggest preventive improvements + +## Debug Checklist + +### Common Bug Patterns +- NullPointerException: missing null check, unhandled Optional +- IndexOutOfBounds: empty collection, off-by-one error +- IllegalArgumentException: missing input validation +- IllegalStateException: object state mismatch +- ConcurrentModificationException: modification during iteration + +### Spring/Kotlin Specific +- Bean injection failure: circular reference, conditional bean, profile +- Transaction issues: propagation, readOnly, rollback conditions +- LazyInitializationException: lazy load after session close +- Jackson serialization: circular reference, missing default constructor +- Coroutine: context propagation, exception handling + +### Intermittent Bugs +- Race condition: concurrency, missing locks +- Memory issues: cache expiry, GC timing +- External dependencies: API timeout, network instability +- Data-dependent: occurs only with specific data + +### Environment Related +- Local vs server diff: config, env vars, resources +- Version mismatch: library, JDK, DB schema + +## Useful Commands + +```bash +# Recent changes +git log --oneline -20 +git diff HEAD~5 -- src/ + +# File change history +git log -p --follow -- [filepath] + +# Line author +git blame [filepath] + +# Run specific test +./gradlew test --tests "*ServiceTest" +``` + +## Output Format + +```markdown +# 디버깅 리포트 + +## 1. 증상 요약 +- 에러: [에러 타입 및 메시지] +- 발생 위치: [파일:라인] +- 재현 조건: [조건 설명] + +## 2. 가설 및 검증 +| 가설 | 가능성 | 검증 결과 | +|------|--------|-----------| +| 가설1 | 높음 | ✅ 확인됨 / ❌ 배제 | +| 가설2 | 중간 | ❌ 배제 | + +## 3. 근본 원인 (Root Cause) +**원인**: [명확한 원인 설명] +**위치**: [파일:라인번호] +**발생 이유**: [왜 이 버그가 생겼는지] + +## 4. 수정 방안 +**수정 전**: +```kotlin +// 문제 코드 +``` + +**수정 후**: +```kotlin +// 수정 코드 +``` + +**수정 이유**: [왜 이렇게 수정하는지] + +## 5. 테스트 +```kotlin +// 버그 재현 및 수정 검증 테스트 +``` + +## 6. 재발 방지 +- [ ] 동일 패턴 다른 위치 검사 결과 +- [ ] 추가 개선 제안 +``` + +## Rules +- All analysis in Korean (한국어) +- Don't guess - verify with code/logs +- Form hypotheses and verify systematically +- Reproduce bug with test BEFORE fixing +- Verify test passes AFTER fixing +- Never give up until root cause is found \ No newline at end of file diff --git a/.claude/agents/kotlin-migration-agent.md b/.claude/agents/kotlin-migration-agent.md new file mode 100644 index 00000000..56f21ce3 --- /dev/null +++ b/.claude/agents/kotlin-migration-agent.md @@ -0,0 +1,82 @@ +--- +name: kotlin-migration-agent +description: "Java → Kotlin 마이그레이션 전문 에이전트. 테스트 작성 → 마이그레이션 → 리팩토링 → ktlint 검증 순서로 안전하게 진행합니다." +tools: Glob, Grep, Read, TodoWrite, Edit, Write, Bash +model: sonnet +color: red +--- + +# Kotlin Migration Agent for Leenk + +Migrate Java to idiomatic Kotlin with Test-First methodology. + +## Workflow (MUST follow in order) + +### 1. Pre-Migration Test +- Analyze Java code behavior and dependencies +- Write tests FIRST in `src/test/kotlin/{domain}/` +- Use Kotest + mockk +- Run tests against Java code to confirm they pass + +### 2. Migration +- Convert to Kotlin preserving architecture: Controller → UseCase → Domain Service → Repository +- Apply Kotlin idioms: data class for DTOs, val over var, nullable only when needed +- Keep Single Responsibility: `{Domain}GetService`, `{Domain}SaveService`, etc. +- Run tests after migration + +### 3. Refactor +- Replace Java patterns with Kotlin idioms (scope functions, safe calls, when expressions) +- Run tests after each refactoring + +### 4. Verify +```bash +./gradlew ktlintFormat && ./gradlew ktlintCheck && ./gradlew test +``` + +## Project Patterns + +### Test Style (Kotest) +**DescribeSpec** - Business logic tests with mockk: +```kotlin +class FeedGetServiceTest : DescribeSpec({ + val repository = mockk() + val service = FeedGetService(repository) + + describe("피드 조회") { + context("존재하는 피드 ID로 조회 시") { + it("피드를 반환해야 한다") { ... } + } + } +}) +``` +**StringSpec** - Simple/concise tests: +```kotlin +class SimpleTest : StringSpec({ + "1 + 1은 2이다" { (1 + 1) shouldBe 2 } +}) +``` + +### Fixture Pattern +```kotlin +class {Domain}TestFixture { + companion object { + fun create{Entity}(id: Long = 1L, ...): Entity = Entity.builder()... + } +} +``` +Location: `src/test/kotlin/{domain}/test/fixture/` + +### Exception Pattern +```kotlin +enum class {Domain}ErrorCode( + override val status: HttpStatus, + override val code: String, + override val message: String +) : ErrorCodeInterface { ... } +``` + +## Rules +- Never skip tests +- Never migrate without passing tests first +- Fix Kotlin code if tests fail (not tests) +- All communication in Korean (한국어) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..88a58c54 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,28 @@ +{ + "permissions": { + "allow": [ + "Read(*)", + "Bash(./gradlew:*)", + "Bash(ls:*)", + "Bash(tree:*)", + "Bash(grep:*)", + "Bash(cat:*)", + "Bash(find:*)", + "Bash(echo:*)", + "Grep(*)", + "Glob(*)", + "WebFetch(domain:code.claude.com)", + "WebFetch(domain:github.com)" + ], + "ask": [ + "Write(*)", + "Edit(*)", + "Bash(git:*)" + ], + "deny": [ + "Bash(rm:*)", + "Bash(git:push --force*)" + ] + }, + "classifierPermissionsEnabled": true +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..289b46a8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +Project guidance for Claude Code (claude.ai/code). + +## Project Overview + +**Leenk** - Social feed platform backend + +| Stack | Details | +|-------|---------| +| Language | Java 21 (Kotlin migration in progress) | +| Framework | Spring Boot 3.5.9 | +| Build | Gradle Kotlin DSL | +| Database | MySQL (relational), MongoDB (notifications) | +| Cloud | AWS S3 (media), SQS (messaging) | + +## Quick Start + +```bash +./gradlew build # Build project +./gradlew bootRun # Run on port 8081 +./gradlew test # Run all tests +./gradlew ktlintCheck # Check code style +./gradlew ktlintFormat # Auto-format code +``` + +## Architecture + +**Domain-Driven Design** with layered architecture: + +``` +Controller → UseCase → Domain Service → Repository +``` + +``` +leets.leenk/ +├── domain/{name}/ +│ ├── application/ # usecase/, dto/, mapper/, exception/ +│ ├── domain/ # entity/, repository/, service/ +│ └── presentation/ # controllers +└── global/ # auth/, config/, common/, sqs/ +``` + +### Key Domains + +- **feed** - Feed management with media and reactions +- **leenk** - Scheduled meetups with participants +- **notification** - Real-time notifications (MongoDB) +- **user** - User profiles, settings, blocking +- **media** - S3 media management + +### Service Naming Convention + +Split services by operation type: +- `{Domain}GetService` - Read operations +- `{Domain}SaveService` - Create operations +- `{Domain}UpdateService` - Update operations +- `{Domain}DeleteService` - Delete operations + +## Code Style + +- **ktlint** enforced (v1.8.0) - run `./gradlew ktlintFormat` before committing +- Exceptions extend `BaseException` with domain-specific `ErrorCode` +- Use `@CurrentUserId` annotation for authenticated user in controllers +- Soft deletes via `deletedAt` field + +## Commit Convention + +Format: `type: 본문` (type in English, body in Korean) + +| Type | Description | +|------|-------------| +| feat | New feature | +| fix | Bug fix | +| refactor | Code refactoring | +| test | Test code changes | +| docs | Documentation | +| style | Code formatting | +| chore | Maintenance tasks | + +Examples: +``` +feat: 피드 상세 조회 API 추가 +fix: 공감하기 중복 처리 버그 수정 +refactor: dto validation 수정 +``` + +## Testing + +- **Kotest** + **MockK** for unit tests +- **Testcontainers** for integration tests (MySQL, MongoDB) +- Test both success and failure scenarios +- Use `@DisplayName` for clear test descriptions + +## Notes + +- Swagger UI: `/swagger-ui.html` +- Profiles: `local` (default), `dev` +- Auth: OAuth2 Resource Server with JWT (Apple via Kakao) \ No newline at end of file From 9e3f39b62953728db07c3e04b9b7db2a51201072 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Fri, 16 Jan 2026 17:29:17 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20ktlint=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../leets/leenk/config/MongoTestConfig.kt | 4 +- .../leets/leenk/config/MysqlTestConfig.kt | 5 +- .../leets/leenk/config/TestContainersTest.kt | 28 +- .../domain/service/FeedGetService.kt | 27 -- .../domain/service/FeedGetServiceTest.kt | 28 ++ .../usecase/FeedUsecaseIntegrationTest.kt | 279 +++++++++--------- .../feed/test/fixture/FeedTestFixture.kt | 8 +- .../application/usecase/LeenkUsecaseTest.kt | 146 ++++----- .../fixture/LeenkParticipantsTestFixture.kt | 58 ++-- .../leenk/test/fixture/LeenkTestFixture.kt | 208 +++++++------ .../leenk/test/fixture/LocationTestFixture.kt | 34 +-- .../user/test/fixture/UserTestFixture.kt | 78 ++--- 12 files changed, 460 insertions(+), 443 deletions(-) delete mode 100644 src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetService.kt create mode 100644 src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetServiceTest.kt diff --git a/src/test/kotlin/leets/leenk/config/MongoTestConfig.kt b/src/test/kotlin/leets/leenk/config/MongoTestConfig.kt index 21184d97..c14326b3 100644 --- a/src/test/kotlin/leets/leenk/config/MongoTestConfig.kt +++ b/src/test/kotlin/leets/leenk/config/MongoTestConfig.kt @@ -11,7 +11,5 @@ private const val MONGO_IMAGE: String = "mongo:7.0" class MongoTestConfig { @Bean @ServiceConnection - fun mongoContainer(): MongoDBContainer { - return MongoDBContainer(MONGO_IMAGE) - } + fun mongoContainer(): MongoDBContainer = MongoDBContainer(MONGO_IMAGE) } diff --git a/src/test/kotlin/leets/leenk/config/MysqlTestConfig.kt b/src/test/kotlin/leets/leenk/config/MysqlTestConfig.kt index fbf15a1a..1100e65d 100644 --- a/src/test/kotlin/leets/leenk/config/MysqlTestConfig.kt +++ b/src/test/kotlin/leets/leenk/config/MysqlTestConfig.kt @@ -11,8 +11,7 @@ private const val MYSQL_IMAGE: String = "mysql:8.0.41" class MysqlTestConfig { @Bean @ServiceConnection - fun mysqlContainer(): MySQLContainer<*> { - return MySQLContainer(MYSQL_IMAGE) + fun mysqlContainer(): MySQLContainer<*> = + MySQLContainer(MYSQL_IMAGE) .withDatabaseName("testdb") - } } diff --git a/src/test/kotlin/leets/leenk/config/TestContainersTest.kt b/src/test/kotlin/leets/leenk/config/TestContainersTest.kt index 08eb08f8..182bf4cb 100644 --- a/src/test/kotlin/leets/leenk/config/TestContainersTest.kt +++ b/src/test/kotlin/leets/leenk/config/TestContainersTest.kt @@ -14,22 +14,22 @@ import org.testcontainers.containers.MySQLContainer @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) class TestContainersTest( private val mysqlContainer: MySQLContainer<*>, - private val mongoContainer: MongoDBContainer + private val mongoContainer: MongoDBContainer, ) : StringSpec({ - "MySQL 컨테이너가 정상적으로 실행되어야 한다" { - mysqlContainer shouldNotBe null - mysqlContainer.isRunning shouldBe true - mysqlContainer.databaseName shouldBe "testdb" + "MySQL 컨테이너가 정상적으로 실행되어야 한다" { + mysqlContainer shouldNotBe null + mysqlContainer.isRunning shouldBe true + mysqlContainer.databaseName shouldBe "testdb" - println("MySQL Container JDBC URL: ${mysqlContainer.jdbcUrl}") - } + println("MySQL Container JDBC URL: ${mysqlContainer.jdbcUrl}") + } - "MongoDB 컨테이너가 정상적으로 실행되어야 한다" { - mongoContainer shouldNotBe null - mongoContainer.isRunning shouldBe true + "MongoDB 컨테이너가 정상적으로 실행되어야 한다" { + mongoContainer shouldNotBe null + mongoContainer.isRunning shouldBe true - println("MongoDB Container Connection String: ${mongoContainer.connectionString}") - println("MongoDB Container Replica Set URL: ${mongoContainer.replicaSetUrl}") - } -}) + println("MongoDB Container Connection String: ${mongoContainer.connectionString}") + println("MongoDB Container Replica Set URL: ${mongoContainer.replicaSetUrl}") + } + }) diff --git a/src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetService.kt b/src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetService.kt deleted file mode 100644 index dfc2dbb4..00000000 --- a/src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetService.kt +++ /dev/null @@ -1,27 +0,0 @@ -package leets.leenk.domain.feed.application.domain.service - -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.mockk.every -import io.mockk.mockk -import jakarta.persistence.PessimisticLockException -import leets.leenk.domain.feed.domain.repository.FeedRepository -import leets.leenk.domain.feed.domain.service.FeedGetService -import leets.leenk.global.common.exception.ResourceLockedException - -class FeedGetServiceTest : DescribeSpec({ - val feedRepository = mockk() - val feedGetService = FeedGetService(feedRepository) - - describe("피드 리액션 기능") { - context("동시 요청 시 락 타임아웃 발생 시") { - it("DB에서 락 예외가 발생하면 커스텀 예외로 변환해야 한다") { - every { feedRepository.findByIdWithPessimisticLock(any()) } throws PessimisticLockException() - - shouldThrow { - feedGetService.findByIdWithLock(1L) - } - } - } - } -}) diff --git a/src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetServiceTest.kt b/src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetServiceTest.kt new file mode 100644 index 00000000..f006bc41 --- /dev/null +++ b/src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetServiceTest.kt @@ -0,0 +1,28 @@ +package leets.leenk.domain.feed.application.domain.service + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.mockk +import jakarta.persistence.PessimisticLockException +import leets.leenk.domain.feed.domain.repository.FeedRepository +import leets.leenk.domain.feed.domain.service.FeedGetService +import leets.leenk.global.common.exception.ResourceLockedException + +class FeedGetServiceTest : + DescribeSpec({ + val feedRepository = mockk() + val feedGetService = FeedGetService(feedRepository) + + describe("피드 리액션 기능") { + context("동시 요청 시 락 타임아웃 발생 시") { + it("DB에서 락 예외가 발생하면 커스텀 예외로 변환해야 한다") { + every { feedRepository.findByIdWithPessimisticLock(any()) } throws PessimisticLockException() + + shouldThrow { + feedGetService.findByIdWithLock(1L) + } + } + } + } + }) diff --git a/src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseIntegrationTest.kt b/src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseIntegrationTest.kt index a4594310..7869a096 100644 --- a/src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseIntegrationTest.kt +++ b/src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseIntegrationTest.kt @@ -34,156 +34,167 @@ class FeedUsecaseIntegrationTest( private val feedUsecase: FeedUsecase, private val userRepository: UserRepository, private val feedRepository: FeedRepository, - private val reactionRepository: ReactionRepository + private val reactionRepository: ReactionRepository, ) : BehaviorSpec({ - afterEach { - reactionRepository.deleteAll() - feedRepository.deleteAll() - userRepository.deleteAll() - } - - /** - * 여러 사용자가 동시에 같은 피드에 공감할 때 비관적 락으로 동시성을 제어하는지 검증 - * 비관적 락이 없으면 데드락이 발생할 수 있음 - */ - Given("여러 사용자가 동시에 같은 피드에 공감하는 경우") { - val feedAuthor = userRepository.save( - UserTestFixture.createUser(id = FEED_AUTHOR_ID_100, name = "피드작성자") - ) - - val feed = feedRepository.save( - FeedTestFixture.createFeed(user = feedAuthor, description = "동시성 테스트용 피드") - ) - - val users = (1..CONCURRENT_THREAD_COUNT).map { i -> - userRepository.save( - UserTestFixture.createUser(id = i.toLong(), name = "사용자$i") - ) + afterEach { + reactionRepository.deleteAll() + feedRepository.deleteAll() + userRepository.deleteAll() } - When("비관적 락을 사용하여 동시에 공감을 추가하면") { - val (successCount, failureCount) = executeConcurrentReactions( - threadCount = CONCURRENT_THREAD_COUNT, - reactions = users.map { user -> - { feedUsecase.reactToFeed(user.id!!, feed.id!!, ReactionRequest(1L)) } + // 여러 사용자가 동시에 같은 피드에 공감할 때 비관적 락으로 동시성을 제어하는지 검증 + // 비관적 락이 없으면 데드락이 발생할 수 있음 + Given("여러 사용자가 동시에 같은 피드에 공감하는 경우") { + val feedAuthor = + userRepository.save( + UserTestFixture.createUser(id = FEED_AUTHOR_ID_100, name = "피드작성자"), + ) + + val feed = + feedRepository.save( + FeedTestFixture.createFeed(user = feedAuthor, description = "동시성 테스트용 피드"), + ) + + val users = + (1..CONCURRENT_THREAD_COUNT).map { i -> + userRepository.save( + UserTestFixture.createUser(id = i.toLong(), name = "사용자$i"), + ) } - ) - - Then("모든 요청이 성공하고 리액션 수가 정확해야 한다") { - successCount shouldBe CONCURRENT_THREAD_COUNT - failureCount shouldBe 0 - - val reactions = reactionRepository.findAllByFeed(feed) - reactions.size shouldBe CONCURRENT_THREAD_COUNT - - // 영속성 컨텍스트에서 최신 데이터 조회 - val updatedFeedAuthor = userRepository.findById(feedAuthor.id!!).get() - updatedFeedAuthor.totalReactionCount shouldBe CONCURRENT_THREAD_COUNT.toLong() - val updatedFeed = feedRepository.findById(feed.id!!).get() - updatedFeed.totalReactionCount shouldBe CONCURRENT_THREAD_COUNT.toLong() + When("비관적 락을 사용하여 동시에 공감을 추가하면") { + val (successCount, failureCount) = + executeConcurrentReactions( + threadCount = CONCURRENT_THREAD_COUNT, + reactions = + users.map { user -> + { feedUsecase.reactToFeed(user.id!!, feed.id!!, ReactionRequest(1L)) } + }, + ) + + Then("모든 요청이 성공하고 리액션 수가 정확해야 한다") { + successCount shouldBe CONCURRENT_THREAD_COUNT + failureCount shouldBe 0 + + val reactions = reactionRepository.findAllByFeed(feed) + reactions.size shouldBe CONCURRENT_THREAD_COUNT + + // 영속성 컨텍스트에서 최신 데이터 조회 + val updatedFeedAuthor = userRepository.findById(feedAuthor.id!!).get() + updatedFeedAuthor.totalReactionCount shouldBe CONCURRENT_THREAD_COUNT.toLong() + + val updatedFeed = feedRepository.findById(feed.id!!).get() + updatedFeed.totalReactionCount shouldBe CONCURRENT_THREAD_COUNT.toLong() + } } } - } - /** - * 동일 사용자가 여러 번 공감할 때 reactionCount가 정확하게 증가하는지 검증 - * 피드에 먼저 락을 걸어 유니크 제약 조건 위반(동시 INSERT)을 방지 - */ - Given("동일 사용자가 동시에 여러 번 공감하는 경우") { - val feedAuthor = userRepository.save( - UserTestFixture.createUser(id = FEED_AUTHOR_ID_200, name = "피드작성자") - ) - - val user = userRepository.save( - UserTestFixture.createUser(id = USER_ID_201, name = "공감사용자") - ) - - val feed = feedRepository.save( - FeedTestFixture.createFeed(user = feedAuthor, description = "동일 사용자 동시성 테스트용 피드") - ) - - When("비관적 락을 사용하여 동시에 여러 번 공감을 추가하면") { - val (successCount, failureCount) = executeConcurrentReactions( - threadCount = ATTEMPT_COUNT, - reactions = List(ATTEMPT_COUNT) { - { feedUsecase.reactToFeed(user.id!!, feed.id!!, ReactionRequest(1L)) } + // 동일 사용자가 여러 번 공감할 때 reactionCount가 정확하게 증가하는지 검증 + // 피드에 먼저 락을 걸어 유니크 제약 조건 위반(동시 INSERT)을 방지 + Given("동일 사용자가 동시에 여러 번 공감하는 경우") { + val feedAuthor = + userRepository.save( + UserTestFixture.createUser(id = FEED_AUTHOR_ID_200, name = "피드작성자"), + ) + + val user = + userRepository.save( + UserTestFixture.createUser(id = USER_ID_201, name = "공감사용자"), + ) + + val feed = + feedRepository.save( + FeedTestFixture.createFeed(user = feedAuthor, description = "동일 사용자 동시성 테스트용 피드"), + ) + + When("비관적 락을 사용하여 동시에 여러 번 공감을 추가하면") { + val (successCount, failureCount) = + executeConcurrentReactions( + threadCount = ATTEMPT_COUNT, + reactions = + List(ATTEMPT_COUNT) { + { feedUsecase.reactToFeed(user.id!!, feed.id!!, ReactionRequest(1L)) } + }, + ) + + Then("모든 요청이 성공하고 리액션 카운트가 정확해야 한다") { + successCount shouldBe ATTEMPT_COUNT + failureCount shouldBe 0 + + val reaction = reactionRepository.findByFeedAndUser(feed, user) + reaction.isPresent shouldBe true + reaction.get().reactionCount shouldBe ATTEMPT_COUNT.toLong() + + // 영속성 컨텍스트에서 최신 데이터 조회 + val updatedFeedAuthor = userRepository.findById(feedAuthor.id!!).get() + updatedFeedAuthor.totalReactionCount shouldBe ATTEMPT_COUNT.toLong() + + val updatedFeed = feedRepository.findById(feed.id!!).get() + updatedFeed.totalReactionCount shouldBe ATTEMPT_COUNT.toLong() } - ) - - Then("모든 요청이 성공하고 리액션 카운트가 정확해야 한다") { - successCount shouldBe ATTEMPT_COUNT - failureCount shouldBe 0 - - val reaction = reactionRepository.findByFeedAndUser(feed, user) - reaction.isPresent shouldBe true - reaction.get().reactionCount shouldBe ATTEMPT_COUNT.toLong() - - // 영속성 컨텍스트에서 최신 데이터 조회 - val updatedFeedAuthor = userRepository.findById(feedAuthor.id!!).get() - updatedFeedAuthor.totalReactionCount shouldBe ATTEMPT_COUNT.toLong() - - val updatedFeed = feedRepository.findById(feed.id!!).get() - updatedFeed.totalReactionCount shouldBe ATTEMPT_COUNT.toLong() } } - } - /** - * 동일 작성자의 여러 피드에 여러 사용자가 동시에 공감할 때 데드락이 발생하지 않는지 검증 - * 작성자에 락이 걸리지 않은 경우 데드락이 발생할 수 있음 - */ - Given("다른 사용자가 동일한 사용자의 다른 피드에 동시에 공감하는 경우 (고강도 테스트)") { - val feedAuthor = userRepository.saveAndFlush( - UserTestFixture.createUser(id = FEED_AUTHOR_ID_300, name = "피드 작성자") - ) - - val feed1 = feedRepository.saveAndFlush( - FeedTestFixture.createFeed(user = feedAuthor) - ) - - val feed2 = feedRepository.saveAndFlush( - FeedTestFixture.createFeed(user = feedAuthor) - ) - - val users = (1..CONCURRENT_THREAD_COUNT).map { i -> - userRepository.saveAndFlush( - UserTestFixture.createUser(id = USER_ID_BASE_300 + i, name = "사용자$i") - ) - } - - When("비관적 락 환경에서 동시에 요청을 보낼 때") { - val (successCount, failureCount) = executeConcurrentReactions( - threadCount = CONCURRENT_THREAD_COUNT, - reactions = users.mapIndexed { index, user -> - // 짝수/홀수 인덱스로 피드를 나누어 동일 작성자의 서로 다른 피드로 트랜잭션 분산 - val targetFeedId = if (index % 2 == 0) feed1.id!! else feed2.id!! - { feedUsecase.reactToFeed(user.id!!, targetFeedId, ReactionRequest(1L)) } + // 동일 작성자의 여러 피드에 여러 사용자가 동시에 공감할 때 데드락이 발생하지 않는지 검증 + // 작성자에 락이 걸리지 않은 경우 데드락이 발생할 수 있음 + Given("다른 사용자가 동일한 사용자의 다른 피드에 동시에 공감하는 경우 (고강도 테스트)") { + val feedAuthor = + userRepository.saveAndFlush( + UserTestFixture.createUser(id = FEED_AUTHOR_ID_300, name = "피드 작성자"), + ) + + val feed1 = + feedRepository.saveAndFlush( + FeedTestFixture.createFeed(user = feedAuthor), + ) + + val feed2 = + feedRepository.saveAndFlush( + FeedTestFixture.createFeed(user = feedAuthor), + ) + + val users = + (1..CONCURRENT_THREAD_COUNT).map { i -> + userRepository.saveAndFlush( + UserTestFixture.createUser(id = USER_ID_BASE_300 + i, name = "사용자$i"), + ) } - ) - - Then("데드락 없이 모든 요청이 성공해야 한다") { - successCount shouldBe CONCURRENT_THREAD_COUNT - failureCount shouldBe 0 - val reactions1 = reactionRepository.findAllByFeed(feed1) - val reactions2 = reactionRepository.findAllByFeed(feed2) - - reactions1.size shouldBe (CONCURRENT_THREAD_COUNT / 2 + CONCURRENT_THREAD_COUNT % 2) - reactions2.size shouldBe CONCURRENT_THREAD_COUNT / 2 - - // 영속성 컨텍스트에서 최신 데이터 조회 - val updatedFeedAuthor = userRepository.findById(feedAuthor.id!!).get() - updatedFeedAuthor.totalReactionCount shouldBe CONCURRENT_THREAD_COUNT.toLong() - - val updatedFeed1 = feedRepository.findById(feed1.id!!).get() - val updatedFeed2 = feedRepository.findById(feed2.id!!).get() - (updatedFeed1.totalReactionCount + updatedFeed2.totalReactionCount) shouldBe CONCURRENT_THREAD_COUNT.toLong() + When("비관적 락 환경에서 동시에 요청을 보낼 때") { + val (successCount, failureCount) = + executeConcurrentReactions( + threadCount = CONCURRENT_THREAD_COUNT, + reactions = + users.mapIndexed { index, user -> + // 짝수/홀수 인덱스로 피드를 나누어 동일 작성자의 서로 다른 피드로 트랜잭션 분산 + val targetFeedId = if (index % 2 == 0) feed1.id!! else feed2.id!! + { feedUsecase.reactToFeed(user.id!!, targetFeedId, ReactionRequest(1L)) } + }, + ) + + Then("데드락 없이 모든 요청이 성공해야 한다") { + successCount shouldBe CONCURRENT_THREAD_COUNT + failureCount shouldBe 0 + + val reactions1 = reactionRepository.findAllByFeed(feed1) + val reactions2 = reactionRepository.findAllByFeed(feed2) + + reactions1.size shouldBe (CONCURRENT_THREAD_COUNT / 2 + CONCURRENT_THREAD_COUNT % 2) + reactions2.size shouldBe CONCURRENT_THREAD_COUNT / 2 + + // 영속성 컨텍스트에서 최신 데이터 조회 + val updatedFeedAuthor = userRepository.findById(feedAuthor.id!!).get() + updatedFeedAuthor.totalReactionCount shouldBe CONCURRENT_THREAD_COUNT.toLong() + + val updatedFeed1 = feedRepository.findById(feed1.id!!).get() + val updatedFeed2 = feedRepository.findById(feed2.id!!).get() + (updatedFeed1.totalReactionCount + updatedFeed2.totalReactionCount) shouldBe + CONCURRENT_THREAD_COUNT.toLong() + } } } - } -}) + }) /** * 동시성 테스트를 위한 헬퍼 함수 @@ -194,7 +205,7 @@ class FeedUsecaseIntegrationTest( */ private fun executeConcurrentReactions( threadCount: Int, - reactions: List<() -> Unit> + reactions: List<() -> Unit>, ): Pair { val executor = Executors.newFixedThreadPool(threadCount) val latch = CountDownLatch(reactions.size) diff --git a/src/test/kotlin/leets/leenk/domain/feed/test/fixture/FeedTestFixture.kt b/src/test/kotlin/leets/leenk/domain/feed/test/fixture/FeedTestFixture.kt index 955b7fe2..7da94f72 100644 --- a/src/test/kotlin/leets/leenk/domain/feed/test/fixture/FeedTestFixture.kt +++ b/src/test/kotlin/leets/leenk/domain/feed/test/fixture/FeedTestFixture.kt @@ -8,13 +8,13 @@ class FeedTestFixture { fun createFeed( user: User, description: String = "테스트 피드", - totalReactionCount: Long = 0L - ): Feed { - return Feed.builder() + totalReactionCount: Long = 0L, + ): Feed = + Feed + .builder() .user(user) .description(description) .totalReactionCount(totalReactionCount) .build() - } } } diff --git a/src/test/kotlin/leets/leenk/domain/leenk/application/usecase/LeenkUsecaseTest.kt b/src/test/kotlin/leets/leenk/domain/leenk/application/usecase/LeenkUsecaseTest.kt index 99186071..ce9c7d47 100644 --- a/src/test/kotlin/leets/leenk/domain/leenk/application/usecase/LeenkUsecaseTest.kt +++ b/src/test/kotlin/leets/leenk/domain/leenk/application/usecase/LeenkUsecaseTest.kt @@ -28,9 +28,7 @@ import leets.leenk.domain.user.test.fixture.UserTestFixture import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.* - class LeenkUsecaseTest { - private val locationSaveService: LocationSaveService = mockk() private val leenkSaveService: LeenkSaveService = mockk() private val leenkGetService: LeenkGetService = mockk() @@ -81,9 +79,9 @@ class LeenkUsecaseTest { participantsMapper: LeenkParticipantsMapper, locationMapper: LocationMapper, mediaMapper: MediaMapper, - leenkNotificationUsecase: LeenkNotificationUsecase - ): LeenkUsecase { - return LeenkUsecase( + leenkNotificationUsecase: LeenkNotificationUsecase, + ): LeenkUsecase = + LeenkUsecase( locationSaveService, leenkSaveService, leenkGetService, @@ -102,48 +100,50 @@ class LeenkUsecaseTest { participantsMapper, locationMapper, mediaMapper, - leenkNotificationUsecase + leenkNotificationUsecase, ) - } @BeforeEach fun setUp() { - leenkUsecase = createLeenkUsecase( - locationSaveService = locationSaveService, - leenkSaveService = leenkSaveService, - leenkGetService = leenkGetService, - leenkUpdateService = leenkUpdateService, - leenkDeleteService = leenkDeleteService, - leenkParticipantsSaveService = leenkParticipantsSaveService, - leenkParticipantsGetService = leenkParticipantsGetService, - leenkParticipantsDeleteService = leenkParticipantsDeleteService, - mediaSaveService = mediaSaveService, - mediaGetService = mediaGetService, - mediaDeleteService = mediaDeleteService, - userGetService = userGetService, - slackWebhookService = slackWebhookService, - notionDatabaseService = notionDatabaseService, - leenkMapper = leenkMapper, - participantsMapper = participantsMapper, - locationMapper = locationMapper, - mediaMapper = mediaMapper, - leenkNotificationUsecase = leenkNotificationUsecase - ) + leenkUsecase = + createLeenkUsecase( + locationSaveService = locationSaveService, + leenkSaveService = leenkSaveService, + leenkGetService = leenkGetService, + leenkUpdateService = leenkUpdateService, + leenkDeleteService = leenkDeleteService, + leenkParticipantsSaveService = leenkParticipantsSaveService, + leenkParticipantsGetService = leenkParticipantsGetService, + leenkParticipantsDeleteService = leenkParticipantsDeleteService, + mediaSaveService = mediaSaveService, + mediaGetService = mediaGetService, + mediaDeleteService = mediaDeleteService, + userGetService = userGetService, + slackWebhookService = slackWebhookService, + notionDatabaseService = notionDatabaseService, + leenkMapper = leenkMapper, + participantsMapper = participantsMapper, + locationMapper = locationMapper, + mediaMapper = mediaMapper, + leenkNotificationUsecase = leenkNotificationUsecase, + ) user = UserTestFixture.createUser(id = 1L) location = LocationTestFixture.createLocation(id = 1L) - recruitingLeenk = LeenkTestFixture.createLeenk( - id = 1L, - author = user, - location = location, - status = LeenkStatus.RECRUITING, - currentParticipants = 2L, - maxParticipants = 10L - ) - participant = LeenkParticipantsTestFixture.createParticipant( - leenk = recruitingLeenk, - participant = user - ) + recruitingLeenk = + LeenkTestFixture.createLeenk( + id = 1L, + author = user, + location = location, + status = LeenkStatus.RECRUITING, + currentParticipants = 2L, + maxParticipants = 10L, + ) + participant = + LeenkParticipantsTestFixture.createParticipant( + leenk = recruitingLeenk, + participant = user, + ) } @Nested @@ -174,11 +174,12 @@ class LeenkUsecaseTest { @DisplayName("모집 중이 아닌 링크에 참여 시 예외가 발생한다") fun participateLeenkNotRecruitingThrowsException() { // given - val closedLeenk = LeenkTestFixture.createClosedLeenk( - id = 1L, - author = user, - location = location - ) + val closedLeenk = + LeenkTestFixture.createClosedLeenk( + id = 1L, + author = user, + location = location, + ) every { userGetService.findById(1L) } returns user every { leenkGetService.findById(1L) } returns closedLeenk @@ -212,11 +213,12 @@ class LeenkUsecaseTest { @DisplayName("최대 참여 인원을 초과하면 예외가 발생한다") fun participateLeenkMaxParticipantsExceededThrowsException() { // given - val fullLeenk = LeenkTestFixture.createFullLeenk( - id = 1L, - author = user, - location = location - ) + val fullLeenk = + LeenkTestFixture.createFullLeenk( + id = 1L, + author = user, + location = location, + ) every { userGetService.findById(1L) } returns user every { leenkGetService.findById(1L) } returns fullLeenk every { leenkParticipantsGetService.existsByLeenkAndParticipant(fullLeenk, user) } returns false @@ -234,16 +236,18 @@ class LeenkUsecaseTest { @DisplayName("최대 참여 인원 직전에 참여하면 정상적으로 처리된다") fun participateLeenkLastSlotSuccess() { // given - val almostFullLeenk = LeenkTestFixture.createAlmostFullLeenk( - id = 1L, - author = user, - location = location - ) - - val lastParticipant = LeenkParticipantsTestFixture.createParticipant( - leenk = almostFullLeenk, - participant = user - ) + val almostFullLeenk = + LeenkTestFixture.createAlmostFullLeenk( + id = 1L, + author = user, + location = location, + ) + + val lastParticipant = + LeenkParticipantsTestFixture.createParticipant( + leenk = almostFullLeenk, + participant = user, + ) every { userGetService.findById(1L) } returns user every { leenkGetService.findById(1L) } returns almostFullLeenk @@ -300,11 +304,12 @@ class LeenkUsecaseTest { @DisplayName("이미 마감된 링크를 다시 마감 시 예외가 발생한다") fun closeLeenkAlreadyClosedThrowsException() { // given - val closedLeenk = LeenkTestFixture.createClosedLeenk( - id = 1L, - author = user, - location = location - ) + val closedLeenk = + LeenkTestFixture.createClosedLeenk( + id = 1L, + author = user, + location = location, + ) every { userGetService.findById(1L) } returns user every { leenkGetService.findById(1L) } returns closedLeenk @@ -320,11 +325,12 @@ class LeenkUsecaseTest { @DisplayName("완료된 상태의 링크를 마감 시 예외가 발생한다") fun closeLeenkCompletedStatusThrowsException() { // given - val completedLeenk = LeenkTestFixture.createFinishedLeenk( - id = 1L, - author = user, - location = location - ) + val completedLeenk = + LeenkTestFixture.createFinishedLeenk( + id = 1L, + author = user, + location = location, + ) every { userGetService.findById(1L) } returns user every { leenkGetService.findById(1L) } returns completedLeenk diff --git a/src/test/kotlin/leets/leenk/domain/leenk/test/fixture/LeenkParticipantsTestFixture.kt b/src/test/kotlin/leets/leenk/domain/leenk/test/fixture/LeenkParticipantsTestFixture.kt index e36fc79a..ce3c674f 100644 --- a/src/test/kotlin/leets/leenk/domain/leenk/test/fixture/LeenkParticipantsTestFixture.kt +++ b/src/test/kotlin/leets/leenk/domain/leenk/test/fixture/LeenkParticipantsTestFixture.kt @@ -1,28 +1,30 @@ -package leets.leenk.domain.leenk.test.fixture - -import leets.leenk.domain.leenk.domain.entity.Leenk -import leets.leenk.domain.leenk.domain.entity.LeenkParticipants -import leets.leenk.domain.user.domain.entity.User -import java.time.LocalDateTime - -class LeenkParticipantsTestFixture { - companion object { - fun createParticipant( - id: Long? = null, - leenk: Leenk, - participant: User, - joinedAt: LocalDateTime = LocalDateTime.now() - ): LeenkParticipants { - val builder = LeenkParticipants.builder() - .leenk(leenk) - .participant(participant) - .joinedAt(joinedAt) - - if (id != null) { - builder.id(id) - } - - return builder.build() - } - } -} +package leets.leenk.domain.leenk.test.fixture + +import leets.leenk.domain.leenk.domain.entity.Leenk +import leets.leenk.domain.leenk.domain.entity.LeenkParticipants +import leets.leenk.domain.user.domain.entity.User +import java.time.LocalDateTime + +class LeenkParticipantsTestFixture { + companion object { + fun createParticipant( + id: Long? = null, + leenk: Leenk, + participant: User, + joinedAt: LocalDateTime = LocalDateTime.now(), + ): LeenkParticipants { + val builder = + LeenkParticipants + .builder() + .leenk(leenk) + .participant(participant) + .joinedAt(joinedAt) + + if (id != null) { + builder.id(id) + } + + return builder.build() + } + } +} diff --git a/src/test/kotlin/leets/leenk/domain/leenk/test/fixture/LeenkTestFixture.kt b/src/test/kotlin/leets/leenk/domain/leenk/test/fixture/LeenkTestFixture.kt index 406b3f1f..f38e8b8f 100644 --- a/src/test/kotlin/leets/leenk/domain/leenk/test/fixture/LeenkTestFixture.kt +++ b/src/test/kotlin/leets/leenk/domain/leenk/test/fixture/LeenkTestFixture.kt @@ -1,105 +1,103 @@ -package leets.leenk.domain.leenk.test.fixture - -import leets.leenk.domain.leenk.domain.entity.Leenk -import leets.leenk.domain.leenk.domain.entity.Location -import leets.leenk.domain.leenk.domain.entity.enums.LeenkStatus -import leets.leenk.domain.user.domain.entity.User -import java.time.LocalDateTime - -class LeenkTestFixture { - companion object { - fun createLeenk( - id: Long? = null, - author: User, - location: Location, - title: String = "테스트 링크", - content: String? = "테스트 내용", - startTime: LocalDateTime = LocalDateTime.now().plusDays(1), - maxParticipants: Long = 10L, - currentParticipants: Long = 1L, - status: LeenkStatus = LeenkStatus.RECRUITING - ): Leenk { - val builder = Leenk.builder() - .author(author) - .location(location) - .title(title) - .content(content) - .startTime(startTime) - .maxParticipants(maxParticipants) - .currentParticipants(currentParticipants) - .status(status) - - if (id != null) { - builder.id(id) - } - - return builder.build() - } - - fun createClosedLeenk( - id: Long? = null, - author: User, - location: Location, - currentParticipants: Long = 5L, - maxParticipants: Long = 10L - ): Leenk { - return createLeenk( - id = id, - author = author, - location = location, - status = LeenkStatus.CLOSED, - currentParticipants = currentParticipants, - maxParticipants = maxParticipants - ) - } - - fun createFullLeenk( - id: Long? = null, - author: User, - location: Location, - maxParticipants: Long = 10L - ): Leenk { - return createLeenk( - id = id, - author = author, - location = location, - status = LeenkStatus.RECRUITING, - currentParticipants = maxParticipants, - maxParticipants = maxParticipants - ) - } - - fun createAlmostFullLeenk( - id: Long? = null, - author: User, - location: Location, - maxParticipants: Long = 10L - ): Leenk { - return createLeenk( - id = id, - author = author, - location = location, - status = LeenkStatus.RECRUITING, - currentParticipants = maxParticipants - 1, - maxParticipants = maxParticipants - ) - } - - fun createFinishedLeenk( - id: Long? = null, - author: User, - location: Location, - currentParticipants: Long = 10L, - maxParticipants: Long = 10L - ): Leenk { - return createLeenk( - id = id, - author = author, - location = location, - status = LeenkStatus.FINISHED, - currentParticipants = currentParticipants, - maxParticipants = maxParticipants - ) - } - } -} +package leets.leenk.domain.leenk.test.fixture + +import leets.leenk.domain.leenk.domain.entity.Leenk +import leets.leenk.domain.leenk.domain.entity.Location +import leets.leenk.domain.leenk.domain.entity.enums.LeenkStatus +import leets.leenk.domain.user.domain.entity.User +import java.time.LocalDateTime + +class LeenkTestFixture { + companion object { + fun createLeenk( + id: Long? = null, + author: User, + location: Location, + title: String = "테스트 링크", + content: String? = "테스트 내용", + startTime: LocalDateTime = LocalDateTime.now().plusDays(1), + maxParticipants: Long = 10L, + currentParticipants: Long = 1L, + status: LeenkStatus = LeenkStatus.RECRUITING, + ): Leenk { + val builder = + Leenk + .builder() + .author(author) + .location(location) + .title(title) + .content(content) + .startTime(startTime) + .maxParticipants(maxParticipants) + .currentParticipants(currentParticipants) + .status(status) + + if (id != null) { + builder.id(id) + } + + return builder.build() + } + + fun createClosedLeenk( + id: Long? = null, + author: User, + location: Location, + currentParticipants: Long = 5L, + maxParticipants: Long = 10L, + ): Leenk = + createLeenk( + id = id, + author = author, + location = location, + status = LeenkStatus.CLOSED, + currentParticipants = currentParticipants, + maxParticipants = maxParticipants, + ) + + fun createFullLeenk( + id: Long? = null, + author: User, + location: Location, + maxParticipants: Long = 10L, + ): Leenk = + createLeenk( + id = id, + author = author, + location = location, + status = LeenkStatus.RECRUITING, + currentParticipants = maxParticipants, + maxParticipants = maxParticipants, + ) + + fun createAlmostFullLeenk( + id: Long? = null, + author: User, + location: Location, + maxParticipants: Long = 10L, + ): Leenk = + createLeenk( + id = id, + author = author, + location = location, + status = LeenkStatus.RECRUITING, + currentParticipants = maxParticipants - 1, + maxParticipants = maxParticipants, + ) + + fun createFinishedLeenk( + id: Long? = null, + author: User, + location: Location, + currentParticipants: Long = 10L, + maxParticipants: Long = 10L, + ): Leenk = + createLeenk( + id = id, + author = author, + location = location, + status = LeenkStatus.FINISHED, + currentParticipants = currentParticipants, + maxParticipants = maxParticipants, + ) + } +} diff --git a/src/test/kotlin/leets/leenk/domain/leenk/test/fixture/LocationTestFixture.kt b/src/test/kotlin/leets/leenk/domain/leenk/test/fixture/LocationTestFixture.kt index 0acc00bb..c4888edc 100644 --- a/src/test/kotlin/leets/leenk/domain/leenk/test/fixture/LocationTestFixture.kt +++ b/src/test/kotlin/leets/leenk/domain/leenk/test/fixture/LocationTestFixture.kt @@ -1,17 +1,17 @@ -package leets.leenk.domain.leenk.test.fixture - -import leets.leenk.domain.leenk.domain.entity.Location - -class LocationTestFixture { - companion object { - fun createLocation( - id: Long? = null, - placeName: String = "테스트 장소" - ): Location { - return Location.builder() - .apply { id?.let { id(it) } } - .placeName(placeName) - .build() - } - } -} +package leets.leenk.domain.leenk.test.fixture + +import leets.leenk.domain.leenk.domain.entity.Location + +class LocationTestFixture { + companion object { + fun createLocation( + id: Long? = null, + placeName: String = "테스트 장소", + ): Location = + Location + .builder() + .apply { id?.let { id(it) } } + .placeName(placeName) + .build() + } +} diff --git a/src/test/kotlin/leets/leenk/domain/user/test/fixture/UserTestFixture.kt b/src/test/kotlin/leets/leenk/domain/user/test/fixture/UserTestFixture.kt index a948a430..da421b23 100644 --- a/src/test/kotlin/leets/leenk/domain/user/test/fixture/UserTestFixture.kt +++ b/src/test/kotlin/leets/leenk/domain/user/test/fixture/UserTestFixture.kt @@ -1,38 +1,40 @@ -package leets.leenk.domain.user.test.fixture - -import leets.leenk.domain.user.domain.entity.User -import java.time.LocalDate - -class UserTestFixture { - companion object { - fun createUser( - id: Long = 1L, - name: String = "테스트유저", - cardinal: Int = 1, - profileImage: String? = null, - birthday: LocalDate? = null, - thumbnail: String? = null, - mbti: String? = null, - introduction: String? = null, - fcmToken: String? = null, - kakaoTalkId: String? = null, - totalReactionCount: Long = 0L, - termsAgreement: Boolean = true, - privacyAgreement: Boolean = true - ): User = User.builder() - .id(id) - .name(name) - .cardinal(cardinal) - .profileImage(profileImage) - .birthday(birthday) - .thumbnail(thumbnail) - .mbti(mbti) - .introduction(introduction) - .fcmToken(fcmToken) - .kakaoTalkId(kakaoTalkId) - .totalReactionCount(totalReactionCount) - .termsAgreement(termsAgreement) - .privacyAgreement(privacyAgreement) - .build() - } -} +package leets.leenk.domain.user.test.fixture + +import leets.leenk.domain.user.domain.entity.User +import java.time.LocalDate + +class UserTestFixture { + companion object { + fun createUser( + id: Long = 1L, + name: String = "테스트유저", + cardinal: Int = 1, + profileImage: String? = null, + birthday: LocalDate? = null, + thumbnail: String? = null, + mbti: String? = null, + introduction: String? = null, + fcmToken: String? = null, + kakaoTalkId: String? = null, + totalReactionCount: Long = 0L, + termsAgreement: Boolean = true, + privacyAgreement: Boolean = true, + ): User = + User + .builder() + .id(id) + .name(name) + .cardinal(cardinal) + .profileImage(profileImage) + .birthday(birthday) + .thumbnail(thumbnail) + .mbti(mbti) + .introduction(introduction) + .fcmToken(fcmToken) + .kakaoTalkId(kakaoTalkId) + .totalReactionCount(totalReactionCount) + .termsAgreement(termsAgreement) + .privacyAgreement(privacyAgreement) + .build() + } +} From 5f8139a9932b080fe7a1511ccb382f5b391059a6 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Fri, 16 Jan 2026 17:39:58 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20=EB=A7=88=EC=A7=80=EB=A7=89=20?= =?UTF-8?q?=EC=89=BC=ED=91=9C=20=EC=B6=94=EA=B0=80=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 2 -- 1 file changed, 2 deletions(-) diff --git a/.editorconfig b/.editorconfig index b18893e9..e98a5472 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,8 +8,6 @@ trim_trailing_whitespace = true indent_style = space indent_size = 4 max_line_length = 120 -ij_kotlin_allow_trailing_comma = true # 마지막에 쉼표 추가 -ij_kotlin_allow_trailing_comma_on_call_site = true ktlint_standard_no-wildcard-imports = disabled # 와일드카드 Import 허용 [*.{yml,yaml}] From dd2f245250cb74cb9a5fcd6a8832e109d433e217 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Fri, 16 Jan 2026 17:48:09 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20CI=20=EB=8F=99=EC=9E=91?= =?UTF-8?q?=EC=8B=9C=20=EB=A6=B0=ED=8A=B8=20=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev.yml | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 25455a89..27ff2bdd 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -32,6 +32,10 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- + # Check Ktlint + - name: Run ktlint Check + run: ./gradlew ktlintCheck + # Run tests - name: Run tests run: ./gradlew test @@ -67,12 +71,12 @@ jobs: port: 22 script: | cd /home/ubuntu/compose - + echo "===== 최신 Docker 이미지 가져오는 중 =====" docker pull ${{ secrets.DOCKER_USER_NAME }}/leenk-dev - + EXIST_BLUE=$(docker inspect -f '{{.State.Running}}' spring-blue 2>/dev/null) - + if [ "$EXIST_BLUE" != "true" ]; then docker-compose up -d spring-blue BEFORE_COLOR="green" @@ -86,9 +90,9 @@ jobs: BEFORE_PORT=8080 AFTER_PORT=8081 fi - + echo "===== ${AFTER_COLOR} server up(port:${AFTER_PORT}) =====" - + for cnt in {1..10} do echo "===== 서버 응답 확인중(${cnt}/10) ====="; @@ -100,29 +104,29 @@ jobs: break fi done - + if [ $cnt -eq 10 ]; then echo "===== 서버 실행 실패 =====" - + echo "===== Health-check 실패: spring-${AFTER_COLOR} 로그 =====" docker logs spring-${AFTER_COLOR} || true docker-compose stop spring-${AFTER_COLOR} docker-compose rm -f spring-${AFTER_COLOR} - - echo "===== 롤백 완료: 기존 컨테이너 유지 =====" + + echo "===== 롤백 완료: 기존 컨테이너 유지 =====" exit 1 fi - + echo "===== Caddy 설정 변경 =====" sudo sed -i "s/${BEFORE_PORT}/${AFTER_PORT}/g" /etc/caddy/Caddyfile - + sudo caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile - + echo "$BEFORE_COLOR server down(port:${BEFORE_PORT})" docker-compose stop spring-${BEFORE_COLOR} docker-compose rm -f spring-${BEFORE_COLOR} - + echo "===== 사용하지 않는 Docker 이미지 정리 중 =====" sudo docker image prune -f From 72bc807a1da2749fae0b18998d7b40d7e59204bb Mon Sep 17 00:00:00 2001 From: hyxklee Date: Sun, 18 Jan 2026 22:24:49 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20=EC=97=90=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A8=EB=8D=B8=20=EC=83=81=EC=86=8D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/agents/code-review-agent.md | 2 +- .claude/agents/debugger-agent.md | 4 ++-- .claude/agents/kotlin-migration-agent.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude/agents/code-review-agent.md b/.claude/agents/code-review-agent.md index 7866c79e..480f6ce6 100644 --- a/.claude/agents/code-review-agent.md +++ b/.claude/agents/code-review-agent.md @@ -2,7 +2,7 @@ name: code-review-agent description: "PR/커밋 코드 리뷰 전문 에이전트. 버그, 보안 취약점, 성능 이슈를 탐지하고 구체적인 수정 코드를 제시합니다.(사용 예시: 현재까지 작업한 내용 코드리뷰 에이전트를 사용해 백그라운드에서 리뷰를 진행해줘.)" tools: Glob, Grep, Read, Bash -model: sonnet +model: inherit color: orange --- diff --git a/.claude/agents/debugger-agent.md b/.claude/agents/debugger-agent.md index 119b4b0e..8dbdc59b 100644 --- a/.claude/agents/debugger-agent.md +++ b/.claude/agents/debugger-agent.md @@ -2,7 +2,7 @@ name: debugger-agent description: "버그 디버깅 전문 에이전트. 에러/스택트레이스 분석, 가설-검증 방식으로 근본 원인 파악, 수정 코드와 재발 방지책 제시." tools: Glob, Grep, Read, Bash, Edit, Write, TodoWrite -model: opus +model: inherit color: red --- @@ -142,4 +142,4 @@ git blame [filepath] - Form hypotheses and verify systematically - Reproduce bug with test BEFORE fixing - Verify test passes AFTER fixing -- Never give up until root cause is found \ No newline at end of file +- Never give up until root cause is found diff --git a/.claude/agents/kotlin-migration-agent.md b/.claude/agents/kotlin-migration-agent.md index 56f21ce3..34374d3f 100644 --- a/.claude/agents/kotlin-migration-agent.md +++ b/.claude/agents/kotlin-migration-agent.md @@ -2,7 +2,7 @@ name: kotlin-migration-agent description: "Java → Kotlin 마이그레이션 전문 에이전트. 테스트 작성 → 마이그레이션 → 리팩토링 → ktlint 검증 순서로 안전하게 진행합니다." tools: Glob, Grep, Read, TodoWrite, Edit, Write, Bash -model: sonnet +model: inherit color: red ---