---
name: spring-boot-codegen
description: |
  트리거: "spring boot 코드 생성", "entity 만들어줘", "controller 생성", "jpa 레이어 생성", "spring 계층 구조 만들어줘"
  수행: Java Spring Boot 프로젝트의 Entity·Repository·Service·Controller 계층을 일괄 생성한다.
  도메인형 패키지 구조, CustomException 예외 처리, CQS 원칙, BaseTimeEntity, ApiResponse<T> 공통 응답,
  도메인 모델 패턴(팩토리 메서드), 메서드 단위 @Transactional, Bean Validation을 자동 적용한다.
  출력: 패키지 경로가 포함된 각 계층별 .java 파일 코드 블록 + 생성 체크리스트.
---

# Spring Boot 계층 코드 생성기

## 목적

도메인 이름과 필드 목록만 입력하면 Spring Boot 표준 계층 전체를 즉시 생성한다.
반복적인 보일러플레이트 작성을 제거하고 아래 개발 철학을 일관되게 적용한다.

---

## 핵심 개발 철학 (생성 시 반드시 준수)

### 1. 주석 원칙
- **쓸모없는 주석 작성 금지** — 코드 자체가 설명되어야 한다.
- `// #NOTE`: 코드 의도, 약어 설명 등 정말 필요한 메모에만 사용.
- `// #TODO`, `// #FIXME`, `// #REFACTOR`: 미완성·수정 필요 지점에 적극 사용.

### 2. 패키지 구조 (도메인형)
```
src/main/java/{basePackage}/
├── config/
├── security/
├── common/
│   ├── entity/           ← 모든 엔티티는 여기에 (JPA 순환 참조 방지, 검색 용이)
│   │   └── {Entity}.java
│   ├── dto/
│   │   ├── ApiResponse.java
│   │   └── ExceptionRes.java
│   ├── exceptions/
│   │   ├── CustomException.java  (abstract)
│   │   └── ...공통 예외 클래스들
│   ├── utils/
│   ├── enums/
│   ├── validator/
│   └── manager/
└── domain/
    └── {domain}/          ← 도메인마다 1 Depth 평탄 구조 (중첩 금지)
        ├── {Domain}Controller.java
        ├── {Domain}Service.java
        ├── {Domain}Mapper.java    (MapStruct 또는 수동 매퍼)
        ├── {Domain}Repository.java
        ├── dto/
        │   ├── req/
        │   │   └── {Domain}CreateReq.java
        │   └── res/
        │       └── {Domain}Res.java
        └── exceptions/    ← 도메인 특화 예외만 여기에
```

**규칙:**
- 도메인 하위에 하위 도메인 폴더 중첩 **금지** (`domain/user/authority` ❌ → `domain/user_authority` ✅)
- 모든 도메인은 1 Depth 평탄 나열

### 3. 특수 계층 추가 기준
- **Facade 패턴**: 거대한 모노리스 또는 MSA에서 Service↔Controller 결합도를 낮춰야 할 때만 추가
- **Assembler 패턴**: Service에서 조립하는 객체의 비즈니스 로직이 복잡해 가독성이 떨어질 때만 추가

---

## 실행 절차

1. **입력 파악**: 도메인 이름, 필드 목록(이름·타입·제약), 기본 패키지명, DB 종류(PostgreSQL 여부), 논리삭제 필요 여부 확인
2. **패키지 구조 결정**: 위 도메인형 구조에 맞게 매핑
3. **공통 기반 클래스 생성**: `BaseTimeEntity`, `LogicalDeletedEntity`, `CustomException`, `ApiResponse`, `ExceptionRes`
4. **Entity 생성**: `common/entity/`에 위치, 도메인 모델 패턴 적용
5. **Repository 생성**: JPA 기본, 필요 시 QueryDSL Impl, 복잡 시 MyBatis Mapper
6. **Service 생성**: CQS 원칙, 메서드 단위 `@Transactional`, CustomException 사용
7. **DTO 생성**: `@@Req`/`@@Res` 네이밍, 팩토리 메서드 패턴, Bean Validation
8. **Controller 생성**: 순수 라우팅+검증, `ApiResponse<T>` 응답 래핑, `@ResponseStatus` 우선
9. **체크리스트 출력**

