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
129 changes: 68 additions & 61 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ Kokomen (꼬꼬면) is an AI-powered mock interview platform for developers. The
./gradlew test

# Run single test class
./gradlew test --tests "com.samhap.kokomen.interview.service.InterviewServiceTest"
./gradlew test --tests "com.samhap.kokomen.interview.service.core.InterviewServiceTest"

# Run single test method
./gradlew test --tests "com.samhap.kokomen.interview.service.InterviewServiceTest.메소드명"
./gradlew test --tests "com.samhap.kokomen.interview.service.core.InterviewServiceTest.메소드명"

# Start test infrastructure (MySQL + Redis)
docker compose -f test.yml up -d
Expand All @@ -33,59 +33,73 @@ docker compose -f test.yml up -d

## Architecture

### Project Structure
```
kokomen-backend/
├── src/main/java/com/samhap/kokomen/
│ ├── admin/
│ ├── answer/
│ ├── auth/
│ ├── category/
│ ├── global/
│ ├── interview/
│ ├── member/
│ ├── product/
│ ├── recruit/
│ ├── resume/
│ └── token/
├── src/main/resources/
│ ├── db/migration/ # Flyway migrations
│ ├── application.yml # Common config
│ └── application-{profile}.yml
├── src/test/
├── src/docs/asciidoc/ # REST Docs
├── docker/ # Deployment configs
└── build.gradle
```

### Key Technologies
- Java 17, Spring Boot 3.x
- MySQL 8.0 (Primary DB), Redis/Valkey (Session & Cache)
- MySQL 8.0 (Primary DB), Redis/Valkey (Session & Cache, Redisson for distributed locks)
- OpenAI GPT-4 / AWS Bedrock for AI features
- Supertone for TTS (voice mode)
- Kakao/Google OAuth for authentication
- Flyway for DB migrations (`src/main/resources/db/migration/`)
- Spring REST Docs for API documentation
- Micrometer + Prometheus for metrics (management port: 8081)

### Domain Package Structure
### Domain Packages
```
Comment on lines +46 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

헤딩/코드펜스 마크다운 규칙 위반을 한 번에 정리해 주세요.

해당 구간은 MD022/MD031/MD040(헤딩 주변 공백, 펜스 주변 공백, fence language 지정) 경고가 발생합니다. 문서 lint를 통과하도록 공백 줄과 언어 태그를 추가해 주세요.

수정 예시
 ### Domain Packages
+
-```
+```text
 src/main/java/com/samhap/kokomen/
 ...

Domain Package Convention

- +text
{domain}/
...


### Interview Service Sub-Packages
The interview domain uses sub-packages to organize its service layer. Facade services and InterviewQueryService remain at the root for controller access:
-```
+ 
+```text
interview/service/
...
</details>


Also applies to: 63-64, 77-79

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.21.0)</summary>

[warning] 46-46: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

---

[warning] 47-47: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

---

[warning] 47-47: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @CLAUDE.md around lines 46 - 47, 해당 섹션의 마크다운은 헤딩 주변 공백(MD022/MD031)과 코드펜스 언어
태그(MD040) 규칙을 위반하니, 각 헤딩(예: "### Domain Packages", "### Domain Package
Convention", "### Interview Service Sub-Packages") 앞뒤에 빈 줄을 추가하고 모든 코드 펜스 블록(...)에 언어 태그를 명시(예: ```text)하며 펜스 전후에도 빈 줄을 넣어 문서 린트 규칙을 충족하도록 수정하세요.


</details>

<!-- fingerprinting:phantom:triton:hawk -->

<!-- This is an auto-generated comment by CodeRabbit -->

