This commit is contained in:
hiondal
2025-09-09 01:12:14 +09:00
parent 7ec8a682c6
commit b489c73201
276 changed files with 43859 additions and 98 deletions
+37
View File
@@ -0,0 +1,37 @@
// Common 모듈: 일반 jar 생성
// java-library 플러그인 추가로 api/implementation 사용 가능
apply plugin: 'java-library'
// Spring Boot BOM 추가 (의존성 관리를 위해)
dependencyManagement {
imports {
mavenBom "org.springframework.boot:spring-boot-dependencies:3.3.0"
}
}
jar {
enabled = true
archiveClassifier = ''
}
dependencies {
// Spring Boot Starters
api 'org.springframework.boot:spring-boot-starter-web'
api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-data-redis'
api 'org.springframework.boot:spring-boot-starter-security'
api 'org.springframework.boot:spring-boot-starter-validation'
// JWT 라이브러리
api "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
// MapStruct
api "org.mapstruct:mapstruct:${mapstructVersion}"
annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
// Jackson
api 'com.fasterxml.jackson.core:jackson-databind'
api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
}
@@ -0,0 +1,59 @@
package com.phonebill.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.springframework.stereotype.Component;
/**
* 로깅 AOP
* 메소드 실행 시간과 파라미터를 로깅합니다.
*/
@Slf4j
@Aspect
@Component
public class LoggingAspect {
@Around("execution(* com.phonebill..service..*(..))")
public Object logServiceMethods(ProceedingJoinPoint joinPoint) throws Throwable {
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
log.info("[SERVICE] {}.{}() called with args: {}", className, methodName, args);
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
long executionTime = System.currentTimeMillis() - startTime;
log.info("[SERVICE] {}.{}() completed in {}ms", className, methodName, executionTime);
return result;
} catch (Exception e) {
long executionTime = System.currentTimeMillis() - startTime;
log.error("[SERVICE] {}.{}() failed in {}ms with error: {}", className, methodName, executionTime, e.getMessage());
throw e;
}
}
@Around("execution(* com.phonebill..controller..*(..))")
public Object logControllerMethods(ProceedingJoinPoint joinPoint) throws Throwable {
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
log.info("[CONTROLLER] {}.{}() called with args: {}", className, methodName, args);
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
long executionTime = System.currentTimeMillis() - startTime;
log.info("[CONTROLLER] {}.{}() completed in {}ms", className, methodName, executionTime);
return result;
} catch (Exception e) {
long executionTime = System.currentTimeMillis() - startTime;
log.error("[CONTROLLER] {}.{}() failed in {}ms with error: {}", className, methodName, executionTime, e.getMessage());
throw e;
}
}
}
@@ -0,0 +1,15 @@
package com.phonebill.common.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* JPA 설정
* JPA Auditing과 Repository 설정을 제공합니다.
*/
@Configuration
@EnableJpaAuditing
@EnableJpaRepositories(basePackages = "com.phonebill")
public class JpaConfig {
}
@@ -0,0 +1,76 @@
package com.phonebill.common.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* 표준 API 응답 DTO
* 모든 API 응답의 일관성을 보장하기 위한 공통 응답 구조
*/
@Getter
@Setter
@NoArgsConstructor
public class ApiResponse<T> {
/**
* 응답 성공 여부
*/
private boolean success;
/**
* 응답 메시지
*/
private String message;
/**
* 응답 데이터
*/
private T data;
/**
* 오류 코드 (실패시)
*/
private String errorCode;
/**
* 타임스탬프
*/
private long timestamp;
private ApiResponse(boolean success, String message, T data, String errorCode) {
this.success = success;
this.message = message;
this.data = data;
this.errorCode = errorCode;
this.timestamp = System.currentTimeMillis();
}
/**
* 성공 응답 생성
*/
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, "Success", data, null);
}
/**
* 성공 응답 생성 (메시지 포함)
*/
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(true, message, data, null);
}
/**
* 실패 응답 생성
*/
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, message, null, null);
}
/**
* 실패 응답 생성 (오류 코드 포함)
*/
public static <T> ApiResponse<T> error(String message, String errorCode) {
return new ApiResponse<>(false, message, null, errorCode);
}
}
@@ -0,0 +1,52 @@
package com.phonebill.common.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 오류 응답 구조
* API 오류 발생 시 표준화된 응답 형식을 제공합니다.
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponse {
private String code;
private String message;
private String detail;
private LocalDateTime timestamp;
private String path;
public static ErrorResponse of(String code, String message) {
return ErrorResponse.builder()
.code(code)
.message(message)
.timestamp(LocalDateTime.now())
.build();
}
public static ErrorResponse of(String code, String message, String detail) {
return ErrorResponse.builder()
.code(code)
.message(message)
.detail(detail)
.timestamp(LocalDateTime.now())
.build();
}
public static ErrorResponse of(String code, String message, String detail, String path) {
return ErrorResponse.builder()
.code(code)
.message(message)
.detail(detail)
.timestamp(LocalDateTime.now())
.path(path)
.build();
}
}
@@ -0,0 +1,44 @@
package com.phonebill.common.dto;
import jakarta.validation.constraints.Min;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* 페이징 요청 DTO
* 목록 조회시 페이징 처리를 위한 공통 요청 구조
*/
@Getter
@Setter
@NoArgsConstructor
public class PageableRequest {
/**
* 페이지 번호 (0부터 시작)
*/
@Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.")
private int page = 0;
/**
* 페이지 크기
*/
@Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.")
private int size = 20;
/**
* 정렬 기준 (예: "id,desc" 또는 "name,asc")
*/
private String sort;
public PageableRequest(int page, int size) {
this.page = page;
this.size = size;
}
public PageableRequest(int page, int size, String sort) {
this.page = page;
this.size = size;
this.sort = sort;
}
}
@@ -0,0 +1,75 @@
package com.phonebill.common.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.List;
/**
* 페이징 응답 DTO
* 목록 조회 결과의 페이징 정보를 포함하는 공통 응답 구조
*/
@Getter
@Setter
@NoArgsConstructor
public class PageableResponse<T> {
/**
* 실제 데이터 목록
*/
private List<T> content;
/**
* 현재 페이지 번호 (0부터 시작)
*/
private int page;
/**
* 페이지 크기
*/
private int size;
/**
* 전체 요소 개수
*/
private long totalElements;
/**
* 전체 페이지 수
*/
private int totalPages;
/**
* 첫 번째 페이지 여부
*/
private boolean first;
/**
* 마지막 페이지 여부
*/
private boolean last;
/**
* 정렬 기준
*/
private String sort;
public PageableResponse(List<T> content, int page, int size, long totalElements, String sort) {
this.content = content;
this.page = page;
this.size = size;
this.totalElements = totalElements;
this.totalPages = (int) Math.ceil((double) totalElements / size);
this.first = page == 0;
this.last = page >= totalPages - 1;
this.sort = sort;
}
/**
* 페이징 응답 생성
*/
public static <T> PageableResponse<T> of(List<T> content, PageableRequest request, long totalElements) {
return new PageableResponse<>(content, request.getPage(), request.getSize(), totalElements, request.getSort());
}
}
@@ -0,0 +1,29 @@
package com.phonebill.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;
/**
* 기본 엔티티 클래스
* 생성일시, 수정일시를 자동으로 관리하는 JPA Auditing 기능을 제공합니다.
*/
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,51 @@
package com.phonebill.common.exception;
import lombok.Getter;
/**
* 비즈니스 로직 처리 중 발생하는 예외
* 일반적인 업무 처리 과정에서 예상되는 오류 상황을 나타냄
*/
@Getter
public class BusinessException extends RuntimeException {
/**
* 오류 코드
*/
private final String errorCode;
/**
* HTTP 상태 코드
*/
private final int httpStatus;
public BusinessException(String message) {
super(message);
this.errorCode = "BUSINESS_ERROR";
this.httpStatus = 400; // Bad Request
}
public BusinessException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
this.httpStatus = 400; // Bad Request
}
public BusinessException(String message, String errorCode, int httpStatus) {
super(message);
this.errorCode = errorCode;
this.httpStatus = httpStatus;
}
public BusinessException(String message, String errorCode, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
this.httpStatus = 400; // Bad Request
}
public BusinessException(String message, String errorCode, int httpStatus, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
this.httpStatus = httpStatus;
}
}
@@ -0,0 +1,72 @@
package com.phonebill.common.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 오류 코드 열거형
* 시스템 전체에서 사용되는 표준화된 오류 코드를 정의합니다.
*/
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
// 공통 오류
INTERNAL_SERVER_ERROR("E0001", "내부 서버 오류가 발생했습니다."),
INVALID_INPUT_VALUE("E0002", "입력값이 올바르지 않습니다."),
METHOD_NOT_ALLOWED("E0003", "허용되지 않은 HTTP 메소드입니다."),
ENTITY_NOT_FOUND("E0004", "요청한 리소스를 찾을 수 없습니다."),
INVALID_TYPE_VALUE("E0005", "잘못된 타입의 값입니다."),
HANDLE_ACCESS_DENIED("E0006", "접근이 거부되었습니다."),
// 인증/인가 오류
UNAUTHORIZED("E1001", "인증이 필요합니다."),
FORBIDDEN("E1002", "권한이 없습니다."),
INVALID_TOKEN("E1003", "유효하지 않은 토큰입니다."),
TOKEN_EXPIRED("E1004", "토큰이 만료되었습니다."),
LOGIN_REQUIRED("E1005", "로그인이 필요합니다."),
ACCOUNT_LOCKED("E1006", "계정이 잠겨있습니다."),
INVALID_CREDENTIALS("E1007", "잘못된 인증 정보입니다."),
// 비즈니스 오류
BUSINESS_ERROR("E2001", "비즈니스 로직 오류가 발생했습니다."),
VALIDATION_ERROR("E2002", "검증 오류가 발생했습니다."),
DUPLICATE_RESOURCE("E2003", "중복된 리소스입니다."),
RESOURCE_NOT_FOUND("E2004", "요청한 리소스를 찾을 수 없습니다."),
OPERATION_NOT_ALLOWED("E2005", "허용되지 않은 작업입니다."),
// 외부 시스템 연동 오류
EXTERNAL_SYSTEM_ERROR("E3001", "외부 시스템 연동 오류가 발생했습니다."),
CIRCUIT_BREAKER_OPEN("E3002", "외부 시스템이 일시적으로 사용할 수 없습니다."),
TIMEOUT_ERROR("E3003", "요청 시간이 초과되었습니다."),
CONNECTION_ERROR("E3004", "연결 오류가 발생했습니다."),
// 데이터베이스 오류
DATABASE_ERROR("E4001", "데이터베이스 오류가 발생했습니다."),
CONSTRAINT_VIOLATION("E4002", "데이터 제약 조건 위반이 발생했습니다."),
TRANSACTION_ERROR("E4003", "트랜잭션 오류가 발생했습니다."),
// 캐시 오류
CACHE_ERROR("E5001", "캐시 오류가 발생했습니다."),
CACHE_NOT_FOUND("E5002", "캐시에서 데이터를 찾을 수 없습니다."),
// 요금조회 관련 오류
BILL_INQUIRY_ERROR("E6001", "요금조회 중 오류가 발생했습니다."),
BILL_NOT_FOUND("E6002", "요금 정보를 찾을 수 없습니다."),
BILL_INQUIRY_FAILED("E6003", "요금조회에 실패했습니다."),
// 상품변경 관련 오류
PRODUCT_CHANGE_ERROR("E7001", "상품변경 중 오류가 발생했습니다."),
PRODUCT_NOT_FOUND("E7002", "상품 정보를 찾을 수 없습니다."),
PRODUCT_VALIDATION_ERROR("E7003", "상품변경 검증에 실패했습니다."),
PRODUCT_CHANGE_FAILED("E7004", "상품변경에 실패했습니다."),
// KOS 연동 오류
KOS_CONNECTION_ERROR("E8001", "KOS 시스템 연결 오류가 발생했습니다."),
KOS_RESPONSE_ERROR("E8002", "KOS 시스템 응답 오류가 발생했습니다."),
KOS_TIMEOUT_ERROR("E8003", "KOS 시스템 응답 시간 초과가 발생했습니다."),
KOS_SERVICE_UNAVAILABLE("E8004", "KOS 시스템이 일시적으로 사용할 수 없습니다.");
private final String code;
private final String message;
}
@@ -0,0 +1,83 @@
package com.phonebill.common.exception;
import com.phonebill.common.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
/**
* 전역 예외 처리기
* 모든 컨트롤러에서 발생하는 예외를 일관된 형태로 처리
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 비즈니스 예외 처리
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
log.warn("Business exception occurred: {}", ex.getMessage());
ApiResponse<Void> response = ApiResponse.error(ex.getMessage(), ex.getErrorCode());
return ResponseEntity.status(ex.getHttpStatus()).body(response);
}
/**
* 유효성 검증 실패 예외 처리
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidationException(MethodArgumentNotValidException ex) {
log.warn("Validation exception occurred: {}", ex.getMessage());
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error -> {
errors.put(error.getField(), error.getDefaultMessage());
});
ApiResponse<Map<String, String>> response = ApiResponse.error("입력값이 올바르지 않습니다.", "VALIDATION_ERROR");
response.setData(errors);
return ResponseEntity.badRequest().body(response);
}
/**
* 일반적인 예외 처리
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception ex) {
log.error("Unexpected exception occurred", ex);
ApiResponse<Void> response = ApiResponse.error("서버 내부 오류가 발생했습니다.", "INTERNAL_SERVER_ERROR");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
/**
* IllegalArgumentException 처리
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalArgumentException(IllegalArgumentException ex) {
log.warn("Illegal argument exception occurred: {}", ex.getMessage());
ApiResponse<Void> response = ApiResponse.error(ex.getMessage(), "INVALID_ARGUMENT");
return ResponseEntity.badRequest().body(response);
}
/**
* RuntimeException 처리
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiResponse<Void>> handleRuntimeException(RuntimeException ex) {
log.error("Runtime exception occurred", ex);
ApiResponse<Void> response = ApiResponse.error("처리 중 오류가 발생했습니다.", "RUNTIME_ERROR");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
@@ -0,0 +1,34 @@
package com.phonebill.common.exception;
/**
* 인프라 예외
* 데이터베이스, 캐시, 외부 시스템 연동 등 인프라 관련 오류를 나타냅니다.
*/
public class InfraException extends RuntimeException {
private final ErrorCode errorCode;
public InfraException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public InfraException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public InfraException(ErrorCode errorCode, Throwable cause) {
super(errorCode.getMessage(), cause);
this.errorCode = errorCode;
}
public InfraException(ErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
@@ -0,0 +1,20 @@
package com.phonebill.common.exception;
/**
* 리소스를 찾을 수 없는 경우 발생하는 예외
* 사용자, 요금제, 청구서 등의 데이터가 존재하지 않을 때 사용
*/
public class ResourceNotFoundException extends BusinessException {
public ResourceNotFoundException(String message) {
super(message, "RESOURCE_NOT_FOUND", 404);
}
public ResourceNotFoundException(String resourceType, Object id) {
super(String.format("%s를 찾을 수 없습니다. ID: %s", resourceType, id), "RESOURCE_NOT_FOUND", 404);
}
public ResourceNotFoundException(String message, Throwable cause) {
super(message, "RESOURCE_NOT_FOUND", 404, cause);
}
}
@@ -0,0 +1,20 @@
package com.phonebill.common.exception;
/**
* 인증되지 않은 요청에 대한 예외
* JWT 토큰이 유효하지 않거나 만료된 경우 발생
*/
public class UnauthorizedException extends BusinessException {
public UnauthorizedException(String message) {
super(message, "UNAUTHORIZED", 401);
}
public UnauthorizedException() {
super("인증이 필요합니다.", "UNAUTHORIZED", 401);
}
public UnauthorizedException(String message, Throwable cause) {
super(message, "UNAUTHORIZED", 401, cause);
}
}
@@ -0,0 +1,20 @@
package com.phonebill.common.exception;
/**
* 데이터 검증 실패시 발생하는 예외
* 입력 데이터가 비즈니스 규칙에 맞지 않을 때 사용
*/
public class ValidationException extends BusinessException {
public ValidationException(String message) {
super(message, "VALIDATION_ERROR", 400);
}
public ValidationException(String field, String message) {
super(String.format("%s: %s", field, message), "VALIDATION_ERROR", 400);
}
public ValidationException(String message, Throwable cause) {
super(message, "VALIDATION_ERROR", 400, cause);
}
}
@@ -0,0 +1,86 @@
package com.phonebill.common.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
/**
* 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");
}
}
@@ -0,0 +1,144 @@
package com.phonebill.common.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
/**
* JWT 토큰 제공자
* JWT 토큰의 생성, 검증, 파싱을 담당
*/
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long tokenValidityInMilliseconds;
public JwtTokenProvider(@Value("${security.jwt.secret:}") String secret,
@Value("${security.jwt.access-token-expiration:3600}") long tokenValidityInSeconds) {
if (StringUtils.hasText(secret)) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
} else {
// 개발용 기본 시크릿 키 (32바이트 이상)
this.secretKey = Keys.hmacShaKeyFor("phonebill-default-secret-key-for-development-only".getBytes(StandardCharsets.UTF_8));
log.warn("JWT secret key not provided, using default development key");
}
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()
.verifyWith(secretKey)
.build()
.parseSignedClaims(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()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
return claims.getSubject();
}
/**
* JWT 토큰에서 사용자명 추출
*/
public String getUsername(String token) {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
return claims.get("username", String.class);
}
/**
* JWT 토큰에서 권한 정보 추출
*/
public String getAuthority(String token) {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
return claims.get("authority", String.class);
}
/**
* 토큰 만료 시간 확인
*/
public boolean isTokenExpired(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
return claims.getExpiration().before(new Date());
} catch (Exception e) {
return true;
}
}
/**
* 토큰에서 만료 시간 추출
*/
public Date getExpirationDate(String token) {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
return claims.getExpiration();
}
}
@@ -0,0 +1,51 @@
package com.phonebill.common.security;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 인증된 사용자 정보
* 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;
}
}
@@ -0,0 +1,92 @@
package com.phonebill.common.util;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 날짜/시간 관련 유틸리티
* 날짜 포맷팅, 파싱 등의 공통 기능을 제공
*/
public class DateTimeUtils {
/**
* 표준 날짜/시간 포맷터
*/
public static final DateTimeFormatter STANDARD_DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 날짜 포맷터
*/
public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/**
* 시간 포맷터
*/
public static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
/**
* ISO 8601 포맷터
*/
public static final DateTimeFormatter ISO_DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
/**
* LocalDateTime을 문자열로 변환
*/
public static String format(LocalDateTime dateTime) {
if (dateTime == null) {
return null;
}
return dateTime.format(STANDARD_DATETIME_FORMATTER);
}
/**
* LocalDateTime을 지정된 포맷으로 변환
*/
public static String format(LocalDateTime dateTime, DateTimeFormatter formatter) {
if (dateTime == null) {
return null;
}
return dateTime.format(formatter);
}
/**
* 문자열을 LocalDateTime으로 파싱
*/
public static LocalDateTime parse(String dateTimeString) {
if (dateTimeString == null || dateTimeString.trim().isEmpty()) {
return null;
}
return LocalDateTime.parse(dateTimeString, STANDARD_DATETIME_FORMATTER);
}
/**
* 문자열을 지정된 포맷으로 LocalDateTime으로 파싱
*/
public static LocalDateTime parse(String dateTimeString, DateTimeFormatter formatter) {
if (dateTimeString == null || dateTimeString.trim().isEmpty()) {
return null;
}
return LocalDateTime.parse(dateTimeString, formatter);
}
/**
* 현재 날짜/시간을 표준 포맷으로 반환
*/
public static String getCurrentDateTime() {
return LocalDateTime.now().format(STANDARD_DATETIME_FORMATTER);
}
/**
* 현재 날짜를 반환
*/
public static String getCurrentDate() {
return LocalDateTime.now().format(DATE_FORMATTER);
}
/**
* 현재 시간을 반환
*/
public static String getCurrentTime() {
return LocalDateTime.now().format(TIME_FORMATTER);
}
}
@@ -0,0 +1,108 @@
package com.phonebill.common.util;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
/**
* 날짜 유틸리티
* 날짜 관련 공통 기능을 제공합니다.
*/
public class DateUtil {
public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
public static final String DEFAULT_DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static final String DEFAULT_TIMESTAMP_FORMAT = "yyyy-MM-dd HH:mm:ss.SSS";
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT);
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern(DEFAULT_DATETIME_FORMAT);
private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern(DEFAULT_TIMESTAMP_FORMAT);
/**
* 현재 날짜를 문자열로 반환
*/
public static String getCurrentDateString() {
return LocalDate.now().format(DATE_FORMATTER);
}
/**
* 현재 날짜시간을 문자열로 반환
*/
public static String getCurrentDateTimeString() {
return LocalDateTime.now().format(DATETIME_FORMATTER);
}
/**
* 현재 타임스탬프를 문자열로 반환
*/
public static String getCurrentTimestampString() {
return LocalDateTime.now().format(TIMESTAMP_FORMATTER);
}
/**
* LocalDate를 문자열로 변환
*/
public static String formatDate(LocalDate date) {
return date != null ? date.format(DATE_FORMATTER) : null;
}
/**
* LocalDateTime을 문자열로 변환
*/
public static String formatDateTime(LocalDateTime dateTime) {
return dateTime != null ? dateTime.format(DATETIME_FORMATTER) : null;
}
/**
* 문자열을 LocalDate로 변환
*/
public static LocalDate parseDate(String dateString) {
if (dateString == null || dateString.trim().isEmpty()) {
return null;
}
try {
return LocalDate.parse(dateString, DATE_FORMATTER);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid date format: " + dateString, e);
}
}
/**
* 문자열을 LocalDateTime으로 변환
*/
public static LocalDateTime parseDateTime(String dateTimeString) {
if (dateTimeString == null || dateTimeString.trim().isEmpty()) {
return null;
}
try {
return LocalDateTime.parse(dateTimeString, DATETIME_FORMATTER);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid datetime format: " + dateTimeString, e);
}
}
/**
* 날짜 유효성 검사
*/
public static boolean isValidDate(String dateString) {
try {
parseDate(dateString);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
/**
* 날짜시간 유효성 검사
*/
public static boolean isValidDateTime(String dateTimeString) {
try {
parseDateTime(dateTimeString);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
}
@@ -0,0 +1,74 @@
package com.phonebill.common.util;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Optional;
/**
* 보안 유틸리티
* Spring Security 관련 공통 기능을 제공합니다.
*/
public class SecurityUtil {
/**
* 현재 인증된 사용자 ID를 반환
*/
public static Optional<String> getCurrentUserId() {
return getCurrentUserDetails()
.map(UserDetails::getUsername);
}
/**
* 현재 인증된 사용자 정보를 반환
*/
public static Optional<UserDetails> getCurrentUserDetails() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return Optional.empty();
}
Object principal = authentication.getPrincipal();
if (principal instanceof UserDetails) {
return Optional.of((UserDetails) principal);
}
return Optional.empty();
}
/**
* 현재 인증된 사용자의 권한을 확인
*/
public static boolean hasAuthority(String authority) {
return getCurrentUserDetails()
.map(user -> user.getAuthorities().stream()
.anyMatch(grantedAuthority -> grantedAuthority.getAuthority().equals(authority)))
.orElse(false);
}
/**
* 현재 인증된 사용자가 특정 역할을 가지고 있는지 확인
*/
public static boolean hasRole(String role) {
return hasAuthority("ROLE_" + role);
}
/**
* 현재 인증된 사용자가 인증되었는지 확인
*/
public static boolean isAuthenticated() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication != null && authentication.isAuthenticated()
&& !"anonymousUser".equals(authentication.getPrincipal());
}
/**
* 현재 인증된 사용자의 인증 정보를 반환
*/
public static Optional<Authentication> getCurrentAuthentication() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return Optional.ofNullable(authentication);
}
}
@@ -0,0 +1,117 @@
package com.phonebill.common.util;
import java.util.regex.Pattern;
/**
* 검증 유틸리티
* 입력값 검증 관련 공통 기능을 제공합니다.
*/
public class ValidatorUtil {
// 전화번호 패턴 (010-1234-5678, 01012345678)
private static final Pattern PHONE_PATTERN = Pattern.compile("^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$");
// 이메일 패턴
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
// 사용자 ID 패턴 (영문, 숫자, 3-20자)
private static final Pattern USER_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]{3,20}$");
// 비밀번호 패턴 (영문, 숫자, 특수문자 포함 8-20자)
private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]).{8,20}$");
/**
* 전화번호 형식 검증
*/
public static boolean isValidPhoneNumber(String phoneNumber) {
if (phoneNumber == null || phoneNumber.trim().isEmpty()) {
return false;
}
return PHONE_PATTERN.matcher(phoneNumber.trim()).matches();
}
/**
* 이메일 형식 검증
*/
public static boolean isValidEmail(String email) {
if (email == null || email.trim().isEmpty()) {
return false;
}
return EMAIL_PATTERN.matcher(email.trim()).matches();
}
/**
* 사용자 ID 형식 검증
*/
public static boolean isValidUserId(String userId) {
if (userId == null || userId.trim().isEmpty()) {
return false;
}
return USER_ID_PATTERN.matcher(userId.trim()).matches();
}
/**
* 비밀번호 형식 검증
*/
public static boolean isValidPassword(String password) {
if (password == null || password.trim().isEmpty()) {
return false;
}
return PASSWORD_PATTERN.matcher(password).matches();
}
/**
* 문자열이 null이거나 비어있는지 검증
*/
public static boolean isNullOrEmpty(String str) {
return str == null || str.trim().isEmpty();
}
/**
* 문자열이 null이거나 비어있지 않은지 검증
*/
public static boolean isNotNullOrEmpty(String str) {
return !isNullOrEmpty(str);
}
/**
* 문자열 길이 검증
*/
public static boolean isValidLength(String str, int minLength, int maxLength) {
if (str == null) {
return minLength == 0;
}
int length = str.length();
return length >= minLength && length <= maxLength;
}
/**
* 숫자 문자열 검증
*/
public static boolean isNumeric(String str) {
if (str == null || str.trim().isEmpty()) {
return false;
}
try {
Long.parseLong(str.trim());
return true;
} catch (NumberFormatException e) {
return false;
}
}
/**
* 양수 검증
*/
public static boolean isPositiveNumber(String str) {
if (!isNumeric(str)) {
return false;
}
try {
long number = Long.parseLong(str.trim());
return number > 0;
} catch (NumberFormatException e) {
return false;
}
}
}