---

## 계층별 상세 규칙

### Entity 계층

| 규칙 | 내용 |
|------|------|
| 클래스명 | `User` 대신 **`Member`** 사용 (SQL/Java 예약어 충돌 방지) |
| 위치 | `common/entity/` |
| 시간 필드 | `BaseTimeEntity` extends (createdAt, updatedAt) |
| 논리삭제 | `LogicalDeletedEntity` extends (deletedAt), 필요 시만 사용 |
| 감사 필드 | `BaseAuditEntity` extends (createdBy, updatedBy, deletedBy), 필요 시만 사용 |
| DB | PostgreSQL이면 `@Column(columnDefinition = "timestamptz")` 적용 |
| Lombok | `@Getter` 클래스 단위, `@Setter` 정말 필요한 필드만 |
| 생성자 | `@NoArgsConstructor(access = AccessLevel.PROTECTED)` 기본 강제 |
| Builder | 클래스 레벨 `@Builder` **금지** → 직접 작성한 생성자/정적 팩토리 메서드 위에 `@Builder` |
| 컬렉션 | `List<X> list = new ArrayList<>()` 즉시 초기화 (NPE 방지) |
| ToString | `@ToString` 사용 시 연관관계 필드 반드시 `exclude` |
| 논리삭제 자동화 | `@SQLDelete` + `@SQLRestriction` 적용 |
| 객체 생성 | 엔티티 내부에 정적 팩토리 메서드(`of()`, `create()`) 작성 |

**`BaseTimeEntity` 예시:**
```java
// 파일: common/entity/BaseTimeEntity.java
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate
    @Column(updatable = false, columnDefinition = "timestamptz")
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(columnDefinition = "timestamptz")
    private LocalDateTime updatedAt;
}
```

**`LogicalDeletedEntity` 예시:**
```java
// 파일: common/entity/LogicalDeletedEntity.java
@Getter
@MappedSuperclass
public abstract class LogicalDeletedEntity extends BaseTimeEntity {

    @Column(columnDefinition = "timestamptz")
    private LocalDateTime deletedAt;
}
```

**Entity 예시:**
```java
// 파일: common/entity/Member.java
@Entity
@Table(name = "member")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(exclude = {"orders"})  // #NOTE: 연관관계 필드 exclude 필수 (무한순환 방지)
@SQLDelete(sql = "UPDATE member SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Member extends LogicalDeletedEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String username;

    @Column(nullable = false, unique = true, length = 100)
    private String email;

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private MemberRole role;

    @OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
    private List<Order> orders = new ArrayList<>();

    @Builder
    private Member(String username, String email, String password, MemberRole role) {
        this.username = username;
        this.email = email;
        this.password = password;
        this.role = role;
    }

    public static Member create(String username, String email, String encodedPassword) {
        return Member.builder()
                .username(username)
                .email(email)
                .password(encodedPassword)
                .role(MemberRole.USER)
                .build();
    }

    // Domain Model 패턴: 도메인 로직은 엔티티 내부에
    public void updateProfile(String username) {
        this.username = username;
    }
}
```

---

### 예외 처리 계층

**절대 금지:**
- `ErrorCode` Enum 기반 중앙 집중형 에러 코드 관리 ❌
- Controller/Service에서 try-catch로 비즈니스 예외를 직접 처리 ❌

**필수 패턴:**

```java
// 파일: common/exceptions/CustomException.java
public abstract class CustomException extends RuntimeException {
    public abstract String getMessage();
    public abstract HttpStatus getHttpStatus();
}
```

```java
// 파일: domain/member/exceptions/MemberNotFoundException.java
public class MemberNotFoundException extends CustomException {

    @Override
    public String getMessage() {
        return "회원을 찾을 수 없습니다.";
    }

    @Override
    public HttpStatus getHttpStatus() {
        return HttpStatus.NOT_FOUND;
    }
}
```

```java
// 파일: common/dto/ExceptionRes.java
@Getter
@Builder
public class ExceptionRes {
    private final String message;
    private final int status;

    public static ExceptionRes of(CustomException e) {
        return ExceptionRes.builder()
                .message(e.getMessage())
                .status(e.getHttpStatus().value())
                .build();
    }
}
```