src/main/java/com/samhap/kokomen/
├── admin/ # Admin operations (root question voice management)
├── answer/ # Interview answers, likes, memos
├── auth/ # OAuth login (Kakao, Google)
├── category/ # Interview categories
├── global/ # Cross-cutting concerns (AOP, config, exceptions, fixtures)
├── interview/ # Core interview domain (start, proceed, questions, resume-based)
├── member/ # User profiles and scores
├── payment/ # Tosspayments integration (payments, webhooks, refunds)
├── product/ # Purchasable products
├── recruit/ # Job recruitment listings
├── resume/ # Resume/portfolio upload and AI evaluation
└── token/ # Interview tokens (purchase, consumption)
```

### Domain Package Convention
```
{domain}/
├── controller/
├── service/
│ └── dto/ # Request/Response DTOs
│ └── dto/ # Request/Response DTOs (suffixed with Request or Response)
├── repository/
│ └── dto/ # Query projections
├── entity/ # JPA entities
├── domain/ # Domain logic & enums
├── tool/ # Utility classes
└── external/ # External API clients
│ └── dto/ # Query projections
├── entity/ # JPA entities
├── domain/ # Domain logic & enums
├── tool/ # Utility classes
└── external/ # External API clients
```

### Interview Service Sub-Packages
The interview domain uses sub-packages to organize its service layer. Facade services and InterviewQueryService remain at the root for controller access:
```
interview/service/
├── InterviewStartFacadeService # Orchestrates interview start flow
├── InterviewProceedFacadeService # Orchestrates answer submission flow
├── InterviewQueryService # Read-only query delegation for controllers
├── core/ # InterviewService, InterviewProceedService
├── question/ # QuestionService, RootQuestionService, QuestionGeneration*
├── resume/ # ResumeBasedInterviewService, ResumeContentService
├── social/ # InterviewLikeService, InterviewViewCountService
├── infra/ # InterviewSchedulerService, InterviewProceedBedrockFlowAsyncService
└── dto/
```

### Cross-Cutting Patterns
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

헤딩 주변 빈 줄 규칙(MD022)도 동일하게 맞춰 주세요.

여러 섹션 헤딩 앞/뒤 빈 줄이 누락되어 lint 경고가 반복됩니다. 동일 규칙으로 일괄 정리하는 편이 유지보수에 좋습니다.

Also applies to: 99-99, 132-132, 141-141, 148-148

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 92-92: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` at line 92, Several Markdown headings (e.g., the "Cross-Cutting
Patterns" heading and the other headings referenced at lines 99, 132, 141, 148)
are missing required blank lines around them causing MD022 lint warnings; edit
those headings to ensure there is one blank line both immediately before and
immediately after each heading (i.e., insert a blank line above and below
"Cross-Cutting Patterns" and the other referenced section headings) so the file
conforms to the MD022 rule.

- **Facade pattern**: Domains with complex orchestration use `*FacadeService` classes. Other domains should depend on facade services, not internal services directly.
- **Custom annotations**: `@DistributedLock` (Redis-based), `@ExecutionTimer`, `@RedisExceptionWrapper`
- **Base entity**: `BaseEntity` with `@CreatedDate createdAt`

## Code Conventions (from docs/convention.md)

### Style Guide
- Follows Woowacourse Java Style Guide (based on Google Java Style)
- Line limit: 160 characters
- Indent: 4 spaces
- Column limit: **120 characters**
- Indent: 4 spaces, continuation indent: +8 spaces minimum

### Naming
- Methods: `행위 + 도메인` (e.g., `saveMember()`)
Expand All @@ -108,38 +122,33 @@ public void example() {}
3. Business methods (CRUD order, private methods after their calling public method)
4. Override methods (equals, hashCode, toString)

### Testing
- Test method names in **Korean**
- No `@DisplayName` annotation
- Controller tests: MockMvc + real beans (integration test, generates RestDocs)
- Service tests: integration with repository
- Domain tests: unit tests
- Test isolation: `MySQLDatabaseCleaner` (not `@Transactional`)
- Fixtures: `global/fixture/XxxFixtureBuilder` classes
- Tests use real MySQL container (not H2)

### Exception Handling
- Custom exceptions: `BadRequestException`, `UnauthorizedException`, `ForbiddenException`, etc.
- Validation: `@Valid` in DTO, entity-level validation in constructors
- Business validation that needs external data goes in service layer

## Test Infrastructure
## Testing

### Test Conventions
- Test method names in **Korean**
- No `@DisplayName` annotation
- Controller tests: MockMvc + real beans (integration test, generates RestDocs)
- Service tests: integration with repository
- Domain tests: unit tests
- Test isolation: `MySQLDatabaseCleaner` truncates all tables (not `@Transactional`)
- Fixtures: `global/fixture/{domain}/XxxFixtureBuilder` — static `builder()`, fluent setters, sensible defaults

### Test Infrastructure
Tests require MySQL and Redis containers:
- MySQL: port 13306 (database: kokomen-test, password: root)
- Redis: port 16379

Start with: `docker compose -f test.yml up -d`

Test base classes:
- `BaseTest`: `@SpringBootTest` with mock beans for external services (GPT, S3, etc.)
- `BaseControllerTest`: Extends BaseTest, adds MockMvc with RestDocs configuration

## API Documentation

- Generated via Spring REST Docs
- Build generates docs into `build/docs/`
- Access at: `http://localhost:8080/docs/index.html`
### Test Base Classes
- **`BaseTest`**: `@SpringBootTest` with `@ActiveProfiles("test")`, mocks external services (GPT, S3, Supertone, Tosspayments, OAuth clients, Bedrock), spies on Redis. Uses real MySQL container + `MySQLDatabaseCleaner`.
- **`BaseControllerTest`**: Extends BaseTest, adds MockMvc with RestDocs configuration.
- **`DocsTest`**: `@ActiveProfiles("docs")`, uses H2 in-memory DB with `@Transactional`. For lightweight REST Docs generation without Docker.

Comment on lines +149 to 152
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

테스트 인프라 원칙이 충돌로 읽힙니다—예외 범위를 명시해 주세요.

BaseTest는 실 MySQL 컨테이너를 전제로 하지만 DocsTest는 H2를 사용한다고 되어 있어 “모든 테스트는 MySQL” 원칙과 상충될 수 있습니다. DocsTest는 REST Docs 전용 예외 같은 문구를 바로 옆에 명시해 해석 여지를 없애는 게 좋습니다.

Based on learnings: Use real MySQL container (not H2) for all tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` around lines 149 - 152, Clarify the apparent conflict by
explicitly stating the exception: update the README bullets so BaseTest remains
the default (real MySQL container + MySQLDatabaseCleaner) and DocsTest is an
intentional exception that uses H2 for lightweight REST Docs generation only;
mention both class names (BaseTest and DocsTest) and add a short sentence like
"Exception: DocsTest uses H2 in-memory DB solely for lightweight REST Docs
generation and is excluded from the 'use real MySQL for tests' rule."

## Environment Variables

Expand All @@ -158,7 +167,5 @@ SUPERTONE_API_TOKEN
- `dev`: Development server
- `prod`: Production
- `load-test`: Load testing
- `test`: Test environment (used by tests)

# currentDate
Today's date is 2026-02-24.
- `test`: Test environment (real MySQL + Redis containers)
- `docs`: REST Docs generation (H2 in-memory, no Docker needed)
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import com.samhap.kokomen.admin.service.dto.RootQuestionVoiceResponse;
import com.samhap.kokomen.global.exception.BadRequestException;
import com.samhap.kokomen.interview.entity.RootQuestion;
import com.samhap.kokomen.interview.entity.RootQuestionState;
import com.samhap.kokomen.interview.service.RootQuestionService;
import com.samhap.kokomen.interview.domain.RootQuestion;
import com.samhap.kokomen.interview.domain.RootQuestionState;
import com.samhap.kokomen.interview.service.question.RootQuestionService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

Expand All @@ -30,7 +30,8 @@ public RootQuestionVoiceResponse uploadRootQuestionVoiceWithApiKey(Long rootQues
if (rootQuestionService.isRootQuestionVoiceExists(rootQuestionId)) {
throw new BadRequestException("이미 S3에 올라가있는 음성파일입니다. rootQuestionId = " + rootQuestionId);
}
String rootQuestionVoiceCdnUrl = rootQuestionService.createAndUploadRootQuestionVoiceWithApiKey(rootQuestionId, oneTimeApiKey);
String rootQuestionVoiceCdnUrl = rootQuestionService.createAndUploadRootQuestionVoiceWithApiKey(rootQuestionId,
oneTimeApiKey);
return new RootQuestionVoiceResponse(rootQuestionVoiceCdnUrl);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.samhap.kokomen.answer.domain;

import com.samhap.kokomen.global.domain.BaseEntity;
import com.samhap.kokomen.interview.entity.Question;
import com.samhap.kokomen.interview.domain.Question;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import com.samhap.kokomen.answer.domain.AnswerMemo;
import com.samhap.kokomen.answer.domain.AnswerMemoState;
import com.samhap.kokomen.answer.domain.AnswerMemoVisibility;
import com.samhap.kokomen.interview.entity.Interview;
import com.samhap.kokomen.interview.domain.Interview;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import com.samhap.kokomen.answer.domain.Answer;
import com.samhap.kokomen.answer.domain.AnswerRank;
import com.samhap.kokomen.interview.entity.Question;
import com.samhap.kokomen.interview.domain.Question;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import com.samhap.kokomen.answer.repository.AnswerRepository;
import com.samhap.kokomen.global.exception.BadRequestException;
import com.samhap.kokomen.global.exception.NotFoundException;
import com.samhap.kokomen.interview.entity.Question;
import com.samhap.kokomen.interview.domain.Question;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
import com.samhap.kokomen.answer.domain.AnswerRank;
import com.samhap.kokomen.answer.repository.AnswerRepository;
import com.samhap.kokomen.category.domain.Category;
import com.samhap.kokomen.interview.entity.Interview;
import com.samhap.kokomen.interview.entity.InterviewMode;
import com.samhap.kokomen.interview.entity.Question;
import com.samhap.kokomen.interview.entity.RootQuestion;
import com.samhap.kokomen.interview.domain.Interview;
import com.samhap.kokomen.interview.domain.InterviewMode;
import com.samhap.kokomen.interview.domain.Question;
import com.samhap.kokomen.interview.domain.RootQuestion;
import com.samhap.kokomen.interview.repository.InterviewRepository;
import com.samhap.kokomen.interview.repository.QuestionRepository;
import com.samhap.kokomen.interview.repository.RootQuestionRepository;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,15 @@
import com.samhap.kokomen.answer.domain.Answer;
import com.samhap.kokomen.answer.domain.AnswerRank;
import com.samhap.kokomen.answer.service.AnswerService;
import com.samhap.kokomen.interview.entity.Interview;
import com.samhap.kokomen.interview.entity.InterviewState;
import com.samhap.kokomen.interview.domain.QuestionAndAnswers;
import com.samhap.kokomen.interview.domain.Interview;
import com.samhap.kokomen.interview.domain.InterviewState;
import com.samhap.kokomen.interview.tool.QuestionAndAnswers;
import com.samhap.kokomen.interview.repository.InterviewRepository;
import com.samhap.kokomen.interview.repository.QuestionRepository;
import com.samhap.kokomen.interview.service.InterviewService;
import com.samhap.kokomen.interview.service.dto.InterviewProceedResponse;
import com.samhap.kokomen.interview.service.core.InterviewService;
import com.samhap.kokomen.interview.service.dto.start.InterviewStartResponse;
import io.micrometer.core.instrument.MeterRegistry;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.AfterReturning;
Expand All @@ -35,7 +33,7 @@ public class RootQuestionMetricAspect {
private final InterviewRepository interviewRepository;
private final QuestionRepository questionRepository;

@Pointcut("execution(* com.samhap.kokomen.interview.service.InterviewFacadeService.startInterview(..))")
@Pointcut("execution(* com.samhap.kokomen.interview.service.InterviewStartFacadeService.startInterview(..))")
public void startInterviewMethod() {
}

Expand All @@ -50,39 +48,7 @@ public void increaseRootQuestionInterviewCount(InterviewStartResponse result) {
).increment();
}

@Pointcut("execution(* com.samhap.kokomen.interview.service.InterviewFacadeService.proceedInterview(..)) && args(interviewId, curQuestionId, ..)")
public void proceedInterviewPointcut(Long interviewId, Long curQuestionId) {
}

@AfterReturning(pointcut = "proceedInterviewPointcut(interviewId, curQuestionId)", returning = "result")
public void increaseRootQuestionInterviewEndCount(Optional<InterviewProceedResponse> result, Long interviewId,
Long curQuestionId) {
boolean isInterviewEnded = result.isEmpty();
if (isInterviewEnded) {
Long rootQuestionId = interviewRepository.findRootQuestionIdByInterviewId(interviewId);
meterRegistry.counter(
"root_question_interview_end_count_total",
"root_question_id", String.valueOf(rootQuestionId)
).increment();
}
}

@AfterReturning(pointcut = "proceedInterviewPointcut(interviewId, curQuestionId)", returning = "result")
public void increaseRootQuestionAnswerRankCount(Optional<InterviewProceedResponse> result, Long interviewId,
Long curQuestionId) {
Long firstQuestionId = questionRepository.findFirstQuestionIdByInterviewIdOrderByIdAsc(interviewId);
boolean isRootQuestionAnswer = Objects.equals(curQuestionId, firstQuestionId);
if (isRootQuestionAnswer) {
AnswerRank answerRank = result.get().curAnswerRank();
Long rootQuestionId = interviewRepository.findRootQuestionIdByInterviewId(interviewId);
meterRegistry.counter(
"root_question_answer_rank_count_" + answerRank.name().toLowerCase(),
"root_question_id", String.valueOf(rootQuestionId)
).increment();
}
}

@Pointcut("execution(* com.samhap.kokomen.interview.service.InterviewProceedBedrockFlowAsyncService.proceedInterviewByBedrockFlowAsync(..)) && args(memberId, questionAndAnswers, interviewId)")
@Pointcut("execution(* com.samhap.kokomen.interview.service.infra.InterviewProceedBedrockFlowAsyncService.proceedInterviewByBedrockFlowAsync(..)) && args(memberId, questionAndAnswers, interviewId)")
public void asyncProceedInterviewPointcut(Long memberId, QuestionAndAnswers questionAndAnswers, Long interviewId) {
}

Expand Down
Loading