This commit is contained in:
mindol1004
2024-10-21 17:59:04 +09:00
parent d5e8941d5d
commit 868bb01453
57 changed files with 1913 additions and 99 deletions

View File

@@ -12,15 +12,42 @@ import com.spring.common.converter.CommHttpMessageConverter;
import lombok.RequiredArgsConstructor;
/**
* 웹 애플리케이션의 설정을 정의하는 구성 클래스입니다.
*
* <p>이 클래스는 Spring MVC의 웹 설정을 구성하며, HTTP 메시지 변환기와 같은
* 웹 관련 빈을 설정합니다.</p>
*
* <p>주요 기능:</p>
* <ul>
* <li>커스텀 HTTP 메시지 변환기 등록</li>
* <li>Spring MVC의 기본 설정을 확장</li>
* </ul>
*
* @author mindol
* @version 1.0
*/
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
/**
* Jackson ObjectMapper 객체입니다.
*
* <p>JSON 데이터의 직렬화 및 역직렬화에 사용됩니다.</p>
*/
private final ObjectMapper objectMapper;
/**
* HTTP 메시지 변환기를 확장합니다.
*
* <p>기본 HTTP 메시지 변환기 목록에 커스텀 변환기를 추가합니다.</p>
*
* @param converters HTTP 메시지 변환기 목록
*/
@Override
public void extendMessageConverters(@NonNull List<HttpMessageConverter<?>> converters) {
converters.add(0, new CommHttpMessageConverter(objectMapper));
}
}

View File

@@ -8,17 +8,46 @@ import org.springframework.lang.Nullable;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
/**
* 커스텀 HTTP 메시지 변환기 클래스입니다.
*
* <p>이 클래스는 Spring의 MappingJackson2HttpMessageConverter를 확장하여
* JSON 데이터의 직렬화 및 역직렬화를 처리합니다.</p>
*
* <p>Java 8의 날짜 및 시간 API를 지원하기 위해 JavaTimeModule을 등록합니다.</p>
*
* @author mindol
* @version 1.0
*/
public class CommHttpMessageConverter extends MappingJackson2HttpMessageConverter {
/**
* 기본 생성자입니다.
*
* <p>주어진 ObjectMapper를 사용하여 CommHttpMessageConverter를 초기화합니다.</p>
*
* <p>JavaTimeModule을 등록하여 Java 8의 날짜 및 시간 API를 지원합니다.</p>
*
* @param objectMapper JSON 변환에 사용할 ObjectMapper
*/
public CommHttpMessageConverter(ObjectMapper objectMapper) {
super(objectMapper);
objectMapper.registerModule(new JavaTimeModule());
setObjectMapper(objectMapper);
}
/**
* 주어진 클래스 타입과 미디어 타입에 대해 이 변환기가 쓸 수 있는지 확인합니다.
*
* <p>미디어 타입이 지원되는 경우 true를 반환합니다.</p>
*
* @param clazz 변환할 클래스 타입
* @param mediaType 변환할 미디어 타입
* @return 지원하는 경우 true, 그렇지 않으면 false
*/
@Override
public boolean canWrite(@NonNull Class<?> clazz, @Nullable MediaType mediaType) {
return canWrite(mediaType);
}
}

View File

@@ -2,19 +2,46 @@ package com.spring.common.error;
import lombok.Getter;
/**
* 비즈니스 로직에서 발생할 수 있는 예외를 나타내는 클래스입니다.
*
* <p>이 클래스는 RuntimeException을 확장하여 비즈니스 로직에서 발생하는
* 특정 오류를 처리하는 데 사용됩니다.</p>
*
* <p>각 예외는 ErrorRule을 통해 정의된 오류 규칙을 기반으로 하며,
* 해당 규칙에 대한 메시지를 포함합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Getter
public class BizBaseException extends RuntimeException {
/**
* 오류 규칙을 나타내는 객체입니다.
*
* <p>이 필드는 발생한 예외의 유형을 정의하는 ErrorRule 객체입니다.</p>
*/
private final ErrorRule errorRule;
/**
* 기본 생성자입니다.
*
* <p>기본 오류 규칙인 SYSTEM_ERROR를 사용하여 BizBaseException을 초기화합니다.</p>
*/
public BizBaseException() {
super(ExceptionRule.SYSTEM_ERROR.getMessage());
this.errorRule = ExceptionRule.SYSTEM_ERROR;
}
/**
* 주어진 오류 규칙을 사용하여 BizBaseException을 초기화합니다.
*
* @param exceptionRule 발생한 예외의 오류 규칙
*/
public BizBaseException(ErrorRule exceptionRule) {
super(exceptionRule.getMessage());
this.errorRule = exceptionRule;
}
}

View File