**전역 예외 핸들러:**
```java
// 파일: common/exceptions/ExceptionControllerAdvice.java
@Slf4j
@RestControllerAdvice
public class ExceptionControllerAdvice {

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ExceptionRes> handleCustomException(CustomException e) {
        log.error("CustomException: {}", e.getMessage());
        Sentry.captureException(e);
        return ResponseEntity.status(e.getHttpStatus()).body(ExceptionRes.of(e));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidationException(MethodArgumentNotValidException e) {
        // #NOTE: 검증 에러는 ExceptionRes 대신 필드별 에러 메시지 맵으로 반환
        return e.getBindingResult().getFieldErrors().stream()
                .collect(Collectors.toMap(
                        FieldError::getField,
                        fe -> Objects.requireNonNullElse(fe.getDefaultMessage(), "유효하지 않은 값입니다.")
                ));
    }

    @ExceptionHandler(NoResourceFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ExceptionRes handleNoResourceFound(NoResourceFoundException e) {
        return ExceptionRes.builder().message("요청한 경로를 찾을 수 없습니다.").status(404).build();
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ExceptionRes handleException(Exception e) {
        log.error("Unhandled Exception", e);
        Sentry.captureException(e);
        return ExceptionRes.builder().message("서버 오류가 발생했습니다.").status(500).build();
    }
}
```

**비즈니스 로직 예외 처리 원칙:**
```java
// ✅ 올바른 패턴: 조건 체크 후 throw
Member member = memberRepository.findById(id)
        .orElseThrow(MemberNotFoundException::new);

// ❌ 금지 패턴: try-catch로 직접 처리
try {
    member = memberRepository.findById(id).get();
} catch (NoSuchElementException e) {
    return ApiResponse.error("찾을 수 없음");
}
```

---

### Repository 계층

| 복잡도 | 사용 기술 |
|--------|----------|
| 단순 CRUD | JPA (`JpaRepository`) |
| 약간의 조건 로직 | JPQL (`@Query`) |
| 복잡한 비즈니스 쿼리 | QueryDSL + `{Domain}RepositoryImpl` |
| 매우 복잡한 쿼리 | MyBatis + `{Domain}Mapper.xml` |

**네이밍 규칙 (재사용성 중시):**
```java
// 파일: domain/member/MemberRepository.java
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByEmail(String email);        // 재사용 가능한 범용 쿼리
    Optional<Member> findByUsername(String username);
    boolean existsByEmail(String email);
    boolean existsByUsername(String username);
    List<Member> findAllByRole(MemberRole role);
}
```

**QueryDSL Impl 패턴 (복잡한 조회):**
```java
// 파일: domain/member/MemberRepositoryImpl.java
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<Member> findActiveMembersWithOrders(MemberSearchCondition condition) {
        return queryFactory
                .selectFrom(member)
                .leftJoin(member.orders, order).fetchJoin()
                .where(
                        usernameContains(condition.getUsername()),
                        roleEq(condition.getRole())
                )
                .fetch();
    }

    private BooleanExpression usernameContains(String username) {
        return StringUtils.hasText(username) ? member.username.contains(username) : null;
    }

    private BooleanExpression roleEq(MemberRole role) {
        return role != null ? member.role.eq(role) : null;
    }
}
```

---

### Service 계층

**핵심 원칙:**
- `@Transactional` 클래스 단위 금지 → **메서드 단위** 적용
- 조회: `@Transactional(readOnly = true)`, 변경: `@Transactional`
- Setter 사용 금지 → Domain Model 패턴(엔티티 내부 메서드) 사용
- 객체 조립은 DTO 내부 팩토리 메서드(`from()`, `of()`, `fromEntity()`)에게 책임 위임
- **CQS 원칙**: 상태 변경 메서드는 도메인 데이터 반환 금지 (void 또는 ID만)
- 외부 API 호출은 `WebClient` 사용
- 다른 서비스 메서드 호출 최대한 지양 (순환참조 위험)

**네이밍 컨벤션:**
- `find{Domain}s()` → 목록 조회
- `get{Domain}()` → 단건 조회
- `create{Domain}()` → 생성 (void 또는 ID 반환)
- `update{Domain}()` → 수정 (void)
- `delete{Domain}()` → 삭제 (void)

