Skip to content

6th-COKATHON/Team2-Server

Repository files navigation

Spring Boot Template

한국어 | English

📋 프로젝트 소개

이 템플릿은 Spring Boot 기반의 웹 애플리케이션 개발을 위한 구조화된 시작점을 제공합니다. 현대적인 웹 개발에 필요한 핵심 기능들을 사전 구성하여, 개발자가 비즈니스 로직에 집중할 수 있도록 설계되었습니다.

🛠️ 기술 스택

  • Java 21 - 최신 LTS 버전
  • Spring Boot 3.5.3 - 최신 Spring Boot 프레임워크
  • Spring Data JPA - 데이터 액세스 계층
  • MySQL - 관계형 데이터베이스 (개발용 H2 포함)
  • Spring Security - 보안 및 인증
  • JWT (JSON Web Token) - 토큰 기반 인증
  • Swagger/OpenAPI 3 - API 문서화
  • Lombok - 보일러플레이트 코드 감소
  • Gradle - 빌드 및 의존성 관리

✨ 주요 특징

  • 🏗️ 계층화된 아키텍처 - 명확한 패키지 구조와 책임 분리
  • 🔐 보안 내장 - Spring Security와 JWT 인증 기본 설정
  • 📝 표준화된 응답 - 일관된 API 응답 형식
  • 🔧 타입 안전한 설정 - Properties 클래스를 통한 설정 관리
  • 📚 자동 API 문서화 - Swagger를 통한 실시간 API 문서
  • 🎯 Claude Code 통합 - AI 기반 개발 도구 및 자동화 훅
  • 📊 JPA Auditing - 자동 생성/수정 시간 관리

📁 프로젝트 구조 상세

src/main/java/com/example/demo/
├── DemoApplication.java                 # 메인 애플리케이션 클래스
├── common/                              # 공통 컴포넌트
│   ├── base/                           # 기본 엔티티 및 설정
│   │   ├── BaseEntity.java             # JPA 엔티티 공통 필드 (생성/수정 시간)
│   │   └── JpaAuditingConfig.java      # JPA Auditing 활성화 설정
│   ├── cors/                           # CORS 설정
│   │   └── CorsConfig.java             # Cross-Origin 요청 허용 설정
│   ├── error/                          # 에러 처리
│   │   ├── ErrorCode.java              # 표준화된 에러 코드 열거형
│   │   └── exception/                  # 예외 처리
│   │       ├── AppException.java       # 커스텀 애플리케이션 예외
│   │       └── handler/
│   │           └── GlobalExceptionHandler.java  # 전역 예외 처리기
│   ├── property/                       # 설정 관리
│   │   ├── CorsProperties.java         # CORS 설정값
│   │   ├── SwaggerProperties.java      # Swagger 설정값
│   │   └── config/
│   │       └── PropertyConfig.java     # Properties 클래스 등록
│   ├── response/                       # 표준 응답
│   │   ├── AppResponse.java            # 성공 응답 래퍼
│   │   ├── BaseResponse.java           # 기본 응답 클래스
│   │   ├── ErrorResponse.java          # 에러 응답 래퍼
│   │   └── PageResponse.java           # 페이징 응답 래퍼
│   ├── security/                       # 보안 설정
│   │   ├── EncoderConfig.java          # 패스워드 인코더 설정
│   │   ├── SecurityConfig.java         # Spring Security 메인 설정
│   │   └── filter/
│   │       └── SecurityExceptionFilter.java  # 보안 예외 필터
│   └── swagger/                        # API 문서화
│       └── config/
│           └── SwaggerConfig.java      # Swagger/OpenAPI 설정
├── domain/                             # 도메인 로직 (비즈니스 엔티티, 서비스)
└── web/                               # 웹 계층 (컨트롤러, DTO)

.claude/                               # Claude Code 설정
├── hooks/                             # 자동화 훅 스크립트
│   ├── pre_tool_use.py               # 위험 명령어 차단 및 로깅
│   ├── post_tool_use.py              # 도구 사용 후 로깅
│   ├── notification.py               # TTS 알림 시스템
│   ├── stop.py                       # 세션 종료 알림
│   └── utils/                        # 유틸리티
│       ├── llm/                      # LLM 통합
│       └── tts/                      # Text-to-Speech
├── commands/                         # 커스텀 명령어
│   ├── prime.md                      # 프로젝트 컨텍스트 로드
│   ├── commit.md                     # Conventional Commit 생성
│   └── git_status.md                 # Git 상태 확인
└── settings.local.json               # 로컬 권한 설정

