From 97b29eb60a3e0ff91853b0712870669b9d11b316 Mon Sep 17 00:00:00 2001 From: Seol-JY Date: Mon, 16 Mar 2026 21:55:36 +0900 Subject: [PATCH] chore: add AI agent harness with AGENTS.md, sub-agents, hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md를 프로젝트 루트 source of truth로 배치하고, CLAUDE.md를 포인터로 축소. 상세 컨벤션은 .agent-docs/로 분리하여 Progressive Disclosure 적용. PostToolUse Hook으로 Java 파일 수정 시 checkstyle 즉각 피드백 설정. security-reviewer, test-writer 서브에이전트 추가. Co-Authored-By: Claude Opus 4.6 (1M context) --- .agent-docs/conventions.md | 1114 +++++++++++++++++++++++++ .agent-docs/test-conventions.md | 364 +++++++++ .claude/CLAUDE.md | 1175 +-------------------------- .claude/agents/security-reviewer.md | 43 + .claude/agents/test-writer.md | 34 + .claude/settings.json | 15 + AGENTS.md | 49 ++ 7 files changed, 1623 insertions(+), 1171 deletions(-) create mode 100644 .agent-docs/conventions.md create mode 100644 .agent-docs/test-conventions.md create mode 100644 .claude/agents/security-reviewer.md create mode 100644 .claude/agents/test-writer.md create mode 100644 .claude/settings.json create mode 100644 AGENTS.md diff --git a/.agent-docs/conventions.md b/.agent-docs/conventions.md new file mode 100644 index 00000000..18973022 --- /dev/null +++ b/.agent-docs/conventions.md @@ -0,0 +1,1114 @@ +# 새로운 도메인 개발 가이드 + +## 프로젝트 구조 및 패키지 구성 + +새로운 도메인을 개발할 때는 다음과 같은 패키지 구조를 엄격히 따라야 합니다: + +``` +domain/{도메인명}/ +├── common/ # 도메인 내 공통 유틸리티 +│ ├── converter/ # JPA 컨버터 (enum, 사용자 정의 타입 등) +│ ├── dto/ # 도메인 내부 DTO (DailyRoutineData 등) +│ ├── helper/ # 도메인별 헬퍼 클래스 +│ └── mapper/ # 엔티티-DTO 매핑 클래스 +├── domain/ # 핵심 비즈니스 로직 (Clean Architecture) +│ ├── event/ # 도메인 이벤트 (RoutineCreatedEvent 등) +│ ├── service/ # 도메인 서비스 (데이터 접근 로직) +│ └── usecase/ # 애플리케이션 유스케이스 (비즈니스 로직 조합) +├── infrastructure/ # 외부 시스템 연동 +│ └── scheduler/ # 스케줄링 관련 작업 (Quartz Job 등) +├── persistence/ # 데이터 계층 +│ ├── entity/ # JPA 엔티티 +│ ├── repository/ # 레포지토리 인터페이스 및 구현 +│ │ └── querydsl/ # QueryDSL 구현체 (복잡한 쿼리) +│ └── vo/ # 값 객체 (Value Object) +└── presentation/ # 프레젠테이션 계층 + ├── api/ # API 문서화 인터페이스 (Swagger 통합) + ├── controller/ # REST 컨트롤러 + └── dto/ # 요청/응답 DTO + ├── request/ # 요청 DTO + └── response/ # 응답 DTO +``` + +## 엔티티 작성 규칙 + +### 1. BaseEntity 상속 및 소프트 삭제 패턴 + +모든 엔티티는 `BaseEntity`를 상속하여 공통 필드(id, createdAt, updatedAt, deletedAt)를 사용합니다: + +```java +@Entity +@Getter +@Table(name = "routine") +@NoArgsConstructor // 반드시 기본 생성자 필요 +public class Routine extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) // IDENTITY 전략 사용 + private Long id; + + // ... 비즈니스 필드들 +} +``` + +**소프트 삭제 메서드 -> 소프트 삭제는 상황에 따라 필요한 경우에만 선택적으로 수행** + +```java +public void delete() { + this.deletedAt = LocalDateTime.now(); +} + +public Boolean isInDeletedState() { + return deletedAt != null; +} +``` + +### 2. Builder 패턴 사용 (생성자 패턴) + +엔티티 생성 시 반드시 Builder 패턴을 사용하고, private 생성자로 외부 생성을 방지합니다: + +```java +@Builder +private Routine( + PlanCategory category, + PlanCategoryColor color, + String title, + Boolean isPublic, + Integer reminderMinutes, + RoutineMemo memo, + LocalTime time, + DaysOfWeekBitmask daysOfWeekBitmask, + User user +) { + this.category = category; + this.color = color; + this.title = title; + this.isPublic = isPublic; + this.reminderMinutes = reminderMinutes; + this.memo = memo; + this.time = time; + this.daysOfWeekBitmask = daysOfWeekBitmask; + this.user = user; + this.scheduleModifiedAt = LocalDateTime.now(); // 생성 시점 자동 기록 +} +``` + +### 3. 비즈니스 로직 메서드 + +엔티티에 도메인 로직을 포함하는 메서드를 작성합니다: + +```java +public void updateFromRequest(final RoutineUpdateRequest request) { + if (request.isTitleChanged()) { + this.title = request.title(); + } + + // 스케줄 변경 시 자동으로 수정 시점 기록 + if (request.isTimeChanged() && !Objects.equals(this.time, request.time())) { + this.time = request.time(); + this.scheduleModifiedAt = LocalDateTime.now(); + } + + if (request.isDaysOfWeekChanged()) { + DaysOfWeekBitmask newDaysOfWeek = DaysOfWeekBitmask.createByDayOfWeek(request.daysOfWeek()); + if (!newDaysOfWeek.equals(this.daysOfWeekBitmask)) { + this.daysOfWeekBitmask = newDaysOfWeek; + this.scheduleModifiedAt = LocalDateTime.now(); + } + } + + // ... 다른 필드 업데이트 로직 +} + +// 권한 체크 메서드 +public boolean isOwner(final User requestingUser) { + return this.user.getId().equals(requestingUser.getId()); +} + +// 비즈니스 상태 체크 메서드 +public Boolean isAllDay() { + return time == null; +} +``` + +### 4. 연관관계 설정 원칙 + +연관관계는 꼭 필요한 경우에만 사용하도록 하고, 남용하면 유지보수에 문제가 생기므로 잘 생각해서 사용하세요. + +**지연 로딩을 기본으로 사용** (성능 최적화): + +```java +@ManyToOne(fetch = FetchType.LAZY) +@JoinColumn(name = "user_id", nullable = false) +private User user; +``` + +**일대다 관계에서 cascade 및 orphanRemoval 설정**: + +```java +@OneToMany(mappedBy = "diary", cascade = CascadeType.ALL, orphanRemoval = true) +private List diaryImages = new ArrayList<>(); +``` + +**소프트 삭제를 위한 Hibernate 어노테이션 사용**: + +```java +@SQLDelete(sql = "UPDATE diary SET deleted_at = NOW() where id=?") +@SQLRestriction(value = "deleted_at is NULL") +``` + +## 값 객체(Value Object) 작성 규칙 + +### 1. @Embeddable 사용 및 검증 패턴 + +값 객체의 경우, 자주 사용되거나 맥락상 사용하는 경우가 사용하지 않을때 보다 더 적절한 경우 사용하세요. +값 객체는 `@Embeddable`을 사용하여 작성하고, 필요한 경우 검증 로직을 포함합니다: + +```java +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA 요구사항 +@EqualsAndHashCode // 값 객체 동등성 비교 +@Getter +public class PlanCategoryColor { + private static final Pattern HEX_COLOR_CODE_PATTERN = Pattern.compile(HEX_COLOR_CODE_REGEX); + + @Column(name = "color") + private String value; + + private PlanCategoryColor(final String value) { + validate(value); + this.value = value; + } + + // 팩토리 메서드 패턴 + public static PlanCategoryColor from(final String color) { + if (color == null) { + return null; + } + return new PlanCategoryColor(color); + } + + // 필수 검증 로직 + private void validate(final String value) { + if (value != null) { + if (!HEX_COLOR_CODE_PATTERN.matcher(value).matches()) { + throw new VoException("색상은 '#RRGGBB' 형식이어야 합니다."); + } + } + } +} +``` + +### 2. 길이 제한이 있는 값 객체 + +```java +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +@Getter +public class RoutineMemo { + private static final int MAX_LENGTH = 40; + + @Column(name = "memo", columnDefinition = "TEXT") + private String value; + + private RoutineMemo(final String value) { + validate(value); + this.value = value; + } + + public static RoutineMemo from(final String memo) { + if (memo == null) { + return null; + } + return new RoutineMemo(memo); + } + + private void validate(final String value) { + if (value != null && value.length() > MAX_LENGTH) { + throw new VoException("메모는 " + MAX_LENGTH + "자를 초과할 수 없습니다."); + } + } +} +``` + +## 레포지토리 작성 규칙 + +### 1. 표준 레포지토리 인터페이스 + +기본 CRUD 및 표준 쿼리를 위한 메인 레포지토리 패턴: + +```java +@Repository +public interface RoutineRepository extends JpaRepository, RoutineRepositoryCustom { + + // 표준 쿼리 메서드 (Spring Data JPA) + Optional findByIdAndUser(final Long id, final User user); + + List findAllByUserAndIsPublicTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(final User user); + + // 원자적 연산을 위한 @Modifying 쿼리 (동시성 제어) + @Modifying(clearAutomatically = true) + @Query("UPDATE Routine r SET r.sharedCount = r.sharedCount + 1 WHERE r.id = :id") + void incrementSharedCountAtomically(@Param("id") final Long id); + + // 집계 쿼리 (COALESCE로 null 처리) + @Query("SELECT COALESCE(SUM(r.sharedCount), 0) FROM Routine r WHERE r.user = :user AND r.isPublic = true AND r.deletedAt IS NULL") + int sumRoutineSharedCountByUser(@Param("user") final User user); +} +``` + +### 2. QueryDSL을 활용한 복잡한 쿼리 구조 + +복잡한 쿼리는 QueryDSL을 사용하여 별도 인터페이스로 분리합니다: + +**인터페이스 정의:** + +```java +public interface RoutineRepositoryCustom { + List findUnrecordedRoutinesByDateMatchingDayOfWeek( + final User user, + final LocalDate date, + final List routineRecords + ); + + List findRoutinesByDateBetween( + final User user, + final LocalDate startDate, + final LocalDate endDate + ); + + boolean isActiveForDate(final Routine routine, final LocalDate date); + + void deleteAllUnsharedRoutinesByUser(final User user); +} +``` + +**QueryDSL 구현체:** + +```java +@Repository +@RequiredArgsConstructor +public class RoutineRepositoryCustomImpl implements RoutineRepositoryCustom { + private final JPAQueryFactory queryFactory; + private final QRoutine qRoutine = QRoutine.routine; + + @Override + public List findUnrecordedRoutinesByDateMatchingDayOfWeek( + final User user, + final LocalDate date, + final List routineRecords + ) { + return queryFactory + .selectFrom(qRoutine) + .where( + qRoutine.user.eq(user), + scheduleModifiedOnOrBeforeDate(date), + routineNotRecorded(routineRecords), + routineMatchesDate(date), + routineNotDeleted() + ) + .fetch(); + } + + // 재사용 가능한 조건 메서드들 + private BooleanExpression routineNotDeleted() { + return qRoutine.deletedAt.isNull(); + } + + private BooleanExpression scheduleModifiedOnOrBeforeDate(final LocalDate date) { + LocalDateTime endOfDay = date.atTime(LocalTime.MAX); + return qRoutine.scheduleModifiedAt.loe(endOfDay); + } + + // 비트 연산을 활용한 요일 매칭 (성능 최적화) + private BooleanExpression routineMatchesDate(final LocalDate date) { + byte dayBitmask = DaysOfWeekBitmask.getDayBitmask(date.getDayOfWeek()); + + return Expressions.numberTemplate( + Byte.class, "function('bitand', {0}, CAST({1} as byte))", + qRoutine.daysOfWeekBitmask, dayBitmask + ).gt((byte)0); + } +} +``` + +### 3. QueryDSL 사용 시 주의사항 + +- **성능 최적화**: 복잡한 조건은 메서드로 분리하여 재사용 +- **타입 안전성**: Q클래스를 사용하여 컴파일 타임 안전성 확보 +- **동적 쿼리**: BooleanExpression을 활용한 조건부 쿼리 +- **페이징**: limit, offset을 활용한 커서 기반 페이징 구현 + +## 서비스 레이어 작성 규칙 + +### 1. 도메인 서비스 (데이터 접근 계층) + +**중요한 아키텍처 원칙**: +- **서비스는 자신의 도메인 레포지토리만 사용**해야 합니다 +- **다른 도메인의 레포지토리를 직접 의존하면 안됩니다** +- **다른 도메인의 데이터가 필요한 경우, 유스케이스 레이어에서 여러 서비스를 조합**해야 합니다 + +도메인 서비스는 주로 **자신의 도메인 데이터 접근 로직과 단순한 비즈니스 로직**을 담당합니다: + +```java +@Slf4j +@Service +@RequiredArgsConstructor +public class RoutineService { + private final RoutineRepository routineRepository; + + @Transactional + public RoutineCreateResponse create(final User user, final RoutineCreateRequest request) { + Routine routine = RoutineMapper.toRoutine(user, request); + Routine savedRoutine = routineRepository.save(routine); + return RoutineMapper.toRoutineCreateResponse(savedRoutine); + } + + @Transactional(readOnly = true) + public List getUnrecordedRoutinesForDate( + final User user, + final LocalDate date, + final List routineRecords + ) { + return routineRepository.findUnrecordedRoutinesByDateMatchingDayOfWeek(user, date, routineRecords); + } + + // Optional 사용으로 null 안전성 확보 + @Transactional(readOnly = true) + public Optional getUserRoutine(final User user, final Long id) { + return routineRepository.findByIdAndUser(id, user); + } + + // 비즈니스 로직 메서드 + public boolean canCreateRecordForDate(final Routine routine, final LocalDate date) { + return routineRepository.isActiveForDate(routine, date); + } +} +``` + +### 2. 트랜잭션 경계 설정 원칙 + +- **조회 전용 메서드**: `@Transactional(readOnly = true)` (성능 최적화) +- **수정 메서드**: `@Transactional` (기본값) +- **배치 작업**: `@Transactional` + 적절한 flush 및 clear + +## 유스케이스 작성 규칙 + +### 1. 비즈니스 로직 조합 및 트랜잭션 관리 + +유스케이스는 **여러 서비스를 조합하여 복잡한 비즈니스 로직을 구현**합니다: + +```java +@Slf4j +@UseCase // 커스텀 어노테이션 (비즈니스 계층 표시) +@RequiredArgsConstructor +public class RoutineUseCase { + private static final int MAX_ROUTINE_DATE_RANGE_DAYS = 13; // 비즈니스 상수 + + private final UserService userService; + private final RoutineService routineService; + private final RoutineRecordService routineRecordService; + private final DistributedLock distributedLock; // 동시성 제어 + private final ApplicationEventPublisher eventPublisher; // 도메인 이벤트 + + @Transactional + public RoutineCreateResponse createRoutine(final Long userId, final RoutineCreateRequest request) { + // 1. 사용자 검증 + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + // 2. 비즈니스 로직 실행 + RoutineCreateResponse response = routineService.create(user, request); + + // 3. 로깅 (비즈니스 이벤트 추적) + log.info("루틴 생성 - UserId: {}, RoutineId:{}", userId, response.routineId()); + + // 4. 도메인 이벤트 발행 (비동기 처리) + eventPublisher.publishEvent(new RoutineCreatedEvent(response.routineId(), userId)); + + return response; + } +} +``` + +### 2. 비즈니스 규칙 검증 패턴 + +유스케이스에서 **비즈니스 규칙을 엄격히 검증**합니다: + +```java +@Transactional(readOnly = true) +public MyRoutineRecordReadMultipleDatesResponse readMyRoutineRecordListMultipleDates( + final Long userId, + final LocalDate startDate, + final LocalDate endDate +) { + // 비즈니스 규칙 검증 (사전 조건) + if (startDate.isAfter(endDate) || + ChronoUnit.DAYS.between(startDate, endDate) > MAX_ROUTINE_DATE_RANGE_DAYS) { + throw CommonException.from(ExceptionCode.EXCEED_ROUTINE_DATE_RANGE); + } + + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + // 복잡한 비즈니스 로직 실행 + List allRoutineRecords = + routineRecordService.getRecordsBetweenDates(user, startDate, endDate); + + List dailyRoutineDatas = + routineService.getRoutineDataByDateRange(user, startDate, endDate, allRoutineRecords); + + log.info("본인 루틴 기록 기간 조회 - UserId: {}, 조회 기간: {} ~ {}", userId, startDate, endDate); + + return RoutineMapper.toMyRoutineRecordReadMultipleDatesResponse( + startDate, endDate, dailyRoutineDatas + ); +} +``` + +### 3. 분산 락을 활용한 동시성 제어 + +동시성 제어가 필요한 경우 분산 락을 사용합니다: + +```java +@Transactional +public void updateRoutineCompletion( + final Long userId, + final Long routineId, + final RoutinePutCompletionRequest request +) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + Routine routine = routineService.getUserRoutineIncludingDeleted(user, routineId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ROUTINE)); + + LocalDate date = request.routineDate(); + boolean isCompleted = request.isCompleted(); + + // 분산 락 키 생성 (리소스별 세밀한 제어) + String lockKey = "routine:" + routineId + ":date:" + date; + + distributedLock.executeWithLock(lockKey, () -> { + // 원자적으로 실행되어야 하는 로직 + if (routineRecordService.updateIfPresent(routine, date, isCompleted)) { + log.info("루틴 상태 변경 성공(기록 수정) - 사용자 Id: {}, 루틴 Id: {}, 루틴 날짜: {}, 완료상태: {}", + userId, routineId, date, isCompleted); + return; + } + + if (!routineService.canCreateRecordForDate(routine, date)) { + log.info("루틴 상태 변경 실패 - 사용자 Id: {}, 루틴 Id: {}, 루틴 날짜: {}", userId, routineId, date); + throw CommonException.from(ExceptionCode.ROUTINE_INVALID_DATE); + } + + routineRecordService.create(routine, date, isCompleted); + log.info("루틴 상태 변경 성공(기록 생성) - 사용자 Id: {}, 루틴 Id: {}, 루틴 날짜: {}, 완료상태: {}", + userId, routineId, date, isCompleted); + }); +} +``` + +## 프레젠테이션 레이어 작성 규칙 + +### 1. API 문서화를 위한 인터페이스 분리 + +API 문서화와 구현을 분리하여 관리합니다: + +```java +@Tag(name = "Routine") // Swagger 태그 +public interface RoutineApi { + @Operation( + summary = "루틴 생성", + description = "내 루틴을 생성합니다. Request body 에서 필수 값 여부를 확인할 수 있습니다. 예를들어, time 필드가 null 인 경우에는 종일 루틴으로 간주합니다." + ) + @ApiResponseExplanations( // 커스텀 어노테이션 + success = @ApiSuccessResponseExplanation( + responseClass = RoutineCreateResponse.class, + description = "루틴 생성 성공, 생성된 루틴의 Id를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_USER), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.INVALID_INPUT) + } + ) + ResponseEntity> postRoutine( + @AuthenticationPrincipal final CustomUserDetails userDetails, + @RequestBody @Valid final RoutineCreateRequest request + ); +} +``` + +### 2. 컨트롤러 구현 패턴 + +컨트롤러는 API 인터페이스를 구현하고 **유스케이스에만 위임**합니다: + +```java +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/routines") +public class RoutineController implements RoutineApi { + private final RoutineUseCase routineUseCase; + + @Override + @PostMapping + @PreAuthorize("isAuthenticated()") // 인증 체크 + public ResponseEntity> postRoutine( + @AuthenticationPrincipal final CustomUserDetails userDetails, + @RequestBody @Valid final RoutineCreateRequest request + ) { + RoutineCreateResponse response = routineUseCase.createRoutine( + userDetails.getUserId(), + request + ); + + return ResponseEntity.ok(ApiResponse.createSuccess(response)); + } +} +``` + +### 3. ApiResponse 사용 규칙 + +**중요한 API 응답 규칙**: +- **성공 응답**: `ApiResponse.createSuccess(content)` 사용 +- **내용 없는 성공 응답**: `ApiResponse.createSuccessWithNoContent()` 사용 +- **절대 사용 금지**: `ApiResponse.onSuccess()` (존재하지 않는 메서드) + +```java +// 올바른 패턴 - 데이터가 있는 성공 응답 +return ResponseEntity.ok(ApiResponse.createSuccess(response)); + +// 올바른 패턴 - 데이터가 없는 성공 응답 (삭제, 수정 등) +return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + +// 잘못된 패턴 - 존재하지 않는 메서드 +return ResponseEntity.ok(ApiResponse.onSuccess(response)); + +// 잘못된 패턴 - 응답 래핑 누락 +return ResponseEntity.ok(response); +``` + +**ApiResponse의 주요 정적 메서드들**: +- `createSuccess(T content)`: 성공 응답 (데이터 포함) +- `createSuccessWithNoContent()`: 성공 응답 (빈 Map 반환) +- `createError(ExceptionCode ec)`: 에러 응답 (ExceptionCode 기반) +- `createValidationError(Map errors)`: 유효성 검사 오류 응답 +- `createServerError()`: 서버 오류 응답 + +## DTO 작성 규칙 + +### 1. Record 사용 및 검증 어노테이션 + +요청/응답 DTO는 **불변 객체인 record**를 사용합니다: + +```java +@Schema(description = "루틴 생성 요청 DTO") +public record RoutineCreateRequest( + @NotBlank(message = "제목은 비어있을 수 없습니다.") + @Size(max = 20, message = "제목은 20자를 초과할 수 없습니다.") + @Schema(description = "루틴 제목", example = "아침 운동") + String title, + + @NotNull(message = "카테고리는 비어있을 수 없습니다.") + @Schema(description = "루틴 카테고리", example = "COMPUTER") + PlanCategory category, + + @Schema(description = "루틴 색상 (색상 없으면 null)", example = "#FF5733") + @Pattern(regexp = HEX_COLOR_CODE_REGEX, message = "색상은 유효한 Hex code 여야 합니다.") + String color, + + @JsonDeserialize(using = LocalTimeDeserializer.class) // 커스텀 직렬화 + @JsonFormat(pattern = "HH:mm") + @Schema(description = "루틴 시간 (종일 루틴이면 null)", example = "07:00") + LocalTime time, + + @NotNull(message = "공개 여부는 null 일 수 없습니다.") + @Schema(description = "공개 여부", example = "true") + Boolean isPublic, + + @JsonDeserialize(using = DayOfWeekListDeserializer.class) // 커스텀 리스트 직렬화 + @NotNull(message = "반복 요일은 null 일 수 없습니다.") + @NotEmpty(message = "반복 요일은 최소 하나 이상 선택되어야 합니다.") + @Schema(description = "반복 요일", example = "[\"MONDAY\",\"TUESDAY\"]") + List daysOfWeek, + + @PositiveOrZero(message = "분은 양수여야 합니다.") + @Schema(description = "알림 시간 (분 단위, null 이면 알림을 보내지 않음)", example = "30") + Integer reminderMinutes, + + @Schema(description = "메모", example = "30분 동안 조깅하기") + @Size(max = 40, message = "메모는 40자를 넘을 수 없습니다.") + String memo +) { +} +``` + +### 2. 응답 DTO에서 Builder 패턴 + +복잡한 응답 DTO에서는 Builder 패턴을 사용합니다: + +```java +@Schema(description = "루틴 상세조회 응답 DTO") +@Builder +public record RoutineDetailResponse( + @Schema(description = "루틴 Id", example = "1") + Long routineId, + + @Schema(description = "루틴 카테고리", example = "PENCIL") + PlanCategory category, + + @JsonSerialize(using = LocalTimeSerializer.class) // 커스텀 직렬화 + @JsonFormat(pattern = "HH:mm") + @Schema(description = "루틴 시간(null 이면 종일 루틴)", example = "14:30") + LocalTime time, + + @Schema(description = "반복 요일 목록") + List daysOfWeek +) { +} +``` + +## 매퍼 작성 규칙 + +### 1. 정적 유틸리티 클래스 패턴 + +매퍼는 **정적 유틸리티 클래스**로 작성하고 외부 인스턴스화를 방지합니다: + +```java +@NoArgsConstructor(access = AccessLevel.PRIVATE) // 인스턴스화 방지 +public final class RoutineMapper { + private static final boolean INCOMPLETE_STATUS = false; // 상수 정의 + + public static Routine toRoutine(final User user, final RoutineCreateRequest request) { + // 값 객체 생성 + DaysOfWeekBitmask daysOfWeekBitmask = DaysOfWeekBitmask.createByDayOfWeek(request.daysOfWeek()); + PlanCategoryColor planCategoryColor = PlanCategoryColor.from(request.color()); + RoutineMemo routineMemo = RoutineMemo.from(request.memo()); + + return Routine.builder() + .user(user) + .category(request.category()) + .color(planCategoryColor) + .title(request.title()) + .memo(routineMemo) + .isPublic(request.isPublic()) + .reminderMinutes(request.reminderMinutes()) + .time(request.time()) + .daysOfWeekBitmask(daysOfWeekBitmask) + .build(); + } + + public static RoutineCreateResponse toRoutineCreateResponse(final Routine routine) { + return RoutineCreateResponse.builder() + .routineId(routine.getId()) + .build(); + } +} +``` + +### 2. Stream API 활용 패턴 + +복잡한 데이터 변환 시 **Stream API**를 적극 활용합니다: + +```java +public static MyRoutineRecordReadListResponse toMyRoutineRecordReadListResponse( + final DailyRoutineData dailyRoutineData +) { + // 미완료 루틴 응답 생성 + List routineResponses = dailyRoutineData.routines() + .stream() + .map(routine -> toMyRoutineRecordReadResponse(routine, INCOMPLETE_STATUS)) + .toList(); + + // 기록된 루틴 응답 생성 + List recordResponses = dailyRoutineData.routineRecords() + .stream() + .map(record -> toMyRoutineRecordReadResponse(record.getRoutine(), record.getIsCompleted())) + .toList(); + + // 두 스트림 합치기 + List combinedResponses = + Stream.concat(routineResponses.stream(), recordResponses.stream()) + .toList(); + + return MyRoutineRecordReadListResponse.builder() + .queryDate(dailyRoutineData.date()) + .routines(combinedResponses) + .build(); +} +``` + +## 커스텀 어노테이션 사용법 + +### 1. UseCase 어노테이션 + +비즈니스 계층을 명확히 표시하는 커스텀 어노테이션: + +```java +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Component // Spring Bean 등록 +public @interface UseCase { +} +``` + +사용법: + +```java +@UseCase // 유스케이스 계층임을 명시 +@RequiredArgsConstructor +public class RoutineUseCase { + // ... +} +``` + +### 2. API 문서화 어노테이션 + +API 응답을 체계적으로 문서화하는 커스텀 어노테이션: + +```java +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiResponseExplanations { + ApiSuccessResponseExplanation success() default @ApiSuccessResponseExplanation(); + + ApiErrorResponseExplanation[] errors() default {}; +} + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiSuccessResponseExplanation { + HttpStatus status() default HttpStatus.OK; + + Class responseClass() default EmptyClass.class; + + String description() default ""; +} + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiErrorResponseExplanation { + ExceptionCode exceptionCode(); +} +``` + +## 메서드 파라미터 및 코딩 컨벤션 + +### 1. 메서드 파라미터 규칙 + +- **모든 메서드 파라미터는 final 키워드 사용** (Builder 생성자 파라미터는 Lombok이 생성하므로 예외) +- **명확한 의미를 가진 파라미터명 사용** +- **null 체크가 필요한 경우 Optional 사용** + +```java +// 올바른 패턴 +public void updateRoutine(final Long userId, final Long routineId, final RoutineUpdateRequest request) { + // ... +} + +public Optional getUserRoutine(final User user, final Long id) { + // ... +} + +// 잘못된 패턴 +public void updateRoutine(Long userId, Long routineId, RoutineUpdateRequest request) { + // ... +} +``` + +### 2. 메서드 명명 규칙 + +- **동사 + 명사** 조합으로 명확한 의도 표현 +- **비즈니스 용어** 사용 +- **boolean 반환 메서드는 is/can/has 접두사** 사용 + +```java +// 올바른 메서드명 +public RoutineCreateResponse createRoutine(...) +public boolean canCreateRecordForDate(...) +public boolean isActiveForDate(...) +public boolean isOwner(...) +public void deleteIncompletedFuturesByRoutine(...) + +// 잘못된 메서드명 +public RoutineCreateResponse create(...) // 너무 일반적 +public boolean checkDate(...) // 불명확 +public void remove(...) // 비즈니스 의미 부족 +``` + +### 3. 클래스 및 패키지 명명 규칙 + +- **도메인 용어를 중심**으로 명명 +- **계층별 일관된 접미사** 사용 +- **패키지는 기능별** 구성 + +```java +// 올바른 명명 +RoutineUseCase // 유스케이스 계층 +RoutineService // 서비스 계층 +RoutineRepository // 레포지토리 계층 +RoutineCreateRequest // 요청 DTO +RoutineDetailResponse // 응답 DTO +RoutineMapper // 매퍼 클래스 +PlanCategoryColor // 값 객체 +``` + +## 예외 처리 규칙 + +### 1. 비즈니스 예외는 ExceptionCode 사용 + +시스템 전반에서 **일관된 예외 처리**를 위해 ExceptionCode를 사용합니다: + +```java +// 표준 예외 처리 패턴 +User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + +Routine routine = routineService.getUserRoutine(user, routineId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ROUTINE)); + +// 비즈니스 규칙 위반 시 +if (startDate.isAfter(endDate)) { + throw CommonException.from(ExceptionCode.EXCEED_ROUTINE_DATE_RANGE); +} +``` + +### 2. 값 객체 검증 예외 + +값 객체의 검증 예외는 `VoException`을 사용합니다: + +```java +private void validate(final String value) { + if (value != null && value.length() > MAX_LENGTH) { + throw new VoException("메모는 " + MAX_LENGTH + "자를 초과할 수 없습니다."); + } +} +``` + +## 도메인 이벤트 작성 규칙 + +### 1. 이벤트 클래스 (불변 객체) + +이벤트를 사용하는것이 유지보수나 가독성 측면에서 효과가 있다고 판단되는 경우에만 선택적으로 사용하세요. +도메인 이벤트는 **불변 객체**로 작성하고 **final 필드**를 사용합니다: + +```java +@Getter +public class RoutineCreatedEvent { + private final Long routineId; + private final Long userId; + + public RoutineCreatedEvent(final Long routineId, final Long userId) { + this.routineId = routineId; + this.userId = userId; + } +} + +// 변경 플래그를 포함한 이벤트 예제 +@Getter +public class RoutineUpdatedEvent { + private final Long routineId; + private final Long userId; + private final boolean isTimeChanged; + private final boolean isDaysOfWeekChanged; + private final boolean isReminderMinutesChanged; + private final boolean isTitleChanged; + + public RoutineUpdatedEvent(final Long routineId, final Long userId, + final boolean isTimeChanged, final boolean isDaysOfWeekChanged, + final boolean isReminderMinutesChanged, final boolean isTitleChanged) { + this.routineId = routineId; + this.userId = userId; + this.isTimeChanged = isTimeChanged; + this.isDaysOfWeekChanged = isDaysOfWeekChanged; + this.isReminderMinutesChanged = isReminderMinutesChanged; + this.isTitleChanged = isTitleChanged; + } +} +``` + +### 2. 이벤트 발행 패턴 + +유스케이스에서 `ApplicationEventPublisher`를 사용하여 이벤트를 발행합니다: + +```java +@Transactional +public void updateRoutine(final Long userId, final Long routineId, final RoutineUpdateRequest request) { + // ... 비즈니스 로직 실행 + + routineService.updateFields(routine, request); + + log.info("루틴 수정 성공 - 사용자 Id: {}, 루틴 Id: {}", userId, routineId); + + // 도메인 이벤트 발행 (트랜잭션 커밋 후 비동기 처리) + eventPublisher.publishEvent(new RoutineUpdatedEvent( + routineId, + userId, + request.isTimeChanged(), + request.isDaysOfWeekChanged(), + request.isReminderMinutesChanged(), + request.isTitleChanged() + )); +} +``` + +### 3. 이벤트 리스너 패턴 + +이벤트 리스너는 `@TransactionalEventListener`와 `@Async`를 사용합니다. +`@EventListener` 대신 `@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)`을 사용하여 트랜잭션 커밋 후에만 이벤트가 처리되도록 합니다: + +```java +@Component +@RequiredArgsConstructor +@Slf4j +public class RoutineReminderEventListener { + + private final RoutineReminderSchedulerService schedulerService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleRoutineCreated(final RoutineCreatedEvent event) { + try { + schedulerService.scheduleReminder(event.getRoutineId()); + log.info("루틴 알림 스케줄링 성공 - RoutineId: {}", event.getRoutineId()); + } catch (Exception e) { + log.error("루틴 알림 스케줄링 실패 - RoutineId: {}", event.getRoutineId(), e); + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleRoutineUpdated(final RoutineUpdatedEvent event) { + // 알림 설정이 변경된 경우에만 처리 + if (event.isTimeChanged() || event.isDaysOfWeekChanged() || event.isReminderMinutesChanged()) { + try { + schedulerService.rescheduleReminder(event.getRoutineId()); + log.info("루틴 알림 재스케줄링 성공 - RoutineId: {}", event.getRoutineId()); + } catch (Exception e) { + log.error("루틴 알림 재스케줄링 실패 - RoutineId: {}", event.getRoutineId(), e); + } + } + } +} +``` + +## 로깅 규칙 + +### 1. 구조화된 로깅 패턴 + +주요 비즈니스 이벤트는 **구조화된 형태**로 로깅합니다: + +```java +// 올바른 로깅 패턴 (구조화된 정보) +log.info("루틴 생성 - UserId: {}, RoutineId: {}", userId, routineCreateResponse.routineId()); +log.info("루틴 상태 변경 성공(기록 수정) - 사용자 Id: {}, 루틴 Id: {}, 루틴 날짜: {}, 완료상태: {}", + userId, routineId, date, isCompleted); +log.info("본인 루틴 기록 기간 조회 - UserId: {}, 조회 기간: {} ~ {}", userId, startDate, endDate); + +// 잘못된 로깅 패턴 +log.info("루틴을 생성했습니다."); // 정보 부족 +log.info("루틴 생성 완료: " + userId + ", " + routineId); // 문자열 연결 +``` + +### 2. 로그 레벨별 사용 기준 + +- **INFO**: 주요 비즈니스 이벤트, 성공 케이스 +- **WARN**: 권한 부족, 비정상적인 접근 시도 +- **ERROR**: 예외 발생, 시스템 오류 + +```java +// INFO - 정상적인 비즈니스 플로우 +log.info("루틴 생성 - UserId: {}, RoutineId: {}", userId, routine.getId()); + +// WARN - 보안 관련, 권한 위반 +log.warn("권한이 없는 유저가 일기 수정 시도 - UserId: {}, DiaryId: {}", user.getId(), diary.getId()); + +// ERROR - 예외 상황, 시스템 오류 +log.error("루틴 알림 스케줄링 실패 - RoutineId: {}", routine.getId(), e); +``` + +## 성능 최적화 규칙 + +### 1. 연관관계 최적화 + +**지연 로딩을 기본으로 사용**하고, 필요한 경우에만 fetch join 사용: + +```java +// 기본 설정 - 지연 로딩 +@ManyToOne(fetch = FetchType.LAZY) +@JoinColumn(name = "user_id", nullable = false) +private User user; + +// QueryDSL에서 필요한 경우 fetch join +public List findAllByUserAndRecordAtDate(final User user, final LocalDate date) { + return queryFactory + .selectFrom(qRecord) + .join(qRecord.routine, qRoutine).fetchJoin() // 명시적 fetch join + .where( + qRoutine.user.eq(user), + recordAtBetween(date) + ) + .fetch(); +} +``` + +### 2. 배치 연산 활용 + +여러 데이터를 처리할 때는 **배치 연산**을 사용합니다: + +```java +// 일괄 저장 +public void saveAll(final List newRecords) { + if (!newRecords.isEmpty()) { + routineRecordRepository.saveAll(newRecords); + } +} + +// 일괄 삭제 (QueryDSL) +@Override +public void deleteIncompletedFuturesByRoutine(final Routine routine, final LocalDateTime targetDateTime) { + queryFactory + .delete(qRecord) + .where( + qRecord.routine.eq(routine), + qRecord.recordAt.after(targetDateTime), + qRecord.isCompleted.isFalse(), + qRecord.deletedAt.isNull() + ) + .execute(); +} +``` + +### 3. 원자적 연산 (동시성 제어) + +카운터 등의 업데이트는 **원자적 연산**을 사용합니다: + +```java +@Modifying(clearAutomatically = true) // 영속성 컨텍스트 자동 클리어 +@Query("UPDATE Routine r SET r.sharedCount = r.sharedCount + 1 WHERE r.id = :id") +void incrementSharedCountAtomically(@Param("id") final Long id); + +// 사용 예제 +public void shareRoutine(final Long routineId) { + routineRepository.incrementSharedCountAtomically(routineId); +} +``` + +### 4. 커서 기반 페이징 + +대용량 데이터 조회 시 **커서 기반 페이징**을 사용합니다: + +```java +private BooleanExpression cursorCondition(final Long cursor) { + if (cursor == null) { + return null; + } + return qSocial.id.lt(cursor); // ID 기준 커서 +} + +private JPAQuery applyPagination(final JPAQuery query, final Pageable pageable) { + return query + .orderBy(qSocial.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()); +} +``` diff --git a/.agent-docs/test-conventions.md b/.agent-docs/test-conventions.md new file mode 100644 index 00000000..840c60fa --- /dev/null +++ b/.agent-docs/test-conventions.md @@ -0,0 +1,364 @@ +# 테스트 컨벤션 + +## 베이스 클래스 선택 기준 + +| 베이스 클래스 | 용도 | 특징 | +|---|---|---| +| `UseCaseTest` | UseCase 계층 테스트 | `@SpringBootTest`, `@ActiveProfiles("test")`, 외부 서비스 `@MockBean` (OAuth, JWT, Firebase, RabbitMQ 등), `TestFixtureBuilder` 주입 | +| `ServiceTest` | Service 계층 및 동시성 테스트 | `@SpringBootTest`, `@ActiveProfiles("test")`, `EntityManager` 포함, `AccessTokenProvider`/`RefreshTokenProvider` 등 MockBean | +| `RepositoryTest` | Repository/QueryDSL 테스트 | `@DataJpaTest`, QueryDSL 설정 import, `@AutoConfigureDataRedis`, Quartz 비활성화, 경량 JPA 테스트 | + +**주요 차이점**: +- `UseCaseTest`/`ServiceTest`: 전체 Spring Context를 로드하므로 통합 테스트에 적합 +- `RepositoryTest`: `@DataJpaTest`로 JPA 관련 빈만 로드하여 빠른 테스트 실행 +- 동시성 테스트(`ConcurrencyTest`)는 `ServiceTest`를 상속 + +## 테스트 구조 + +### 한글 `@DisplayName` + `@Nested` 그루핑 + +```java +class RoutineUseCaseTest extends UseCaseTest { + + @Nested + @DisplayName("루틴 목록 조회시") + class ReadMyRoutineListTest { + + @Nested + @DisplayName("성공") + class Success { + @Test + void 루틴_기록이_존재하는_경우에는_해당_기록을_그대로_사용한다() { + // ... + } + } + + @Nested + @DisplayName("실패") + class Fail { + @Test + void 유효한_유저가_아닐경우_실패한다() { + // ... + } + } + } +} +``` + +### Given-When-Then 구조 + +```java +@Test +void 루틴_기록이_존재하는_경우에는_해당_기록을_그대로_사용한다() { + // given + Routine WEEKDAY_ROUTINE = testFixtureBuilder.buildRoutineAndUpdateAuditFields( + PUBLIC_WEEKDAY_MORNING_ROUTINE(USER) + .createdAt("2024-11-29 01:00:00") + .build() + ); + + // when + MyRoutineRecordReadListResponse responses = + routineUseCase.readMyRoutineRecordList(USER.getId(), queryDate); + + // then + assertSoftly(softly -> { + softly.assertThat(responses.queryDate()).isEqualTo(queryDate); + softly.assertThat(responses.routines()).hasSize(1); + }); +} +``` + +### 예외 테스트에서는 when-then 결합 가능 + +```java +@Test +void 유효한_유저가_아닐경우_실패한다() { + // given + int NOISE_USER_ID = 9999; + + // when -> then + assertSoftly(softly -> { + softly.assertThatThrownBy( + () -> routineUseCase.createRoutine( + savedUser.getId() + NOISE_USER_ID, request)) + .isInstanceOf(CommonException.class) + .hasFieldOrPropertyWithValue("httpStatus", ExceptionCode.NOT_FOUND_USER.getHttpStatus()) + .hasFieldOrPropertyWithValue("errorCode", ExceptionCode.NOT_FOUND_USER.getErrorCode()) + .hasFieldOrPropertyWithValue("message", ExceptionCode.NOT_FOUND_USER.getMessage()); + }); +} +``` + +## 테스트 픽스처 + +### TestFixtureBuilder + +`TestFixtureBuilder`는 `@Component`로 모든 베이스 테스트 클래스에 `@Autowired`로 주입됩니다. +내부적으로 `BuilderSupporter`를 통해 각 도메인 Repository에 접근합니다: + +```java +// 사용자 생성 +USER = testFixtureBuilder.buildUser(GENERAL_USER()); + +// 루틴 생성 (audit 필드 포함) +Routine routine = testFixtureBuilder.buildRoutineAndUpdateAuditFields( + PUBLIC_WEEKDAY_MORNING_ROUTINE(USER) + .createdAt("2024-11-29 01:00:00") + .build() +); + +// 루틴 기록 생성 +testFixtureBuilder.buildRoutineRecord( + COMPLETED_RECORD(routine).recordAt("2024-12-02 07:00:00").build() +); +``` + +### Fixture 클래스 (`src/test/java/im/toduck/fixtures/`) + +정적 팩토리 메서드로 테스트 데이터 생성. 각 Fixture는 Builder를 반환하여 체이닝 가능: + +```java +// 사용자 픽스처 +GENERAL_USER() + +// 루틴 픽스처 - 네이밍: [공개여부]_[요일패턴]_[시간대]_ROUTINE +PUBLIC_WEEKDAY_MORNING_ROUTINE(user) // 평일, 오전 7시, 공개 +PRIVATE_DAILY_EVENING_ROUTINE(user) // 매일, 오후 7시, 비공개 +PUBLIC_MONDAY_ALLDAY_ROUTINE(user) // 월요일, 종일(time=null), 공개 + +// 루틴 기록 픽스처 +COMPLETED_RECORD(routine) +INCOMPLETED_RECORD(routine) +``` + +### Audit 필드 설정 (RoutineWithAuditInfo) + +`ReflectionTestUtils.setField()`를 사용하여 audit 필드를 설정하는 래퍼 클래스. +날짜 형식은 `"yyyy-MM-dd HH:mm:ss"`: + +```java +PUBLIC_WEEKDAY_MORNING_ROUTINE(USER) + .createdAt("2024-11-29 01:00:00") // 생성 시점 + .scheduleModifiedAt("2024-12-01 09:00:00") // 스케줄 수정 시점 + .deletedAt("2024-12-05 00:00:00") // 삭제 시점 (소프트 삭제 테스트) + .build() +``` + +**스마트 기본값**: `createdAt`만 설정하면 `scheduleModifiedAt`이 자동으로 동일 값 설정됨. +조합이 유효하지 않으면 `IllegalStateException` 발생. + +## Assertion 패턴 + +### AssertJ `assertSoftly` (여러 검증 한 번에) + +```java +assertSoftly(softly -> { + softly.assertThat(responses.queryDate()).isEqualTo(queryDate); + softly.assertThat(responses.routines()).hasSize(1); + softly.assertThat(response.routineId()).isEqualTo(routine.getId()); +}); +``` + +### 예외 테스트 + +`hasFieldOrPropertyWithValue`로 ExceptionCode의 각 필드를 검증: + +```java +assertSoftly(softly -> { + softly.assertThatThrownBy( + () -> routineUseCase.createRoutine(invalidUserId, request)) + .isInstanceOf(CommonException.class) + .hasFieldOrPropertyWithValue("httpStatus", ExceptionCode.NOT_FOUND_USER.getHttpStatus()) + .hasFieldOrPropertyWithValue("errorCode", ExceptionCode.NOT_FOUND_USER.getErrorCode()) + .hasFieldOrPropertyWithValue("message", ExceptionCode.NOT_FOUND_USER.getMessage()); +}); +``` + +### 예외 미발생 검증 + +```java +assertThatCode(() -> + routineRecordRepository.deleteIncompletedFuturesByRoutine(routine, now) +).doesNotThrowAnyException(); +``` + +### 컬렉션 검증 + +```java +assertThat(unrecordedRoutines).contains(ROUTINE); +assertThat(routineRecords).hasSize(1); +assertThat(remainingIds).doesNotContain(futureIncomplete.getId()); +``` + +### Stream 기반 복합 검증 + +```java +Map> routinesByDate = + response.dateRoutines().stream() + .collect(Collectors.toMap( + MyRoutineRecordReadListResponse::queryDate, + MyRoutineRecordReadListResponse::routines + )); + +Set actualIds = routines.stream() + .map(MyRoutineReadResponse::routineId) + .collect(Collectors.toSet()); + +unexpectedIds.forEach(unexpectedId -> + softly.assertThat(actualIds) + .as("%s - 루틴(ID: %d)는 존재하지 않아야 함", date, unexpectedId) + .doesNotContain(unexpectedId) +); +``` + +## Mockito BDD 패턴 + +### `@MockBean` 선언 및 설정 + +베이스 클래스에 공통 MockBean이 선언되어 있고, 테스트에서 `@BeforeEach`/`@AfterEach`로 설정/해제: + +```java +// Setup (@BeforeEach) +given(userService.getUserById(any(Long.class))).willReturn(Optional.ofNullable(USER)); + +// Teardown (@AfterEach) - 모의 상태 초기화 +reset(userService); +``` + +### MockedStatic (정적 메서드 모킹) + +`LocalDateTime.now()` 등 정적 메서드를 고정할 때 사용. **반드시 `@AfterEach`에서 `close()` 호출**: + +```java +private MockedStatic mockedStatic; + +@BeforeEach +void setUp() { + LocalDateTime fixedNow = LocalDateTime.parse("2024-12-15T10:00:00"); + mockedStatic = mockStatic(LocalDateTime.class, CALLS_REAL_METHODS); + mockedStatic.when(LocalDateTime::now).thenReturn(fixedNow); +} + +@AfterEach +void tearDown() { + mockedStatic.close(); +} +``` + +## 트랜잭션 관리 + +### 기본 동작 + +`UseCaseTest`/`ServiceTest` 상속 시 테스트별로 트랜잭션이 롤백됩니다. + +### `Propagation.NEVER` — 실제 DB 상태 검증 + +트랜잭션 롤백 없이 실제 커밋된 DB 상태를 확인해야 할 때 사용: + +```java +@Test +@Transactional(propagation = Propagation.NEVER) +void 기존_기록이_존재하는_경우에_완료_상태_변경이_성공한다() { + // 실제 DB 커밋 후 상태 검증 +} +``` + +### `TransactionTemplate` — 테스트 데이터 사전 커밋 + +동시성 테스트 등에서 데이터를 먼저 커밋한 후 테스트 실행: + +```java +TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); +Long routineId = transactionTemplate.execute(status -> { + User user = testFixtureBuilder.buildUser(GENERAL_USER()); + Routine routine = testFixtureBuilder.buildRoutineAndUpdateAuditFields( + PUBLIC_WEEKDAY_MORNING_ROUTINE(user).createdAt("2024-11-29 01:00:00").build() + ); + entityManager.flush(); + entityManager.clear(); + return routine.getId(); +}); +``` + +## 동시성 테스트 패턴 + +`ServiceTest`를 상속하고 `CountDownLatch` + `ExecutorService`를 사용: + +```java +class RoutineUseCaseConcurrencyTest extends ServiceTest { + + @Test + void 동시에_여러_스레드가_같은_루틴의_완료_상태를_변경해도_정확히_하나의_기록만_생성된다() { + // given + int numberOfThreads = 6; + ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(numberOfThreads); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = Collections.synchronizedList(new ArrayList<>()); + + // when + for (int i = 0; i < numberOfThreads; i++) { + executorService.submit(() -> { + try { + startLatch.await(); + Thread.sleep(random.nextInt(10)); + routineUseCase.updateRoutineCompletion(userId, routineId, request); + successCount.incrementAndGet(); + } catch (Exception e) { + exceptions.add(e); + } finally { + endLatch.countDown(); + } + }); + } + + startLatch.countDown(); // 모든 스레드 동시 시작 + boolean completed = endLatch.await(5, TimeUnit.SECONDS); + executorService.shutdown(); + + // then + assertSoftly(softly -> { + softly.assertThat(completed).isTrue(); + softly.assertThat(routineRecords).hasSize(1); + softly.assertThat(successCount.get()).isEqualTo(numberOfThreads); + softly.assertThat(exceptions).isEmpty(); + }); + } +} +``` + +## 변수 네이밍 규칙 + +- **UPPER_CASE**: 픽스처로 생성된 엔티티 변수 (`USER`, `WEEKDAY_ROUTINE`, `RECORD`) +- **camelCase**: 로컬/일시적 변수 (`queryDate`, `isCompleted`, `numberOfThreads`) +- **날짜 상수**: `private final LocalDate QUERY_START_DATE = LocalDate.of(2025, 1, 10);` +- **요청 DTO**: camelCase (`successScheduleCreateRequest`) + +## 주요 어노테이션 정리 + +| 어노테이션 | 용도 | +|---|---| +| `@Test` | 테스트 메서드 표시 | +| `@DisplayName("한글 설명")` | 사람이 읽을 수 있는 테스트 설명 | +| `@Nested` | 관련 테스트 그루핑 | +| `@BeforeEach` / `@AfterEach` | 테스트 전후 setup/teardown | +| `@Disabled("사유")` | 테스트 일시 비활성화 | +| `@Transactional` | 테스트 후 롤백 (클래스/메서드 레벨) | +| `@Transactional(propagation = Propagation.NEVER)` | 롤백 없이 실제 DB 상태 검증 | +| `@MockBean` | Spring Context에 Mock 빈 주입 | +| `@Autowired` | 테스트 대상 빈 주입 | + +## 표준 Static Import + +```java +import static im.toduck.fixtures.routine.RoutineFixtures.*; +import static im.toduck.fixtures.routine.RoutineRecordFixtures.*; +import static im.toduck.fixtures.user.UserFixtures.*; +import static im.toduck.global.exception.ExceptionCode.*; +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.SoftAssertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +``` diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b90babbf..37eb1ce3 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,1172 +1,5 @@ -# 새로운 도메인 개발 가이드 +# toduck-backend -이 문서는 toduck-backend의 루틴(routine) 도메인을 중심으로 분석하여 새로운 도메인 개발 시 동일한 코드 패턴과 품질을 유지하기 위한 상세한 가이드입니다. - -## 프로젝트 구조 및 패키지 구성 - -새로운 도메인을 개발할 때는 다음과 같은 패키지 구조를 엄격히 따라야 합니다: - -``` -domain/{도메인명}/ -├── common/ # 도메인 내 공통 유틸리티 -│ ├── converter/ # JPA 컨버터 (enum, 사용자 정의 타입 등) -│ ├── dto/ # 도메인 내부 DTO (DailyRoutineData 등) -│ ├── helper/ # 도메인별 헬퍼 클래스 -│ └── mapper/ # 엔티티-DTO 매핑 클래스 -├── domain/ # 핵심 비즈니스 로직 (Clean Architecture) -│ ├── event/ # 도메인 이벤트 (RoutineCreatedEvent 등) -│ ├── service/ # 도메인 서비스 (데이터 접근 로직) -│ └── usecase/ # 애플리케이션 유스케이스 (비즈니스 로직 조합) -├── infrastructure/ # 외부 시스템 연동 -│ └── scheduler/ # 스케줄링 관련 작업 (Quartz Job 등) -├── persistence/ # 데이터 계층 -│ ├── entity/ # JPA 엔티티 -│ ├── repository/ # 레포지토리 인터페이스 및 구현 -│ │ └── querydsl/ # QueryDSL 구현체 (복잡한 쿼리) -│ └── vo/ # 값 객체 (Value Object) -└── presentation/ # 프레젠테이션 계층 - ├── api/ # API 문서화 인터페이스 (Swagger 통합) - ├── controller/ # REST 컨트롤러 - └── dto/ # 요청/응답 DTO - ├── request/ # 요청 DTO - └── response/ # 응답 DTO -``` - -## 엔티티 작성 규칙 - -### 1. BaseEntity 상속 및 소프트 삭제 패턴 - -모든 엔티티는 `BaseEntity`를 상속하여 공통 필드(id, createdAt, updatedAt, deletedAt)를 사용합니다: - -```java - -@Entity -@Getter -@Table(name = "routine") -@NoArgsConstructor // 반드시 기본 생성자 필요 -public class Routine extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) // IDENTITY 전략 사용 - private Long id; - - // ... 비즈니스 필드들 -} -``` - -**소프트 삭제 메서드 -> 소프트 삭제는 상황에 따라 필요한 경우에만 선택적으로 수행** - -```java -public void delete() { - this.deletedAt = LocalDateTime.now(); -} - -public Boolean isInDeletedState() { - return deletedAt != null; -} -``` - -### 2. Builder 패턴 사용 (생성자 패턴) - -엔티티 생성 시 반드시 Builder 패턴을 사용하고, private 생성자로 외부 생성을 방지합니다: - -```java - -@Builder -private Routine( - final PlanCategory category, // final 키워드 필수 - final PlanCategoryColor color, - final String title, - final Boolean isPublic, - final Integer reminderMinutes, - final RoutineMemo memo, - final LocalTime time, - final DaysOfWeekBitmask daysOfWeekBitmask, - final User user -) { - this.category = category; - this.color = color; - this.title = title; - this.isPublic = isPublic; - this.reminderMinutes = reminderMinutes; - this.memo = memo; - this.time = time; - this.daysOfWeekBitmask = daysOfWeekBitmask; - this.user = user; - this.scheduleModifiedAt = LocalDateTime.now(); // 생성 시점 자동 기록 -} -``` - -### 3. 비즈니스 로직 메서드 - -엔티티에 도메인 로직을 포함하는 메서드를 작성합니다: - -```java -public void updateFromRequest(final RoutineUpdateRequest request) { - if (request.isTitleChanged()) { - this.title = request.title(); - } - - // 스케줄 변경 시 자동으로 수정 시점 기록 - if (request.isTimeChanged() && !Objects.equals(this.time, request.time())) { - this.time = request.time(); - this.scheduleModifiedAt = LocalDateTime.now(); - } - - if (request.isDaysOfWeekChanged()) { - DaysOfWeekBitmask newDaysOfWeek = DaysOfWeekBitmask.createByDayOfWeek(request.daysOfWeek()); - if (!newDaysOfWeek.equals(this.daysOfWeekBitmask)) { - this.daysOfWeekBitmask = newDaysOfWeek; - this.scheduleModifiedAt = LocalDateTime.now(); - } - } - - // ... 다른 필드 업데이트 로직 -} - -// 권한 체크 메서드 -public boolean isOwner(final User requestingUser) { - return this.user.getId().equals(requestingUser.getId()); -} - -// 비즈니스 상태 체크 메서드 -public Boolean isAllDay() { - return time == null; -} -``` - -### 4. 연관관계 설정 원칙 - -연관관계는 꼭 필요한 경우에만 사용하도록 하고, 남용하면 유지보수에 문제가 생기므로 잘 생각해서 사용하세요. - -**지연 로딩을 기본으로 사용** (성능 최적화): - -```java - -@ManyToOne(fetch = FetchType.LAZY) -@JoinColumn(name = "user_id", nullable = false) -private User user; -``` - -**일대다 관계에서 cascade 및 orphanRemoval 설정**: - -```java - -@OneToMany(mappedBy = "diary", cascade = CascadeType.ALL, orphanRemoval = true) -private List diaryImages = new ArrayList<>(); -``` - -**소프트 삭제를 위한 Hibernate 어노테이션 사용**: - -```java -@SQLDelete(sql = "UPDATE diary SET deleted_at = NOW() where id=?") -@SQLRestriction(value = "deleted_at is NULL") -``` - -## 값 객체(Value Object) 작성 규칙 - -### 1. @Embeddable 사용 및 검증 패턴 - -값 객체의 경우, 자주 사용되거나 맥락상 사용하는 경우가 사용하지 않을때 보다 더 적절한 경우 사용하세요. -값 객체는 `@Embeddable`을 사용하여 작성하고, 필요한 경우 검증 로직을 포함합니다: - -```java - -@Embeddable -@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA 요구사항 -@EqualsAndHashCode // 값 객체 동등성 비교 -@Getter -public class PlanCategoryColor { - private static final Pattern HEX_COLOR_CODE_PATTERN = Pattern.compile(HEX_COLOR_CODE_REGEX); - - @Column(name = "color") - private String value; - - private PlanCategoryColor(final String value) { - validate(value); - this.value = value; - } - - // 팩토리 메서드 패턴 - public static PlanCategoryColor from(final String color) { - if (color == null) { - return null; - } - return new PlanCategoryColor(color); - } - - // 필수 검증 로직 - private void validate(final String value) { - if (value != null) { - if (!HEX_COLOR_CODE_PATTERN.matcher(value).matches()) { - throw new VoException("색상은 '#RRGGBB' 형식이어야 합니다."); - } - } - } -} -``` - -### 2. 길이 제한이 있는 값 객체 - -```java - -@Embeddable -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@EqualsAndHashCode -@Getter -public class RoutineMemo { - private static final int MAX_LENGTH = 40; - - @Column(name = "memo", columnDefinition = "TEXT") - private String value; - - private RoutineMemo(final String value) { - validate(value); - this.value = value; - } - - public static RoutineMemo from(final String memo) { - if (memo == null) { - return null; - } - return new RoutineMemo(memo); - } - - private void validate(final String value) { - if (value != null && value.length() > MAX_LENGTH) { - throw new VoException("메모는 " + MAX_LENGTH + "자를 초과할 수 없습니다."); - } - } -} -``` - -## 레포지토리 작성 규칙 - -### 1. 표준 레포지토리 인터페이스 - -기본 CRUD 및 표준 쿼리를 위한 메인 레포지토리 패턴: - -```java - -@Repository -public interface RoutineRepository extends JpaRepository, RoutineRepositoryCustom { - - // 표준 쿼리 메서드 (Spring Data JPA) - Optional findByIdAndUser(final Long id, final User user); - - List findAllByUserAndIsPublicTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(final User user); - - // 원자적 연산을 위한 @Modifying 쿼리 (동시성 제어) - @Modifying(clearAutomatically = true) - @Query("UPDATE Routine r SET r.sharedCount = r.sharedCount + 1 WHERE r.id = :id") - void incrementSharedCountAtomically(@Param("id") final Long id); - - // 집계 쿼리 (COALESCE로 null 처리) - @Query("SELECT COALESCE(SUM(r.sharedCount), 0) FROM Routine r WHERE r.user = :user AND r.isPublic = true AND r.deletedAt IS NULL") - int sumRoutineSharedCountByUser(@Param("user") final User user); -} -``` - -### 2. QueryDSL을 활용한 복잡한 쿼리 구조 - -복잡한 쿼리는 QueryDSL을 사용하여 별도 인터페이스로 분리합니다: - -**인터페이스 정의:** - -```java -public interface RoutineRepositoryCustom { - List findUnrecordedRoutinesByDateMatchingDayOfWeek( - final User user, - final LocalDate date, - final List routineRecords - ); - - List findRoutinesByDateBetween( - final User user, - final LocalDate startDate, - final LocalDate endDate - ); - - boolean isActiveForDate(final Routine routine, final LocalDate date); - - void deleteAllUnsharedRoutinesByUser(final User user); -} -``` - -**QueryDSL 구현체:** - -```java - -@Repository -@RequiredArgsConstructor -public class RoutineRepositoryCustomImpl implements RoutineRepositoryCustom { - private final JPAQueryFactory queryFactory; - private final QRoutine qRoutine = QRoutine.routine; - - @Override - public List findUnrecordedRoutinesByDateMatchingDayOfWeek( - final User user, - final LocalDate date, - final List routineRecords - ) { - return queryFactory - .selectFrom(qRoutine) - .where( - qRoutine.user.eq(user), - scheduleModifiedOnOrBeforeDate(date), - routineNotRecorded(routineRecords), - routineMatchesDate(date), - routineNotDeleted() - ) - .fetch(); - } - - // 재사용 가능한 조건 메서드들 - private BooleanExpression routineNotDeleted() { - return qRoutine.deletedAt.isNull(); - } - - private BooleanExpression scheduleModifiedOnOrBeforeDate(final LocalDate date) { - LocalDateTime endOfDay = date.atTime(LocalTime.MAX); - return qRoutine.scheduleModifiedAt.loe(endOfDay); - } - - // 비트 연산을 활용한 요일 매칭 (성능 최적화) - private BooleanExpression routineMatchesDate(final LocalDate date) { - byte dayBitmask = DaysOfWeekBitmask.getDayBitmask(date.getDayOfWeek()); - - return Expressions.numberTemplate( - Byte.class, "function('bitand', {0}, CAST({1} as byte))", - qRoutine.daysOfWeekBitmask, dayBitmask - ).gt((byte)0); - } -} -``` - -### 3. QueryDSL 사용 시 주의사항 - -- **성능 최적화**: 복잡한 조건은 메서드로 분리하여 재사용 -- **타입 안전성**: Q클래스를 사용하여 컴파일 타임 안전성 확보 -- **동적 쿼리**: BooleanExpression을 활용한 조건부 쿼리 -- **페이징**: limit, offset을 활용한 커서 기반 페이징 구현 - -## 서비스 레이어 작성 규칙 - -### 1. 도메인 서비스 (데이터 접근 계층) - -**중요한 아키텍처 원칙**: -- **서비스는 자신의 도메인 레포지토리만 사용**해야 합니다 -- **다른 도메인의 레포지토리를 직접 의존하면 안됩니다** -- **다른 도메인의 데이터가 필요한 경우, 유스케이스 레이어에서 여러 서비스를 조합**해야 합니다 - -도메인 서비스는 주로 **자신의 도메인 데이터 접근 로직과 단순한 비즈니스 로직**을 담당합니다: - -```java - -@Slf4j -@Service -@RequiredArgsConstructor -public class RoutineService { - private final RoutineRepository routineRepository; - - @Transactional - public RoutineCreateResponse create(final User user, final RoutineCreateRequest request) { - Routine routine = RoutineMapper.toRoutine(user, request); - Routine savedRoutine = routineRepository.save(routine); - return RoutineMapper.toRoutineCreateResponse(savedRoutine); - } - - @Transactional(readOnly = true) - public List getUnrecordedRoutinesForDate( - final User user, - final LocalDate date, - final List routineRecords - ) { - return routineRepository.findUnrecordedRoutinesByDateMatchingDayOfWeek(user, date, routineRecords); - } - - // Optional 사용으로 null 안전성 확보 - @Transactional(readOnly = true) - public Optional getUserRoutine(final User user, final Long id) { - return routineRepository.findByIdAndUser(id, user); - } - - // 비즈니스 로직 메서드 - public boolean canCreateRecordForDate(final Routine routine, final LocalDate date) { - return routineRepository.isActiveForDate(routine, date); - } -} -``` - -### 2. 트랜잭션 경계 설정 원칙 - -- **조회 전용 메서드**: `@Transactional(readOnly = true)` (성능 최적화) -- **수정 메서드**: `@Transactional` (기본값) -- **배치 작업**: `@Transactional` + 적절한 flush 및 clear - -## 유스케이스 작성 규칙 - -### 1. 비즈니스 로직 조합 및 트랜잭션 관리 - -유스케이스는 **여러 서비스를 조합하여 복잡한 비즈니스 로직을 구현**합니다: - -```java - -@Slf4j -@UseCase // 커스텀 어노테이션 (비즈니스 계층 표시) -@RequiredArgsConstructor -public class RoutineUseCase { - private static final int MAX_ROUTINE_DATE_RANGE_DAYS = 13; // 비즈니스 상수 - - private final UserService userService; - private final RoutineService routineService; - private final RoutineRecordService routineRecordService; - private final DistributedLock distributedLock; // 동시성 제어 - private final ApplicationEventPublisher eventPublisher; // 도메인 이벤트 - - @Transactional - public RoutineCreateResponse createRoutine(final Long userId, final RoutineCreateRequest request) { - // 1. 사용자 검증 - User user = userService.getUserById(userId) - .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); - - // 2. 비즈니스 로직 실행 - RoutineCreateResponse response = routineService.create(user, request); - - // 3. 로깅 (비즈니스 이벤트 추적) - log.info("루틴 생성 - UserId: {}, RoutineId:{}", userId, response.routineId()); - - // 4. 도메인 이벤트 발행 (비동기 처리) - eventPublisher.publishEvent(new RoutineCreatedEvent(response.routineId(), userId)); - - return response; - } -} -``` - -### 2. 비즈니스 규칙 검증 패턴 - -유스케이스에서 **비즈니스 규칙을 엄격히 검증**합니다: - -```java - -@Transactional(readOnly = true) -public MyRoutineRecordReadMultipleDatesResponse readMyRoutineRecordListMultipleDates( - final Long userId, - final LocalDate startDate, - final LocalDate endDate -) { - // 비즈니스 규칙 검증 (사전 조건) - if (startDate.isAfter(endDate) || - ChronoUnit.DAYS.between(startDate, endDate) > MAX_ROUTINE_DATE_RANGE_DAYS) { - throw CommonException.from(ExceptionCode.EXCEED_ROUTINE_DATE_RANGE); - } - - User user = userService.getUserById(userId) - .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); - - // 복잡한 비즈니스 로직 실행 - List allRoutineRecords = - routineRecordService.getRecordsBetweenDates(user, startDate, endDate); - - List dailyRoutineDatas = - routineService.getRoutineDataByDateRange(user, startDate, endDate, allRoutineRecords); - - log.info("본인 루틴 기록 기간 조회 - UserId: {}, 조회 기간: {} ~ {}", userId, startDate, endDate); - - return RoutineMapper.toMyRoutineRecordReadMultipleDatesResponse( - startDate, endDate, dailyRoutineDatas - ); -} -``` - -### 3. 분산 락을 활용한 동시성 제어 - -동시성 제어가 필요한 경우 분산 락을 사용합니다: - -```java - -@Transactional -public void updateRoutineCompletion( - final Long userId, - final Long routineId, - final RoutinePutCompletionRequest request -) { - User user = userService.getUserById(userId) - .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); - Routine routine = routineService.getUserRoutineIncludingDeleted(user, routineId) - .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ROUTINE)); - - LocalDate date = request.routineDate(); - boolean isCompleted = request.isCompleted(); - - // 분산 락 키 생성 (리소스별 세밀한 제어) - String lockKey = "routine:" + routineId + ":date:" + date; - - distributedLock.executeWithLock(lockKey, () -> { - // 원자적으로 실행되어야 하는 로직 - if (routineRecordService.updateIfPresent(routine, date, isCompleted)) { - log.info("루틴 상태 변경 성공(기록 수정) - 사용자 Id: {}, 루틴 Id: {}, 루틴 날짜: {}, 완료상태: {}", - userId, routineId, date, isCompleted); - return; - } - - if (!routineService.canCreateRecordForDate(routine, date)) { - log.info("루틴 상태 변경 실패 - 사용자 Id: {}, 루틴 Id: {}, 루틴 날짜: {}", userId, routineId, date); - throw CommonException.from(ExceptionCode.ROUTINE_INVALID_DATE); - } - - routineRecordService.create(routine, date, isCompleted); - log.info("루틴 상태 변경 성공(기록 생성) - 사용자 Id: {}, 루틴 Id: {}, 루틴 날짜: {}, 완료상태: {}", - userId, routineId, date, isCompleted); - }); -} -``` - -## 프레젠테이션 레이어 작성 규칙 - -### 1. API 문서화를 위한 인터페이스 분리 - -API 문서화와 구현을 분리하여 관리합니다: - -```java - -@Tag(name = "Routine") // Swagger 태그 -public interface RoutineApi { - @Operation( - summary = "루틴 생성", - description = "내 루틴을 생성합니다. Request body 에서 필수 값 여부를 확인할 수 있습니다. 예를들어, time 필드가 null 인 경우에는 종일 루틴으로 간주합니다." - ) - @ApiResponseExplanations( // 커스텀 어노테이션 - success = @ApiSuccessResponseExplanation( - responseClass = RoutineCreateResponse.class, - description = "루틴 생성 성공, 생성된 루틴의 Id를 반환합니다." - ), - errors = { - @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_USER), - @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.INVALID_INPUT) - } - ) - ResponseEntity> postRoutine( - @AuthenticationPrincipal final CustomUserDetails userDetails, - @RequestBody @Valid final RoutineCreateRequest request - ); -} -``` - -### 2. 컨트롤러 구현 패턴 - -컨트롤러는 API 인터페이스를 구현하고 **유스케이스에만 위임**합니다: - -```java - -@RestController -@RequiredArgsConstructor -@RequestMapping("/v1/routines") -public class RoutineController implements RoutineApi { - private final RoutineUseCase routineUseCase; - - @Override - @PostMapping - @PreAuthorize("isAuthenticated()") // 인증 체크 - public ResponseEntity> postRoutine( - @AuthenticationPrincipal final CustomUserDetails userDetails, - @RequestBody @Valid final RoutineCreateRequest request - ) { - RoutineCreateResponse response = routineUseCase.createRoutine( - userDetails.getUserId(), - request - ); - - return ResponseEntity.ok(ApiResponse.createSuccess(response)); - } -} -``` - -### 3. ApiResponse 사용 규칙 - -**중요한 API 응답 규칙**: -- **성공 응답**: `ApiResponse.createSuccess(content)` 사용 -- **내용 없는 성공 응답**: `ApiResponse.createSuccessWithNoContent()` 사용 -- **절대 사용 금지**: `ApiResponse.onSuccess()` (존재하지 않는 메서드) - -```java -// ✅ 올바른 패턴 - 데이터가 있는 성공 응답 -return ResponseEntity.ok(ApiResponse.createSuccess(response)); - -// ✅ 올바른 패턴 - 데이터가 없는 성공 응답 (삭제, 수정 등) -return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); - -// ❌ 잘못된 패턴 - 존재하지 않는 메서드 -return ResponseEntity.ok(ApiResponse.onSuccess(response)); - -// ❌ 잘못된 패턴 - 응답 래핑 누락 -return ResponseEntity.ok(response); -``` - -**ApiResponse의 주요 정적 메서드들**: -- `createSuccess(T content)`: 성공 응답 (데이터 포함) -- `createSuccessWithNoContent()`: 성공 응답 (빈 Map 반환) -- `createError(ExceptionCode ec)`: 에러 응답 (ExceptionCode 기반) -- `createValidationError(Map errors)`: 유효성 검사 오류 응답 -- `createServerError()`: 서버 오류 응답 - -## DTO 작성 규칙 - -### 1. Record 사용 및 검증 어노테이션 - -요청/응답 DTO는 **불변 객체인 record**를 사용합니다: - -```java - -@Schema(description = "루틴 생성 요청 DTO") -public record RoutineCreateRequest( - @NotBlank(message = "제목은 비어있을 수 없습니다.") - @Size(max = 20, message = "제목은 20자를 초과할 수 없습니다.") - @Schema(description = "루틴 제목", example = "아침 운동") - String title, - - @NotNull(message = "카테고리는 비어있을 수 없습니다.") - @Schema(description = "루틴 카테고리", example = "COMPUTER") - PlanCategory category, - - @Schema(description = "루틴 색상 (색상 없으면 null)", example = "#FF5733") - @Pattern(regexp = HEX_COLOR_CODE_REGEX, message = "색상은 유효한 Hex code 여야 합니다.") - String color, - - @JsonDeserialize(using = LocalTimeDeserializer.class) // 커스텀 직렬화 - @JsonFormat(pattern = "HH:mm") - @Schema(description = "루틴 시간 (종일 루틴이면 null)", example = "07:00") - LocalTime time, - - @NotNull(message = "공개 여부는 null 일 수 없습니다.") - @Schema(description = "공개 여부", example = "true") - Boolean isPublic, - - @JsonDeserialize(using = DayOfWeekListDeserializer.class) // 커스텀 리스트 직렬화 - @NotNull(message = "반복 요일은 null 일 수 없습니다.") - @NotEmpty(message = "반복 요일은 최소 하나 이상 선택되어야 합니다.") - @Schema(description = "반복 요일", example = "[\"MONDAY\",\"TUESDAY\"]") - List daysOfWeek, - - @PositiveOrZero(message = "분은 양수여야 합니다.") - @Schema(description = "알림 시간 (분 단위, null 이면 알림을 보내지 않음)", example = "30") - Integer reminderMinutes, - - @Schema(description = "메모", example = "30분 동안 조깅하기") - @Size(max = 40, message = "메모는 40자를 넘을 수 없습니다.") - String memo -) { -} -``` - -### 2. 응답 DTO에서 Builder 패턴 - -복잡한 응답 DTO에서는 Builder 패턴을 사용합니다: - -```java - -@Schema(description = "루틴 상세조회 응답 DTO") -@Builder -public record RoutineDetailResponse( - @Schema(description = "루틴 Id", example = "1") - Long routineId, - - @Schema(description = "루틴 카테고리", example = "PENCIL") - PlanCategory category, - - @JsonSerialize(using = LocalTimeSerializer.class) // 커스텀 직렬화 - @JsonFormat(pattern = "HH:mm") - @Schema(description = "루틴 시간(null 이면 종일 루틴)", example = "14:30") - LocalTime time, - - @Schema(description = "반복 요일 목록") - List daysOfWeek -) { -} -``` - -## 매퍼 작성 규칙 - -### 1. 정적 유틸리티 클래스 패턴 - -매퍼는 **정적 유틸리티 클래스**로 작성하고 외부 인스턴스화를 방지합니다: - -```java - -@NoArgsConstructor(access = AccessLevel.PRIVATE) // 인스턴스화 방지 -public final class RoutineMapper { - private static final boolean INCOMPLETE_STATUS = false; // 상수 정의 - - public static Routine toRoutine(final User user, final RoutineCreateRequest request) { - // 값 객체 생성 - DaysOfWeekBitmask daysOfWeekBitmask = DaysOfWeekBitmask.createByDayOfWeek(request.daysOfWeek()); - PlanCategoryColor planCategoryColor = PlanCategoryColor.from(request.color()); - RoutineMemo routineMemo = RoutineMemo.from(request.memo()); - - return Routine.builder() - .user(user) - .category(request.category()) - .color(planCategoryColor) - .title(request.title()) - .memo(routineMemo) - .isPublic(request.isPublic()) - .reminderMinutes(request.reminderMinutes()) - .time(request.time()) - .daysOfWeekBitmask(daysOfWeekBitmask) - .build(); - } - - public static RoutineCreateResponse toRoutineCreateResponse(final Routine routine) { - return RoutineCreateResponse.builder() - .routineId(routine.getId()) - .build(); - } -} -``` - -### 2. Stream API 활용 패턴 - -복잡한 데이터 변환 시 **Stream API**를 적극 활용합니다: - -```java -public static MyRoutineRecordReadListResponse toMyRoutineRecordReadListResponse( - final DailyRoutineData dailyRoutineData -) { - // 미완료 루틴 응답 생성 - List routineResponses = dailyRoutineData.routines() - .stream() - .map(routine -> toMyRoutineRecordReadResponse(routine, INCOMPLETE_STATUS)) - .toList(); - - // 기록된 루틴 응답 생성 - List recordResponses = dailyRoutineData.routineRecords() - .stream() - .map(record -> toMyRoutineRecordReadResponse(record.getRoutine(), record.getIsCompleted())) - .toList(); - - // 두 스트림 합치기 - List combinedResponses = - Stream.concat(routineResponses.stream(), recordResponses.stream()) - .toList(); - - return MyRoutineRecordReadListResponse.builder() - .queryDate(dailyRoutineData.date()) - .routines(combinedResponses) - .build(); -} -``` - -## 커스텀 어노테이션 사용법 - -### 1. UseCase 어노테이션 - -비즈니스 계층을 명확히 표시하는 커스텀 어노테이션: - -```java - -@Target({ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -@Component // Spring Bean 등록 -public @interface UseCase { -} -``` - -사용법: - -```java - -@UseCase // 유스케이스 계층임을 명시 -@RequiredArgsConstructor -public class RoutineUseCase { - // ... -} -``` - -### 2. API 문서화 어노테이션 - -API 응답을 체계적으로 문서화하는 커스텀 어노테이션: - -```java - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface ApiResponseExplanations { - ApiSuccessResponseExplanation success() default @ApiSuccessResponseExplanation(); - - ApiErrorResponseExplanation[] errors() default {}; -} - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface ApiSuccessResponseExplanation { - HttpStatus status() default HttpStatus.OK; - - Class responseClass() default EmptyClass.class; - - String description() default ""; -} - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface ApiErrorResponseExplanation { - ExceptionCode exceptionCode(); -} -``` - -## 메서드 파라미터 및 코딩 컨벤션 - -### 1. 메서드 파라미터 규칙 - -- **모든 메서드 파라미터는 final 키워드 사용** -- **명확한 의미를 가진 파라미터명 사용** -- **null 체크가 필요한 경우 Optional 사용** - -```java -// ✅ 올바른 패턴 -public void updateRoutine(final Long userId, final Long routineId, final RoutineUpdateRequest request) { - // ... -} - -public Optional getUserRoutine(final User user, final Long id) { - // ... -} - -// ❌ 잘못된 패턴 -public void updateRoutine(Long userId, Long routineId, RoutineUpdateRequest request) { - // ... -} -``` - -### 2. 메서드 명명 규칙 - -- **동사 + 명사** 조합으로 명확한 의도 표현 -- **비즈니스 용어** 사용 -- **boolean 반환 메서드는 is/can/has 접두사** 사용 - -```java -// ✅ 올바른 메서드명 -public RoutineCreateResponse createRoutine(...) - -public boolean canCreateRecordForDate(...) - -public boolean isActiveForDate(...) - -public boolean isOwner(...) - -public void deleteIncompletedFuturesByRoutine(...) - -// ❌ 잘못된 메서드명 -public RoutineCreateResponse create(...) // 너무 일반적 - -public boolean checkDate(...) // 불명확 - -public void remove(...) // 비즈니스 의미 부족 -``` - -### 3. 클래스 및 패키지 명명 규칙 - -- **도메인 용어를 중심**으로 명명 -- **계층별 일관된 접미사** 사용 -- **패키지는 기능별** 구성 - -```java -// ✅ 올바른 명명 -RoutineUseCase // 유스케이스 계층 - RoutineService // 서비스 계층 -RoutineRepository // 레포지토리 계층 - RoutineCreateRequest // 요청 DTO -RoutineDetailResponse // 응답 DTO - RoutineMapper // 매퍼 클래스 -PlanCategoryColor // 값 객체 -``` - -## 예외 처리 규칙 - -### 1. 비즈니스 예외는 ExceptionCode 사용 - -시스템 전반에서 **일관된 예외 처리**를 위해 ExceptionCode를 사용합니다: - -```java -// 표준 예외 처리 패턴 -User user = userService.getUserById(userId) - .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); - -Routine routine = routineService.getUserRoutine(user, routineId) - .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ROUTINE)); - -// 비즈니스 규칙 위반 시 -if(startDate. - -isAfter(endDate)){ - throw CommonException. - -from(ExceptionCode.EXCEED_ROUTINE_DATE_RANGE); -} -``` - -### 2. 값 객체 검증 예외 - -값 객체의 검증 예외는 `VoException`을 사용합니다: - -```java -private void validate(final String value) { - if (value != null && value.length() > MAX_LENGTH) { - throw new VoException("메모는 " + MAX_LENGTH + "자를 초과할 수 없습니다."); - } -} -``` - -## 도메인 이벤트 작성 규칙 - -### 1. 이벤트 클래스 (불변 객체) - -이벤트를 사용하는것이 유지보수나 가독성 측면에서 효과가 있다고 판단되는 경우에만 선택적으로 사용하세요 -도메인 이벤트는 **불변 객체**로 작성하고 **final 필드**를 사용합니다: - -```java - -@Getter -public class RoutineCreatedEvent { - private final Long routineId; - private final Long userId; - private final LocalDateTime occurredAt; - - public RoutineCreatedEvent(final Long routineId, final Long userId) { - this.routineId = routineId; - this.userId = userId; - this.occurredAt = LocalDateTime.now(); // 이벤트 발생 시점 기록 - } -} - -// 더 복잡한 이벤트 예제 -@Getter -public class RoutineUpdatedEvent { - private final Long routineId; - private final Long userId; - private final boolean isTimeChanged; - private final boolean isDaysOfWeekChanged; - private final boolean isReminderMinutesChanged; - private final boolean isTitleChanged; - private final LocalDateTime occurredAt; - - public RoutineUpdatedEvent(final Long routineId, final Long userId, - final boolean isTimeChanged, final boolean isDaysOfWeekChanged, - final boolean isReminderMinutesChanged, final boolean isTitleChanged) { - this.routineId = routineId; - this.userId = userId; - this.isTimeChanged = isTimeChanged; - this.isDaysOfWeekChanged = isDaysOfWeekChanged; - this.isReminderMinutesChanged = isReminderMinutesChanged; - this.isTitleChanged = isTitleChanged; - this.occurredAt = LocalDateTime.now(); - } -} -``` - -### 2. 이벤트 발행 패턴 - -유스케이스에서 `ApplicationEventPublisher`를 사용하여 이벤트를 발행합니다: - -```java - -@Transactional -public void updateRoutine(final Long userId, final Long routineId, final RoutineUpdateRequest request) { - // ... 비즈니스 로직 실행 - - routineService.updateFields(routine, request); - - log.info("루틴 수정 성공 - 사용자 Id: {}, 루틴 Id: {}", userId, routineId); - - // 도메인 이벤트 발행 (트랜잭션 커밋 후 비동기 처리) - eventPublisher.publishEvent(new RoutineUpdatedEvent( - routineId, - userId, - request.isTimeChanged(), - request.isDaysOfWeekChanged(), - request.isReminderMinutesChanged(), - request.isTitleChanged() - )); -} -``` - -### 3. 이벤트 리스너 패턴 - -이벤트 리스너는 `@EventListener`와 `@Async`를 사용합니다: - -```java - -@Component -@RequiredArgsConstructor -@Slf4j -public class RoutineReminderEventListener { - - private final RoutineReminderSchedulerService schedulerService; - - @EventListener - @Async // 비동기 처리 - @Transactional // 별도 트랜잭션 - public void handleRoutineCreated(final RoutineCreatedEvent event) { - try { - schedulerService.scheduleReminder(event.getRoutineId()); - log.info("루틴 알림 스케줄링 성공 - RoutineId: {}", event.getRoutineId()); - } catch (Exception e) { - log.error("루틴 알림 스케줄링 실패 - RoutineId: {}", event.getRoutineId(), e); - } - } - - @EventListener - @Async - @Transactional - public void handleRoutineUpdated(final RoutineUpdatedEvent event) { - // 알림 설정이 변경된 경우에만 처리 - if (event.isTimeChanged() || event.isDaysOfWeekChanged() || event.isReminderMinutesChanged()) { - try { - schedulerService.rescheduleReminder(event.getRoutineId()); - log.info("루틴 알림 재스케줄링 성공 - RoutineId: {}", event.getRoutineId()); - } catch (Exception e) { - log.error("루틴 알림 재스케줄링 실패 - RoutineId: {}", event.getRoutineId(), e); - } - } - } -} -``` - -## 로깅 규칙 - -### 1. 구조화된 로깅 패턴 - -주요 비즈니스 이벤트는 **구조화된 형태**로 로깅합니다: - -```java -// ✅ 올바른 로깅 패턴 (구조화된 정보) -log.info("루틴 생성 - UserId: {}, RoutineId: {}",userId, routineCreateResponse.routineId()); - log. - -info("루틴 상태 변경 성공(기록 수정) - 사용자 Id: {}, 루틴 Id: {}, 루틴 날짜: {}, 완료상태: {}", - userId, routineId, date, isCompleted); -log. - -info("본인 루틴 기록 기간 조회 - UserId: {}, 조회 기간: {} ~ {}",userId, startDate, endDate); - -// ❌ 잘못된 로깅 패턴 -log. - -info("루틴을 생성했습니다."); // 정보 부족 -log. - -info("루틴 생성 완료: "+userId +", "+routineId); // 문자열 연결 -``` - -### 2. 로그 레벨별 사용 기준 - -- **INFO**: 주요 비즈니스 이벤트, 성공 케이스 -- **WARN**: 권한 부족, 비정상적인 접근 시도 -- **ERROR**: 예외 발생, 시스템 오류 - -```java -// INFO - 정상적인 비즈니스 플로우 -log.info("루틴 생성 - UserId: {}, RoutineId: {}",userId, routine.getId()); - - // WARN - 보안 관련, 권한 위반 - log. - -warn("권한이 없는 유저가 일기 수정 시도 - UserId: {}, DiaryId: {}",user.getId(),diary. - -getId()); - - // ERROR - 예외 상황, 시스템 오류 - log. - -error("루틴 알림 스케줄링 실패 - RoutineId: {}",routine.getId(),e); -``` - -## 성능 최적화 규칙 - -### 1. 연관관계 최적화 - -**지연 로딩을 기본으로 사용**하고, 필요한 경우에만 fetch join 사용: - -```java -// 기본 설정 - 지연 로딩 -@ManyToOne(fetch = FetchType.LAZY) -@JoinColumn(name = "user_id", nullable = false) -private User user; - -// QueryDSL에서 필요한 경우 fetch join -public List findAllByUserAndRecordAtDate(final User user, final LocalDate date) { - return queryFactory - .selectFrom(qRecord) - .join(qRecord.routine, qRoutine).fetchJoin() // 명시적 fetch join - .where( - qRoutine.user.eq(user), - recordAtBetween(date) - ) - .fetch(); -} -``` - -### 2. 배치 연산 활용 - -여러 데이터를 처리할 때는 **배치 연산**을 사용합니다: - -```java -// 일괄 저장 -public void saveAll(final List newRecords) { - if (!newRecords.isEmpty()) { - routineRecordRepository.saveAll(newRecords); - } -} - -// 일괄 삭제 (QueryDSL) -@Override -public void deleteIncompletedFuturesByRoutine(final Routine routine, final LocalDateTime targetDateTime) { - queryFactory - .delete(qRecord) - .where( - qRecord.routine.eq(routine), - qRecord.recordAt.after(targetDateTime), - qRecord.isCompleted.isFalse(), - qRecord.deletedAt.isNull() - ) - .execute(); -} -``` - -### 3. 원자적 연산 (동시성 제어) - -카운터 등의 업데이트는 **원자적 연산**을 사용합니다: - -```java - -@Modifying(clearAutomatically = true) // 영속성 컨텍스트 자동 클리어 -@Query("UPDATE Routine r SET r.sharedCount = r.sharedCount + 1 WHERE r.id = :id") -void incrementSharedCountAtomically(@Param("id") final Long id); - -// 사용 예제 -public void shareRoutine(final Long routineId) { - routineRepository.incrementSharedCountAtomically(routineId); -} -``` - -### 4. 커서 기반 페이징 - -대용량 데이터 조회 시 **커서 기반 페이징**을 사용합니다: - -```java -private BooleanExpression cursorCondition(final Long cursor) { - if (cursor == null) { - return null; - } - return qSocial.id.lt(cursor); // ID 기준 커서 -} - -private JPAQuery applyPagination(final JPAQuery query, final Pageable pageable) { - return query - .orderBy(qSocial.id.desc()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()); -} -``` - -이 가이드를 따라 새로운 도메인을 개발하면 **코드 품질과 패턴 일관성**을 유지할 수 있습니다. 특히 QueryDSL을 활용한 복잡한 쿼리 처리, 커스텀 어노테이션을 통한 API -문서화, final 키워드를 통한 불변성 보장, 그리고 철저한 비즈니스 로직 검증을 통해 견고하고 확장 가능한 시스템을 구축할 수 있습니다. +프로젝트 루트의 AGENTS.md를 따를 것. +상세 개발 컨벤션: .agent-docs/conventions.md +테스트 컨벤션: .agent-docs/test-conventions.md diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md new file mode 100644 index 00000000..c1836c88 --- /dev/null +++ b/.claude/agents/security-reviewer.md @@ -0,0 +1,43 @@ +--- +name: security-reviewer +description: Spring Security 및 데이터 보호 관점의 보안 리뷰 에이전트 +--- + +# Security Reviewer + +프로젝트 컨벤션은 `AGENTS.md` (프로젝트 루트)를 참조할 것. + +## 역할 +변경된 코드에 대해 Spring 특화 보안 체크리스트를 검토합니다. + +## 체크리스트 + +### 1. SQL Injection +- `@Query` 사용 시 반드시 파라미터 바인딩(`:paramName`) 사용 여부 확인 +- 문자열 연결로 쿼리를 구성하는 코드가 없는지 확인 +- QueryDSL 사용 시 `Expressions.stringTemplate` 등에서 사용자 입력이 직접 삽입되지 않는지 확인 + +### 2. 인증/인가 누락 +- 모든 컨트롤러 엔드포인트에 `@PreAuthorize("isAuthenticated()")` 또는 적절한 권한 체크가 있는지 확인 +- 공개 API가 의도적인지 확인 + +### 3. Mass Assignment +- 요청 DTO에서 엔티티로 직접 바인딩하는 코드가 없는지 확인 +- 반드시 Mapper를 통해 변환해야 함 + +### 4. IDOR (Insecure Direct Object Reference) +- 리소스 접근 시 소유권 검증(`isOwner()`) 또는 `findByIdAndUser()` 패턴 사용 여부 확인 +- 다른 사용자의 리소스에 접근 가능한 경로가 없는지 확인 + +### 5. 민감 정보 로깅 +- 비밀번호, 토큰, 개인정보가 로그에 출력되지 않는지 확인 +- `log.info/debug/error`에 민감 데이터가 포함되지 않는지 확인 + +### 6. 소프트 삭제 누수 (상황 및 비즈니스 의사결정에 따라 다를 수 있음) +- 소프트 삭제를 사용하는 엔티티에 `@SQLRestriction("deleted_at is NULL")`이 적용되어 있는지 확인 +- 직접 쿼리 작성 시 `deletedAt IS NULL` 조건이 누락되지 않았는지 확인 + +## 실행 방법 +1. 변경된 Java 파일 목록을 확인 (`git diff --name-only`) +2. 각 파일을 읽고 위 체크리스트 항목별로 검토 +3. 발견된 문제를 심각도(Critical/Warning/Info)와 함께 보고 diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md new file mode 100644 index 00000000..9df6a186 --- /dev/null +++ b/.claude/agents/test-writer.md @@ -0,0 +1,34 @@ +--- +name: test-writer +description: 프로젝트 테스트 컨벤션에 맞는 테스트 코드 작성 에이전트 +--- + +# Test Writer + +프로젝트 컨벤션은 `AGENTS.md` (프로젝트 루트)를 참조할 것. +테스트 컨벤션은 `.agent-docs/test-conventions.md`를 반드시 읽고 따를 것. + +## 역할 +프로젝트 테스트 컨벤션에 맞는 테스트 코드를 작성합니다. + +## 베이스 클래스 선택 +- UseCase 테스트 → `extends UseCaseTest` +- Service 테스트 → `extends ServiceTest` +- Repository 테스트 → `extends RepositoryTest` + +## 필수 규칙 +1. 한글 `@DisplayName` 사용 +2. `@Nested`로 성공/실패 케이스 그루핑 +3. Given-When-Then 주석 구조 +4. `assertSoftly` 사용 (여러 검증 시) +5. Mockito BDD 스타일: `given().willReturn()` +6. 테스트 픽스처는 `testFixtureBuilder` + `*Fixtures` 클래스 사용 +7. 예외 테스트: `assertThatThrownBy` + `hasFieldOrPropertyWithValue` +8. 변수명은 대문자 상수 스타일 (`USER`, `ROUTINE`) + +## 워크플로우 +1. 대상 프로덕션 코드를 읽고 테스트 대상 메서드 파악 +2. 기존 Fixture 클래스 확인 (`src/test/java/im/toduck/fixtures/`) +3. 필요하면 새 Fixture 추가 +4. 테스트 작성 +5. `./gradlew test` 실행하여 통과 확인 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..d369aedb --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.file_path // empty' | { read file; if echo \"$file\" | grep -qE '\\.java$'; then ./gradlew checkstyleMain -q 2>&1 | tail -20; fi; }" + } + ] + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b12e68f8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# toduck-backend + +ADHD 자기관리 앱 toduck의 백엔드 서버. +Java 17 · Spring Boot 3.3.1 · Gradle · 단일 모듈 + +## Commands +- Build: `./gradlew build` +- Test: `./gradlew test` +- Code style check: `./gradlew checkstyleMain` (Naver Checkstyle) + +## Architecture +도메인별 Clean Architecture: `domain/{name}/` → common, domain(service/usecase), persistence(entity/repository), presentation(controller/dto) + +의존 방향: Controller → UseCase → Service → Repository (단방향) + +## Code Style +Checkstyle(Naver rules)과 EditorConfig로 자동 강제됨. +에이전트가 코드 스타일을 수동으로 관리할 필요 없음. + +## Core Rules +- 모든 메서드 파라미터에 `final` 키워드 필수 (Builder 생성자 파라미터는 Lombok이 생성하므로 예외) +- 엔티티는 `BaseEntity` 상속, `@Builder` + private 생성자 +- Service는 자기 도메인 Repository만 의존. 교차 도메인은 UseCase에서 조합 +- UseCase 클래스는 `@UseCase` 어노테이션 사용 (`@Service` 아님) +- API 응답: `ApiResponse.createSuccess(data)` 또는 `ApiResponse.createSuccessWithNoContent()` + - `ApiResponse.onSuccess()`는 존재하지 않음 — 절대 사용 금지 +- 예외: `CommonException.from(ExceptionCode.XXX)`, VO 검증은 `VoException` +- DTO는 Java record + Bean Validation +- 조회: `@Transactional(readOnly = true)`, 수정: `@Transactional` +- 이벤트 리스너: `@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)` (`@EventListener` 아님) + +## Testing +- 베이스 클래스: `UseCaseTest`, `ServiceTest`, `RepositoryTest` +- 한글 `@DisplayName`, Given-When-Then, AssertJ +- 상세: `.agent-docs/test-conventions.md` + +## Detailed Conventions +새 도메인 개발 시 반드시 참조: `.agent-docs/conventions.md` +도메인 모델: `docs/도메인모델.md` · 용어집: `docs/용어.md` + +## Code Navigation +- 코드 탐색 시 LSP를 우선 사용할 것. grep/ripgrep보다 정확한 타입 정보, 참조 추적, 컴파일 에러 감지 가능. +- IDE 내장 LSP 서버가 가용하면 우선 활용. + +## Git +- 커밋 메시지: commitlint 규칙 준수 (commitlint.config.js) + - 타입: feat, fix, docs, style, refactor, test, chore + - 스코프 사용 안 함, 헤더 100자 제한 +- PR: 수동 리뷰 프로세스 (템플릿 없음)