```java
// 파일: domain/member/MemberService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional(readOnly = true)
    public List<MemberRes> findMembers() {
        return memberRepository.findAll().stream()
                .map(MemberRes::from)
                .toList();
    }

    @Transactional(readOnly = true)
    public MemberRes getMember(Long id) {
        Member member = memberRepository.findById(id)
                .orElseThrow(MemberNotFoundException::new);
        return MemberRes.from(member);
    }

    @Transactional
    public Long createMember(MemberCreateReq req) {
        if (memberRepository.existsByEmail(req.getEmail())) {
            throw new MemberEmailDuplicateException();
        }
        Member member = Member.create(req.getUsername(), req.getEmail(),
                passwordEncoder.encode(req.getPassword()));
        return memberRepository.save(member).getId();
        // #NOTE: CQS 원칙 - ID만 반환, 생성된 데이터가 필요하면 프론트에서 GET 호출
    }

    @Transactional
    public void updateMember(Long id, MemberUpdateReq req) {
        Member member = memberRepository.findById(id)
                .orElseThrow(MemberNotFoundException::new);
        member.updateProfile(req.getUsername());
    }

    @Transactional
    public void deleteMember(Long id) {
        Member member = memberRepository.findById(id)
                .orElseThrow(MemberNotFoundException::new);
        memberRepository.delete(member);
        // #NOTE: @SQLDelete 어노테이션으로 물리삭제 대신 deleted_at 자동 세팅됨
    }

    // Stream 람다 3줄 이상 시 private 메서드로 추출
    private MemberRes toRes(Member member) {
        return MemberRes.from(member);
    }
}
```

---

### DTO 계층

**네이밍 컨벤션:**
- `{Domain}CreateReq`, `{Domain}UpdateReq` → Request DTO
- `{Domain}Res` → Response DTO
- `{Domain}Data` → 복잡한 객체 조립용 내부 객체
- `{Domain}Detail` → 다른 테이블 정보까지 포함한 확장 객체

**DTO 위치:**
- `domain/{domain}/dto/req/` — Request 객체
- `domain/{domain}/dto/res/` — Response 객체

```java
// 파일: domain/member/dto/req/MemberCreateReq.java
@Getter
@NoArgsConstructor
public class MemberCreateReq {

    @Schema(description = "사용자명", example = "홍길동")
    @NotBlank(message = "사용자명은 필수입니다.")
    @Size(min = 2, max = 20, message = "사용자명은 2~20자여야 합니다.")
    private String username;

    @Schema(description = "이메일", example = "hong@example.com")
    @NotBlank(message = "이메일은 필수입니다.")
    @Email(message = "이메일 형식이 올바르지 않습니다.")
    private String email;

    @Schema(description = "비밀번호 (영문+숫자+특수문자 8자 이상)")
    @NotBlank(message = "비밀번호는 필수입니다.")
    @Size(min = 8, message = "비밀번호는 최소 8자여야 합니다.")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&]).{8,}$",
             message = "비밀번호는 영문, 숫자, 특수문자를 포함해야 합니다.")
    private String password;
}
```

```java
// 파일: domain/member/dto/res/MemberRes.java
@Getter
@Builder
public class MemberRes {

    @Schema(description = "회원 ID")
    private Long id;

    @Schema(description = "사용자명")
    private String username;

    @Schema(description = "이메일")
    private String email;

    @Schema(description = "권한")
    private MemberRole role;

    @Schema(description = "가입일시")
    private LocalDateTime createdAt;

    // 팩토리 메서드 패턴: 객체 조립 책임을 DTO에게 위임
    public static MemberRes from(Member member) {
        return MemberRes.builder()
                .id(member.getId())
                .username(member.getUsername())
                .email(member.getEmail())
                .role(member.getRole())
                .createdAt(member.getCreatedAt())
                .build();
    }
}
```

---

### Controller 계층