📦 패키지별 역할

common 패키지

프로젝트 전반에 걸쳐 사용되는 공통 컴포넌트들을 포함합니다.

  • base/: 모든 JPA 엔티티가 상속받을 기본 클래스와 JPA Auditing 설정
  • cors/: Cross-Origin 요청 처리를 위한 CORS 설정
  • error/: 에러 코드 정의 및 예외 처리 로직
  • property/: 타입 안전한 설정값 관리를 위한 Properties 클래스들
  • response/: API 응답의 일관성을 위한 표준 응답 래퍼 클래스들
  • security/: Spring Security 및 JWT 인증 설정
  • swagger/: API 문서 자동 생성을 위한 Swagger 설정

domain 패키지

비즈니스 로직의 핵심인 도메인 모델과 서비스를 배치합니다.

  • 엔티티 클래스
  • 도메인 서비스
  • 리포지토리 인터페이스

web 패키지

웹 계층의 컴포넌트들을 배치합니다.

  • REST 컨트롤러
  • DTO (Data Transfer Object)
  • 요청/응답 모델

🚀 시작하기

1. 환경 설정

필수 환경 변수

# 데이터베이스 설정
export DB_NAME=your_database_name
export DB_USERNAME=your_db_username  
export DB_PASSWORD=your_db_password

# 애플리케이션 설정 (선택사항)
export APP_NAME=your_app_name           # 기본값: backend
export SWAGGER_URL=localhost:8080       # 기본값: localhost:8080

로컬 개발용 환경 변수 파일 (.env)

# .env 파일 생성 (gitignore에 포함됨)
DB_NAME=demo_db
DB_USERNAME=root
DB_PASSWORD=password
APP_NAME=my-spring-app
SWAGGER_URL=localhost:8080

2. 데이터베이스 설정

MySQL 설치 및 설정

# MySQL 8.0 설치 (macOS)
brew install mysql@8.0

# MySQL 서비스 시작
brew services start mysql@8.0

# 데이터베이스 생성
mysql -u root -p
CREATE DATABASE your_database_name;

H2 Database (개발용)

개발 중에는 별도 설정 없이 H2 인메모리 데이터베이스를 사용할 수 있습니다.

3. 애플리케이션 실행

# Gradle을 통한 실행
./gradlew bootRun

# 또는 IDE에서 DemoApplication.java 실행

애플리케이션이 성공적으로 시작되면:


⚙️ 설정 파일 상세 가이드

application.yml 구조

spring:
  application:
    name: ${APP_NAME:backend}          # 애플리케이션 이름
    
  datasource:                          # 데이터소스 설정
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/${DB_NAME}?useSSL=false&characterEncoding=UTF-8&createDatabaseIfNotExist=true&rewriteBatchedStatements=true
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    
  jpa:                                 # JPA 설정
    hibernate:
      ddl-auto: update                 # 스키마 자동 업데이트
    properties:
      hibernate:
        format_sql: true               # SQL 포맷팅
    show-sql: true                     # SQL 쿼리 출력
    open-in-view: false               # OSIV 비활성화 (권장)

cors:                                  # CORS 설정 (커스텀)
  allowed-origins:
    - http://localhost:3000           # React 개발 서버
    - http://localhost:8080           # 같은 포트 허용

swagger:                              # Swagger 설정 (커스텀)
  url: ${SWAGGER_URL:localhost:8080}

Properties 클래스 활용

1. CorsProperties 사용 예제

@RestController
public class CorsTestController {
    
    private final CorsProperties corsProperties;
    
    public CorsTestController(CorsProperties corsProperties) {
        this.corsProperties = corsProperties;
    }
    
    @GetMapping("/cors-config")
    public AppResponse<String[]> getCorsConfig() {
        return AppResponse.ok(corsProperties.getAllowedOrigins());
    }
}

2. 새로운 Properties 클래스 추가

// 1. Properties 클래스 생성
@Getter
@Setter
@ConfigurationProperties(prefix = "myapp")
public class MyAppProperties {
    private String title;
    private String version;
    private boolean debugMode;
}

// 2. PropertyConfig에 등록
@Configuration
@EnableConfigurationProperties(value = {
    CorsProperties.class,
    SwaggerProperties.class,
    MyAppProperties.class  // 새로 추가
})
public class PropertyConfig {
}

