Skip to content
Closed
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
114 changes: 114 additions & 0 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: Claude Code Review

on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: read
pull-requests: write
issues: write

jobs:
claude-review:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Claude Code Review
uses: anthropics/claude-code-action@beta
with:
use_claude_max: true
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
direct_prompt: |
당신은 **헥사고날 아키텍처(Ports & Adapters)** 를 사용하는 Kotlin/Spring Boot 프로젝트의 전문 코드 리뷰어입니다.
**모든 리뷰 내용은 반드시 한국어로 작성하세요.**

이 프로젝트의 패키지 구조는 다음과 같습니다:
- `adapter/in/web` — 인풋 어댑터 (REST 컨트롤러)
- `adapter/out` — 아웃풋 어댑터 (영속성, 외부 API, AI 등)
- `application/port/in` — 인풋 포트 (유스케이스 인터페이스)
- `application/port/out` — 아웃풋 포트 (드리븐 포트 인터페이스)
- `application/service` — 유스케이스 구현체
- `domain` — 도메인 모델 및 비즈니스 로직 (프레임워크 의존성 없음)

PR diff를 리뷰하고 아래 5개 항목에 대해 피드백을 제공하세요.
각 항목마다 판정(✅ 통과 / ⚠️ 주의 / ❌ 위반)과 상세 설명을 작성하세요.
위반 사항이 있다면 구체적인 수정 코드를 함께 제안하세요.

---

## 1. 헥사고날 아키텍처 준수 여부

다음 규칙을 확인하세요:
- **Domain 레이어**는 `adapter`나 `application` 패키지를 import하면 안 됩니다. 프레임워크에 독립적이어야 합니다 (`@Entity`, `@Service` 등 Spring 어노테이션 사용 금지).
- **Application service**는 **포트 인터페이스**에만 의존해야 하며, 구체적인 어댑터에 직접 의존하면 안 됩니다.
- **인풋 어댑터**(컨트롤러)는 **인풋 포트** 인터페이스만 호출해야 하며, 서비스를 직접 호출하면 안 됩니다.
- **아웃풋 어댑터**는 **아웃풋 포트** 인터페이스를 구현해야 합니다.
- 레이어 간 순환 의존성이 없어야 합니다.
- DTO는 adapter 레이어에 위치해야 하며, domain이나 application 레이어에 있으면 안 됩니다.

## 2. 단위 테스트 작성 여부

다음을 확인하세요:
- 새로 추가/수정된 소스 파일에 대응하는 테스트 파일이 있는지?
- 테스트가 모킹(Mockito, MockK 등)을 사용하여 단위를 격리하고 있는지?
- 변경 사항에 대한 테스트 커버리지가 적절한지? (최소한 서비스 레이어와 도메인 로직에는 테스트가 있어야 함)
- 엣지 케이스가 커버되고 있는지?
- 새로운 로직에 대한 테스트가 없다면 위반으로 표시하고, 어떤 테스트를 작성해야 하는지 제안하세요.

## 3. 성능 저하 우려 코드

다음을 확인하세요:
- N+1 쿼리 패턴 (반복문 내부에서 DB 호출을 반복하는 경우)
- 리스트 조회 시 페이징 누락
- 제한 없는 컬렉션 로딩
- 코루틴이나 리액티브 스트림 내부에서의 블로킹 호출
- 핫 패스에서의 대용량 객체 할당
- 쿼리 패턴에서 암시되는 인덱스 누락
- 반복문 내 비효율적인 문자열 연결
- 불필요한 즉시 로딩(Eager fetching)

## 4. SOLID 원칙 준수 여부

각 원칙을 확인하세요:
- **S (단일 책임 원칙):** 각 클래스가 하나의 명확한 책임만 가지고 있는지?
- **O (개방-폐쇄 원칙):** 확장에는 열려 있고 수정에는 닫혀 있는지? (인터페이스/추상화 사용)
- **L (리스코프 치환 원칙):** 하위 타입이 상위 타입을 대체해도 동작이 깨지지 않는지?
- **I (인터페이스 분리 원칙):** 인터페이스가 작고 집중적인지? 불필요한 구현을 강제하는 "뚱뚱한" 인터페이스가 없는지?
- **D (의존성 역전 원칙):** 상위 모듈이 구체 구현이 아닌 추상화에 의존하고 있는지?

