diff --git a/backing-service/install/postgres/values-user.yaml b/backing-service/install/postgres/values-user.yaml
index 665a2fa..af3a323 100644
--- a/backing-service/install/postgres/values-user.yaml
+++ b/backing-service/install/postgres/values-user.yaml
@@ -18,7 +18,7 @@ primary:
enabled: true
storageClass: "managed-premium"
size: 10Gi
-
+
resources:
limits:
memory: "4Gi"
@@ -26,12 +26,14 @@ primary:
requests:
memory: "2Gi"
cpu: "0.5"
-
- # 성능 최적화 설정
+
+ # 성능 최적화 설정
extraEnvVars:
+ - name: POSTGRESQL_READ_ONLY_MODE
+ value: "no"
- name: POSTGRESQL_SHARED_BUFFERS
value: "1GB"
- - name: POSTGRESQL_EFFECTIVE_CACHE_SIZE
+ - name: POSTGRESQL_EFFECTIVE_CACHE_SIZE
value: "3GB"
- name: POSTGRESQL_MAX_CONNECTIONS
value: "200"
diff --git a/design/backend/api/user-service-api.yaml b/design/backend/api/user-service-api.yaml
index e1c486f..20112a3 100644
--- a/design/backend/api/user-service-api.yaml
+++ b/design/backend/api/user-service-api.yaml
@@ -51,7 +51,7 @@ paths:
- JWT 토큰 자동 발급
**처리 흐름:**
- 1. 중복 사용자 확인 (전화번호 기반)
+ 1. 중복 사용자 확인 (이메일/전화번호 기반)
2. 비밀번호 해싱 (bcrypt)
3. User/Store 데이터베이스 트랜잭션 처리
4. JWT 토큰 생성 및 세션 저장 (Redis)
@@ -114,7 +114,7 @@ paths:
summary: 중복 사용자
value:
code: USER_001
- message: 이미 가입된 전화번호입니다
+ message: 이미 가입된 이메일입니다
timestamp: 2025-10-22T10:30:00Z
validationError:
summary: 입력 검증 오류
@@ -140,7 +140,7 @@ paths:
**유저스토리:** UFR-USER-020
**주요 기능:**
- - 전화번호/비밀번호 인증
+ - 이메일/비밀번호 인증
- JWT 토큰 발급
- Redis 세션 저장
- 최종 로그인 시각 업데이트 (비동기)
@@ -162,7 +162,7 @@ paths:
default:
summary: 로그인 요청 예시
value:
- phoneNumber: "01012345678"
+ email: hong@example.com
password: "Password123!"
responses:
'200':
@@ -191,7 +191,7 @@ paths:
summary: 인증 실패
value:
code: AUTH_001
- message: 전화번호 또는 비밀번호를 확인해주세요
+ message: 이메일 또는 비밀번호를 확인해주세요
timestamp: 2025-10-22T10:30:00Z
/users/logout:
@@ -679,14 +679,15 @@ components:
LoginRequest:
type: object
required:
- - phoneNumber
+ - email
- password
properties:
- phoneNumber:
+ email:
type: string
- pattern: '^010\d{8}$'
- description: 휴대폰 번호
- example: "01012345678"
+ format: email
+ maxLength: 100
+ description: 이메일 주소
+ example: hong@example.com
password:
type: string
minLength: 8
@@ -977,7 +978,7 @@ components:
message:
type: string
description: 에러 메시지
- example: 이미 가입된 전화번호입니다
+ example: 이미 가입된 이메일입니다
timestamp:
type: string
format: date-time
diff --git a/user-service/.run/user-service.run.xml b/user-service/.run/user-service.run.xml
new file mode 100644
index 0000000..07dfd36
--- /dev/null
+++ b/user-service/.run/user-service.run.xml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ false
+
+
+
diff --git a/user-service/build.gradle b/user-service/build.gradle
index 63a1c78..ad1b873 100644
--- a/user-service/build.gradle
+++ b/user-service/build.gradle
@@ -7,4 +7,10 @@ dependencies {
// OpenFeign for external API calls (사업자번호 검증)
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
+
+ // H2 Database for development
+ runtimeOnly 'com.h2database:h2'
+
+ // PostgreSQL Database for production
+ runtimeOnly 'org.postgresql:postgresql'
}
diff --git a/user-service/src/main/java/com/kt/event/user/UserServiceApplication.java b/user-service/src/main/java/com/kt/event/user/UserServiceApplication.java
new file mode 100644
index 0000000..007a47d
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/UserServiceApplication.java
@@ -0,0 +1,30 @@
+package com.kt.event.user;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+/**
+ * User Service Application
+ *
+ * KT AI 기반 소상공인 이벤트 자동 생성 서비스 - User Service
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@SpringBootApplication(scanBasePackages = {
+ "com.kt.event.user",
+ "com.kt.event.common"
+})
+@EntityScan(basePackages = {
+ "com.kt.event.user.entity",
+ "com.kt.event.common.entity"
+})
+@EnableJpaAuditing
+public class UserServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(UserServiceApplication.class, args);
+ }
+}
diff --git a/user-service/src/main/java/com/kt/event/user/config/AsyncConfig.java b/user-service/src/main/java/com/kt/event/user/config/AsyncConfig.java
new file mode 100644
index 0000000..782145c
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/config/AsyncConfig.java
@@ -0,0 +1,32 @@
+package com.kt.event.user.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.context.annotation.Bean;
+
+import java.util.concurrent.Executor;
+
+/**
+ * 비동기 처리 설정
+ *
+ * @Async 어노테이션 활성화 및 스레드 풀 설정
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Configuration
+@EnableAsync
+public class AsyncConfig {
+
+ @Bean(name = "taskExecutor")
+ public Executor taskExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(2);
+ executor.setMaxPoolSize(10);
+ executor.setQueueCapacity(500);
+ executor.setThreadNamePrefix("async-");
+ executor.initialize();
+ return executor;
+ }
+}
diff --git a/user-service/src/main/java/com/kt/event/user/config/RedisConfig.java b/user-service/src/main/java/com/kt/event/user/config/RedisConfig.java
new file mode 100644
index 0000000..c4c48d6
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/config/RedisConfig.java
@@ -0,0 +1,59 @@
+package com.kt.event.user.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+/**
+ * Redis 설정
+ *
+ * Redis 연결 및 템플릿 설정
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Configuration
+@ConditionalOnProperty(name = "spring.data.redis.enabled", havingValue = "true", matchIfMissing = false)
+public class RedisConfig {
+
+ @Value("${spring.data.redis.host}")
+ private String host;
+
+ @Value("${spring.data.redis.port}")
+ private int port;
+
+ @Value("${spring.data.redis.password:}")
+ private String password;
+
+ @Value("${spring.data.redis.database:0}")
+ private int database;
+
+ @Bean
+ public RedisConnectionFactory redisConnectionFactory() {
+ RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
+ config.setHostName(host);
+ config.setPort(port);
+ config.setDatabase(database);
+ if (password != null && !password.isEmpty()) {
+ config.setPassword(password);
+ }
+ return new LettuceConnectionFactory(config);
+ }
+
+ @Bean
+ public RedisTemplate redisTemplate() {
+ RedisTemplate template = new RedisTemplate<>();
+ template.setConnectionFactory(redisConnectionFactory());
+ template.setKeySerializer(new StringRedisSerializer());
+ template.setValueSerializer(new StringRedisSerializer());
+ template.setHashKeySerializer(new StringRedisSerializer());
+ template.setHashValueSerializer(new StringRedisSerializer());
+ return template;
+ }
+}
diff --git a/user-service/src/main/java/com/kt/event/user/config/SecurityConfig.java b/user-service/src/main/java/com/kt/event/user/config/SecurityConfig.java
new file mode 100644
index 0000000..9e891c3
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/config/SecurityConfig.java
@@ -0,0 +1,96 @@
+package com.kt.event.user.config;
+
+import com.kt.event.common.security.JwtAuthenticationFilter;
+import com.kt.event.common.security.JwtTokenProvider;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import java.util.Arrays;
+
+/**
+ * Spring Security 설정
+ *
+ * JWT 기반 인증 및 API 보안 설정
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Configuration
+@EnableWebSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ private final JwtTokenProvider jwtTokenProvider;
+
+ @Value("${cors.allowed-origins:http://localhost:*}")
+ 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
+ // Public endpoints
+ .requestMatchers("/api/v1/users/register", "/api/v1/users/login").permitAll()
+ // Actuator endpoints
+ .requestMatchers("/actuator/**").permitAll()
+ // Swagger UI endpoints
+ .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;
+ }
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
diff --git a/user-service/src/main/java/com/kt/event/user/config/SwaggerConfig.java b/user-service/src/main/java/com/kt/event/user/config/SwaggerConfig.java
new file mode 100644
index 0000000..60ab414
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/config/SwaggerConfig.java
@@ -0,0 +1,67 @@
+package com.kt.event.user.config;
+
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Contact;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.security.SecurityRequirement;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import io.swagger.v3.oas.models.servers.Server;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Swagger/OpenAPI 설정
+ *
+ * User Service API 문서화를 위한 설정
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Configuration
+public class SwaggerConfig {
+
+ @Bean
+ public OpenAPI openAPI() {
+ return new OpenAPI()
+ .info(apiInfo())
+ .addServersItem(new Server()
+ .url("http://localhost:8081")
+ .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("8081")
+ .description("Server port"))))
+ .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
+ .components(new Components()
+ .addSecuritySchemes("Bearer Authentication", createAPIKeyScheme()));
+ }
+
+ private Info apiInfo() {
+ return new Info()
+ .title("User Service API")
+ .description("KT AI 기반 소상공인 이벤트 자동 생성 서비스 - User Service API")
+ .version("1.0.0")
+ .contact(new Contact()
+ .name("Digital Garage Team")
+ .email("support@kt-event-marketing.com"));
+ }
+
+ private SecurityScheme createAPIKeyScheme() {
+ return new SecurityScheme()
+ .type(SecurityScheme.Type.HTTP)
+ .bearerFormat("JWT")
+ .scheme("bearer");
+ }
+}
diff --git a/user-service/src/main/java/com/kt/event/user/controller/UserController.java b/user-service/src/main/java/com/kt/event/user/controller/UserController.java
new file mode 100644
index 0000000..4b93daf
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/controller/UserController.java
@@ -0,0 +1,132 @@
+package com.kt.event.user.controller;
+
+import com.kt.event.common.security.UserPrincipal;
+import com.kt.event.user.dto.request.ChangePasswordRequest;
+import com.kt.event.user.dto.request.LoginRequest;
+import com.kt.event.user.dto.request.RegisterRequest;
+import com.kt.event.user.dto.request.UpdateProfileRequest;
+import com.kt.event.user.dto.response.LoginResponse;
+import com.kt.event.user.dto.response.LogoutResponse;
+import com.kt.event.user.dto.response.ProfileResponse;
+import com.kt.event.user.dto.response.RegisterResponse;
+import com.kt.event.user.service.AuthenticationService;
+import com.kt.event.user.service.UserService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * User Controller
+ *
+ * 사용자 인증 및 프로필 관리 API
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/users")
+@RequiredArgsConstructor
+@Tag(name = "User", description = "사용자 인증 및 프로필 관리 API")
+public class UserController {
+
+ private final UserService userService;
+ private final AuthenticationService authenticationService;
+
+ /**
+ * 회원가입
+ *
+ * UFR-USER-010: 회원가입
+ */
+ @PostMapping("/register")
+ @Operation(summary = "회원가입", description = "소상공인 회원가입 API")
+ public ResponseEntity register(@Valid @RequestBody RegisterRequest request) {
+ log.info("회원가입 요청: phoneNumber={}, email={}", request.getPhoneNumber(), request.getEmail());
+ RegisterResponse response = userService.register(request);
+ log.info("회원가입 성공: userId={}", response.getUserId());
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ }
+
+ /**
+ * 로그인
+ *
+ * UFR-USER-020: 로그인
+ */
+ @PostMapping("/login")
+ @Operation(summary = "로그인", description = "소상공인 로그인 API")
+ public ResponseEntity login(@Valid @RequestBody LoginRequest request) {
+ log.info("로그인 요청: email={}", request.getEmail());
+ LoginResponse response = authenticationService.login(request);
+ log.info("로그인 성공: userId={}", response.getUserId());
+ return ResponseEntity.ok(response);
+ }
+
+ /**
+ * 로그아웃
+ *
+ * UFR-USER-040: 로그아웃
+ */
+ @PostMapping("/logout")
+ @Operation(summary = "로그아웃", description = "로그아웃 API")
+ public ResponseEntity logout(@RequestHeader("Authorization") String authHeader) {
+ String token = authHeader.substring(7); // "Bearer " 제거
+ log.info("로그아웃 요청");
+ LogoutResponse response = authenticationService.logout(token);
+ log.info("로그아웃 성공");
+ return ResponseEntity.ok(response);
+ }
+
+ /**
+ * 프로필 조회
+ *
+ * UFR-USER-030: 프로필 관리
+ */
+ @GetMapping("/profile")
+ @Operation(summary = "프로필 조회", description = "사용자 프로필 조회 API")
+ public ResponseEntity getProfile(@AuthenticationPrincipal UserPrincipal principal) {
+ Long userId = principal.getUserId();
+ log.info("프로필 조회 요청: userId={}", userId);
+ ProfileResponse response = userService.getProfile(userId);
+ return ResponseEntity.ok(response);
+ }
+
+ /**
+ * 프로필 수정
+ *
+ * UFR-USER-030: 프로필 관리
+ */
+ @PutMapping("/profile")
+ @Operation(summary = "프로필 수정", description = "사용자 프로필 수정 API")
+ public ResponseEntity updateProfile(
+ @AuthenticationPrincipal UserPrincipal principal,
+ @Valid @RequestBody UpdateProfileRequest request) {
+ Long userId = principal.getUserId();
+ log.info("프로필 수정 요청: userId={}", userId);
+ ProfileResponse response = userService.updateProfile(userId, request);
+ log.info("프로필 수정 성공: userId={}", userId);
+ return ResponseEntity.ok(response);
+ }
+
+ /**
+ * 비밀번호 변경
+ *
+ * UFR-USER-030: 프로필 관리
+ */
+ @PutMapping("/password")
+ @Operation(summary = "비밀번호 변경", description = "비밀번호 변경 API")
+ public ResponseEntity changePassword(
+ @AuthenticationPrincipal UserPrincipal principal,
+ @Valid @RequestBody ChangePasswordRequest request) {
+ Long userId = principal.getUserId();
+ log.info("비밀번호 변경 요청: userId={}", userId);
+ userService.changePassword(userId, request);
+ log.info("비밀번호 변경 성공: userId={}", userId);
+ return ResponseEntity.ok().build();
+ }
+}
diff --git a/user-service/src/main/java/com/kt/event/user/dto/request/ChangePasswordRequest.java b/user-service/src/main/java/com/kt/event/user/dto/request/ChangePasswordRequest.java
new file mode 100644
index 0000000..b141321
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/dto/request/ChangePasswordRequest.java
@@ -0,0 +1,36 @@
+package com.kt.event.user.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 비밀번호 변경 요청 DTO
+ *
+ * UFR-USER-030: 프로필 관리
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class ChangePasswordRequest {
+
+ /**
+ * 현재 비밀번호
+ */
+ @NotBlank(message = "현재 비밀번호는 필수입니다")
+ private String currentPassword;
+
+ /**
+ * 새 비밀번호 (8자 이상)
+ */
+ @NotBlank(message = "새 비밀번호는 필수입니다")
+ @Size(min = 8, max = 100, message = "새 비밀번호는 8자 이상 100자 이하여야 합니다")
+ private String newPassword;
+}
diff --git a/user-service/src/main/java/com/kt/event/user/dto/request/LoginRequest.java b/user-service/src/main/java/com/kt/event/user/dto/request/LoginRequest.java
new file mode 100644
index 0000000..b743595
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/dto/request/LoginRequest.java
@@ -0,0 +1,38 @@
+package com.kt.event.user.dto.request;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 로그인 요청 DTO
+ *
+ * UFR-USER-020: 로그인
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class LoginRequest {
+
+ /**
+ * 이메일 주소
+ */
+ @NotBlank(message = "이메일은 필수입니다")
+ @Email(message = "이메일 형식이 올바르지 않습니다")
+ @Size(max = 100, message = "이메일은 100자를 초과할 수 없습니다")
+ private String email;
+
+ /**
+ * 비밀번호
+ */
+ @NotBlank(message = "비밀번호는 필수입니다")
+ private String password;
+}
diff --git a/user-service/src/main/java/com/kt/event/user/dto/request/RegisterRequest.java b/user-service/src/main/java/com/kt/event/user/dto/request/RegisterRequest.java
new file mode 100644
index 0000000..95db436
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/dto/request/RegisterRequest.java
@@ -0,0 +1,80 @@
+package com.kt.event.user.dto.request;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 회원가입 요청 DTO
+ *
+ * UFR-USER-010: 회원가입
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class RegisterRequest {
+
+ /**
+ * 사용자 이름 (2자 이상)
+ */
+ @NotBlank(message = "이름은 필수입니다")
+ @Size(min = 2, max = 50, message = "이름은 2자 이상 50자 이하여야 합니다")
+ private String name;
+
+ /**
+ * 전화번호 (010XXXXXXXX 형식)
+ */
+ @NotBlank(message = "전화번호는 필수입니다")
+ @Pattern(regexp = "^010\\d{8}$", message = "전화번호는 010XXXXXXXX 형식이어야 합니다")
+ private String phoneNumber;
+
+ /**
+ * 이메일 주소
+ */
+ @NotBlank(message = "이메일은 필수입니다")
+ @Email(message = "이메일 형식이 올바르지 않습니다")
+ @Size(max = 100, message = "이메일은 100자 이하여야 합니다")
+ private String email;
+
+ /**
+ * 비밀번호 (8자 이상)
+ */
+ @NotBlank(message = "비밀번호는 필수입니다")
+ @Size(min = 8, max = 100, message = "비밀번호는 8자 이상 100자 이하여야 합니다")
+ private String password;
+
+ /**
+ * 매장명
+ */
+ @NotBlank(message = "매장명은 필수입니다")
+ @Size(max = 100, message = "매장명은 100자 이하여야 합니다")
+ private String storeName;
+
+ /**
+ * 업종
+ */
+ @Size(max = 50, message = "업종은 50자 이하여야 합니다")
+ private String industry;
+
+ /**
+ * 주소
+ */
+ @NotBlank(message = "주소는 필수입니다")
+ @Size(max = 255, message = "주소는 255자 이하여야 합니다")
+ private String address;
+
+ /**
+ * 영업시간
+ */
+ @Size(max = 255, message = "영업시간은 255자 이하여야 합니다")
+ private String businessHours;
+}
diff --git a/user-service/src/main/java/com/kt/event/user/dto/request/UpdateProfileRequest.java b/user-service/src/main/java/com/kt/event/user/dto/request/UpdateProfileRequest.java
new file mode 100644
index 0000000..6ca8ea9
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/dto/request/UpdateProfileRequest.java
@@ -0,0 +1,67 @@
+package com.kt.event.user.dto.request;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 프로필 수정 요청 DTO
+ *
+ * UFR-USER-030: 프로필 관리
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class UpdateProfileRequest {
+
+ /**
+ * 사용자 이름
+ */
+ @Size(min = 2, max = 50, message = "이름은 2자 이상 50자 이하여야 합니다")
+ private String name;
+
+ /**
+ * 전화번호 (010XXXXXXXX 형식)
+ */
+ @Pattern(regexp = "^010\\d{8}$", message = "전화번호는 010XXXXXXXX 형식이어야 합니다")
+ private String phoneNumber;
+
+ /**
+ * 이메일 주소
+ */
+ @Email(message = "이메일 형식이 올바르지 않습니다")
+ @Size(max = 100, message = "이메일은 100자 이하여야 합니다")
+ private String email;
+
+ /**
+ * 매장명
+ */
+ @Size(max = 100, message = "매장명은 100자 이하여야 합니다")
+ private String storeName;
+
+ /**
+ * 업종
+ */
+ @Size(max = 50, message = "업종은 50자 이하여야 합니다")
+ private String industry;
+
+ /**
+ * 주소
+ */
+ @Size(max = 255, message = "주소는 255자 이하여야 합니다")
+ private String address;
+
+ /**
+ * 영업시간
+ */
+ @Size(max = 255, message = "영업시간은 255자 이하여야 합니다")
+ private String businessHours;
+}
diff --git a/user-service/src/main/java/com/kt/event/user/dto/response/LoginResponse.java b/user-service/src/main/java/com/kt/event/user/dto/response/LoginResponse.java
new file mode 100644
index 0000000..9fc930b
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/dto/response/LoginResponse.java
@@ -0,0 +1,46 @@
+package com.kt.event.user.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 로그인 응답 DTO
+ *
+ * UFR-USER-020: 로그인
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class LoginResponse {
+
+ /**
+ * JWT 토큰
+ */
+ private String token;
+
+ /**
+ * 사용자 ID
+ */
+ private Long userId;
+
+ /**
+ * 사용자 이름
+ */
+ private String userName;
+
+ /**
+ * 역할
+ */
+ private String role;
+
+ /**
+ * 이메일
+ */
+ private String email;
+}
diff --git a/user-service/src/main/java/com/kt/event/user/dto/response/LogoutResponse.java b/user-service/src/main/java/com/kt/event/user/dto/response/LogoutResponse.java
new file mode 100644
index 0000000..cebfb57
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/dto/response/LogoutResponse.java
@@ -0,0 +1,31 @@
+package com.kt.event.user.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 로그아웃 응답 DTO
+ *
+ * UFR-USER-040: 로그아웃
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class LogoutResponse {
+
+ /**
+ * 성공 여부
+ */
+ private boolean success;
+
+ /**
+ * 메시지
+ */
+ private String message;
+}
diff --git a/user-service/src/main/java/com/kt/event/user/dto/response/ProfileResponse.java b/user-service/src/main/java/com/kt/event/user/dto/response/ProfileResponse.java
new file mode 100644
index 0000000..334e2cb
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/dto/response/ProfileResponse.java
@@ -0,0 +1,83 @@
+package com.kt.event.user.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 프로필 응답 DTO
+ *
+ * UFR-USER-030: 프로필 관리
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class ProfileResponse {
+
+ /**
+ * 사용자 ID
+ */
+ private Long userId;
+
+ /**
+ * 사용자 이름
+ */
+ private String userName;
+
+ /**
+ * 전화번호
+ */
+ private String phoneNumber;
+
+ /**
+ * 이메일
+ */
+ private String email;
+
+ /**
+ * 역할
+ */
+ private String role;
+
+ /**
+ * 매장 ID
+ */
+ private Long storeId;
+
+ /**
+ * 매장명
+ */
+ private String storeName;
+
+ /**
+ * 업종
+ */
+ private String industry;
+
+ /**
+ * 주소
+ */
+ private String address;
+
+ /**
+ * 영업시간
+ */
+ private String businessHours;
+
+ /**
+ * 생성일시
+ */
+ private LocalDateTime createdAt;
+
+ /**
+ * 최종 로그인 일시
+ */
+ private LocalDateTime lastLoginAt;
+}
diff --git a/user-service/src/main/java/com/kt/event/user/dto/response/RegisterResponse.java b/user-service/src/main/java/com/kt/event/user/dto/response/RegisterResponse.java
new file mode 100644
index 0000000..6f01cdd
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/dto/response/RegisterResponse.java
@@ -0,0 +1,46 @@
+package com.kt.event.user.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 회원가입 응답 DTO
+ *
+ * UFR-USER-010: 회원가입
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class RegisterResponse {
+
+ /**
+ * JWT 토큰
+ */
+ private String token;
+
+ /**
+ * 사용자 ID
+ */
+ private Long userId;
+
+ /**
+ * 사용자 이름
+ */
+ private String userName;
+
+ /**
+ * 매장 ID
+ */
+ private Long storeId;
+
+ /**
+ * 매장명
+ */
+ private String storeName;
+}
diff --git a/user-service/src/main/java/com/kt/event/user/entity/Store.java b/user-service/src/main/java/com/kt/event/user/entity/Store.java
new file mode 100644
index 0000000..75917db
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/entity/Store.java
@@ -0,0 +1,93 @@
+package com.kt.event.user.entity;
+
+import com.kt.event.common.entity.BaseTimeEntity;
+import jakarta.persistence.*;
+import lombok.*;
+
+/**
+ * 매장 엔티티
+ *
+ * 소상공인 매장 정보를 저장하는 엔티티
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Entity
+@Table(name = "stores")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class Store extends BaseTimeEntity {
+
+ /**
+ * 매장 ID (Primary Key)
+ */
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "store_id")
+ private Long id;
+
+ /**
+ * 매장명
+ */
+ @Column(name = "name", nullable = false, length = 100)
+ private String name;
+
+ /**
+ * 업종
+ */
+ @Column(name = "industry", length = 50)
+ private String industry;
+
+ /**
+ * 주소
+ */
+ @Column(name = "address", nullable = false, length = 255)
+ private String address;
+
+ /**
+ * 영업시간
+ */
+ @Column(name = "business_hours", length = 255)
+ private String businessHours;
+
+ /**
+ * 사용자 정보 (One-to-One)
+ */
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ /**
+ * 사용자 연결 (내부용)
+ *
+ * @param user 사용자
+ */
+ void setUser(User user) {
+ this.user = user;
+ }
+
+ /**
+ * 매장 정보 수정
+ *
+ * @param name 매장명
+ * @param industry 업종
+ * @param address 주소
+ * @param businessHours 영업시간
+ */
+ public void updateInfo(String name, String industry, String address, String businessHours) {
+ if (name != null) {
+ this.name = name;
+ }
+ if (industry != null) {
+ this.industry = industry;
+ }
+ if (address != null) {
+ this.address = address;
+ }
+ if (businessHours != null) {
+ this.businessHours = businessHours;
+ }
+ }
+}
diff --git a/user-service/src/main/java/com/kt/event/user/entity/User.java b/user-service/src/main/java/com/kt/event/user/entity/User.java
new file mode 100644
index 0000000..89ec86e
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/entity/User.java
@@ -0,0 +1,174 @@
+package com.kt.event.user.entity;
+
+import com.kt.event.common.entity.BaseTimeEntity;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+/**
+ * 사용자 엔티티
+ *
+ * 소상공인 사용자 정보를 저장하는 엔티티
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Entity
+@Table(name = "users", indexes = {
+ @Index(name = "idx_user_phone", columnList = "phone_number", unique = true),
+ @Index(name = "idx_user_email", columnList = "email", unique = true)
+})
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class User extends BaseTimeEntity {
+
+ /**
+ * 사용자 ID (Primary Key)
+ */
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "user_id")
+ private Long id;
+
+ /**
+ * 사용자 이름
+ */
+ @Column(name = "name", nullable = false, length = 50)
+ private String name;
+
+ /**
+ * 전화번호 (로그인 ID로도 사용)
+ */
+ @Column(name = "phone_number", nullable = false, unique = true, length = 20)
+ private String phoneNumber;
+
+ /**
+ * 이메일 주소
+ */
+ @Column(name = "email", nullable = false, unique = true, length = 100)
+ private String email;
+
+ /**
+ * 비밀번호 (bcrypt 해시)
+ */
+ @Column(name = "password_hash", nullable = false, length = 255)
+ private String passwordHash;
+
+ /**
+ * 사용자 역할 (기본값: OWNER)
+ */
+ @Enumerated(EnumType.STRING)
+ @Column(name = "role", nullable = false, length = 20)
+ @Builder.Default
+ private UserRole role = UserRole.OWNER;
+
+ /**
+ * 계정 상태 (기본값: ACTIVE)
+ */
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 20)
+ @Builder.Default
+ private UserStatus status = UserStatus.ACTIVE;
+
+ /**
+ * 최종 로그인 일시
+ */
+ @Column(name = "last_login_at")
+ private LocalDateTime lastLoginAt;
+
+ /**
+ * 매장 정보 (One-to-One)
+ */
+ @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
+ private Store store;
+
+ /**
+ * 최종 로그인 시각 업데이트
+ */
+ public void updateLastLoginAt() {
+ this.lastLoginAt = LocalDateTime.now();
+ }
+
+ /**
+ * 비밀번호 변경
+ *
+ * @param newPasswordHash 새 비밀번호 해시
+ */
+ public void changePassword(String newPasswordHash) {
+ this.passwordHash = newPasswordHash;
+ }
+
+ /**
+ * 프로필 정보 수정
+ *
+ * @param name 이름
+ * @param email 이메일
+ * @param phoneNumber 전화번호
+ */
+ public void updateProfile(String name, String email, String phoneNumber) {
+ if (name != null) {
+ this.name = name;
+ }
+ if (email != null) {
+ this.email = email;
+ }
+ if (phoneNumber != null) {
+ this.phoneNumber = phoneNumber;
+ }
+ }
+
+ /**
+ * 매장 정보 연결
+ *
+ * @param store 매장 정보
+ */
+ public void setStore(Store store) {
+ this.store = store;
+ if (store != null) {
+ store.setUser(this);
+ }
+ }
+
+ /**
+ * 사용자 역할 Enum
+ */
+ public enum UserRole {
+ /**
+ * 매장 소유주
+ */
+ OWNER,
+
+ /**
+ * 시스템 관리자
+ */
+ ADMIN
+ }
+
+ /**
+ * 사용자 계정 상태 Enum
+ */
+ public enum UserStatus {
+ /**
+ * 활성 상태
+ */
+ ACTIVE,
+
+ /**
+ * 비활성 상태
+ */
+ INACTIVE,
+
+ /**
+ * 잠금 상태 (보안상 이유)
+ */
+ LOCKED,
+
+ /**
+ * 탈퇴 상태
+ */
+ WITHDRAWN
+ }
+}
diff --git a/user-service/src/main/java/com/kt/event/user/exception/UserErrorCode.java b/user-service/src/main/java/com/kt/event/user/exception/UserErrorCode.java
new file mode 100644
index 0000000..3f82060
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/exception/UserErrorCode.java
@@ -0,0 +1,44 @@
+package com.kt.event.user.exception;
+
+import com.kt.event.common.exception.ErrorCode;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * User Service 에러 코드
+ *
+ * Common 모듈의 ErrorCode enum을 사용
+ * User Service에서 사용하는 에러 코드만 열거
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Getter
+@RequiredArgsConstructor
+public enum UserErrorCode {
+
+ // User 관련 에러 - Common ErrorCode 사용
+ USER_DUPLICATE_EMAIL(ErrorCode.USER_001),
+ USER_DUPLICATE_PHONE(ErrorCode.USER_001), // 중복 사용자로 처리
+ USER_NOT_FOUND(ErrorCode.USER_003),
+
+ // Authentication 관련 에러 - Common ErrorCode 사용
+ AUTH_FAILED(ErrorCode.AUTH_001),
+ AUTH_INVALID_TOKEN(ErrorCode.AUTH_002),
+ AUTH_TOKEN_EXPIRED(ErrorCode.AUTH_003),
+ AUTH_UNAUTHORIZED(ErrorCode.AUTH_001),
+
+ // Password 관련 에러 - Common ErrorCode 사용
+ PWD_INVALID_CURRENT(ErrorCode.USER_004),
+ PWD_SAME_AS_CURRENT(ErrorCode.USER_004);
+
+ private final ErrorCode errorCode;
+
+ public String getCode() {
+ return errorCode.getCode();
+ }
+
+ public String getMessage() {
+ return errorCode.getMessage();
+ }
+}
diff --git a/user-service/src/main/java/com/kt/event/user/repository/StoreRepository.java b/user-service/src/main/java/com/kt/event/user/repository/StoreRepository.java
new file mode 100644
index 0000000..dfab0ef
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/repository/StoreRepository.java
@@ -0,0 +1,27 @@
+package com.kt.event.user.repository;
+
+import com.kt.event.user.entity.Store;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+/**
+ * 매장 Repository
+ *
+ * 매장 데이터 액세스 인터페이스
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Repository
+public interface StoreRepository extends JpaRepository {
+
+ /**
+ * 사용자 ID로 매장 조회
+ *
+ * @param userId 사용자 ID
+ * @return 매장 Optional
+ */
+ Optional findByUserId(Long userId);
+}
diff --git a/user-service/src/main/java/com/kt/event/user/repository/UserRepository.java b/user-service/src/main/java/com/kt/event/user/repository/UserRepository.java
new file mode 100644
index 0000000..91b6606
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/repository/UserRepository.java
@@ -0,0 +1,65 @@
+package com.kt.event.user.repository;
+
+import com.kt.event.user.entity.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+/**
+ * 사용자 Repository
+ *
+ * 사용자 데이터 액세스 인터페이스
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Repository
+public interface UserRepository extends JpaRepository {
+
+ /**
+ * 이메일로 사용자 조회
+ *
+ * @param email 이메일
+ * @return 사용자 Optional
+ */
+ Optional findByEmail(String email);
+
+ /**
+ * 전화번호로 사용자 조회
+ *
+ * @param phoneNumber 전화번호
+ * @return 사용자 Optional
+ */
+ Optional findByPhoneNumber(String phoneNumber);
+
+ /**
+ * 이메일 존재 여부 확인
+ *
+ * @param email 이메일
+ * @return 존재 여부
+ */
+ boolean existsByEmail(String email);
+
+ /**
+ * 전화번호 존재 여부 확인
+ *
+ * @param phoneNumber 전화번호
+ * @return 존재 여부
+ */
+ boolean existsByPhoneNumber(String phoneNumber);
+
+ /**
+ * 최종 로그인 시각 업데이트
+ *
+ * @param userId 사용자 ID
+ * @param lastLoginAt 최종 로그인 시각
+ */
+ @Modifying
+ @Query("UPDATE User u SET u.lastLoginAt = :lastLoginAt WHERE u.id = :userId")
+ void updateLastLoginAt(@Param("userId") Long userId, @Param("lastLoginAt") LocalDateTime lastLoginAt);
+}
diff --git a/user-service/src/main/java/com/kt/event/user/service/AuthenticationService.java b/user-service/src/main/java/com/kt/event/user/service/AuthenticationService.java
new file mode 100644
index 0000000..f014196
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/service/AuthenticationService.java
@@ -0,0 +1,32 @@
+package com.kt.event.user.service;
+
+import com.kt.event.user.dto.request.LoginRequest;
+import com.kt.event.user.dto.response.LoginResponse;
+import com.kt.event.user.dto.response.LogoutResponse;
+
+/**
+ * Authentication Service Interface
+ *
+ * 인증 관련 비즈니스 로직 인터페이스
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+public interface AuthenticationService {
+
+ /**
+ * 로그인
+ *
+ * @param request 로그인 요청
+ * @return 로그인 응답
+ */
+ LoginResponse login(LoginRequest request);
+
+ /**
+ * 로그아웃
+ *
+ * @param token JWT 토큰
+ * @return 로그아웃 응답
+ */
+ LogoutResponse logout(String token);
+}
diff --git a/user-service/src/main/java/com/kt/event/user/service/UserService.java b/user-service/src/main/java/com/kt/event/user/service/UserService.java
new file mode 100644
index 0000000..da171a5
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/service/UserService.java
@@ -0,0 +1,58 @@
+package com.kt.event.user.service;
+
+import com.kt.event.user.dto.request.ChangePasswordRequest;
+import com.kt.event.user.dto.request.UpdateProfileRequest;
+import com.kt.event.user.dto.request.RegisterRequest;
+import com.kt.event.user.dto.response.ProfileResponse;
+import com.kt.event.user.dto.response.RegisterResponse;
+
+/**
+ * User Service Interface
+ *
+ * 사용자 관리 비즈니스 로직 인터페이스
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+public interface UserService {
+
+ /**
+ * 회원가입
+ *
+ * @param request 회원가입 요청
+ * @return 회원가입 응답
+ */
+ RegisterResponse register(RegisterRequest request);
+
+ /**
+ * 프로필 조회
+ *
+ * @param userId 사용자 ID
+ * @return 프로필 응답
+ */
+ ProfileResponse getProfile(Long userId);
+
+ /**
+ * 프로필 수정
+ *
+ * @param userId 사용자 ID
+ * @param request 프로필 수정 요청
+ * @return 프로필 응답
+ */
+ ProfileResponse updateProfile(Long userId, UpdateProfileRequest request);
+
+ /**
+ * 비밀번호 변경
+ *
+ * @param userId 사용자 ID
+ * @param request 비밀번호 변경 요청
+ */
+ void changePassword(Long userId, ChangePasswordRequest request);
+
+ /**
+ * 최종 로그인 시각 업데이트 (비동기)
+ *
+ * @param userId 사용자 ID
+ */
+ void updateLastLoginAt(Long userId);
+}
diff --git a/user-service/src/main/java/com/kt/event/user/service/impl/AuthenticationServiceImpl.java b/user-service/src/main/java/com/kt/event/user/service/impl/AuthenticationServiceImpl.java
new file mode 100644
index 0000000..8ccd04b
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/service/impl/AuthenticationServiceImpl.java
@@ -0,0 +1,147 @@
+package com.kt.event.user.service.impl;
+
+import com.kt.event.common.exception.BusinessException;
+import com.kt.event.common.security.JwtTokenProvider;
+import com.kt.event.user.dto.request.LoginRequest;
+import com.kt.event.user.dto.response.LoginResponse;
+import com.kt.event.user.dto.response.LogoutResponse;
+import com.kt.event.user.entity.User;
+import com.kt.event.user.exception.UserErrorCode;
+import com.kt.event.user.repository.UserRepository;
+import com.kt.event.user.service.AuthenticationService;
+import com.kt.event.user.service.UserService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Authentication Service 구현체
+ *
+ * 인증 관련 비즈니스 로직 구현
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Slf4j
+@Service
+@Transactional(readOnly = true)
+public class AuthenticationServiceImpl implements AuthenticationService {
+
+ private final UserRepository userRepository;
+ private final PasswordEncoder passwordEncoder;
+ private final JwtTokenProvider jwtTokenProvider;
+ private final UserService userService;
+
+ @Autowired(required = false)
+ private RedisTemplate redisTemplate;
+
+ public AuthenticationServiceImpl(UserRepository userRepository,
+ PasswordEncoder passwordEncoder,
+ JwtTokenProvider jwtTokenProvider,
+ UserService userService) {
+ this.userRepository = userRepository;
+ this.passwordEncoder = passwordEncoder;
+ this.jwtTokenProvider = jwtTokenProvider;
+ this.userService = userService;
+ }
+
+ /**
+ * 로그인
+ *
+ * UFR-USER-020: 로그인
+ */
+ @Override
+ @Transactional(readOnly = false)
+ public LoginResponse login(LoginRequest request) {
+ // 1. 사용자 조회 (이메일 기반)
+ User user = userRepository.findByEmail(request.getEmail())
+ .orElseThrow(() -> new BusinessException(UserErrorCode.AUTH_FAILED.getErrorCode()));
+
+ // 2. 비밀번호 검증
+ if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
+ throw new BusinessException(UserErrorCode.AUTH_FAILED.getErrorCode());
+ }
+
+ // 3. JWT 토큰 생성
+ String token = jwtTokenProvider.createAccessToken(
+ user.getId(),
+ user.getEmail(),
+ user.getName(),
+ List.of(user.getRole().name())
+ );
+
+ // 4. Redis 세션 저장 (TTL 7일)
+ saveSession(token, user.getId(), user.getRole().name());
+
+ // 5. 최종 로그인 시각 업데이트 (비동기)
+ userService.updateLastLoginAt(user.getId());
+
+ // 6. 응답 반환
+ return LoginResponse.builder()
+ .token(token)
+ .userId(user.getId())
+ .userName(user.getName())
+ .role(user.getRole().name())
+ .email(user.getEmail())
+ .build();
+ }
+
+ /**
+ * 로그아웃
+ *
+ * UFR-USER-040: 로그아웃
+ */
+ @Override
+ public LogoutResponse logout(String token) {
+ // 1. JWT 토큰 검증
+ if (!jwtTokenProvider.validateToken(token)) {
+ throw new BusinessException(UserErrorCode.AUTH_INVALID_TOKEN.getErrorCode());
+ }
+
+ // 2. Redis 세션 삭제 (Redis가 활성화된 경우에만)
+ if (redisTemplate != null) {
+ String sessionKey = "user:session:" + token;
+ redisTemplate.delete(sessionKey);
+
+ // 3. JWT Blacklist 추가 (남은 만료 시간만큼 TTL 설정)
+ String blacklistKey = "jwt:blacklist:" + token;
+ long remainingTime = jwtTokenProvider.getExpirationFromToken(token).getTime() - System.currentTimeMillis();
+ if (remainingTime > 0) {
+ redisTemplate.opsForValue().set(blacklistKey, "true", remainingTime, TimeUnit.MILLISECONDS);
+ }
+ log.debug("Redis session and blacklist updated for logout");
+ } else {
+ log.warn("Redis is disabled. Session not cleared from Redis.");
+ }
+
+ // 4. 응답 반환
+ return LogoutResponse.builder()
+ .success(true)
+ .message("안전하게 로그아웃되었습니다")
+ .build();
+ }
+
+ /**
+ * Redis 세션 저장 (Redis가 활성화된 경우에만)
+ *
+ * @param token JWT 토큰
+ * @param userId 사용자 ID
+ * @param role 역할
+ */
+ private void saveSession(String token, Long userId, String role) {
+ if (redisTemplate != null) {
+ String key = "user:session:" + token;
+ String value = userId + ":" + role;
+ redisTemplate.opsForValue().set(key, value, 7, TimeUnit.DAYS);
+ log.debug("Redis session saved: userId={}", userId);
+ } else {
+ log.warn("Redis is disabled. Session not saved to Redis.");
+ }
+ }
+}
diff --git a/user-service/src/main/java/com/kt/event/user/service/impl/UserServiceImpl.java b/user-service/src/main/java/com/kt/event/user/service/impl/UserServiceImpl.java
new file mode 100644
index 0000000..15ef003
--- /dev/null
+++ b/user-service/src/main/java/com/kt/event/user/service/impl/UserServiceImpl.java
@@ -0,0 +1,236 @@
+package com.kt.event.user.service.impl;
+
+import com.kt.event.common.exception.BusinessException;
+import com.kt.event.common.security.JwtTokenProvider;
+import com.kt.event.user.dto.request.ChangePasswordRequest;
+import com.kt.event.user.dto.request.RegisterRequest;
+import com.kt.event.user.dto.request.UpdateProfileRequest;
+import com.kt.event.user.dto.response.ProfileResponse;
+import com.kt.event.user.dto.response.RegisterResponse;
+import com.kt.event.user.entity.Store;
+import com.kt.event.user.entity.User;
+import com.kt.event.user.exception.UserErrorCode;
+import com.kt.event.user.repository.StoreRepository;
+import com.kt.event.user.repository.UserRepository;
+import com.kt.event.user.service.UserService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * User Service 구현체
+ *
+ * 사용자 관리 비즈니스 로직 구현
+ *
+ * @author Backend Developer
+ * @since 1.0
+ */
+@Slf4j
+@Service
+@Transactional(readOnly = true)
+public class UserServiceImpl implements UserService {
+
+ private final UserRepository userRepository;
+ private final StoreRepository storeRepository;
+ private final PasswordEncoder passwordEncoder;
+ private final JwtTokenProvider jwtTokenProvider;
+
+ @Autowired(required = false)
+ private RedisTemplate redisTemplate;
+
+ public UserServiceImpl(UserRepository userRepository,
+ StoreRepository storeRepository,
+ PasswordEncoder passwordEncoder,
+ JwtTokenProvider jwtTokenProvider) {
+ this.userRepository = userRepository;
+ this.storeRepository = storeRepository;
+ this.passwordEncoder = passwordEncoder;
+ this.jwtTokenProvider = jwtTokenProvider;
+ }
+
+ /**
+ * 회원가입
+ *
+ * UFR-USER-010: 회원가입
+ */
+ @Override
+ @Transactional
+ public RegisterResponse register(RegisterRequest request) {
+ // 1. 이메일 중복 확인
+ if (userRepository.existsByEmail(request.getEmail())) {
+ throw new BusinessException(UserErrorCode.USER_DUPLICATE_EMAIL.getErrorCode());
+ }
+
+ // 2. 전화번호 중복 확인
+ if (userRepository.existsByPhoneNumber(request.getPhoneNumber())) {
+ throw new BusinessException(UserErrorCode.USER_DUPLICATE_PHONE.getErrorCode());
+ }
+
+ // 3. 비밀번호 해싱
+ String passwordHash = passwordEncoder.encode(request.getPassword());
+
+ // 4. User 엔티티 생성 및 저장
+ User user = User.builder()
+ .name(request.getName())
+ .phoneNumber(request.getPhoneNumber())
+ .email(request.getEmail())
+ .passwordHash(passwordHash)
+ .role(User.UserRole.OWNER)
+ .status(User.UserStatus.ACTIVE)
+ .build();
+
+ User savedUser = userRepository.save(user);
+
+ // 5. Store 엔티티 생성 및 저장
+ Store store = Store.builder()
+ .name(request.getStoreName())
+ .industry(request.getIndustry())
+ .address(request.getAddress())
+ .businessHours(request.getBusinessHours())
+ .user(savedUser)
+ .build();
+
+ Store savedStore = storeRepository.save(store);
+
+ // 6. JWT 토큰 생성
+ String token = jwtTokenProvider.createAccessToken(
+ savedUser.getId(),
+ savedUser.getEmail(),
+ savedUser.getName(),
+ List.of(savedUser.getRole().name())
+ );
+
+ // 7. Redis 세션 저장 (TTL 7일)
+ saveSession(token, savedUser.getId(), savedUser.getRole().name());
+
+ // 8. 응답 반환
+ return RegisterResponse.builder()
+ .token(token)
+ .userId(savedUser.getId())
+ .userName(savedUser.getName())
+ .storeId(savedStore.getId())
+ .storeName(savedStore.getName())
+ .build();
+ }
+
+ /**
+ * 프로필 조회
+ *
+ * UFR-USER-030: 프로필 관리
+ */
+ @Override
+ public ProfileResponse getProfile(Long userId) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND.getErrorCode()));
+
+ Store store = storeRepository.findByUserId(userId)
+ .orElse(null);
+
+ return ProfileResponse.builder()
+ .userId(user.getId())
+ .userName(user.getName())
+ .phoneNumber(user.getPhoneNumber())
+ .email(user.getEmail())
+ .role(user.getRole().name())
+ .storeId(store != null ? store.getId() : null)
+ .storeName(store != null ? store.getName() : null)
+ .industry(store != null ? store.getIndustry() : null)
+ .address(store != null ? store.getAddress() : null)
+ .businessHours(store != null ? store.getBusinessHours() : null)
+ .createdAt(user.getCreatedAt())
+ .lastLoginAt(user.getLastLoginAt())
+ .build();
+ }
+
+ /**
+ * 프로필 수정
+ *
+ * UFR-USER-030: 프로필 관리
+ */
+ @Override
+ @Transactional
+ public ProfileResponse updateProfile(Long userId, UpdateProfileRequest request) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND.getErrorCode()));
+
+ // User 정보 수정
+ user.updateProfile(request.getName(), request.getEmail(), request.getPhoneNumber());
+
+ // Store 정보 수정
+ Store store = storeRepository.findByUserId(userId).orElse(null);
+ if (store != null) {
+ store.updateInfo(
+ request.getStoreName(),
+ request.getIndustry(),
+ request.getAddress(),
+ request.getBusinessHours()
+ );
+ }
+
+ return getProfile(userId);
+ }
+
+ /**
+ * 비밀번호 변경
+ *
+ * UFR-USER-030: 프로필 관리
+ */
+ @Override
+ @Transactional
+ public void changePassword(Long userId, ChangePasswordRequest request) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND.getErrorCode()));
+
+ // 현재 비밀번호 검증
+ if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPasswordHash())) {
+ throw new BusinessException(UserErrorCode.PWD_INVALID_CURRENT.getErrorCode());
+ }
+
+ // 새 비밀번호가 현재 비밀번호와 동일한지 확인
+ if (passwordEncoder.matches(request.getNewPassword(), user.getPasswordHash())) {
+ throw new BusinessException(UserErrorCode.PWD_SAME_AS_CURRENT.getErrorCode());
+ }
+
+ // 새 비밀번호 해싱 및 저장
+ String newPasswordHash = passwordEncoder.encode(request.getNewPassword());
+ user.changePassword(newPasswordHash);
+ }
+
+ /**
+ * 최종 로그인 시각 업데이트 (비동기)
+ *
+ * UFR-USER-020: 로그인
+ */
+ @Override
+ @Async
+ @Transactional
+ public void updateLastLoginAt(Long userId) {
+ userRepository.updateLastLoginAt(userId, LocalDateTime.now());
+ }
+
+ /**
+ * Redis 세션 저장 (Redis가 활성화된 경우에만)
+ *
+ * @param token JWT 토큰
+ * @param userId 사용자 ID
+ * @param role 역할
+ */
+ private void saveSession(String token, Long userId, String role) {
+ if (redisTemplate != null) {
+ String key = "user:session:" + token;
+ String value = userId + ":" + role;
+ redisTemplate.opsForValue().set(key, value, 7, TimeUnit.DAYS);
+ log.debug("Redis session saved: userId={}", userId);
+ } else {
+ log.warn("Redis is disabled. Session not saved to Redis.");
+ }
+ }
+}
diff --git a/user-service/src/main/resources/application.yml b/user-service/src/main/resources/application.yml
new file mode 100644
index 0000000..4637dd2
--- /dev/null
+++ b/user-service/src/main/resources/application.yml
@@ -0,0 +1,123 @@
+spring:
+ application:
+ name: user-service
+
+ # Database Configuration (PostgreSQL)
+ datasource:
+ url: ${DB_URL:jdbc:postgresql://20.249.125.115:5432/userdb}
+ username: ${DB_USERNAME:eventuser}
+ password: ${DB_PASSWORD:Hi5Jessica!}
+ driver-class-name: ${DB_DRIVER:org.postgresql.Driver}
+ hikari:
+ maximum-pool-size: ${DB_POOL_MAX:20}
+ minimum-idle: ${DB_POOL_MIN:5}
+ connection-timeout: ${DB_CONN_TIMEOUT:30000}
+ idle-timeout: ${DB_IDLE_TIMEOUT:600000}
+ max-lifetime: ${DB_MAX_LIFETIME:1800000}
+ leak-detection-threshold: ${DB_LEAK_THRESHOLD:60000}
+
+ # H2 Console (개발용 - PostgreSQL 사용 시 비활성화)
+ h2:
+ console:
+ enabled: ${H2_CONSOLE_ENABLED:false}
+ path: /h2-console
+
+ # JPA Configuration
+ jpa:
+ show-sql: ${SHOW_SQL:true}
+ properties:
+ hibernate:
+ format_sql: true
+ use_sql_comments: true
+ dialect: ${JPA_DIALECT:org.hibernate.dialect.PostgreSQLDialect}
+ hibernate:
+ ddl-auto: ${DDL_AUTO:update}
+
+ # Auto-configuration exclusions for development without external services
+ autoconfigure:
+ exclude:
+ - ${EXCLUDE_KAFKA:}
+ - ${EXCLUDE_REDIS:}
+
+ # Redis Configuration
+ data:
+ redis:
+ enabled: ${REDIS_ENABLED:true}
+ host: ${REDIS_HOST:20.214.210.71}
+ port: ${REDIS_PORT:6379}
+ password: ${REDIS_PASSWORD:Hi5Jessica!}
+ timeout: ${REDIS_TIMEOUT:2000ms}
+ lettuce:
+ pool:
+ max-active: ${REDIS_POOL_MAX:8}
+ max-idle: ${REDIS_POOL_IDLE:8}
+ min-idle: ${REDIS_POOL_MIN:0}
+ max-wait: ${REDIS_POOL_WAIT:-1ms}
+ database: ${REDIS_DATABASE:0}
+
+ # Kafka Configuration
+ kafka:
+ bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:4.230.50.63:9092}
+ consumer:
+ group-id: ${KAFKA_CONSUMER_GROUP:user-service-consumers}
+ auto-offset-reset: earliest
+ key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
+ properties:
+ spring.json.trusted.packages: "*"
+ producer:
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
+
+# JWT Configuration
+jwt:
+ secret: ${JWT_SECRET:kt-event-marketing-secret-key-for-development-only-please-change-in-production}
+ access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:604800000} # 7 days in milliseconds
+
+# 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:
+ com.kt.event.user: ${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/user-service.log}
+
+# Server Configuration
+server:
+ port: ${SERVER_PORT:8081}