// 3. application.yml에 설정 추가
myapp:
  title: "My Spring Boot App"
  version: "1.0.0"
  debug-mode: true

🤖 Claude Code 훅 시스템

이 프로젝트는 Claude Code와의 통합을 위한 고도화된 훅 시스템을 포함하고 있습니다.

훅 스크립트 설명

1. pre_tool_use.py - 사전 안전 검사

# 주요 기능:
# - 위험한 rm -rf 명령어 자동 차단
# - .env 파일 접근 보호 (민감 정보 보호)
# - 모든 도구 사용 로깅 (logs/pre_tool_use.json)

보호되는 명령어 패턴:

  • rm -rf 계열 모든 명령어
  • .env 파일 접근 (.env.sample 제외)
  • 위험한 경로 삭제 시도

2. post_tool_use.py - 사후 로깅

# 주요 기능:
# - 도구 사용 결과 로깅 (logs/post_tool_use.json)
# - 성능 메트릭 수집
# - 에러 발생 시 컨텍스트 보존

3. notification.py - 알림 시스템

# 주요 기능:
# - TTS (Text-to-Speech) 알림
# - 다중 TTS 엔진 지원 (ElevenLabs > OpenAI > pyttsx3)
# - 엔지니어 이름 개인화 (ENGINEER_NAME 환경변수)

TTS 설정:

# 환경변수 설정
export ELEVENLABS_API_KEY=your_elevenlabs_key    # 최우선
export OPENAI_API_KEY=your_openai_key            # 두 번째
export ENGINEER_NAME="김개발"                      # 개인화 (선택사항)

4. stop.py - 세션 종료 처리

# 주요 기능:
# - 세션 완료 알림
# - 대화 로그 정리 (--chat 옵션)
# - 작업 완료 TTS 안내

커스텀 명령어

/prime - 프로젝트 컨텍스트 로드

프로젝트 구조를 분석하고 README를 읽어 Claude에게 프로젝트 정보를 제공합니다.

/prime

/commit - Conventional Commit 생성

한국어 지원 conventional commit 메시지를 자동 생성합니다.

/commit              # 기본 검증 포함
/commit --no-verify  # 사전 검증 건너뛰기

생성되는 커밋 타입:

  • feat: 새로운 기능 추가
  • fix: 버그 수정
  • docs: 문서 변경
  • style: 코드 스타일 변경
  • refactor: 리팩토링
  • perf: 성능 개선
  • test: 테스트 추가/수정
  • chore: 빌드/도구 변경

/git_status - Git 상태 확인

현재 git 저장소 상태를 종합적으로 분석합니다.

/git_status

Claude Code 설정 파일

settings.local.json

{
  "permissions": {
    "allow": [
      "mcp__sequential-thinking__sequentialthinking",  // 순차적 사고 모드
      "Bash(./gradlew test:*)"                         // Gradle 테스트 허용
    ],
    "deny": []
  }
}

💻 개발 가이드

1. 새로운 도메인 엔티티 추가

Step 1: 엔티티 클래스 생성

// src/main/java/com/example/demo/domain/user/User.java
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {  // BaseEntity 상속으로 생성/수정 시간 자동 관리
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(unique = true, nullable = false)
    private String email;
    
    @Column(nullable = false)
    private String password;
    
    @Column(nullable = false)
    private String name;
    
    @Enumerated(EnumType.STRING)
    private UserRole role;
    
    // 생성자
    private User(String email, String password, String name, UserRole role) {
        this.email = email;
        this.password = password;
        this.name = name;
        this.role = role;
    }
    
    // 정적 팩토리 메서드
    public static User create(String email, String password, String name, UserRole role) {
        return new User(email, password, name, role);
    }
}

Step 2: 리포지토리 인터페이스 생성

// src/main/java/com/example/demo/domain/user/UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
}

Step 3: 서비스 클래스 생성

// src/main/java/com/example/demo/domain/user/UserService.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {
    
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    
    @Transactional
    public User createUser(String email, String rawPassword, String name) {
        // 이메일 중복 검사
        if (userRepository.existsByEmail(email)) {
            throw new AppException(ErrorCode.USER_INPUT_EXCEPTION, "Email already exists");
        }
        
        // 패스워드 암호화
        String encodedPassword = passwordEncoder.encode(rawPassword);
        
        // 사용자 생성 및 저장
        User user = User.create(email, encodedPassword, name, UserRole.USER);
        return userRepository.save(user);
    }
    
    public User getUserByEmail(String email) {
        return userRepository.findByEmail(email)
            .orElseThrow(() -> new AppException(ErrorCode.USER_INPUT_EXCEPTION, "User not found"));
    }
}

