diff --git a/.gradle/8.10/checksums/checksums.lock b/.gradle/8.10/checksums/checksums.lock new file mode 100644 index 0000000..837e5b9 Binary files /dev/null and b/.gradle/8.10/checksums/checksums.lock differ diff --git a/.gradle/8.10/checksums/md5-checksums.bin b/.gradle/8.10/checksums/md5-checksums.bin new file mode 100644 index 0000000..04c6d00 Binary files /dev/null and b/.gradle/8.10/checksums/md5-checksums.bin differ diff --git a/.gradle/8.10/checksums/sha1-checksums.bin b/.gradle/8.10/checksums/sha1-checksums.bin new file mode 100644 index 0000000..19a5410 Binary files /dev/null and b/.gradle/8.10/checksums/sha1-checksums.bin differ diff --git a/.gradle/8.10/dependencies-accessors/gc.properties b/.gradle/8.10/dependencies-accessors/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/8.10/executionHistory/executionHistory.bin b/.gradle/8.10/executionHistory/executionHistory.bin new file mode 100644 index 0000000..2177cdd Binary files /dev/null and b/.gradle/8.10/executionHistory/executionHistory.bin differ diff --git a/.gradle/8.10/executionHistory/executionHistory.lock b/.gradle/8.10/executionHistory/executionHistory.lock new file mode 100644 index 0000000..0ce4c96 Binary files /dev/null and b/.gradle/8.10/executionHistory/executionHistory.lock differ diff --git a/.gradle/8.10/fileChanges/last-build.bin b/.gradle/8.10/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/8.10/fileChanges/last-build.bin differ diff --git a/.gradle/8.10/fileHashes/fileHashes.bin b/.gradle/8.10/fileHashes/fileHashes.bin new file mode 100644 index 0000000..8088fbb Binary files /dev/null and b/.gradle/8.10/fileHashes/fileHashes.bin differ diff --git a/.gradle/8.10/fileHashes/fileHashes.lock b/.gradle/8.10/fileHashes/fileHashes.lock new file mode 100644 index 0000000..340e0dd Binary files /dev/null and b/.gradle/8.10/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.10/fileHashes/resourceHashesCache.bin b/.gradle/8.10/fileHashes/resourceHashesCache.bin new file mode 100644 index 0000000..3d21896 Binary files /dev/null and b/.gradle/8.10/fileHashes/resourceHashesCache.bin differ diff --git a/.gradle/8.10/gc.properties b/.gradle/8.10/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/9.1.0/checksums/checksums.lock b/.gradle/9.1.0/checksums/checksums.lock new file mode 100644 index 0000000..3d9ab52 Binary files /dev/null and b/.gradle/9.1.0/checksums/checksums.lock differ diff --git a/.gradle/9.1.0/executionHistory/executionHistory.bin b/.gradle/9.1.0/executionHistory/executionHistory.bin new file mode 100644 index 0000000..c3b4cb1 Binary files /dev/null and b/.gradle/9.1.0/executionHistory/executionHistory.bin differ diff --git a/.gradle/9.1.0/executionHistory/executionHistory.lock b/.gradle/9.1.0/executionHistory/executionHistory.lock new file mode 100644 index 0000000..4cc7cd5 Binary files /dev/null and b/.gradle/9.1.0/executionHistory/executionHistory.lock differ diff --git a/.gradle/9.1.0/fileChanges/last-build.bin b/.gradle/9.1.0/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/9.1.0/fileChanges/last-build.bin differ diff --git a/.gradle/9.1.0/fileHashes/fileHashes.bin b/.gradle/9.1.0/fileHashes/fileHashes.bin new file mode 100644 index 0000000..5c96b1a Binary files /dev/null and b/.gradle/9.1.0/fileHashes/fileHashes.bin differ diff --git a/.gradle/9.1.0/fileHashes/fileHashes.lock b/.gradle/9.1.0/fileHashes/fileHashes.lock new file mode 100644 index 0000000..abbb4d0 Binary files /dev/null and b/.gradle/9.1.0/fileHashes/fileHashes.lock differ diff --git a/.gradle/9.1.0/gc.properties b/.gradle/9.1.0/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000..0350ff2 Binary files /dev/null and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..80e1268 --- /dev/null +++ b/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Thu Oct 23 17:51:21 KST 2025 +gradle.version=8.10 diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin new file mode 100644 index 0000000..4ed6f06 Binary files /dev/null and b/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe new file mode 100644 index 0000000..ac4beb4 Binary files /dev/null and b/.gradle/file-system.probe differ diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/ai-service/build.gradle b/ai-service/build.gradle new file mode 100644 index 0000000..a39127e --- /dev/null +++ b/ai-service/build.gradle @@ -0,0 +1,17 @@ +dependencies { + // Kafka Consumer + implementation 'org.springframework.kafka:spring-kafka' + + // Redis for result caching + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // OpenFeign for Claude/GPT API + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + + // Resilience4j for Circuit Breaker + implementation "io.github.resilience4j:resilience4j-spring-boot3:${resilience4jVersion}" + implementation "io.github.resilience4j:resilience4j-circuitbreaker:${resilience4jVersion}" + + // Jackson for JSON + implementation 'com.fasterxml.jackson.core:jackson-databind' +} diff --git a/analytics-service/build.gradle b/analytics-service/build.gradle new file mode 100644 index 0000000..a72c1bc --- /dev/null +++ b/analytics-service/build.gradle @@ -0,0 +1,17 @@ +dependencies { + // Kafka Consumer + implementation 'org.springframework.kafka:spring-kafka' + + // Redis for caching + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // OpenFeign for external APIs (우리동네TV, 지니TV, SNS) + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + + // Resilience4j for Circuit Breaker + implementation "io.github.resilience4j:resilience4j-spring-boot3:${resilience4jVersion}" + implementation "io.github.resilience4j:resilience4j-circuitbreaker:${resilience4jVersion}" + + // Jackson for JSON + implementation 'com.fasterxml.jackson.core:jackson-databind' +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..3f32137 --- /dev/null +++ b/build.gradle @@ -0,0 +1,124 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.0' apply false + id 'io.spring.dependency-management' version '1.1.6' apply false + id 'io.freefair.lombok' version '8.10' apply false +} + +group = 'com.kt.event' +version = '1.0.0' + +allprojects { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +subprojects { + apply plugin: 'java' + apply plugin: 'io.freefair.lombok' + + java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + tasks.named('test') { + useJUnitPlatform() + } + + // Common versions for all subprojects + ext { + jjwtVersion = '0.12.5' + springdocVersion = '2.5.0' + mapstructVersion = '1.5.5.Final' + commonsLang3Version = '3.14.0' + commonsIoVersion = '2.16.1' + hypersistenceVersion = '3.7.3' + openaiVersion = '0.18.2' + feignJacksonVersion = '13.1' + resilience4jVersion = '2.2.0' + azureStorageVersion = '12.25.0' + } +} + +// Configure all subprojects with Spring dependency management +subprojects { + apply plugin: 'io.spring.dependency-management' + + dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:2023.0.2" + } + } +} + +// Configure only service modules (exclude common) +configure(subprojects.findAll { it.name != 'common' }) { + apply plugin: 'org.springframework.boot' + + dependencies { + // Common module dependency + implementation project(':common') + + // Spring Boot Starters + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Actuator for health checks and monitoring + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Kafka + implementation 'org.springframework.kafka:spring-kafka' + + // API Documentation (common across all services) + implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}" + + // Database + runtimeOnly 'org.postgresql:postgresql' + + // Testing + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.springframework.kafka:spring-kafka-test' + testImplementation 'org.mockito:mockito-junit-jupiter' + + // Configuration Processor + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + } + + // Configure bootJar task for each service + bootJar { + archiveFileName = "${project.name}.jar" + } +} + +// Java version consistency check for all modules +tasks.register('checkJavaVersion') { + doLast { + println "Java Version: ${System.getProperty('java.version')}" + println "Java Home: ${System.getProperty('java.home')}" + } +} + +// Clean task for all subprojects +tasks.register('cleanAll') { + dependsOn subprojects.collect { it.tasks.named('clean') } + description = 'Clean all subprojects' +} + +// Build task for all subprojects +tasks.register('buildAll') { + dependsOn subprojects.collect { it.tasks.named('build') } + description = 'Build all subprojects' +} diff --git a/claude/dev-backend.md b/claude/dev-backend.md new file mode 100644 index 0000000..81ece9d --- /dev/null +++ b/claude/dev-backend.md @@ -0,0 +1,662 @@ +# 백엔드 개발 가이드 + +[요청사항] +- <개발원칙>을 준용하여 개발 +- <개발순서>에 따라 아래 3단계로 개발 + - '0. 준비'를 수행하고 완료 후 다음 단계 진행여부를 사용자에게 확인 + - '1. common 모듈 개발'을 수행하고 완료 후 다음 단계 진행여부를 사용자에게 확인 + - '2. 각 서비스별 구현'은 사용자와 함께 각 서비스를 개발 + +[가이드] +<개발원칙> +- '개발주석표준'에 맞게 주석 작성 +- API설계서와 일관성 있게 설계. Controller에 API를 누락하지 말고 모두 개발 +- '외부시퀀스설계서'와 '내부시퀀스설계서'와 일치되도록 개발 +- 각 서비스별 지정된 {설계 아키텍처 패턴}을 적용하여 개발 + - Layered 아키텍처 적용 시 Service레이어에 Interface 사용 + - Clean아키텍처 적용 시 Port/Adapter라는 용어 대신 Clean 아키텍처에 맞는 용어 사용 +- 백킹서비스 연동은 '데이터베이스설치결과서', '캐시설치결과서', 'MQ설치결과서'를 기반으로 개발 +- 빌드도구는 Gradle 사용 +- 설정 Manifest(src/main/resources/application*.yml) 작성 시 '[설정 Manifest 표준]' 준용 +<개발순서> +- 0. 준비: + - 참고자료 분석 및 이해 + - '패키지구조표준'의 예시를 참조하여 모든 클래스와 파일이 포함된 패키지 구조도를 작성 + - plantuml 스크립트가 아니라 트리구조 텍스트로 작성 + - 결과파일: develop/dev/package-structure.md + - settings.gralde 파일 작성 + - build.gradle 작성 + - '' 가이드대로 최상위와 각 서비스별 build.gradle 작성 + - '[루트 build.gradle 표준]'대로 최상위 build.gradle 작성 + - SpringBoot 3.3.0, Java 21 사용 + - common을 제외한 각 서비스에서 공통으로 사용되는 설정과 Dependency는 루트 build.gradle에 지정 + - 서비스별 build.gradle 작성 + - 최상위 build.gradle에 정의한 설정은 각 마이크로서비스의 build.gradle에 중복하여 정의하지 않도록 함 + - 각 서비스의 실행 jar 파일명은 서비스명과 동일하게 함 + - 각 서비스별 설정 파일 작성 + - 설정 Manifest(application.yml) 작성: '[설정 Manifest 표준]' 준용 + +- 1. common 모듈 개발 + - 각 서비스에서 공통으로 사용되는 클래스를 개발 + - 외부(웹브라우저, 데이터베이스, Message Queue, 외부시스템)와의 인터페이스를 위한 클래스는 포함하지 않음 + - 개발 완료 후 컴파일 및 에러 해결: {프로젝트 루트}/gradlew common:compileJava + +- 2. 각 서비스별 개발 + - 사용자가 제공한 서비스의 유저스토리, 외부시퀀스설계서, 내부시퀀스설계서, API설계서 파악 + - 기존 개발 결과 파악 + - API 설계서의 각 API를 순차적으로 개발 + - Controller -> Service -> Data 레이어순으로 순차적으로 개발 + - 컴파일 및 에러 해결: {프로젝트 루트}/gradlew {service-name}:compileJava + - 컴파일까지만 하고 서버 실행은 하지 않음 + - 모든 API개발 후 아래 수행 + - 컴파일 및 에러 해결: {프로젝트 루트}/gradlew {service-name}:compileJava + - 빌드 및 에러 해결: {프로젝트 루트}/gradlew {service-name}:build + - SecurityConfig 클래스 작성: '' 참조 + - JWT 인증 처리 클래스 작성: '' 참조 + - Swagger Config 클래스 작성: '' 참조 + - 테스트 코드 작성은 하지 않음 + + + +- **중앙 버전 관리**: 루트 build.gradle의 `ext` 블록에서 모든 외부 라이브러리 버전 통일 관리 +- **Spring Boot BOM 활용**: Spring Boot/Cloud에서 관리하는 라이브러리는 버전 명시 불필요 (자동 호환성 보장) +- **Common 모듈 설정**: `java-library` + Spring Boot 플러그인 조합, `bootJar` 비활성화로 일반 jar 생성 +- **서비스별 최적화**: 공통 의존성(API 문서화, 테스트 등)은 루트에서 일괄 적용 +- **JWT 버전 통일**: 라이브러리 버전 변경시 API 호환성 확인 필수 (`parserBuilder()` → `parser()`) +- **dependency-management 적용**: 모든 서브프로젝트에 Spring BOM 적용으로 버전 충돌 방지 + +[참고자료] +- 유저스토리 +- API설계서 +- 외부시퀀스설계서 +- 내부시퀀스설계서 +- 데이터베이스설치결과서 +- 캐시설치결과서 +- MQ설치결과서 +- 테스트코드표준 +- 패키지구조표준 + +--- + +[설정 Manifest 표준] +- common모듈은 작성하지 않음 +- application.yml에 작성 +- 하드코딩하지 않고 환경변수 사용 + 특히, 데이터베이스, MQ 등의 연결 정보는 반드시 환경변수로 변환해야 함: '' 참조 +- spring.application.name은 서비스명과 동일하게 함 +- Redis Database는 각 서비스마다 다르게 설정 +- 민감한 정보의 디폴트값은 생략하거나 간략한 값으로 지정 +- JWT Secret Key는 모든 서비스가 동일해야 함 +- '[JWT,CORS,Actuaotr,OpenAPI Documentation,Loggings 표준]'을 준수하여 설정 + +[JWT, CORS, Actuaotr,OpenAPI Documentation,Loggings 표준] +``` +# JWT +jwt: + secret: ${JWT_SECRET:} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800} + refresh-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:86400} + +# CORS Configuration +cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*} + +# Actuator +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + base-path: /actuator + endpoint: + health: + show-details: always + show-components: always + health: + livenessState: + enabled: true + readinessState: + enabled: true + +# OpenAPI Documentation +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha + show-actuator: false + +# Logging +logging: + level: + {서비스 패키지 경로}: ${LOG_LEVEL_APP:DEBUG} + org.springframework.web: ${LOG_LEVEL_WEB:INFO} + org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG} + org.hibernate.type: ${LOG_LEVEL_SQL_TYPE:TRACE} + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: + name: ${LOG_FILE_PATH:logs/{서비스명}.log} + +``` + +[루트 build.gradle 표준] +``` +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.0' apply false + id 'io.spring.dependency-management' version '1.1.6' apply false + id 'io.freefair.lombok' version '8.10' apply false +} + +group = 'com.unicorn.{시스템명}' +version = '1.0.0' + +allprojects { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +subprojects { + apply plugin: 'java' + apply plugin: 'io.freefair.lombok' + + java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + tasks.named('test') { + useJUnitPlatform() + } + + // Common versions for all subprojects + ext { + jjwtVersion = '0.12.5' + springdocVersion = '2.5.0' + mapstructVersion = '1.5.5.Final' + commonsLang3Version = '3.14.0' + commonsIoVersion = '2.16.1' + hypersistenceVersion = '3.7.3' + openaiVersion = '0.18.2' + feignJacksonVersion = '13.1' + } +} + +// Configure all subprojects with Spring dependency management +subprojects { + apply plugin: 'io.spring.dependency-management' + + dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:2023.0.2" + } + } +} + +// Configure only service modules (exclude common) +configure(subprojects.findAll { it.name != 'common' }) { + apply plugin: 'org.springframework.boot' + + dependencies { + // Common module dependency + implementation project(':common') + + // Actuator for health checks and monitoring + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // API Documentation (common across all services) + implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}" + + // Testing + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.mockito:mockito-junit-jupiter' + + // Configuration Processor + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + } +} + +// Java version consistency check for all modules +tasks.register('checkJavaVersion') { + doLast { + println "Java Version: ${System.getProperty('java.version')}" + println "Java Home: ${System.getProperty('java.home')}" + } +} + +// Clean task for all subprojects +tasks.register('cleanAll') { + dependsOn subprojects.collect { it.tasks.named('clean') } + description = 'Clean all subprojects' +} + +// Build task for all subprojects +tasks.register('buildAll') { + dependsOn subprojects.collect { it.tasks.named('build') } + description = 'Build all subprojects' +} +``` + + +``` +spring: + datasource: + url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:phonebill_auth} + username: ${DB_USERNAME:phonebill_user} + password: ${DB_PASSWORD:phonebill_pass} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 20 + minimum-idle: 5 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + leak-detection-threshold: 60000 + # JPA 설정 + jpa: + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + format_sql: true + use_sql_comments: true + hibernate: + ddl-auto: ${DDL_AUTO:update} + + # Redis 설정 + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: 2000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + max-wait: -1ms + database: ${REDIS_DATABASE:0} + +``` + + +``` +/** + * Spring Security 설정 + * JWT 기반 인증 및 API 보안 설정 + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + + @Value("${cors.allowed-origins:http://localhost:3000,http://localhost:8080,http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084}") + private String allowedOrigins; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // Actuator endpoints + .requestMatchers("/actuator/**").permitAll() + // Swagger UI endpoints - context path와 상관없이 접근 가능하도록 설정 + .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll() + // Health check + .requestMatchers("/health").permitAll() + // All other requests require authentication + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), + UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + // 환경변수에서 허용할 Origin 패턴 설정 + String[] origins = allowedOrigins.split(","); + configuration.setAllowedOriginPatterns(Arrays.asList(origins)); + + // 허용할 HTTP 메소드 + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + + // 허용할 헤더 + configuration.setAllowedHeaders(Arrays.asList( + "Authorization", "Content-Type", "X-Requested-With", "Accept", + "Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers" + )); + + // 자격 증명 허용 + configuration.setAllowCredentials(true); + + // Pre-flight 요청 캐시 시간 + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} +``` + + + +1) JwtAuthenticationFilter +``` +/** + * JWT 인증 필터 + * HTTP 요청에서 JWT 토큰을 추출하여 인증을 수행 + */ +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String token = jwtTokenProvider.resolveToken(request); + + if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { + String userId = jwtTokenProvider.getUserId(token); + String username = null; + String authority = null; + + try { + username = jwtTokenProvider.getUsername(token); + } catch (Exception e) { + log.debug("JWT에 username 클레임이 없음: {}", e.getMessage()); + } + + try { + authority = jwtTokenProvider.getAuthority(token); + } catch (Exception e) { + log.debug("JWT에 authority 클레임이 없음: {}", e.getMessage()); + } + + if (StringUtils.hasText(userId)) { + // UserPrincipal 객체 생성 (username과 authority가 없어도 동작) + UserPrincipal userPrincipal = UserPrincipal.builder() + .userId(userId) + .username(username != null ? username : "unknown") + .authority(authority != null ? authority : "USER") + .build(); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userPrincipal, + null, + Collections.singletonList(new SimpleGrantedAuthority(authority != null ? authority : "USER")) + ); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.debug("인증된 사용자: {} ({})", userPrincipal.getUsername(), userId); + } + } + + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/actuator") || + path.startsWith("/swagger-ui") || + path.startsWith("/v3/api-docs") || + path.equals("/health"); + } +} +``` + +1) JwtTokenProvider +``` +/** + * JWT 토큰 제공자 + * JWT 토큰의 생성, 검증, 파싱을 담당 + */ +@Slf4j +@Component +public class JwtTokenProvider { + + private final SecretKey secretKey; + private final long tokenValidityInMilliseconds; + + public JwtTokenProvider(@Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-validity:3600}") long tokenValidityInSeconds) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000; + } + + /** + * HTTP 요청에서 JWT 토큰 추출 + */ + public String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + /** + * JWT 토큰 유효성 검증 + */ + public boolean validateToken(String token) { + try { + Jwts.parser() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); + return true; + } catch (SecurityException | MalformedJwtException e) { + log.debug("Invalid JWT signature: {}", e.getMessage()); + } catch (ExpiredJwtException e) { + log.debug("Expired JWT token: {}", e.getMessage()); + } catch (UnsupportedJwtException e) { + log.debug("Unsupported JWT token: {}", e.getMessage()); + } catch (IllegalArgumentException e) { + log.debug("JWT token compact of handler are invalid: {}", e.getMessage()); + } + return false; + } + + /** + * JWT 토큰에서 사용자 ID 추출 + */ + public String getUserId(String token) { + Claims claims = Jwts.parser() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + + return claims.getSubject(); + } + + /** + * JWT 토큰에서 사용자명 추출 + */ + public String getUsername(String token) { + Claims claims = Jwts.parser() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + + return claims.get("username", String.class); + } + + /** + * JWT 토큰에서 권한 정보 추출 + */ + public String getAuthority(String token) { + Claims claims = Jwts.parser() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + + return claims.get("authority", String.class); + } + + /** + * 토큰 만료 시간 확인 + */ + public boolean isTokenExpired(String token) { + try { + Claims claims = Jwts.parser() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + + return claims.getExpiration().before(new Date()); + } catch (Exception e) { + return true; + } + } + + /** + * 토큰에서 만료 시간 추출 + */ + public Date getExpirationDate(String token) { + Claims claims = Jwts.parser() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + + return claims.getExpiration(); + } +} +``` + +1) UserPrincipal +``` +/** + * 인증된 사용자 정보 + * JWT 토큰에서 추출된 사용자 정보를 담는 Principal 객체 + */ +@Getter +@Builder +@RequiredArgsConstructor +public class UserPrincipal { + + /** + * 사용자 고유 ID + */ + private final String userId; + + /** + * 사용자명 + */ + private final String username; + + /** + * 사용자 권한 + */ + private final String authority; + + /** + * 사용자 ID 반환 (별칭) + */ + public String getName() { + return userId; + } + + /** + * 관리자 권한 여부 확인 + */ + public boolean isAdmin() { + return "ADMIN".equals(authority); + } + + /** + * 일반 사용자 권한 여부 확인 + */ + public boolean isUser() { + return "USER".equals(authority) || authority == null; + } +} +``` + + +``` +/** + * Swagger/OpenAPI 설정 + * AI Service API 문서화를 위한 설정 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(apiInfo()) + .addServersItem(new Server() + .url("http://localhost:8084") + .description("Local Development")) + .addServersItem(new Server() + .url("{protocol}://{host}:{port}") + .description("Custom Server") + .variables(new io.swagger.v3.oas.models.servers.ServerVariables() + .addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable() + ._default("http") + .description("Protocol (http or https)") + .addEnumItem("http") + .addEnumItem("https")) + .addServerVariable("host", new io.swagger.v3.oas.models.servers.ServerVariable() + ._default("localhost") + .description("Server host")) + .addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable() + ._default("8084") + .description("Server port")))) + .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) + .components(new Components() + .addSecuritySchemes("Bearer Authentication", createAPIKeyScheme())); + } + + private Info apiInfo() { + return new Info() + .title("AI Service API") + .description("AI 기반 시간별 상세 일정 생성 및 장소 추천 정보 API") + .version("1.0.0") + .contact(new Contact() + .name("TripGen Development Team") + .email("dev@tripgen.com")); + } + + private SecurityScheme createAPIKeyScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .bearerFormat("JWT") + .scheme("bearer"); + } +} +``` diff --git a/claude/standard_comment.md b/claude/standard_comment.md new file mode 100644 index 0000000..5200015 --- /dev/null +++ b/claude/standard_comment.md @@ -0,0 +1,518 @@ +# 개발주석표준 가이드 + +## 📋 개요 + +이 문서는 CMS 프로젝트의 JavaDoc 주석 작성 표준을 정의합니다. 일관된 주석 스타일을 통해 코드의 가독성과 유지보수성을 향상시키는 것을 목표로 합니다. + +## 🎯 주석 작성 원칙 + +### 1. **기본 원칙** +- **명확성**: 코드의 의도와 동작을 명확하게 설명 +- **일관성**: 프로젝트 전체에서 동일한 스타일 적용 +- **완전성**: 모든 public 메서드와 클래스에 주석 작성 +- **최신성**: 코드 변경 시 주석도 함께 업데이트 + +### 2. **주석 대상** +- **필수**: public 클래스, 인터페이스, 메서드 +- **권장**: protected 메서드, 중요한 필드 +- **선택**: private 메서드 (복잡한 로직인 경우) + +## 📝 JavaDoc 기본 문법 + +### 1. **기본 구조** +```java +/** + * 클래스나 메서드의 간단한 설명 (첫 번째 문장) + * + *

상세한 설명이 필요한 경우 여기에 작성합니다.

+ * + * @param paramName 파라미터 설명 + * @return 반환값 설명 + * @throws ExceptionType 예외 상황 설명 + * @since 1.0 + * @author 작성자명 + * @see 관련클래스#메서드 + */ +``` + +### 2. **주요 JavaDoc 태그** + +| 태그 | 설명 | 사용 위치 | +|------|------|-----------| +| `@param` | 메서드 파라미터 설명 | 메서드 | +| `@return` | 반환값 설명 | 메서드 | +| `@throws` | 예외 상황 설명 | 메서드 | +| `@since` | 도입 버전 | 클래스, 메서드 | +| `@author` | 작성자 | 클래스 | +| `@version` | 버전 정보 | 클래스 | +| `@see` | 관련 항목 참조 | 모든 곳 | +| `@apiNote` | API 사용 시 주의사항 | 메서드 | +| `@implNote` | 구현 관련 참고사항 | 메서드 | + +## 🎨 HTML 태그 활용 가이드 + +### 1. **HTML 태그 사용 이유** + +JavaDoc은 소스코드 주석을 파싱하여 **HTML 형태의 API 문서**를 자동 생성합니다. HTML 태그를 사용하면: + +- **가독성 향상**: 구조화된 문서로 이해하기 쉬움 +- **자동 문서화**: JavaDoc 도구가 예쁜 HTML 문서 생성 +- **IDE 지원**: 개발 도구에서 리치 텍스트로 표시 +- **표준 준수**: Oracle JavaDoc 스타일 가이드 준수 + +### 2. **자주 사용되는 HTML 태그** + +#### **텍스트 서식** +```java +/** + *

단락을 구분할 때 사용합니다.

+ * 중요한 내용을 강조할 때 사용합니다. + * 이탤릭체로 표시할 때 사용합니다. + * method()와 같은 코드를 표시할 때 사용합니다. + */ +``` + +#### **목록 작성** +```java +/** + *

주요 기능:

+ *
    + *
  • 첫 번째 기능
  • + *
  • 두 번째 기능
  • + *
  • 세 번째 기능
  • + *
+ * + *

처리 과정:

+ *
    + *
  1. 첫 번째 단계
  2. + *
  3. 두 번째 단계
  4. + *
  5. 세 번째 단계
  6. + *
+ */ +``` + +#### **코드 블록** +```java +/** + *

사용 예시:

+ *
+ * AuthController controller = new AuthController();
+ * LoginRequest request = new LoginRequest("user", "password");
+ * ResponseEntity<LoginResponse> response = controller.login(request);
+ * 
+ */ +``` + +#### **테이블** +```java +/** + *

HTTP 상태 코드:

+ * + * + * + * + * + *
상태 코드설명
200성공
400잘못된 요청
401인증 실패
+ */ +``` + +### 3. **HTML 태그 사용 규칙** + +- **<와 >**: 제네릭 타입 표현 시 `<T>` 사용 +- **줄바꿈**: `
` 태그 사용 (가급적 `

` 태그 권장) +- **링크**: `{@link ClassName#methodName}` 사용 +- **인라인 코드**: `{@code variableName}` 또는 `` 사용 + +## 📋 클래스 주석 표준 + +### 1. **클래스 주석 템플릿** +```java +/** + * 클래스의 간단한 설명 + * + *

클래스의 상세한 설명과 목적을 여기에 작성합니다.

+ * + *

주요 기능:

+ *
    + *
  • 기능 1
  • + *
  • 기능 2
  • + *
  • 기능 3
  • + *
+ * + *

사용 예시:

+ *
+ * ClassName instance = new ClassName();
+ * instance.someMethod();
+ * 
+ * + *

주의사항:

+ *
    + *
  • 주의사항 1
  • + *
  • 주의사항 2
  • + *
+ * + * @author 작성자명 + * @version 1.0 + * @since 2024-01-01 + * + * @see 관련클래스1 + * @see 관련클래스2 + */ +public class ClassName { + // ... +} +``` + +### 2. **Controller 클래스 주석 예시** +```java +/** + * 사용자 관리 API 컨트롤러 + * + *

사용자 등록, 조회, 수정, 삭제 기능을 제공하는 REST API 컨트롤러입니다.

+ * + *

주요 기능:

+ *
    + *
  • 사용자 등록 및 인증
  • + *
  • 사용자 정보 조회 및 수정
  • + *
  • 사용자 권한 관리
  • + *
+ * + *

API 엔드포인트:

+ *
    + *
  • POST /api/users - 사용자 등록
  • + *
  • GET /api/users/{id} - 사용자 조회
  • + *
  • PUT /api/users/{id} - 사용자 수정
  • + *
  • DELETE /api/users/{id} - 사용자 삭제
  • + *
+ * + *

보안 고려사항:

+ *
    + *
  • 모든 엔드포인트는 인증이 필요합니다
  • + *
  • 개인정보 처리 시 데이터 마스킹 적용
  • + *
  • 입력값 검증 및 XSS 방지
  • + *
+ * + * @author cms-team + * @version 1.0 + * @since 2024-01-01 + * + * @see UserService + * @see UserRepository + * @see UserDTO + */ +@RestController +@RequestMapping("/api/users") +public class UserController { + // ... +} +``` + +## 📋 메서드 주석 표준 + +### 1. **메서드 주석 템플릿** +```java +/** + * 메서드의 간단한 설명 + * + *

메서드의 상세한 설명과 동작을 여기에 작성합니다.

+ * + *

처리 과정:

+ *
    + *
  1. 첫 번째 단계
  2. + *
  3. 두 번째 단계
  4. + *
  5. 세 번째 단계
  6. + *
+ * + *

주의사항:

+ *
    + *
  • 주의사항 1
  • + *
  • 주의사항 2
  • + *
+ * + * @param param1 첫 번째 파라미터 설명 + * - 추가 설명이 필요한 경우 + * @param param2 두 번째 파라미터 설명 + * + * @return 반환값 설명 + * - 성공 시: 설명 + * - 실패 시: 설명 + * + * @throws ExceptionType1 예외 상황 1 설명 + * @throws ExceptionType2 예외 상황 2 설명 + * + * @apiNote API 사용 시 주의사항 + * + * @see 관련메서드1 + * @see 관련메서드2 + * + * @since 1.0 + */ +public ReturnType methodName(Type param1, Type param2) { + // ... +} +``` + +### 2. **API 메서드 주석 예시** +```java +/** + * 사용자 로그인 처리 + * + *

사용자 ID와 비밀번호를 검증하여 JWT 토큰을 생성합니다.

+ * + *

처리 과정:

+ *
    + *
  1. 입력값 검증 (@Valid 어노테이션)
  2. + *
  3. 사용자 인증 정보 확인
  4. + *
  5. JWT 토큰 생성
  6. + *
  7. 사용자 세션 시작
  8. + *
  9. 로그인 메트릭 업데이트
  10. + *
+ * + *

보안 고려사항:

+ *
    + *
  • 비밀번호는 BCrypt로 암호화된 값과 비교
  • + *
  • 로그인 실패 시 상세 정보 노출 방지
  • + *
  • 로그인 시도 로그 기록
  • + *
+ * + * @param request 로그인 요청 정보 + * - username: 사용자 ID (3-50자, 필수) + * - password: 비밀번호 (6-100자, 필수) + * + * @return ResponseEntity<LoginResponse> 로그인 응답 정보 + * - 성공 시: 200 OK + JWT 토큰, 사용자 역할, 만료 시간 + * - 실패 시: 401 Unauthorized + 에러 메시지 + * + * @throws InvalidCredentialsException 인증 정보가 올바르지 않은 경우 + * @throws RuntimeException 로그인 처리 중 시스템 오류 발생 시 + * + * @apiNote 보안상 이유로 로그인 실패 시 구체적인 실패 사유를 반환하지 않습니다. + * + * @see AuthService#login(LoginRequest) + * @see UserSessionService#startSession(String, String, java.time.Instant) + * + * @since 1.0 + */ +@PostMapping("/login") +public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + // ... +} +``` + +## 📋 필드 주석 표준 + +### 1. **필드 주석 템플릿** +```java +/** + * 필드의 간단한 설명 + * + *

필드의 상세한 설명과 용도를 여기에 작성합니다.

+ * + *

주의사항:

+ *
    + *
  • 주의사항 1
  • + *
  • 주의사항 2
  • + *
+ * + * @since 1.0 + */ +private final ServiceType serviceName; +``` + +### 2. **의존성 주입 필드 예시** +```java +/** + * 인증 서비스 + * + *

사용자 로그인/로그아웃 처리 및 JWT 토큰 관리를 담당합니다.

+ * + *

주요 기능:

+ *
    + *
  • 사용자 인증 정보 검증
  • + *
  • JWT 토큰 생성 및 검증
  • + *
  • 로그인/로그아웃 처리
  • + *
+ * + * @see AuthService + * @since 1.0 + */ +private final AuthService authService; +``` + +## 📋 예외 클래스 주석 표준 + +```java +/** + * 사용자 인증 실패 예외 + * + *

로그인 시 사용자 ID 또는 비밀번호가 올바르지 않을 때 발생하는 예외입니다.

+ * + *

발생 상황:

+ *
    + *
  • 존재하지 않는 사용자 ID
  • + *
  • 잘못된 비밀번호
  • + *
  • 계정 잠금 상태
  • + *
+ * + *

처리 방법:

+ *
    + *
  • 사용자에게 일반적인 오류 메시지 표시
  • + *
  • 보안 로그에 상세 정보 기록
  • + *
  • 브루트 포스 공격 방지 로직 실행
  • + *
+ * + * @author cms-team + * @version 1.0 + * @since 2024-01-01 + * + * @see AuthService + * @see SecurityException + */ +public class InvalidCredentialsException extends RuntimeException { + // ... +} +``` + +## 📋 인터페이스 주석 표준 + +```java +/** + * 사용자 인증 서비스 인터페이스 + * + *

사용자 로그인, 로그아웃, 토큰 관리 등 인증 관련 기능을 정의합니다.

+ * + *

구현 클래스:

+ *
    + *
  • {@link AuthServiceImpl} - 기본 구현체
  • + *
  • {@link LdapAuthService} - LDAP 연동 구현체
  • + *
+ * + *

주요 기능:

+ *
    + *
  • 사용자 인증 및 토큰 생성
  • + *
  • 로그아웃 및 토큰 무효화
  • + *
  • 토큰 유효성 검증
  • + *
+ * + * @author cms-team + * @version 1.0 + * @since 2024-01-01 + * + * @see AuthServiceImpl + * @see TokenProvider + */ +public interface AuthService { + // ... +} +``` + +## 📋 Enum 주석 표준 + +```java +/** + * 사용자 역할 열거형 + * + *

시스템 사용자의 권한 수준을 정의합니다.

+ * + *

권한 계층:

+ *
    + *
  1. {@link #ADMIN} - 최고 관리자 권한
  2. + *
  3. {@link #MANAGER} - 관리자 권한
  4. + *
  5. {@link #USER} - 일반 사용자 권한
  6. + *
+ * + * @author cms-team + * @version 1.0 + * @since 2024-01-01 + */ +public enum Role { + + /** + * 시스템 관리자 + * + *

모든 시스템 기능에 대한 접근 권한을 가집니다.

+ * + *

주요 권한:

+ *
    + *
  • 사용자 관리
  • + *
  • 시스템 설정
  • + *
  • 모든 데이터 접근
  • + *
+ */ + ADMIN, + + /** + * 관리자 + * + *

제한된 관리 기능에 대한 접근 권한을 가집니다.

+ */ + MANAGER, + + /** + * 일반 사용자 + * + *

기본적인 시스템 기능에 대한 접근 권한을 가집니다.

+ */ + USER +} +``` + +## 📋 주석 작성 체크리스트 + +### ✅ **클래스 주석 체크리스트** +- [ ] 클래스의 목적과 역할 명시 +- [ ] 주요 기능 목록 작성 +- [ ] 사용 예시 코드 포함 +- [ ] 주의사항 및 제약사항 명시 +- [ ] @author, @version, @since 태그 작성 +- [ ] 관련 클래스 @see 태그 추가 + +### ✅ **메서드 주석 체크리스트** +- [ ] 메서드의 목적과 동작 설명 +- [ ] 처리 과정 단계별 설명 +- [ ] 모든 @param 태그 작성 +- [ ] @return 태그 작성 (void 메서드 제외) +- [ ] 가능한 예외 @throws 태그 작성 +- [ ] 보안 관련 주의사항 명시 +- [ ] 관련 메서드 @see 태그 추가 + +### ✅ **HTML 태그 체크리스트** +- [ ] 목록은 `
    `, `
      `, `
    1. ` 태그 사용 +- [ ] 강조는 `` 태그 사용 +- [ ] 단락 구분은 `

      ` 태그 사용 +- [ ] 코드는 `` 또는 `

      ` 태그 사용
      +- [ ] 제네릭 타입은 `<`, `>` 사용
      +
      +## 📋 도구 및 설정
      +
      +### 1. **JavaDoc 생성**
      +```bash
      +# Gradle 프로젝트
      +./gradlew javadoc
      +
      +# Maven 프로젝트
      +mvn javadoc:javadoc
      +
      +# 직접 실행
      +javadoc -d docs -cp classpath src/**/*.java
      +```
      +
      +### 2. **IDE 설정**
      +- **IntelliJ IDEA**: Settings > Editor > Code Style > Java > JavaDoc
      +- **Eclipse**: Window > Preferences > Java > Code Style > Code Templates
      +- **VS Code**: Java Extension Pack + JavaDoc 플러그인
      +
      +### 3. **정적 분석 도구**
      +- **Checkstyle**: JavaDoc 누락 검사
      +- **SpotBugs**: 주석 품질 검사
      +- **SonarQube**: 문서화 품질 메트릭
      +
      +## 📋 참고 자료
      +
      +- [Oracle JavaDoc 가이드](https://docs.oracle.com/javase/8/docs/technotes/tools/windows/javadoc.html)
      +- [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html)
      +- [Spring Framework 주석 스타일](https://github.com/spring-projects/spring-framework/wiki/Code-Style)
      +
      +---
      +
      +> **💡 팁**: 이 가이드를 팀 내에서 공유하고, 코드 리뷰 시 주석 품질도 함께 검토하세요!
      \ No newline at end of file
      diff --git a/claude/standard_package_structure.md b/claude/standard_package_structure.md
      new file mode 100644
      index 0000000..81a4890
      --- /dev/null
      +++ b/claude/standard_package_structure.md
      @@ -0,0 +1,173 @@
      +패키지 구조 표준
      +
      +레이어드 아키텍처 패키지 구조
      +
      +├── {SERVICE}
      +│   ├── domain
      +│   ├── service
      +│   ├── controller
      +│   ├── dto
      +│   ├── repository
      +│   │   ├── jpa
      +│   │   └── entity
      +│   ├── config
      +└── common
      +        ├── dto
      +        ├── util
      +        ├── response
      +        └── exception
      +
      +Package명: 
      +- com.{ORG}.{ROOT}.{SERVICE}
      +예) com.unicorn.lifesub.mysub, com.unicorn.lifesub.common
      +
      +변수: 
      +- ORG: 회사 또는 조직명
      +- ROOT: Root Project 명
      +- SERVICE: 서비스명으로 Root Project의 서브 프로젝트임
      +
      +
      +예시
      +
      +com.unicorn.lifesub.member
      + ├── MemberApplication.java
      + ├── controller
      + │   └── MemberController.java
      + ├── dto
      + │   ├── LoginRequest.java
      + │   ├── LogoutRequest.java
      + │   └── LogoutResponse.java  
      + ├── service
      + │   ├── MemberService.java
      + │   └── MemberServiceImpl.java
      + ├── domain
      + │   └── Member.java
      + ├── repository  
      + │   ├── entity
      + │   │   └── MemberEntity.java
      + │   └── jpa
      + │       └── MemberRepository.java
      + └── config
      +     ├── SecurityConfig.java
      +     ├── DataLoader.java
      +     ├── SwaggerConfig.java
      +     └── jwt
      +         ├── JwtAuthenticationFilter.java
      +         ├── JwtTokenProvider.java
      +         └── CustomUserDetailsService.java
      +
      +
      +클린 아키텍처 패키지 구조 
      +
      +├── biz
      +│   ├── usecase
      +│   │   ├── in
      +│   │   ├── out
      +│   ├── service
      +│   └── domain
      +│   └── dto
      +├── infra
      +│   ├── controller
      +│   ├── dto
      +│   ├── gateway
      +│   │   ├── repository
      +│   │   └── entity
      +│   └── config    
      +
      +
      +Package명: 
      +- com.{ORG}.{ROOT}.{SERVICE}.biz
      +- com.{ORG}.{ROOT}.{SERVICE}.infra
      +예) com.unicorn.lifesub.mysub.biz, com.unicorn.lifesub.common
      +
      +변수: 
      +- ORG: 회사 또는 조직명
      +- ROOT: Root Project 명
      +- SERVICE: 서비스명으로 Root Project의 서브 프로젝트임
      +
      +예시
      +
      +
      +com.unicorn.lifesub.mysub
      + ├── biz
      + │   ├── dto
      + │   │   ├── CategoryResponse.java
      + │   │   ├── ServiceListResponse.java
      + │   │   ├── MySubResponse.java
      + │   │   ├── SubDetailResponse.java
      + │   │   └── TotalFeeResponse.java
      + │   ├── service
      + │   │   ├── FeeLevel.java
      + │   │   └── MySubscriptionService.java
      + │   ├── usecase
      + │   │   ├── in
      + │   │   │   ├── CancelSubscriptionUseCase.java
      + │   │   │   ├── CategoryUseCase.java
      + │   │   │   ├── MySubscriptionsUseCase.java
      + │   │   │   ├── SubscribeUseCase.java
      + │   │   │   ├── SubscriptionDetailUseCase.java
      + │   │   │   └── TotalFeeUseCase.java
      + │   │   └── out 
      + │   │       ├── MySubscriptionReader.java
      + │   │       ├── MySubscriptionWriter.java
      + │   │       └── SubscriptionReader.java
      + │   └── domain
      + │       ├── Category.java
      + │       ├── MySubscription.java
      + │       └── Subscription.java
      + └── infra  
      +     ├── MySubApplication.java 
      +     ├── controller
      +     │   ├── CategoryController.java
      +     │   ├── MySubController.java
      +     │   └── ServiceController.java
      +     ├── config
      +     │   ├── DataLoader.java
      +     │   ├── SecurityConfig.java
      +     │   ├── SwaggerConfig.java
      +     │   └── jwt
      +     │       ├── JwtAuthenticationFilter.java
      +     │       └── JwtTokenProvider.java
      +     └── gateway
      +         ├── entity
      +         │   ├── CategoryEntity.java   
      +         │   ├── MySubscriptionEntity.java
      +         │   └── SubscriptionEntity.java
      +         ├── repository
      +         │   ├── CategoryJpaRepository.java
      +         │   ├── MySubscriptionJpaRepository.java
      +         │   └── SubscriptionJpaRepository.java  
      +         ├── MySubscriptionGateway.java
      +         └── SubscriptionGateway.java
      +
      +
      +---
      +
      +common 모듈 패키지 구조
      +
      +├── common
      +    ├── dto
      +    ├── entity
      +    ├── config
      +    ├── util
      +    └── exception
      +
      +
      +com.unicorn.lifesub.common
      + ├── dto
      + │   ├── ApiResponse.java
      + │   ├── JwtTokenDTO.java
      + │   ├── JwtTokenRefreshDTO.java
      + │   └── JwtTokenVerifyDTO.java
      + ├── config
      + │   └── JpaConfig.java
      + ├── entity
      + │   └── BaseTimeEntity.java        
      + ├── aop  
      + │   └── LoggingAspect.java
      + └── exception
      +     ├── ErrorCode.java
      +     ├── InfraException.java
      +     └── BusinessException.java
      +
      +
      diff --git a/claude/standard_testcode.md b/claude/standard_testcode.md
      new file mode 100644
      index 0000000..1eec6d5
      --- /dev/null
      +++ b/claude/standard_testcode.md
      @@ -0,0 +1,214 @@
      +1.TDD 기본 이해
      +
      +1) TDD 목적  
      +   코드 품질 향상으로 유지보수 비용 절감
      +- 설계 품질 향상: 테스트를 먼저 작성하면서 코드 구조와 인터페이스를 먼저 고민
      +- 회귀 버그 방지: 테스트 자동화로 코드 변경 시 기존 기능의 오작동을 빠르게 감지
      +- 리팩토링 검증: 코드 개선 후 테스트 코드로 검증할 수 있어 리팩토링에 대한 자신감 확보
      +- 살아있는 문서: 테스트 코드에 샘플 데이터를 이용한 예시가 있으므로 실제 코드의 동작 방식을 문서화
      +
      +---  
      +
      +2) 테스트 유형
      +- 단위 테스트(Unit Test): 외부 기술요소(DB, 웹서버 등)와의 인터페이스 없이 단위 클래스의 퍼블릭 메소드 테스트
      +- 통합 테스트(Integration Test): 일부 아키텍처 영역에서 외부 기술 요소와 인터페이스까지 테스트
      +- E2E 테스트(E2E Test): 모든 아키텍처 영역에서 외부 기술 요소와 인터페이스를 테스트
      +
      +* 아키텍처 영역: 클래스를 아키텍처적으로 나눈 레이어를 의미함(예: controller, service, domain, repository)
      +
      +---
      +
      +3) 테스트 피라미드
      +
      +- 단위 테스트 70%, 통합 테스트 20%, E2E 테스트 10%의 비율로 권장
      +- Mike Cohn이 "Succeeding with Agile"에서 처음 제시한 개념
      +- 단위 테스트에서 E2E 테스트로 가면서 속도는 느려지고 비용은 높아짐
      +
      +---
      +
      +4) Red-Green-Refactor 사이클
      +
      +Red-Green-Refactor는 TDD(Test-Driven Development)를 수행하는 핵심 사이클임
      +- Red (실패하는 테스트 작성)
      +    - 새로운 기능에 대한 테스트 코드를 먼저 작성
      +    - 아직 구현이 없으므로 테스트는 실패
      +    - 이 단계에서 기능의 인터페이스를 설계
      +- Green (테스트 통과하는 코드 작성)
      +    - 테스트를 통과하는 최소한의 코드 작성
      +    - 품질보다는 동작에 초점
      +- Refactor (리팩토링)
      +    - 중복 제거, 가독성 개선
      +    - 테스트는 계속 통과하도록 유지
      +    - 코드 품질 개선
      +
      +---
      +2. 테스트 전략
      +
      +1) 테스트 수행 원칙: FIRST 원칙
      +- Fast: 테스트는 빠르게 실행되어야 함
      +- Isolated: 각 테스트는 독립적이어야 함
      +- Repeatable: 어떤 환경에서도 동일한 결과가 나와야 함
      +- Self-validating: 테스트는 성공/실패가 명확해야 함
      +- Timely: 테스트는 실제 코드 작성 전/직후에 작성되어야 함
      +
      +---
      +
      +2) 공통 전략: 테스트 코드 작성 관련
      +- 한 테스트는 한 가지만 테스트
      +- Given-When-Then 패턴 사용
      +    - Given(준비): 테스트에 필요한 상태와 데이터를 설정
      +    - When(실행): 테스트하려는 동작을 수행
      +    - Then(검증): 기대하는 결과가 나왔는지 확인
      +- 깨끗한 테스트 코드 작성
      +    - 테스트 의도를 명확히 하는 네이밍
      +    - 테스트 케이스는 시나리오 중심으로 구성
      +    - 공통 설정은 별도 메서드로 분리
      +    - 매직넘버 대신 상수 사용
      +    - 테스트 데이터는 최소한으로 사용
      +- 경계값 테스트가 중요
      +    - null 값
      +    - 빈 컬렉션
      +    - 최대/최소값
      +    - 0이나 1과 같은 특수값
      +    - 잘못된 포맷의 입력값
      +
      +---
      +
      +2) 공통 전략: 테스트 코드 관리 관련
      +- 비용 효율적인 테스트 전략
      +    - 자주 변경되는 비즈니스 로직에 대한 테스트 강화
      +    - 실제 운영 환경과 유사한 통합 테스트 구성
      +    - 테스트 실행 시간과 리소스 사용량 모니터링
      +- 지속적인 테스트 개선
      +    - 테스트 커버리지보다 테스트 품질 중시
      +    - 깨진 테스트는 즉시 수정하는 문화 정착
      +    - 테스트 코드도 실제 코드만큼 중요하게 관리
      +- 팀 협업을 위한 가이드라인 수립
      +    - 테스트 네이밍 컨벤션 수립
      +    - 테스트 데이터 관리 전략 합의
      +    - 테스트 실패 시 대응 프로세스 수립
      +
      +---
      +
      +3) 단위 테스트 전략
      +- 테스트 범위 명확화
      +    - 클래스의 각 public 메소드가 수행하는 단일 책임을 검증
      +    - private 메서드는 public 메서드를 통해 간접적으로 테스트
      +- 외부 의존성 처리
      +    - DB, 파일, 네트워크 등 외부 시스템은 가짜 객체로 대체(Mocking)
      +    - 테스트 더블(스턴트맨을 Stunt Double이라고 함. 대역으로 이해)은 꼭 필요한 동작만 구현
      +        - Mock: 메소드 호출 여부와 파라미터 검증
      +        - Stub: 반환값의 일치 여부 검증
      +        - Spy: Mocking하지 않고 실제 메소드를 감싸서 호출횟수, 호출순서등 추가 정보 검증
      +- 격리성 확보
      +    - 테스트 간 상호 영향 없도록 설계: 동일 공유 자원/객체를 사용하지 않게 함
      +    - 테스트 실행 순서와 무관하게 동작
      +- 가독성과 유지보수성
      +    - 테스트 대상 클래스당 하나의 테스트 클래스
      +    - 테스트 메서드는 한 가지 시나리오만 검증
      +
      +---
      +
      +4) 단위 테스트 시 Mocking 전략
      +- 외부 시스템(DB, 외부 API 등)은 반드시 Mocking
      +- 같은 레이어의 의존성 있는 클래스는 실제 객체 사용
      +- 예외적으로 의존 객체가 매우 복잡하거나 무거운 경우 Mocking 고려
      +
      +* 참고: 모의 객체 테스트 균형점 찾기  
      +  출처: When to mocking by Uncle Bob(https://blog.cleancoder.com/uncle-bob/2014/05/10/WhenToMock.html)
      +- 모의 객체를 이용 안 하면: 테스트가 오래 걸리고 결과를 신뢰하기 어려우며 인프라에 너무 많은 영향을 받음
      +- 모의 객체를 지나치게 사용하면: 복잡하고 수정에 영향을 너무 많이 받으며 모의 인터페이스가 폭발적으로 증가
      +- 균형점 찾기
      +    - 아키텍처적으로 중요한 경계에서만 모의 테스트를 수행하고, 그 경계 안에서는 하지 않는다.  
      +      (Mock across architecturally significant boundaries, but not within those boundaries.)
      +    - 여기서 경계란 Controller, Service, Repository, Domain등의 레이어를 의미함
      +
      +---
      +5) 통합 테스트 전략
      +- 웹 서버 인터페이스
      +    - @WebMvcTest, @WebFluxTest 활용
      +    - Controller 계층의 요청/응답 검증
      +    - Service 계층은 Mocking 처리
      +
      +- Database 인터페이스
      +    - @DataJpaTest 활용
      +    - TestContainer로 실제 DB 엔진 실행
      +
      +- 외부 서비스 인터페이스
      +    - WireMock 등을 활용한 Mocking
      +    - 실제 API 스펙 기반 테스트
      +
      +- 테스트 환경 구성
      +    - 테스트용 별도 설정 파일 구성
      +    - 테스트 데이터는 테스트 시작 시 초기화
      +    - @Transactional을 활용한 테스트 격리
      +    - 테스트 간 독립성 보장
      +
      +---
      +6) E2E 테스트 전략
      +- 원칙
      +    - 단위 테스트나 컴포넌트 테스트에서 놓칠 수 있는 시나리오를 찾아내는 것이 목표임
      +    - 조건별 로직이나 분기 상황(edge cases)이 아닌 상위 수준의 일반적인 시나리오만 테스트
      +    - 만약 어떤 시스템 테스트 시나리오가 실패 했는데 단위 테스트나 통합 테스트가 없다면 만들어야 함
      +
      +- 운영과 동일한 테스트 환경 구성: 웹서버/WAS, DB, 캐시, MQ, 외부시스템
      +- 테스트 데이터 관리
      +    - 테스트용 마스터 데이터 구성
      +    - 시나리오별 테스트 데이터 세트 준비
      +    - 데이터 초기화 및 정리 자동화
      +- 테스트 자동화 전략
      +    - UI 테스트: Selenium, Cucumber, Playwright 등 도구 활용
      +    - API 테스트: Rest-Assured, Postman 등 도구 활용
      +
      +---
      +
      +7) 테스트 코드 네이밍 컨벤션
      +
      +- 패키지 네이밍
      +```
      +[Syntax]
      +{프로덕션패키지}.test.{테스트유형}
      +
      +[Example]
      +- 단위테스트: com.company.order.test.unit
      +- 통합테스트: com.company.order.test.integration
      +- E2E테스트: com.company.order.test.e2e
      +```
      +
      +- 클래스 네이밍
      +```
      +[Syntax]
      +{대상클래스}{테스트유형}Test
      +
      +[Example]
      +- 단위테스트: OrderServiceUnitTest
      +- 통합테스트: OrderServiceIntegrationTest
      +- E2E테스트: OrderServiceE2ETest
      +```
      +
      +- 메소드 네이밍
      +```
      +[Syntax]
      +given{초기상태}_when{행위}_then{결과}
      +
      +[Example]
      +givenEmptyCart_whenAddItem_thenSuccess()
      +givenInvalidToken_whenAuthenticate_thenThrowException()
      +givenExistingUser_whenUpdateProfile_thenProfileUpdated()
      +```
      +
      +- 테스트 데이터 네이밍
      +```
      +[Syntax]
      +상수: {상태}_{대상}
      +변수: {상태}{대상}
      +
      +[Example]
      +// 상수
      +VALID_USER_ID = 1L
      +EMPTY_ORDER_LIST = Collections.emptyList()
      +
      +// 변수
      +normalUser = new User(...)
      +emptyCart = new Cart()
      +```
      diff --git a/common/build.gradle b/common/build.gradle
      new file mode 100644
      index 0000000..f1d6d37
      --- /dev/null
      +++ b/common/build.gradle
      @@ -0,0 +1,35 @@
      +plugins {
      +    id 'java-library'
      +    id 'org.springframework.boot'
      +    id 'io.spring.dependency-management'
      +}
      +
      +// common 모듈은 실행 가능한 jar가 아니므로 bootJar 비활성화
      +bootJar {
      +    enabled = false
      +}
      +
      +jar {
      +    enabled = true
      +}
      +
      +dependencies {
      +    // Spring Boot Starters
      +    api 'org.springframework.boot:spring-boot-starter-web'
      +    api 'org.springframework.boot:spring-boot-starter-security'
      +    api 'org.springframework.boot:spring-boot-starter-data-jpa'
      +    api 'org.springframework.boot:spring-boot-starter-validation'
      +
      +    // JWT
      +    api "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
      +    runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
      +    runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
      +
      +    // Utilities
      +    api "org.apache.commons:commons-lang3:${commonsLang3Version}"
      +    api "commons-io:commons-io:${commonsIoVersion}"
      +
      +    // Jackson for JSON
      +    api 'com.fasterxml.jackson.core:jackson-databind'
      +    api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
      +}
      diff --git a/common/src/main/java/com/kt/event/common/dto/ApiResponse.java b/common/src/main/java/com/kt/event/common/dto/ApiResponse.java
      new file mode 100644
      index 0000000..e4605e1
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/dto/ApiResponse.java
      @@ -0,0 +1,78 @@
      +package com.kt.event.common.dto;
      +
      +import com.fasterxml.jackson.annotation.JsonInclude;
      +import lombok.AccessLevel;
      +import lombok.AllArgsConstructor;
      +import lombok.Getter;
      +
      +import java.time.LocalDateTime;
      +
      +/**
      + * 공통 API 응답 래퍼
      + * 모든 API 응답을 감싸는 표준 응답 포맷
      + *
      + * @param  응답 데이터 타입
      + */
      +@Getter
      +@AllArgsConstructor(access = AccessLevel.PRIVATE)
      +@JsonInclude(JsonInclude.Include.NON_NULL)
      +public class ApiResponse {
      +
      +    /**
      +     * 성공 여부
      +     */
      +    private final boolean success;
      +
      +    /**
      +     * 응답 데이터
      +     */
      +    private final T data;
      +
      +    /**
      +     * 에러 코드 (실패 시)
      +     */
      +    private final String errorCode;
      +
      +    /**
      +     * 에러 메시지 (실패 시)
      +     */
      +    private final String message;
      +
      +    /**
      +     * 응답 시간
      +     */
      +    private final LocalDateTime timestamp;
      +
      +    /**
      +     * 성공 응답 생성 (데이터 포함)
      +     *
      +     * @param data 응답 데이터
      +     * @param   응답 데이터 타입
      +     * @return API 응답
      +     */
      +    public static  ApiResponse success(T data) {
      +        return new ApiResponse<>(true, data, null, null, LocalDateTime.now());
      +    }
      +
      +    /**
      +     * 성공 응답 생성 (데이터 없음)
      +     *
      +     * @param  응답 데이터 타입
      +     * @return API 응답
      +     */
      +    public static  ApiResponse success() {
      +        return new ApiResponse<>(true, null, null, null, LocalDateTime.now());
      +    }
      +
      +    /**
      +     * 실패 응답 생성
      +     *
      +     * @param errorCode 에러 코드
      +     * @param message   에러 메시지
      +     * @param        응답 데이터 타입
      +     * @return API 응답
      +     */
      +    public static  ApiResponse error(String errorCode, String message) {
      +        return new ApiResponse<>(false, null, errorCode, message, LocalDateTime.now());
      +    }
      +}
      diff --git a/common/src/main/java/com/kt/event/common/dto/ErrorResponse.java b/common/src/main/java/com/kt/event/common/dto/ErrorResponse.java
      new file mode 100644
      index 0000000..7e45d1a
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/dto/ErrorResponse.java
      @@ -0,0 +1,122 @@
      +package com.kt.event.common.dto;
      +
      +import com.fasterxml.jackson.annotation.JsonInclude;
      +import lombok.AccessLevel;
      +import lombok.AllArgsConstructor;
      +import lombok.Builder;
      +import lombok.Getter;
      +
      +import java.time.LocalDateTime;
      +import java.util.List;
      +
      +/**
      + * 에러 응답
      + * API 에러 발생 시 반환되는 상세 에러 정보
      + */
      +@Getter
      +@Builder
      +@AllArgsConstructor(access = AccessLevel.PRIVATE)
      +@JsonInclude(JsonInclude.Include.NON_NULL)
      +public class ErrorResponse {
      +
      +    /**
      +     * 성공 여부 (항상 false)
      +     */
      +    @Builder.Default
      +    private final boolean success = false;
      +
      +    /**
      +     * 에러 코드
      +     */
      +    private final String errorCode;
      +
      +    /**
      +     * 에러 메시지
      +     */
      +    private final String message;
      +
      +    /**
      +     * 상세 에러 정보 (선택)
      +     */
      +    private final String details;
      +
      +    /**
      +     * 유효성 검증 에러 목록 (선택)
      +     */
      +    private final List fieldErrors;
      +
      +    /**
      +     * 에러 발생 시간
      +     */
      +    @Builder.Default
      +    private final LocalDateTime timestamp = LocalDateTime.now();
      +
      +    /**
      +     * 필드 유효성 검증 에러
      +     */
      +    @Getter
      +    @Builder
      +    @AllArgsConstructor
      +    public static class FieldError {
      +        /**
      +         * 필드명
      +         */
      +        private final String field;
      +
      +        /**
      +         * 입력된 값
      +         */
      +        private final Object rejectedValue;
      +
      +        /**
      +         * 에러 메시지
      +         */
      +        private final String message;
      +    }
      +
      +    /**
      +     * 기본 에러 응답 생성
      +     *
      +     * @param errorCode 에러 코드
      +     * @param message   에러 메시지
      +     * @return ErrorResponse
      +     */
      +    public static ErrorResponse of(String errorCode, String message) {
      +        return ErrorResponse.builder()
      +                .errorCode(errorCode)
      +                .message(message)
      +                .build();
      +    }
      +
      +    /**
      +     * 상세 정보가 포함된 에러 응답 생성
      +     *
      +     * @param errorCode 에러 코드
      +     * @param message   에러 메시지
      +     * @param details   상세 에러 정보
      +     * @return ErrorResponse
      +     */
      +    public static ErrorResponse of(String errorCode, String message, String details) {
      +        return ErrorResponse.builder()
      +                .errorCode(errorCode)
      +                .message(message)
      +                .details(details)
      +                .build();
      +    }
      +
      +    /**
      +     * 필드 유효성 검증 에러 응답 생성
      +     *
      +     * @param errorCode   에러 코드
      +     * @param message     에러 메시지
      +     * @param fieldErrors 필드 에러 목록
      +     * @return ErrorResponse
      +     */
      +    public static ErrorResponse of(String errorCode, String message, List fieldErrors) {
      +        return ErrorResponse.builder()
      +                .errorCode(errorCode)
      +                .message(message)
      +                .fieldErrors(fieldErrors)
      +                .build();
      +    }
      +}
      diff --git a/common/src/main/java/com/kt/event/common/dto/PageResponse.java b/common/src/main/java/com/kt/event/common/dto/PageResponse.java
      new file mode 100644
      index 0000000..f8e27dd
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/dto/PageResponse.java
      @@ -0,0 +1,75 @@
      +package com.kt.event.common.dto;
      +
      +import lombok.AllArgsConstructor;
      +import lombok.Builder;
      +import lombok.Getter;
      +import lombok.NoArgsConstructor;
      +
      +import java.util.List;
      +
      +/**
      + * 페이지네이션 응답
      + * 목록 조회 시 페이징 정보를 포함하는 응답
      + *
      + * @param  목록 아이템 타입
      + */
      +@Getter
      +@Builder
      +@NoArgsConstructor
      +@AllArgsConstructor
      +public class PageResponse {
      +
      +    /**
      +     * 목록 데이터
      +     */
      +    private List content;
      +
      +    /**
      +     * 현재 페이지 번호 (0부터 시작)
      +     */
      +    private int page;
      +
      +    /**
      +     * 페이지 크기
      +     */
      +    private int size;
      +
      +    /**
      +     * 전체 요소 수
      +     */
      +    private long totalElements;
      +
      +    /**
      +     * 전체 페이지 수
      +     */
      +    private int totalPages;
      +
      +    /**
      +     * 첫 페이지 여부
      +     */
      +    private boolean first;
      +
      +    /**
      +     * 마지막 페이지 여부
      +     */
      +    private boolean last;
      +
      +    /**
      +     * Spring Data Page를 PageResponse로 변환
      +     *
      +     * @param page Spring Data Page 객체
      +     * @param   목록 아이템 타입
      +     * @return PageResponse
      +     */
      +    public static  PageResponse of(org.springframework.data.domain.Page page) {
      +        return PageResponse.builder()
      +                .content(page.getContent())
      +                .page(page.getNumber())
      +                .size(page.getSize())
      +                .totalElements(page.getTotalElements())
      +                .totalPages(page.getTotalPages())
      +                .first(page.isFirst())
      +                .last(page.isLast())
      +                .build();
      +    }
      +}
      diff --git a/common/src/main/java/com/kt/event/common/entity/BaseTimeEntity.java b/common/src/main/java/com/kt/event/common/entity/BaseTimeEntity.java
      new file mode 100644
      index 0000000..d098181
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/entity/BaseTimeEntity.java
      @@ -0,0 +1,35 @@
      +package com.kt.event.common.entity;
      +
      +import jakarta.persistence.Column;
      +import jakarta.persistence.EntityListeners;
      +import jakarta.persistence.MappedSuperclass;
      +import lombok.Getter;
      +import org.springframework.data.annotation.CreatedDate;
      +import org.springframework.data.annotation.LastModifiedDate;
      +import org.springframework.data.jpa.domain.support.AuditingEntityListener;
      +
      +import java.time.LocalDateTime;
      +
      +/**
      + * 베이스 타임 엔티티
      + * 생성일시, 수정일시를 자동으로 관리하는 공통 엔티티
      + */
      +@Getter
      +@MappedSuperclass
      +@EntityListeners(AuditingEntityListener.class)
      +public abstract class BaseTimeEntity {
      +
      +    /**
      +     * 생성일시
      +     */
      +    @CreatedDate
      +    @Column(name = "created_at", nullable = false, updatable = false)
      +    private LocalDateTime createdAt;
      +
      +    /**
      +     * 수정일시
      +     */
      +    @LastModifiedDate
      +    @Column(name = "updated_at", nullable = false)
      +    private LocalDateTime updatedAt;
      +}
      diff --git a/common/src/main/java/com/kt/event/common/exception/BusinessException.java b/common/src/main/java/com/kt/event/common/exception/BusinessException.java
      new file mode 100644
      index 0000000..507543c
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/exception/BusinessException.java
      @@ -0,0 +1,84 @@
      +package com.kt.event.common.exception;
      +
      +import lombok.Getter;
      +
      +/**
      + * 비즈니스 예외
      + * 비즈니스 로직 처리 중 발생하는 예외
      + * (예: 중복 데이터, 권한 없음, 유효하지 않은 상태 전환 등)
      + */
      +@Getter
      +public class BusinessException extends RuntimeException {
      +
      +    /**
      +     * 에러 코드
      +     */
      +    private final ErrorCode errorCode;
      +
      +    /**
      +     * 상세 에러 정보
      +     */
      +    private final String details;
      +
      +    /**
      +     * 비즈니스 예외 생성 (기본 메시지 사용)
      +     *
      +     * @param errorCode 에러 코드
      +     */
      +    public BusinessException(ErrorCode errorCode) {
      +        super(errorCode.getMessage());
      +        this.errorCode = errorCode;
      +        this.details = null;
      +    }
      +
      +    /**
      +     * 비즈니스 예외 생성 (커스텀 메시지 사용)
      +     *
      +     * @param errorCode 에러 코드
      +     * @param message   커스텀 에러 메시지
      +     */
      +    public BusinessException(ErrorCode errorCode, String message) {
      +        super(message);
      +        this.errorCode = errorCode;
      +        this.details = null;
      +    }
      +
      +    /**
      +     * 비즈니스 예외 생성 (상세 정보 포함)
      +     *
      +     * @param errorCode 에러 코드
      +     * @param message   커스텀 에러 메시지
      +     * @param details   상세 에러 정보
      +     */
      +    public BusinessException(ErrorCode errorCode, String message, String details) {
      +        super(message);
      +        this.errorCode = errorCode;
      +        this.details = details;
      +    }
      +
      +    /**
      +     * 비즈니스 예외 생성 (원인 예외 포함)
      +     *
      +     * @param errorCode 에러 코드
      +     * @param cause     원인 예외
      +     */
      +    public BusinessException(ErrorCode errorCode, Throwable cause) {
      +        super(errorCode.getMessage(), cause);
      +        this.errorCode = errorCode;
      +        this.details = cause.getMessage();
      +    }
      +
      +    /**
      +     * 비즈니스 예외 생성 (모든 정보 포함)
      +     *
      +     * @param errorCode 에러 코드
      +     * @param message   커스텀 에러 메시지
      +     * @param details   상세 에러 정보
      +     * @param cause     원인 예외
      +     */
      +    public BusinessException(ErrorCode errorCode, String message, String details, Throwable cause) {
      +        super(message, cause);
      +        this.errorCode = errorCode;
      +        this.details = details;
      +    }
      +}
      diff --git a/common/src/main/java/com/kt/event/common/exception/ErrorCode.java b/common/src/main/java/com/kt/event/common/exception/ErrorCode.java
      new file mode 100644
      index 0000000..bd422c5
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/exception/ErrorCode.java
      @@ -0,0 +1,108 @@
      +package com.kt.event.common.exception;
      +
      +import lombok.Getter;
      +import lombok.RequiredArgsConstructor;
      +
      +/**
      + * 에러 코드 정의
      + * 시스템 전체에서 사용하는 에러 코드와 메시지를 관리
      + */
      +@Getter
      +@RequiredArgsConstructor
      +public enum ErrorCode {
      +
      +    // 공통 에러 (COMMON_XXX)
      +    COMMON_001("COMMON_001", "잘못된 요청입니다"),
      +    COMMON_002("COMMON_002", "필수 파라미터가 누락되었습니다"),
      +    COMMON_003("COMMON_003", "유효성 검증에 실패했습니다"),
      +    COMMON_004("COMMON_004", "서버 내부 오류가 발생했습니다"),
      +    COMMON_005("COMMON_005", "지원하지 않는 작업입니다"),
      +
      +    // 인증/인가 에러 (AUTH_XXX)
      +    AUTH_001("AUTH_001", "인증에 실패했습니다"),
      +    AUTH_002("AUTH_002", "유효하지 않은 토큰입니다"),
      +    AUTH_003("AUTH_003", "만료된 토큰입니다"),
      +    AUTH_004("AUTH_004", "권한이 없습니다"),
      +    AUTH_005("AUTH_005", "토큰이 제공되지 않았습니다"),
      +
      +    // 사용자 에러 (USER_XXX)
      +    USER_001("USER_001", "이미 존재하는 사용자입니다"),
      +    USER_002("USER_002", "사업자번호 검증에 실패했습니다"),
      +    USER_003("USER_003", "사용자를 찾을 수 없습니다"),
      +    USER_004("USER_004", "비밀번호가 일치하지 않습니다"),
      +    USER_005("USER_005", "휴폐업 사업자번호입니다"),
      +
      +    // 이벤트 에러 (EVENT_XXX)
      +    EVENT_001("EVENT_001", "이벤트를 찾을 수 없습니다"),
      +    EVENT_002("EVENT_002", "유효하지 않은 상태 전환입니다"),
      +    EVENT_003("EVENT_003", "필수 데이터가 누락되었습니다"),
      +    EVENT_004("EVENT_004", "이벤트 생성에 실패했습니다"),
      +    EVENT_005("EVENT_005", "이벤트 수정 권한이 없습니다"),
      +
      +    // Job 에러 (JOB_XXX)
      +    JOB_001("JOB_001", "Job을 찾을 수 없습니다"),
      +    JOB_002("JOB_002", "Job 처리에 실패했습니다"),
      +    JOB_003("JOB_003", "Job이 아직 처리 중입니다"),
      +    JOB_004("JOB_004", "Job 타임아웃이 발생했습니다"),
      +
      +    // AI 에러 (AI_XXX)
      +    AI_001("AI_001", "AI 추천 생성에 실패했습니다"),
      +    AI_002("AI_002", "트렌드 분석에 실패했습니다"),
      +    AI_003("AI_003", "AI API 호출에 실패했습니다"),
      +    AI_004("AI_004", "AI 추천 결과를 찾을 수 없습니다"),
      +
      +    // 콘텐츠 에러 (CONTENT_XXX)
      +    CONTENT_001("CONTENT_001", "이미지 생성에 실패했습니다"),
      +    CONTENT_002("CONTENT_002", "이미지를 찾을 수 없습니다"),
      +    CONTENT_003("CONTENT_003", "CDN 업로드에 실패했습니다"),
      +    CONTENT_004("CONTENT_004", "콘텐츠를 찾을 수 없습니다"),
      +
      +    // 배포 에러 (DIST_XXX)
      +    DIST_001("DIST_001", "배포에 실패했습니다"),
      +    DIST_002("DIST_002", "채널 연동에 실패했습니다"),
      +    DIST_003("DIST_003", "서킷 브레이커가 열려있습니다"),
      +    DIST_004("DIST_004", "배포 상태를 찾을 수 없습니다"),
      +
      +    // 참여 에러 (PART_XXX)
      +    PART_001("PART_001", "이미 참여한 이벤트입니다"),
      +    PART_002("PART_002", "이벤트 참여 기간이 아닙니다"),
      +    PART_003("PART_003", "참여자를 찾을 수 없습니다"),
      +    PART_004("PART_004", "당첨자 추첨에 실패했습니다"),
      +    PART_005("PART_005", "이벤트가 종료되었습니다"),
      +
      +    // 분석 에러 (ANALYTICS_XXX)
      +    ANALYTICS_001("ANALYTICS_001", "분석 데이터를 찾을 수 없습니다"),
      +    ANALYTICS_002("ANALYTICS_002", "외부 API 호출에 실패했습니다"),
      +    ANALYTICS_003("ANALYTICS_003", "통계 계산에 실패했습니다"),
      +
      +    // 외부 연동 에러 (EXTERNAL_XXX)
      +    EXTERNAL_001("EXTERNAL_001", "외부 API 호출에 실패했습니다"),
      +    EXTERNAL_002("EXTERNAL_002", "외부 API 타임아웃이 발생했습니다"),
      +    EXTERNAL_003("EXTERNAL_003", "외부 API 응답 형식이 올바르지 않습니다"),
      +
      +    // 데이터베이스 에러 (DB_XXX)
      +    DB_001("DB_001", "데이터베이스 연결에 실패했습니다"),
      +    DB_002("DB_002", "데이터 저장에 실패했습니다"),
      +    DB_003("DB_003", "데이터 조회에 실패했습니다"),
      +    DB_004("DB_004", "데이터 삭제에 실패했습니다"),
      +
      +    // Redis 에러 (REDIS_XXX)
      +    REDIS_001("REDIS_001", "Redis 연결에 실패했습니다"),
      +    REDIS_002("REDIS_002", "캐시 저장에 실패했습니다"),
      +    REDIS_003("REDIS_003", "캐시 조회에 실패했습니다"),
      +
      +    // Kafka 에러 (KAFKA_XXX)
      +    KAFKA_001("KAFKA_001", "Kafka 메시지 발행에 실패했습니다"),
      +    KAFKA_002("KAFKA_002", "Kafka 메시지 소비에 실패했습니다"),
      +    KAFKA_003("KAFKA_003", "Kafka 연결에 실패했습니다");
      +
      +    /**
      +     * 에러 코드
      +     */
      +    private final String code;
      +
      +    /**
      +     * 에러 메시지
      +     */
      +    private final String message;
      +}
      diff --git a/common/src/main/java/com/kt/event/common/exception/GlobalExceptionHandler.java b/common/src/main/java/com/kt/event/common/exception/GlobalExceptionHandler.java
      new file mode 100644
      index 0000000..d382813
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/exception/GlobalExceptionHandler.java
      @@ -0,0 +1,198 @@
      +package com.kt.event.common.exception;
      +
      +import com.kt.event.common.dto.ErrorResponse;
      +import lombok.extern.slf4j.Slf4j;
      +import org.springframework.http.HttpStatus;
      +import org.springframework.http.ResponseEntity;
      +import org.springframework.security.access.AccessDeniedException;
      +import org.springframework.security.core.AuthenticationException;
      +import org.springframework.validation.BindException;
      +import org.springframework.validation.FieldError;
      +import org.springframework.web.bind.MethodArgumentNotValidException;
      +import org.springframework.web.bind.annotation.ExceptionHandler;
      +import org.springframework.web.bind.annotation.RestControllerAdvice;
      +
      +import java.util.List;
      +import java.util.stream.Collectors;
      +
      +/**
      + * 전역 예외 핸들러
      + * 애플리케이션 전체에서 발생하는 예외를 일관된 형식으로 처리
      + */
      +@Slf4j
      +@RestControllerAdvice
      +public class GlobalExceptionHandler {
      +
      +    /**
      +     * 비즈니스 예외 처리
      +     *
      +     * @param ex 비즈니스 예외
      +     * @return 에러 응답
      +     */
      +    @ExceptionHandler(BusinessException.class)
      +    public ResponseEntity handleBusinessException(BusinessException ex) {
      +        log.warn("Business exception occurred: {}", ex.getMessage());
      +
      +        ErrorResponse errorResponse = ErrorResponse.of(
      +                ex.getErrorCode().getCode(),
      +                ex.getMessage(),
      +                ex.getDetails()
      +        );
      +
      +        return ResponseEntity
      +                .status(HttpStatus.BAD_REQUEST)
      +                .body(errorResponse);
      +    }
      +
      +    /**
      +     * 인프라 예외 처리
      +     *
      +     * @param ex 인프라 예외
      +     * @return 에러 응답
      +     */
      +    @ExceptionHandler(InfraException.class)
      +    public ResponseEntity handleInfraException(InfraException ex) {
      +        log.error("Infrastructure exception occurred: {}", ex.getMessage(), ex);
      +
      +        ErrorResponse errorResponse = ErrorResponse.of(
      +                ex.getErrorCode().getCode(),
      +                ex.getMessage(),
      +                ex.getDetails()
      +        );
      +
      +        return ResponseEntity
      +                .status(HttpStatus.INTERNAL_SERVER_ERROR)
      +                .body(errorResponse);
      +    }
      +
      +    /**
      +     * 인증 예외 처리
      +     *
      +     * @param ex 인증 예외
      +     * @return 에러 응답
      +     */
      +    @ExceptionHandler(AuthenticationException.class)
      +    public ResponseEntity handleAuthenticationException(AuthenticationException ex) {
      +        log.warn("Authentication exception occurred: {}", ex.getMessage());
      +
      +        ErrorResponse errorResponse = ErrorResponse.of(
      +                ErrorCode.AUTH_001.getCode(),
      +                ErrorCode.AUTH_001.getMessage(),
      +                ex.getMessage()
      +        );
      +
      +        return ResponseEntity
      +                .status(HttpStatus.UNAUTHORIZED)
      +                .body(errorResponse);
      +    }
      +
      +    /**
      +     * 권한 예외 처리
      +     *
      +     * @param ex 권한 예외
      +     * @return 에러 응답
      +     */
      +    @ExceptionHandler(AccessDeniedException.class)
      +    public ResponseEntity handleAccessDeniedException(AccessDeniedException ex) {
      +        log.warn("Access denied exception occurred: {}", ex.getMessage());
      +
      +        ErrorResponse errorResponse = ErrorResponse.of(
      +                ErrorCode.AUTH_004.getCode(),
      +                ErrorCode.AUTH_004.getMessage(),
      +                ex.getMessage()
      +        );
      +
      +        return ResponseEntity
      +                .status(HttpStatus.FORBIDDEN)
      +                .body(errorResponse);
      +    }
      +
      +    /**
      +     * 유효성 검증 예외 처리 (RequestBody)
      +     *
      +     * @param ex 유효성 검증 예외
      +     * @return 에러 응답
      +     */
      +    @ExceptionHandler(MethodArgumentNotValidException.class)
      +    public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
      +        log.warn("Validation exception occurred: {}", ex.getMessage());
      +
      +        List fieldErrors = ex.getBindingResult()
      +                .getFieldErrors()
      +                .stream()
      +                .map(this::mapToFieldError)
      +                .collect(Collectors.toList());
      +
      +        ErrorResponse errorResponse = ErrorResponse.of(
      +                ErrorCode.COMMON_003.getCode(),
      +                ErrorCode.COMMON_003.getMessage(),
      +                fieldErrors
      +        );
      +
      +        return ResponseEntity
      +                .status(HttpStatus.BAD_REQUEST)
      +                .body(errorResponse);
      +    }
      +
      +    /**
      +     * 유효성 검증 예외 처리 (ModelAttribute)
      +     *
      +     * @param ex 유효성 검증 예외
      +     * @return 에러 응답
      +     */
      +    @ExceptionHandler(BindException.class)
      +    public ResponseEntity handleBindException(BindException ex) {
      +        log.warn("Bind exception occurred: {}", ex.getMessage());
      +
      +        List fieldErrors = ex.getBindingResult()
      +                .getFieldErrors()
      +                .stream()
      +                .map(this::mapToFieldError)
      +                .collect(Collectors.toList());
      +
      +        ErrorResponse errorResponse = ErrorResponse.of(
      +                ErrorCode.COMMON_003.getCode(),
      +                ErrorCode.COMMON_003.getMessage(),
      +                fieldErrors
      +        );
      +
      +        return ResponseEntity
      +                .status(HttpStatus.BAD_REQUEST)
      +                .body(errorResponse);
      +    }
      +
      +    /**
      +     * 일반 예외 처리
      +     *
      +     * @param ex 일반 예외
      +     * @return 에러 응답
      +     */
      +    @ExceptionHandler(Exception.class)
      +    public ResponseEntity handleException(Exception ex) {
      +        log.error("Unexpected exception occurred: {}", ex.getMessage(), ex);
      +
      +        ErrorResponse errorResponse = ErrorResponse.of(
      +                ErrorCode.COMMON_004.getCode(),
      +                ErrorCode.COMMON_004.getMessage(),
      +                ex.getMessage()
      +        );
      +
      +        return ResponseEntity
      +                .status(HttpStatus.INTERNAL_SERVER_ERROR)
      +                .body(errorResponse);
      +    }
      +
      +    /**
      +     * Spring FieldError를 ErrorResponse.FieldError로 변환
      +     *
      +     * @param fieldError Spring FieldError
      +     * @return ErrorResponse.FieldError
      +     */
      +    private ErrorResponse.FieldError mapToFieldError(FieldError fieldError) {
      +        return ErrorResponse.FieldError.builder()
      +                .field(fieldError.getField())
      +                .rejectedValue(fieldError.getRejectedValue())
      +                .message(fieldError.getDefaultMessage())
      +                .build();
      +    }
      +}
      diff --git a/common/src/main/java/com/kt/event/common/exception/InfraException.java b/common/src/main/java/com/kt/event/common/exception/InfraException.java
      new file mode 100644
      index 0000000..674d732
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/exception/InfraException.java
      @@ -0,0 +1,84 @@
      +package com.kt.event.common.exception;
      +
      +import lombok.Getter;
      +
      +/**
      + * 인프라 예외
      + * 인프라 계층에서 발생하는 예외
      + * (예: DB 연결 실패, Redis 오류, Kafka 오류, 외부 API 호출 실패 등)
      + */
      +@Getter
      +public class InfraException extends RuntimeException {
      +
      +    /**
      +     * 에러 코드
      +     */
      +    private final ErrorCode errorCode;
      +
      +    /**
      +     * 상세 에러 정보
      +     */
      +    private final String details;
      +
      +    /**
      +     * 인프라 예외 생성 (기본 메시지 사용)
      +     *
      +     * @param errorCode 에러 코드
      +     */
      +    public InfraException(ErrorCode errorCode) {
      +        super(errorCode.getMessage());
      +        this.errorCode = errorCode;
      +        this.details = null;
      +    }
      +
      +    /**
      +     * 인프라 예외 생성 (커스텀 메시지 사용)
      +     *
      +     * @param errorCode 에러 코드
      +     * @param message   커스텀 에러 메시지
      +     */
      +    public InfraException(ErrorCode errorCode, String message) {
      +        super(message);
      +        this.errorCode = errorCode;
      +        this.details = null;
      +    }
      +
      +    /**
      +     * 인프라 예외 생성 (상세 정보 포함)
      +     *
      +     * @param errorCode 에러 코드
      +     * @param message   커스텀 에러 메시지
      +     * @param details   상세 에러 정보
      +     */
      +    public InfraException(ErrorCode errorCode, String message, String details) {
      +        super(message);
      +        this.errorCode = errorCode;
      +        this.details = details;
      +    }
      +
      +    /**
      +     * 인프라 예외 생성 (원인 예외 포함)
      +     *
      +     * @param errorCode 에러 코드
      +     * @param cause     원인 예외
      +     */
      +    public InfraException(ErrorCode errorCode, Throwable cause) {
      +        super(errorCode.getMessage(), cause);
      +        this.errorCode = errorCode;
      +        this.details = cause.getMessage();
      +    }
      +
      +    /**
      +     * 인프라 예외 생성 (모든 정보 포함)
      +     *
      +     * @param errorCode 에러 코드
      +     * @param message   커스텀 에러 메시지
      +     * @param details   상세 에러 정보
      +     * @param cause     원인 예외
      +     */
      +    public InfraException(ErrorCode errorCode, String message, String details, Throwable cause) {
      +        super(message, cause);
      +        this.errorCode = errorCode;
      +        this.details = details;
      +    }
      +}
      diff --git a/common/src/main/java/com/kt/event/common/security/JwtAuthenticationFilter.java b/common/src/main/java/com/kt/event/common/security/JwtAuthenticationFilter.java
      new file mode 100644
      index 0000000..2f1f55b
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/security/JwtAuthenticationFilter.java
      @@ -0,0 +1,130 @@
      +package com.kt.event.common.security;
      +
      +import com.kt.event.common.util.StringUtil;
      +import jakarta.servlet.FilterChain;
      +import jakarta.servlet.ServletException;
      +import jakarta.servlet.http.HttpServletRequest;
      +import jakarta.servlet.http.HttpServletResponse;
      +import lombok.RequiredArgsConstructor;
      +import lombok.extern.slf4j.Slf4j;
      +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
      +import org.springframework.security.core.context.SecurityContextHolder;
      +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
      +import org.springframework.stereotype.Component;
      +import org.springframework.web.filter.OncePerRequestFilter;
      +
      +import java.io.IOException;
      +
      +/**
      + * JWT 인증 필터
      + * 요청 헤더에서 JWT 토큰을 추출하고 인증 처리
      + */
      +@Slf4j
      +@Component
      +@RequiredArgsConstructor
      +public class JwtAuthenticationFilter extends OncePerRequestFilter {
      +
      +    /**
      +     * Authorization 헤더 이름
      +     */
      +    private static final String AUTHORIZATION_HEADER = "Authorization";
      +
      +    /**
      +     * Bearer 토큰 접두사
      +     */
      +    private static final String BEARER_PREFIX = "Bearer ";
      +
      +    /**
      +     * JWT 토큰 제공자
      +     */
      +    private final JwtTokenProvider jwtTokenProvider;
      +
      +    /**
      +     * 필터 실행
      +     *
      +     * @param request     HTTP 요청
      +     * @param response    HTTP 응답
      +     * @param filterChain 필터 체인
      +     * @throws ServletException 서블릿 예외
      +     * @throws IOException      입출력 예외
      +     */
      +    @Override
      +    protected void doFilterInternal(HttpServletRequest request,
      +                                    HttpServletResponse response,
      +                                    FilterChain filterChain) throws ServletException, IOException {
      +        try {
      +            // 요청에서 JWT 토큰 추출
      +            String token = extractTokenFromRequest(request);
      +
      +            // 토큰이 존재하고 유효한 경우 인증 처리
      +            if (StringUtil.isNotBlank(token) && jwtTokenProvider.validateToken(token)) {
      +                // Access Token인지 확인
      +                if (jwtTokenProvider.isAccessToken(token)) {
      +                    authenticateUser(token, request);
      +                } else {
      +                    log.warn("Refresh token used for authentication: {}", request.getRequestURI());
      +                }
      +            }
      +        } catch (Exception e) {
      +            log.error("Could not set user authentication in security context", e);
      +        }
      +
      +        filterChain.doFilter(request, response);
      +    }
      +
      +    /**
      +     * 요청에서 JWT 토큰 추출
      +     *
      +     * @param request HTTP 요청
      +     * @return JWT 토큰 (없으면 null)
      +     */
      +    private String extractTokenFromRequest(HttpServletRequest request) {
      +        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
      +
      +        if (StringUtil.isNotBlank(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
      +            return bearerToken.substring(BEARER_PREFIX.length());
      +        }
      +
      +        return null;
      +    }
      +
      +    /**
      +     * 사용자 인증 처리
      +     *
      +     * @param token   JWT 토큰
      +     * @param request HTTP 요청
      +     */
      +    private void authenticateUser(String token, HttpServletRequest request) {
      +        // 토큰에서 사용자 정보 추출
      +        UserPrincipal userPrincipal = jwtTokenProvider.getUserPrincipalFromToken(token);
      +
      +        // Spring Security 인증 객체 생성
      +        UsernamePasswordAuthenticationToken authentication =
      +                new UsernamePasswordAuthenticationToken(
      +                        userPrincipal,
      +                        null,
      +                        userPrincipal.getAuthorities()
      +                );
      +
      +        // 요청 상세 정보 설정
      +        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
      +
      +        // SecurityContext에 인증 정보 저장
      +        SecurityContextHolder.getContext().setAuthentication(authentication);
      +
      +        log.debug("Set authentication for user: {} (userId: {})",
      +                userPrincipal.getEmail(), userPrincipal.getUserId());
      +    }
      +
      +    /**
      +     * 필터 적용 여부 결정
      +     * OPTIONS 요청은 필터를 적용하지 않음
      +     *
      +     * @param request HTTP 요청
      +     * @return 필터 적용 제외 여부
      +     */
      +    @Override
      +    protected boolean shouldNotFilter(HttpServletRequest request) {
      +        return "OPTIONS".equalsIgnoreCase(request.getMethod());
      +    }
      +}
      diff --git a/common/src/main/java/com/kt/event/common/security/JwtTokenProvider.java b/common/src/main/java/com/kt/event/common/security/JwtTokenProvider.java
      new file mode 100644
      index 0000000..d441f92
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/security/JwtTokenProvider.java
      @@ -0,0 +1,215 @@
      +package com.kt.event.common.security;
      +
      +import com.kt.event.common.exception.ErrorCode;
      +import com.kt.event.common.exception.InfraException;
      +import io.jsonwebtoken.*;
      +import io.jsonwebtoken.security.Keys;
      +import lombok.extern.slf4j.Slf4j;
      +import org.springframework.beans.factory.annotation.Value;
      +import org.springframework.stereotype.Component;
      +
      +import javax.crypto.SecretKey;
      +import java.nio.charset.StandardCharsets;
      +import java.util.Date;
      +import java.util.List;
      +
      +/**
      + * JWT 토큰 생성 및 검증 제공자
      + * Access Token 및 Refresh Token 생성/검증 기능 제공
      + */
      +@Slf4j
      +@Component
      +public class JwtTokenProvider {
      +
      +    /**
      +     * JWT 서명 키
      +     */
      +    private final SecretKey secretKey;
      +
      +    /**
      +     * Access Token 유효기간 (밀리초)
      +     */
      +    private final long accessTokenValidityMs;
      +
      +    /**
      +     * Refresh Token 유효기간 (밀리초)
      +     */
      +    private final long refreshTokenValidityMs;
      +
      +    public JwtTokenProvider(
      +            @Value("${jwt.secret}") String secret,
      +            @Value("${jwt.access-token-validity:3600000}") long accessTokenValidityMs,
      +            @Value("${jwt.refresh-token-validity:604800000}") long refreshTokenValidityMs) {
      +        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
      +        this.accessTokenValidityMs = accessTokenValidityMs;
      +        this.refreshTokenValidityMs = refreshTokenValidityMs;
      +    }
      +
      +    /**
      +     * Access Token 생성
      +     *
      +     * @param userId 사용자 ID
      +     * @param email  이메일
      +     * @param name   이름
      +     * @param roles  역할 목록
      +     * @return Access Token
      +     */
      +    public String createAccessToken(Long userId, String email, String name, List roles) {
      +        Date now = new Date();
      +        Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
      +
      +        return Jwts.builder()
      +                .subject(userId.toString())
      +                .claim("email", email)
      +                .claim("name", name)
      +                .claim("roles", roles)
      +                .claim("type", "access")
      +                .issuedAt(now)
      +                .expiration(expiryDate)
      +                .signWith(secretKey)
      +                .compact();
      +    }
      +
      +    /**
      +     * Refresh Token 생성
      +     *
      +     * @param userId 사용자 ID
      +     * @return Refresh Token
      +     */
      +    public String createRefreshToken(Long userId) {
      +        Date now = new Date();
      +        Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs);
      +
      +        return Jwts.builder()
      +                .subject(userId.toString())
      +                .claim("type", "refresh")
      +                .issuedAt(now)
      +                .expiration(expiryDate)
      +                .signWith(secretKey)
      +                .compact();
      +    }
      +
      +    /**
      +     * 토큰에서 사용자 ID 추출
      +     *
      +     * @param token JWT 토큰
      +     * @return 사용자 ID
      +     */
      +    public Long getUserIdFromToken(String token) {
      +        Claims claims = parseToken(token);
      +        return Long.parseLong(claims.getSubject());
      +    }
      +
      +    /**
      +     * 토큰에서 UserPrincipal 추출
      +     *
      +     * @param token JWT 토큰
      +     * @return UserPrincipal
      +     */
      +    public UserPrincipal getUserPrincipalFromToken(String token) {
      +        Claims claims = parseToken(token);
      +
      +        Long userId = Long.parseLong(claims.getSubject());
      +        String email = claims.get("email", String.class);
      +        String name = claims.get("name", String.class);
      +        @SuppressWarnings("unchecked")
      +        List roles = claims.get("roles", List.class);
      +
      +        return new UserPrincipal(userId, email, name, roles);
      +    }
      +
      +    /**
      +     * 토큰 유효성 검증
      +     *
      +     * @param token JWT 토큰
      +     * @return 유효 여부
      +     */
      +    public boolean validateToken(String token) {
      +        try {
      +            parseToken(token);
      +            return true;
      +        } catch (SecurityException | MalformedJwtException e) {
      +            log.error("Invalid JWT signature: {}", e.getMessage());
      +        } catch (ExpiredJwtException e) {
      +            log.error("Expired JWT token: {}", e.getMessage());
      +        } catch (UnsupportedJwtException e) {
      +            log.error("Unsupported JWT token: {}", e.getMessage());
      +        } catch (IllegalArgumentException e) {
      +            log.error("JWT claims string is empty: {}", e.getMessage());
      +        }
      +        return false;
      +    }
      +
      +    /**
      +     * 토큰 타입 확인 (access/refresh)
      +     *
      +     * @param token JWT 토큰
      +     * @return 토큰 타입
      +     */
      +    public String getTokenType(String token) {
      +        Claims claims = parseToken(token);
      +        return claims.get("type", String.class);
      +    }
      +
      +    /**
      +     * Access Token 여부 확인
      +     *
      +     * @param token JWT 토큰
      +     * @return Access Token 여부
      +     */
      +    public boolean isAccessToken(String token) {
      +        return "access".equals(getTokenType(token));
      +    }
      +
      +    /**
      +     * Refresh Token 여부 확인
      +     *
      +     * @param token JWT 토큰
      +     * @return Refresh Token 여부
      +     */
      +    public boolean isRefreshToken(String token) {
      +        return "refresh".equals(getTokenType(token));
      +    }
      +
      +    /**
      +     * 토큰 파싱
      +     *
      +     * @param token JWT 토큰
      +     * @return Claims
      +     */
      +    private Claims parseToken(String token) {
      +        try {
      +            return Jwts.parser()
      +                    .verifyWith(secretKey)
      +                    .build()
      +                    .parseSignedClaims(token)
      +                    .getPayload();
      +        } catch (ExpiredJwtException e) {
      +            throw new InfraException(ErrorCode.AUTH_002, e);
      +        } catch (Exception e) {
      +            throw new InfraException(ErrorCode.AUTH_003, e);
      +        }
      +    }
      +
      +    /**
      +     * 토큰 만료 시간 조회
      +     *
      +     * @param token JWT 토큰
      +     * @return 만료 시간
      +     */
      +    public Date getExpirationFromToken(String token) {
      +        Claims claims = parseToken(token);
      +        return claims.getExpiration();
      +    }
      +
      +    /**
      +     * 토큰 발급 시간 조회
      +     *
      +     * @param token JWT 토큰
      +     * @return 발급 시간
      +     */
      +    public Date getIssuedAtFromToken(String token) {
      +        Claims claims = parseToken(token);
      +        return claims.getIssuedAt();
      +    }
      +}
      diff --git a/common/src/main/java/com/kt/event/common/security/UserPrincipal.java b/common/src/main/java/com/kt/event/common/security/UserPrincipal.java
      new file mode 100644
      index 0000000..695f7ea
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/security/UserPrincipal.java
      @@ -0,0 +1,122 @@
      +package com.kt.event.common.security;
      +
      +import lombok.AllArgsConstructor;
      +import lombok.Getter;
      +import org.springframework.security.core.GrantedAuthority;
      +import org.springframework.security.core.authority.SimpleGrantedAuthority;
      +import org.springframework.security.core.userdetails.UserDetails;
      +
      +import java.util.Collection;
      +import java.util.List;
      +import java.util.stream.Collectors;
      +
      +/**
      + * Spring Security 인증 주체
      + * JWT 토큰에서 추출한 사용자 정보를 담는 객체
      + */
      +@Getter
      +@AllArgsConstructor
      +public class UserPrincipal implements UserDetails {
      +
      +    /**
      +     * 사용자 ID
      +     */
      +    private final Long userId;
      +
      +    /**
      +     * 사용자 이메일
      +     */
      +    private final String email;
      +
      +    /**
      +     * 사용자 이름
      +     */
      +    private final String name;
      +
      +    /**
      +     * 사용자 역할 목록
      +     */
      +    private final List roles;
      +
      +    /**
      +     * Spring Security 권한 목록 반환
      +     *
      +     * @return 권한 목록
      +     */
      +    @Override
      +    public Collection getAuthorities() {
      +        return roles.stream()
      +                .map(SimpleGrantedAuthority::new)
      +                .collect(Collectors.toList());
      +    }
      +
      +    /**
      +     * 비밀번호 반환 (JWT 인증에서는 사용하지 않음)
      +     *
      +     * @return null
      +     */
      +    @Override
      +    public String getPassword() {
      +        return null;
      +    }
      +
      +    /**
      +     * 사용자명 반환 (이메일 사용)
      +     *
      +     * @return 이메일
      +     */
      +    @Override
      +    public String getUsername() {
      +        return email;
      +    }
      +
      +    /**
      +     * 계정 만료 여부
      +     *
      +     * @return true (만료되지 않음)
      +     */
      +    @Override
      +    public boolean isAccountNonExpired() {
      +        return true;
      +    }
      +
      +    /**
      +     * 계정 잠김 여부
      +     *
      +     * @return true (잠기지 않음)
      +     */
      +    @Override
      +    public boolean isAccountNonLocked() {
      +        return true;
      +    }
      +
      +    /**
      +     * 자격증명 만료 여부
      +     *
      +     * @return true (만료되지 않음)
      +     */
      +    @Override
      +    public boolean isCredentialsNonExpired() {
      +        return true;
      +    }
      +
      +    /**
      +     * 계정 활성화 여부
      +     *
      +     * @return true (활성화됨)
      +     */
      +    @Override
      +    public boolean isEnabled() {
      +        return true;
      +    }
      +
      +    /**
      +     * 특정 역할 보유 여부 확인
      +     *
      +     * @param role 역할명
      +     * @return 역할 보유 여부
      +     */
      +    public boolean hasRole(String role) {
      +        return roles.contains(role);
      +    }
      +}
      diff --git a/common/src/main/java/com/kt/event/common/util/DateTimeUtil.java b/common/src/main/java/com/kt/event/common/util/DateTimeUtil.java
      new file mode 100644
      index 0000000..4c687b1
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/util/DateTimeUtil.java
      @@ -0,0 +1,148 @@
      +package com.kt.event.common.util;
      +
      +import java.time.LocalDateTime;
      +import java.time.ZoneId;
      +import java.time.format.DateTimeFormatter;
      +
      +/**
      + * 날짜/시간 유틸리티
      + * 날짜와 시간 관련 공통 기능 제공
      + */
      +public class DateTimeUtil {
      +
      +    /**
      +     * 기본 날짜 시간 포맷터 (yyyy-MM-dd HH:mm:ss)
      +     */
      +    private static final DateTimeFormatter DEFAULT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
      +
      +    /**
      +     * 날짜 포맷터 (yyyy-MM-dd)
      +     */
      +    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
      +
      +    /**
      +     * 시간 포맷터 (HH:mm:ss)
      +     */
      +    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
      +
      +    /**
      +     * 기본 타임존 (Asia/Seoul)
      +     */
      +    private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Seoul");
      +
      +    /**
      +     * 현재 시간 조회 (Asia/Seoul 타임존)
      +     *
      +     * @return 현재 시간
      +     */
      +    public static LocalDateTime now() {
      +        return LocalDateTime.now(DEFAULT_ZONE);
      +    }
      +
      +    /**
      +     * LocalDateTime을 기본 포맷 문자열로 변환
      +     *
      +     * @param dateTime LocalDateTime 객체
      +     * @return 포맷된 문자열 (yyyy-MM-dd HH:mm:ss)
      +     */
      +    public static String format(LocalDateTime dateTime) {
      +        if (dateTime == null) {
      +            return null;
      +        }
      +        return dateTime.format(DEFAULT_FORMATTER);
      +    }
      +
      +    /**
      +     * LocalDateTime을 날짜 포맷 문자열로 변환
      +     *
      +     * @param dateTime LocalDateTime 객체
      +     * @return 포맷된 문자열 (yyyy-MM-dd)
      +     */
      +    public static String formatDate(LocalDateTime dateTime) {
      +        if (dateTime == null) {
      +            return null;
      +        }
      +        return dateTime.format(DATE_FORMATTER);
      +    }
      +
      +    /**
      +     * LocalDateTime을 시간 포맷 문자열로 변환
      +     *
      +     * @param dateTime LocalDateTime 객체
      +     * @return 포맷된 문자열 (HH:mm:ss)
      +     */
      +    public static String formatTime(LocalDateTime dateTime) {
      +        if (dateTime == null) {
      +            return null;
      +        }
      +        return dateTime.format(TIME_FORMATTER);
      +    }
      +
      +    /**
      +     * 문자열을 LocalDateTime으로 파싱
      +     *
      +     * @param dateTimeStr 날짜 시간 문자열 (yyyy-MM-dd HH:mm:ss)
      +     * @return LocalDateTime 객체
      +     */
      +    public static LocalDateTime parse(String dateTimeStr) {
      +        if (dateTimeStr == null || dateTimeStr.isEmpty()) {
      +            return null;
      +        }
      +        return LocalDateTime.parse(dateTimeStr, DEFAULT_FORMATTER);
      +    }
      +
      +    /**
      +     * 두 날짜 사이의 차이 계산 (일 단위)
      +     *
      +     * @param start 시작 날짜
      +     * @param end   종료 날짜
      +     * @return 일수 차이
      +     */
      +    public static long daysBetween(LocalDateTime start, LocalDateTime end) {
      +        if (start == null || end == null) {
      +            return 0;
      +        }
      +        return java.time.Duration.between(start, end).toDays();
      +    }
      +
      +    /**
      +     * 날짜가 특정 범위 내에 있는지 확인
      +     *
      +     * @param target 확인할 날짜
      +     * @param start  시작 날짜
      +     * @param end    종료 날짜
      +     * @return 범위 내 여부
      +     */
      +    public static boolean isBetween(LocalDateTime target, LocalDateTime start, LocalDateTime end) {
      +        if (target == null || start == null || end == null) {
      +            return false;
      +        }
      +        return !target.isBefore(start) && !target.isAfter(end);
      +    }
      +
      +    /**
      +     * 날짜가 현재보다 이전인지 확인
      +     *
      +     * @param dateTime 확인할 날짜
      +     * @return 과거 날짜 여부
      +     */
      +    public static boolean isPast(LocalDateTime dateTime) {
      +        if (dateTime == null) {
      +            return false;
      +        }
      +        return dateTime.isBefore(now());
      +    }
      +
      +    /**
      +     * 날짜가 현재보다 이후인지 확인
      +     *
      +     * @param dateTime 확인할 날짜
      +     * @return 미래 날짜 여부
      +     */
      +    public static boolean isFuture(LocalDateTime dateTime) {
      +        if (dateTime == null) {
      +            return false;
      +        }
      +        return dateTime.isAfter(now());
      +    }
      +}
      diff --git a/common/src/main/java/com/kt/event/common/util/EncryptionUtil.java b/common/src/main/java/com/kt/event/common/util/EncryptionUtil.java
      new file mode 100644
      index 0000000..eeb9a78
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/util/EncryptionUtil.java
      @@ -0,0 +1,157 @@
      +package com.kt.event.common.util;
      +
      +import com.kt.event.common.exception.InfraException;
      +import com.kt.event.common.exception.ErrorCode;
      +import lombok.extern.slf4j.Slf4j;
      +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
      +
      +import javax.crypto.Cipher;
      +import javax.crypto.SecretKey;
      +import javax.crypto.spec.GCMParameterSpec;
      +import javax.crypto.spec.SecretKeySpec;
      +import java.nio.ByteBuffer;
      +import java.nio.charset.StandardCharsets;
      +import java.security.SecureRandom;
      +import java.util.Base64;
      +
      +/**
      + * 암호화 유틸리티
      + * 비밀번호 해싱 및 데이터 암호화 기능 제공
      + */
      +@Slf4j
      +public class EncryptionUtil {
      +
      +    /**
      +     * BCrypt 인코더 (Cost Factor: 10)
      +     */
      +    private static final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(10);
      +
      +    /**
      +     * AES-256-GCM 설정
      +     */
      +    private static final String ALGORITHM = "AES/GCM/NoPadding";
      +    private static final int GCM_TAG_LENGTH = 128;
      +    private static final int GCM_IV_LENGTH = 12;
      +
      +    /**
      +     * 비밀번호 해싱 (BCrypt)
      +     *
      +     * @param rawPassword 원본 비밀번호
      +     * @return 해싱된 비밀번호
      +     */
      +    public static String hashPassword(String rawPassword) {
      +        if (StringUtil.isBlank(rawPassword)) {
      +            throw new InfraException(ErrorCode.COMMON_002, "비밀번호는 필수입니다");
      +        }
      +        return passwordEncoder.encode(rawPassword);
      +    }
      +
      +    /**
      +     * 비밀번호 검증 (BCrypt)
      +     *
      +     * @param rawPassword    원본 비밀번호
      +     * @param hashedPassword 해싱된 비밀번호
      +     * @return 일치 여부
      +     */
      +    public static boolean verifyPassword(String rawPassword, String hashedPassword) {
      +        if (StringUtil.isBlank(rawPassword) || StringUtil.isBlank(hashedPassword)) {
      +            return false;
      +        }
      +        try {
      +            return passwordEncoder.matches(rawPassword, hashedPassword);
      +        } catch (Exception e) {
      +            log.error("Password verification failed", e);
      +            return false;
      +        }
      +    }
      +
      +    /**
      +     * AES-256-GCM 암호화
      +     *
      +     * @param plainText 평문
      +     * @param secretKey 비밀키 (32바이트)
      +     * @return Base64 인코딩된 암호문
      +     */
      +    public static String encrypt(String plainText, String secretKey) {
      +        if (StringUtil.isBlank(plainText)) {
      +            return plainText;
      +        }
      +
      +        try {
      +            // IV 생성 (12바이트)
      +            byte[] iv = new byte[GCM_IV_LENGTH];
      +            SecureRandom random = new SecureRandom();
      +            random.nextBytes(iv);
      +
      +            // 암호화
      +            SecretKey key = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES");
      +            Cipher cipher = Cipher.getInstance(ALGORITHM);
      +            GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
      +            cipher.init(Cipher.ENCRYPT_MODE, key, parameterSpec);
      +
      +            byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
      +
      +            // IV + 암호문 결합
      +            ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + cipherText.length);
      +            byteBuffer.put(iv);
      +            byteBuffer.put(cipherText);
      +
      +            // Base64 인코딩
      +            return Base64.getEncoder().encodeToString(byteBuffer.array());
      +
      +        } catch (Exception e) {
      +            log.error("Encryption failed", e);
      +            throw new InfraException(ErrorCode.COMMON_004, e);
      +        }
      +    }
      +
      +    /**
      +     * AES-256-GCM 복호화
      +     *
      +     * @param cipherText Base64 인코딩된 암호문
      +     * @param secretKey  비밀키 (32바이트)
      +     * @return 평문
      +     */
      +    public static String decrypt(String cipherText, String secretKey) {
      +        if (StringUtil.isBlank(cipherText)) {
      +            return cipherText;
      +        }
      +
      +        try {
      +            // Base64 디코딩
      +            byte[] decodedData = Base64.getDecoder().decode(cipherText);
      +
      +            // IV와 암호문 분리
      +            ByteBuffer byteBuffer = ByteBuffer.wrap(decodedData);
      +            byte[] iv = new byte[GCM_IV_LENGTH];
      +            byteBuffer.get(iv);
      +            byte[] encrypted = new byte[byteBuffer.remaining()];
      +            byteBuffer.get(encrypted);
      +
      +            // 복호화
      +            SecretKey key = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES");
      +            Cipher cipher = Cipher.getInstance(ALGORITHM);
      +            GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
      +            cipher.init(Cipher.DECRYPT_MODE, key, parameterSpec);
      +
      +            byte[] decryptedData = cipher.doFinal(encrypted);
      +            return new String(decryptedData, StandardCharsets.UTF_8);
      +
      +        } catch (Exception e) {
      +            log.error("Decryption failed", e);
      +            throw new InfraException(ErrorCode.COMMON_004, e);
      +        }
      +    }
      +
      +    /**
      +     * 32바이트 비밀키 생성 (개발/테스트용)
      +     * 실제 운영에서는 환경변수나 Key Management Service 사용 권장
      +     *
      +     * @param seed 시드 문자열
      +     * @return 32바이트 비밀키
      +     */
      +    public static String generateSecretKey(String seed) {
      +        String paddedSeed = (seed + "00000000000000000000000000000000").substring(0, 32);
      +        return paddedSeed;
      +    }
      +}
      diff --git a/common/src/main/java/com/kt/event/common/util/StringUtil.java b/common/src/main/java/com/kt/event/common/util/StringUtil.java
      new file mode 100644
      index 0000000..ff34215
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/util/StringUtil.java
      @@ -0,0 +1,178 @@
      +package com.kt.event.common.util;
      +
      +import org.apache.commons.lang3.StringUtils;
      +
      +/**
      + * 문자열 유틸리티
      + * 문자열 처리 관련 공통 기능 제공
      + */
      +public class StringUtil {
      +
      +    /**
      +     * 문자열이 null이거나 공백인지 확인
      +     *
      +     * @param str 확인할 문자열
      +     * @return null 또는 공백 여부
      +     */
      +    public static boolean isBlank(String str) {
      +        return StringUtils.isBlank(str);
      +    }
      +
      +    /**
      +     * 문자열이 null이 아니고 공백이 아닌지 확인
      +     *
      +     * @param str 확인할 문자열
      +     * @return null 또는 공백이 아닌지 여부
      +     */
      +    public static boolean isNotBlank(String str) {
      +        return StringUtils.isNotBlank(str);
      +    }
      +
      +    /**
      +     * 전화번호 마스킹 처리
      +     * 예: 010-1234-5678 → 010-****-5678
      +     *
      +     * @param phoneNumber 전화번호
      +     * @return 마스킹된 전화번호
      +     */
      +    public static String maskPhoneNumber(String phoneNumber) {
      +        if (isBlank(phoneNumber)) {
      +            return phoneNumber;
      +        }
      +
      +        String cleaned = phoneNumber.replaceAll("[^0-9]", "");
      +
      +        if (cleaned.length() == 11) {
      +            return cleaned.substring(0, 3) + "-****-" + cleaned.substring(7);
      +        } else if (cleaned.length() == 10) {
      +            return cleaned.substring(0, 3) + "-***-" + cleaned.substring(6);
      +        }
      +
      +        return phoneNumber;
      +    }
      +
      +    /**
      +     * 사업자번호 마스킹 처리
      +     * 예: 123-45-67890 → 123-**-****0
      +     *
      +     * @param businessNumber 사업자번호
      +     * @return 마스킹된 사업자번호
      +     */
      +    public static String maskBusinessNumber(String businessNumber) {
      +        if (isBlank(businessNumber)) {
      +            return businessNumber;
      +        }
      +
      +        String cleaned = businessNumber.replaceAll("[^0-9]", "");
      +
      +        if (cleaned.length() == 10) {
      +            return cleaned.substring(0, 3) + "-**-****" + cleaned.substring(9);
      +        }
      +
      +        return businessNumber;
      +    }
      +
      +    /**
      +     * 이메일 마스킹 처리
      +     * 예: user@example.com → u***@example.com
      +     *
      +     * @param email 이메일
      +     * @return 마스킹된 이메일
      +     */
      +    public static String maskEmail(String email) {
      +        if (isBlank(email) || !email.contains("@")) {
      +            return email;
      +        }
      +
      +        String[] parts = email.split("@");
      +        String localPart = parts[0];
      +        String domain = parts[1];
      +
      +        if (localPart.length() <= 1) {
      +            return email;
      +        }
      +
      +        String masked = localPart.charAt(0) + "***";
      +        return masked + "@" + domain;
      +    }
      +
      +    /**
      +     * 문자열을 지정된 길이로 자르고 말줄임표 추가
      +     *
      +     * @param str       원본 문자열
      +     * @param maxLength 최대 길이
      +     * @return 잘린 문자열
      +     */
      +    public static String truncate(String str, int maxLength) {
      +        if (isBlank(str) || str.length() <= maxLength) {
      +            return str;
      +        }
      +        return str.substring(0, maxLength) + "...";
      +    }
      +
      +    /**
      +     * null인 경우 기본값 반환
      +     *
      +     * @param str          원본 문자열
      +     * @param defaultValue 기본값
      +     * @return 원본 또는 기본값
      +     */
      +    public static String defaultIfBlank(String str, String defaultValue) {
      +        return StringUtils.defaultIfBlank(str, defaultValue);
      +    }
      +
      +    /**
      +     * 문자열에서 공백 제거
      +     *
      +     * @param str 원본 문자열
      +     * @return 공백이 제거된 문자열
      +     */
      +    public static String removeWhitespace(String str) {
      +        if (isBlank(str)) {
      +            return str;
      +        }
      +        return str.replaceAll("\\s+", "");
      +    }
      +
      +    /**
      +     * 전화번호 포맷 검증
      +     *
      +     * @param phoneNumber 전화번호
      +     * @return 유효한 전화번호 형식 여부
      +     */
      +    public static boolean isValidPhoneNumber(String phoneNumber) {
      +        if (isBlank(phoneNumber)) {
      +            return false;
      +        }
      +        String cleaned = phoneNumber.replaceAll("[^0-9]", "");
      +        return cleaned.length() >= 10 && cleaned.length() <= 11;
      +    }
      +
      +    /**
      +     * 이메일 포맷 검증
      +     *
      +     * @param email 이메일
      +     * @return 유효한 이메일 형식 여부
      +     */
      +    public static boolean isValidEmail(String email) {
      +        if (isBlank(email)) {
      +            return false;
      +        }
      +        String emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}$";
      +        return email.matches(emailRegex);
      +    }
      +
      +    /**
      +     * 사업자번호 포맷 검증
      +     *
      +     * @param businessNumber 사업자번호
      +     * @return 유효한 사업자번호 형식 여부
      +     */
      +    public static boolean isValidBusinessNumber(String businessNumber) {
      +        if (isBlank(businessNumber)) {
      +            return false;
      +        }
      +        String cleaned = businessNumber.replaceAll("[^0-9]", "");
      +        return cleaned.length() == 10;
      +    }
      +}
      diff --git a/common/src/main/java/com/kt/event/common/util/ValidationUtil.java b/common/src/main/java/com/kt/event/common/util/ValidationUtil.java
      new file mode 100644
      index 0000000..090ae79
      --- /dev/null
      +++ b/common/src/main/java/com/kt/event/common/util/ValidationUtil.java
      @@ -0,0 +1,173 @@
      +package com.kt.event.common.util;
      +
      +import com.kt.event.common.exception.BusinessException;
      +import com.kt.event.common.exception.ErrorCode;
      +
      +/**
      + * 유효성 검증 유틸리티
      + * 비즈니스 로직에서 사용하는 공통 유효성 검증 기능 제공
      + */
      +public class ValidationUtil {
      +
      +    /**
      +     * null 체크 및 예외 발생
      +     *
      +     * @param object    검증할 객체
      +     * @param errorCode 에러 코드
      +     * @throws BusinessException 객체가 null인 경우
      +     */
      +    public static void requireNonNull(Object object, ErrorCode errorCode) {
      +        if (object == null) {
      +            throw new BusinessException(errorCode);
      +        }
      +    }
      +
      +    /**
      +     * null 체크 및 예외 발생 (커스텀 메시지)
      +     *
      +     * @param object    검증할 객체
      +     * @param errorCode 에러 코드
      +     * @param message   커스텀 메시지
      +     * @throws BusinessException 객체가 null인 경우
      +     */
      +    public static void requireNonNull(Object object, ErrorCode errorCode, String message) {
      +        if (object == null) {
      +            throw new BusinessException(errorCode, message);
      +        }
      +    }
      +
      +    /**
      +     * 문자열 공백 체크 및 예외 발생
      +     *
      +     * @param str       검증할 문자열
      +     * @param errorCode 에러 코드
      +     * @throws BusinessException 문자열이 null이거나 공백인 경우
      +     */
      +    public static void requireNotBlank(String str, ErrorCode errorCode) {
      +        if (StringUtil.isBlank(str)) {
      +            throw new BusinessException(errorCode);
      +        }
      +    }
      +
      +    /**
      +     * 문자열 공백 체크 및 예외 발생 (커스텀 메시지)
      +     *
      +     * @param str       검증할 문자열
      +     * @param errorCode 에러 코드
      +     * @param message   커스텀 메시지
      +     * @throws BusinessException 문자열이 null이거나 공백인 경우
      +     */
      +    public static void requireNotBlank(String str, ErrorCode errorCode, String message) {
      +        if (StringUtil.isBlank(str)) {
      +            throw new BusinessException(errorCode, message);
      +        }
      +    }
      +
      +    /**
      +     * 조건 검증 및 예외 발생
      +     *
      +     * @param condition 검증할 조건
      +     * @param errorCode 에러 코드
      +     * @throws BusinessException 조건이 false인 경우
      +     */
      +    public static void require(boolean condition, ErrorCode errorCode) {
      +        if (!condition) {
      +            throw new BusinessException(errorCode);
      +        }
      +    }
      +
      +    /**
      +     * 조건 검증 및 예외 발생 (커스텀 메시지)
      +     *
      +     * @param condition 검증할 조건
      +     * @param errorCode 에러 코드
      +     * @param message   커스텀 메시지
      +     * @throws BusinessException 조건이 false인 경우
      +     */
      +    public static void require(boolean condition, ErrorCode errorCode, String message) {
      +        if (!condition) {
      +            throw new BusinessException(errorCode, message);
      +        }
      +    }
      +
      +    /**
      +     * 전화번호 유효성 검증
      +     *
      +     * @param phoneNumber 전화번호
      +     * @param errorCode   에러 코드
      +     * @throws BusinessException 유효하지 않은 전화번호인 경우
      +     */
      +    public static void requireValidPhoneNumber(String phoneNumber, ErrorCode errorCode) {
      +        if (!StringUtil.isValidPhoneNumber(phoneNumber)) {
      +            throw new BusinessException(errorCode, "유효하지 않은 전화번호입니다: " + phoneNumber);
      +        }
      +    }
      +
      +    /**
      +     * 이메일 유효성 검증
      +     *
      +     * @param email     이메일
      +     * @param errorCode 에러 코드
      +     * @throws BusinessException 유효하지 않은 이메일인 경우
      +     */
      +    public static void requireValidEmail(String email, ErrorCode errorCode) {
      +        if (!StringUtil.isValidEmail(email)) {
      +            throw new BusinessException(errorCode, "유효하지 않은 이메일입니다: " + email);
      +        }
      +    }
      +
      +    /**
      +     * 사업자번호 유효성 검증
      +     *
      +     * @param businessNumber 사업자번호
      +     * @param errorCode      에러 코드
      +     * @throws BusinessException 유효하지 않은 사업자번호인 경우
      +     */
      +    public static void requireValidBusinessNumber(String businessNumber, ErrorCode errorCode) {
      +        if (!StringUtil.isValidBusinessNumber(businessNumber)) {
      +            throw new BusinessException(errorCode, "유효하지 않은 사업자번호입니다: " + businessNumber);
      +        }
      +    }
      +
      +    /**
      +     * 양수 검증
      +     *
      +     * @param value     검증할 값
      +     * @param errorCode 에러 코드
      +     * @throws BusinessException 값이 0보다 작거나 같은 경우
      +     */
      +    public static void requirePositive(long value, ErrorCode errorCode) {
      +        if (value <= 0) {
      +            throw new BusinessException(errorCode, "값은 양수여야 합니다: " + value);
      +        }
      +    }
      +
      +    /**
      +     * 음수 아닌 값 검증
      +     *
      +     * @param value     검증할 값
      +     * @param errorCode 에러 코드
      +     * @throws BusinessException 값이 0보다 작은 경우
      +     */
      +    public static void requireNonNegative(long value, ErrorCode errorCode) {
      +        if (value < 0) {
      +            throw new BusinessException(errorCode, "값은 음수가 아니어야 합니다: " + value);
      +        }
      +    }
      +
      +    /**
      +     * 범위 검증
      +     *
      +     * @param value     검증할 값
      +     * @param min       최소값
      +     * @param max       최대값
      +     * @param errorCode 에러 코드
      +     * @throws BusinessException 값이 범위를 벗어난 경우
      +     */
      +    public static void requireInRange(long value, long min, long max, ErrorCode errorCode) {
      +        if (value < min || value > max) {
      +            throw new BusinessException(errorCode,
      +                    String.format("값은 %d ~ %d 범위여야 합니다: %d", min, max, value));
      +        }
      +    }
      +}
      diff --git a/content-service/build.gradle b/content-service/build.gradle
      new file mode 100644
      index 0000000..aa9be20
      --- /dev/null
      +++ b/content-service/build.gradle
      @@ -0,0 +1,20 @@
      +dependencies {
      +    // Kafka Consumer
      +    implementation 'org.springframework.kafka:spring-kafka'
      +
      +    // Redis for AI data reading and image URL caching
      +    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
      +
      +    // OpenFeign for Stable Diffusion/DALL-E API
      +    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
      +
      +    // Azure Blob Storage for CDN
      +    implementation "com.azure:azure-storage-blob:${azureStorageVersion}"
      +
      +    // Resilience4j for Circuit Breaker
      +    implementation "io.github.resilience4j:resilience4j-spring-boot3:${resilience4jVersion}"
      +    implementation "io.github.resilience4j:resilience4j-circuitbreaker:${resilience4jVersion}"
      +
      +    // Jackson for JSON
      +    implementation 'com.fasterxml.jackson.core:jackson-databind'
      +}
      diff --git a/develop/dev/package-structure.md b/develop/dev/package-structure.md
      new file mode 100644
      index 0000000..2d5c56d
      --- /dev/null
      +++ b/develop/dev/package-structure.md
      @@ -0,0 +1,610 @@
      +# KT Event Marketing - Clean Architecture Package Structure
      +
      +## 프로젝트 구조 개요
      +
      +```
      +kt-event-marketing/
      +├── common/                          # 공통 모듈
      +├── user-service/                    # 사용자 서비스
      +├── event-service/                   # 이벤트 서비스
      +├── ai-service/                      # AI 서비스
      +├── content-service/                 # 콘텐츠 서비스
      +├── distribution-service/            # 배포 서비스
      +├── participation-service/           # 참여 서비스
      +├── analytics-service/               # 분석 서비스
      +├── settings.gradle
      +└── build.gradle
      +```
      +
      +---
      +
      +## Common 모듈 패키지 구조
      +
      +```
      +common/
      +└── src/main/java/com/kt/event/common/
      +    ├── dto/
      +    │   ├── ApiResponse.java              # 공통 API 응답 래퍼
      +    │   ├── PageResponse.java             # 페이지네이션 응답
      +    │   └── ErrorResponse.java            # 에러 응답
      +    ├── exception/
      +    │   ├── ErrorCode.java                # 에러 코드 enum
      +    │   ├── BusinessException.java        # 비즈니스 예외
      +    │   ├── InfraException.java           # 인프라 예외
      +    │   └── GlobalExceptionHandler.java   # 전역 예외 핸들러
      +    ├── util/
      +    │   ├── DateTimeUtil.java             # 날짜/시간 유틸
      +    │   ├── StringUtil.java               # 문자열 유틸
      +    │   ├── ValidationUtil.java           # 유효성 검증 유틸
      +    │   └── EncryptionUtil.java           # 암호화 유틸
      +    ├── security/
      +    │   ├── UserPrincipal.java            # 인증된 사용자 정보
      +    │   ├── JwtTokenProvider.java         # JWT 토큰 제공자
      +    │   └── JwtAuthenticationFilter.java  # JWT 인증 필터
      +    └── entity/
      +        └── BaseTimeEntity.java           # 생성/수정 시간 base entity
      +```
      +
      +---
      +
      +## User Service 패키지 구조 (Clean Architecture)
      +
      +```
      +user-service/
      +└── src/main/java/com/kt/event/user/
      +    ├── biz/                              # Business Layer
      +    │   ├── domain/                       # 도메인 모델
      +    │   │   ├── User.java
      +    │   │   ├── Store.java
      +    │   │   └── BusinessVerification.java
      +    │   ├── usecase/                      # Use Case 인터페이스
      +    │   │   ├── in/                       # Inbound Port (비즈니스 로직 진입점)
      +    │   │   │   ├── RegisterUserUseCase.java
      +    │   │   │   ├── LoginUserUseCase.java
      +    │   │   │   ├── LogoutUserUseCase.java
      +    │   │   │   ├── GetUserProfileUseCase.java
      +    │   │   │   ├── UpdateUserProfileUseCase.java
      +    │   │   │   ├── ChangePasswordUseCase.java
      +    │   │   │   └── GetStoreInfoUseCase.java
      +    │   │   └── out/                      # Outbound Port (외부 의존성 인터페이스)
      +    │   │       ├── UserReader.java
      +    │   │       ├── UserWriter.java
      +    │   │       ├── StoreReader.java
      +    │   │       ├── StoreWriter.java
      +    │   │       ├── RedisSessionWriter.java
      +    │   │       └── BusinessVerifier.java
      +    │   ├── service/                      # Use Case 구현체
      +    │   │   ├── UserService.java
      +    │   │   ├── AuthenticationService.java
      +    │   │   └── ProfileService.java
      +    │   └── dto/                          # 비즈니스 DTO
      +    │       ├── UserCommand.java
      +    │       ├── UserInfo.java
      +    │       └── StoreInfo.java
      +    └── infra/                            # Infrastructure Layer
      +        ├── UserApplication.java          # Spring Boot Main
      +        ├── controller/                   # REST API Controller (Inbound Adapter)
      +        │   ├── UserController.java
      +        │   └── AuthController.java
      +        ├── dto/                          # API Request/Response DTO
      +        │   ├── request/
      +        │   │   ├── UserRegisterRequest.java
      +        │   │   ├── UserLoginRequest.java
      +        │   │   ├── UserProfileUpdateRequest.java
      +        │   │   └── ChangePasswordRequest.java
      +        │   └── response/
      +        │       ├── UserProfileResponse.java
      +        │       ├── StoreInfoResponse.java
      +        │       └── LoginResponse.java
      +        ├── gateway/                      # Outbound Adapter
      +        │   ├── entity/                   # JPA Entity
      +        │   │   ├── UserEntity.java
      +        │   │   └── StoreEntity.java
      +        │   ├── repository/               # JPA Repository
      +        │   │   ├── UserJpaRepository.java
      +        │   │   └── StoreJpaRepository.java
      +        │   ├── UserGateway.java          # User Reader/Writer 구현
      +        │   ├── StoreGateway.java         # Store Reader/Writer 구현
      +        │   ├── RedisSessionGateway.java  # Redis Session 구현
      +        │   └── BusinessVerifierGateway.java # 사업자번호 검증 구현
      +        └── config/                       # 설정
      +            ├── SecurityConfig.java
      +            ├── RedisConfig.java
      +            ├── SwaggerConfig.java
      +            └── DataLoader.java
      +```
      +
      +---
      +
      +## Event Service 패키지 구조 (Clean Architecture)
      +
      +```
      +event-service/
      +└── src/main/java/com/kt/event/event/
      +    ├── biz/
      +    │   ├── domain/
      +    │   │   ├── Event.java
      +    │   │   ├── EventObjective.java
      +    │   │   ├── EventStatus.java
      +    │   │   ├── AIRecommendation.java
      +    │   │   ├── Image.java
      +    │   │   ├── DistributionChannel.java
      +    │   │   └── Job.java
      +    │   ├── usecase/
      +    │   │   ├── in/
      +    │   │   │   ├── CreateEventObjectiveUseCase.java
      +    │   │   │   ├── RequestAIRecommendationUseCase.java
      +    │   │   │   ├── SelectAIRecommendationUseCase.java
      +    │   │   │   ├── RequestImageGenerationUseCase.java
      +    │   │   │   ├── SelectImageUseCase.java
      +    │   │   │   ├── EditImageUseCase.java
      +    │   │   │   ├── SelectDistributionChannelsUseCase.java
      +    │   │   │   ├── PublishEventUseCase.java
      +    │   │   │   ├── GetEventListUseCase.java
      +    │   │   │   ├── GetEventDetailUseCase.java
      +    │   │   │   ├── UpdateEventUseCase.java
      +    │   │   │   ├── DeleteEventUseCase.java
      +    │   │   │   ├── EndEventUseCase.java
      +    │   │   │   └── GetJobStatusUseCase.java
      +    │   │   └── out/
      +    │   │       ├── EventReader.java
      +    │   │       ├── EventWriter.java
      +    │   │       ├── JobReader.java
      +    │   │       ├── JobWriter.java
      +    │   │       ├── KafkaJobPublisher.java
      +    │   │       ├── KafkaEventPublisher.java
      +    │   │       ├── RedisAIDataReader.java
      +    │   │       ├── RedisImageDataReader.java
      +    │   │       └── DistributionServiceCaller.java
      +    │   ├── service/
      +    │   │   ├── EventCreationService.java
      +    │   │   ├── EventManagementService.java
      +    │   │   ├── AIRecommendationService.java
      +    │   │   ├── ImageGenerationService.java
      +    │   │   ├── DistributionService.java
      +    │   │   └── JobStatusService.java
      +    │   └── dto/
      +    │       ├── EventCommand.java
      +    │       ├── EventInfo.java
      +    │       ├── AIRecommendationInfo.java
      +    │       ├── ImageInfo.java
      +    │       ├── ChannelInfo.java
      +    │       └── JobInfo.java
      +    └── infra/
      +        ├── EventApplication.java
      +        ├── controller/
      +        │   ├── EventController.java
      +        │   ├── EventCreationController.java
      +        │   └── JobController.java
      +        ├── dto/
      +        │   ├── request/
      +        │   │   ├── EventObjectiveRequest.java
      +        │   │   ├── AIRecommendationSelectionRequest.java
      +        │   │   ├── ImageSelectionRequest.java
      +        │   │   ├── ImageEditRequest.java
      +        │   │   ├── ChannelSelectionRequest.java
      +        │   │   └── EventUpdateRequest.java
      +        │   └── response/
      +        │       ├── EventResponse.java
      +        │       ├── EventListResponse.java
      +        │       ├── JobStatusResponse.java
      +        │       └── PublishResponse.java
      +        ├── gateway/
      +        │   ├── entity/
      +        │   │   ├── EventEntity.java
      +        │   │   ├── AIRecommendationEntity.java
      +        │   │   ├── ImageEntity.java
      +        │   │   ├── DistributionChannelEntity.java
      +        │   │   └── JobEntity.java
      +        │   ├── repository/
      +        │   │   ├── EventJpaRepository.java
      +        │   │   ├── AIRecommendationJpaRepository.java
      +        │   │   ├── ImageJpaRepository.java
      +        │   │   ├── DistributionChannelJpaRepository.java
      +        │   │   └── JobJpaRepository.java
      +        │   ├── EventGateway.java
      +        │   ├── JobGateway.java
      +        │   ├── KafkaProducerGateway.java
      +        │   ├── RedisGateway.java
      +        │   └── DistributionServiceGateway.java
      +        └── config/
      +            ├── SecurityConfig.java
      +            ├── KafkaProducerConfig.java
      +            ├── RedisConfig.java
      +            ├── SwaggerConfig.java
      +            └── FeignConfig.java
      +```
      +
      +---
      +
      +## AI Service 패키지 구조 (Clean Architecture)
      +
      +```
      +ai-service/
      +└── src/main/java/com/kt/event/ai/
      +    ├── biz/
      +    │   ├── domain/
      +    │   │   ├── AIRecommendation.java
      +    │   │   ├── TrendAnalysis.java
      +    │   │   ├── EventRecommendation.java
      +    │   │   └── Job.java
      +    │   ├── usecase/
      +    │   │   ├── in/
      +    │   │   │   ├── GenerateAIRecommendationUseCase.java
      +    │   │   │   ├── GetJobStatusUseCase.java
      +    │   │   │   └── GetRecommendationResultUseCase.java
      +    │   │   └── out/
      +    │   │       ├── JobReader.java
      +    │   │       ├── JobWriter.java
      +    │   │       ├── RedisResultWriter.java
      +    │   │       ├── RedisResultReader.java
      +    │   │       ├── TrendAnalyzer.java
      +    │   │       └── AIModelCaller.java
      +    │   ├── service/
      +    │   │   ├── AIRecommendationService.java
      +    │   │   ├── TrendAnalysisService.java
      +    │   │   └── JobManagementService.java
      +    │   └── dto/
      +    │       ├── AICommand.java
      +    │       ├── AIResult.java
      +    │       ├── TrendInfo.java
      +    │       └── JobInfo.java
      +    └── infra/
      +        ├── AIApplication.java
      +        ├── controller/
      +        │   └── InternalAIController.java
      +        ├── dto/
      +        │   ├── request/
      +        │   │   └── AIRequestDto.java
      +        │   └── response/
      +        │       ├── AIRecommendationResponse.java
      +        │       ├── JobStatusResponse.java
      +        │       └── TrendAnalysisResponse.java
      +        ├── gateway/
      +        │   ├── entity/
      +        │   │   └── JobEntity.java
      +        │   ├── repository/
      +        │   │   └── JobJpaRepository.java
      +        │   ├── JobGateway.java
      +        │   ├── RedisGateway.java
      +        │   ├── TrendAnalyzerGateway.java
      +        │   └── ClaudeAPIGateway.java
      +        ├── consumer/
      +        │   └── AIJobConsumer.java
      +        └── config/
      +            ├── SecurityConfig.java
      +            ├── KafkaConsumerConfig.java
      +            ├── RedisConfig.java
      +            ├── SwaggerConfig.java
      +            └── ClaudeAPIConfig.java
      +```
      +
      +---
      +
      +## Content Service 패키지 구조 (Clean Architecture)
      +
      +```
      +content-service/
      +└── src/main/java/com/kt/event/content/
      +    ├── biz/
      +    │   ├── domain/
      +    │   │   ├── Content.java
      +    │   │   ├── GeneratedImage.java
      +    │   │   ├── ImageStyle.java
      +    │   │   ├── Platform.java
      +    │   │   └── Job.java
      +    │   ├── usecase/
      +    │   │   ├── in/
      +    │   │   │   ├── GenerateImagesUseCase.java
      +    │   │   │   ├── GetJobStatusUseCase.java
      +    │   │   │   ├── GetEventContentUseCase.java
      +    │   │   │   ├── GetImageListUseCase.java
      +    │   │   │   ├── GetImageDetailUseCase.java
      +    │   │   │   └── RegenerateImageUseCase.java
      +    │   │   └── out/
      +    │   │       ├── ContentReader.java
      +    │   │       ├── ContentWriter.java
      +    │   │       ├── JobReader.java
      +    │   │       ├── JobWriter.java
      +    │   │       ├── RedisAIDataReader.java
      +    │   │       ├── RedisImageWriter.java
      +    │   │       ├── ImageGeneratorCaller.java
      +    │   │       └── CDNUploader.java
      +    │   ├── service/
      +    │   │   ├── ImageGenerationService.java
      +    │   │   ├── ContentManagementService.java
      +    │   │   └── JobManagementService.java
      +    │   └── dto/
      +    │       ├── ContentCommand.java
      +    │       ├── ContentInfo.java
      +    │       ├── ImageInfo.java
      +    │       └── JobInfo.java
      +    └── infra/
      +        ├── ContentApplication.java
      +        ├── controller/
      +        │   └── ContentController.java
      +        ├── dto/
      +        │   ├── request/
      +        │   │   ├── ImageGenerationRequest.java
      +        │   │   └── RegenerateRequest.java
      +        │   └── response/
      +        │       ├── ContentResponse.java
      +        │       ├── ImageResponse.java
      +        │       └── JobStatusResponse.java
      +        ├── gateway/
      +        │   ├── entity/
      +        │   │   ├── ContentEntity.java
      +        │   │   ├── GeneratedImageEntity.java
      +        │   │   └── JobEntity.java
      +        │   ├── repository/
      +        │   │   ├── ContentJpaRepository.java
      +        │   │   ├── GeneratedImageJpaRepository.java
      +        │   │   └── JobJpaRepository.java
      +        │   ├── ContentGateway.java
      +        │   ├── JobGateway.java
      +        │   ├── RedisGateway.java
      +        │   ├── StableDiffusionGateway.java
      +        │   └── AzureBlobStorageGateway.java
      +        ├── consumer/
      +        │   └── ImageGenerationJobConsumer.java
      +        └── config/
      +            ├── SecurityConfig.java
      +            ├── KafkaConsumerConfig.java
      +            ├── RedisConfig.java
      +            ├── SwaggerConfig.java
      +            └── AzureStorageConfig.java
      +```
      +
      +---
      +
      +## Distribution Service 패키지 구조 (Clean Architecture)
      +
      +```
      +distribution-service/
      +└── src/main/java/com/kt/event/distribution/
      +    ├── biz/
      +    │   ├── domain/
      +    │   │   ├── Distribution.java
      +    │   │   ├── DistributionChannel.java
      +    │   │   ├── ChannelResult.java
      +    │   │   └── DistributionStatus.java
      +    │   ├── usecase/
      +    │   │   ├── in/
      +    │   │   │   ├── DistributeToChannelsUseCase.java
      +    │   │   │   └── GetDistributionStatusUseCase.java
      +    │   │   └── out/
      +    │   │       ├── DistributionReader.java
      +    │   │       ├── DistributionWriter.java
      +    │   │       ├── ChannelDistributor.java
      +    │   │       └── KafkaEventPublisher.java
      +    │   ├── service/
      +    │   │   ├── DistributionService.java
      +    │   │   └── ChannelService.java
      +    │   └── dto/
      +    │       ├── DistributionCommand.java
      +    │       ├── DistributionInfo.java
      +    │       └── ChannelInfo.java
      +    └── infra/
      +        ├── DistributionApplication.java
      +        ├── controller/
      +        │   └── DistributionController.java
      +        ├── dto/
      +        │   ├── request/
      +        │   │   └── DistributionRequest.java
      +        │   └── response/
      +        │       ├── DistributionResponse.java
      +        │       └── DistributionStatusResponse.java
      +        ├── gateway/
      +        │   ├── entity/
      +        │   │   ├── DistributionEntity.java
      +        │   │   └── ChannelResultEntity.java
      +        │   ├── repository/
      +        │   │   ├── DistributionJpaRepository.java
      +        │   │   └── ChannelResultJpaRepository.java
      +        │   ├── DistributionGateway.java
      +        │   ├── KafkaProducerGateway.java
      +        │   └── channel/
      +        │       ├── UriDongneTVGateway.java
      +        │       ├── RingoBizGateway.java
      +        │       ├── GenieTVGateway.java
      +        │       ├── InstagramGateway.java
      +        │       ├── NaverBlogGateway.java
      +        │       └── KakaoChannelGateway.java
      +        └── config/
      +            ├── SecurityConfig.java
      +            ├── KafkaProducerConfig.java
      +            ├── SwaggerConfig.java
      +            ├── ResilienceConfig.java
      +            └── FeignConfig.java
      +```
      +
      +---
      +
      +## Participation Service 패키지 구조 (Clean Architecture)
      +
      +```
      +participation-service/
      +└── src/main/java/com/kt/event/participation/
      +    ├── biz/
      +    │   ├── domain/
      +    │   │   ├── Participant.java
      +    │   │   ├── Winner.java
      +    │   │   ├── DrawResult.java
      +    │   │   └── Consent.java
      +    │   ├── usecase/
      +    │   │   ├── in/
      +    │   │   │   ├── ParticipateEventUseCase.java
      +    │   │   │   ├── GetParticipantListUseCase.java
      +    │   │   │   ├── GetParticipantDetailUseCase.java
      +    │   │   │   ├── DrawWinnersUseCase.java
      +    │   │   │   └── GetWinnerListUseCase.java
      +    │   │   └── out/
      +    │   │       ├── ParticipantReader.java
      +    │   │       ├── ParticipantWriter.java
      +    │   │       ├── WinnerReader.java
      +    │   │       ├── WinnerWriter.java
      +    │   │       ├── DrawExecutor.java
      +    │   │       └── KafkaEventPublisher.java
      +    │   ├── service/
      +    │   │   ├── ParticipationService.java
      +    │   │   └── WinnerDrawService.java
      +    │   └── dto/
      +    │       ├── ParticipationCommand.java
      +    │       ├── ParticipantInfo.java
      +    │       ├── WinnerInfo.java
      +    │       └── DrawCommand.java
      +    └── infra/
      +        ├── ParticipationApplication.java
      +        ├── controller/
      +        │   └── ParticipationController.java
      +        ├── dto/
      +        │   ├── request/
      +        │   │   ├── ParticipationRequest.java
      +        │   │   └── WinnerDrawRequest.java
      +        │   └── response/
      +        │       ├── ParticipationResponse.java
      +        │       ├── ParticipantListResponse.java
      +        │       └── WinnerResponse.java
      +        ├── gateway/
      +        │   ├── entity/
      +        │   │   ├── ParticipantEntity.java
      +        │   │   └── WinnerEntity.java
      +        │   ├── repository/
      +        │   │   ├── ParticipantJpaRepository.java
      +        │   │   └── WinnerJpaRepository.java
      +        │   ├── ParticipantGateway.java
      +        │   ├── WinnerGateway.java
      +        │   ├── DrawExecutorGateway.java
      +        │   └── KafkaProducerGateway.java
      +        └── config/
      +            ├── SecurityConfig.java
      +            ├── KafkaProducerConfig.java
      +            └── SwaggerConfig.java
      +```
      +
      +---
      +
      +## Analytics Service 패키지 구조 (Clean Architecture)
      +
      +```
      +analytics-service/
      +└── src/main/java/com/kt/event/analytics/
      +    ├── biz/
      +    │   ├── domain/
      +    │   │   ├── EventAnalytics.java
      +    │   │   ├── ChannelPerformance.java
      +    │   │   ├── TimelineData.java
      +    │   │   ├── RoiDetail.java
      +    │   │   └── ParticipantProfile.java
      +    │   ├── usecase/
      +    │   │   ├── in/
      +    │   │   │   ├── GetAnalyticsDashboardUseCase.java
      +    │   │   │   ├── GetChannelPerformanceUseCase.java
      +    │   │   │   ├── GetTimelineDataUseCase.java
      +    │   │   │   └── GetRoiDetailUseCase.java
      +    │   │   └── out/
      +    │   │       ├── AnalyticsReader.java
      +    │   │       ├── AnalyticsWriter.java
      +    │   │       ├── ExternalAPIDataCollector.java
      +    │   │       └── RedisAnalyticsCache.java
      +    │   ├── service/
      +    │   │   ├── AnalyticsDashboardService.java
      +    │   │   ├── PerformanceAnalysisService.java
      +    │   │   └── RoiCalculationService.java
      +    │   └── dto/
      +    │       ├── AnalyticsCommand.java
      +    │       ├── AnalyticsInfo.java
      +    │       ├── PerformanceInfo.java
      +    │       └── RoiInfo.java
      +    └── infra/
      +        ├── AnalyticsApplication.java
      +        ├── controller/
      +        │   └── AnalyticsController.java
      +        ├── dto/
      +        │   └── response/
      +        │       ├── AnalyticsDashboardResponse.java
      +        │       ├── ChannelPerformanceResponse.java
      +        │       ├── TimelineDataResponse.java
      +        │       └── RoiDetailResponse.java
      +        ├── gateway/
      +        │   ├── entity/
      +        │   │   ├── EventAnalyticsEntity.java
      +        │   │   ├── ChannelPerformanceEntity.java
      +        │   │   └── TimelineDataEntity.java
      +        │   ├── repository/
      +        │   │   ├── EventAnalyticsJpaRepository.java
      +        │   │   ├── ChannelPerformanceJpaRepository.java
      +        │   │   └── TimelineDataJpaRepository.java
      +        │   ├── AnalyticsGateway.java
      +        │   ├── RedisGateway.java
      +        │   └── external/
      +        │       ├── UriDongneTVAPIGateway.java
      +        │       ├── GenieTVAPIGateway.java
      +        │       ├── InstagramAPIGateway.java
      +        │       ├── NaverAPIGateway.java
      +        │       └── KakaoAPIGateway.java
      +        ├── consumer/
      +        │   └── AnalyticsEventConsumer.java
      +        └── config/
      +            ├── SecurityConfig.java
      +            ├── KafkaConsumerConfig.java
      +            ├── RedisConfig.java
      +            ├── SwaggerConfig.java
      +            ├── ResilienceConfig.java
      +            └── FeignConfig.java
      +```
      +
      +---
      +
      +## 아키텍처 설명
      +
      +### Clean Architecture 레이어 구조
      +
      +1. **biz (Business Layer)** - 비즈니스 로직
      +   - `domain/`: 핵심 도메인 모델 (순수 Java 객체, 외부 의존성 없음)
      +   - `usecase/in/`: Inbound Port (비즈니스 로직 진입점 인터페이스)
      +   - `usecase/out/`: Outbound Port (외부 의존성 인터페이스)
      +   - `service/`: Use Case 구현체 (비즈니스 로직 실행)
      +   - `dto/`: 비즈니스 레이어 내부 DTO
      +
      +2. **infra (Infrastructure Layer)** - 외부 세계와의 통신
      +   - `controller/`: REST API Controller (Inbound Adapter)
      +   - `dto/request/`, `dto/response/`: API 계약 DTO
      +   - `gateway/`: Outbound Adapter (DB, 외부 API, Kafka, Redis 등)
      +   - `gateway/entity/`: JPA Entity
      +   - `gateway/repository/`: JPA Repository
      +   - `consumer/`: Kafka Consumer
      +   - `config/`: 설정 클래스
      +
      +### 의존성 규칙
      +
      +- **biz → infra**: ❌ 불가능 (비즈니스 로직은 인프라에 의존하지 않음)
      +- **infra → biz**: ✅ 가능 (인프라는 비즈니스 인터페이스를 구현)
      +- **domain → 외부**: ❌ 불가능 (순수 비즈니스 로직만 포함)
      +
      +### 네이밍 규칙
      +
      +- **UseCase 인터페이스**: `{동작}{대상}UseCase` (예: `RegisterUserUseCase`)
      +- **Service 구현체**: `{대상}Service` (예: `UserService`)
      +- **Gateway 구현체**: `{대상}Gateway` (예: `UserGateway`)
      +- **Port 인터페이스**: `{대상}{동작}` (예: `UserReader`, `UserWriter`)
      +
      +---
      +
      +## 기술 스택 정리
      +
      +| 레이어 | 기술 |
      +|--------|------|
      +| Framework | Spring Boot 3.3.0, Java 21 |
      +| Database | PostgreSQL (각 서비스별 독립 DB) |
      +| Cache | Redis (공통) |
      +| Message Queue | Kafka (비동기 Job 처리, Event 발행) |
      +| Security | JWT, Spring Security |
      +| API Documentation | Swagger UI (SpringDoc OpenAPI) |
      +| Persistence | Spring Data JPA |
      +| Build Tool | Gradle 8.x |
      +
      +---
      +
      +**작성일**: 2025-10-23
      +**작성자**: Backend Developer (리액트킹)
      diff --git a/distribution-service/build.gradle b/distribution-service/build.gradle
      new file mode 100644
      index 0000000..f50172a
      --- /dev/null
      +++ b/distribution-service/build.gradle
      @@ -0,0 +1,16 @@
      +dependencies {
      +    // Kafka for event publishing
      +    implementation 'org.springframework.kafka:spring-kafka'
      +
      +    // OpenFeign for external channel APIs
      +    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
      +
      +    // Resilience4j for Circuit Breaker, Retry, Bulkhead
      +    implementation "io.github.resilience4j:resilience4j-spring-boot3:${resilience4jVersion}"
      +    implementation "io.github.resilience4j:resilience4j-circuitbreaker:${resilience4jVersion}"
      +    implementation "io.github.resilience4j:resilience4j-retry:${resilience4jVersion}"
      +    implementation "io.github.resilience4j:resilience4j-bulkhead:${resilience4jVersion}"
      +
      +    // Jackson for JSON
      +    implementation 'com.fasterxml.jackson.core:jackson-databind'
      +}
      diff --git a/event-service/build.gradle b/event-service/build.gradle
      new file mode 100644
      index 0000000..0f2d88c
      --- /dev/null
      +++ b/event-service/build.gradle
      @@ -0,0 +1,13 @@
      +dependencies {
      +    // Kafka for job publishing
      +    implementation 'org.springframework.kafka:spring-kafka'
      +
      +    // Redis for AI/Image data caching
      +    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
      +
      +    // OpenFeign for Distribution Service call
      +    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
      +
      +    // Jackson for JSON
      +    implementation 'com.fasterxml.jackson.core:jackson-databind'
      +}
      diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
      new file mode 100644
      index 0000000..8bdaf60
      Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
      diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
      new file mode 100644
      index 0000000..9355b41
      --- /dev/null
      +++ b/gradle/wrapper/gradle-wrapper.properties
      @@ -0,0 +1,7 @@
      +distributionBase=GRADLE_USER_HOME
      +distributionPath=wrapper/dists
      +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
      +networkTimeout=10000
      +validateDistributionUrl=true
      +zipStoreBase=GRADLE_USER_HOME
      +zipStorePath=wrapper/dists
      diff --git a/gradlew b/gradlew
      new file mode 100755
      index 0000000..adff685
      --- /dev/null
      +++ b/gradlew
      @@ -0,0 +1,248 @@
      +#!/bin/sh
      +
      +#
      +# Copyright © 2015 the original authors.
      +#
      +# Licensed under the Apache License, Version 2.0 (the "License");
      +# you may not use this file except in compliance with the License.
      +# You may obtain a copy of the License at
      +#
      +#      https://www.apache.org/licenses/LICENSE-2.0
      +#
      +# Unless required by applicable law or agreed to in writing, software
      +# distributed under the License is distributed on an "AS IS" BASIS,
      +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +# See the License for the specific language governing permissions and
      +# limitations under the License.
      +#
      +# SPDX-License-Identifier: Apache-2.0
      +#
      +
      +##############################################################################
      +#
      +#   Gradle start up script for POSIX generated by Gradle.
      +#
      +#   Important for running:
      +#
      +#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
      +#       noncompliant, but you have some other compliant shell such as ksh or
      +#       bash, then to run this script, type that shell name before the whole
      +#       command line, like:
      +#
      +#           ksh Gradle
      +#
      +#       Busybox and similar reduced shells will NOT work, because this script
      +#       requires all of these POSIX shell features:
      +#         * functions;
      +#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
      +#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
      +#         * compound commands having a testable exit status, especially «case»;
      +#         * various built-in commands including «command», «set», and «ulimit».
      +#
      +#   Important for patching:
      +#
      +#   (2) This script targets any POSIX shell, so it avoids extensions provided
      +#       by Bash, Ksh, etc; in particular arrays are avoided.
      +#
      +#       The "traditional" practice of packing multiple parameters into a
      +#       space-separated string is a well documented source of bugs and security
      +#       problems, so this is (mostly) avoided, by progressively accumulating
      +#       options in "$@", and eventually passing that to Java.
      +#
      +#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
      +#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
      +#       see the in-line comments for details.
      +#
      +#       There are tweaks for specific operating systems such as AIX, CygWin,
      +#       Darwin, MinGW, and NonStop.
      +#
      +#   (3) This script is generated from the Groovy template
      +#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
      +#       within the Gradle project.
      +#
      +#       You can find Gradle at https://github.com/gradle/gradle/.
      +#
      +##############################################################################
      +
      +# Attempt to set APP_HOME
      +
      +# Resolve links: $0 may be a link
      +app_path=$0
      +
      +# Need this for daisy-chained symlinks.
      +while
      +    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
      +    [ -h "$app_path" ]
      +do
      +    ls=$( ls -ld "$app_path" )
      +    link=${ls#*' -> '}
      +    case $link in             #(
      +      /*)   app_path=$link ;; #(
      +      *)    app_path=$APP_HOME$link ;;
      +    esac
      +done
      +
      +# This is normally unused
      +# shellcheck disable=SC2034
      +APP_BASE_NAME=${0##*/}
      +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
      +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
      +
      +# Use the maximum available, or set MAX_FD != -1 to use that value.
      +MAX_FD=maximum
      +
      +warn () {
      +    echo "$*"
      +} >&2
      +
      +die () {
      +    echo
      +    echo "$*"
      +    echo
      +    exit 1
      +} >&2
      +
      +# OS specific support (must be 'true' or 'false').
      +cygwin=false
      +msys=false
      +darwin=false
      +nonstop=false
      +case "$( uname )" in                #(
      +  CYGWIN* )         cygwin=true  ;; #(
      +  Darwin* )         darwin=true  ;; #(
      +  MSYS* | MINGW* )  msys=true    ;; #(
      +  NONSTOP* )        nonstop=true ;;
      +esac
      +
      +
      +
      +# Determine the Java command to use to start the JVM.
      +if [ -n "$JAVA_HOME" ] ; then
      +    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
      +        # IBM's JDK on AIX uses strange locations for the executables
      +        JAVACMD=$JAVA_HOME/jre/sh/java
      +    else
      +        JAVACMD=$JAVA_HOME/bin/java
      +    fi
      +    if [ ! -x "$JAVACMD" ] ; then
      +        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
      +
      +Please set the JAVA_HOME variable in your environment to match the
      +location of your Java installation."
      +    fi
      +else
      +    JAVACMD=java
      +    if ! command -v java >/dev/null 2>&1
      +    then
      +        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
      +
      +Please set the JAVA_HOME variable in your environment to match the
      +location of your Java installation."
      +    fi
      +fi
      +
      +# Increase the maximum file descriptors if we can.
      +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
      +    case $MAX_FD in #(
      +      max*)
      +        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
      +        # shellcheck disable=SC2039,SC3045
      +        MAX_FD=$( ulimit -H -n ) ||
      +            warn "Could not query maximum file descriptor limit"
      +    esac
      +    case $MAX_FD in  #(
      +      '' | soft) :;; #(
      +      *)
      +        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
      +        # shellcheck disable=SC2039,SC3045
      +        ulimit -n "$MAX_FD" ||
      +            warn "Could not set maximum file descriptor limit to $MAX_FD"
      +    esac
      +fi
      +
      +# Collect all arguments for the java command, stacking in reverse order:
      +#   * args from the command line
      +#   * the main class name
      +#   * -classpath
      +#   * -D...appname settings
      +#   * --module-path (only if needed)
      +#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
      +
      +# For Cygwin or MSYS, switch paths to Windows format before running java
      +if "$cygwin" || "$msys" ; then
      +    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
      +
      +    JAVACMD=$( cygpath --unix "$JAVACMD" )
      +
      +    # Now convert the arguments - kludge to limit ourselves to /bin/sh
      +    for arg do
      +        if
      +            case $arg in                                #(
      +              -*)   false ;;                            # don't mess with options #(
      +              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
      +                    [ -e "$t" ] ;;                      #(
      +              *)    false ;;
      +            esac
      +        then
      +            arg=$( cygpath --path --ignore --mixed "$arg" )
      +        fi
      +        # Roll the args list around exactly as many times as the number of
      +        # args, so each arg winds up back in the position where it started, but
      +        # possibly modified.
      +        #
      +        # NB: a `for` loop captures its iteration list before it begins, so
      +        # changing the positional parameters here affects neither the number of
      +        # iterations, nor the values presented in `arg`.
      +        shift                   # remove old arg
      +        set -- "$@" "$arg"      # push replacement arg
      +    done
      +fi
      +
      +
      +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
      +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
      +
      +# Collect all arguments for the java command:
      +#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
      +#     and any embedded shellness will be escaped.
      +#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
      +#     treated as '${Hostname}' itself on the command line.
      +
      +set -- \
      +        "-Dorg.gradle.appname=$APP_BASE_NAME" \
      +        -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
      +        "$@"
      +
      +# Stop when "xargs" is not available.
      +if ! command -v xargs >/dev/null 2>&1
      +then
      +    die "xargs is not available"
      +fi
      +
      +# Use "xargs" to parse quoted args.
      +#
      +# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
      +#
      +# In Bash we could simply go:
      +#
      +#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
      +#   set -- "${ARGS[@]}" "$@"
      +#
      +# but POSIX shell has neither arrays nor command substitution, so instead we
      +# post-process each arg (as a line of input to sed) to backslash-escape any
      +# character that might be a shell metacharacter, then use eval to reverse
      +# that process (while maintaining the separation between arguments), and wrap
      +# the whole thing up as a single "set" statement.
      +#
      +# This will of course break if any of these variables contains a newline or
      +# an unmatched quote.
      +#
      +
      +eval "set -- $(
      +        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
      +        xargs -n1 |
      +        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
      +        tr '\n' ' '
      +    )" '"$@"'
      +
      +exec "$JAVACMD" "$@"
      diff --git a/gradlew.bat b/gradlew.bat
      new file mode 100644
      index 0000000..e509b2d
      --- /dev/null
      +++ b/gradlew.bat
      @@ -0,0 +1,93 @@
      +@rem
      +@rem Copyright 2015 the original author or authors.
      +@rem
      +@rem Licensed under the Apache License, Version 2.0 (the "License");
      +@rem you may not use this file except in compliance with the License.
      +@rem You may obtain a copy of the License at
      +@rem
      +@rem      https://www.apache.org/licenses/LICENSE-2.0
      +@rem
      +@rem Unless required by applicable law or agreed to in writing, software
      +@rem distributed under the License is distributed on an "AS IS" BASIS,
      +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +@rem See the License for the specific language governing permissions and
      +@rem limitations under the License.
      +@rem
      +@rem SPDX-License-Identifier: Apache-2.0
      +@rem
      +
      +@if "%DEBUG%"=="" @echo off
      +@rem ##########################################################################
      +@rem
      +@rem  Gradle startup script for Windows
      +@rem
      +@rem ##########################################################################
      +
      +@rem Set local scope for the variables with windows NT shell
      +if "%OS%"=="Windows_NT" setlocal
      +
      +set DIRNAME=%~dp0
      +if "%DIRNAME%"=="" set DIRNAME=.
      +@rem This is normally unused
      +set APP_BASE_NAME=%~n0
      +set APP_HOME=%DIRNAME%
      +
      +@rem Resolve any "." and ".." in APP_HOME to make it shorter.
      +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
      +
      +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
      +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
      +
      +@rem Find java.exe
      +if defined JAVA_HOME goto findJavaFromJavaHome
      +
      +set JAVA_EXE=java.exe
      +%JAVA_EXE% -version >NUL 2>&1
      +if %ERRORLEVEL% equ 0 goto execute
      +
      +echo. 1>&2
      +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
      +echo. 1>&2
      +echo Please set the JAVA_HOME variable in your environment to match the 1>&2
      +echo location of your Java installation. 1>&2
      +
      +goto fail
      +
      +:findJavaFromJavaHome
      +set JAVA_HOME=%JAVA_HOME:"=%
      +set JAVA_EXE=%JAVA_HOME%/bin/java.exe
      +
      +if exist "%JAVA_EXE%" goto execute
      +
      +echo. 1>&2
      +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
      +echo. 1>&2
      +echo Please set the JAVA_HOME variable in your environment to match the 1>&2
      +echo location of your Java installation. 1>&2
      +
      +goto fail
      +
      +:execute
      +@rem Setup the command line
      +
      +
      +
      +@rem Execute Gradle
      +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
      +
      +:end
      +@rem End local scope for the variables with windows NT shell
      +if %ERRORLEVEL% equ 0 goto mainEnd
      +
      +:fail
      +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
      +rem the _cmd.exe /c_ return code!
      +set EXIT_CODE=%ERRORLEVEL%
      +if %EXIT_CODE% equ 0 set EXIT_CODE=1
      +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
      +exit /b %EXIT_CODE%
      +
      +:mainEnd
      +if "%OS%"=="Windows_NT" endlocal
      +
      +:omega
      diff --git a/participation-service/build.gradle b/participation-service/build.gradle
      new file mode 100644
      index 0000000..c5507a9
      --- /dev/null
      +++ b/participation-service/build.gradle
      @@ -0,0 +1,7 @@
      +dependencies {
      +    // Kafka for event publishing
      +    implementation 'org.springframework.kafka:spring-kafka'
      +
      +    // Jackson for JSON
      +    implementation 'com.fasterxml.jackson.core:jackson-databind'
      +}
      diff --git a/settings.gradle b/settings.gradle
      new file mode 100644
      index 0000000..e3c178a
      --- /dev/null
      +++ b/settings.gradle
      @@ -0,0 +1,13 @@
      +rootProject.name = 'kt-event-marketing'
      +
      +// Common module
      +include 'common'
      +
      +// Microservices
      +include 'user-service'
      +include 'event-service'
      +include 'ai-service'
      +include 'content-service'
      +include 'distribution-service'
      +include 'participation-service'
      +include 'analytics-service'
      diff --git a/user-service/build.gradle b/user-service/build.gradle
      new file mode 100644
      index 0000000..63a1c78
      --- /dev/null
      +++ b/user-service/build.gradle
      @@ -0,0 +1,10 @@
      +dependencies {
      +    // BCrypt for password hashing
      +    implementation 'org.springframework.security:spring-security-crypto'
      +
      +    // Redis for session management
      +    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
      +
      +    // OpenFeign for external API calls (사업자번호 검증)
      +    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
      +}