## 5. 네이밍 컨벤션 통일성 (권장)

기존 코드베이스 패턴과의 일관성을 확인하세요:
- 서비스 클래스: `*Service`
- 포트 인터페이스: `*UseCase` (인풋), `*Port` (아웃풋)
- 어댑터: `*Adapter`, `*Controller`
- 도메인 모델: 단순 명사 (접미사 없음)
- DTO: `*Request`, `*Response`
- 테스트 클래스: `*Test`, `*IntegrationTest`
- 메서드 네이밍: camelCase, 동사 시작 (예: `findById`, `createDiary`)
- 새로운 이름이 이 코드베이스의 기존 컨벤션과 일관적인지?

---

## 출력 형식

다음 형식으로 리뷰를 구조화하세요:

### 📋 코드 리뷰 요약

| 항목 | 결과 |
|------|------|
| 헥사고날 아키텍처 | ✅ / ⚠️ / ❌ |
| 단위 테스트 | ✅ / ⚠️ / ❌ |
| 성능 | ✅ / ⚠️ / ❌ |
| SOLID 원칙 | ✅ / ⚠️ / ❌ |
| 네이밍 컨벤션 | ✅ / ⚠️ / ❌ |

그 다음 각 항목에 대해 구체적인 파일 경로, 라인 번호, 코드 수정 제안을 포함한 상세 내용을 작성하세요.

긴 코드 제안은 `<details>` 접기 섹션을 사용하여 리뷰의 가독성을 유지하세요.
40 changes: 40 additions & 0 deletions .github/workflows/qodana_code_quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#-------------------------------------------------------------------------------#
# Discover all capabilities of Qodana in our documentation #
# https://www.jetbrains.com/help/qodana/about-qodana.html #
#-------------------------------------------------------------------------------#

name: Qodana
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
- feat/diary

jobs:
qodana:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
checks: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: 'Qodana Scan'
uses: JetBrains/qodana-action@v2025.3
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
with:
# When pr-mode is set to true, Qodana analyzes only the files that have been changed
pr-mode: false
use-caches: true
post-pr-comment: true
use-annotations: true
# Upload Qodana results (SARIF, other artifacts, logs) as an artifact to the job
upload-result: false
# quick-fixes available in Ultimate and Ultimate Plus plans
push-fixes: 'none'
7 changes: 0 additions & 7 deletions .sisyphus/ultrawork-state.json

This file was deleted.

2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ dependencies {
testImplementation("org.awaitility:awaitility-kotlin:4.2.0")
// aws s3 (Spring Cloud AWS for Spring Boot 3.x)
implementation("io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.1")
//코루틴
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
}