2. API 컨트롤러 작성

Step 1: DTO 클래스 생성

// src/main/java/com/example/demo/web/user/dto/CreateUserRequest.java
@Getter
@NoArgsConstructor
public class CreateUserRequest {
    
    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    private String email;
    
    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    private String password;
    
    @NotBlank(message = "Name is required")
    @Size(max = 50, message = "Name must not exceed 50 characters")
    private String name;
}

// src/main/java/com/example/demo/web/user/dto/UserResponse.java
@Getter
@RequiredArgsConstructor
public class UserResponse {
    private final Long id;
    private final String email;
    private final String name;
    private final UserRole role;
    private final LocalDateTime createdAt;
    
    public static UserResponse from(User user) {
        return new UserResponse(
            user.getId(),
            user.getEmail(),
            user.getName(),
            user.getRole(),
            user.getCreatedAt()
        );
    }
}

Step 2: 컨트롤러 클래스 생성

// src/main/java/com/example/demo/web/user/UserController.java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Tag(name = "User", description = "사용자 관리 API")
public class UserController {
    
    private final UserService userService;
    
    @PostMapping
    @Operation(summary = "사용자 생성", description = "새로운 사용자를 생성합니다.")
    public AppResponse<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
        User user = userService.createUser(request.getEmail(), request.getPassword(), request.getName());
        UserResponse response = UserResponse.from(user);
        return AppResponse.created(response);
    }
    
    @GetMapping("/{id}")
    @Operation(summary = "사용자 조회", description = "ID로 사용자를 조회합니다.")
    public AppResponse<UserResponse> getUser(@PathVariable Long id) {
        User user = userService.getUserById(id);
        UserResponse response = UserResponse.from(user);
        return AppResponse.ok(response);
    }
}

3. 에러 처리 패턴

Step 1: 커스텀 에러 코드 추가

// ErrorCode.java에 추가
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
    // 기존 코드들...
    
    // User 관련 에러 추가
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U-001", "User not found"),
    EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "U-002", "Email already exists"),
    INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U-003", "Invalid password"),
    ;
}

Step 2: 서비스에서 예외 발생

@Service
@RequiredArgsConstructor
public class UserService {
    
    public User getUserById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND));
    }
    
    @Transactional
    public User createUser(String email, String password, String name) {
        if (userRepository.existsByEmail(email)) {
            throw new AppException(ErrorCode.EMAIL_ALREADY_EXISTS);
        }
        // ... 생성 로직
    }
}

Step 3: 자동 예외 처리

GlobalExceptionHandler가 자동으로 처리하여 다음과 같은 응답을 생성합니다:

{
  "success": false,
  "status": 404,
  "error": {
    "code": "U-001",
    "message": "User not found"
  }
}

4. 표준 응답 형식 사용

성공 응답

// 200 OK
return AppResponse.ok(data);

// 201 Created  
return AppResponse.created(data);

// 204 No Content
return AppResponse.noContent();

// 커스텀 상태 코드
return AppResponse.of(HttpStatus.ACCEPTED, data);

페이징 응답

@GetMapping
public AppResponse<PageResponse<UserResponse>> getUsers(
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "10") int size) {
    
    Pageable pageable = PageRequest.of(page, size);
    Page<User> userPage = userService.getUsers(pageable);
    
    PageResponse<UserResponse> response = PageResponse.of(
        userPage.map(UserResponse::from)
    );
    
    return AppResponse.ok(response);
}

🔐 보안 설정

Spring Security 구성

현재 SecurityConfig는 개발 편의성을 위해 모든 요청을 허용하도록 설정되어 있습니다:

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)              // CSRF 비활성화
            .cors(cors -> cors.configurationSource(corsConfigurationSource))  // CORS 설정
            .formLogin(AbstractHttpConfigurer::disable)         // 폼 로그인 비활성화
            .authorizeHttpRequests(auth -> auth
                .anyRequest().permitAll())                      // 모든 요청 허용 (개발용)
            .addFilterBefore(securityExceptionFilter, UsernamePasswordAuthenticationFilter.class)
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))  // Stateless 세션
            .build();
    }
}