@@ -7,18 +7,58 @@ import org.springframework.validation.FieldError;
import lombok.Getter;
/**
* 비즈니스 오류 응답을 나타내는 클래스입니다.
*
* <p>이 클래스는 ErrorResponse를 확장하여 비즈니스 로직에서 발생한 오류에 대한
* 응답을 구성하는 데 사용됩니다.</p>
*
* <p>오류 규칙과 관련된 추가적인 오류 정보를 포함할 수 있습니다.</p>
*
* @author mindol
* @version 1.0
*/
@Getter
public class BizErrorResponse extends ErrorResponse {
/**
* 오류 목록을 나타내는 필드입니다.
*
* <p>비즈니스 로직에서 발생한 오류에 대한 세부 정보를 포함합니다.</p>
*/
private final List<RejectedValue> errors;
/**
* 주어진 오류 규칙과 오류 목록을 사용하여 BizErrorResponse를 초기화합니다.
*
* @param exceptionRule 발생한 예외의 오류 규칙
* @param errors 발생한 오류 목록
*/
public BizErrorResponse(ErrorRule exceptionRule, List<RejectedValue> errors) {
super(exceptionRule);
this.errors = errors;
}
/**
* 주어진 오류 규칙을 사용하여 BizErrorResponse 객체를 생성합니다.
*
* <p>오류 목록은 null로 설정됩니다.</p>
*
* @param exceptionRule 발생한 예외의 오류 규칙
* @return 생성된 BizErrorResponse 객체
*/
public static BizErrorResponse valueOf(ErrorRule exceptionRule) {
return new BizErrorResponse(exceptionRule, null);
}
/**
* 주어진 필드 오류 목록을 기반으로 BizErrorResponse 객체를 생성합니다.
*
* <p>필드 오류를 RejectedValue 객체로 변환하여 오류 목록을 구성합니다.</p>
*
* @param fieldErrors 필드 오류 목록
* @return 생성된 BizErrorResponse 객체
*/
public static BizErrorResponse fromFieldError(List<FieldError> fieldErrors) {
List<RejectedValue> errors = fieldErrors.stream()
.map(fieldError -> RejectedValue.of(fieldError.getField(), fieldError.getRejectedValue(), fieldError.getDefaultMessage()))

View File

@@ -6,11 +6,32 @@ import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 오류 응답을 나타내는 추상 클래스입니다.
*
* <p>이 클래스는 비즈니스 로직에서 발생한 오류에 대한 응답을 구성하는 데 사용됩니다.</p>
*
* <p>각 오류 응답은 오류 규칙과 관련된 오류 목록을 포함할 수 있습니다.</p>
*
* @author mindol
* @version 1.0
*/
@Getter
@RequiredArgsConstructor
public abstract class ErrorResponse {
/**
* 오류 규칙을 나타내는 필드입니다.
*
* <p>발생한 오류의 유형을 정의하는 ErrorRule 객체입니다.</p>
*/
protected final ErrorRule errorRule;
/**
* 오류 목록을 나타내는 필드입니다.
*
* <p>비즈니스 로직에서 발생한 오류에 대한 세부 정보를 포함합니다.</p>
*/
protected List<RejectedValue> errors;
}

View File

@@ -2,7 +2,31 @@ package com.spring.common.error;
import org.springframework.http.HttpStatus;
/**
* 오류 규칙을 정의하는 인터페이스입니다.
*
* <p>이 인터페이스는 비즈니스 로직에서 발생하는 오류에 대한 상태 코드와 메시지를 제공하는
* 메서드를 정의합니다.</p>
*
* <p>구현 클래스는 이 인터페이스를 구현하여 특정 오류 규칙에 대한 정보를 제공해야 합니다.</p>
*
* @author mindol
* @version 1.0
*/
public interface ErrorRule {
/**
* 오류에 대한 HTTP 상태 코드를 반환합니다.
*
* @return 오류에 해당하는 HttpStatus 객체
*/
HttpStatus getStatus();
/**
* 오류에 대한 메시지를 반환합니다.
*
* @return 오류 메시지 문자열
*/
String getMessage();
}

View File

@@ -5,6 +5,17 @@ import org.springframework.http.HttpStatus;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 예외 규칙을 정의하는 열거형입니다.
*
* <p>이 열거형은 다양한 오류 상황에 대한 HTTP 상태 코드와 메시지를 정의합니다.</p>
*
* <p>각 열거 상수는 특정 오류 상황을 나타내며, 이를 통해 비즈니스 로직에서 발생하는
* 오류를 일관되게 처리할 수 있습니다.</p>
*
* @author mindol
* @version 1.0
*/
@Getter
@RequiredArgsConstructor
public enum ExceptionRule implements ErrorRule {

View File

@@ -11,9 +11,30 @@ import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
/**
* 전역 오류 처리를 위한 컨트롤러 클래스입니다.
*
* <p>이 클래스는 Spring MVC의 ErrorController 인터페이스를 구현하여
* 애플리케이션에서 발생하는 오류를 처리합니다.</p>
*
* <p>HTTP 오류가 발생했을 때 사용자에게 오류 페이지를 표시하는 기능을 제공합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Controller
public class GlobalErrorController implements ErrorController {
/**
* 모든 종류의 오류를 처리하는 메서드입니다.
*
* <p>HTTP 요청에서 오류가 발생했을 때 호출되며, 오류 상태 코드와 메시지를
* 모델에 추가하여 오류 페이지를 렌더링합니다.</p>
*
* @param request HTTP 요청 객체
* @param model 모델 객체
* @return 오류 페이지의 뷰 이름
*/
@ExceptionHandler(Throwable.class)
@GetMapping("/error")
public String handleError(HttpServletRequest request, Model model) {
@@ -21,8 +42,12 @@ public class GlobalErrorController implements ErrorController {
String errorMessage = String.valueOf(request.getAttribute(RequestDispatcher.ERROR_MESSAGE));
String statusMsg = status.toString();
HttpStatus httpStatus = HttpStatus.valueOf(Integer.valueOf(statusMsg));
model.addAttribute("message", statusMsg + " " + httpStatus.getReasonPhrase());
if (StringUtils.hasText(errorMessage)) model.addAttribute("errorMessage", errorMessage);
if (StringUtils.hasText(errorMessage)) {
model.addAttribute("errorMessage", errorMessage);
}
return "pages/error/error";
}

View File

@@ -4,17 +4,46 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 전역 예외 처리를 위한 클래스입니다.
*
* <p>이 클래스는 Spring의 @RestControllerAdvice를 사용하여 애플리케이션 전역에서 발생하는
* 예외를 처리합니다.</p>
*
* <p>특정 예외 유형에 대해 적절한 응답을 생성하여 클라이언트에 반환하는 기능을 제공합니다.</p>
*
* @author mindol
* @version 1.0
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* BizBaseException 예외를 처리하는 메서드입니다.
*
* <p>BizBaseException이 발생했을 때 호출되며, 해당 예외의 오류 규칙을 기반으로
* BizErrorResponse 객체를 생성하여 반환합니다.</p>
*
* @param e 발생한 BizBaseException 예외
* @return 생성된 BizErrorResponse 객체
*/
@ExceptionHandler(BizBaseException.class)
public BizErrorResponse handleCustomException(BizBaseException e) {
return BizErrorResponse.valueOf(e.getErrorRule());
}
/**
* MethodArgumentNotValidException 예외를 처리하는 메서드입니다.
*
* <p>MethodArgumentNotValidException이 발생했을 때 호출되며, 필드 오류 목록을 기반으로
* BizErrorResponse 객체를 생성하여 반환합니다.</p>
*
* @param e 발생한 MethodArgumentNotValidException 예외
* @return 생성된 BizErrorResponse 객체
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public BizErrorResponse methodArgumentNotValidException(MethodArgumentNotValidException e) {
return BizErrorResponse.fromFieldError(e.getFieldErrors());
}
}

View File

@@ -3,16 +3,53 @@ package com.spring.common.error;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 요청에서 거부된 값을 나타내는 클래스입니다.
*
* <p>이 클래스는 유효성 검사에서 실패한 필드와 관련된 정보를 포함합니다.</p>
*
* <p>각 RejectedValue 객체는 필드 이름, 거부된 값, 그리고 거부 사유를 저장합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Getter
@RequiredArgsConstructor
public class RejectedValue {
/**
* 거부된 필드의 이름입니다.
*
* <p>유효성 검사에서 실패한 필드의 이름을 나타냅니다.</p>
*/
private final String field;
/**
* 거부된 값입니다.
*
* <p>유효성 검사에서 실패한 값입니다.</p>
*/
private final Object value;
/**
* 거부 사유입니다.
*
* <p>유효성 검사에서 실패한 이유를 설명하는 메시지입니다.</p>
*/
private final String reason;
/**
* RejectedValue 객체를 생성하는 정적 팩토리 메서드입니다.
*
* <p>주어진 필드 이름, 값, 및 사유를 사용하여 RejectedValue 객체를 생성합니다.</p>
*
* @param field 거부된 필드의 이름
* @param value 거부된 값
* @param reason 거부 사유
* @return 생성된 RejectedValue 객체
*/
public static RejectedValue of(String field, Object value, String reason) {
return new RejectedValue(field, value, reason);
}
}

View File

@@ -14,23 +14,57 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import lombok.Getter;
/**
* 모든 감사 엔티티의 기본 클래스를 정의하는 추상 클래스입니다.
*
* <p>이 클래스는 생성일, 생성자, 수정일 및 수정자를 포함하여
* 감사 관련 정보를 관리하는 데 사용됩니다.</p>
*
* <p>JPA의 @MappedSuperclass 어노테이션을 사용하여 이 클래스를 상속받는
* 모든 엔티티가 이 필드를 포함할 수 있도록 합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditEntity {
/**
* 엔티티가 생성된 날짜 및 시간입니다.
*
* <p>이 필드는 엔티티가 처음 생성될 때 자동으로 설정되며,
* 업데이트할 수 없습니다.</p>
*/
@CreatedDate
@Column(name = "CREATED_DATE", updatable = false, insertable = true)
protected LocalDateTime createdDate;
/**
* 엔티티를 생성한 사용자의 ID입니다.
*
* <p>이 필드는 엔티티가 생성될 때 자동으로 설정되며,
* 업데이트할 수 없습니다.</p>
*/
@CreatedBy
@Column(name = "CREATED_BY", updatable = false, insertable = true, length = 50)
protected String createdBy;
/**
* 엔티티가 마지막으로 수정된 날짜 및 시간입니다.
*
* <p>이 필드는 엔티티가 수정될 때 자동으로 설정됩니다.</p>
*/
@LastModifiedDate
@Column(name = "MODIFIED_DATE", updatable = true, insertable = true)
protected LocalDateTime modifiedDate;
/**
* 엔티티를 마지막으로 수정한 사용자의 ID입니다.
*
* <p>이 필드는 엔티티가 수정될 때 자동으로 설정됩니다.</p>
*/
@LastModifiedBy
@Column(name = "MODIFIED_BY", updatable = true, insertable = true, length = 50)
protected String modifiedBy;

View File

@@ -5,12 +5,30 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* JPA 감사 기능을 설정하는 구성 클래스입니다.
*
* <p>이 클래스는 Spring Data JPA의 감사 기능을 활성화하고,
* 사용자 감사 정보를 제공하는 AuditorAware 빈을 정의합니다.</p>
*
* <p>감사 기능은 엔티티의 생성 및 수정 시점을 기록하는 데 사용됩니다.</p>
*
* @author mindol
* @version 1.0
*/
@Configuration
@EnableJpaAuditing
public class JpaAuditConfig {
/**
* 사용자 감사 정보를 제공하는 AuditorAware 빈을 생성합니다.
*
* <p>이 메서드는 현재 사용자의 ID를 반환하는 UserAuditorAware 인스턴스를 생성합니다.</p>
*
* @return AuditorAware<String> 현재 사용자의 ID를 제공하는 AuditorAware 인스턴스
*/
@Bean
AuditorAware<String> auditorProvider(){
AuditorAware<String> auditorProvider() {
return new UserAuditorAware();
}

View File

@@ -9,8 +9,27 @@ import org.springframework.security.core.context.SecurityContextHolder;
import com.spring.infra.security.domain.UserPrincipal;
/**
* 현재 사용자의 감사 정보를 제공하는 클래스입니다.
*
* <p>이 클래스는 Spring Data JPA의 AuditorAware 인터페이스를 구현하여,
* 현재 인증된 사용자의 ID를 반환합니다.</p>
*
* <p>사용자는 SecurityContextHolder를 통해 인증 정보를 가져오며,
* 인증되지 않은 경우에는 빈 값을 반환합니다.</p>
*
* @author mindol
* @version 1.0
*/
public class UserAuditorAware implements AuditorAware<String> {
/**
* 현재 인증된 사용자의 ID를 반환합니다.
*
* <p>사용자가 인증되지 않았거나 인증 정보가 없는 경우에는 빈 Optional을 반환합니다.</p>
*
* @return 현재 사용자의 ID가 포함된 Optional 객체, 인증되지 않은 경우 빈 Optional
*/
@Override
public @NonNull Optional<String> getCurrentAuditor() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

View File

@@ -5,16 +5,67 @@ import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
/**
* API 응답을 나타내는 제네릭 클래스입니다.
*
* <p>이 클래스는 API 요청에 대한 응답을 구성하는 데 사용되며,
* 응답의 상태 코드, 경로, 데이터, 메시지 등을 포함합니다.</p>
*
* <p>제네릭 타입 T를 사용하여 다양한 데이터 형식을 지원합니다.</p>
*
* @param <T> 응답 데이터의 타입
* @author mindol
* @version 1.0
*/
@Getter
public class ApiResponse<T> {
/**
* 응답 생성 시각입니다.
*
* <p>응답이 생성된 시각을 나타내며, LocalDateTime 객체로 저장됩니다.</p>
*/
private final LocalDateTime timestamp = LocalDateTime.now();
/**
* HTTP 상태 코드입니다.
*
* <p>응답의 상태를 나타내는 정수 값입니다.</p>
*/
private int statusCode;
/**
* 요청 경로입니다.
*
* <p>응답이 생성된 API의 경로를 나타냅니다.</p>
*/
private String path;
/**
* 응답 데이터입니다.
*
* <p>제네릭 타입 T로 정의된 응답의 실제 데이터를 포함합니다.</p>
*/
private T data;
/**
* 응답 메시지입니다.
*
* <p>응답에 대한 설명이나 상태 메시지를 포함합니다.</p>
*/
private String message;
@Builder
/**
* ApiResponse 객체를 생성하는 생성자입니다.
*
* <p>상태 코드, 경로, 데이터, 메시지를 사용하여 ApiResponse 객체를 초기화합니다.</p>
*
* @param statusCode 응답의 HTTP 상태 코드
* @param path 요청 경로
* @param data 응답 데이터
* @param message 응답 메시지
*/
@Builder
private ApiResponse(int statusCode, String path, T data, String message) {
this.statusCode = statusCode;
this.path = path;

View File

@@ -21,23 +21,62 @@ import com.spring.common.error.GlobalExceptionHandler;
import com.spring.common.validation.CollectionValidator;
import com.spring.infra.security.error.SecurityExceptionHandler;
/**
* API 응답을 래핑하는 전역 응답 처리기 클래스입니다.
*
* <p>이 클래스는 Spring의 @RestControllerAdvice를 사용하여 특정 패키지 내의 모든
* 컨트롤러에서 발생하는 응답을 처리합니다.</p>
*
* <p>응답 본문을 ApiResponse 형식으로 변환하며, 오류 응답을 처리하는 기능을 제공합니다.</p>
*
* @author mindol
* @version 1.0
*/
@RestControllerAdvice(
basePackages = "com.spring.domain",
basePackageClasses = { GlobalExceptionHandler.class, SecurityExceptionHandler.class }
)
public class ResponseWrapper implements ResponseBodyAdvice<Object> {
/**
* 유효성 검사기를 나타내는 필드입니다.
*
* <p>LocalValidatorFactoryBean을 사용하여 유효성 검사를 수행합니다.</p>
*/
private final LocalValidatorFactoryBean validator;
/**
* ResponseWrapper 생성자입니다.
*
* <p>주입된 LocalValidatorFactoryBean을 사용하여 ResponseWrapper를 초기화합니다.</p>
*
* @param validator 유효성 검사기
*/
public ResponseWrapper(LocalValidatorFactoryBean validator) {
this.validator = validator;
}
/**
* 데이터 바인딩 초기화 메서드입니다.
*
* <p>WebDataBinder에 CollectionValidator를 추가하여 컬렉션 유효성 검사를 수행합니다.</p>
*
* @param binder WebDataBinder 객체
*/
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.addValidators(new CollectionValidator(validator));
}
/**
* 응답 본문을 처리할 수 있는지 여부를 확인합니다.
*
* <p>CommHttpMessageConverter를 사용하여 응답 본문을 처리할 수 있는지 판단합니다.</p>
*
* @param returnType 반환할 메서드의 MethodParameter
* @param converterType 사용된 HttpMessageConverter의 클래스
* @return 처리할 수 있는 경우 true, 그렇지 않으면 false
*/
@Override
public boolean supports(
@NonNull MethodParameter returnType,
@@ -46,6 +85,19 @@ public class ResponseWrapper implements ResponseBodyAdvice<Object> {
return CommHttpMessageConverter.class.isAssignableFrom(converterType);
}
/**
* 응답 본문을 작성하기 전에 호출되는 메서드입니다.
*
* <p>응답 본문이 ErrorResponse인 경우, ApiResponse 형식으로 변환하여 반환합니다.</p>
*
* @param body 응답 본문
* @param returnType 반환할 메서드의 MethodParameter
* @param selectedContentType 선택된 미디어 타입
* @param selectedConverterType 선택된 HttpMessageConverter의 클래스
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @return 변환된 ApiResponse 객체
*/
@Override
public Object beforeBodyWrite(
@Nullable Object body,

View File

@@ -8,18 +8,57 @@ import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
/**
* 컬렉션의 유효성을 검사하는 Validator 클래스입니다.
*
* <p>이 클래스는 Spring의 Validator 인터페이스를 구현하여,
* 컬렉션 내의 각 요소에 대해 유효성 검사를 수행합니다.</p>
*
* <p>주어진 LocalValidatorFactoryBean을 사용하여 각 요소의 유효성을 검사합니다.</p>
*
* @author mindol
* @version 1.0
*/
public class CollectionValidator implements Validator {
/**
* 요소의 유효성을 검사하는 Validator 객체입니다.
*
* <p>LocalValidatorFactoryBean을 사용하여 초기화됩니다.</p>
*/
private final Validator validator;
/**
* CollectionValidator 생성자입니다.
*
* <p>주어진 LocalValidatorFactoryBean을 사용하여 CollectionValidator를 초기화합니다.</p>
*
* @param validatorFactory 유효성 검사기 팩토리
*/
public CollectionValidator(LocalValidatorFactoryBean validatorFactory) {
this.validator = validatorFactory;
}
/**
* 주어진 클래스 타입에 대해 이 Validator가 지원하는지 확인합니다.
*
* <p>현재 구현에서는 모든 클래스 타입을 지원하도록 true를 반환합니다.</p>
*
* @param clazz 검사할 클래스 타입
* @return 항상 true
*/
@Override
public boolean supports(@NonNull Class<?> clazz) {
return true;
}
/**
* 주어진 객체의 유효성을 검사합니다.
*
* <p>객체가 컬렉션인 경우, 컬렉션의 각 요소에 대해 유효성 검사를 수행합니다.</p>
*
* @param target 검사할 객체
* @param errors 유효성 검사 중 발생한 오류를 저장할 객체
*/
@Override
public void validate(@NonNull Object target, @NonNull Errors errors) {
if (target instanceof Collection<?>) {

View File

@@ -8,15 +8,41 @@ import javax.validation.ConstraintValidatorContext;
import org.quartz.CronExpression;
/**
* Cron 표현식의 유효성을 검사하는 제약 조건 검증기 클래스입니다.
*
* <p>이 클래스는 Spring의 ConstraintValidator 인터페이스를 구현하여,
* 주어진 문자열이 유효한 Cron 표현식인지 확인합니다.</p>
*
* <p>유효한 Cron 표현식은 다음 유효성 검사를 통과해야 합니다:</p>
* <ul>
* <li>Null 또는 빈 문자열이 아니어야 합니다.</li>
* <li>CronExpression.isValidExpression 메서드를 통해 유효한 표현식인지 확인해야 합니다.</li>
* <li>현재 시간 이후의 유효한 실행 시간을 가져올 수 있어야 합니다.</li>
* </ul>
*
* @author mindol
* @version 1.0
*/
public class CronExpressionValidator implements ConstraintValidator<ValidCronExpression, String> {
/**
* 주어진 Cron 표현식의 유효성을 검사합니다.
*
* <p>유효성 검사 과정에서 Cron 표현식이 null이거나 빈 문자열인 경우 false를 반환합니다.
* 유효한 Cron 표현식인 경우, 다음 실행 시간을 확인하여 true 또는 false를 반환합니다.</p>
*
* @param cronExpression 검사할 Cron 표현식
* @param context 제약 조건 검증기 컨텍스트
* @return 유효한 Cron 표현식인 경우 true, 그렇지 않으면 false
*/
@Override
public boolean isValid(String cronExpression, ConstraintValidatorContext context) {
if (cronExpression == null || cronExpression.isEmpty()) {
return false;
}
boolean result = false;
if(CronExpression.isValidExpression(cronExpression)){
if (CronExpression.isValidExpression(cronExpression)) {
try {
CronExpression targetExpression = new CronExpression(cronExpression);
if (targetExpression.getNextValidTimeAfter(new Date(System.currentTimeMillis())) != null) {

View File

@@ -8,12 +8,51 @@ import java.lang.annotation.Target;
import javax.validation.Constraint;
import javax.validation.Payload;
/**
* Enum 값의 유효성을 검사하기 위한 제약 조건 어노테이션입니다.
*
* <p>이 어노테이션은 필드, 메서드 또는 매개변수에 적용되어 해당 값이 지정된 Enum 타입의
* 유효한 값인지 확인합니다.</p>
*
* <p>EnumValidator 클래스를 사용하여 유효성 검사를 수행합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Constraint(validatedBy = {EnumValidator.class})
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface EnumValid {
/**
* 유효성 검사 실패 시 반환할 메시지입니다.
*
* @return 유효성 검사 실패 메시지
*/
String message() default "Invalid Enum Value.";
/**
* 제약 조건 그룹을 지정합니다.
*
* <p>유효성 검사 그룹을 정의하여 특정 상황에서만 유효성 검사를 수행할 수 있습니다.</p>
*
* @return 제약 조건 그룹
*/
Class<?>[] groups() default {};
/**
* 페이로드를 지정합니다.
*
* <p>제약 조건에 대한 추가 정보를 제공하는 데 사용됩니다.</p>
*
* @return 페이로드 클래스 배열
*/
Class<? extends Payload>[] payload() default {};
/**
* 유효성을 검사할 대상 Enum 클래스입니다.
*
* @return 검사할 Enum 클래스
*/
Class<? extends java.lang.Enum<?>> target();
}

View File

@@ -3,14 +3,46 @@ package com.spring.common.validation;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
/**
* Enum 값의 유효성을 검사하는 제약 조건 검증기 클래스입니다.
*
* <p>이 클래스는 Spring의 ConstraintValidator 인터페이스를 구현하여,
* 주어진 Enum 값이 지정된 Enum 타입의 유효한 값인지 확인합니다.</p>
*
* <p>EnumValid 어노테이션을 사용하여 유효성 검사를 수행합니다.</p>
*
* @author mindol
* @version 1.0
*/
public class EnumValidator implements ConstraintValidator<EnumValid, Enum<?>> {
/**
* EnumValid 어노테이션 인스턴스입니다.
*
* <p>유효성 검사에 필요한 설정 정보를 포함합니다.</p>
*/
private EnumValid annotation;
/**
* EnumValid 어노테이션을 초기화합니다.
*
* <p>유효성 검사기 초기화 시 호출되며, 주어진 어노테이션을 저장합니다.</p>
*
* @param constraintAnnotation 초기화할 EnumValid 어노테이션
*/
@Override
public void initialize(EnumValid constraintAnnotation) {
this.annotation = constraintAnnotation;
}
/**
* 주어진 Enum 값의 유효성을 검사합니다.
*
* <p>값이 null인 경우 false를 반환하며, 지정된 Enum 타입의 유효한 값인지 확인합니다.</p>
*
* @param value 검사할 Enum 값
* @param context 제약 조건 검증기 컨텍스트
* @return 유효한 Enum 값인 경우 true, 그렇지 않으면 false
*/
@Override
public boolean isValid(Enum<?> value, ConstraintValidatorContext context) {
if (value == null) return false;

View File

@@ -9,12 +9,43 @@ import java.lang.annotation.Target;
import javax.validation.Constraint;
import javax.validation.Payload;
/**
* Cron 표현식의 유효성을 검사하기 위한 제약 조건 어노테이션입니다.
*
* <p>이 어노테이션은 필드에 적용되어 해당 값이 유효한 Cron 표현식인지 확인합니다.</p>
*
* <p>CronExpressionValidator 클래스를 사용하여 유효성 검사를 수행합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Documented
@Constraint(validatedBy = CronExpressionValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidCronExpression {
/**
* 유효성 검사 실패 시 반환할 메시지입니다.
*
* @return 유효성 검사 실패 메시지
*/
String message() default "Invalid cron expression";
/**
* 제약 조건 그룹을 지정합니다.
*
* <p>유효성 검사 그룹을 정의하여 특정 상황에서만 유효성 검사를 수행할 수 있습니다.</p>
*
* @return 제약 조건 그룹
*/
Class<?>[] groups() default {};
/**
* 페이로드를 지정합니다.
*
* <p>제약 조건에 대한 추가 정보를 제공하는 데 사용됩니다.</p>
*
* @return 페이로드 클래스 배열
*/
Class<? extends Payload>[] payload() default {};
}

View File

@@ -2,16 +2,52 @@ package com.spring.infra.batch;
import org.springframework.batch.core.Job;
/**
* 배치 작업을 정의하는 추상 인터페이스입니다.
*
* <p>이 인터페이스는 배치 작업의 초기화, 등록 및 생성과 관련된 메서드를 정의합니다.
* 구현 클래스는 이 인터페이스를 구현하여 특정 배치 작업의 로직을 제공해야 합니다.</p>
*
* <p>주요 메서드로는 배치 작업 정보를 초기화하고, 작업 빈을 등록하며,
* 배치 작업을 생성하는 기능이 포함됩니다.</p>
*
* @author mindol
* @version 1.0
*/
public interface AbstractBatch {
/**
* 배치 작업 정보를 초기화합니다.
*
* <p>배치 작업을 실행하기 전에 필요한 초기 설정을 수행합니다.</p>
*/
void initializeBatchJobInfo();
/**
* 배치 작업에 필요한 빈을 등록합니다.
*
* <p>배치 작업에서 사용할 JobBean을 등록하는 메서드입니다.</p>
*/
void registerJobBean();
/**
* 배치 작업을 생성합니다.
*
* <p>배치 작업을 생성하고 반환합니다.</p>
*
* @return 생성된 Job 객체
*/
Job createJob();
/**
* 스코프가 지정된 타겟 프리픽스를 제거합니다.
*
* <p>주어진 빈 이름에서 "scopedTarget." 접두사를 제거하여 반환합니다.</p>
*
* @param beanName 빈 이름
* @return 수정된 빈 이름
*/
default String removeScopedTargetPrefix(String beanName) {
return beanName.startsWith("scopedTarget.") ? beanName.substring("scopedTarget.".length()) : beanName;
}
}

View File

@@ -15,21 +15,66 @@ import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.lang.NonNull;
import org.springframework.transaction.PlatformTransactionManager;
/**
* 배치 청크 처리를 위한 추상 클래스입니다.
*
* <p>이 클래스는 Spring Batch의 청크 기반 처리 로직을 구현하기 위한 기본 클래스로,
* 배치 작업의 초기화 및 등록, JobRepository 및 TransactionManager와의 통합을 제공합니다.</p>
*
* <p>구현 클래스는 이 클래스를 상속받아 특정 배치 작업의 로직을 정의해야 합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Configuration
public abstract class AbstractBatchChunk implements AbstractBatch, ApplicationContextAware, InitializingBean {
/**
* 배치 작업 정보.
*
* <p>BatchJobInfo 어노테이션을 통해 정의된 배치 작업의 메타데이터를 포함합니다.</p>
*/
private final BatchJobInfo batchJobInfo;
/**
* 배치 작업 정보 서비스.
*
* <p>배치 작업 정보를 관리하는 서비스 객체입니다.</p>
*/
private BatchJobInfoService batchJobInfoService;
/**
* 애플리케이션 컨텍스트.
*
* <p>Spring의 ApplicationContext를 통해 필요한 빈을 가져옵니다.</p>
*/
private ApplicationContext applicationContext;
/**
* 배치 작업 정보 데이터.
*
* <p>배치 작업의 실행에 필요한 데이터를 포함합니다.</p>
*/
protected BatchJobInfoData batchJobInfoData;
/**
* JobRepository.
*
* <p>배치 작업의 상태를 관리하는 리포지토리입니다.</p>
*/
protected JobRepository jobRepository;
/**
* 트랜잭션 매니저.
*
* <p>배치 작업의 트랜잭션 관리를 담당합니다.</p>
*/
protected PlatformTransactionManager transactionManager;
/**
* 기본 생성자입니다.
* <p>
* BatchJobInfo 어노테이션을 찾고, 없으면 예외를 발생시킵니다.
* </p>
*
* <p>BatchJobInfo 어노테이션을 찾고, 없으면 예외를 발생시킵니다.</p>
*/
protected AbstractBatchChunk() {
this.batchJobInfo = AnnotationUtils.findAnnotation(getClass(), BatchJobInfo.class);
@@ -38,11 +83,24 @@ public abstract class AbstractBatchChunk implements AbstractBatch, ApplicationCo
}
}
/**
* 애플리케이션 컨텍스트를 설정합니다.
*
* @param applicationContext 설정할 ApplicationContext
* @throws BeansException 빈 설정 중 발생할 수 있는 예외
*/
@Override
public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
/**
* 빈의 속성이 설정된 후 호출됩니다.
*
* <p>배치 작업 정보 서비스 초기화 및 배치 작업 등록을 수행합니다.</p>
*
* @throws Exception 초기화 중 발생할 수 있는 예외
*/
@Override
public void afterPropertiesSet() throws Exception {
this.batchJobInfoService = applicationContext.getBean(BatchJobInfoService.class);
@@ -50,6 +108,11 @@ public abstract class AbstractBatchChunk implements AbstractBatch, ApplicationCo
registerJobBean();
}
/**
* 배치 작업 정보를 초기화합니다.
*
* <p>배치 작업의 메타데이터를 로드하여 설정합니다.</p>
*/
@Override
public void initializeBatchJobInfo() {
String beanName = applicationContext.getBeanNamesForType(this.getClass())[0];
@@ -58,6 +121,8 @@ public abstract class AbstractBatchChunk implements AbstractBatch, ApplicationCo
/**
* 배치 작업을 Spring의 Bean으로 등록합니다.
*
* <p>JobRepository에 배치 작업을 등록하여 실행할 수 있도록 합니다.</p>
*/
@Override
public void registerJobBean() {
@@ -90,6 +155,13 @@ public abstract class AbstractBatchChunk implements AbstractBatch, ApplicationCo
this.transactionManager = transactionManager;
}
/**
* 배치 작업을 생성합니다.
*
* <p>구현 클래스에서 정의해야 하는 추상 메서드입니다.</p>
*
* @return 생성된 Job 객체
*/
@Override
public abstract Job createJob();

View File

@@ -25,9 +25,11 @@ import org.springframework.transaction.PlatformTransactionManager;
/**
* 배치 작업을 정의하는 추상 클래스입니다.
* <p>
* 이 클래스는 배치 작업의 기본 설정 및 실행을 관리합니다.
* </p>
*
* <p>이 클래스는 배치 작업의 기본 설정 및 실행을 관리하며,
* 배치 작업의 메타데이터를 초기화하고, JobRepository에 등록하는 기능을 제공합니다.</p>
*
* <p>구현 클래스는 이 클래스를 상속받아 특정 배치 작업의 로직을 정의해야 합니다.</p>
*
* @author mindol
* @version 1.0
@@ -35,18 +37,52 @@ import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public abstract class AbstractBatchTask implements AbstractBatch, ApplicationContextAware, InitializingBean {
/**
* 배치 작업 정보.
*
* <p>BatchJobInfo 어노테이션을 통해 정의된 배치 작업의 메타데이터를 포함합니다.</p>
*/
private final BatchJobInfo batchJobInfo;
/**
* 배치 작업 정보 서비스.
*
* <p>배치 작업 정보를 관리하는 서비스 객체입니다.</p>
*/
private BatchJobInfoService batchJobInfoService;
/**
* 배치 작업 정보 데이터.
*
* <p>배치 작업의 실행에 필요한 데이터를 포함합니다.</p>
*/
private BatchJobInfoData batchJobInfoData;
/**
* 애플리케이션 컨텍스트.
*
* <p>Spring의 ApplicationContext를 통해 필요한 빈을 가져옵니다.</p>
*/
private ApplicationContext applicationContext;
/**
* JobRepository.
*
* <p>배치 작업의 상태를 관리하는 리포지토리입니다.</p>
*/
private JobRepository jobRepository;
/**
* 트랜잭션 매니저.
*
* <p>배치 작업의 트랜잭션 관리를 담당합니다.</p>
*/
private PlatformTransactionManager transactionManager;
/**
* 기본 생성자입니다.
* <p>
* BatchJobInfo 어노테이션을 찾고, 없으면 예외를 발생시킵니다.
* </p>
*
* <p>BatchJobInfo 어노테이션을 찾고, 없으면 예외를 발생시킵니다.</p>
*/
protected AbstractBatchTask() {
this.batchJobInfo = AnnotationUtils.findAnnotation(getClass(), BatchJobInfo.class);
@@ -55,18 +91,36 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon
}
}
/**
* 애플리케이션 컨텍스트를 설정합니다.
*
* @param applicationContext 설정할 ApplicationContext
* @throws BeansException 빈 설정 중 발생할 수 있는 예외
*/
@Override
public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
this.batchJobInfoService = applicationContext.getBean(BatchJobInfoService.class);
}
/**
* 빈의 속성이 설정된 후 호출됩니다.
*
* <p>배치 작업 정보 초기화 및 배치 작업 등록을 수행합니다.</p>
*
* @throws Exception 초기화 중 발생할 수 있는 예외
*/
@Override
public void afterPropertiesSet() throws Exception {
initializeBatchJobInfo();
registerJobBean();
}
/**
* 배치 작업 정보를 초기화합니다.
*
* <p>배치 작업의 메타데이터를 로드하여 설정합니다.</p>
*/
@Override
public void initializeBatchJobInfo() {
String beanName = applicationContext.getBeanNamesForType(this.getClass())[0];
@@ -75,6 +129,8 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon
/**
* 배치 작업을 Spring의 Bean으로 등록합니다.
*
* <p>JobRepository에 배치 작업을 등록하여 실행할 수 있도록 합니다.</p>
*/
@Override
public void registerJobBean() {
@@ -110,6 +166,8 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon
/**
* 배치 작업을 생성합니다.
*
* <p>구현 클래스에서 정의해야 하는 추상 메서드입니다.</p>
*
* @return 생성된 Job 객체
* @throws IllegalStateException STEP이 정의되지 않은 경우 예외 발생
*/
@@ -132,6 +190,8 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon
/**
* 배치 작업의 STEP을 생성합니다.
*
* <p>기본적으로 하나의 STEP을 생성하여 반환합니다.</p>
*
* @return 생성된 Step 리스트
*/
protected List<Step> createSteps() {
@@ -143,6 +203,8 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon
/**
* STEP을 추가합니다.
*
* <p>주어진 이름과 Tasklet을 사용하여 Step 객체를 생성합니다.</p>
*
* @param stepName STEP의 이름
* @param tasklet STEP에서 실행할 Tasklet
* @return 생성된 Step 객체

View File

@@ -13,14 +13,46 @@ import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* 배치 작업 정보에 대한 BeanPostProcessor입니다.
*
* <p>이 클래스는 Spring의 BeanPostProcessor 인터페이스를 구현하여,
* 배치 작업에 대한 메타데이터를 처리하고, 배치 작업이 초기화될 때
* 관련 정보를 설정하는 역할을 합니다.</p>
*
* <p>배치 작업에 대한 @BatchJobInfo 어노테이션을 처리하여,
* 배치 작업의 정보를 서비스에 저장합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class BatchJobInfoBeanPostProcessor implements BeanPostProcessor {
/**
* 배치 작업 정보 서비스를 제공하는 서비스 객체입니다.
*/
private final BatchJobInfoService batchJobInfoService;
/**
* JobRegistry 객체입니다.
*
* <p>Spring Batch의 JobRegistry를 사용하여 배치 작업을 등록합니다.</p>
*/
private final JobRegistry jobRegistry;
/**
* 초기화 전에 호출되는 메서드입니다.
*
* <p>배치 작업 정보 어노테이션을 처리하여 관련 정보를 설정합니다.</p>
*
* @param bean 초기화할 Bean 객체
* @param beanName Bean의 이름
* @return 초기화된 Bean 객체
* @throws BeansException Bean 초기화 중 발생할 수 있는 예외
*/
@Override
@Nullable
public Object postProcessBeforeInitialization(@NonNull Object bean, @NonNull String beanName) throws BeansException {
@@ -28,6 +60,16 @@ public class BatchJobInfoBeanPostProcessor implements BeanPostProcessor {
return bean;
}
/**
* 초기화 후에 호출되는 메서드입니다.
*
* <p>배치 작업이 등록되지 않은 경우 JobRegistry에 등록합니다.</p>
*
* @param bean 초기화된 Bean 객체
* @param beanName Bean의 이름
* @return 초기화된 Bean 객체
* @throws BeansException Bean 초기화 중 발생할 수 있는 예외
*/
@Override
@Nullable
public Object postProcessAfterInitialization(@NonNull Object bean, @NonNull String beanName) throws BeansException {
@@ -46,6 +88,14 @@ public class BatchJobInfoBeanPostProcessor implements BeanPostProcessor {
return bean;
}
/**
* 배치 작업 정보 어노테이션을 처리합니다.
*
* <p>주어진 Bean에서 @BatchJobInfo 어노테이션을 찾아 관련 정보를 설정합니다.</p>
*
* @param bean 처리할 Bean 객체
* @param beanName Bean의 이름
*/
private void processBatchJobInfoAnnotations(Object bean, String beanName) {
if (bean.getClass().isAnnotationPresent(BatchJobInfo.class)) {
BatchJobInfo batchJobInfo = bean.getClass().getAnnotation(BatchJobInfo.class);
@@ -53,6 +103,14 @@ public class BatchJobInfoBeanPostProcessor implements BeanPostProcessor {
}
}
/**
* 스코프가 지정된 타겟 프리픽스를 제거합니다.
*
* <p>주어진 빈 이름에서 "scopedTarget." 접두사를 제거하여 반환합니다.</p>
*
* @param beanName 빈 이름
* @return 수정된 빈 이름
*/
private String removeScopedTargetPrefix(String beanName) {
return beanName.startsWith("scopedTarget.") ? beanName.substring("scopedTarget.".length()) : beanName;
}

View File

@@ -3,11 +3,46 @@ package com.spring.infra.batch;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 배치 작업에 대한 정보를 저장하는 데이터 클래스입니다.
*
* <p>이 클래스는 배치 작업의 메타데이터를 포함하며,
* 배치 작업의 이름, 그룹, 설명 및 cron 표현식 등을 저장합니다.</p>
*
* <p>배치 작업의 실행 및 관리에 필요한 정보를 제공하는 역할을 합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Getter
@RequiredArgsConstructor
public class BatchJobInfoData {
/**
* 배치 작업의 그룹 이름입니다.
*
* <p>배치 작업이 속하는 그룹을 식별하는 데 사용됩니다.</p>
*/
private final String jobGroup;
/**
* 배치 작업의 이름입니다.
*
* <p>각 배치 작업을 고유하게 식별하는 이름입니다.</p>
*/
private final String jobName;
/**
* 배치 작업의 cron 표현식입니다.
*
* <p>배치 작업의 실행 주기를 정의하는 cron 표현식입니다.</p>
*/
private final String cronExpression;
/**
* 배치 작업에 대한 설명입니다.
*
* <p>배치 작업의 목적이나 기능에 대한 설명을 포함합니다.</p>
*/
private final String description;
}

View File

@@ -8,21 +8,68 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
/**
* 배치 작업 정보를 관리하는 서비스 클래스입니다.
*
* <p>이 클래스는 배치 작업의 메타데이터를 저장하고 관리하며,
* 배치 작업에 대한 CRUD(Create, Read, Update, Delete) 작업을 수행합니다.</p>
*
* <p>배치 작업의 정보는 데이터베이스 또는 메모리에 저장될 수 있으며,
* 이 서비스를 통해 배치 작업의 상태를 조회하고 업데이트할 수 있습니다.</p>
*
* @author mindol
* @version 1.0
*/
@Service
@RequiredArgsConstructor
public class BatchJobInfoService {
/**
* Spring의 Environment 객체입니다.
*
* <p>환경 설정에서 플레이스홀더를 해석하는 데 사용됩니다.</p>
*/
private final Environment environment;
/**
* 배치 작업 정보를 저장하는 맵입니다.
*
* <p>배치 작업의 이름을 키로 사용하여 BatchJobInfoData 객체를 저장합니다.</p>
*/
private final Map<String, BatchJobInfoData> batchJobInfoMap = new ConcurrentHashMap<>();
/**
* 배치 작업 정보를 설정합니다.
*
* <p>주어진 배치 작업 정보를 맵에 저장합니다.</p>
*
* @param beanName 배치 작업의 이름
* @param batchJobInfo 배치 작업 정보 객체
*/
public void setBatchJobInfoData(String beanName, BatchJobInfo batchJobInfo) {
batchJobInfoMap.put(beanName, processBatchJobInfo(batchJobInfo));
}
/**
* 배치 작업 정보를 조회합니다.
*
* <p>주어진 배치 작업 이름에 해당하는 배치 작업 정보를 반환합니다.</p>
*
* @param beanName 배치 작업의 이름
* @return 해당 배치 작업 정보 객체, 없으면 null
*/
public BatchJobInfoData getBatchJobInfo(String beanName) {
return batchJobInfoMap.get(beanName);
}
/**
* 배치 작업 정보를 처리합니다.
*
* <p>주어진 BatchJobInfo 객체를 기반으로 BatchJobInfoData 객체를 생성합니다.</p>
*
* @param batchJobInfo 배치 작업 정보 객체
* @return 생성된 BatchJobInfoData 객체
*/
private BatchJobInfoData processBatchJobInfo(BatchJobInfo batchJobInfo) {
return new BatchJobInfoData(
resolvePlaceHolder(batchJobInfo.group()),
@@ -32,8 +79,15 @@ public class BatchJobInfoService {
);
}
/**
* 주어진 값을 처리하여 플레이스홀더를 해석합니다.
*
* <p>환경 설정에서 플레이스홀더를 해석하여 실제 값을 반환합니다.</p>
*
* @param value 해석할 값
* @return 해석된 값
*/
private String resolvePlaceHolder(String value) {
return environment.resolvePlaceholders(value);
}
}

View File

@@ -1,5 +1,8 @@
package com.spring.infra.db.orm.mybatis;
import java.io.IOException;
import java.util.Optional;
import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
@@ -11,7 +14,10 @@ import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternUtils;
import com.spring.infra.db.PrimaryDataSourceConfig;
import com.spring.infra.db.orm.mybatis.annotation.PrimaryMapper;
@@ -52,10 +58,11 @@ public class PrimaryMybatisConfig {
@Primary
@Bean(name = SESSION_FACTORY)
SqlSessionFactory sqlSessionFactory(@Qualifier(PrimaryDataSourceConfig.DATASOURCE) DataSource dataSource) throws Exception {
var resolver = ResourcePatternUtils.getResourcePatternResolver(new DefaultResourceLoader());
var sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setConfiguration(mybatisConfiguration());
sessionFactory.setDataSource(dataSource);
sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(MAPPER_RESOURCES));
sessionFactory.setMapperLocations(Optional.ofNullable(getResourcesSafely(resolver, MAPPER_RESOURCES)).orElse(new Resource[0]));
sessionFactory.setTypeAliasesPackage(ALIASES_PACKAGE);
sessionFactory.setVfs(SpringBootVFS.class);
return sessionFactory.getObject();
@@ -75,4 +82,12 @@ public class PrimaryMybatisConfig {
return configuration;
}
private Resource[] getResourcesSafely(ResourcePatternResolver resolver, String pattern) {
try {
return resolver.getResources(pattern);
} catch (IOException e) {
return new Resource[0];
}
}
}

View File

@@ -1,5 +1,8 @@
package com.spring.infra.db.orm.mybatis;
import java.io.IOException;
import java.util.Optional;
import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
@@ -10,7 +13,10 @@ import org.mybatis.spring.boot.autoconfigure.SpringBootVFS;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternUtils;
import com.spring.infra.db.SecondaryDataSourceConfig;
import com.spring.infra.db.orm.mybatis.annotation.SecondaryMapper;
@@ -50,10 +56,11 @@ public class SecondaryMybatisConfig {
*/
@Bean(name = SESSION_FACTORY)
SqlSessionFactory sqlSessionFactory(@Qualifier(SecondaryDataSourceConfig.DATASOURCE) DataSource dataSource) throws Exception {
var resolver = ResourcePatternUtils.getResourcePatternResolver(new DefaultResourceLoader());
var sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setConfiguration(mybatisConfiguration());
sessionFactory.setDataSource(dataSource);
sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(MAPPER_RESOURCES));
sessionFactory.setMapperLocations(Optional.ofNullable(getResourcesSafely(resolver, MAPPER_RESOURCES)).orElse(new Resource[0]));
sessionFactory.setTypeAliasesPackage(ALIASES_PACKAGE);
sessionFactory.setVfs(SpringBootVFS.class);
return sessionFactory.getObject();
@@ -73,4 +80,12 @@ public class SecondaryMybatisConfig {
return configuration;
}
private Resource[] getResourcesSafely(ResourcePatternResolver resolver, String pattern) {
try {
return resolver.getResources(pattern);
} catch (IOException e) {
return new Resource[0];
}
}
}

View File

@@ -31,8 +31,25 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class QuartzConfig {
/**
* Quartz 스케줄러의 속성을 설정하는 객체입니다.
*
* <p>Quartz의 다양한 설정을 포함하고 있으며, JobFactory 및 SchedulerFactoryBean에 사용됩니다.</p>
*/
private final QuartzProperties quartzProperties;
/**
* 데이터 소스 객체입니다.
*
* <p>Quartz 스케줄러가 사용할 데이터베이스 연결을 제공합니다.</p>
*/
private final DataSource dataSource;
/**
* 트랜잭션 매니저 객체입니다.
*
* <p>Quartz 작업의 트랜잭션 관리를 담당합니다.</p>
*/
private final PlatformTransactionManager transactionManager;
/**

View File

@@ -36,10 +36,32 @@ import lombok.extern.slf4j.Slf4j;
@RequiredArgsConstructor
public class QuartzJobLauncher extends QuartzJobBean {
/**
* JobParameters의 인스턴스 키입니다.
*
* <p>Job 실행 시 사용되는 인스턴스 ID를 저장합니다.</p>
*/
private static final String JOB_PARAMETERS_INSTANCE_KEY = "InstanceId";
/**
* JobParameters의 타임스탬프 키입니다.
*
* <p>Job 실행 시 현재 시간을 저장합니다.</p>
*/
private static final String JOB_PARAMETERS_TIMESTAMP_KEY = "timestamp";
/**
* JobLauncher 객체입니다.
*
* <p>Spring Batch Job을 실행하는 데 사용됩니다.</p>
*/
private final JobLauncher jobLauncher;
/**
* JobRegistry 객체입니다.
*
* <p>Spring Batch에서 등록된 Job을 조회하는 데 사용됩니다.</p>
*/
private final JobRegistry jobRegistry;
/**
@@ -53,6 +75,7 @@ public class QuartzJobLauncher extends QuartzJobBean {
* </ol>
*
* @param context Quartz JobExecutionContext 객체
* @throws JobExecutionException Job 실행 중 발생할 수 있는 예외
*/
@Override
protected void executeInternal(@NonNull JobExecutionContext context) throws JobExecutionException {
@@ -68,7 +91,7 @@ public class QuartzJobLauncher extends QuartzJobBean {
jobLauncher.run(job, params);
} catch (Exception e) {
log.error("Job execution exception! - {}", e.getMessage(), e);
throw new JobExecutionException();
throw new JobExecutionException(e);
}
}

View File

@@ -18,17 +18,16 @@ import lombok.RequiredArgsConstructor;
/**
* Quartz 작업 등록기 클래스입니다.
*
* <p>이 클래스는 Spring의 ApplicationContext 내에서 Quartz 작업을 등록하고 관리하는 역할을 합니다.</p>
*
* <p>주요 기능:</p>
* <ul>
* <li>애플리케이션 컨텍스트 내의 모든 빈을 검사</li>
* <li>QuartzJob 어노테이션이 붙은 메소드 식별</li>
* <li>AbstractBatchJob을 상속받은 클래스 식별</li>
* <li>식별된 메소드를 Quartz 작업으로 등록</li>
* <li>각 작업에 대한 JobDetail 및 Trigger 생성</li>
* <li>애플리케이션 컨텍스트 내의 모든 빈을 검사하여 QuartzJob 어노테이션이 붙은 메소드를 식별합니다.</li>
* <li>AbstractBatchJob을 상속받은 클래스를 식별하고, 해당 메소드를 Quartz 작업으로 등록합니다.</li>
* <li>각 작업에 대한 JobDetail 및 Trigger를 생성합니다.</li>
* </ul>
*
* <p>이 클래스는 {@link ApplicationListener}를 구현하여
* {@link ContextRefreshedEvent}가 발생할 때 자동으로 실행됩니다.</p>
* <p>이 클래스는 {@link ApplicationListener}를 구현하여 {@link ContextRefreshedEvent}가 발생할 때 자동으로 실행됩니다.</p>
*
* @author mindol
* @version 1.0
@@ -37,13 +36,25 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedEvent> {
/**
* 애플리케이션 컨텍스트 객체입니다.
*
* <p>Spring의 ApplicationContext를 통해 필요한 빈을 가져옵니다.</p>
*/
private final ApplicationContext applicationContext;
/**
* Quartz 스케줄 서비스 객체입니다.
*
* <p>Quartz 작업을 등록하고 관리하는 데 사용됩니다.</p>
*/
private final QuartzScheduleService quartzScheduleService;
/**
* 애플리케이션 컨텍스트가 리프레시될 때 호출되는 메소드입니다.
* QuartzJob 어노테이션이 붙은 메소드와 AbstractBatchJob을 상속받은 클래스를 등록합니다.
*
*
* <p>QuartzJob 어노테이션이 붙은 메소드와 AbstractBatchJob을 상속받은 클래스를 등록합니다.</p>
*
* @param event ContextRefreshedEvent 객체
*/
@Override
@@ -51,11 +62,23 @@ public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedE
refreshJobs();
}
/**
* 리프레시된 스코프에서 Quartz 작업을 등록합니다.
*
* <p>리프레시된 스코프에서 Quartz 작업을 등록하기 위해 호출됩니다.</p>
*
* @param event RefreshScopeRefreshedEvent 객체
*/
@EventListener
public void onRefreshScopeRefreshed(@NonNull RefreshScopeRefreshedEvent event) {
refreshJobs();
}
/**
* Quartz 작업을 새로 고칩니다.
*
* <p>기존의 Quartz 작업을 지우고 새로운 작업을 등록합니다.</p>
*/
private void refreshJobs() {
quartzScheduleService.clearJobs();
registerBatchJobs();
@@ -63,6 +86,9 @@ public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedE
/**
* AbstractBatchJob을 상속받은 모든 클래스를 찾아 Quartz 스케줄러에 등록합니다.
*
* <p>애플리케이션 컨텍스트에서 AbstractBatch 타입의 빈을 검색하고,
* AOP 프록시가 아닌 경우 Quartz 작업을 생성합니다.</p>
*/
private void registerBatchJobs() {
Map<String, AbstractBatch> batchJobs = applicationContext.getBeansOfType(AbstractBatch.class);
@@ -74,6 +100,14 @@ public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedE
}
}
/**
* 스코프가 지정된 타겟 프리픽스를 제거합니다.
*
* <p>주어진 빈 이름에서 "scopedTarget." 접두사를 제거하여 반환합니다.</p>
*
* @param beanName 빈 이름
* @return 수정된 빈 이름
*/
private String removeScopedTargetPrefix(String beanName) {
return beanName.startsWith("scopedTarget.") ? beanName.substring("scopedTarget.".length()) : beanName;
}

View File

@@ -15,21 +15,56 @@ import com.spring.infra.batch.BatchJobInfoService;
import lombok.RequiredArgsConstructor;
/**
* Quartz 스케줄러를 관리하는 서비스 클래스입니다.
*
* <p>이 클래스는 Quartz 스케줄러의 작업을 생성, 삭제 및 관리하는 기능을 제공합니다.</p>
*
* <p>주요 기능:</p>
* <ul>
* <li>작업 및 트리거 생성</li>
* <li>작업 삭제 및 스케줄러 초기화</li>
* <li>작업 상태 관리</li>
* </ul>
*
* @author mindol
* @version 1.0
*/
@Service
@RequiredArgsConstructor
public class QuartzScheduleService {
/**
* Quartz 스케줄러 객체입니다.
*
* <p>스케줄러의 작업을 관리하는 데 사용됩니다.</p>
*/
private final Scheduler scheduler;
private final BatchJobInfoService batchJobInfoService;
/**
* 모든 작업을 삭제합니다.
*
* <p>스케줄러에 등록된 모든 작업을 삭제하고 초기화합니다.</p>
*
* @throws IllegalStateException 작업 삭제 중 오류가 발생한 경우
*/
public void clearJobs() {
try {
scheduler.clear();
} catch (SchedulerException e) {
throw new IllegalStateException();
throw new IllegalStateException("Failed to clear jobs from scheduler", e);
}
}
/**
* 새로운 작업 트리거를 생성합니다.
*
* <p>주어진 배치 작업 이름을 기반으로 작업과 트리거를 생성하고 스케줄러에 등록합니다.</p>
*
* @param beanName 배치 작업의 이름
* @throws IllegalStateException 작업 스케줄링 중 오류가 발생한 경우
*/
public void createJobTrigger(String beanName) {
BatchJobInfoData batchJobInfoData = batchJobInfoService.getBatchJobInfo(beanName);

View File

@@ -3,6 +3,14 @@ package com.spring.infra.security.config;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 애플리케이션에서 허용된 URI를 정의하는 열거형입니다.
*
* <p>이 열거형은 보안 설정에서 접근이 허용된 URI 경로를 관리합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Getter
@RequiredArgsConstructor
public enum PermittedURI {

View File

@@ -52,11 +52,15 @@ public class SecurityConfig {
/**
* Spring Security의 필터 체인을 구성합니다.
*
* @param http HttpSecurity 객체
* @param tokenService JWT 토큰 서비스
* @param authenticationEntryPoint JWT 인증 진입점
* @param accessDeniedHandler JWT 접근 거부 핸들러
* @return 구성된 SecurityFilterChain
* <p>이 메서드는 HTTP 보안 설정을 정의하고, JWT 인증 및 접근 제어를 설정합니다.</p>
*
* @param http HttpSecurity 객체, 보안 설정을 구성하는 데 사용됩니다.
* @param tokenService JWT 토큰 서비스, 인증 및 토큰 관리를 담당합니다.
* @param authenticationEntryPoint JWT 인증 진입점, 인증 실패 시 처리합니다.
* @param accessDeniedHandler JWT 접근 거부 핸들러, 접근 권한이 없는 경우 처리합니다.
* @param authenticationProcessingFilter 사용자 정의 인증 필터, 인증 프로세스를 처리합니다.
* @return 구성된 SecurityFilterChain, 보안 필터 체인을 반환합니다.
* @throws Exception 보안 설정 중 발생할 수 있는 예외를 처리합니다.
*/
@Bean
@Order(1)
@@ -107,7 +111,11 @@ public class SecurityConfig {
/**
* 특정 요청에 대해 보안 검사를 무시하도록 설정합니다.
*
* @return 구성된 SecurityFilterChain
* <p>이 메서드는 정적 리소스에 대한 접근을 허용하고, 보안 검사를 비활성화합니다.</p>
*
* @param http HttpSecurity 객체, 보안 설정을 구성하는 데 사용됩니다.
* @return 구성된 SecurityFilterChain, 보안 필터 체인을 반환합니다.
* @throws Exception 보안 설정 중 발생할 수 있는 예외를 처리합니다.
*/
@Bean
@Order(0)
@@ -124,18 +132,39 @@ public class SecurityConfig {
/**
* 비밀번호 인코더를 구성합니다.
*
* @return PasswordEncoder 객체
* <p>이 메서드는 비밀번호 인코딩 및 검증을 위한 PasswordEncoder 객체를 반환합니다.</p>
*
* @return PasswordEncoder 객체, 비밀번호 인코딩 및 검증을 담당합니다.
*/
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
/**
* 사용자 인증 관리자를 구성합니다.
*
* <p>이 메서드는 사용자 인증을 처리하는 AuthenticationManager 객체를 반환합니다.</p>
*
* @param provider 사용자 인증 제공자, 인증 프로세스를 처리합니다.
* @return AuthenticationManager 객체, 인증 요청을 처리합니다.
*/
@Bean
AuthenticationManager authenticationManager(UserAuthenticationProvider provider) {
return new ProviderManager(provider);
}
/**
* 사용자 정의 인증 필터를 구성합니다.
*
* <p>이 메서드는 인증 요청을 처리하는 AuthenticationProcessingFilter 객체를 반환합니다.</p>
*
* @param objectMapper JSON 변환을 위한 ObjectMapper 객체입니다.
* @param authenticationManager 인증 요청을 처리하는 AuthenticationManager 객체입니다.
* @param signinSuccessHandler 인증 성공 시 처리하는 핸들러입니다.
* @param signinFailureHandler 인증 실패 시 처리하는 핸들러입니다.
* @return AuthenticationProcessingFilter 객체, 인증 요청을 처리합니다.
*/
@Bean
AuthenticationProcessingFilter authenticationProcessingFilter(
ObjectMapper objectMapper,

View File

@@ -3,6 +3,14 @@ package com.spring.infra.security.config;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 애플리케이션에서 사용되는 보안 관련 URI를 정의하는 열거형입니다.
*
* <p>이 열거형은 보안 설정에서 사용되는 URI 경로를 관리하며, 각 URI는 특정 기능이나 페이지에 대한 접근을 나타냅니다.</p>
*
* @author mindol
* @version 1.0
*/
@Getter
@RequiredArgsConstructor
public enum SecurityURI {

View File

@@ -14,20 +14,54 @@ import com.spring.domain.user.entity.AgentUserRole;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 사용자 정보를 나타내는 클래스입니다.
*
* <p>이 클래스는 Spring Security의 UserDetails 인터페이스를 구현하여,
* 인증된 사용자의 정보를 제공하는 역할을 합니다.</p>
*
* <p>사용자 정보는 AgentUser 객체를 통해 관리되며, 사용자 권한 및 상태 정보를 포함합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Getter
@RequiredArgsConstructor
public class UserPrincipal implements UserDetails {
/**
* 인증된 사용자 정보를 담고 있는 AgentUser 객체입니다.
*/
private final transient AgentUser agentUser;
/**
* AgentUser 객체를 기반으로 UserPrincipal 객체를 생성합니다.
*
* @param agentUser 인증된 사용자 정보가 담긴 AgentUser 객체
* @return 생성된 UserPrincipal 객체
*/
public static UserPrincipal valueOf(AgentUser agentUser) {
return new UserPrincipal(agentUser);
}
/**
* 사용자 ID와 사용자 이름을 기반으로 UserPrincipal 객체를 생성합니다.
*
* @param userId 사용자 ID
* @param userName 사용자 이름
* @return 생성된 UserPrincipal 객체
*/
public static UserPrincipal of(String userId, String userName) {
return new UserPrincipal(AgentUser.builder().userId(userId).name(userName).build());
}
/**
* 사용자의 권한을 반환합니다.
*
* <p>사용자의 역할에 따라 GrantedAuthority 객체의 컬렉션을 반환합니다.</p>
*
* @return 사용자의 권한 목록
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.stream(AgentUserRole.values())
@@ -37,40 +71,80 @@ public class UserPrincipal implements UserDetails {
.collect(Collectors.toList());
}
/**
* 계정이 만료되지 않았는지 여부를 반환합니다.
*
* @return 계정이 만료되지 않았으면 true, 그렇지 않으면 false
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 계정이 잠겨 있지 않았는지 여부를 반환합니다.
*
* @return 계정이 잠겨 있지 않으면 true, 그렇지 않으면 false
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 자격 증명이 만료되지 않았는지 여부를 반환합니다.
*
* @return 자격 증명이 만료되지 않았으면 true, 그렇지 않으면 false
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
/**
* 계정이 활성화되었는지 여부를 반환합니다.
*
* @return 계정이 활성화되었으면 true, 그렇지 않으면 false
*/
@Override
public boolean isEnabled() {
return true;
}
/**
* 사용자의 비밀번호를 반환합니다.
*
* @return 사용자의 비밀번호
*/
@Override
public String getPassword() {
return agentUser.getPassword();
}
/**
* 사용자의 사용자 ID를 반환합니다.
*
* @return 사용자의 사용자 ID
*/
@Override
public String getUsername() {
return agentUser.getUserId();
}
/**
* 사용자의 고유 키를 반환합니다.
*
* @return 사용자의 고유 키
*/
public String getKey() {
return agentUser.getId().toString();
}
/**
* 사용자의 이름을 반환합니다.
*
* @return 사용자의 이름
*/
public String getMemberName() {
return agentUser.getName();
}

View File

@@ -17,37 +17,107 @@ import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.SignatureException;
import lombok.RequiredArgsConstructor;
/**
* 보안 관련 예외를 처리하는 클래스입니다.
*
* <p>이 클래스는 Spring의 @RestControllerAdvice를 사용하여
* 보안 관련 예외를 전역적으로 처리합니다.</p>
*
* <p>각 예외에 대해 적절한 응답을 생성하여 클라이언트에 반환합니다.</p>
*
* @author mindol
* @version 1.0
*/
@RestControllerAdvice
@RequiredArgsConstructor
public class SecurityExceptionHandler {
/**
* 접근 거부 핸들러.
*
* <p>AccessDeniedException이 발생했을 때 호출되며,
* 사용자에게 접근 거부 응답을 반환합니다.</p>
*/
private final AccessDeniedHandler accessDeniedHandler;
/**
* 사용자 인증 예외를 처리합니다.
*
* <p>SecurityAuthException이 발생했을 때 호출되며,
* 해당 예외에 대한 응답을 생성합니다.</p>
*
* @param e 발생한 SecurityAuthException
* @return SecurityErrorResponse 인증 오류에 대한 응답
*/
@ExceptionHandler(SecurityAuthException.class)
public SecurityErrorResponse handleAuthenticationException(SecurityAuthException e) {
return SecurityErrorResponse.valueOf(e.getExceptionRule());
}
/**
* 인증 예외를 처리합니다.
*
* <p>AuthenticationException이 발생했을 때 호출되며,
* 사용자 인증이 실패했음을 나타내는 응답을 생성합니다.</p>
*
* @return SecurityErrorResponse 사용자 인증 실패에 대한 응답
*/
@ExceptionHandler(AuthenticationException.class)
public SecurityErrorResponse handleAuthenticationException() {
return SecurityErrorResponse.valueOf(SecurityExceptionRule.USER_UNAUTHORIZED);
}
/**
* 서명 예외를 처리합니다.
*
* <p>SignatureException이 발생했을 때 호출되며,
* 서명 오류에 대한 응답을 생성합니다.</p>
*
* @return SecurityErrorResponse 서명 오류에 대한 응답
*/
@ExceptionHandler(SignatureException.class)
public SecurityErrorResponse handleSignatureException() {
return SecurityErrorResponse.valueOf(SecurityExceptionRule.SIGNATURE_ERROR);
}
/**
* 잘못된 JWT 예외를 처리합니다.
*
* <p>MalformedJwtException이 발생했을 때 호출되며,
* 잘못된 JWT에 대한 응답을 생성합니다.</p>
*
* @return SecurityErrorResponse 잘못된 JWT 오류에 대한 응답
*/
@ExceptionHandler(MalformedJwtException.class)
public SecurityErrorResponse handleMalformedJwtException() {
return SecurityErrorResponse.valueOf(SecurityExceptionRule.MALFORMED_JWT_ERROR);
}
/**
* 만료된 JWT 예외를 처리합니다.
*
* <p>ExpiredJwtException이 발생했을 때 호출되며,
* 만료된 JWT에 대한 응답을 생성합니다.</p>
*
* @return SecurityErrorResponse 만료된 JWT 오류에 대한 응답
*/
@ExceptionHandler(ExpiredJwtException.class)
public SecurityErrorResponse handleExpiredJwtException() {
return SecurityErrorResponse.valueOf(SecurityExceptionRule.EXPIRED_JWT_ERROR);
}
/**
* 접근 거부 예외를 처리합니다.
*
* <p>AccessDeniedException이 발생했을 때 호출되며,
* 접근 거부 핸들러를 사용하여 적절한 응답을 반환합니다.</p>
*
* @param ex 발생한 AccessDeniedException
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @throws IOException 입출력 예외
* @throws ServletException 서블릿 예외
*/
@ExceptionHandler(AccessDeniedException.class)
public void handleAccessDeniedException(
AccessDeniedException ex, HttpServletRequest request, HttpServletResponse response

View File

@@ -23,19 +23,64 @@ import com.spring.infra.security.dto.SignInRequest;
import com.spring.infra.security.error.SecurityAuthException;
import com.spring.infra.security.error.SecurityExceptionRule;
/**
* 사용자 인증 요청을 처리하는 필터 클래스입니다.
*
* <p>이 클래스는 Spring Security의 AbstractAuthenticationProcessingFilter를 확장하여,
* 사용자 로그인 요청을 처리하고 인증을 수행합니다.</p>
*
* <p>HTTP POST 요청을 통해 사용자 이름과 비밀번호를 받아 인증을 시도하며,
* 인증 결과에 따라 적절한 응답을 반환합니다.</p>
*
* @author mindol
* @version 1.0
*/
public class AuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter {
/**
* 기본 로그인 요청 URI.
*/
private static final String DEFAULT_LOGIN_REQUEST_URI = PermittedURI.USER_SIGN_IN.getUri();
private static final String HTTP_METHOD = "POST";
private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER = new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URI, HTTP_METHOD);
/**
* HTTP 메서드 (POST).
*/
private static final String HTTP_METHOD = "POST";
/**
* 기본 로그인 경로 요청 매처.
*/
private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URI, HTTP_METHOD);
/**
* JSON 변환을 위한 ObjectMapper 객체.
*/
private final ObjectMapper objectMapper;
/**
* AuthenticationProcessingFilter 생성자.
*
* @param objectMapper JSON 변환을 위한 ObjectMapper 객체
*/
public AuthenticationProcessingFilter(ObjectMapper objectMapper) {
super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER);
this.objectMapper = objectMapper;
}
/**
* 사용자 인증을 시도합니다.
*
* <p>HTTP 요청에서 사용자 이름과 비밀번호를 추출하고,
* 인증 매니저를 사용하여 인증을 수행합니다.</p>
*
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @return 인증된 Authentication 객체
* @throws AuthenticationException 인증 실패 시 발생하는 예외
* @throws IOException 입출력 예외
* @throws ServletException 서블릿 예외
*/
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
@@ -49,7 +94,7 @@ public class AuthenticationProcessingFilter extends AbstractAuthenticationProces
if (!isValidRequest(signInRequest)) {
throw new SecurityAuthException(SecurityExceptionRule.USER_BAD_REQUEST);
}
var token = new UsernamePasswordAuthenticationToken(signInRequest.getUsername(), signInRequest.getPassword());
var token = new UsernamePasswordAuthenticationToken(signInRequest.getUsername(), signInRequest.getPassword());
var authentication = this.getAuthenticationManager().authenticate(token);
if (authentication.getPrincipal() instanceof UserPrincipal) {
@@ -62,11 +107,27 @@ public class AuthenticationProcessingFilter extends AbstractAuthenticationProces
return authentication;
}
/**
* 요청의 유효성 유형을 검사합니다.
*
* <p>요청 메서드가 POST이고, 콘텐츠 타입이 JSON인지 확인합니다.</p>
*
* @param request HTTP 요청 객체
* @return 요청이 유효하면 true, 그렇지 않으면 false
*/
private boolean isValidRequestType(HttpServletRequest request) {
return Objects.equals(request.getMethod(), HttpMethod.POST.name()) &&
Objects.equals(request.getContentType(), MediaType.APPLICATION_JSON_VALUE);
}
/**
* 사용자 로그인 요청의 유효성을 검사합니다.
*
* <p>사용자 이름과 비밀번호가 모두 존재하는지 확인합니다.</p>
*
* @param signInRequest 로그인 요청 객체
* @return 요청이 유효하면 true, 그렇지 않으면 false
*/
private boolean isValidRequest(SignInRequest signInRequest) {
return StringUtils.hasText(signInRequest.getUsername()) ||
StringUtils.hasText(signInRequest.getPassword());

View File

@@ -26,13 +26,23 @@ import lombok.RequiredArgsConstructor;
*
* <p>이 필터는 요청마다 JWT 토큰을 검증하고, 필요한 경우 토큰을 갱신합니다.</p>
*
* <p>인증된 사용자의 요청을 처리하기 위해 JWT 토큰을 사용하며,
* 유효한 토큰이 없거나 만료된 경우에는 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급합니다.</p>
*
* @author mindol
* @version 1.0
*/
@RequiredArgsConstructor
public final class JwtAuthenticationFilter extends OncePerRequestFilter {
/**
* JWT 토큰 서비스를 제공하는 서비스 객체입니다.
*/
private final JwtTokenService jwtTokenService;
/**
* 예외 속성 이름.
*/
private static final String EXCEPTION_ATTRIBUTE = "exception";
/**
@@ -43,6 +53,8 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter {
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @param filterChain 필터 체인
* @throws ServletException 서블릿 예외
* @throws IOException 입출력 예외
*/
@Override
protected void doFilterInternal(
@@ -51,16 +63,20 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter {
@NonNull FilterChain filterChain
) throws ServletException, IOException {
// 허용된 URI에 대한 요청은 필터 체인을 계속 진행합니다.
if (isPermittedURI(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
try {
// 요청에서 액세스 토큰을 파싱합니다.
String accessToken = parseBearerToken(request, HttpHeaders.AUTHORIZATION);
if (jwtTokenService.validateAccessToken(accessToken)) {
// 유효한 액세스 토큰인 경우 인증 정보를 설정합니다.
setAuthenticationToContext(accessToken);
} else {
// 액세스 토큰이 유효하지 않은 경우 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급합니다.
String refreshToken = jwtTokenService.resolveTokenFromCookie(request, JwtTokenRule.REFRESH_PREFIX);
jwtTokenService.validateToken(refreshToken);
String reissuedAccessToken = jwtTokenService.getRefreshToken(refreshToken);
@@ -69,24 +85,34 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter {
setAuthenticationToContext(jwtTokenService.generateAccessToken(response, authentication));
}
} catch (Exception e) {
// 예외 발생 시 쿠키를 삭제하고 예외 정보를 요청 속성에 설정합니다.
jwtTokenService.deleteCookie(response);
request.setAttribute(EXCEPTION_ATTRIBUTE, e);
}
// 필터 체인을 계속 진행합니다.
filterChain.doFilter(request, response);
}
/**
* 인증 정보를 SecurityContext에 설정합니다.
*
* @param token 토큰
* @param token JWT 토큰
*/
private void setAuthenticationToContext(final String token) {
Authentication authentication = jwtTokenService.getAccessAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
/**
* Bearer 토큰을 파싱하여 반환합니다.
*
* <p>HTTP 요청 헤더에서 Bearer 토큰을 추출하고, 쿠키에서 액세스 토큰을 가져옵니다.</p>
*
* @param request HTTP 요청 객체
* @param headerName 헤더 이름
* @return 파싱된 Bearer 토큰
*/
private String parseBearerToken(final HttpServletRequest request, final String headerName) {
return Optional.ofNullable(request.getHeader(headerName))
.filter(token -> token.substring(0, 7).equalsIgnoreCase(JwtTokenRule.BEARER_PREFIX.getValue()))
@@ -94,6 +120,14 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter {
.orElse(jwtTokenService.resolveTokenFromCookie(request, JwtTokenRule.ACCESS_PREFIX));
}
/**
* 허용된 URI인지 확인합니다.
*
* <p>요청 URI가 허용된 URI 목록에 포함되어 있는지 확인합니다.</p>
*
* @param requestURI 요청 URI
* @return 허용된 URI이면 true, 그렇지 않으면 false
*/
private boolean isPermittedURI(String requestURI) {
return Arrays.stream(PermittedURI.values())
.map(PermittedURI::getUri)

View File

@@ -15,21 +15,50 @@ import org.springframework.web.filter.OncePerRequestFilter;
import com.spring.infra.security.config.PermittedURI;
import com.spring.infra.security.config.SecurityURI;
/**
* 사용자가 인증된 경우 특정 URI로 리다이렉트하는 필터 클래스입니다.
*
* <p>이 필터는 사용자가 인증된 상태에서 루트 URI에 접근할 경우,
* 지정된 리다이렉트 URI로 이동하도록 처리합니다.</p>
*
* <p>인증된 사용자가 루트 URI에 접근하면, 사용자는 대시보드와 같은
* 다른 페이지로 리다이렉트됩니다.</p>
*
* @author mindol
* @version 1.0
*/
public class RedirectIfAuthenticatedFilter extends OncePerRequestFilter {
/**
* 필터의 내부 로직을 구현하는 메서드입니다.
*
* <p>HTTP 요청을 검사하여 사용자가 인증된 경우 리다이렉트를 수행합니다.</p>
*
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @param filterChain 필터 체인
* @throws ServletException 서블릿 예외
* @throws IOException 입출력 예외
*/
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String requestURI = request.getRequestURI();
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// 사용자가 인증된 상태이고 요청 URI가 루트 URI인 경우 리다이렉트 수행
if (auth != null && auth.isAuthenticated() && PermittedURI.ROOT_URI.getUri().equals(requestURI)) {
response.sendRedirect(SecurityURI.REDIRECT_URI.getUri());
return;
}
// 필터 체인을 계속 진행
filterChain.doFilter(request, response);
}
}
}

View File

@@ -11,27 +11,55 @@ import org.springframework.web.util.ServletRequestPathUtils;
import lombok.RequiredArgsConstructor;
/**
* HTTP 요청의 엔드포인트 존재 여부를 확인하는 클래스입니다.
*
* <p>이 클래스는 주어진 HTTP 요청이 유효한 엔드포인트에 해당하는지 검사합니다.
* Spring의 RequestMappingHandlerMapping을 사용하여 요청에 대한 핸들러를 조회합니다.</p>
*
* <p>요청 경로가 파싱되지 않은 경우, 이를 파싱하고 캐시하여 이후 요청에서 재사용할 수 있도록 합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Component
@RequiredArgsConstructor
public class HttpRequestEndpointChecker {
/**
* 요청 매핑 핸들러 매핑 객체입니다.
*
* <p>Spring MVC의 요청 매핑을 처리하는 데 사용됩니다.</p>
*/
@Qualifier("requestMappingHandlerMapping")
private final RequestMappingHandlerMapping requestMapping;
/**
* 주어진 HTTP 요청이 유효한 엔드포인트인지 확인합니다.
*
* <p>요청이 유효한 엔드포인트에 해당하면 true를 반환하고, 그렇지 않으면 false를 반환합니다.</p>
*
* @param request 확인할 HTTP 요청 객체
* @return 요청이 유효한 엔드포인트이면 true, 그렇지 않으면 false
*/
public boolean isEndpointExist(HttpServletRequest request) {
try {
// 요청 경로가 파싱되지 않은 경우 파싱합니다.
if (!ServletRequestPathUtils.hasParsedRequestPath(request)) {
ServletRequestPathUtils.parseAndCache(request);
}
// 요청에 대한 핸들러를 조회합니다.
HandlerExecutionChain handler = requestMapping.getHandler(request);
if (handler != null) {
HandlerMethod method = (HandlerMethod) handler.getHandler();
// 핸들러가 존재하면 true를 반환합니다.
if (method != null) return true;
}
} catch (Exception e) {
// 예외 발생 시 false를 반환합니다.
return false;
}
// 핸들러가 존재하지 않으면 false를 반환합니다.
return false;
}
}

View File

@@ -21,10 +21,12 @@ import com.spring.infra.security.error.SecurityExceptionRule;
import lombok.RequiredArgsConstructor;
/**
* JWT 인증에서 접근 거부 상황을 처리하는 핸들러 클래스입니다.
* 접근 거부 상황을 처리하는 핸들러 클래스입니다.
*
* <p>이 클래스는 사용자가 인증되었지만 특정 리소스에 대한 접근 권한이 없는 경우를 처리합니다.
* 이 경우 SC_FORBIDDEN (403) 응답을 반환합니다.</p>
* <p>이 클래스는 사용자가 인증되었지만 특정 리소스에 대한 접근 권한이 없는 경우를 처리합니다.
* 이 경우 SC_FORBIDDEN (403) 응답을 반환합니다.</p>
*
* <p>API 요청과 일반 요청을 구분하여 각각에 대해 적절한 응답을 생성합니다.</p>
*
* @author mindol
* @version 1.0
@@ -33,15 +35,34 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class SecurityAccessDeniedHandler implements AccessDeniedHandler {
/**
* 애플리케이션 컨텍스트 객체입니다.
*
* <p>Spring의 ApplicationContext를 사용하여 필요한 빈을 가져옵니다.</p>
*/
private final ApplicationContext applicationContext;
/**
* 접근 거부 상황을 처리합니다.
*
* <p>HTTP 요청을 검사하여 API 요청인 경우 HandlerExceptionResolver를 사용하여
* 예외를 처리하고, 일반 요청인 경우 403 Forbidden 응답을 반환합니다.</p>
*
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @param accessDeniedException 접근 거부 예외
* @throws IOException 입출력 예외
* @throws ServletException 서블릿 예외
*/
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
if (isApiRequest(request)) {
// API 요청인 경우 예외를 처리합니다.
HandlerExceptionResolver resolver = applicationContext.getBean("handlerExceptionResolver", HandlerExceptionResolver.class);
resolver.resolveException(request, response, null, new SecurityAuthException(SecurityExceptionRule.USER_FORBIDDEN));
} else {
// 일반 요청인 경우 403 Forbidden 응답을 설정합니다.
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
request.setAttribute(RequestDispatcher.ERROR_STATUS_CODE, HttpServletResponse.SC_FORBIDDEN);
request.setAttribute(RequestDispatcher.ERROR_MESSAGE, SecurityExceptionRule.USER_FORBIDDEN.getMessage());
@@ -50,6 +71,14 @@ public class SecurityAccessDeniedHandler implements AccessDeniedHandler {
}
}
/**
* 요청이 API 요청인지 확인합니다.
*
* <p>HTTP 요청의 Accept 헤더를 검사하여 JSON 형식의 응답을 요구하는지 확인합니다.</p>
*
* @param request HTTP 요청 객체
* @return API 요청이면 true, 그렇지 않으면 false
*/
private boolean isApiRequest(HttpServletRequest request) {
String accept = request.getHeader(HttpHeaders.ACCEPT);
return accept != null && accept.contains(MediaType.APPLICATION_JSON_VALUE);

View File

@@ -19,7 +19,12 @@ import com.spring.infra.security.config.PermittedURI;
import lombok.RequiredArgsConstructor;
/**
* JWT 인증에서 인증되지 않은 접근을 처리하는 진입점 클래스입니다.
* 인증되지 않은 접근을 처리하는 진입점 클래스입니다.
*
* <p>이 클래스는 Spring Security의 AuthenticationEntryPoint 인터페이스를 구현하여,
* 인증되지 않은 사용자가 보호된 리소스에 접근할 때 적절한 응답을 반환합니다.</p>
*
* <p>API 요청과 일반 요청을 구분하여 각각에 대해 적절한 처리 로직을 수행합니다.</p>
*
* @author mindol
* @version 1.0
@@ -28,34 +33,82 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint {
/**
* 예외 속성 이름.
*/
private static final String EXCEPTION_ATTRIBUTE = "exception";
/**
* 예외 처리 리졸버.
*
* <p>Spring의 HandlerExceptionResolver를 사용하여 예외를 처리합니다.</p>
*/
@Qualifier("handlerExceptionResolver")
private final HandlerExceptionResolver resolver;
/**
* HTTP 요청의 엔드포인트 존재 여부를 확인하는 체크 클래스.
*/
private final HttpRequestEndpointChecker endpointChecker;
/**
* 인증되지 않은 사용자가 보호된 리소스에 접근할 때 호출됩니다.
*
* <p>요청된 엔드포인트가 존재하지 않으면 404 Not Found 응답을 반환하고,
* API 요청인 경우에는 예외를 처리하여 적절한 응답을 반환합니다.</p>
*
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @param authException 인증 예외
* @throws IOException 입출력 예외
* @throws ServletException 서블릿 예외
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
if (!endpointChecker.isEndpointExist(request)) {
// 요청된 엔드포인트가 존재하지 않는 경우 404 응답을 반환합니다.
response.sendError(HttpServletResponse.SC_NOT_FOUND);
} else if (isApiRequest(request)) {
// API 요청인 경우 예외를 처리합니다.
handleApiRequest(request, response, authException);
} else {
// 일반 요청인 경우 루트 URI로 리다이렉트합니다.
response.sendRedirect(PermittedURI.ROOT_URI.getUri());
}
}
/**
* 요청이 API 요청인지 확인합니다.
*
* <p>HTTP 요청의 Accept 헤더를 검사하여 JSON 형식의 응답을 요구하는지 확인합니다.</p>
*
* @param request HTTP 요청 객체
* @return API 요청이면 true, 그렇지 않으면 false
*/
private boolean isApiRequest(HttpServletRequest request) {
String accept = request.getHeader(HttpHeaders.ACCEPT);
return accept != null && accept.contains(MediaType.APPLICATION_JSON_VALUE);
}
private void handleApiRequest(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
/**
* API 요청에 대한 예외를 처리합니다.
*
* <p>인증 예외가 발생한 경우 적절한 응답을 생성하여 클라이언트에 반환합니다.</p>
*
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @param authException 인증 예외
*/
private void handleApiRequest(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) {
if (request.getAttribute(EXCEPTION_ATTRIBUTE) != null) {
resolver.resolveException(request, response, null, (Exception) request.getAttribute(EXCEPTION_ATTRIBUTE));
// 요청 속성에 예외가 설정된 경우 해당 예외를 처리합니다.
resolver.resolveException(request, response, null,
(Exception) request.getAttribute(EXCEPTION_ATTRIBUTE));
} else {
// 인증 예외를 처리합니다.
resolver.resolveException(request, response, null, authException);
}
}
}

View File

@@ -11,15 +11,46 @@ import com.spring.infra.security.jwt.JwtTokenService;
import lombok.RequiredArgsConstructor;
/**
* 사용자 로그아웃 처리를 담당하는 핸들러 클래스입니다.
*
* <p>이 클래스는 Spring Security의 LogoutHandler 인터페이스를 구현하여,
* 사용자가 로그아웃할 때 필요한 작업을 수행합니다.</p>
*
* <p>로그아웃 시 JWT 리프레시 토큰을 삭제하고, 관련 쿠키를 제거합니다.</p>
*
* @author mindol
* @version 1.0
*/
@RequiredArgsConstructor
public class SignOutHandler implements LogoutHandler {
/**
* JWT 토큰 서비스를 제공하는 서비스 객체입니다.
*
* <p>로그아웃 시 리프레시 토큰을 삭제하고 쿠키를 제거하는 데 사용됩니다.</p>
*/
private final JwtTokenService jwtTokenService;
/**
* 사용자가 로그아웃할 때 호출되는 메서드입니다.
*
* <p>HTTP 요청에서 리프레시 토큰을 추출하고, 해당 토큰을 삭제한 후,
* 관련 쿠키를 제거합니다.</p>
*
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @param authentication 인증 정보
*/
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
// 요청에서 리프레시 토큰을 추출합니다.
String refreshToken = jwtTokenService.resolveTokenFromCookie(request, JwtTokenRule.REFRESH_PREFIX);
// 리프레시 토큰을 삭제합니다.
jwtTokenService.deleteRefreshToken(jwtTokenService.getUserPk(refreshToken));
// 관련 쿠키를 제거합니다.
jwtTokenService.deleteCookie(response);
}

View File

@@ -14,19 +14,47 @@ import org.springframework.web.servlet.HandlerExceptionResolver;
import lombok.RequiredArgsConstructor;
/**
* 사용자 로그인 실패를 처리하는 핸들러 클래스입니다.
*
* <p>이 클래스는 Spring Security의 AuthenticationFailureHandler 인터페이스를 구현하여,
* 사용자 인증 실패 시 적절한 처리를 수행합니다.</p>
*
* <p>로그인 실패 시 발생하는 예외를 처리하고, 이를 기반으로 적절한 응답을 생성합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Component
@RequiredArgsConstructor
public class SigninFailureHandler implements AuthenticationFailureHandler {
/**
* 예외 처리 리졸버.
*
* <p>Spring의 HandlerExceptionResolver를 사용하여 예외를 처리합니다.</p>
*/
@Qualifier("handlerExceptionResolver")
private final HandlerExceptionResolver resolver;
/**
* 사용자 인증 실패 시 호출되는 메서드입니다.
*
* <p>인증 실패 시 발생하는 예외를 처리하고, 적절한 응답을 생성합니다.</p>
*
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @param exception 인증 예외
* @throws IOException 입출력 예외
* @throws ServletException 서블릿 예외
*/
@Override
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception
) throws IOException, ServletException {
// 인증 실패 시 예외를 처리합니다.
resolver.resolveException(request, response, null, exception);
}

View File

@@ -22,14 +22,54 @@ import com.spring.infra.security.jwt.JwtTokenService;
import lombok.RequiredArgsConstructor;
/**
* 사용자 로그인 성공을 처리하는 핸들러 클래스입니다.
*
* <p>이 클래스는 Spring Security의 AuthenticationSuccessHandler 인터페이스를 구현하여,
* 사용자 인증 성공 시 적절한 처리를 수행합니다.</p>
*
* <p>로그인 성공 시 JWT 토큰을 생성하고, 사용자를 지정된 URL로 리다이렉트합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Component
@RequiredArgsConstructor
public class SigninSuccessHandler implements AuthenticationSuccessHandler {
/**
* JWT 토큰 서비스를 제공하는 서비스 객체입니다.
*
* <p>사용자 인증 성공 시 JWT 토큰을 생성하는 데 사용됩니다.</p>
*/
private final JwtTokenService jwtTokenService;
/**
* JSON 변환을 위한 ObjectMapper 객체입니다.
*
* <p>로그인 성공 시 사용자 정보를 JSON 형식으로 변환하는 데 사용됩니다.</p>
*/
private final ObjectMapper objectMapper;
/**
* 요청 캐시 객체입니다.
*
* <p>사용자가 인증되기 전에 요청된 URL을 저장하는 데 사용됩니다.</p>
*/
private RequestCache requestCache = new HttpSessionRequestCache();
/**
* 사용자 인증 성공 시 호출되는 메서드입니다.
*
* <p>인증 성공 시 JWT 액세스 토큰과 리프레시 토큰을 생성하고,
* 사용자를 지정된 URL로 리다이렉트합니다.</p>
*
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @param authentication 인증 정보
* @throws IOException 입출력 예외
* @throws ServletException 서블릿 예외
*/
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
@@ -37,16 +77,19 @@ public class SigninSuccessHandler implements AuthenticationSuccessHandler {
Authentication authentication
) throws IOException, ServletException {
// 액세스 토큰 생성
jwtTokenService.generateAccessToken(response, authentication);
// 리프레시 토큰 저장
jwtTokenService.saveRefreshToken(authentication.getName(), jwtTokenService.generateRefreshToken(response, authentication));
// 요청 캐시에서 저장된 요청을 가져옵니다.
SavedRequest savedRequest = requestCache.getRequest(request, response);
String targetUrl = (savedRequest != null) ? savedRequest.getRedirectUrl() : SecurityURI.REDIRECT_URI.getUri();
// 응답 설정
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write(objectMapper.writeValueAsString(SignResponse.of(true, targetUrl)));
}
}

View File

@@ -19,7 +19,8 @@ import lombok.RequiredArgsConstructor;
/**
* JWT 토큰을 생성하는 클래스입니다.
*
* <p>이 클래스는 액세스 토큰과 리프레시 토큰을 생성하는 기능을 제공합니다.</p>
* <p>이 클래스는 액세스 토큰과 리프레시 토큰을 생성하는 기능을 제공합니다.
* 사용자의 인증 정보를 기반으로 JWT 토큰을 생성하며, 토큰의 유효 기간과 서명 알고리즘을 설정합니다.</p>
*
* @author mindol
* @version 1.0
@@ -28,12 +29,22 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class JwtTokenGenerator {
/**
* JWT 토큰의 속성을 설정하는 클래스입니다.
*/
private final JwtProperties jwtProperties;
/**
* JWT 서명 키를 제공하는 유틸리티 클래스입니다.
*/
private final JwtTokenUtil jwtTokenUtil;
/**
* 액세스 토큰을 생성합니다.
*
* <p>사용자의 인증 정보를 기반으로 JWT 액세스 토큰을 생성합니다.
* 생성된 토큰은 지정된 만료 시간과 서명 알고리즘을 사용하여 서명됩니다.</p>
*
* @param authentication 인증 정보
* @return 생성된 액세스 토큰
*/
@@ -52,6 +63,9 @@ public class JwtTokenGenerator {
/**
* 리프레시 토큰을 생성합니다.
*
* <p>사용자의 인증 정보를 기반으로 JWT 리프레시 토큰을 생성합니다.
* 생성된 토큰은 지정된 만료 시간과 서명 알고리즘을 사용하여 서명됩니다.</p>
*
* @param authentication 인증 정보
* @return 생성된 리프레시 토큰
*/
@@ -68,6 +82,9 @@ public class JwtTokenGenerator {
/**
* JWT 헤더를 생성합니다.
*
* <p>JWT 토큰의 헤더 정보를 설정하는 메서드입니다.
* 헤더에는 토큰의 유형과 서명 알고리즘이 포함됩니다.</p>
*
* @return JWT 헤더 맵
*/
private Map<String, Object> createHeader() {
@@ -80,7 +97,10 @@ public class JwtTokenGenerator {
/**
* JWT 클레임을 생성합니다.
*
* @param authentication 인증 정보
* <p>사용자의 정보를 기반으로 JWT 클레임을 설정하는 메서드입니다.
* 클레임에는 사용자 ID, 이름 및 권한 정보가 포함됩니다.</p>
*
* @param user 사용자 정보
* @return JWT 클레임 맵
*/
private Map<String, Object> createClaims(UserPrincipal user) {

View File

@@ -28,9 +28,12 @@ import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
/**
* JWT 토큰 관련 서비스를 제공하는 클래스입니다.
* JWT 토큰을 생성하고 검증하는 서비스를 제공하는 클래스입니다.
*
* <p>이 클래스는 JWT 토큰의 생성, 검증, 해석 등 다양한 토큰 관련 기능을 제공합니다.</p>
* <p>이 클래스는 JWT 토큰의 생성, 검증, 파싱 및 사용자 인증 정보를 관리하는 기능을 제공합니다.</p>
*
* <p>액세스 토큰과 리프레시 토큰을 생성하고, 토큰의 유효성을 검사하며,
* 쿠키에서 토큰을 추출하는 등의 작업을 수행합니다.</p>
*
* @author mindol
* @version 1.0
@@ -38,24 +41,80 @@ import io.jsonwebtoken.Jwts;
@Service
public class JwtTokenService {
private final JwtTokenUtil jwtTokenUtil;
private final JwtTokenGenerator jwtTokenGenerator;
private final UserAuthenticationService userAuthenticationService;
private final RefreshTokenService refreshTokenService;
/**
* JWT 비밀 키.
*
* <p>토큰의 서명을 생성하고 검증하는 데 사용되는 비밀 키입니다.</p>
*/
private final Key accessSecretKey;
/**
* JWT 리프레시 비밀 키.
*
* <p>리프레시 토큰의 서명을 생성하고 검증하는 데 사용되는 비밀 키입니다.</p>
*/
private final Key refreshSecretKey;
/**
* 액세스 토큰의 만료 시간.
*
* <p>토큰이 유효한 시간을 설정하는 데 사용됩니다.</p>
*/
private final long accessExpiration;
/**
* 리프레시 토큰의 만료 시간.
*
* <p>리프레시 토큰이 유효한 시간을 설정하는 데 사용됩니다.</p>
*/
private final long refreshExpiration;
/**
* JWT 토큰 생성기.
*
* <p>액세스 토큰과 리프레시 토큰을 생성하는 데 사용됩니다.</p>
*/
private final JwtTokenGenerator jwtTokenGenerator;
/**
* JWT 유틸리티 클래스.
*
* <p>토큰의 서명 및 검증을 위한 유틸리티 메서드를 제공합니다.</p>
*/
private final JwtTokenUtil jwtTokenUtil;
/**
* 사용자 인증 서비스.
*
* <p>사용자 인증 정보를 관리하는 서비스입니다.</p>
*/
private final UserAuthenticationService userAuthenticationService;
/**
* 리프레시 토큰 서비스.
*
* <p>리프레시 토큰을 관리하는 서비스입니다.</p>
*/
private final RefreshTokenService refreshTokenService;
/**
* JwtTokenService 생성자.
*
* @param jwtTokenGenerator JWT 토큰 생성기
* @param jwtTokenUtil JWT 유틸리티 클래스
* @param userAuthenticationService 사용자 인증 서비스
* @param refreshTokenService 리프레시 토큰 서비스
* @param jwtProperties JWT 속성
*/
public JwtTokenService(
JwtTokenUtil jwtTokenUtil,
JwtTokenGenerator jwtTokenGenerator,
JwtTokenUtil jwtTokenUtil,
UserAuthenticationService userAuthenticationService,
RefreshTokenService refreshTokenService,
JwtProperties jwtProperties
) {
this.jwtTokenUtil = jwtTokenUtil;
this.jwtTokenGenerator = jwtTokenGenerator;
this.jwtTokenUtil = jwtTokenUtil;
this.userAuthenticationService = userAuthenticationService;
this.refreshTokenService = refreshTokenService;
this.accessSecretKey = jwtTokenUtil.getSigningKey(jwtProperties.getAccessToken().getSecret());
@@ -80,7 +139,7 @@ public class JwtTokenService {
}
/**
* 리프레시 토큰을 생성하고 쿠키에 저장다.
* 리프레시 토큰을 생성하고 쿠키에 저장합니다.
*
* @param response HTTP 응답 객체
* @param authentication 인증 정보
@@ -102,10 +161,10 @@ public class JwtTokenService {
* @param maxAgeSeconds 쿠키 유효 시간(초)
* @return 생성된 ResponseCookie 객체
*/
private ResponseCookie setTokenToCookie(String tokenPrefix, String token, long maxAgeMinutes) {
private ResponseCookie setTokenToCookie(String tokenPrefix, String token, long maxAgeSeconds) {
return ResponseCookie.from(tokenPrefix, token)
.path("/")
.maxAge(Duration.ofMinutes(maxAgeMinutes))
.maxAge(Duration.ofSeconds(maxAgeSeconds))
.httpOnly(true)
.sameSite("None")
.secure(true)
@@ -187,14 +246,31 @@ public class JwtTokenService {
return new UsernamePasswordAuthenticationToken(user, "", null);
}
/**
* 리프레시 토큰을 저장합니다.
*
* @param userId 사용자 ID
* @param token 리프레시 토큰
*/
public void saveRefreshToken(String userId, String token) {
refreshTokenService.saveRefreshToken(userId, token);
}
/**
* 리프레시 토큰을 가져옵니다.
*
* @param token JWT 토큰
* @return 리프레시 토큰
*/
public String getRefreshToken(String token) {
return refreshTokenService.getRefreshToken(getUserPk(token));
}
/**
* 리프레시 토큰을 삭제합니다.
*
* @param key 사용자 ID
*/
public void deleteRefreshToken(String key) {
refreshTokenService.deleteRefreshToken(key);
}

View File

@@ -23,6 +23,8 @@ import io.jsonwebtoken.security.SignatureException;
*
* <p>이 클래스는 JWT 토큰의 상태 확인, 쿠키에서 토큰 추출, 서명 키 생성 등의 기능을 제공합니다.</p>
*
* <p>JWT 토큰의 생성 및 검증에 필요한 다양한 메서드를 포함하고 있습니다.</p>
*
* @author mindol
* @version 1.0
*/
@@ -32,6 +34,8 @@ public class JwtTokenUtil {
/**
* JWT 토큰의 상태를 확인합니다.
*
* <p>주어진 JWT 토큰이 유효한지, 만료되었는지, 또는 잘못된 것인지 확인합니다.</p>
*
* @param token 검증할 JWT 토큰
* @param secretKey 토큰 검증에 사용할 비밀 키
* @return 토큰의 상태 (AUTHENTICATED, EXPIRED, INVALID)
@@ -53,8 +57,14 @@ public class JwtTokenUtil {
/**
* JWT 토큰의 상태를 확인하고 예외를 던집니다.
*
* <p>주어진 JWT 토큰이 유효한지 확인하고, 유효하지 않은 경우 적절한 예외를 발생시킵니다.</p>
*
* @param token 검증할 JWT 토큰
* @param secretKey 토큰 검증에 사용할 비밀 키
* @throws ExpiredJwtException 토큰이 만료된 경우 발생
* @throws SignatureException 서명 오류가 발생한 경우 발생
* @throws MalformedJwtException 잘못된 JWT 형식인 경우 발생
* @throws JwtException 기타 JWT 관련 오류가 발생한 경우 발생
*/
public void tokenStatus(String token, Key secretKey) {
try {
@@ -76,6 +86,8 @@ public class JwtTokenUtil {
/**
* 쿠키에서 특정 접두사를 가진 토큰을 추출합니다.
*
* <p>주어진 쿠키 배열에서 특정 접두사를 가진 JWT 토큰을 찾아 반환합니다.</p>
*
* @param cookies 쿠키 배열
* @param tokenPrefix 토큰 접두사
* @return 추출된 토큰 값 (없으면 빈 문자열)
@@ -91,6 +103,8 @@ public class JwtTokenUtil {
/**
* 주어진 비밀 키로 서명 키를 생성합니다.
*
* <p>주어진 비밀 키 문자열을 기반으로 HMAC 서명 키를 생성합니다.</p>
*
* @param secretKey 비밀 키 문자열
* @return 생성된 서명 키
*/
@@ -102,6 +116,8 @@ public class JwtTokenUtil {
/**
* 문자열을 Base64로 인코딩합니다.
*
* <p>주어진 문자열을 Base64 형식으로 인코딩하여 반환합니다.</p>
*
* @param secretKey 인코딩할 문자열
* @return Base64로 인코딩된 문자열
*/
@@ -112,6 +128,8 @@ public class JwtTokenUtil {
/**
* 토큰을 리셋하는 쿠키를 생성합니다.
*
* <p>주어진 토큰 접두사에 대해 만료된 쿠키를 생성합니다.</p>
*
* @param tokenPrefix 토큰 접두사
* @return 생성된 쿠키 객체
*/

View File

@@ -15,30 +15,81 @@ import com.spring.infra.security.error.SecurityExceptionRule;
import lombok.RequiredArgsConstructor;
/**
* 사용자 인증을 처리하는 클래스입니다.
*
* <p>이 클래스는 Spring Security의 AuthenticationProvider 인터페이스를 구현하여,
* 사용자 이름과 비밀번호를 기반으로 인증을 수행합니다.</p>
*
* <p>사용자의 비밀번호는 암호화된 형태로 저장되며, 입력된 비밀번호와 비교하여
* 인증의 유효성을 검사합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Component
@RequiredArgsConstructor
public class UserAuthenticationProvider implements AuthenticationProvider {
/**
* 비밀번호 인코더.
*
* <p>사용자의 비밀번호를 암호화하고 검증하는 데 사용됩니다.</p>
*/
private final PasswordEncoder passwordEncoder;
/**
* 사용자 세부 정보를 로드하는 서비스.
*
* <p>사용자 이름을 기반으로 사용자 정보를 가져오는 데 사용됩니다.</p>
*/
private final UserDetailsService userDetailsService;
/**
* 인증을 수행합니다.
*
* <p>주어진 인증 정보를 기반으로 사용자를 인증하고, 인증된 사용자 정보를 반환합니다.</p>
*
* @param authentication 인증 요청 정보
* @return 인증된 Authentication 객체
* @throws AuthenticationException 인증 실패 시 발생하는 예외
*/
@Transactional(readOnly = true)
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = String.valueOf(authentication.getCredentials());
// 사용자 정보를 로드합니다.
UserPrincipal user = (UserPrincipal) userDetailsService.loadUserByUsername(username);
// 비밀번호가 일치하는지 확인합니다.
if (isNotMatches(password, user.getPassword())) {
throw new SecurityAuthException(SecurityExceptionRule.USER_NOT_PASSWORD);
}
// 인증된 사용자 정보를 반환합니다.
return new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());
}
/**
* 주어진 인증 클래스가 지원되는지 확인합니다.
*
* @param authentication 인증 클래스
* @return 지원되는 경우 true, 그렇지 않으면 false
*/
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
/**
* 비밀번호와 암호화된 비밀번호가 일치하지 않는지 확인합니다.
*
* @param password 입력된 비밀번호
* @param encodePassword 암호화된 비밀번호
* @return 일치하지 않으면 true, 일치하면 false
*/
private boolean isNotMatches(String password, String encodePassword) {
return !passwordEncoder.matches(password, encodePassword);
}

View File

@@ -1,11 +1,42 @@
package com.spring.infra.security.service;
/**
* 리프레시 토큰을 관리하는 서비스 클래스입니다.
*
* <p>이 클래스는 리프레시 토큰의 저장, 조회 및 삭제 기능을 제공합니다.
* 사용자의 리프레시 토큰을 데이터베이스에 저장하고, 필요 시 이를 조회하거나 삭제합니다.</p>
*
* @author mindol
* @version 1.0
*/
public interface RefreshTokenService {
/**
* 리프레시 토큰을 저장합니다.
*
* <p>주어진 사용자 ID와 리프레시 토큰을 데이터베이스에 저장합니다.</p>
*
* @param userId 사용자 ID
* @param refreshToken 저장할 리프레시 토큰
*/
void saveRefreshToken(String userId, String refreshToken);
/**
* 리프레시 토큰을 조회합니다.
*
* <p>주어진 리프레시 토큰에 대한 정보를 조회합니다.</p>
*
* @param refreshToken 조회할 리프레시 토큰
* @return 해당 리프레시 토큰의 정보
*/
String getRefreshToken(String refreshToken);
/**
* 리프레시 토큰을 삭제합니다.
*
* <p>주어진 키에 해당하는 리프레시 토큰을 데이터베이스에서 삭제합니다.</p>
*
* @param key 삭제할 리프레시 토큰의 키 (예: 사용자 ID)
*/
void deleteRefreshToken(String key);
}

View File

@@ -2,6 +2,27 @@ package com.spring.infra.security.service;
import org.springframework.security.core.userdetails.UserDetails;
/**
* 사용자 인증 관련 서비스를 정의하는 인터페이스입니다.
*
* <p>이 인터페이스는 사용자 인증 정보를 가져오는 메서드를 정의하며,
* 사용자 세부 정보를 기반으로 인증을 수행하는 데 사용됩니다.</p>
*
* <p>구현 클래스는 사용자 정보를 데이터베이스에서 조회하거나,
* 다른 소스에서 가져오는 로직을 포함해야 합니다.</p>
*
* @author mindol
* @version 1.0
*/
public interface UserAuthenticationService {
/**
* 주어진 키에 해당하는 사용자 세부 정보를 가져옵니다.
*
* <p>사용자 ID 또는 사용자 이름을 기반으로 사용자 정보를 조회합니다.</p>
*
* @param key 사용자 ID 또는 사용자 이름
* @return 사용자 세부 정보 객체
*/
UserDetails getUserDetails(String key);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long