kotlin {
Expand Down
49 changes: 49 additions & 0 deletions qodana.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#

#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"

#Specify inspection profile for code analysis
profile:
name: qodana.starter

#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>

#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>

projectJDK: "17" #(Applied in CI/CD pipeline)

#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh

#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)

# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50

#Qodana supports other languages, for example, Python, JavaScript, TypeScript, Go, C#, PHP
#For all supported languages see https://www.jetbrains.com/help/qodana/linters.html
linter: jetbrains/qodana-jvm-community:2025.3
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import b1a4.harudew.auth.security.jwt.JwtTokenProvider
import b1a4.harudew.global.exception.BusinessException
import b1a4.harudew.global.exception.ErrorCode
import b1a4.harudew.member.adapter.infra.MemberJpaRepository
import b1a4.harudew.member.adapter.out.infrastructure.MemberJpaRepository
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Profile
Expand Down Expand Up @@ -47,7 +47,7 @@
private val jwtTokenProvider: JwtTokenProvider,
private val memberRepository: MemberJpaRepository,

@Value("\${jwt.access-expiration:3600000}")

Check notice on line 50 in src/main/kotlin/b1a4/harudew/auth/adapter/in/web/AuthController.kt

View workflow job for this annotation

GitHub Actions / Qodana for JVM

Multi-dollar interpolation can be used in string literals

An interpolation prefix can simplify the string

Check notice on line 50 in src/main/kotlin/b1a4/harudew/auth/adapter/in/web/AuthController.kt

View workflow job for this annotation

GitHub Actions / Qodana for JVM

Multi-dollar interpolation can be used in string literals

An interpolation prefix can simplify the string

Check notice on line 50 in src/main/kotlin/b1a4/harudew/auth/adapter/in/web/AuthController.kt

View workflow job for this annotation

GitHub Actions / Qodana for JVM

Multi-dollar interpolation can be used in string literals

An interpolation prefix can simplify the string
private val accessTokenExpiration: Long
) {

Expand Down
4 changes: 2 additions & 2 deletions src/main/kotlin/b1a4/harudew/auth/config/SuperUserConfig.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package b1a4.harudew.auth.config

import b1a4.harudew.member.adapter.infra.MemberEntity
import b1a4.harudew.member.adapter.infra.MemberJpaRepository
import b1a4.harudew.member.adapter.out.infrastructure.MemberEntity
import b1a4.harudew.member.adapter.out.infrastructure.MemberJpaRepository
import b1a4.harudew.member.domain.Member
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.boot.CommandLineRunner
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import b1a4.harudew.auth.annotation.CurrentMember
import b1a4.harudew.auth.security.jwt.JwtPrincipal
import b1a4.harudew.global.exception.BusinessException
import b1a4.harudew.global.exception.ErrorCode
import b1a4.harudew.member.adapter.infra.MemberJpaRepository
import b1a4.harudew.member.adapter.out.infrastructure.MemberJpaRepository
import b1a4.harudew.member.domain.Member
import org.springframework.core.MethodParameter
import org.springframework.security.core.context.SecurityContextHolder
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package b1a4.harudew.auth.security.oauth2

import b1a4.harudew.member.adapter.infra.MemberEntity
import b1a4.harudew.member.adapter.infra.MemberJpaRepository
import b1a4.harudew.member.adapter.out.infrastructure.MemberEntity
import b1a4.harudew.member.adapter.out.infrastructure.MemberJpaRepository
import b1a4.harudew.member.domain.Member
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
Expand Down Expand Up @@ -131,7 +131,7 @@
org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_USER")
// 확장: 관리자 권한 등 추가
// if (member.isAdmin) SimpleGrantedAuthority("ROLE_ADMIN") else null
).filterNotNull()

Check warning on line 134 in src/main/kotlin/b1a4/harudew/auth/security/oauth2/CustomOAuth2UserService.kt

View workflow job for this annotation

GitHub Actions / Qodana for JVM

Redundant call on collection type

Redundant call on collection type

Check warning on line 134 in src/main/kotlin/b1a4/harudew/auth/security/oauth2/CustomOAuth2UserService.kt

View workflow job for this annotation

GitHub Actions / Qodana for JVM

Redundant call on collection type

Redundant call on collection type

Check warning on line 134 in src/main/kotlin/b1a4/harudew/auth/security/oauth2/CustomOAuth2UserService.kt

View workflow job for this annotation

GitHub Actions / Qodana for JVM

Redundant call on collection type

Redundant call on collection type