JWT 인증 구현 가이드

Step 1: JWT 서비스 클래스 생성

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    
    @Value("${jwt.secret}")
    private String secretKey;
    
    @Value("${jwt.access-token-validity}")
    private long accessTokenValidity;
    
    @Value("${jwt.refresh-token-validity}")
    private long refreshTokenValidity;
    
    private Key getSigningKey() {
        byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }
    
    public String generateAccessToken(String userEmail, UserRole role) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + accessTokenValidity);
        
        return Jwts.builder()
            .setSubject(userEmail)
            .claim("role", role.name())
            .claim("type", "ACCESS")
            .setIssuedAt(now)
            .setExpiration(expiry)
            .signWith(getSigningKey(), SignatureAlgorithm.HS256)
            .compact();
    }
    
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
    
    public Claims getClaimsFromToken(String token) {
        return Jwts.parserBuilder()
            .setSigningKey(getSigningKey())
            .build()
            .parseClaimsJws(token)
            .getBody();
    }
}

Step 2: JWT 인증 필터 생성

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    private final JwtTokenProvider jwtTokenProvider;
    private final UserService userService;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                   HttpServletResponse response, 
                                   FilterChain filterChain) throws ServletException, IOException {
        
        String token = resolveToken(request);
        
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Claims claims = jwtTokenProvider.getClaimsFromToken(token);
            String userEmail = claims.getSubject();
            
            User user = userService.getUserByEmail(userEmail);
            
            UserPrincipal userPrincipal = UserPrincipal.from(user);
            Authentication authentication = new UsernamePasswordAuthenticationToken(
                userPrincipal, null, userPrincipal.getAuthorities()
            );
            
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        
        filterChain.doFilter(request, response);
    }
    
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

Step 3: SecurityConfig 업데이트

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final CorsConfigurationSource corsConfigurationSource;
    private final SecurityExceptionFilter securityExceptionFilter;
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -> cors.configurationSource(corsConfigurationSource))
            .formLogin(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()           // 인증 API 허용
                .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()  // Swagger 허용
                .anyRequest().authenticated())                         // 나머지는 인증 필요
            .addFilterBefore(securityExceptionFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .build();
    }
}

CORS 설정

CORS 설정은 CorsPropertiesCorsConfig를 통해 관리됩니다:

@Configuration
@RequiredArgsConstructor
public class CorsConfig {
    
    private final CorsProperties corsProperties;
    
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        
        // application.yml의 cors.allowed-origins 사용
        configuration.setAllowedOriginPatterns(Arrays.asList(corsProperties.getAllowedOrigins()));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        
        return source;
    }
}

📚 API 문서화

Swagger/OpenAPI 3 설정

Swagger는 자동으로 설정되며, 다음 URL에서 접근 가능합니다:

API 문서화 어노테이션 사용

@RestController
@RequestMapping("/api/users")
@Tag(name = "User Management", description = "사용자 관리 API")
@RequiredArgsConstructor
public class UserController {
    
    @PostMapping
    @Operation(
        summary = "사용자 생성",
        description = "새로운 사용자를 생성합니다. 이메일은 고유해야 합니다."
    )
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "사용자 생성 성공"),
        @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"),
        @ApiResponse(responseCode = "409", description = "이메일 중복")
    })
    public AppResponse<UserResponse> createUser(
        @Valid @RequestBody 
        @io.swagger.v3.oas.annotations.parameters.RequestBody(
            description = "사용자 생성 요청 데이터",
            required = true
        ) CreateUserRequest request) {
        
        User user = userService.createUser(request.getEmail(), request.getPassword(), request.getName());
        return AppResponse.created(UserResponse.from(user));
    }
    
    @GetMapping("/{id}")
    @Operation(summary = "사용자 조회", description = "ID로 특정 사용자를 조회합니다.")
    public AppResponse<UserResponse> getUser(
        @Parameter(description = "사용자 ID", required = true, example = "1")
        @PathVariable Long id) {
        
        User user = userService.getUserById(id);
        return AppResponse.ok(UserResponse.from(user));
    }
}

DTO 문서화

@Schema(description = "사용자 생성 요청")
@Getter
@NoArgsConstructor
public class CreateUserRequest {
    
