add common module

This commit is contained in:
cherry2250 2025-10-23 17:54:28 +09:00
parent 8029d8f9ce
commit ea82ff4748
57 changed files with 4706 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

View File

@ -0,0 +1,2 @@
#Thu Oct 23 17:51:21 KST 2025
gradle.version=8.10

Binary file not shown.

BIN
.gradle/file-system.probe Normal file

Binary file not shown.

View File

17
ai-service/build.gradle Normal file
View File

@ -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'
}

View File

@ -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'
}

124
build.gradle Normal file
View File

@ -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'
}

662
claude/dev-backend.md Normal file
View File

@ -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 표준]'대로 최상위 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 클래스 작성: '<SecurityConfig 예제>' 참조
- JWT 인증 처리 클래스 작성: '<JWT 인증처리 예제>' 참조
- Swagger Config 클래스 작성: '<SwaggerConfig 예제>' 참조
- 테스트 코드 작성은 하지 않음
<Build.gradle 구성 최적화>
- **중앙 버전 관리**: 루트 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 등의 연결 정보는 반드시 환경변수로 변환해야 함: '<DB/Redis 설정 예제>' 참조
- 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'
}
```
<DB/Redis 설정 예제>
```
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}
```
<SecurityConfig 예제>
```
/**
* 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;
}
}
```
<JWT 인증처리 예제>
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;
}
}
```
<SwaggerConfig 예제>
```
/**
* 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");
}
}
```

518
claude/standard_comment.md Normal file
View File

@ -0,0 +1,518 @@
# 개발주석표준 가이드
## 📋 개요
이 문서는 CMS 프로젝트의 JavaDoc 주석 작성 표준을 정의합니다. 일관된 주석 스타일을 통해 코드의 가독성과 유지보수성을 향상시키는 것을 목표로 합니다.
## 🎯 주석 작성 원칙
### 1. **기본 원칙**
- **명확성**: 코드의 의도와 동작을 명확하게 설명
- **일관성**: 프로젝트 전체에서 동일한 스타일 적용
- **완전성**: 모든 public 메서드와 클래스에 주석 작성
- **최신성**: 코드 변경 시 주석도 함께 업데이트
### 2. **주석 대상**
- **필수**: public 클래스, 인터페이스, 메서드
- **권장**: protected 메서드, 중요한 필드
- **선택**: private 메서드 (복잡한 로직인 경우)
## 📝 JavaDoc 기본 문법
### 1. **기본 구조**
```java
/**
* 클래스나 메서드의 간단한 설명 (첫 번째 문장)
*
* <p>상세한 설명이 필요한 경우 여기에 작성합니다.</p>
*
* @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
/**
* <p>단락을 구분할 때 사용합니다.</p>
* <b>중요한 내용</b>을 강조할 때 사용합니다.
* <i>이탤릭체</i>로 표시할 때 사용합니다.
* <code>method()</code>와 같은 코드를 표시할 때 사용합니다.
*/
```
#### **목록 작성**
```java
/**
* <p><b>주요 기능:</b></p>
* <ul>
* <li>첫 번째 기능</li>
* <li>두 번째 기능</li>
* <li>세 번째 기능</li>
* </ul>
*
* <p><b>처리 과정:</b></p>
* <ol>
* <li>첫 번째 단계</li>
* <li>두 번째 단계</li>
* <li>세 번째 단계</li>
* </ol>
*/
```
#### **코드 블록**
```java
/**
* <p>사용 예시:</p>
* <pre>
* AuthController controller = new AuthController();
* LoginRequest request = new LoginRequest("user", "password");
* ResponseEntity&lt;LoginResponse&gt; response = controller.login(request);
* </pre>
*/
```
#### **테이블**
```java
/**
* <p><b>HTTP 상태 코드:</b></p>
* <table>
* <tr><th>상태 코드</th><th>설명</th></tr>
* <tr><td>200</td><td>성공</td></tr>
* <tr><td>400</td><td>잘못된 요청</td></tr>
* <tr><td>401</td><td>인증 실패</td></tr>
* </table>
*/
```
### 3. **HTML 태그 사용 규칙**
- **&lt;&gt;**: 제네릭 타입 표현 시 `&lt;T&gt;` 사용
- **줄바꿈**: `<br>` 태그 사용 (가급적 `<p>` 태그 권장)
- **링크**: `{@link ClassName#methodName}` 사용
- **인라인 코드**: `{@code variableName}` 또는 `<code>` 사용
## 📋 클래스 주석 표준
### 1. **클래스 주석 템플릿**
```java
/**
* 클래스의 간단한 설명
*
* <p>클래스의 상세한 설명과 목적을 여기에 작성합니다.</p>
*
* <p><b>주요 기능:</b></p>
* <ul>
* <li>기능 1</li>
* <li>기능 2</li>
* <li>기능 3</li>
* </ul>
*
* <p><b>사용 예시:</b></p>
* <pre>
* ClassName instance = new ClassName();
* instance.someMethod();
* </pre>
*
* <p><b>주의사항:</b></p>
* <ul>
* <li>주의사항 1</li>
* <li>주의사항 2</li>
* </ul>
*
* @author 작성자명
* @version 1.0
* @since 2024-01-01
*
* @see 관련클래스1
* @see 관련클래스2
*/
public class ClassName {
// ...
}
```
### 2. **Controller 클래스 주석 예시**
```java
/**
* 사용자 관리 API 컨트롤러
*
* <p>사용자 등록, 조회, 수정, 삭제 기능을 제공하는 REST API 컨트롤러입니다.</p>
*
* <p><b>주요 기능:</b></p>
* <ul>
* <li>사용자 등록 및 인증</li>
* <li>사용자 정보 조회 및 수정</li>
* <li>사용자 권한 관리</li>
* </ul>
*
* <p><b>API 엔드포인트:</b></p>
* <ul>
* <li>POST /api/users - 사용자 등록</li>
* <li>GET /api/users/{id} - 사용자 조회</li>
* <li>PUT /api/users/{id} - 사용자 수정</li>
* <li>DELETE /api/users/{id} - 사용자 삭제</li>
* </ul>
*
* <p><b>보안 고려사항:</b></p>
* <ul>
* <li>모든 엔드포인트는 인증이 필요합니다</li>
* <li>개인정보 처리 시 데이터 마스킹 적용</li>
* <li>입력값 검증 및 XSS 방지</li>
* </ul>
*
* @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
/**
* 메서드의 간단한 설명
*
* <p>메서드의 상세한 설명과 동작을 여기에 작성합니다.</p>
*
* <p><b>처리 과정:</b></p>
* <ol>
* <li>첫 번째 단계</li>
* <li>두 번째 단계</li>
* <li>세 번째 단계</li>
* </ol>
*
* <p><b>주의사항:</b></p>
* <ul>
* <li>주의사항 1</li>
* <li>주의사항 2</li>
* </ul>
*
* @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
/**
* 사용자 로그인 처리
*
* <p>사용자 ID와 비밀번호를 검증하여 JWT 토큰을 생성합니다.</p>
*
* <p><b>처리 과정:</b></p>
* <ol>
* <li>입력값 검증 (@Valid 어노테이션)</li>
* <li>사용자 인증 정보 확인</li>
* <li>JWT 토큰 생성</li>
* <li>사용자 세션 시작</li>
* <li>로그인 메트릭 업데이트</li>
* </ol>
*
* <p><b>보안 고려사항:</b></p>
* <ul>
* <li>비밀번호는 BCrypt로 암호화된 값과 비교</li>
* <li>로그인 실패 시 상세 정보 노출 방지</li>
* <li>로그인 시도 로그 기록</li>
* </ul>
*
* @param request 로그인 요청 정보
* - username: 사용자 ID (3-50자, 필수)
* - password: 비밀번호 (6-100자, 필수)
*
* @return ResponseEntity&lt;LoginResponse&gt; 로그인 응답 정보
* - 성공 시: 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<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
// ...
}
```
## 📋 필드 주석 표준
### 1. **필드 주석 템플릿**
```java
/**
* 필드의 간단한 설명
*
* <p>필드의 상세한 설명과 용도를 여기에 작성합니다.</p>
*
* <p><b>주의사항:</b></p>
* <ul>
* <li>주의사항 1</li>
* <li>주의사항 2</li>
* </ul>
*
* @since 1.0
*/
private final ServiceType serviceName;
```
### 2. **의존성 주입 필드 예시**
```java
/**
* 인증 서비스
*
* <p>사용자 로그인/로그아웃 처리 및 JWT 토큰 관리를 담당합니다.</p>
*
* <p><b>주요 기능:</b></p>
* <ul>
* <li>사용자 인증 정보 검증</li>
* <li>JWT 토큰 생성 및 검증</li>
* <li>로그인/로그아웃 처리</li>
* </ul>
*
* @see AuthService
* @since 1.0
*/
private final AuthService authService;
```
## 📋 예외 클래스 주석 표준
```java
/**
* 사용자 인증 실패 예외
*
* <p>로그인 시 사용자 ID 또는 비밀번호가 올바르지 않을 때 발생하는 예외입니다.</p>
*
* <p><b>발생 상황:</b></p>
* <ul>
* <li>존재하지 않는 사용자 ID</li>
* <li>잘못된 비밀번호</li>
* <li>계정 잠금 상태</li>
* </ul>
*
* <p><b>처리 방법:</b></p>
* <ul>
* <li>사용자에게 일반적인 오류 메시지 표시</li>
* <li>보안 로그에 상세 정보 기록</li>
* <li>브루트 포스 공격 방지 로직 실행</li>
* </ul>
*
* @author cms-team
* @version 1.0
* @since 2024-01-01
*
* @see AuthService
* @see SecurityException
*/
public class InvalidCredentialsException extends RuntimeException {
// ...
}
```
## 📋 인터페이스 주석 표준
```java
/**
* 사용자 인증 서비스 인터페이스
*
* <p>사용자 로그인, 로그아웃, 토큰 관리 등 인증 관련 기능을 정의합니다.</p>
*
* <p><b>구현 클래스:</b></p>
* <ul>
* <li>{@link AuthServiceImpl} - 기본 구현체</li>
* <li>{@link LdapAuthService} - LDAP 연동 구현체</li>
* </ul>
*
* <p><b>주요 기능:</b></p>
* <ul>
* <li>사용자 인증 및 토큰 생성</li>
* <li>로그아웃 및 토큰 무효화</li>
* <li>토큰 유효성 검증</li>
* </ul>
*
* @author cms-team
* @version 1.0
* @since 2024-01-01
*
* @see AuthServiceImpl
* @see TokenProvider
*/
public interface AuthService {
// ...
}
```
## 📋 Enum 주석 표준
```java
/**
* 사용자 역할 열거형
*
* <p>시스템 사용자의 권한 수준을 정의합니다.</p>
*
* <p><b>권한 계층:</b></p>
* <ol>
* <li>{@link #ADMIN} - 최고 관리자 권한</li>
* <li>{@link #MANAGER} - 관리자 권한</li>
* <li>{@link #USER} - 일반 사용자 권한</li>
* </ol>
*
* @author cms-team
* @version 1.0
* @since 2024-01-01
*/
public enum Role {
/**
* 시스템 관리자
*
* <p>모든 시스템 기능에 대한 접근 권한을 가집니다.</p>
*
* <p><b>주요 권한:</b></p>
* <ul>
* <li>사용자 관리</li>
* <li>시스템 설정</li>
* <li>모든 데이터 접근</li>
* </ul>
*/
ADMIN,
/**
* 관리자
*
* <p>제한된 관리 기능에 대한 접근 권한을 가집니다.</p>
*/
MANAGER,
/**
* 일반 사용자
*
* <p>기본적인 시스템 기능에 대한 접근 권한을 가집니다.</p>
*/
USER
}
```
## 📋 주석 작성 체크리스트
### ✅ **클래스 주석 체크리스트**
- [ ] 클래스의 목적과 역할 명시
- [ ] 주요 기능 목록 작성
- [ ] 사용 예시 코드 포함
- [ ] 주의사항 및 제약사항 명시
- [ ] @author, @version, @since 태그 작성
- [ ] 관련 클래스 @see 태그 추가
### ✅ **메서드 주석 체크리스트**
- [ ] 메서드의 목적과 동작 설명
- [ ] 처리 과정 단계별 설명
- [ ] 모든 @param 태그 작성
- [ ] @return 태그 작성 (void 메서드 제외)
- [ ] 가능한 예외 @throws 태그 작성
- [ ] 보안 관련 주의사항 명시
- [ ] 관련 메서드 @see 태그 추가
### ✅ **HTML 태그 체크리스트**
- [ ] 목록은 `<ul>`, `<ol>`, `<li>` 태그 사용
- [ ] 강조는 `<b>` 태그 사용
- [ ] 단락 구분은 `<p>` 태그 사용
- [ ] 코드는 `<code>` 또는 `<pre>` 태그 사용
- [ ] 제네릭 타입은 `&lt;`, `&gt;` 사용
## 📋 도구 및 설정
### 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)
---
> **💡 팁**: 이 가이드를 팀 내에서 공유하고, 코드 리뷰 시 주석 품질도 함께 검토하세요!

View File

@ -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

214
claude/standard_testcode.md Normal file
View File

@ -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()
```

35
common/build.gradle Normal file
View File

@ -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'
}

View File

@ -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 <T> 응답 데이터 타입
*/
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
/**
* 성공 여부
*/
private final boolean success;
/**
* 응답 데이터
*/
private final T data;
/**
* 에러 코드 (실패 )
*/
private final String errorCode;
/**
* 에러 메시지 (실패 )
*/
private final String message;
/**
* 응답 시간
*/
private final LocalDateTime timestamp;
/**
* 성공 응답 생성 (데이터 포함)
*
* @param data 응답 데이터
* @param <T> 응답 데이터 타입
* @return API 응답
*/
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, data, null, null, LocalDateTime.now());
}
/**
* 성공 응답 생성 (데이터 없음)
*
* @param <T> 응답 데이터 타입
* @return API 응답
*/
public static <T> ApiResponse<T> success() {
return new ApiResponse<>(true, null, null, null, LocalDateTime.now());
}
/**
* 실패 응답 생성
*
* @param errorCode 에러 코드
* @param message 에러 메시지
* @param <T> 응답 데이터 타입
* @return API 응답
*/
public static <T> ApiResponse<T> error(String errorCode, String message) {
return new ApiResponse<>(false, null, errorCode, message, LocalDateTime.now());
}
}

View File

@ -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<FieldError> 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<FieldError> fieldErrors) {
return ErrorResponse.builder()
.errorCode(errorCode)
.message(message)
.fieldErrors(fieldErrors)
.build();
}
}

View File

@ -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 <T> 목록 아이템 타입
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PageResponse<T> {
/**
* 목록 데이터
*/
private List<T> 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 <T> 목록 아이템 타입
* @return PageResponse
*/
public static <T> PageResponse<T> of(org.springframework.data.domain.Page<T> page) {
return PageResponse.<T>builder()
.content(page.getContent())
.page(page.getNumber())
.size(page.getSize())
.totalElements(page.getTotalElements())
.totalPages(page.getTotalPages())
.first(page.isFirst())
.last(page.isLast())
.build();
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
log.warn("Validation exception occurred: {}", ex.getMessage());
List<ErrorResponse.FieldError> 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<ErrorResponse> handleBindException(BindException ex) {
log.warn("Bind exception occurred: {}", ex.getMessage());
List<ErrorResponse.FieldError> 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<ErrorResponse> 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();
}
}

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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<String> 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<String> 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();
}
}

View File

@ -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<String> roles;
/**
* Spring Security 권한 목록 반환
*
* @return 권한 목록
*/
@Override
public Collection<? extends GrantedAuthority> 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);
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}
}

View File

@ -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'
}

View File

@ -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 (리액트킹)

View File

@ -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'
}

View File

@ -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'
}

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -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

248
gradlew vendored Executable file
View File

@ -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" "$@"

93
gradlew.bat vendored Normal file
View File

@ -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

View File

@ -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'
}

13
settings.gradle Normal file
View File

@ -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'

10
user-service/build.gradle Normal file
View File

@ -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'
}