**핵심 원칙:**
- 컨트롤러 = HTTP 수신 + `@Valid` 검증 + 서비스 위임만
- 컨트롤러 내부 비즈니스 로직(if-else 도메인 판별 등) **금지**
- 모든 정상 응답은 `ApiResponse<T>`로 래핑
- `@ResponseStatus` 우선 사용, `ResponseEntity`는 헤더 조작 필요 시만

**`ApiResponse` 공통 응답:**
```java
// 파일: common/dto/ApiResponse.java
@Getter
@Builder
public class ApiResponse<T> {

    private final boolean success;
    private final T data;
    private final String message;

    public static <T> ApiResponse<T> ok(T data) {
        return ApiResponse.<T>builder().success(true).data(data).build();
    }

    public static <T> ApiResponse<T> created(T data) {
        return ApiResponse.<T>builder().success(true).data(data).build();
    }

    public static ApiResponse<Void> noContent() {
        return ApiResponse.<Void>builder().success(true).build();
    }
}
```

```java
// 파일: domain/member/MemberController.java
@Tag(name = "Member", description = "회원 관리 API")
@RestController
@RequestMapping("/api/v1/members")
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @Operation(summary = "회원 목록 조회")
    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    public ApiResponse<List<MemberRes>> findMembers() {
        return ApiResponse.ok(memberService.findMembers());
    }

    @Operation(summary = "회원 단건 조회")
    @GetMapping("/{id}")
    @ResponseStatus(HttpStatus.OK)
    public ApiResponse<MemberRes> getMember(@PathVariable Long id) {
        return ApiResponse.ok(memberService.getMember(id));
    }

    @Operation(summary = "회원 가입")
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ApiResponse<Long> createMember(@Valid @RequestBody MemberCreateReq req) {
        // #NOTE: CQS - 생성 후 ID만 반환, 상세 데이터는 GET API로 별도 조회
        return ApiResponse.created(memberService.createMember(req));
    }

    @Operation(summary = "회원 정보 수정")
    @PatchMapping("/{id}")
    @ResponseStatus(HttpStatus.OK)
    public ApiResponse<Void> updateMember(@PathVariable Long id,
                                          @Valid @RequestBody MemberUpdateReq req) {
        memberService.updateMember(id, req);
        return ApiResponse.noContent();
    }

    @Operation(summary = "회원 탈퇴")
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.OK)
    public ApiResponse<Void> deleteMember(@PathVariable Long id) {
        memberService.deleteMember(id);
        return ApiResponse.noContent();
    }

    // ResponseEntity 사용 허용 케이스: 헤더 동적 조작 필요 시
    @Operation(summary = "프로필 이미지 다운로드")
    @GetMapping("/{id}/profile-image")
    public ResponseEntity<Resource> downloadProfileImage(@PathVariable Long id) {
        // #TODO: 파일 스트림 반환 로직 구현 필요
        throw new UnsupportedOperationException("구현 필요");
    }
}
```

---

### 테스트 코드 (Service 단위 테스트)

**원칙:**
- `@SpringBootTest` 지양 → `@ExtendWith(MockitoExtension.class)` 사용
- Service 계층과 Entity 도메인 메서드 위주 단위 테스트
- BDD (Given-When-Then) 주석 분리 필수
- `@DisplayName` 한글 명세서화 필수
- **Fail Case 우선** (CustomException이 의도한 조건에서 정확히 터지는지)

```java
@ExtendWith(MockitoExtension.class)
@DisplayName("MemberService 단위 테스트")
class MemberServiceTest {

    @Mock
    private MemberRepository memberRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @InjectMocks
    private MemberService memberService;

    @Test
    @DisplayName("이미 사용 중인 이메일로 가입 시 MemberEmailDuplicateException이 발생한다")
    void createMember_duplicateEmail_throwsException() {
        // given
        MemberCreateReq req = new MemberCreateReq("홍길동", "hong@example.com", "Password1!");
        given(memberRepository.existsByEmail(req.getEmail())).willReturn(true);

        // when & then
        assertThatThrownBy(() -> memberService.createMember(req))
                .isInstanceOf(MemberEmailDuplicateException.class);
    }

    @Test
    @DisplayName("존재하지 않는 회원 ID로 조회 시 MemberNotFoundException이 발생한다")
    void getMember_notFound_throwsException() {
        // given
        Long notExistId = 999L;
        given(memberRepository.findById(notExistId)).willReturn(Optional.empty());

        // when & then
        assertThatThrownBy(() -> memberService.getMember(notExistId))
                .isInstanceOf(MemberNotFoundException.class);
    }

    @Test
    @DisplayName("유효한 정보로 회원 가입 시 저장된 회원의 ID를 반환한다")
    void createMember_validInput_returnsId() {
        // given
        MemberCreateReq req = new MemberCreateReq("홍길동", "hong@example.com", "Password1!");
        Member savedMember = Member.create("홍길동", "hong@example.com", "encodedPw");
        given(memberRepository.existsByEmail(req.getEmail())).willReturn(false);
        given(passwordEncoder.encode(req.getPassword())).willReturn("encodedPw");
        given(memberRepository.save(any(Member.class))).willReturn(savedMember);

        // when
        Long result = memberService.createMember(req);

        // then
        assertThat(result).isNotNull();
        verify(memberRepository).save(any(Member.class));
    }
}
```

---

## 생성 시 선택 사항

| 옵션 | 트리거 조건 |
|------|------------|
| Facade 계층 추가 | 거대한 모노리스 또는 MSA 명시 시 |
| Assembler 패턴 추가 | 복잡한 객체 조립 로직 명시 시 |
| QueryDSL Impl 추가 | 복잡한 동적 쿼리 필요 시 |
| MyBatis Mapper 추가 | 매우 복잡한 집계/리포트 쿼리 명시 시 |
| LogicalDeletedEntity | 논리삭제 필요 시 |
| BaseAuditEntity | 생성자/수정자 추적 필요 시 |
| Swagger 어노테이션 | 모노리스 애플리케이션 (기본 포함) |

---

## 체크리스트 (생성 완료 후 출력)

```
생성 파일 목록
=============
공통 기반 (초기 1회만)
 - [ ] common/entity/BaseTimeEntity.java
 - [ ] common/entity/LogicalDeletedEntity.java     (논리삭제 필요 시)
 - [ ] common/exceptions/CustomException.java
 - [ ] common/exceptions/ExceptionControllerAdvice.java
 - [ ] common/dto/ApiResponse.java
 - [ ] common/dto/ExceptionRes.java

도메인: {Domain}
 - [ ] common/entity/{Domain}.java
 - [ ] domain/{domain}/{Domain}Controller.java
 - [ ] domain/{domain}/{Domain}Service.java
 - [ ] domain/{domain}/{Domain}Repository.java
 - [ ] domain/{domain}/dto/req/{Domain}CreateReq.java
 - [ ] domain/{domain}/dto/req/{Domain}UpdateReq.java
 - [ ] domain/{domain}/dto/res/{Domain}Res.java
 - [ ] domain/{domain}/exceptions/{Domain}NotFoundException.java

테스트
 - [ ] test/.../domain/{domain}/{Domain}ServiceTest.java

추가 설정 확인
 - [ ] build.gradle: Spring Data JPA, Lombok, Validation, Sentry, Swagger(springdoc) 의존성
 - [ ] application.yml: JPA 설정, PostgreSQL timestamptz 주의
 - [ ] @EnableJpaAuditing 메인 클래스 또는 Config에 선언
 - [ ] BCryptPasswordEncoder Bean 등록 (비밀번호 필드 있는 경우)
```

---

## 주의사항

- **Jakarta vs Javax**: Spring Boot 3.x → `jakarta.*`, 2.x → `javax.*`. 버전 확인 필수.
- **ID 전략**: 단순 CRUD → `IDENTITY`, 분산 환경 → `@UuidGenerator`.
- **비밀번호**: 반드시 `BCryptPasswordEncoder`로 인코딩 후 저장.
- **Optional 사용 원칙**: 반환 타입으로만 사용. 필드·파라미터 타입으로 사용 금지.
- **Stream 람다 3줄 이상**: 반드시 `private` 메서드로 추출.
- **로그**: `@Slf4j` 사용. 비밀번호·토큰 등 민감 데이터 로그 출력 금지.
- **Enum 위치**: 특정 도메인 전용이면 해당 엔티티 내부 또는 `domain/{domain}/` 하위, 공용이면 `common/enums/`.