    @Schema(description = "이메일 주소", example = "user@example.com", required = true)
    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    private String email;
    
    @Schema(description = "비밀번호 (최소 8자)", example = "password123", required = true)
    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    private String password;
    
    @Schema(description = "사용자 이름", example = "홍길동", required = true)
    @NotBlank(message = "Name is required")
    @Size(max = 50, message = "Name must not exceed 50 characters")
    private String name;
}

🧪 테스트 및 빌드

Gradle 명령어

# 프로젝트 빌드
./gradlew build

# 테스트 실행
./gradlew test

# 애플리케이션 실행
./gradlew bootRun

# JAR 파일 생성
./gradlew bootJar

# 의존성 확인
./gradlew dependencies

# 프로젝트 정리
./gradlew clean

테스트 작성 예제

단위 테스트

@ExtendWith(MockitoExtension.class)
class UserServiceTest {
    
    @Mock
    private UserRepository userRepository;
    
    @Mock
    private PasswordEncoder passwordEncoder;
    
    @InjectMocks
    private UserService userService;
    
    @Test
    @DisplayName("사용자 생성 성공")
    void createUser_Success() {
        // given
        String email = "test@example.com";
        String rawPassword = "password123";
        String name = "테스트";
        String encodedPassword = "encoded_password";
        
        when(userRepository.existsByEmail(email)).thenReturn(false);
        when(passwordEncoder.encode(rawPassword)).thenReturn(encodedPassword);
        when(userRepository.save(any(User.class))).thenReturn(
            User.create(email, encodedPassword, name, UserRole.USER)
        );
        
        // when
        User result = userService.createUser(email, rawPassword, name);
        
        // then
        assertThat(result.getEmail()).isEqualTo(email);
        assertThat(result.getName()).isEqualTo(name);
        verify(userRepository).existsByEmail(email);
        verify(passwordEncoder).encode(rawPassword);
        verify(userRepository).save(any(User.class));
    }
    
    @Test
    @DisplayName("이메일 중복 시 예외 발생")
    void createUser_EmailExists_ThrowsException() {
        // given
        String email = "existing@example.com";
        when(userRepository.existsByEmail(email)).thenReturn(true);
        
        // when & then
        assertThatThrownBy(() -> userService.createUser(email, "password", "name"))
            .isInstanceOf(AppException.class)
            .hasMessage("Email already exists");
    }
}

통합 테스트

@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(locations = "classpath:application-test.yml")
class UserControllerIntegrationTest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    @DisplayName("사용자 생성 API 통합 테스트")
    void createUser_Integration_Success() {
        // given
        CreateUserRequest request = new CreateUserRequest();
        request.setEmail("integration@test.com");
        request.setPassword("password123");
        request.setName("통합테스트");
        
        // when
        ResponseEntity<AppResponse> response = restTemplate.postForEntity(
            "/api/users", request, AppResponse.class
        );
        
        // then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(userRepository.existsByEmail("integration@test.com")).isTrue();
    }
}

🔧 확장 가이드

1. 새로운 Properties 클래스 추가

Step 1: Properties 클래스 생성

@Getter
@Setter
@ConfigurationProperties(prefix = "app.notification")
public class NotificationProperties {
    private boolean enabled = true;
    private String defaultTemplate;
    private Email email = new Email();
    private Sms sms = new Sms();
    
    @Getter
    @Setter
    public static class Email {
        private String host;
        private int port = 587;
        private String username;
        private String password;
        private boolean ssl = true;
    }
    
    @Getter
    @Setter
    public static class Sms {
        private String apiKey;
        private String from;
    }
}

Step 2: PropertyConfig에 등록

@Configuration
@EnableConfigurationProperties(value = {
    CorsProperties.class,
    SwaggerProperties.class,
    NotificationProperties.class  // 추가
})
public class PropertyConfig {
}

Step 3: application.yml에 설정 추가

app:
  notification:
    enabled: true
    default-template: "default-notification"
    email:
      host: smtp.gmail.com
      port: 587
      username: ${EMAIL_USERNAME}
      password: ${EMAIL_PASSWORD}
      ssl: true
    sms:
      api-key: ${SMS_API_KEY}
      from: "+821012345678"

2. 커스텀 에러 코드 확장

도메인별 에러 코드 추가

