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

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

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

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

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

주요 기능:

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

처리 과정:

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

사용 예시:

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

HTTP 상태 코드:

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

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

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

+ * + *

주요 기능:

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

사용 예시:

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

주의사항:

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

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

+ * + *

주요 기능:

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

API 엔드포인트:

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

보안 고려사항:

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

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

+ * + *

처리 과정:

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

주의사항:

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

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

+ * + *

처리 과정:

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

보안 고려사항:

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

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

+ * + *

주의사항:

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

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

+ * + *

주요 기능:

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

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

+ * + *

발생 상황:

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

처리 방법:

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

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

+ * + *

구현 클래스:

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

주요 기능:

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

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

+ * + *

권한 계층:

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

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

+ * + *

주요 권한:

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

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

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

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

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

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

      ` 태그 사용
      +- [ ] 제네릭 타입은 `<`, `>` 사용
      +
      +## 📋 도구 및 설정
      +
      +### 1. **JavaDoc 생성**
      +```bash
      +# Gradle 프로젝트
      +./gradlew javadoc
      +
      +# Maven 프로젝트
      +mvn javadoc:javadoc
      +
      +# 직접 실행
      +javadoc -d docs -cp classpath src/**/*.java
      +```
      +
      +### 2. **IDE 설정**
      +- **IntelliJ IDEA**: Settings > Editor > Code Style > Java > JavaDoc
      +- **Eclipse**: Window > Preferences > Java > Code Style > Code Templates
      +- **VS Code**: Java Extension Pack + JavaDoc 플러그인
      +
      +### 3. **정적 분석 도구**
      +- **Checkstyle**: JavaDoc 누락 검사
      +- **SpotBugs**: 주석 품질 검사
      +- **SonarQube**: 문서화 품질 메트릭
      +
      +## 📋 참고 자료
      +
      +- [Oracle JavaDoc 가이드](https://docs.oracle.com/javase/8/docs/technotes/tools/windows/javadoc.html)
      +- [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html)
      +- [Spring Framework 주석 스타일](https://github.com/spring-projects/spring-framework/wiki/Code-Style)
      +
      +---
      +
      +> **💡 팁**: 이 가이드를 팀 내에서 공유하고, 코드 리뷰 시 주석 품질도 함께 검토하세요!
      \ No newline at end of file
      diff --git a/claude/standard_package_structure.md b/claude/standard_package_structure.md
      new file mode 100644
      index 0000000..81a4890
      --- /dev/null
      +++ b/claude/standard_package_structure.md
      @@ -0,0 +1,173 @@
      +패키지 구조 표준
      +
      +레이어드 아키텍처 패키지 구조
      +
      +├── {SERVICE}
      +│   ├── domain
      +│   ├── service
      +│   ├── controller
      +│   ├── dto
      +│   ├── repository
      +│   │   ├── jpa
      +│   │   └── entity
      +│   ├── config
      +└── common
      +        ├── dto
      +        ├── util
      +        ├── response
      +        └── exception
      +
      +Package명: 
      +- com.{ORG}.{ROOT}.{SERVICE}
      +예) com.unicorn.lifesub.mysub, com.unicorn.lifesub.common
      +
      +변수: 
      +- ORG: 회사 또는 조직명
      +- ROOT: Root Project 명
      +- SERVICE: 서비스명으로 Root Project의 서브 프로젝트임
      +
      +
      +예시
      +
      +com.unicorn.lifesub.member
      + ├── MemberApplication.java
      + ├── controller
      + │   └── MemberController.java
      + ├── dto
      + │   ├── LoginRequest.java
      + │   ├── LogoutRequest.java
      + │   └── LogoutResponse.java  
      + ├── service
      + │   ├── MemberService.java
      + │   └── MemberServiceImpl.java
      + ├── domain
      + │   └── Member.java
      + ├── repository  
      + │   ├── entity
      + │   │   └── MemberEntity.java
      + │   └── jpa
      + │       └── MemberRepository.java
      + └── config
      +     ├── SecurityConfig.java
      +     ├── DataLoader.java
      +     ├── SwaggerConfig.java
      +     └── jwt
      +         ├── JwtAuthenticationFilter.java
      +         ├── JwtTokenProvider.java
      +         └── CustomUserDetailsService.java
      +
      +
      +클린 아키텍처 패키지 구조 
      +
      +├── biz
      +│   ├── usecase
      +│   │   ├── in
      +│   │   ├── out
      +│   ├── service
      +│   └── domain
      +│   └── dto
      +├── infra
      +│   ├── controller
      +│   ├── dto
      +│   ├── gateway
      +│   │   ├── repository
      +│   │   └── entity
      +│   └── config    
      +
      +
      +Package명: 
      +- com.{ORG}.{ROOT}.{SERVICE}.biz
      +- com.{ORG}.{ROOT}.{SERVICE}.infra
      +예) com.unicorn.lifesub.mysub.biz, com.unicorn.lifesub.common
      +
      +변수: 
      +- ORG: 회사 또는 조직명
      +- ROOT: Root Project 명
      +- SERVICE: 서비스명으로 Root Project의 서브 프로젝트임
      +
      +예시
      +
      +
      +com.unicorn.lifesub.mysub
      + ├── biz
      + │   ├── dto
      + │   │   ├── CategoryResponse.java
      + │   │   ├── ServiceListResponse.java
      + │   │   ├── MySubResponse.java
      + │   │   ├── SubDetailResponse.java
      + │   │   └── TotalFeeResponse.java
      + │   ├── service
      + │   │   ├── FeeLevel.java
      + │   │   └── MySubscriptionService.java
      + │   ├── usecase
      + │   │   ├── in
      + │   │   │   ├── CancelSubscriptionUseCase.java
      + │   │   │   ├── CategoryUseCase.java
      + │   │   │   ├── MySubscriptionsUseCase.java
      + │   │   │   ├── SubscribeUseCase.java
      + │   │   │   ├── SubscriptionDetailUseCase.java
      + │   │   │   └── TotalFeeUseCase.java
      + │   │   └── out 
      + │   │       ├── MySubscriptionReader.java
      + │   │       ├── MySubscriptionWriter.java
      + │   │       └── SubscriptionReader.java
      + │   └── domain
      + │       ├── Category.java
      + │       ├── MySubscription.java
      + │       └── Subscription.java
      + └── infra  
      +     ├── MySubApplication.java 
      +     ├── controller
      +     │   ├── CategoryController.java
      +     │   ├── MySubController.java
      +     │   └── ServiceController.java
      +     ├── config
      +     │   ├── DataLoader.java
      +     │   ├── SecurityConfig.java
      +     │   ├── SwaggerConfig.java
      +     │   └── jwt
      +     │       ├── JwtAuthenticationFilter.java
      +     │       └── JwtTokenProvider.java
      +     └── gateway
      +         ├── entity
      +         │   ├── CategoryEntity.java   
      +         │   ├── MySubscriptionEntity.java
      +         │   └── SubscriptionEntity.java
      +         ├── repository
      +         │   ├── CategoryJpaRepository.java
      +         │   ├── MySubscriptionJpaRepository.java
      +         │   └── SubscriptionJpaRepository.java  
      +         ├── MySubscriptionGateway.java
      +         └── SubscriptionGateway.java
      +
      +
      +---
      +
      +common 모듈 패키지 구조
      +
      +├── common
      +    ├── dto
      +    ├── entity
      +    ├── config
      +    ├── util
      +    └── exception
      +
      +
      +com.unicorn.lifesub.common
      + ├── dto
      + │   ├── ApiResponse.java
      + │   ├── JwtTokenDTO.java
      + │   ├── JwtTokenRefreshDTO.java
      + │   └── JwtTokenVerifyDTO.java
      + ├── config
      + │   └── JpaConfig.java
      + ├── entity
      + │   └── BaseTimeEntity.java        
      + ├── aop  
      + │   └── LoggingAspect.java
      + └── exception
      +     ├── ErrorCode.java
      +     ├── InfraException.java
      +     └── BusinessException.java
      +
      +
      diff --git a/common/build.gradle b/common/build.gradle
      new file mode 100644
      index 0000000..22c4adf
      --- /dev/null
      +++ b/common/build.gradle
      @@ -0,0 +1,32 @@
      +plugins {
      +    id 'java-library'
      +    id 'org.springframework.boot'
      +}
      +
      +bootJar {
      +    enabled = false
      +}
      +
      +jar {
      +    enabled = true
      +}
      +
      +dependencies {
      +    // Spring Boot
      +    api 'org.springframework.boot:spring-boot-starter-web'
      +    api 'org.springframework.boot:spring-boot-starter-data-jpa'
      +    api 'org.springframework.boot:spring-boot-starter-security'
      +    api 'org.springframework.boot:spring-boot-starter-validation'
      +    api 'org.springframework.boot:spring-boot-starter-aop'
      +
      +    // JWT
      +    api "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
      +
      +    // Utilities
      +    api "org.apache.commons:commons-lang3:${commonsLang3Version}"
      +    api "commons-io:commons-io:${commonsIoVersion}"
      +
      +    // Jackson
      +    api 'com.fasterxml.jackson.core:jackson-databind'
      +    api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
      +}
      diff --git a/common/src/main/java/com/unicorn/hgzero/common/aop/LoggingAspect.java b/common/src/main/java/com/unicorn/hgzero/common/aop/LoggingAspect.java
      new file mode 100644
      index 0000000..32a6669
      --- /dev/null
      +++ b/common/src/main/java/com/unicorn/hgzero/common/aop/LoggingAspect.java
      @@ -0,0 +1,98 @@
      +package com.unicorn.hgzero.common.aop;
      +
      +import lombok.extern.slf4j.Slf4j;
      +import org.aspectj.lang.ProceedingJoinPoint;
      +import org.aspectj.lang.annotation.Around;
      +import org.aspectj.lang.annotation.Aspect;
      +import org.aspectj.lang.annotation.Pointcut;
      +import org.springframework.stereotype.Component;
      +
      +import java.util.Arrays;
      +
      +/**
      + * 로깅 AOP
      + * Controller, Service 메서드 실행 시 로그를 자동으로 기록
      + */
      +@Slf4j
      +@Aspect
      +@Component
      +public class LoggingAspect {
      +
      +    /**
      +     * Controller 메서드 포인트컷
      +     */
      +    @Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")
      +    public void controllerPointcut() {
      +        // Pointcut for all methods in @RestController classes
      +    }
      +
      +    /**
      +     * Service 메서드 포인트컷
      +     */
      +    @Pointcut("within(@org.springframework.stereotype.Service *)")
      +    public void servicePointcut() {
      +        // Pointcut for all methods in @Service classes
      +    }
      +
      +    /**
      +     * Controller 메서드 실행 로깅
      +     *
      +     * @param joinPoint 조인 포인트
      +     * @return 메서드 실행 결과
      +     * @throws Throwable 예외 발생 시
      +     */
      +    @Around("controllerPointcut()")
      +    public Object logController(ProceedingJoinPoint joinPoint) throws Throwable {
      +        String className = joinPoint.getSignature().getDeclaringTypeName();
      +        String methodName = joinPoint.getSignature().getName();
      +        Object[] args = joinPoint.getArgs();
      +
      +        log.info("[Controller] {}.{} 호출 - 파라미터: {}",
      +                className, methodName, Arrays.toString(args));
      +
      +        long startTime = System.currentTimeMillis();
      +        Object result = null;
      +        try {
      +            result = joinPoint.proceed();
      +            long executionTime = System.currentTimeMillis() - startTime;
      +            log.info("[Controller] {}.{} 완료 - 실행시간: {}ms",
      +                    className, methodName, executionTime);
      +            return result;
      +        } catch (Exception e) {
      +            long executionTime = System.currentTimeMillis() - startTime;
      +            log.error("[Controller] {}.{} 실패 - 실행시간: {}ms, 에러: {}",
      +                    className, methodName, executionTime, e.getMessage());
      +            throw e;
      +        }
      +    }
      +
      +    /**
      +     * Service 메서드 실행 로깅
      +     *
      +     * @param joinPoint 조인 포인트
      +     * @return 메서드 실행 결과
      +     * @throws Throwable 예외 발생 시
      +     */
      +    @Around("servicePointcut()")
      +    public Object logService(ProceedingJoinPoint joinPoint) throws Throwable {
      +        String className = joinPoint.getSignature().getDeclaringTypeName();
      +        String methodName = joinPoint.getSignature().getName();
      +
      +        log.debug("[Service] {}.{} 시작", className, methodName);
      +
      +        long startTime = System.currentTimeMillis();
      +        Object result = null;
      +        try {
      +            result = joinPoint.proceed();
      +            long executionTime = System.currentTimeMillis() - startTime;
      +            log.debug("[Service] {}.{} 완료 - 실행시간: {}ms",
      +                    className, methodName, executionTime);
      +            return result;
      +        } catch (Exception e) {
      +            long executionTime = System.currentTimeMillis() - startTime;
      +            log.error("[Service] {}.{} 실패 - 실행시간: {}ms, 에러: {}",
      +                    className, methodName, executionTime, e.getMessage());
      +            throw e;
      +        }
      +    }
      +}
      diff --git a/common/src/main/java/com/unicorn/hgzero/common/config/JpaConfig.java b/common/src/main/java/com/unicorn/hgzero/common/config/JpaConfig.java
      new file mode 100644
      index 0000000..8b86c06
      --- /dev/null
      +++ b/common/src/main/java/com/unicorn/hgzero/common/config/JpaConfig.java
      @@ -0,0 +1,15 @@
      +package com.unicorn.hgzero.common.config;
      +
      +import org.springframework.context.annotation.Configuration;
      +import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
      +
      +/**
      + * JPA 설정
      + * JPA Auditing 기능을 활성화하여 엔티티의 생성일시, 수정일시를 자동으로 관리
      + */
      +@Configuration
      +@EnableJpaAuditing
      +public class JpaConfig {
      +    // JPA Auditing 활성화
      +    // BaseTimeEntity의 @CreatedDate, @LastModifiedDate가 자동으로 동작
      +}
      diff --git a/common/src/main/java/com/unicorn/hgzero/common/dto/ApiResponse.java b/common/src/main/java/com/unicorn/hgzero/common/dto/ApiResponse.java
      new file mode 100644
      index 0000000..66edd80
      --- /dev/null
      +++ b/common/src/main/java/com/unicorn/hgzero/common/dto/ApiResponse.java
      @@ -0,0 +1,120 @@
      +package com.unicorn.hgzero.common.dto;
      +
      +import com.fasterxml.jackson.annotation.JsonInclude;
      +import lombok.AllArgsConstructor;
      +import lombok.Builder;
      +import lombok.Getter;
      +import lombok.NoArgsConstructor;
      +
      +import java.time.LocalDateTime;
      +
      +/**
      + * API 응답 공통 포맷
      + * 모든 API 응답에 사용되는 표준 응답 형식
      + *
      + * @param  응답 데이터 타입
      + */
      +@Getter
      +@Builder
      +@NoArgsConstructor
      +@AllArgsConstructor
      +@JsonInclude(JsonInclude.Include.NON_NULL)
      +public class ApiResponse {
      +
      +    /**
      +     * 응답 상태 (success, error)
      +     */
      +    private String status;
      +
      +    /**
      +     * 응답 메시지
      +     */
      +    private String message;
      +
      +    /**
      +     * 응답 데이터
      +     */
      +    private T data;
      +
      +    /**
      +     * 에러 코드 (에러 발생 시)
      +     */
      +    private String code;
      +
      +    /**
      +     * 에러 상세 정보 (에러 발생 시)
      +     */
      +    private Object details;
      +
      +    /**
      +     * 응답 타임스탬프
      +     */
      +    @Builder.Default
      +    private LocalDateTime timestamp = LocalDateTime.now();
      +
      +    /**
      +     * 성공 응답 생성
      +     *
      +     * @param  응답 데이터 타입
      +     * @param data 응답 데이터
      +     * @return 성공 응답
      +     */
      +    public static  ApiResponse success(T data) {
      +        return ApiResponse.builder()
      +                .status("success")
      +                .data(data)
      +                .timestamp(LocalDateTime.now())
      +                .build();
      +    }
      +
      +    /**
      +     * 성공 응답 생성 (메시지 포함)
      +     *
      +     * @param  응답 데이터 타입
      +     * @param message 성공 메시지
      +     * @param data 응답 데이터
      +     * @return 성공 응답
      +     */
      +    public static  ApiResponse success(String message, T data) {
      +        return ApiResponse.builder()
      +                .status("success")
      +                .message(message)
      +                .data(data)
      +                .timestamp(LocalDateTime.now())
      +                .build();
      +    }
      +
      +    /**
      +     * 에러 응답 생성
      +     *
      +     * @param code 에러 코드
      +     * @param message 에러 메시지
      +     * @return 에러 응답
      +     */
      +    public static ApiResponse error(String code, String message) {
      +        return ApiResponse.builder()
      +                .status("error")
      +                .code(code)
      +                .message(message)
      +                .timestamp(LocalDateTime.now())
      +                .build();
      +    }
      +
      +    /**
      +     * 에러 응답 생성 (상세 정보 포함)
      +     *
      +     * @param code 에러 코드
      +     * @param message 에러 메시지
      +     * @param details 에러 상세 정보
      +     * @return 에러 응답
      +     */
      +    public static ApiResponse error(String code, String message, Object details) {
      +        return ApiResponse.builder()
      +                .status("error")
      +                .code(code)
      +                .message(message)
      +                .details(details)
      +                .timestamp(LocalDateTime.now())
      +                .build();
      +    }
      +}
      diff --git a/common/src/main/java/com/unicorn/hgzero/common/dto/JwtTokenDTO.java b/common/src/main/java/com/unicorn/hgzero/common/dto/JwtTokenDTO.java
      new file mode 100644
      index 0000000..8a3cf92
      --- /dev/null
      +++ b/common/src/main/java/com/unicorn/hgzero/common/dto/JwtTokenDTO.java
      @@ -0,0 +1,45 @@
      +package com.unicorn.hgzero.common.dto;
      +
      +import lombok.AllArgsConstructor;
      +import lombok.Builder;
      +import lombok.Getter;
      +import lombok.NoArgsConstructor;
      +
      +/**
      + * JWT 토큰 DTO
      + * Access Token과 Refresh Token을 함께 반환하는 DTO
      + */
      +@Getter
      +@Builder
      +@NoArgsConstructor
      +@AllArgsConstructor
      +public class JwtTokenDTO {
      +
      +    /**
      +     * Access Token
      +     * 단기 인증 토큰 (기본 1시간)
      +     */
      +    private String accessToken;
      +
      +    /**
      +     * Refresh Token
      +     * 장기 인증 토큰 (기본 7일)
      +     */
      +    private String refreshToken;
      +
      +    /**
      +     * Access Token 유효 기간 (초)
      +     */
      +    private Long accessTokenValidity;
      +
      +    /**
      +     * Refresh Token 유효 기간 (초)
      +     */
      +    private Long refreshTokenValidity;
      +
      +    /**
      +     * 토큰 타입 (Bearer)
      +     */
      +    @Builder.Default
      +    private String tokenType = "Bearer";
      +}
      diff --git a/common/src/main/java/com/unicorn/hgzero/common/dto/JwtTokenRefreshDTO.java b/common/src/main/java/com/unicorn/hgzero/common/dto/JwtTokenRefreshDTO.java
      new file mode 100644
      index 0000000..e82ae79
      --- /dev/null
      +++ b/common/src/main/java/com/unicorn/hgzero/common/dto/JwtTokenRefreshDTO.java
      @@ -0,0 +1,38 @@
      +package com.unicorn.hgzero.common.dto;
      +
      +import lombok.AllArgsConstructor;
      +import lombok.Builder;
      +import lombok.Getter;
      +import lombok.NoArgsConstructor;
      +
      +/**
      + * JWT Token 갱신 요청/응답 DTO
      + * Refresh Token을 사용하여 새로운 Access Token을 발급받을 때 사용
      + */
      +@Getter
      +@Builder
      +@NoArgsConstructor
      +@AllArgsConstructor
      +public class JwtTokenRefreshDTO {
      +
      +    /**
      +     * Refresh Token
      +     */
      +    private String refreshToken;
      +
      +    /**
      +     * 새로 발급된 Access Token
      +     */
      +    private String accessToken;
      +
      +    /**
      +     * Access Token 유효 기간 (초)
      +     */
      +    private Long accessTokenValidity;
      +
      +    /**
      +     * 토큰 타입 (Bearer)
      +     */
      +    @Builder.Default
      +    private String tokenType = "Bearer";
      +}
      diff --git a/common/src/main/java/com/unicorn/hgzero/common/dto/JwtTokenVerifyDTO.java b/common/src/main/java/com/unicorn/hgzero/common/dto/JwtTokenVerifyDTO.java
      new file mode 100644
      index 0000000..af186d9
      --- /dev/null
      +++ b/common/src/main/java/com/unicorn/hgzero/common/dto/JwtTokenVerifyDTO.java
      @@ -0,0 +1,47 @@
      +package com.unicorn.hgzero.common.dto;
      +
      +import lombok.AllArgsConstructor;
      +import lombok.Builder;
      +import lombok.Getter;
      +import lombok.NoArgsConstructor;
      +
      +/**
      + * JWT Token 검증 결과 DTO
      + * 토큰 유효성 검증 결과를 반환하는 DTO
      + */
      +@Getter
      +@Builder
      +@NoArgsConstructor
      +@AllArgsConstructor
      +public class JwtTokenVerifyDTO {
      +
      +    /**
      +     * 토큰 유효 여부
      +     */
      +    private Boolean valid;
      +
      +    /**
      +     * 사용자 ID
      +     */
      +    private String userId;
      +
      +    /**
      +     * 사용자 이름
      +     */
      +    private String username;
      +
      +    /**
      +     * 권한
      +     */
      +    private String authority;
      +
      +    /**
      +     * 토큰 만료 여부
      +     */
      +    private Boolean expired;
      +
      +    /**
      +     * 에러 메시지 (유효하지 않은 경우)
      +     */
      +    private String errorMessage;
      +}
      diff --git a/common/src/main/java/com/unicorn/hgzero/common/entity/BaseTimeEntity.java b/common/src/main/java/com/unicorn/hgzero/common/entity/BaseTimeEntity.java
      new file mode 100644
      index 0000000..41d77ca
      --- /dev/null
      +++ b/common/src/main/java/com/unicorn/hgzero/common/entity/BaseTimeEntity.java
      @@ -0,0 +1,37 @@
      +package com.unicorn.hgzero.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;
      +
      +/**
      + * 생성일시/수정일시 자동 관리 Entity
      + * 모든 Entity의 기본 클래스로 사용되며, 생성일시와 수정일시를 자동으로 관리
      + */
      +@Getter
      +@MappedSuperclass
      +@EntityListeners(AuditingEntityListener.class)
      +public abstract class BaseTimeEntity {
      +
      +    /**
      +     * 생성일시
      +     * Entity가 처음 생성될 때 자동으로 설정
      +     */
      +    @CreatedDate
      +    @Column(name = "created_at", nullable = false, updatable = false)
      +    private LocalDateTime createdAt;
      +
      +    /**
      +     * 수정일시
      +     * Entity가 수정될 때마다 자동으로 갱신
      +     */
      +    @LastModifiedDate
      +    @Column(name = "updated_at", nullable = false)
      +    private LocalDateTime updatedAt;
      +}
      diff --git a/common/src/main/java/com/unicorn/hgzero/common/exception/BusinessException.java b/common/src/main/java/com/unicorn/hgzero/common/exception/BusinessException.java
      new file mode 100644
      index 0000000..53cef66
      --- /dev/null
      +++ b/common/src/main/java/com/unicorn/hgzero/common/exception/BusinessException.java
      @@ -0,0 +1,69 @@
      +package com.unicorn.hgzero.common.exception;
      +
      +import lombok.Getter;
      +
      +/**
      + * 비즈니스 로직 예외
      + * 비즈니스 규칙 위반 또는 비즈니스 로직 처리 중 발생하는 예외
      + */
      +@Getter
      +public class BusinessException extends RuntimeException {
      +
      +    /**
      +     * 에러 코드
      +     */
      +    private final ErrorCode errorCode;
      +
      +    /**
      +     * 에러 상세 정보
      +     */
      +    private final Object 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, Object 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 = null;
      +    }
      +}
      diff --git a/common/src/main/java/com/unicorn/hgzero/common/exception/ErrorCode.java b/common/src/main/java/com/unicorn/hgzero/common/exception/ErrorCode.java
      new file mode 100644
      index 0000000..621e4bc
      --- /dev/null
      +++ b/common/src/main/java/com/unicorn/hgzero/common/exception/ErrorCode.java
      @@ -0,0 +1,60 @@
      +package com.unicorn.hgzero.common.exception;
      +
      +import lombok.Getter;
      +import lombok.RequiredArgsConstructor;
      +import org.springframework.http.HttpStatus;
      +
      +/**
      + * 에러 코드 정의
      + * 시스템 전체에서 사용되는 표준 에러 코드
      + */
      +@Getter
      +@RequiredArgsConstructor
      +public enum ErrorCode {
      +
      +    // 공통 에러 (1xxx)
      +    INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "잘못된 입력 값입니다."),
      +    METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "C002", "허용되지 않은 HTTP 메서드입니다."),
      +    ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "C003", "요청한 리소스를 찾을 수 없습니다."),
      +    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C004", "서버 내부 오류가 발생했습니다."),
      +    INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "C005", "잘못된 타입의 값입니다."),
      +    ACCESS_DENIED(HttpStatus.FORBIDDEN, "C006", "접근 권한이 없습니다."),
      +
      +    // 인증/인가 에러 (2xxx)
      +    AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "A001", "인증에 실패했습니다."),
      +    INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A002", "유효하지 않은 토큰입니다."),
      +    EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "A003", "만료된 토큰입니다."),
      +    REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "A004", "Refresh Token을 찾을 수 없습니다."),
      +    INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "A005", "유효하지 않은 Refresh Token입니다."),
      +    ACCOUNT_LOCKED(HttpStatus.UNAUTHORIZED, "A006", "계정이 잠금 상태입니다."),
      +    UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A007", "인증이 필요합니다."),
      +
      +    // 비즈니스 로직 에러 (3xxx)
      +    DUPLICATE_RESOURCE(HttpStatus.CONFLICT, "B001", "이미 존재하는 리소스입니다."),
      +    INVALID_STATUS_TRANSITION(HttpStatus.BAD_REQUEST, "B002", "유효하지 않은 상태 전환입니다."),
      +    RESOURCE_CONFLICT(HttpStatus.CONFLICT, "B003", "리소스 충돌이 발생했습니다."),
      +    OPERATION_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "B004", "허용되지 않은 작업입니다."),
      +    BUSINESS_RULE_VIOLATION(HttpStatus.BAD_REQUEST, "B005", "비즈니스 규칙 위반입니다."),
      +
      +    // 외부 시스템 에러 (4xxx)
      +    EXTERNAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E001", "외부 API 호출 중 오류가 발생했습니다."),
      +    DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E002", "데이터베이스 오류가 발생했습니다."),
      +    CACHE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E003", "캐시 오류가 발생했습니다."),
      +    MESSAGE_QUEUE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E004", "메시지 큐 오류가 발생했습니다."),
      +    STORAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E005", "스토리지 오류가 발생했습니다.");
      +
      +    /**
      +     * HTTP 상태 코드
      +     */
      +    private final HttpStatus httpStatus;
      +
      +    /**
      +     * 에러 코드
      +     */
      +    private final String code;
      +
      +    /**
      +     * 에러 메시지
      +     */
      +    private final String message;
      +}
      diff --git a/common/src/main/java/com/unicorn/hgzero/common/exception/InfraException.java b/common/src/main/java/com/unicorn/hgzero/common/exception/InfraException.java
      new file mode 100644
      index 0000000..1e39c1c
      --- /dev/null
      +++ b/common/src/main/java/com/unicorn/hgzero/common/exception/InfraException.java
      @@ -0,0 +1,69 @@
      +package com.unicorn.hgzero.common.exception;
      +
      +import lombok.Getter;
      +
      +/**
      + * 인프라스트럭처 예외
      + * 외부 시스템(데이터베이스, 캐시, 메시지 큐, 스토리지 등) 연동 중 발생하는 예외
      + */
      +@Getter
      +public class InfraException extends RuntimeException {
      +
      +    /**
      +     * 에러 코드
      +     */
      +    private final ErrorCode errorCode;
      +
      +    /**
      +     * 에러 상세 정보
      +     */
      +    private final Object 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, Object 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 = null;
      +    }
      +}
      diff --git a/common/src/main/java/com/unicorn/hgzero/common/util/DateUtil.java b/common/src/main/java/com/unicorn/hgzero/common/util/DateUtil.java
      new file mode 100644
      index 0000000..1a411e5
      --- /dev/null
      +++ b/common/src/main/java/com/unicorn/hgzero/common/util/DateUtil.java
      @@ -0,0 +1,134 @@
      +package com.unicorn.hgzero.common.util;
      +
      +import java.time.LocalDate;
      +import java.time.LocalDateTime;
      +import java.time.format.DateTimeFormatter;
      +import java.time.temporal.ChronoUnit;
      +
      +/**
      + * 날짜/시간 유틸리티 클래스
      + * 날짜와 시간 관련 공통 기능을 제공
      + */
      +public class DateUtil {
      +
      +    /**
      +     * 기본 날짜 포맷 (yyyy-MM-dd)
      +     */
      +    public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
      +
      +    /**
      +     * 기본 날짜시간 포맷 (yyyy-MM-dd HH:mm:ss)
      +     */
      +    public static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
      +
      +    /**
      +     * ISO 8601 날짜시간 포맷 (yyyy-MM-dd'T'HH:mm:ss)
      +     */
      +    public static final DateTimeFormatter ISO_DATETIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
      +
      +    private DateUtil() {
      +        // Utility class - prevent instantiation
      +    }
      +
      +    /**
      +     * LocalDate를 문자열로 변환
      +     *
      +     * @param date 변환할 날짜
      +     * @return 포맷된 날짜 문자열 (yyyy-MM-dd)
      +     */
      +    public static String format(LocalDate date) {
      +        return date != null ? date.format(DATE_FORMATTER) : null;
      +    }
      +
      +    /**
      +     * LocalDateTime을 문자열로 변환
      +     *
      +     * @param dateTime 변환할 날짜시간
      +     * @return 포맷된 날짜시간 문자열 (yyyy-MM-dd HH:mm:ss)
      +     */
      +    public static String format(LocalDateTime dateTime) {
      +        return dateTime != null ? dateTime.format(DATETIME_FORMATTER) : null;
      +    }
      +
      +    /**
      +     * LocalDateTime을 커스텀 포맷으로 변환
      +     *
      +     * @param dateTime 변환할 날짜시간
      +     * @param formatter 날짜시간 포맷터
      +     * @return 포맷된 날짜시간 문자열
      +     */
      +    public static String format(LocalDateTime dateTime, DateTimeFormatter formatter) {
      +        return dateTime != null ? dateTime.format(formatter) : null;
      +    }
      +
      +    /**
      +     * 문자열을 LocalDate로 변환
      +     *
      +     * @param dateString 날짜 문자열 (yyyy-MM-dd)
      +     * @return LocalDate 객체
      +     */
      +    public static LocalDate parseDate(String dateString) {
      +        return dateString != null ? LocalDate.parse(dateString, DATE_FORMATTER) : null;
      +    }
      +
      +    /**
      +     * 문자열을 LocalDateTime으로 변환
      +     *
      +     * @param dateTimeString 날짜시간 문자열 (yyyy-MM-dd HH:mm:ss)
      +     * @return LocalDateTime 객체
      +     */
      +    public static LocalDateTime parseDateTime(String dateTimeString) {
      +        return dateTimeString != null ? LocalDateTime.parse(dateTimeString, DATETIME_FORMATTER) : null;
      +    }
      +
      +    /**
      +     * 두 날짜 사이의 일수 계산
      +     *
      +     * @param startDate 시작 날짜
      +     * @param endDate 종료 날짜
      +     * @return 일수 차이
      +     */
      +    public static long daysBetween(LocalDate startDate, LocalDate endDate) {
      +        return ChronoUnit.DAYS.between(startDate, endDate);
      +    }
      +
      +    /**
      +     * 두 날짜시간 사이의 시간 계산 (시간 단위)
      +     *
      +     * @param startDateTime 시작 날짜시간
      +     * @param endDateTime 종료 날짜시간
      +     * @return 시간 차이
      +     */
      +    public static long hoursBetween(LocalDateTime startDateTime, LocalDateTime endDateTime) {
      +        return ChronoUnit.HOURS.between(startDateTime, endDateTime);
      +    }
      +
      +    /**
      +     * 두 날짜시간 사이의 시간 계산 (분 단위)
      +     *
      +     * @param startDateTime 시작 날짜시간
      +     * @param endDateTime 종료 날짜시간
      +     * @return 분 차이
      +     */
      +    public static long minutesBetween(LocalDateTime startDateTime, LocalDateTime endDateTime) {
      +        return ChronoUnit.MINUTES.between(startDateTime, endDateTime);
      +    }
      +
      +    /**
      +     * 현재 날짜 반환
      +     *
      +     * @return 현재 날짜
      +     */
      +    public static LocalDate now() {
      +        return LocalDate.now();
      +    }
      +
      +    /**
      +     * 현재 날짜시간 반환
      +     *
      +     * @return 현재 날짜시간
      +     */
      +    public static LocalDateTime nowDateTime() {
      +        return LocalDateTime.now();
      +    }
      +}
      diff --git a/common/src/main/java/com/unicorn/hgzero/common/util/StringUtil.java b/common/src/main/java/com/unicorn/hgzero/common/util/StringUtil.java
      new file mode 100644
      index 0000000..d28ba20
      --- /dev/null
      +++ b/common/src/main/java/com/unicorn/hgzero/common/util/StringUtil.java
      @@ -0,0 +1,197 @@
      +package com.unicorn.hgzero.common.util;
      +
      +import org.springframework.util.StringUtils;
      +
      +/**
      + * 문자열 유틸리티 클래스
      + * 문자열 관련 공통 기능을 제공
      + */
      +public class StringUtil {
      +
      +    private StringUtil() {
      +        // Utility class - prevent instantiation
      +    }
      +
      +    /**
      +     * 문자열이 비어있는지 확인
      +     *
      +     * @param str 확인할 문자열
      +     * @return 비어있으면 true, 아니면 false
      +     */
      +    public static boolean isEmpty(String str) {
      +        return !StringUtils.hasText(str);
      +    }
      +
      +    /**
      +     * 문자열이 비어있지 않은지 확인
      +     *
      +     * @param str 확인할 문자열
      +     * @return 비어있지 않으면 true, 아니면 false
      +     */
      +    public static boolean isNotEmpty(String str) {
      +        return StringUtils.hasText(str);
      +    }
      +
      +    /**
      +     * 문자열 앞뒤 공백 제거
      +     * null-safe 처리
      +     *
      +     * @param str 입력 문자열
      +     * @return 공백이 제거된 문자열 (null인 경우 null 반환)
      +     */
      +    public static String trim(String str) {
      +        return str != null ? str.trim() : null;
      +    }
      +
      +    /**
      +     * 문자열 앞뒤 공백 제거
      +     * null인 경우 빈 문자열 반환
      +     *
      +     * @param str 입력 문자열
      +     * @return 공백이 제거된 문자열 (null인 경우 빈 문자열 반환)
      +     */
      +    public static String trimToEmpty(String str) {
      +        return str != null ? str.trim() : "";
      +    }
      +
      +    /**
      +     * 문자열이 null 또는 빈 문자열인 경우 기본값 반환
      +     *
      +     * @param str 입력 문자열
      +     * @param defaultValue 기본값
      +     * @return 문자열이 비어있으면 기본값, 아니면 입력 문자열
      +     */
      +    public static String defaultIfEmpty(String str, String defaultValue) {
      +        return isEmpty(str) ? defaultValue : str;
      +    }
      +
      +    /**
      +     * 문자열 마스킹
      +     * 이메일, 전화번호 등 민감한 정보를 부분적으로 마스킹
      +     *
      +     * @param str 마스킹할 문자열
      +     * @param visibleStart 앞에서 보일 문자 수
      +     * @param visibleEnd 뒤에서 보일 문자 수
      +     * @param maskChar 마스킹 문자
      +     * @return 마스킹된 문자열
      +     */
      +    public static String mask(String str, int visibleStart, int visibleEnd, char maskChar) {
      +        if (isEmpty(str) || str.length() <= (visibleStart + visibleEnd)) {
      +            return str;
      +        }
      +
      +        int maskLength = str.length() - visibleStart - visibleEnd;
      +        String maskedPart = String.valueOf(maskChar).repeat(maskLength);
      +
      +        return str.substring(0, visibleStart) +
      +               maskedPart +
      +               str.substring(str.length() - visibleEnd);
      +    }
      +
      +    /**
      +     * 이메일 마스킹
      +     * 예: test@example.com -> te**@example.com
      +     *
      +     * @param email 마스킹할 이메일
      +     * @return 마스킹된 이메일
      +     */
      +    public static String maskEmail(String email) {
      +        if (isEmpty(email) || !email.contains("@")) {
      +            return email;
      +        }
      +
      +        String[] parts = email.split("@");
      +        String localPart = parts[0];
      +        String domain = parts[1];
      +
      +        if (localPart.length() <= 2) {
      +            return localPart + "@" + domain;
      +        }
      +
      +        String maskedLocal = localPart.substring(0, 2) +
      +                            "*".repeat(localPart.length() - 2);
      +
      +        return maskedLocal + "@" + domain;
      +    }
      +
      +    /**
      +     * 전화번호 마스킹
      +     * 예: 010-1234-5678 -> 010-****-5678
      +     *
      +     * @param phone 마스킹할 전화번호
      +     * @return 마스킹된 전화번호
      +     */
      +    public static String maskPhone(String phone) {
      +        if (isEmpty(phone)) {
      +            return phone;
      +        }
      +
      +        // 하이픈 제거
      +        String cleanPhone = phone.replaceAll("-", "");
      +
      +        if (cleanPhone.length() < 8) {
      +            return phone;
      +        }
      +
      +        // 뒤 4자리만 보이도록 마스킹
      +        String masked = "*".repeat(cleanPhone.length() - 4) +
      +                       cleanPhone.substring(cleanPhone.length() - 4);
      +
      +        // 원본 형식에 따라 하이픈 추가
      +        if (phone.contains("-")) {
      +            if (cleanPhone.length() == 10) {
      +                return masked.substring(0, 3) + "-" +
      +                      masked.substring(3, 6) + "-" +
      +                      masked.substring(6);
      +            } else if (cleanPhone.length() == 11) {
      +                return masked.substring(0, 3) + "-" +
      +                      masked.substring(3, 7) + "-" +
      +                      masked.substring(7);
      +            }
      +        }
      +
      +        return masked;
      +    }
      +
      +    /**
      +     * Camel Case를 Snake Case로 변환
      +     * 예: userName -> user_name
      +     *
      +     * @param camelCase Camel Case 문자열
      +     * @return Snake Case 문자열
      +     */
      +    public static String toSnakeCase(String camelCase) {
      +        if (isEmpty(camelCase)) {
      +            return camelCase;
      +        }
      +
      +        return camelCase.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
      +    }
      +
      +    /**
      +     * Snake Case를 Camel Case로 변환
      +     * 예: user_name -> userName
      +     *
      +     * @param snakeCase Snake Case 문자열
      +     * @return Camel Case 문자열
      +     */
      +    public static String toCamelCase(String snakeCase) {
      +        if (isEmpty(snakeCase)) {
      +            return snakeCase;
      +        }
      +
      +        StringBuilder result = new StringBuilder();
      +        boolean nextUpper = false;
      +
      +        for (char c : snakeCase.toCharArray()) {
      +            if (c == '_') {
      +                nextUpper = true;
      +            } else {
      +                result.append(nextUpper ? Character.toUpperCase(c) : c);
      +                nextUpper = false;
      +            }
      +        }
      +
      +        return result.toString();
      +    }
      +}
      diff --git a/develop/dev/package-structure.md b/develop/dev/package-structure.md
      new file mode 100644
      index 0000000..fa4a9c8
      --- /dev/null
      +++ b/develop/dev/package-structure.md
      @@ -0,0 +1,419 @@
      +# 패키지 구조도
      +
      +## 전체 프로젝트 구조
      +
      +```
      +HGZero/
      +├── common/                          # 공통 모듈
      +├── user/                           # User Service (Layered)
      +├── meeting/                        # Meeting Service (Clean)
      +├── stt/                           # STT Service (Layered)
      +├── ai/                            # AI Service (Clean)
      +├── notification/                  # Notification Service (Layered)
      +├── settings.gradle                # Gradle 설정
      +└── build.gradle                   # 루트 build.gradle
      +```
      +
      +## 1. Common 모듈
      +
      +```
      +common/
      +└── src/main/java/com/unicorn/hgzero/common/
      +    ├── dto/
      +    │   ├── ApiResponse.java
      +    │   ├── JwtTokenDTO.java
      +    │   ├── JwtTokenRefreshDTO.java
      +    │   └── JwtTokenVerifyDTO.java
      +    ├── entity/
      +    │   └── BaseTimeEntity.java
      +    ├── config/
      +    │   └── JpaConfig.java
      +    ├── util/
      +    │   ├── DateUtil.java
      +    │   └── StringUtil.java
      +    ├── aop/
      +    │   └── LoggingAspect.java
      +    └── exception/
      +        ├── ErrorCode.java
      +        ├── BusinessException.java
      +        └── InfraException.java
      +```
      +
      +## 2. User Service (Layered Architecture)
      +
      +```
      +user/
      +└── src/main/java/com/unicorn/hgzero/user/
      +    ├── UserApplication.java
      +    ├── controller/
      +    │   └── UserController.java
      +    ├── dto/
      +    │   ├── LoginRequest.java
      +    │   ├── LoginResponse.java
      +    │   ├── RefreshTokenRequest.java
      +    │   ├── RefreshTokenResponse.java
      +    │   ├── LogoutRequest.java
      +    │   └── TokenValidateResponse.java
      +    ├── service/
      +    │   ├── UserService.java
      +    │   └── UserServiceImpl.java
      +    ├── domain/
      +    │   └── User.java
      +    ├── repository/
      +    │   ├── entity/
      +    │   │   └── UserEntity.java
      +    │   └── jpa/
      +    │       └── UserRepository.java
      +    └── config/
      +        ├── SecurityConfig.java
      +        ├── SwaggerConfig.java
      +        ├── LdapConfig.java
      +        └── jwt/
      +            ├── JwtAuthenticationFilter.java
      +            ├── JwtTokenProvider.java
      +            └── UserPrincipal.java
      +```
      +
      +## 3. Meeting Service (Clean Architecture)
      +
      +```
      +meeting/
      +└── src/main/java/com/unicorn/hgzero/meeting/
      +    ├── biz/
      +    │   ├── domain/
      +    │   │   ├── Meeting.java
      +    │   │   ├── Minutes.java
      +    │   │   ├── MinutesSection.java
      +    │   │   ├── Todo.java
      +    │   │   ├── Template.java
      +    │   │   └── Dashboard.java
      +    │   ├── dto/
      +    │   │   ├── MeetingDto.java
      +    │   │   ├── MinutesDto.java
      +    │   │   ├── TodoDto.java
      +    │   │   ├── TemplateDto.java
      +    │   │   ├── DashboardDto.java
      +    │   │   └── WebSocketMessageDto.java
      +    │   ├── usecase/
      +    │   │   ├── in/
      +    │   │   │   ├── CreateMeetingUseCase.java
      +    │   │   │   ├── SelectTemplateUseCase.java
      +    │   │   │   ├── StartMeetingUseCase.java
      +    │   │   │   ├── EndMeetingUseCase.java
      +    │   │   │   ├── GetMinutesListUseCase.java
      +    │   │   │   ├── GetMinutesDetailUseCase.java
      +    │   │   │   ├── UpdateMinutesUseCase.java
      +    │   │   │   ├── FinalizeMinutesUseCase.java
      +    │   │   │   ├── VerifySectionUseCase.java
      +    │   │   │   ├── LockSectionUseCase.java
      +    │   │   │   ├── UnlockSectionUseCase.java
      +    │   │   │   ├── CreateTodoUseCase.java
      +    │   │   │   ├── CompleteTodoUseCase.java
      +    │   │   │   ├── GetTemplateListUseCase.java
      +    │   │   │   ├── GetTemplateDetailUseCase.java
      +    │   │   │   └── GetDashboardUseCase.java
      +    │   │   └── out/
      +    │   │       ├── MeetingReader.java
      +    │   │       ├── MeetingWriter.java
      +    │   │       ├── MinutesReader.java
      +    │   │       ├── MinutesWriter.java
      +    │   │       ├── TodoReader.java
      +    │   │       ├── TodoWriter.java
      +    │   │       ├── TemplateReader.java
      +    │   │       ├── CacheManager.java
      +    │   │       └── EventPublisher.java
      +    │   └── service/
      +    │       ├── MeetingService.java
      +    │       ├── MinutesService.java
      +    │       ├── TodoService.java
      +    │       ├── TemplateService.java
      +    │       ├── DashboardService.java
      +    │       └── WebSocketService.java
      +    └── infra/
      +        ├── MeetingApplication.java
      +        ├── controller/
      +        │   ├── MeetingController.java
      +        │   ├── MinutesController.java
      +        │   ├── TodoController.java
      +        │   ├── TemplateController.java
      +        │   ├── DashboardController.java
      +        │   └── WebSocketController.java
      +        ├── gateway/
      +        │   ├── entity/
      +        │   │   ├── MeetingEntity.java
      +        │   │   ├── MinutesEntity.java
      +        │   │   ├── MinutesSectionEntity.java
      +        │   │   ├── TodoEntity.java
      +        │   │   └── TemplateEntity.java
      +        │   ├── repository/
      +        │   │   ├── MeetingJpaRepository.java
      +        │   │   ├── MinutesJpaRepository.java
      +        │   │   ├── TodoJpaRepository.java
      +        │   │   └── TemplateJpaRepository.java
      +        │   ├── MeetingGateway.java
      +        │   ├── MinutesGateway.java
      +        │   ├── TodoGateway.java
      +        │   ├── TemplateGateway.java
      +        │   ├── CacheGateway.java
      +        │   └── EventPublisherGateway.java
      +        └── config/
      +            ├── SecurityConfig.java
      +            ├── SwaggerConfig.java
      +            ├── WebSocketConfig.java
      +            ├── RedisConfig.java
      +            └── jwt/
      +                ├── JwtAuthenticationFilter.java
      +                ├── JwtTokenProvider.java
      +                └── UserPrincipal.java
      +```
      +
      +## 4. STT Service (Layered Architecture)
      +
      +```
      +stt/
      +└── src/main/java/com/unicorn/hgzero/stt/
      +    ├── SttApplication.java
      +    ├── controller/
      +    │   ├── RecordingController.java
      +    │   ├── TranscriptionController.java
      +    │   └── SpeakerController.java
      +    ├── dto/
      +    │   ├── RecordingDto.java
      +    │   ├── TranscriptionDto.java
      +    │   ├── SpeakerDto.java
      +    │   └── TranscriptSegmentDto.java
      +    ├── service/
      +    │   ├── RecordingService.java
      +    │   ├── RecordingServiceImpl.java
      +    │   ├── TranscriptionService.java
      +    │   ├── TranscriptionServiceImpl.java
      +    │   ├── SpeakerService.java
      +    │   └── SpeakerServiceImpl.java
      +    ├── domain/
      +    │   ├── Recording.java
      +    │   ├── Transcription.java
      +    │   ├── TranscriptSegment.java
      +    │   └── Speaker.java
      +    ├── repository/
      +    │   ├── entity/
      +    │   │   ├── RecordingEntity.java
      +    │   │   ├── TranscriptionEntity.java
      +    │   │   └── SpeakerEntity.java
      +    │   └── jpa/
      +    │       ├── RecordingRepository.java
      +    │       ├── TranscriptionRepository.java
      +    │       └── SpeakerRepository.java
      +    └── config/
      +        ├── SecurityConfig.java
      +        ├── SwaggerConfig.java
      +        ├── AzureSpeechConfig.java
      +        ├── AzureBlobConfig.java
      +        ├── WebSocketConfig.java
      +        └── jwt/
      +            ├── JwtAuthenticationFilter.java
      +            ├── JwtTokenProvider.java
      +            └── UserPrincipal.java
      +```
      +
      +## 5. AI Service (Clean Architecture)
      +
      +```
      +ai/
      +└── src/main/java/com/unicorn/hgzero/ai/
      +    ├── biz/
      +    │   ├── domain/
      +    │   │   ├── ProcessedTranscript.java
      +    │   │   ├── ExtractedTodo.java
      +    │   │   ├── RelatedMinutes.java
      +    │   │   ├── Term.java
      +    │   │   └── Suggestion.java
      +    │   ├── dto/
      +    │   │   ├── TranscriptDto.java
      +    │   │   ├── TodoDto.java
      +    │   │   ├── RelatedMinutesDto.java
      +    │   │   ├── TermDto.java
      +    │   │   └── SuggestionDto.java
      +    │   ├── usecase/
      +    │   │   ├── in/
      +    │   │   │   ├── ProcessTranscriptUseCase.java
      +    │   │   │   ├── ImproveTranscriptUseCase.java
      +    │   │   │   ├── ExtractTodoUseCase.java
      +    │   │   │   ├── GetRelatedMinutesUseCase.java
      +    │   │   │   ├── DetectTermsUseCase.java
      +    │   │   │   ├── ExplainTermUseCase.java
      +    │   │   │   ├── SuggestDiscussionUseCase.java
      +    │   │   │   └── SuggestDecisionUseCase.java
      +    │   │   └── out/
      +    │   │       ├── TranscriptReader.java
      +    │   │       ├── TranscriptWriter.java
      +    │   │       ├── TodoWriter.java
      +    │   │       ├── RelatedMinutesReader.java
      +    │   │       ├── TermReader.java
      +    │   │       ├── LlmClient.java
      +    │   │       ├── VectorSearchClient.java
      +    │   │       └── CacheManager.java
      +    │   └── service/
      +    │       ├── TranscriptService.java
      +    │       ├── TodoService.java
      +    │       ├── RelatedMinutesService.java
      +    │       ├── TermService.java
      +    │       └── SuggestionService.java
      +    └── infra/
      +        ├── AiApplication.java
      +        ├── controller/
      +        │   ├── TranscriptController.java
      +        │   ├── TodoController.java
      +        │   ├── TermController.java
      +        │   └── SuggestionController.java
      +        ├── gateway/
      +        │   ├── entity/
      +        │   │   ├── TranscriptEntity.java
      +        │   │   └── TermEntity.java
      +        │   ├── repository/
      +        │   │   ├── TranscriptJpaRepository.java
      +        │   │   └── TermJpaRepository.java
      +        │   ├── TranscriptGateway.java
      +        │   ├── TodoGateway.java
      +        │   ├── RelatedMinutesGateway.java
      +        │   ├── TermGateway.java
      +        │   ├── LlmGateway.java
      +        │   ├── VectorSearchGateway.java
      +        │   └── CacheGateway.java
      +        └── config/
      +            ├── SecurityConfig.java
      +            ├── SwaggerConfig.java
      +            ├── OpenAiConfig.java
      +            ├── AzureAiSearchConfig.java
      +            ├── RedisConfig.java
      +            └── jwt/
      +                ├── JwtAuthenticationFilter.java
      +                ├── JwtTokenProvider.java
      +                └── UserPrincipal.java
      +```
      +
      +## 6. Notification Service (Layered Architecture)
      +
      +```
      +notification/
      +└── src/main/java/com/unicorn/hgzero/notification/
      +    ├── NotificationApplication.java
      +    ├── controller/
      +    │   ├── NotificationController.java
      +    │   └── NotificationSettingsController.java
      +    ├── dto/
      +    │   ├── NotificationDto.java
      +    │   ├── NotificationSettingsDto.java
      +    │   ├── EmailTemplateDto.java
      +    │   └── InvitationNotificationDto.java
      +    ├── service/
      +    │   ├── NotificationService.java
      +    │   ├── NotificationServiceImpl.java
      +    │   ├── NotificationSettingsService.java
      +    │   ├── NotificationSettingsServiceImpl.java
      +    │   ├── EmailService.java
      +    │   └── EmailServiceImpl.java
      +    ├── domain/
      +    │   ├── Notification.java
      +    │   ├── NotificationSettings.java
      +    │   └── EmailTemplate.java
      +    ├── repository/
      +    │   ├── entity/
      +    │   │   ├── NotificationEntity.java
      +    │   │   └── NotificationSettingsEntity.java
      +    │   └── jpa/
      +    │       ├── NotificationRepository.java
      +    │       └── NotificationSettingsRepository.java
      +    └── config/
      +        ├── SecurityConfig.java
      +        ├── SwaggerConfig.java
      +        ├── EmailConfig.java
      +        ├── EventHubConfig.java
      +        └── jwt/
      +            ├── JwtAuthenticationFilter.java
      +            ├── JwtTokenProvider.java
      +            └── UserPrincipal.java
      +```
      +
      +## 패키지 네이밍 규칙
      +
      +### 공통
      +- **Base Package**: `com.unicorn.hgzero`
      +
      +### Layered Architecture (User, STT, Notification)
      +- **Package**: `com.unicorn.hgzero.{service-name}`
      +- **구조**:
      +  - controller: REST API 컨트롤러
      +  - service: 비즈니스 로직
      +  - domain: 도메인 모델
      +  - dto: 데이터 전송 객체
      +  - repository: 데이터 액세스
      +    - entity: JPA 엔티티
      +    - jpa: JPA Repository
      +  - config: 설정 클래스
      +
      +### Clean Architecture (Meeting, AI)
      +- **Base Package**: `com.unicorn.hgzero.{service-name}`
      +- **구조**:
      +  - biz: 비즈니스 레이어
      +    - domain: 도메인 모델
      +    - dto: 비즈니스 DTO
      +    - usecase: 유스케이스
      +      - in: 입력 포트 (인터페이스)
      +      - out: 출력 포트 (인터페이스)
      +    - service: 유스케이스 구현체
      +  - infra: 인프라스트럭처 레이어
      +    - controller: REST API 컨트롤러
      +    - gateway: 출력 포트 구현체
      +      - entity: JPA 엔티티
      +      - repository: JPA Repository
      +    - config: 설정 클래스
      +
      +## 설정 파일 구조
      +
      +```
      +각 서비스/
      +├── src/main/resources/
      +│   ├── application.yml           # 메인 설정
      +│   └── application-dev.yml       # 개발 환경 설정
      +└── build.gradle                  # Gradle 빌드 설정
      +```
      +
      +## 공통 라이브러리 의존성 (루트 build.gradle에 정의)
      +
      +- Spring Boot Starter Web
      +- Spring Boot Starter Data JPA
      +- Spring Boot Starter Security
      +- Spring Boot Starter Actuator
      +- Spring Boot Configuration Processor
      +- Lombok
      +- MapStruct
      +- Springdoc OpenAPI (Swagger)
      +- JWT (jjwt-api, jjwt-impl, jjwt-jackson)
      +- PostgreSQL Driver
      +- Redis (Lettuce)
      +- Apache Commons Lang3
      +- Apache Commons IO
      +
      +## 서비스별 추가 의존성
      +
      +### User Service
      +- Spring LDAP
      +
      +### Meeting Service
      +- Spring WebSocket
      +- Spring Messaging
      +
      +### STT Service
      +- Azure Speech SDK
      +- Azure Blob Storage SDK
      +- Spring WebSocket
      +
      +### AI Service
      +- OpenAI Java Client
      +- Azure AI Search SDK
      +- Spring AI
      +
      +### Notification Service
      +- Spring Mail
      +- Azure Event Hubs SDK
      +- Thymeleaf (Email Template)
      diff --git a/meeting/build.gradle b/meeting/build.gradle
      new file mode 100644
      index 0000000..57b7ca4
      --- /dev/null
      +++ b/meeting/build.gradle
      @@ -0,0 +1,9 @@
      +bootJar {
      +    archiveFileName = 'meeting.jar'
      +}
      +
      +dependencies {
      +    // WebSocket
      +    implementation 'org.springframework.boot:spring-boot-starter-websocket'
      +    implementation 'org.springframework.boot:spring-boot-starter-reactor-netty'
      +}
      diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Dashboard.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Dashboard.java
      new file mode 100644
      index 0000000..9e6971f
      --- /dev/null
      +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Dashboard.java
      @@ -0,0 +1,77 @@
      +package com.unicorn.hgzero.meeting.biz.domain;
      +
      +import lombok.AllArgsConstructor;
      +import lombok.Builder;
      +import lombok.Getter;
      +import lombok.NoArgsConstructor;
      +
      +import java.util.List;
      +
      +/**
      + * 대시보드 도메인 모델
      + */
      +@Getter
      +@Builder
      +@NoArgsConstructor
      +@AllArgsConstructor
      +public class Dashboard {
      +
      +    /**
      +     * 다가오는 회의 목록
      +     */
      +    private List upcomingMeetings;
      +
      +    /**
      +     * 최근 회의록 목록
      +     */
      +    private List recentMinutes;
      +
      +    /**
      +     * 할당된 Todo 목록
      +     */
      +    private List assignedTodos;
      +
      +    /**
      +     * 통계 정보
      +     */
      +    private Statistics statistics;
      +
      +    /**
      +     * 통계 정보 내부 클래스
      +     */
      +    @Getter
      +    @Builder
      +    @NoArgsConstructor
      +    @AllArgsConstructor
      +    public static class Statistics {
      +        /**
      +         * 전체 회의 수
      +         */
      +        private Integer totalMeetings;
      +
      +        /**
      +         * 진행 중인 회의 수
      +         */
      +        private Integer inProgressMeetings;
      +
      +        /**
      +         * 완료된 회의 수
      +         */
      +        private Integer completedMeetings;
      +
      +        /**
      +         * 전체 Todo 수
      +         */
      +        private Integer totalTodos;
      +
      +        /**
      +         * 완료된 Todo 수
      +         */
      +        private Integer completedTodos;
      +
      +        /**
      +         * 지연된 Todo 수
      +         */
      +        private Integer overdueTodos;
      +    }
      +}
      diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Meeting.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Meeting.java
      new file mode 100644
      index 0000000..e2bb9b0
      --- /dev/null
      +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Meeting.java
      @@ -0,0 +1,99 @@
      +package com.unicorn.hgzero.meeting.biz.domain;
      +
      +import lombok.AllArgsConstructor;
      +import lombok.Builder;
      +import lombok.Getter;
      +import lombok.NoArgsConstructor;
      +
      +import java.time.LocalDateTime;
      +import java.util.List;
      +
      +/**
      + * 회의 도메인 모델
      + */
      +@Getter
      +@Builder
      +@NoArgsConstructor
      +@AllArgsConstructor
      +public class Meeting {
      +
      +    /**
      +     * 회의 ID
      +     */
      +    private String meetingId;
      +
      +    /**
      +     * 회의 제목
      +     */
      +    private String title;
      +
      +    /**
      +     * 회의 설명
      +     */
      +    private String description;
      +
      +    /**
      +     * 회의 일시
      +     */
      +    private LocalDateTime scheduledAt;
      +
      +    /**
      +     * 회의 시작 일시
      +     */
      +    private LocalDateTime startedAt;
      +
      +    /**
      +     * 회의 종료 일시
      +     */
      +    private LocalDateTime endedAt;
      +
      +    /**
      +     * 회의 상태 (SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED)
      +     */
      +    private String status;
      +
      +    /**
      +     * 주최자 ID
      +     */
      +    private String organizerId;
      +
      +    /**
      +     * 참석자 ID 목록
      +     */
      +    private List participants;
      +
      +    /**
      +     * 템플릿 ID
      +     */
      +    private String templateId;
      +
      +    /**
      +     * 회의 시작
      +     */
      +    public void start() {
      +        this.status = "IN_PROGRESS";
      +        this.startedAt = LocalDateTime.now();
      +    }
      +
      +    /**
      +     * 회의 종료
      +     */
      +    public void end() {
      +        this.status = "COMPLETED";
      +        this.endedAt = LocalDateTime.now();
      +    }
      +
      +    /**
      +     * 회의 취소
      +     */
      +    public void cancel() {
      +        this.status = "CANCELLED";
      +    }
      +
      +    /**
      +     * 회의 진행 중 여부 확인
      +     */
      +    public boolean isInProgress() {
      +        return "IN_PROGRESS".equals(this.status);
      +    }
      +}
      diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Minutes.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Minutes.java
      new file mode 100644
      index 0000000..8198908
      --- /dev/null
      +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Minutes.java
      @@ -0,0 +1,87 @@
      +package com.unicorn.hgzero.meeting.biz.domain;
      +
      +import lombok.AllArgsConstructor;
      +import lombok.Builder;
      +import lombok.Getter;
      +import lombok.NoArgsConstructor;
      +
      +import java.time.LocalDateTime;
      +import java.util.List;
      +
      +/**
      + * 회의록 도메인 모델
      + */
      +@Getter
      +@Builder
      +@NoArgsConstructor
      +@AllArgsConstructor
      +public class Minutes {
      +
      +    /**
      +     * 회의록 ID
      +     */
      +    private String minutesId;
      +
      +    /**
      +     * 회의 ID
      +     */
      +    private String meetingId;
      +
      +    /**
      +     * 회의록 제목
      +     */
      +    private String title;
      +
      +    /**
      +     * 회의록 섹션 목록
      +     */
      +    private List sections;
      +
      +    /**
      +     * 회의록 상태 (DRAFT, FINALIZED)
      +     */
      +    private String status;
      +
      +    /**
      +     * 버전
      +     */
      +    private Integer version;
      +
      +    /**
      +     * 작성자 ID
      +     */
      +    private String createdBy;
      +
      +    /**
      +     * 확정자 ID
      +     */
      +    private String finalizedBy;
      +
      +    /**
      +     * 확정 일시
      +     */
      +    private LocalDateTime finalizedAt;
      +
      +    /**
      +     * 회의록 확정
      +     */
      +    public void finalize(String userId) {
      +        this.status = "FINALIZED";
      +        this.finalizedBy = userId;
      +        this.finalizedAt = LocalDateTime.now();
      +    }
      +
      +    /**
      +     * 회의록 수정
      +     */
      +    public void update() {
      +        this.version++;
      +    }
      +
      +    /**
      +     * 회의록 확정 여부 확인
      +     */
      +    public boolean isFinalized() {
      +        return "FINALIZED".equals(this.status);
      +    }
      +}
      diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/MinutesSection.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/MinutesSection.java
      new file mode 100644
      index 0000000..4a332f9
      --- /dev/null
      +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/MinutesSection.java
      @@ -0,0 +1,98 @@
      +package com.unicorn.hgzero.meeting.biz.domain;
      +
      +import lombok.AllArgsConstructor;
      +import lombok.Builder;
      +import lombok.Getter;
      +import lombok.NoArgsConstructor;
      +
      +/**
      + * 회의록 섹션 도메인 모델
      + */
      +@Getter
      +@Builder
      +@NoArgsConstructor
      +@AllArgsConstructor
      +public class MinutesSection {
      +
      +    /**
      +     * 섹션 ID
      +     */
      +    private String sectionId;
      +
      +    /**
      +     * 회의록 ID
      +     */
      +    private String minutesId;
      +
      +    /**
      +     * 섹션 유형 (AGENDA, DISCUSSION, DECISION, ACTION_ITEM)
      +     */
      +    private String type;
      +
      +    /**
      +     * 섹션 제목
      +     */
      +    private String title;
      +
      +    /**
      +     * 섹션 내용
      +     */
      +    private String content;
      +
      +    /**
      +     * 섹션 순서
      +     */
      +    private Integer order;
      +
      +    /**
      +     * 검증 완료 여부
      +     */
      +    private Boolean verified;
      +
      +    /**
      +     * 잠금 여부
      +     */
      +    private Boolean locked;
      +
      +    /**
      +     * 잠금 사용자 ID
      +     */
      +    private String lockedBy;
      +
      +    /**
      +     * 섹션 잠금
      +     */
      +    public void lock(String userId) {
      +        this.locked = true;
      +        this.lockedBy = userId;
      +    }
      +
      +    /**
      +     * 섹션 잠금 해제
      +     */
      +    public void unlock() {
      +        this.locked = false;
      +        this.lockedBy = null;
      +    }
      +
      +    /**
      +     * 섹션 검증 완료
      +     */
      +    public void verify() {
      +        this.verified = true;
      +    }
      +
      +    /**
      +     * 섹션 잠금 여부 확인
      +     */
      +    public boolean isLocked() {
      +        return Boolean.TRUE.equals(this.locked);
      +    }
      +
      +    /**
      +     * 섹션 검증 완료 여부 확인
      +     */
      +    public boolean isVerified() {
      +        return Boolean.TRUE.equals(this.verified);
      +    }
      +}
      diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Template.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Template.java
      new file mode 100644
      index 0000000..74d5b9e
      --- /dev/null
      +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Template.java
      @@ -0,0 +1,82 @@
      +package com.unicorn.hgzero.meeting.biz.domain;
      +
      +import lombok.AllArgsConstructor;
      +import lombok.Builder;
      +import lombok.Getter;
      +import lombok.NoArgsConstructor;
      +
      +import java.util.List;
      +
      +/**
      + * 회의록 템플릿 도메인 모델
      + */
      +@Getter
      +@Builder
      +@NoArgsConstructor
      +@AllArgsConstructor
      +public class Template {
      +
      +    /**
      +     * 템플릿 ID
      +     */
      +    private String templateId;
      +
      +    /**
      +     * 템플릿 이름
      +     */
      +    private String name;
      +
      +    /**
      +     * 템플릿 설명
      +     */
      +    private String description;
      +
      +    /**
      +     * 템플릿 카테고리 (GENERAL, TECHNICAL, MANAGEMENT, SALES)
      +     */
      +    private String category;
      +
      +    /**
      +     * 템플릿 섹션 목록
      +     */
      +    private List sections;
      +
      +    /**
      +     * 공개 여부
      +     */
      +    private Boolean isPublic;
      +
      +    /**
      +     * 생성자 ID
      +     */
      +    private String createdBy;
      +
      +    /**
      +     * 템플릿 섹션 내부 클래스
      +     */
      +    @Getter
      +    @Builder
      +    @NoArgsConstructor
      +    @AllArgsConstructor
      +    public static class TemplateSection {
      +        /**
      +         * 섹션 유형
      +         */
      +        private String type;
      +
      +        /**
      +         * 섹션 제목
      +         */
      +        private String title;
      +
      +        /**
      +         * 섹션 순서
      +         */
      +        private Integer order;
      +
      +        /**
      +         * 기본 내용
      +         */
      +        private String defaultContent;
      +    }
      +}
      diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Todo.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Todo.java
      new file mode 100644
      index 0000000..f6b24c7
      --- /dev/null
      +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Todo.java
      @@ -0,0 +1,100 @@
      +package com.unicorn.hgzero.meeting.biz.domain;
      +
      +import lombok.AllArgsConstructor;
      +import lombok.Builder;
      +import lombok.Getter;
      +import lombok.NoArgsConstructor;
      +
      +import java.time.LocalDate;
      +import java.time.LocalDateTime;
      +
      +/**
      + * Todo 도메인 모델
      + */
      +@Getter
      +@Builder
      +@NoArgsConstructor
      +@AllArgsConstructor
      +public class Todo {
      +
      +    /**
      +     * Todo ID
      +     */
      +    private String todoId;
      +
      +    /**
      +     * 회의록 ID
      +     */
      +    private String minutesId;
      +
      +    /**
      +     * 회의 ID
      +     */
      +    private String meetingId;
      +
      +    /**
      +     * Todo 제목
      +     */
      +    private String title;
      +
      +    /**
      +     * Todo 설명
      +     */
      +    private String description;
      +
      +    /**
      +     * 담당자 ID
      +     */
      +    private String assigneeId;
      +
      +    /**
      +     * 마감일
      +     */
      +    private LocalDate dueDate;
      +
      +    /**
      +     * Todo 상태 (PENDING, IN_PROGRESS, COMPLETED, CANCELLED)
      +     */
      +    private String status;
      +
      +    /**
      +     * 우선순위 (HIGH, MEDIUM, LOW)
      +     */
      +    private String priority;
      +
      +    /**
      +     * 완료 일시
      +     */
      +    private LocalDateTime completedAt;
      +
      +    /**
      +     * Todo 완료
      +     */
      +    public void complete() {
      +        this.status = "COMPLETED";
      +        this.completedAt = LocalDateTime.now();
      +    }
      +
      +    /**
      +     * Todo 취소
      +     */
      +    public void cancel() {
      +        this.status = "CANCELLED";
      +    }
      +
      +    /**
      +     * Todo 완료 여부 확인
      +     */
      +    public boolean isCompleted() {
      +        return "COMPLETED".equals(this.status);
      +    }
      +
      +    /**
      +     * 마감일 지남 여부 확인
      +     */
      +    public boolean isOverdue() {
      +        return this.dueDate != null &&
      +               LocalDate.now().isAfter(this.dueDate) &&
      +               !isCompleted();
      +    }
      +}
      diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/DashboardService.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/DashboardService.java
      new file mode 100644
      index 0000000..80704ae
      --- /dev/null
      +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/DashboardService.java
      @@ -0,0 +1,43 @@
      +package com.unicorn.hgzero.meeting.biz.service;
      +
      +import com.unicorn.hgzero.meeting.biz.domain.Dashboard;
      +import com.unicorn.hgzero.meeting.biz.usecase.in.dashboard.GetDashboardUseCase;
      +import com.unicorn.hgzero.meeting.biz.usecase.out.DashboardReader;
      +import lombok.RequiredArgsConstructor;
      +import lombok.extern.slf4j.Slf4j;
      +import org.springframework.stereotype.Service;
      +import org.springframework.transaction.annotation.Transactional;
      +
      +/**
      + * 대시보드 Service
      + * Dashboard 관련 모든 UseCase 구현
      + */
      +@Slf4j
      +@Service
      +@RequiredArgsConstructor
      +public class DashboardService implements GetDashboardUseCase {
      +
      +    private final DashboardReader dashboardReader;
      +
      +    /**
      +     * 사용자 대시보드 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public Dashboard getDashboard(String userId) {
      +        log.debug("Getting dashboard for user: {}", userId);
      +
      +        return dashboardReader.getDashboardByUserId(userId);
      +    }
      +
      +    /**
      +     * 사용자 대시보드 (기간 필터) 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public Dashboard getDashboardByPeriod(String userId, String period) {
      +        log.debug("Getting dashboard for user: {} with period: {}", userId, period);
      +
      +        return dashboardReader.getDashboardByUserIdAndPeriod(userId, period);
      +    }
      +}
      diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MeetingService.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MeetingService.java
      new file mode 100644
      index 0000000..eb8d7e7
      --- /dev/null
      +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MeetingService.java
      @@ -0,0 +1,201 @@
      +package com.unicorn.hgzero.meeting.biz.service;
      +
      +import com.unicorn.hgzero.common.exception.BusinessException;
      +import com.unicorn.hgzero.common.exception.ErrorCode;
      +import com.unicorn.hgzero.meeting.biz.domain.Meeting;
      +import com.unicorn.hgzero.meeting.biz.usecase.in.meeting.*;
      +import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader;
      +import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingWriter;
      +import lombok.RequiredArgsConstructor;
      +import lombok.extern.slf4j.Slf4j;
      +import org.springframework.stereotype.Service;
      +import org.springframework.transaction.annotation.Transactional;
      +
      +import java.time.LocalDateTime;
      +import java.util.List;
      +import java.util.UUID;
      +
      +/**
      + * 회의 Service
      + * Meeting 관련 모든 UseCase 구현
      + */
      +@Slf4j
      +@Service
      +@RequiredArgsConstructor
      +public class MeetingService implements
      +        CreateMeetingUseCase,
      +        StartMeetingUseCase,
      +        EndMeetingUseCase,
      +        CancelMeetingUseCase,
      +        GetMeetingUseCase {
      +
      +    private final MeetingReader meetingReader;
      +    private final MeetingWriter meetingWriter;
      +
      +    /**
      +     * 회의 생성
      +     */
      +    @Override
      +    @Transactional
      +    public Meeting createMeeting(CreateMeetingCommand command) {
      +        log.info("Creating meeting: {}", command.title());
      +
      +        // 회의 ID 생성
      +        String meetingId = UUID.randomUUID().toString();
      +
      +        // 회의 도메인 객체 생성
      +        Meeting meeting = Meeting.builder()
      +                .meetingId(meetingId)
      +                .title(command.title())
      +                .description(command.description())
      +                .scheduledAt(command.scheduledAt())
      +                .status("SCHEDULED")
      +                .organizerId(command.organizerId())
      +                .participants(command.participants())
      +                .templateId(command.templateId())
      +                .build();
      +
      +        // 회의 저장
      +        Meeting savedMeeting = meetingWriter.save(meeting);
      +
      +        log.info("Meeting created successfully: {}", savedMeeting.getMeetingId());
      +        return savedMeeting;
      +    }
      +
      +    /**
      +     * 회의 시작
      +     */
      +    @Override
      +    @Transactional
      +    public Meeting startMeeting(String meetingId) {
      +        log.info("Starting meeting: {}", meetingId);
      +
      +        // 회의 조회
      +        Meeting meeting = meetingReader.findById(meetingId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +
      +        // 회의 상태 검증
      +        if (!"SCHEDULED".equals(meeting.getStatus())) {
      +            throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
      +        }
      +
      +        // 회의 시작
      +        meeting.start();
      +
      +        // 저장
      +        Meeting updatedMeeting = meetingWriter.save(meeting);
      +
      +        log.info("Meeting started successfully: {}", meetingId);
      +        return updatedMeeting;
      +    }
      +
      +    /**
      +     * 회의 종료
      +     */
      +    @Override
      +    @Transactional
      +    public Meeting endMeeting(String meetingId) {
      +        log.info("Ending meeting: {}", meetingId);
      +
      +        // 회의 조회
      +        Meeting meeting = meetingReader.findById(meetingId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +
      +        // 회의 상태 검증
      +        if (!"IN_PROGRESS".equals(meeting.getStatus())) {
      +            throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
      +        }
      +
      +        // 회의 종료
      +        meeting.end();
      +
      +        // 저장
      +        Meeting updatedMeeting = meetingWriter.save(meeting);
      +
      +        log.info("Meeting ended successfully: {}", meetingId);
      +        return updatedMeeting;
      +    }
      +
      +    /**
      +     * 회의 취소
      +     */
      +    @Override
      +    @Transactional
      +    public Meeting cancelMeeting(String meetingId) {
      +        log.info("Canceling meeting: {}", meetingId);
      +
      +        // 회의 조회
      +        Meeting meeting = meetingReader.findById(meetingId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +
      +        // 회의 취소 가능 상태 검증
      +        if ("COMPLETED".equals(meeting.getStatus()) || "CANCELLED".equals(meeting.getStatus())) {
      +            throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
      +        }
      +
      +        // 회의 취소
      +        meeting.cancel();
      +
      +        // 저장
      +        Meeting updatedMeeting = meetingWriter.save(meeting);
      +
      +        log.info("Meeting cancelled successfully: {}", meetingId);
      +        return updatedMeeting;
      +    }
      +
      +    /**
      +     * ID로 회의 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public Meeting getMeeting(String meetingId) {
      +        log.debug("Getting meeting: {}", meetingId);
      +
      +        return meetingReader.findById(meetingId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +    }
      +
      +    /**
      +     * 주최자 ID로 회의 목록 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public List getMeetingsByOrganizer(String organizerId) {
      +        log.debug("Getting meetings by organizer: {}", organizerId);
      +
      +        return meetingReader.findByOrganizerId(organizerId);
      +    }
      +
      +    /**
      +     * 상태로 회의 목록 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public List getMeetingsByStatus(String status) {
      +        log.debug("Getting meetings by status: {}", status);
      +
      +        return meetingReader.findByStatus(status);
      +    }
      +
      +    /**
      +     * 일정 시간 범위로 회의 목록 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public List getMeetingsByScheduledTime(LocalDateTime startTime, LocalDateTime endTime) {
      +        log.debug("Getting meetings by scheduled time: {} ~ {}", startTime, endTime);
      +
      +        return meetingReader.findByScheduledTimeBetween(startTime, endTime);
      +    }
      +
      +    /**
      +     * 주최자 ID와 상태로 회의 목록 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public List getMeetingsByOrganizerAndStatus(String organizerId, String status) {
      +        log.debug("Getting meetings by organizer: {} and status: {}", organizerId, status);
      +
      +        return meetingReader.findByOrganizerIdAndStatus(organizerId, status);
      +    }
      +}
      diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesSectionService.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesSectionService.java
      new file mode 100644
      index 0000000..6de3193
      --- /dev/null
      +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesSectionService.java
      @@ -0,0 +1,234 @@
      +package com.unicorn.hgzero.meeting.biz.service;
      +
      +import com.unicorn.hgzero.common.exception.BusinessException;
      +import com.unicorn.hgzero.common.exception.ErrorCode;
      +import com.unicorn.hgzero.meeting.biz.domain.MinutesSection;
      +import com.unicorn.hgzero.meeting.biz.usecase.in.section.*;
      +import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionReader;
      +import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionWriter;
      +import lombok.RequiredArgsConstructor;
      +import lombok.extern.slf4j.Slf4j;
      +import org.springframework.stereotype.Service;
      +import org.springframework.transaction.annotation.Transactional;
      +
      +import java.util.List;
      +import java.util.UUID;
      +
      +/**
      + * 회의록 섹션 Service
      + * MinutesSection 관련 모든 UseCase 구현
      + */
      +@Slf4j
      +@Service
      +@RequiredArgsConstructor
      +public class MinutesSectionService implements
      +        CreateSectionUseCase,
      +        UpdateSectionUseCase,
      +        DeleteSectionUseCase,
      +        LockSectionUseCase,
      +        VerifySectionUseCase,
      +        GetSectionUseCase {
      +
      +    private final MinutesSectionReader sectionReader;
      +    private final MinutesSectionWriter sectionWriter;
      +
      +    /**
      +     * 섹션 생성
      +     */
      +    @Override
      +    @Transactional
      +    public MinutesSection createSection(CreateSectionCommand command) {
      +        log.info("Creating section for minutes: {}", command.minutesId());
      +
      +        // 섹션 ID 생성
      +        String sectionId = UUID.randomUUID().toString();
      +
      +        // 섹션 도메인 객체 생성
      +        MinutesSection section = MinutesSection.builder()
      +                .sectionId(sectionId)
      +                .minutesId(command.minutesId())
      +                .type(command.type())
      +                .title(command.title())
      +                .content(command.content())
      +                .order(command.order())
      +                .verified(false)
      +                .locked(false)
      +                .build();
      +
      +        // 섹션 저장
      +        MinutesSection savedSection = sectionWriter.save(section);
      +
      +        log.info("Section created successfully: {}", savedSection.getSectionId());
      +        return savedSection;
      +    }
      +
      +    /**
      +     * 섹션 내용 수정
      +     */
      +    @Override
      +    @Transactional
      +    public MinutesSection updateSection(UpdateSectionCommand command) {
      +        log.info("Updating section: {}", command.sectionId());
      +
      +        // 섹션 조회
      +        MinutesSection section = sectionReader.findById(command.sectionId())
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +
      +        // 잠금 상태 검증
      +        if (Boolean.TRUE.equals(section.getLocked())) {
      +            throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
      +        }
      +
      +        // 검증 상태 검증
      +        if (Boolean.TRUE.equals(section.getVerified())) {
      +            throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
      +        }
      +
      +        // 섹션 수정
      +        section.update(command.title(), command.content());
      +
      +        // 저장
      +        MinutesSection updatedSection = sectionWriter.save(section);
      +
      +        log.info("Section updated successfully: {}", command.sectionId());
      +        return updatedSection;
      +    }
      +
      +    /**
      +     * 섹션 삭제
      +     */
      +    @Override
      +    @Transactional
      +    public void deleteSection(String sectionId) {
      +        log.info("Deleting section: {}", sectionId);
      +
      +        // 섹션 조회
      +        MinutesSection section = sectionReader.findById(sectionId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +
      +        // 검증 상태 확인 (검증된 섹션은 삭제 불가)
      +        if (Boolean.TRUE.equals(section.getVerified())) {
      +            throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
      +        }
      +
      +        // 섹션 삭제
      +        sectionWriter.delete(sectionId);
      +
      +        log.info("Section deleted successfully: {}", sectionId);
      +    }
      +
      +    /**
      +     * 섹션 잠금
      +     */
      +    @Override
      +    @Transactional
      +    public MinutesSection lockSection(String sectionId, String userId) {
      +        log.info("Locking section: {} by user: {}", sectionId, userId);
      +
      +        // 섹션 조회
      +        MinutesSection section = sectionReader.findById(sectionId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +
      +        // 이미 잠금 상태 확인
      +        if (Boolean.TRUE.equals(section.getLocked())) {
      +            throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
      +        }
      +
      +        // 섹션 잠금
      +        section.lock(userId);
      +
      +        // 저장
      +        MinutesSection lockedSection = sectionWriter.save(section);
      +
      +        log.info("Section locked successfully: {}", sectionId);
      +        return lockedSection;
      +    }
      +
      +    /**
      +     * 섹션 잠금 해제
      +     */
      +    @Override
      +    @Transactional
      +    public MinutesSection unlockSection(String sectionId) {
      +        log.info("Unlocking section: {}", sectionId);
      +
      +        // 섹션 조회
      +        MinutesSection section = sectionReader.findById(sectionId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +
      +        // 잠금 상태 확인
      +        if (Boolean.FALSE.equals(section.getLocked())) {
      +            throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
      +        }
      +
      +        // 섹션 잠금 해제
      +        section.unlock();
      +
      +        // 저장
      +        MinutesSection unlockedSection = sectionWriter.save(section);
      +
      +        log.info("Section unlocked successfully: {}", sectionId);
      +        return unlockedSection;
      +    }
      +
      +    /**
      +     * 섹션 검증
      +     */
      +    @Override
      +    @Transactional
      +    public MinutesSection verifySection(String sectionId) {
      +        log.info("Verifying section: {}", sectionId);
      +
      +        // 섹션 조회
      +        MinutesSection section = sectionReader.findById(sectionId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +
      +        // 이미 검증 상태 확인
      +        if (Boolean.TRUE.equals(section.getVerified())) {
      +            throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
      +        }
      +
      +        // 섹션 검증
      +        section.verify();
      +
      +        // 저장
      +        MinutesSection verifiedSection = sectionWriter.save(section);
      +
      +        log.info("Section verified successfully: {}", sectionId);
      +        return verifiedSection;
      +    }
      +
      +    /**
      +     * ID로 섹션 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public MinutesSection getSection(String sectionId) {
      +        log.debug("Getting section: {}", sectionId);
      +
      +        return sectionReader.findById(sectionId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +    }
      +
      +    /**
      +     * 회의록 ID로 섹션 목록 조회 (순서대로)
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public List getSectionsByMinutes(String minutesId) {
      +        log.debug("Getting sections by minutes: {}", minutesId);
      +
      +        return sectionReader.findByMinutesIdOrderByOrder(minutesId);
      +    }
      +
      +    /**
      +     * 회의록 ID와 타입으로 섹션 목록 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public List getSectionsByMinutesAndType(String minutesId, String type) {
      +        log.debug("Getting sections by minutes: {} and type: {}", minutesId, type);
      +
      +        return sectionReader.findByMinutesIdAndType(minutesId, type);
      +    }
      +}
      diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesService.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesService.java
      new file mode 100644
      index 0000000..47108db
      --- /dev/null
      +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesService.java
      @@ -0,0 +1,193 @@
      +package com.unicorn.hgzero.meeting.biz.service;
      +
      +import com.unicorn.hgzero.common.exception.BusinessException;
      +import com.unicorn.hgzero.common.exception.ErrorCode;
      +import com.unicorn.hgzero.meeting.biz.domain.Minutes;
      +import com.unicorn.hgzero.meeting.biz.usecase.in.minutes.*;
      +import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesReader;
      +import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesWriter;
      +import lombok.RequiredArgsConstructor;
      +import lombok.extern.slf4j.Slf4j;
      +import org.springframework.stereotype.Service;
      +import org.springframework.transaction.annotation.Transactional;
      +
      +import java.util.List;
      +import java.util.UUID;
      +
      +/**
      + * 회의록 Service
      + * Minutes 관련 모든 UseCase 구현
      + */
      +@Slf4j
      +@Service
      +@RequiredArgsConstructor
      +public class MinutesService implements
      +        CreateMinutesUseCase,
      +        UpdateMinutesUseCase,
      +        FinalizeMinutesUseCase,
      +        GetMinutesUseCase {
      +
      +    private final MinutesReader minutesReader;
      +    private final MinutesWriter minutesWriter;
      +
      +    /**
      +     * 회의록 생성
      +     */
      +    @Override
      +    @Transactional
      +    public Minutes createMinutes(CreateMinutesCommand command) {
      +        log.info("Creating minutes for meeting: {}", command.meetingId());
      +
      +        // 회의록 ID 생성
      +        String minutesId = UUID.randomUUID().toString();
      +
      +        // 회의록 도메인 객체 생성
      +        Minutes minutes = Minutes.builder()
      +                .minutesId(minutesId)
      +                .meetingId(command.meetingId())
      +                .title(command.title())
      +                .status("DRAFT")
      +                .version(1)
      +                .createdBy(command.createdBy())
      +                .build();
      +
      +        // 회의록 저장
      +        Minutes savedMinutes = minutesWriter.save(minutes);
      +
      +        log.info("Minutes created successfully: {}", savedMinutes.getMinutesId());
      +        return savedMinutes;
      +    }
      +
      +    /**
      +     * 회의록 제목 수정
      +     */
      +    @Override
      +    @Transactional
      +    public Minutes updateMinutesTitle(String minutesId, String title) {
      +        log.info("Updating minutes title: {}", minutesId);
      +
      +        // 회의록 조회
      +        Minutes minutes = minutesReader.findById(minutesId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +
      +        // 상태 검증 (확정된 회의록은 수정 불가)
      +        if ("FINALIZED".equals(minutes.getStatus())) {
      +            throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
      +        }
      +
      +        // 제목 수정
      +        minutes.update(title, minutes.getSections());
      +
      +        // 저장
      +        Minutes updatedMinutes = minutesWriter.save(minutes);
      +
      +        log.info("Minutes title updated successfully: {}", minutesId);
      +        return updatedMinutes;
      +    }
      +
      +    /**
      +     * 회의록 버전 증가
      +     */
      +    @Override
      +    @Transactional
      +    public Minutes incrementVersion(String minutesId) {
      +        log.info("Incrementing minutes version: {}", minutesId);
      +
      +        // 회의록 조회
      +        Minutes minutes = minutesReader.findById(minutesId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +
      +        // 버전 증가
      +        minutes.incrementVersion();
      +
      +        // 저장
      +        Minutes updatedMinutes = minutesWriter.save(minutes);
      +
      +        log.info("Minutes version incremented: {} -> {}", minutesId, updatedMinutes.getVersion());
      +        return updatedMinutes;
      +    }
      +
      +    /**
      +     * 회의록 확정
      +     */
      +    @Override
      +    @Transactional
      +    public Minutes finalizeMinutes(String minutesId, String userId) {
      +        log.info("Finalizing minutes: {}", minutesId);
      +
      +        // 회의록 조회
      +        Minutes minutes = minutesReader.findById(minutesId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +
      +        // 상태 검증
      +        if ("FINALIZED".equals(minutes.getStatus())) {
      +            throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
      +        }
      +
      +        // 회의록 확정
      +        minutes.finalize(userId);
      +
      +        // 저장
      +        Minutes finalizedMinutes = minutesWriter.save(minutes);
      +
      +        log.info("Minutes finalized successfully: {}", minutesId);
      +        return finalizedMinutes;
      +    }
      +
      +    /**
      +     * ID로 회의록 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public Minutes getMinutes(String minutesId) {
      +        log.debug("Getting minutes: {}", minutesId);
      +
      +        return minutesReader.findById(minutesId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +    }
      +
      +    /**
      +     * 회의 ID로 회의록 목록 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public List getMinutesByMeeting(String meetingId) {
      +        log.debug("Getting minutes by meeting: {}", meetingId);
      +
      +        return minutesReader.findByMeetingId(meetingId);
      +    }
      +
      +    /**
      +     * 회의 ID로 최신 회의록 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public Minutes getLatestMinutes(String meetingId) {
      +        log.debug("Getting latest minutes for meeting: {}", meetingId);
      +
      +        return minutesReader.findLatestByMeetingId(meetingId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +    }
      +
      +    /**
      +     * 작성자 ID로 회의록 목록 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public List getMinutesByCreator(String createdBy) {
      +        log.debug("Getting minutes by creator: {}", createdBy);
      +
      +        return minutesReader.findByCreatedBy(createdBy);
      +    }
      +
      +    /**
      +     * 상태로 회의록 목록 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public List getMinutesByStatus(String status) {
      +        log.debug("Getting minutes by status: {}", status);
      +
      +        return minutesReader.findByStatus(status);
      +    }
      +}
      diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/TemplateService.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/TemplateService.java
      new file mode 100644
      index 0000000..3606dc2
      --- /dev/null
      +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/TemplateService.java
      @@ -0,0 +1,116 @@
      +package com.unicorn.hgzero.meeting.biz.service;
      +
      +import com.unicorn.hgzero.common.exception.BusinessException;
      +import com.unicorn.hgzero.common.exception.ErrorCode;
      +import com.unicorn.hgzero.meeting.biz.domain.Template;
      +import com.unicorn.hgzero.meeting.biz.usecase.in.template.CreateTemplateUseCase;
      +import com.unicorn.hgzero.meeting.biz.usecase.in.template.GetTemplateUseCase;
      +import com.unicorn.hgzero.meeting.biz.usecase.out.TemplateReader;
      +import com.unicorn.hgzero.meeting.biz.usecase.out.TemplateWriter;
      +import lombok.RequiredArgsConstructor;
      +import lombok.extern.slf4j.Slf4j;
      +import org.springframework.stereotype.Service;
      +import org.springframework.transaction.annotation.Transactional;
      +
      +import java.util.List;
      +import java.util.UUID;
      +
      +/**
      + * 템플릿 Service
      + * Template 관련 모든 UseCase 구현
      + */
      +@Slf4j
      +@Service
      +@RequiredArgsConstructor
      +public class TemplateService implements
      +        CreateTemplateUseCase,
      +        GetTemplateUseCase {
      +
      +    private final TemplateReader templateReader;
      +    private final TemplateWriter templateWriter;
      +
      +    /**
      +     * 템플릿 생성
      +     */
      +    @Override
      +    @Transactional
      +    public Template createTemplate(CreateTemplateCommand command) {
      +        log.info("Creating template: {}", command.name());
      +
      +        // 템플릿 ID 생성
      +        String templateId = UUID.randomUUID().toString();
      +
      +        // 템플릿 도메인 객체 생성
      +        Template template = Template.builder()
      +                .templateId(templateId)
      +                .name(command.name())
      +                .description(command.description())
      +                .category(command.category())
      +                .sections(command.sections())
      +                .isPublic(command.isPublic() != null ? command.isPublic() : true)
      +                .createdBy(command.createdBy())
      +                .build();
      +
      +        // 템플릿 저장
      +        Template savedTemplate = templateWriter.save(template);
      +
      +        log.info("Template created successfully: {}", savedTemplate.getTemplateId());
      +        return savedTemplate;
      +    }
      +
      +    /**
      +     * ID로 템플릿 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public Template getTemplate(String templateId) {
      +        log.debug("Getting template: {}", templateId);
      +
      +        return templateReader.findById(templateId)
      +                .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
      +    }
      +
      +    /**
      +     * 카테고리로 템플릿 목록 조회
      +     */
      +    @Override
      +    @Transactional(readOnly = true)
      +    public List