override fun getName(): String = member.id
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ data class CreateDiaryCommand(
val weather: String? = null,
val photoPath: List<String>? = null,
val audioPath: List<String>? = null,
val longitude: Number? = null,
val latitude: Number? = null
val longitude: Double? = null,
val latitude: Double? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import b1a4.harudew.person.dto.response.PeopleAnalysisResponse
import b1a4.harudew.todo.adapter.dto.response.TodoAnalysisResponse
import com.fasterxml.jackson.annotation.JsonProperty

data class DiaryAnalysisResponse(
data class DiaryAnalysisResult(
val id: Long,
val title: String,
val photoPath: List<String>?,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package b1a4.harudew.diary.adapter.`in`.web

import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/diary")
class DiaryController {
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class DiaryAnalysisAdapter(
private val prompt: Resource
) : DiaryAnalysisPort {

override fun getAnalysis(content: String) : DiaryAnalysisResponse? {
override fun getAnalysis(content: String) : DiaryAnalysisResponse {

val template = PromptTemplate(prompt)
val renderedPrompt = template.render(mapOf("content" to content))
Expand All @@ -32,6 +32,7 @@ class DiaryAnalysisAdapter(

try {
return aiClient.fetchEntity(renderedPrompt, request, DiaryAnalysisResponse::class.java)
?: throw RuntimeException()
} catch (e: Exception) {
throw DiaryAnalysisFailedException(
e,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package b1a4.harudew.diary.adapter.out.ai

import b1a4.harudew.diary.adapter.exception.DiaryAnalysisFailedException
import b1a4.harudew.diary.application.port.out.extract.DiaryKeywordExtracterPort
import b1a4.harudew.diary.application.port.out.extract.DiaryKeywordExtractorPort
import b1a4.harudew.diary.application.port.out.extract.KeywordExtractResponse
import b1a4.harudew.global.infrastructure.ai.AiClientPort
import b1a4.harudew.global.infrastructure.ai.AiModelRequest
Expand All @@ -11,13 +11,13 @@ import org.springframework.core.io.Resource
import org.springframework.stereotype.Component

@Component
class DiaryKeywordExtracterAdapter(
class DiaryKeywordExtractorAdapter(
private val aiClient: AiClientPort,
@Value("classpath:/prompts/keyword-extract.st")
private val prompt: Resource
) : DiaryKeywordExtracterPort {
) : DiaryKeywordExtractorPort {

override fun extract(content: String): KeywordExtractResponse? {
override fun extract(content: String): KeywordExtractResponse {
val template = PromptTemplate(prompt)
val renderedPrompt = template.render(mapOf("content" to content))

Expand All @@ -31,6 +31,7 @@ class DiaryKeywordExtracterAdapter(

try {
return aiClient.fetchEntity(renderedPrompt, request, KeywordExtractResponse::class.java)
?: throw RuntimeException("Could not extract response from AiModel")
} catch (e: Exception) {
throw DiaryAnalysisFailedException(
e,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class DiaryTaggingAdapter(
private val prompt: Resource
) : DiaryTaggingPort {

override fun tag(content: String): AiDiaryTaggingResponse? {
override fun tag(content: String): AiDiaryTaggingResponse {
val template = PromptTemplate(prompt)
val renderedPrompt = template.render(mapOf("content" to content))

Expand All @@ -31,7 +31,11 @@ class DiaryTaggingAdapter(
)

try {
return aiClient.fetchEntity(renderedPrompt, request, AiDiaryTaggingResponse::class.java)
return aiClient.fetchEntity(
renderedPrompt,
request,
AiDiaryTaggingResponse::class.java)
?: throw RuntimeException("Failed to fetch tag")
} catch (e: Exception) {
throw DiaryTaggingFailedException(
e,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package b1a4.harudew.diary.adapter.out.chunk

import b1a4.harudew.diary.application.port.out.chunk.ChunkResult
import b1a4.harudew.diary.application.port.out.chunk.ChunkResponse
import b1a4.harudew.diary.application.port.out.chunk.DiaryChunkPort
import b1a4.harudew.global.infrastructure.chunk.ChunkClientPort
import org.springframework.stereotype.Component
Expand All @@ -10,7 +10,7 @@ class DiaryChunkAdapter(
private val chunkClient: ChunkClientPort
) : DiaryChunkPort {

override fun chunk(content: String): ChunkResult {
override fun chunk(content: String): ChunkResponse {
return chunkClient.chunk(content)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package b1a4.harudew.diary.adapter.out.embed

import b1a4.harudew.diary.adapter.exception.EmbeddingFailedException
import b1a4.harudew.diary.application.port.out.embed.SimpleEmbedderPort
import b1a4.harudew.diary.application.port.out.embed.KeywordEmbedderPort
import b1a4.harudew.global.infrastructure.embed.EmbedClientPort
import org.springframework.stereotype.Component

@Component
class SimpleEmbedderAdapter(
class KeywordEmbedderAdapter(
private val embedClient: EmbedClientPort
) : SimpleEmbedderPort {
) : KeywordEmbedderPort {

override fun embed(content: String): List<Double> {
try {
Expand Down
Loading
Loading