@Getter
@RequiredArgsConstructor
public enum ErrorCode {
    // Common Errors
    USER_INPUT_EXCEPTION(HttpStatus.BAD_REQUEST, "C-001", "User input error"),
    // ... 기존 코드들
    
    // User Domain Errors (U-XXX)
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U-001", "User not found"),
    EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "U-002", "Email already exists"),
    INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U-003", "Invalid password"),
    USER_ACCOUNT_LOCKED(HttpStatus.FORBIDDEN, "U-004", "User account is locked"),
    
    // Order Domain Errors (O-XXX)
    ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "O-001", "Order not found"),
    INSUFFICIENT_INVENTORY(HttpStatus.BAD_REQUEST, "O-002", "Insufficient inventory"),
    ORDER_ALREADY_CANCELLED(HttpStatus.BAD_REQUEST, "O-003", "Order already cancelled"),
    
    // Payment Domain Errors (P-XXX)
    PAYMENT_FAILED(HttpStatus.BAD_REQUEST, "P-001", "Payment processing failed"),
    INVALID_CARD_INFO(HttpStatus.BAD_REQUEST, "P-002", "Invalid card information"),
    PAYMENT_TIMEOUT(HttpStatus.REQUEST_TIMEOUT, "P-003", "Payment request timeout"),
    ;
}

3. 보안 필터 추가

요청 로깅 필터

@Component
@Slf4j
public class RequestLoggingFilter implements Filter {
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        
        String requestId = UUID.randomUUID().toString().substring(0, 8);
        String method = httpRequest.getMethod();
        String uri = httpRequest.getRequestURI();
        String clientIp = getClientIp(httpRequest);
        
        long startTime = System.currentTimeMillis();
        
        try {
            // MDC에 요청 ID 설정 (로그 추적용)
            MDC.put("requestId", requestId);
            
            log.info("REQ [{}] {} {} from {}", requestId, method, uri, clientIp);
            
            chain.doFilter(request, response);
            
        } finally {
            long duration = System.currentTimeMillis() - startTime;
            int status = httpResponse.getStatus();
            
            log.info("RES [{}] {} {}ms", requestId, status, duration);
            
            MDC.clear();
        }
    }
    
    private String getClientIp(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            return xForwardedFor.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

SecurityConfig에 필터 등록

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        // ... 기존 설정
        .addFilterBefore(requestLoggingFilter, SecurityExceptionFilter.class)
        .addFilterBefore(securityExceptionFilter, UsernamePasswordAuthenticationFilter.class)
        .build();
}

4. 데이터베이스 환경별 설정

환경별 Profile 설정

# application.yml (공통 설정)
spring:
  profiles:
    active: ${SPRING_PROFILES_ACTIVE:local}
  application:
    name: ${APP_NAME:backend}

---
# Local 환경
spring:
  config:
    activate:
      on-profile: local
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:testdb
    username: sa
    password:
  h2:
    console:
      enabled: true
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true

---
# Development 환경
spring:
  config:
    activate:
      on-profile: dev
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/${DB_NAME}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
  jpa:
    hibernate:
      ddl-auto: update

---
# Production 환경
spring:
  config:
    activate:
      on-profile: prod
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false

📞 문의 및 지원

개발 환경 문제 해결

일반적인 문제들

  1. 포트 충돌 (Port 8080 already in use)

    # 포트 사용 프로세스 확인
    lsof -i :8080
    
    # 프로세스 종료
    kill -9 <PID>
    
    # 또는 다른 포트 사용
    ./gradlew bootRun --args='--server.port=8081'
  2. 데이터베이스 연결 오류

    # MySQL 서비스 상태 확인
    brew services list | grep mysql
    
    # MySQL 시작
    brew services start mysql@8.0
    
    # 환경변수 확인
    echo $DB_NAME
    echo $DB_USERNAME
  3. Claude Code 훅 권한 오류

    # 훅 스크립트 실행 권한 부여
    chmod +x .claude/hooks/*.py
    
    # UV 설치 확인
    which uv || curl -LsSf https://astral.sh/uv/install.sh | sh

추가 자료


📄 라이센스

이 프로젝트는 MIT 라이센스 하에 제공됩니다. 자유롭게 사용, 수정, 배포할 수 있습니다.


Happy Coding! 🚀

이 템플릿이 여러분의 Spring Boot 프로젝트 개발에 도움이 되기를 바랍니다. 문제가 있거나 개선사항이 있다면 언제든지 이슈를 등록해 주세요.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors