Compare commits

...

13 Commits

Author SHA1 Message Date
mindol1004
f6fba4f09f commit 2024-12-13 16:59:09 +09:00
mindol1004
e3569a06b2 commit 2024-12-12 13:48:05 +09:00
mindol1004
624a46def7 commit 2024-11-26 15:14:07 +09:00
mindol1004
bb4277ed19 commit 2024-11-22 18:21:55 +09:00
mindol1004
fe001eedad commit 2024-11-22 16:35:28 +09:00
mindol1004
b01e150c9a commit 2024-11-15 17:58:46 +09:00
mindol1004
fc98b4ffc0 commit 2024-11-14 15:10:45 +09:00
mindol1004
12e595b728 commit 2024-11-08 18:31:10 +09:00
mindol1004
23e1641644 commit 2024-10-31 17:50:03 +09:00
mindol1004
df373d5d27 commit 2024-10-22 17:12:28 +09:00
mindol1004
d63b268765 commit 2024-10-22 12:11:09 +09:00
mindol1004
868bb01453 commit 2024-10-21 17:59:04 +09:00
mindol1004
d5e8941d5d commit 2024-10-21 10:22:31 +09:00
218 changed files with 60812 additions and 324 deletions

View File

@@ -42,6 +42,14 @@
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>

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,62 @@ 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;
private final String customMessage;
/**
* 기본 생성자입니다.
*
* <p>기본 오류 규칙인 SYSTEM_ERROR를 사용하여 BizBaseException을 초기화합니다.</p>
*/
public BizBaseException() {
super(ExceptionRule.SYSTEM_ERROR.getMessage());
this.errorRule = ExceptionRule.SYSTEM_ERROR;
this.customMessage = null;
}
/**
* 주어진 오류 규칙을 사용하여 BizBaseException을 초기화합니다.
*
* @param exceptionRule 발생한 예외의 오류 규칙
*/
public BizBaseException(ErrorRule exceptionRule) {
super(exceptionRule.getMessage());
this.errorRule = exceptionRule;
this.customMessage = null;
}
public BizBaseException(ErrorRule errorRule, String message) {
super(message);
this.errorRule = errorRule;
this.customMessage = message;
}
public BizBaseException(String message) {
super(ExceptionRule.SYSTEM_ERROR.getMessage());
this.errorRule = ExceptionRule.SYSTEM_ERROR;
this.customMessage = message;
}
}

View File

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

View File

@@ -6,11 +6,34 @@ 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;
protected String customMessage;
}

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 {
@@ -19,16 +30,6 @@ public enum ExceptionRule implements ErrorRule {
UNPROCESSABLE_ENTITY(HttpStatus.UNPROCESSABLE_ENTITY, "요청을 처리할 수 없습니다.");
private final HttpStatus status;
private String message;
ExceptionRule(HttpStatus status, String message) {
this.status = status;
this.message = message;
}
public ExceptionRule message(final String message) {
this.message = message;
return this;
}
private final String message;
}

View File

@@ -1,28 +1,90 @@
package com.spring.common.error;
import java.util.Objects;
import javax.servlet.RequestDispatcher;
import javax.servlet.http.HttpServletRequest;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
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>
*
* <p>이 컨트롤러는 JSON 요청과 HTML 요청을 구분하여 각각에 맞는 오류 처리를 수행합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Controller
public class GlobalErrorController implements ErrorController {
@ExceptionHandler(Throwable.class)
/**
* 오류를 처리하는 메서드입니다.
*
* @param request HTTP 요청 객체
* @param model 모델 객체
* @return JSON 요청일 경우 JSON 오류 응답, HTML 요청일 경우 HTML 오류 페이지
*/
@GetMapping("/error")
public String handleError(HttpServletRequest request, Model model) {
public Object handleError(HttpServletRequest request, Model model) {
if (isJsonRequest(request)) {
return handleJsonError(request);
} else {
return handleHtmlError(request, model);
}
}
/**
* 요청이 JSON 형식인지 확인하는 메서드입니다.
*
* @param request HTTP 요청 객체
* @return JSON 요청이면 true, 그렇지 않으면 false
*/
private boolean isJsonRequest(HttpServletRequest request) {
return Objects.equals(request.getContentType(), MediaType.APPLICATION_JSON_VALUE);
}
/**
* JSON 요청에 대한 오류를 처리하는 메서드입니다.
*
* @param request HTTP 요청 객체
* @return BizErrorResponse 객체와 HTTP 상태 코드를 포함한 ResponseEntity
*/
private ResponseEntity<BizErrorResponse> handleJsonError(HttpServletRequest request) {
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
int statusCode = status != null ? Integer.parseInt(status.toString()) : HttpStatus.INTERNAL_SERVER_ERROR.value();
HttpStatus httpStatus = HttpStatus.valueOf(statusCode);
return new ResponseEntity<>(BizErrorResponse.fromErrorRule(ExceptionRule.NOT_FOUND), httpStatus);
}
/**
* HTML 요청에 대한 오류를 처리하는 메서드입니다.
*
* @param request HTTP 요청 객체
* @param model 모델 객체
* @return 오류 페이지의 경로
*/
private String handleHtmlError(HttpServletRequest request, Model model) {
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
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

@@ -1,20 +1,110 @@
package com.spring.common.error;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import javax.validation.ConstraintViolationException;
import org.springframework.dao.DataAccessException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 전역 예외 처리를 위한 클래스입니다.
*
* <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());
return BizErrorResponse.fromErrorRule(e.getErrorRule());
}
/**
* 모든 일반 예외를 처리하는 메서드입니다.
*
* <p>예기치 않은 예외가 발생했을 때 호출되며, 시스템 오류에 대한 BizErrorResponse 객체를 생성하여 반환합니다.</p>
*
* @param e 발생한 일반 예외
* @return 생성된 BizErrorResponse 객체
*/
@ExceptionHandler(Exception.class)
public BizErrorResponse handleException(Exception e) {
return BizErrorResponse.fromErrorRule(ExceptionRule.SYSTEM_ERROR);
}
/**
* MethodArgumentNotValidException 예외를 처리하는 메서드입니다.
*
* <p>MethodArgumentNotValidException이 발생했을 때 호출되며, 필드 오류 목록을 기반으로
* BizErrorResponse 객체를 생성하여 반환합니다.</p>
*
* @param e 발생한 MethodArgumentNotValidException 예외
* @return 생성된 BizErrorResponse 객체
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public BizErrorResponse methodArgumentNotValidException(MethodArgumentNotValidException e) {
return BizErrorResponse.fromFieldError(e.getFieldErrors());
}
/**
* ConstraintViolationException 예외를 처리하는 메서드입니다.
*
* <p>ConstraintViolationException이 발생했을 때 호출되며, 제약 조건 위반에 대한 필드 오류 목록을 기반으로
* BizErrorResponse 객체를 생성하여 반환합니다.</p>
*
* @param e 발생한 ConstraintViolationException 예외
* @return 생성된 BizErrorResponse 객체
*/
@ExceptionHandler(ConstraintViolationException.class)
public BizErrorResponse constraintViolationException(ConstraintViolationException e) {
List<FieldError> fieldErrors = e.getConstraintViolations().stream()
.map(violation -> new FieldError(
violation.getRootBeanClass().getName(),
violation.getPropertyPath().toString(),
violation.getMessage()))
.collect(Collectors.toList());
return BizErrorResponse.fromFieldError(fieldErrors);
}
/**
* DataAccessException 예외를 처리하는 메서드입니다.
*
* <p>데이터 접근 중 예외가 발생했을 때 호출되며, 가장 구체적인 원인 예외를 기반으로
* BizErrorResponse 객체를 생성하여 반환합니다.</p>
*
* @param e 발생한 DataAccessException 예외
* @return 생성된 BizErrorResponse 객체
*/
@ExceptionHandler(DataAccessException.class)
public BizErrorResponse handleDataAccessException(DataAccessException e) {
Throwable cause = e.getMostSpecificCause();
return BizErrorResponse.of(
ExceptionRule.SYSTEM_ERROR,
Optional.ofNullable(cause)
.map(Throwable::getMessage)
.orElse(ExceptionRule.SYSTEM_ERROR.getMessage())
);
}
}

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

@@ -0,0 +1,65 @@
package com.spring.common.jpa;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Supplier;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.StringUtils;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SpecBuilder {
public static <T> Builder<T> builder(Class<T> type) {
return new Builder<>();
}
public static class Builder<T> {
private List<Specification<T>> specs = new ArrayList<>();
private void addSpec(Specification<T> spec) {
if (spec != null) {
specs.add(spec);
}
}
public Builder<T> and(Specification<T> spec) {
addSpec(spec);
return this;
}
public Builder<T> ifHasText(String str, Specification<T> spec) {
if (StringUtils.hasText(str)) {
addSpec(spec);
}
return this;
}
public Builder<T> ifTrue(Boolean cond, Supplier<Specification<T>> specSupplier) {
if (cond != null && cond.booleanValue()) {
addSpec(specSupplier.get());
}
return this;
}
public <V> Builder<T> ifNotNull(V value, Function<V, Specification<T>> specSupplier) {
if (value != null) {
addSpec(specSupplier.apply(value));
}
return this;
}
public Specification<T> toSpec() {
Specification<T> spec = Specification.where(null);
for (Specification<T> s : specs) {
spec = spec.and(s);
}
return spec;
}
}
}

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

@@ -0,0 +1,34 @@
package com.spring.common.properties;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;
import org.springframework.boot.context.properties.bind.DefaultValue;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@ConstructorBinding
@ConfigurationProperties(prefix = "path")
@RequiredArgsConstructor
public class PathProperties {
private final Map<String, PathConfig> paths;
@Getter
public static class PathConfig {
private final String upload;
private final String dowonload;
public PathConfig(
@DefaultValue("") String upload,
@DefaultValue("") String dowonload
) {
this.upload = upload;
this.dowonload = dowonload;
}
}
}

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,
@@ -66,7 +118,7 @@ public class ResponseWrapper implements ResponseBodyAdvice<Object> {
.statusCode(errorRule.getStatus().value())
.path(path)
.data(errorData.getErrors())
.message(errorRule.getMessage())
.message(errorData.getCustomMessage() != null ? errorData.getCustomMessage() : errorRule.getMessage())
.build();
}

View File

@@ -0,0 +1,97 @@
package com.spring.common.util;
import java.util.Arrays;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class ProfileUtils {
private final Environment environment;
/**
* 현재 활성화된 프로파일 목록을 반환합니다.
*
* @return 현재 활성화된 프로파일 배열
*/
public String[] getActiveProfiles() {
return environment.getActiveProfiles();
}
/**
* 현재 프로파일이 'prod'인지 확인하는 메소드.
*
* @return true if the current profile is 'prod', false otherwise.
*/
public boolean isProdProfile() {
return isProfileActive("prod");
}
/**
* 현재 프로파일이 'dev'인지 확인하는 메소드.
*
* @return true if the current profile is 'dev', false otherwise.
*/
public boolean isDevProfile() {
return isProfileActive("dev");
}
/**
* 특정 프로파일이 활성화되어 있는지 확인하는 메소드.
*
* @param profile 확인할 프로파일 이름
* @return true if the specified profile is active, false otherwise.
*/
public boolean isProfileActive(String profile) {
return Arrays.asList(environment.getActiveProfiles()).contains(profile);
}
/**
* 현재 활성화된 프로파일이 여러 개일 경우, 그 중 하나라도 주어진 프로파일이 포함되어 있는지 확인하는 메소드.
*
* @param profiles 확인할 프로파일 이름 배열
* @return true if any of the specified profiles are active, false otherwise.
*/
public boolean isAnyProfileActive(String... profiles) {
for (String profile : profiles) {
if (isProfileActive(profile)) {
return true;
}
}
return false;
}
/**
* 현재 활성화된 프로파일 중 가장 우선순위가 높은 프로파일을 반환합니다.
*
* @return 가장 우선순위가 높은 프로파일 이름, 없으면 null
*/
public String getHighestPriorityProfile() {
String[] activeProfiles = getActiveProfiles();
return activeProfiles.length > 0 ? activeProfiles[0] : null; // 첫 번째 프로파일이 가장 우선순위가 높다고 가정
}
/**
* 기본 프로파일이 설정되어 있는지 확인하는 메소드.
*
* @return true if the default profile is active, false otherwise.
*/
public boolean isDefaultProfileActive() {
return isProfileActive("default");
}
/**
* 주어진 프로파일이 활성화되어 있지 않은지 확인하는 메소드.
*
* @param profile 확인할 프로파일 이름
* @return true if the specified profile is not active, false otherwise.
*/
public boolean isProfileInactive(String profile) {
return !isProfileActive(profile);
}
}

View File

@@ -0,0 +1,184 @@
package com.spring.common.util;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Parameter;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.http.HttpMethod;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class TypeUtils {
public static TypeInfo extractParameterInfo(Parameter parameter, String requestMethod) {
return TypeInfo.builder()
.type(extractTypeName(parameter.getParameterizedType()))
.paramName(parameter.getName())
.sourceType(determineSourceType(parameter, requestMethod))
.typeDetails(extractTypeDetails(parameter.getParameterizedType()))
.build();
}
public static TypeInfo extractReturnTypeInfo(Type returnType) {
return TypeInfo.builder()
.type(extractTypeName(returnType))
.typeDetails(extractTypeDetails(returnType))
.build();
}
public static String extractRequestMethod(Method method) {
return Arrays.stream(method.getAnnotations())
.filter(annotation -> annotation.annotationType().getSimpleName().endsWith("Mapping"))
.findFirst()
.map(annotation -> {
if (annotation instanceof RequestMapping) {
RequestMethod[] methods = ((RequestMapping) annotation).method();
return methods.length > 0 ? methods[0].name() : HttpMethod.GET.name();
}
return annotation.annotationType().getSimpleName()
.replace("Mapping", "")
.toUpperCase();
})
.orElse(HttpMethod.GET.name());
}
private static String extractTypeName(Type type) {
if (type instanceof Class<?>) {
return ((Class<?>) type).getSimpleName();
}
if (type instanceof ParameterizedType) {
ParameterizedType paramType = (ParameterizedType) type;
String rawType = ((Class<?>) paramType.getRawType()).getSimpleName();
String typeArgs = Arrays.stream(paramType.getActualTypeArguments())
.map(TypeUtils::extractTypeName)
.collect(Collectors.joining(", "));
return String.format("%s<%s>", rawType, typeArgs);
}
return type.getTypeName();
}
private static TypeDetails extractTypeDetails(Type type) {
Class<?> baseClass = getBaseClass(type);
Class<?> actualTypeClass = getActualType(type);
// Collection 타입이면서 제네릭 타입이 복잡한 객체인 경우
if (Collection.class.isAssignableFrom(baseClass) && isComplexType(actualTypeClass)) {
return TypeDetails.builder()
.isCollection(true)
.fields(extractFields(actualTypeClass))
.build();
}
// 일반 복잡한 객체인 경우
else if (isComplexType(baseClass)) {
return TypeDetails.builder()
.isCollection(Collection.class.isAssignableFrom(baseClass))
.fields(extractFields(baseClass))
.build();
}
return null;
}
private static Class<?> getBaseClass(Type type) {
if (type instanceof Class<?>) {
return (Class<?>) type;
}
if (type instanceof ParameterizedType) {
return (Class<?>) ((ParameterizedType) type).getRawType();
}
return null;
}
private static Class<?> getActualType(Type type) {
if (type instanceof Class<?>) {
return (Class<?>) type;
}
if (type instanceof ParameterizedType) {
ParameterizedType paramType = (ParameterizedType) type;
Type[] typeArgs = paramType.getActualTypeArguments();
if (typeArgs.length > 0) {
if (typeArgs[0] instanceof Class) {
return (Class<?>) typeArgs[0];
} else if (typeArgs[0] instanceof ParameterizedType) {
// 중첩된 제네릭 타입의 경우 (ex: List<Map<String, Object>>)
return (Class<?>) ((ParameterizedType) typeArgs[0]).getRawType();
}
}
}
return null;
}
private static boolean isComplexType(Class<?> clazz) {
return clazz != null &&
!clazz.isPrimitive() &&
!clazz.getName().startsWith("java.") &&
!clazz.isEnum();
}
private static List<FieldInfo> extractFields(Class<?> clazz) {
if (clazz == null) return Collections.emptyList();
return Arrays.stream(clazz.getDeclaredFields())
.filter(field -> !Modifier.isStatic(field.getModifiers()))
.map(field -> new FieldInfo(
extractTypeName(field.getGenericType()),
field.getName()
))
.collect(Collectors.toList());
}
private static SourceType determineSourceType(Parameter parameter, String requestMethod) {
if (parameter.isAnnotationPresent(RequestBody.class)) return SourceType.BODY;
if (parameter.isAnnotationPresent(RequestParam.class)) return SourceType.QUERY;
if (parameter.isAnnotationPresent(PathVariable.class)) return SourceType.PATH;
if (parameter.isAnnotationPresent(ModelAttribute.class) || isComplexType(parameter.getType())) {
return "GET".equalsIgnoreCase(requestMethod) ? SourceType.QUERY : SourceType.FORM;
}
return SourceType.QUERY;
}
@Getter
@Builder
public static class TypeInfo {
private final String type;
private final String paramName;
private final SourceType sourceType;
private final TypeDetails typeDetails;
}
@Getter
@Builder
public static class TypeDetails {
private final boolean isCollection;
private final List<FieldInfo> fields;
}
@Getter
@RequiredArgsConstructor
public static class FieldInfo {
private final String type;
private final String name;
}
public enum SourceType {
BODY, QUERY, PATH, FORM
}
}

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

@@ -7,7 +7,7 @@ import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
import com.spring.domain.post.repository.PostRepository;
import com.spring.domain.email.service.EmailSendService;
import com.spring.infra.batch.AbstractBatchTask;
import com.spring.infra.batch.BatchJobInfo;
@@ -25,11 +25,10 @@ import lombok.extern.slf4j.Slf4j;
@RequiredArgsConstructor
public class EmailSendBatch extends AbstractBatchTask {
private final PostRepository postRepository;
private final EmailSendService emailSendService;
@Override
protected List<Step> createSteps() {
log.info("EmailSendBatch -> createSteps");
return List.of(
addStep("emailSendJobStep1", createTasklet()),
addStep("emailSendJobStep2", createSendTasklet())
@@ -40,13 +39,12 @@ public class EmailSendBatch extends AbstractBatchTask {
protected Tasklet createTasklet() {
log.info("EmailSendBatch -> createTasklet");
return ((contribution, chunkContext) -> {
postRepository.findAll();
emailSendService.sendEmail();
return RepeatStatus.FINISHED;
});
}
private Tasklet createSendTasklet() {
log.info("EmailSendBatch -> createSendTasklet");
return ((contribution, chunkContext) -> RepeatStatus.FINISHED);
}

View File

@@ -0,0 +1,25 @@
package com.spring.domain.email.service;
import org.springframework.stereotype.Service;
import com.spring.common.properties.PathProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailSendService {
private final PathProperties pathProperties;
public void sendEmail() {
log.info("pathProperties: {}", pathProperties);
log.info("paths: {}", pathProperties.getPaths());
String upload = pathProperties.getPaths().get("path1").getUpload();
log.info(upload);
}
}

View File

@@ -0,0 +1,26 @@
package com.spring.domain.endpoint.api;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.spring.domain.endpoint.dto.EndpointInfo;
import com.spring.domain.endpoint.service.EndpointCollectorService;
import lombok.RequiredArgsConstructor;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/endpoint")
public class EndpointApi {
private final EndpointCollectorService endpointCollectorService;
@GetMapping
public List<EndpointInfo> getEndpoints() {
return endpointCollectorService.collectApiEndpoints();
}
}

View File

@@ -0,0 +1,22 @@
package com.spring.domain.endpoint.dto;
import java.util.List;
import java.util.Set;
import com.spring.common.util.TypeUtils.TypeInfo;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class EndpointInfo {
private final String uri;
private final Set<String> httpMethods;
private final String controllerName;
private final String methodName;
private final List<TypeInfo> parameters;
private final TypeInfo returnType;
}

View File

@@ -0,0 +1,108 @@
package com.spring.domain.endpoint.service;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import com.spring.common.util.TypeUtils;
import com.spring.common.util.TypeUtils.TypeInfo;
import com.spring.domain.endpoint.dto.EndpointInfo;
import lombok.RequiredArgsConstructor;
/**
* EndpointCollectorService는 Spring MVC의 @RestController로 정의된 API 엔드포인트를 수집하는 서비스입니다.
* 이 서비스는 RequestMappingHandlerMapping을 사용하여 모든 핸들러 메서드를 가져오고,
* 각 메서드의 URI, HTTP 메서드, 클래스 이름, 메서드 이름, 파라미터 정보 및 반환 타입 정보를 포함하는 EndpointInfo 객체를 생성합니다.
*/
@Service
@RequiredArgsConstructor
public class EndpointCollectorService {
private final RequestMappingHandlerMapping requestMappingHandlerMapping;
/**
* collectApiEndpoints 메서드는 모든 API 엔드포인트를 수집하여 EndpointInfo 객체의 리스트를 반환합니다.
* @return 수집된 API 엔드포인트의 리스트
*/
public List<EndpointInfo> collectApiEndpoints() {
List<EndpointInfo> endpoints = new ArrayList<>();
// 핸들러 메서드 맵 가져오기
Map<RequestMappingInfo, HandlerMethod> handlerMethods = requestMappingHandlerMapping.getHandlerMethods();
handlerMethods.forEach((info, method) -> {
// @RestController가 붙은 클래스의 메서드만 처리
if (method.getBeanType().isAnnotationPresent(RestController.class)) {
Set<String> patterns = Optional.ofNullable(info.getPathPatternsCondition())
.map(condition -> condition.getPatternValues())
.orElse(Collections.emptySet());
Set<String> methods = new HashSet<>();
Optional.ofNullable(info.getMethodsCondition())
.ifPresent(methodsCondition ->
methodsCondition.getMethods()
.forEach(requestMethod -> methods.add(requestMethod.name()))
);
String className = method.getBeanType().getSimpleName();
String methodName = method.getMethod().getName();
List<TypeInfo> parameterInfos = extractParameterInfos(method.getMethod());
TypeInfo returnTypeInfo = extractReturnTypeInfo(method.getMethod());
patterns.forEach(pattern -> {
EndpointInfo endpoint = new EndpointInfo(
pattern,
methods.isEmpty() ? Collections.singleton("ALL") : methods,
className,
methodName,
parameterInfos,
returnTypeInfo
);
endpoints.add(endpoint);
});
}
});
return endpoints.stream()
.sorted(Comparator.comparing(EndpointInfo::getUri))
.collect(Collectors.toList());
}
/**
* extractParameterInfos 메서드는 주어진 메서드의 파라미터 정보를 추출하여 리스트로 반환합니다.
* @param method 파라미터 정보를 추출할 메서드
* @return 메서드의 파라미터 정보 리스트
*/
private List<TypeInfo> extractParameterInfos(Method method) {
String requestMethod = TypeUtils.extractRequestMethod(method);
return Arrays.stream(method.getParameters())
.map(parameter -> TypeUtils.extractParameterInfo(parameter, requestMethod))
.collect(Collectors.toList());
}
/**
* extractReturnTypeInfo 메서드는 주어진 메서드의 반환 타입 정보를 추출하여 반환합니다.
* @param method 반환 타입 정보를 추출할 메서드
* @return 메서드의 반환 타입 정보
*/
private TypeInfo extractReturnTypeInfo(Method method) {
return TypeUtils.extractReturnTypeInfo(method.getGenericReturnType());
}
}

View File

@@ -65,12 +65,11 @@ public class PostCreateBatchChunk extends AbstractBatchChunk {
public Job createJob() {
return new JobBuilder(batchJobInfoData.getJobName())
.repository(jobRepository)
.incrementer(new RunIdIncrementer())
.start(readListStep())
.next(decider())
.from(decider()).on("PROCESS").to(processStep())
.from(decider()).on("TERMINATE").to(terminateStep())
.end()
// .incrementer(new RunIdIncrementer())
.start(processStep())
// .next(decider())
// .from(decider()).on("PROCESS").to(processStep())
// .from(decider()).on("TERMINATE").to(terminateStep())
.build();
}

View File

@@ -2,6 +2,9 @@ package com.spring.domain.schedule.api;
import java.util.List;
import javax.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -17,13 +20,17 @@ import lombok.RequiredArgsConstructor;
@RequestMapping("/api/dashboard")
@RestController
@RequiredArgsConstructor
@Validated
public class DashBoardApi {
private final BatchChartService batchChartService;
private final DashBoardJobService dashBoardJobService;
@GetMapping("/chart")
public BatchChartResponse getBatchJobExecutionData(@RequestParam int year, @RequestParam int month) {
public BatchChartResponse getBatchJobExecutionData(
@NotNull(message = "년도는 필수값 입니다.") @RequestParam(required = false) Integer year,
@NotNull(message = "월은 필수값 입니다.") @RequestParam(required = false) Integer month
) {
return batchChartService.getBatchJobExecutionData(year, month);
}

View File

@@ -13,8 +13,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.spring.domain.schedule.dto.BatchJobHistoryResponse;
import com.spring.domain.schedule.dto.FindJobHistoryRequest;
import com.spring.domain.schedule.dto.ReScheduleJobRequest;
import com.spring.domain.schedule.dto.ScheduleJobResponse;
import com.spring.domain.schedule.service.FindJobHistoryService;
import com.spring.domain.schedule.service.FindScheduleJobService;
import com.spring.domain.schedule.service.ReScheduleJobService;
import com.spring.domain.schedule.service.ScheduleControlService;
@@ -25,10 +28,11 @@ import lombok.RequiredArgsConstructor;
@RestController
@RequiredArgsConstructor
public class ScheduleJobApi {
private final FindScheduleJobService findScheduleJobService;
private final ReScheduleJobService reScheduleJobService;
private final ScheduleControlService scheduleControlService;
private final FindJobHistoryService findJobHistoryService;
@GetMapping
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
@@ -66,4 +70,14 @@ public class ScheduleJobApi {
return reScheduleJobService.rescheduleJob(request);
}
@GetMapping("/history")
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
public List<BatchJobHistoryResponse> getJobHistory(
@Valid FindJobHistoryRequest request,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return findJobHistoryService.getJobHistory(request, page, size);
}
}

View File

@@ -0,0 +1,10 @@
package com.spring.domain.schedule.dto;
import java.time.LocalDateTime;
public interface BatchJobHistoryProjection {
String getJobName();
LocalDateTime getStartTime();
LocalDateTime getEndTime();
String getStatus();
}

View File

@@ -0,0 +1,23 @@
package com.spring.domain.schedule.dto;
import java.time.LocalDateTime;
import com.spring.domain.schedule.entity.BatchJobExecution;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class BatchJobHistoryResponse {
private final String jobName;
private final LocalDateTime startTime;
private final LocalDateTime endTime;
private final String status;
public static BatchJobHistoryResponse fromEntity(BatchJobExecution batch) {
return new BatchJobHistoryResponse(batch.getJobInstance().getJobName(), batch.getStartTime(), batch.getEndTime(), batch.getStatus());
}
}

View File

@@ -0,0 +1,51 @@
package com.spring.domain.schedule.dto;
import java.time.LocalDateTime;
import javax.persistence.criteria.Join;
import javax.persistence.criteria.JoinType;
import javax.validation.constraints.NotNull;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.format.annotation.DateTimeFormat;
import com.spring.common.jpa.SpecBuilder;
import com.spring.domain.schedule.entity.BatchJobExecution;
import com.spring.domain.schedule.entity.BatchJobInstance;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class FindJobHistoryRequest {
@NotNull(message = "시작일자는 필수값 입니다.")
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private final LocalDateTime fromDate;
@NotNull(message = "종료일자는 필수값 입니다.")
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private final LocalDateTime toDate;
private final String jobName;
public Specification<BatchJobExecution> betweenCreateTime() {
return (root, query, builder) -> builder.between(root.get("createTime"), fromDate, toDate);
}
public Specification<BatchJobExecution> equalJobName() {
return (root, query, builder) -> {
Join<BatchJobExecution, BatchJobInstance> join = root.join("jobInstance", JoinType.INNER);
return builder.equal(join.get("jobName"), jobName);
};
}
public Specification<BatchJobExecution> toPredicate() {
return SpecBuilder.builder(BatchJobExecution.class)
.and(betweenCreateTime())
.ifHasText(jobName, equalJobName())
.toSpec();
}
}

View File

@@ -1,19 +1,33 @@
package com.spring.domain.schedule.repository;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.spring.domain.schedule.dto.BatchJobAverageDurationProjection;
import com.spring.domain.schedule.dto.BatchJobExecutionProjection;
import com.spring.domain.schedule.dto.BatchJobHistoryProjection;
import com.spring.domain.schedule.dto.BatchJobHourProjection;
import com.spring.domain.schedule.dto.BatchJobStatusCountProjection;
import com.spring.domain.schedule.entity.BatchJobExecution;
public interface BatchJobExecutionRepository extends JpaRepository<BatchJobExecution, Long> {
public interface BatchJobExecutionRepository extends JpaRepository<BatchJobExecution, Long>, JpaSpecificationExecutor<BatchJobExecution> {
@Query("SELECT bji.jobName AS jobName, " +
"bje.startTime AS startTime, " +
"bje.endTime AS endTime, " +
"bje.status AS status " +
"FROM BatchJobExecution bje " +
"JOIN bje.jobInstance bji " +
"WHERE bje.createTime BETWEEN :fromDate AND :toDate " +
"ORDER BY bje.createTime DESC")
List<BatchJobHistoryProjection> findJobHistory(@Param("fromDate") LocalDateTime fromDate, @Param("toDate") LocalDateTime toDate, Pageable pageable);
@Query("SELECT bji.jobName AS jobName, " +
"bje.startTime AS startTime, " +
"bje.endTime AS endTime " +

View File

@@ -0,0 +1,29 @@
package com.spring.domain.schedule.service;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.spring.domain.schedule.dto.BatchJobHistoryResponse;
import com.spring.domain.schedule.dto.FindJobHistoryRequest;
import com.spring.domain.schedule.repository.BatchJobExecutionRepository;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class FindJobHistoryService {
private final BatchJobExecutionRepository batchJobExecutionRepository;
@Transactional(readOnly = true)
public List<BatchJobHistoryResponse> getJobHistory(FindJobHistoryRequest request, int page, int size) {
return batchJobExecutionRepository.findAll(request.toPredicate(), PageRequest.of(page, size)).stream()
.map(BatchJobHistoryResponse::fromEntity)
.collect(Collectors.toList());
}
}

View File

@@ -51,6 +51,7 @@ public class SignUpRequest {
.approved(false)
.userRole(userRole)
.createdBy(userId)
.modifiedBy(userId)
.build();
}

View File

@@ -14,7 +14,7 @@ public class UserManagementResponse {
private final String userId;
private final String userName;
private final String email;
private final boolean isApproved;
private final boolean approved;
private final AgentUserRole userRole;
public static UserManagementResponse valueOf(AgentUser user) {

View File

@@ -51,7 +51,7 @@ public class AgentUser extends AuditEntity {
private AgentUserRole userRole;
@Builder
public AgentUser(String userId, String password, String name, AgentUserRole userRole, String email, boolean approved, String createdBy) {
public AgentUser(String userId, String password, String name, AgentUserRole userRole, String email, boolean approved, String createdBy, String modifiedBy) {
this.userId = userId;
this.password = password;
this.name = name;
@@ -59,6 +59,7 @@ public class AgentUser extends AuditEntity {
this.email = email;
this.approved = approved;
this.createdBy = createdBy;
this.modifiedBy = modifiedBy;
}
public void changePassword(String newPassword) {

View File

@@ -47,8 +47,4 @@ public class AgentUserToken extends AuditEntity {
this.refreshToken = refreshToken;
}
public boolean validateRefreshToken(String refreshToken) {
return this.refreshToken.equals(refreshToken);
}
}

View File

@@ -19,16 +19,6 @@ public enum UserRule implements ErrorRule {
NEW_PASSWORD_SAME_AS_CURRENT(HttpStatus.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 달라야 합니다.");
private final HttpStatus status;
private String message;
UserRule(HttpStatus status, String message) {
this.status = status;
this.message = message;
}
public UserRule message(final String message) {
this.message = message;
return this;
}
private final String message;
}

View File

@@ -13,6 +13,7 @@ import com.spring.domain.user.dto.UserManagementResponse;
import com.spring.domain.user.entity.AgentUser;
import com.spring.domain.user.error.UserNotFoundException;
import com.spring.domain.user.repository.AgentUserRepository;
import com.spring.domain.user.repository.AgentUserTokenRepository;
import lombok.RequiredArgsConstructor;
@@ -21,6 +22,7 @@ import lombok.RequiredArgsConstructor;
public class UserManagementService {
private final AgentUserRepository agentUserRepository;
private final AgentUserTokenRepository agentUserTokenRepository;
@Transactional(readOnly = true)
public List<UserManagementResponse> getUsers(UserFindRequest request) {
@@ -42,8 +44,10 @@ public class UserManagementService {
@Transactional
public void deleteUser(String id) {
AgentUser user = agentUserRepository.findById(UUID.fromString(id))
.orElseThrow(UserNotFoundException::new);
AgentUser user = agentUserRepository.findById(UUID.fromString(id)).orElseThrow(UserNotFoundException::new);
if (agentUserTokenRepository.findById(UUID.fromString(id)).isPresent()) {
agentUserTokenRepository.deleteById(UUID.fromString(id));
}
agentUserRepository.delete(user);
}

View File

@@ -50,7 +50,9 @@ public class UserRefreshTokenService implements RefreshTokenService {
@Transactional
@Override
public void deleteRefreshToken(String key) {
agentUserTokenRepository.deleteById(UUID.fromString(key));
if (agentUserTokenRepository.findById(UUID.fromString(key)).isPresent()) {
agentUserTokenRepository.deleteById(UUID.fromString(key));
}
}
}

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,54 @@ 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;
private BatchExceptionListener batchExceptionListener;
/**
* 기본 생성자입니다.
* <p>
* BatchJobInfo 어노테이션을 찾고, 없으면 예외를 발생시킵니다.
* </p>
*
* <p>BatchJobInfo 어노테이션을 찾고, 없으면 예외를 발생시킵니다.</p>
*/
protected AbstractBatchTask() {
this.batchJobInfo = AnnotationUtils.findAnnotation(getClass(), BatchJobInfo.class);
@@ -55,18 +93,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 +131,8 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon
/**
* 배치 작업을 Spring의 Bean으로 등록합니다.
*
* <p>JobRepository에 배치 작업을 등록하여 실행할 수 있도록 합니다.</p>
*/
@Override
public void registerJobBean() {
@@ -107,9 +165,16 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon
this.transactionManager = transactionManager;
}
@Autowired
public void setBatchExceptionListener(BatchExceptionListener batchExceptionListener) {
this.batchExceptionListener = batchExceptionListener;
}
/**
* 배치 작업을 생성합니다.
*
* <p>구현 클래스에서 정의해야 하는 추상 메서드입니다.</p>
*
* @return 생성된 Job 객체
* @throws IllegalStateException STEP이 정의되지 않은 경우 예외 발생
*/
@@ -122,6 +187,7 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon
var jobBuilder = new JobBuilder(batchJobInfoData.getJobName())
.incrementer(new RunIdIncrementer())
.repository(jobRepository)
.listener(batchExceptionListener)
.start(steps.get(0));
for (int i = 1; i < steps.size(); i++) {
jobBuilder = jobBuilder.next(steps.get(i));
@@ -132,6 +198,8 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon
/**
* 배치 작업의 STEP을 생성합니다.
*
* <p>기본적으로 하나의 STEP을 생성하여 반환합니다.</p>
*
* @return 생성된 Step 리스트
*/
protected List<Step> createSteps() {
@@ -143,6 +211,8 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon
/**
* STEP을 추가합니다.
*
* <p>주어진 이름과 Tasklet을 사용하여 Step 객체를 생성합니다.</p>
*
* @param stepName STEP의 이름
* @param tasklet STEP에서 실행할 Tasklet
* @return 생성된 Step 객체
@@ -151,6 +221,7 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon
return new StepBuilder(stepName)
.repository(jobRepository)
.transactionManager(transactionManager)
.listener(batchExceptionListener)
.tasklet(tasklet)
.build();
}

View File

@@ -0,0 +1,82 @@
package com.spring.infra.batch;
import java.util.List;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.StepExecutionListener;
import org.springframework.dao.DataAccessException;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class BatchExceptionListener implements JobExecutionListener, StepExecutionListener {
@Override
public void beforeJob(@NonNull JobExecution jobExecution) {
log.info("Job 시작: {}", jobExecution.getJobInstance().getJobName());
}
@Override
public void afterJob(@NonNull JobExecution jobExecution) {
if (jobExecution.getStatus() == BatchStatus.FAILED) {
handleJobFailure(jobExecution);
}
}
@Override
public void beforeStep(@NonNull StepExecution stepExecution) {
log.info("Step 시작: {}", stepExecution.getStepName());
}
@Override
@Nullable
public ExitStatus afterStep(@NonNull StepExecution stepExecution) {
if (!stepExecution.getFailureExceptions().isEmpty()) {
Throwable ex = stepExecution.getFailureExceptions().get(0);
if (ex instanceof DataAccessException) {
// SQL 예외 처리
DataAccessException sqlEx = (DataAccessException) ex;
log.error("SQL 오류 발생: {}", sqlEx.getMostSpecificCause().getMessage());
return ExitStatus.FAILED;
} else {
// 기타 예외 처리
log.error("예외 발생: ", ex);
return ExitStatus.FAILED;
}
}
return stepExecution.getExitStatus();
}
private void handleJobFailure(JobExecution jobExecution) {
List<Throwable> exceptions = jobExecution.getAllFailureExceptions();
for (Throwable ex : exceptions) {
if (ex instanceof DataAccessException) {
handleSqlException((DataAccessException) ex, jobExecution);
} else {
handleGeneralException(ex, jobExecution);
}
}
}
private void handleSqlException(DataAccessException ex, JobExecution jobExecution) {
log.error("Job {} 실행 중 SQL 오류 발생: {}",
jobExecution.getJobInstance().getJobName(),
ex.getMostSpecificCause().getMessage());
}
private void handleGeneralException(Throwable ex, JobExecution jobExecution) {
log.error("Job {} 실행 중 오류 발생: {}",
jobExecution.getJobInstance().getJobName(),
ex.getMessage());
}
}

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

@@ -52,7 +52,7 @@ public class PrimaryJpaConfig {
public static final String TRANSACTION_MANAGER = "primaryTransactionManager";
public static final String ENTITY_MANAGER_FACTORY = "primaryEntityManagerFactory";
private static final String BASE_PACKAGE = "com.spring.domain";
public static final String BASE_PACKAGE = "com.spring.domain";
private static final String PERSISTENCE_UNIT = "primaryPersistenceUnit";
private final JpaProperties jpaProperties;

View File

@@ -53,7 +53,7 @@ public class SecondaryJpaConfig {
public static final String TRANSACTION_MANAGER = "secondaryTransactionManager";
public static final String ENTITY_MANAGER_FACTORY = "secondaryEntityManagerFactory";
private static final String BASE_PACKAGE = "com.spring.domain";
public static final String BASE_PACKAGE = "com.spring.domain";
private static final String PERSISTENCE_UNIT = "secondaryPersistenceUnit";
private final JpaProperties jpaProperties;

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;
@@ -37,10 +43,10 @@ import com.spring.infra.db.orm.mybatis.annotation.PrimaryMapper;
)
public class PrimaryMybatisConfig {
private static final String BASE_PACKAGE = "com.spring.domain.*.mapper";
public static final String BASE_PACKAGE = "com.spring.domain.*.mapper";
public static final String SESSION_FACTORY = "primarySqlSessionFactory";
private static final String ALIASES_PACKAGE = "com.spring.domain.*.dto,com.spring.domain.*.entity";
private static final String MAPPER_RESOURCES = "classpath:mapper/**/*.xml";
private static final String SESSION_FACTORY = "primarySqlSessionFactory";
/**
* 주 데이터베이스용 SqlSessionFactory를 생성합니다.
@@ -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;
@@ -36,10 +42,10 @@ import com.spring.infra.db.orm.mybatis.annotation.SecondaryMapper;
)
public class SecondaryMybatisConfig {
private static final String BASE_PACKAGE = "com.spring.domain.*.mapper";
public static final String BASE_PACKAGE = "com.spring.domain.*.mapper";
public static final String SESSION_FACTORY = "secondarySqlSessionFactory";
private static final String ALIASES_PACKAGE = "com.spring.domain.*.dto,com.spring.domain.*.entity";
private static final String MAPPER_RESOURCES = "classpath:mapper/**/*.xml";
private static final String SESSION_FACTORY = "secondarySqlSessionFactory";
/**
* 보조 데이터베이스용 SqlSessionFactory를 생성합니다.
@@ -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

@@ -0,0 +1,17 @@
package com.spring.infra.feign.client;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.cloud.openfeign.SpringQueryMap;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import com.spring.infra.feign.config.CommonFeignConfig;
import com.spring.infra.feign.dto.TestRequest;
@FeignClient(name = "client1", url = "${feign.clients.client1.url}", configuration = CommonFeignConfig.class)
public interface Client1FeignClient {
@PostMapping(value = "/test", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
String getItemById(@SpringQueryMap TestRequest request);
}

View File

@@ -0,0 +1,160 @@
package com.spring.infra.feign.config;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.springframework.cloud.openfeign.FeignFormatterRegistrar;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar;
import org.springframework.http.MediaType;
import feign.Client;
import feign.Logger;
import feign.Request;
import feign.RequestInterceptor;
import feign.Response;
import feign.RetryableException;
import feign.Retryer;
import feign.codec.ErrorDecoder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import okhttp3.OkHttpClient;
import okhttp3.RequestBody;
/**
* Feign 클라이언트에 대한 공통 설정을 제공하는 설정 클래스입니다.
* 이 클래스는 기본 Feign 빌더 설정과 클라이언트별 커스텀 설정을 제공합니다.
*
* @author mindol
* @version 1.0
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class CommonFeignConfig {
private final FeignClientProperties properties;
@Bean
Client feignClient() {
return new Client() {
@Override
public Response execute(Request request, Request.Options options) throws IOException {
String clientName = request.requestTemplate().feignTarget().name();
FeignClientProperties.ClientConfig config = properties.getClients().get(clientName);
OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder()
.connectTimeout(config.getConnectTimeout(), TimeUnit.MILLISECONDS)
.readTimeout(config.getReadTimeout(), TimeUnit.MILLISECONDS);
if (config.isUseProxy()) {
clientBuilder.proxy(new Proxy(
Proxy.Type.HTTP,
new InetSocketAddress(config.getProxyHost(), config.getProxyPort())
));
}
OkHttpClient okHttpClient = clientBuilder.build();
// OkHttp 요청 생성
okhttp3.Request.Builder okHttpRequestBuilder = new okhttp3.Request.Builder()
.url(request.url())
.method(request.httpMethod().name(), request.httpMethod() == Request.HttpMethod.GET ? null : getRequestBody(request)); // 요청 본문 설정
// Feign 요청의 헤더를 OkHttp 요청에 추가
request.headers().forEach((key, values) -> {
for (String value : values) {
okHttpRequestBuilder.addHeader(key, value);
}
});
okhttp3.Request okHttpRequest = okHttpRequestBuilder.build();
try (okhttp3.Response okHttpResponse = okHttpClient.newCall(okHttpRequest).execute()) {
return Response.builder()
.status(okHttpResponse.code())
.reason(okHttpResponse.message())
.headers(convertHeaders(okHttpResponse.headers()))
.body(okHttpResponse.body().bytes())
.request(request)
.build();
}
}
};
}
private Map<String, Collection<String>> convertHeaders(okhttp3.Headers headers) {
Map<String, Collection<String>> convertedHeaders = new HashMap<>();
for (String name : headers.names()) {
convertedHeaders.put(name, headers.values(name));
}
return convertedHeaders;
}
private RequestBody getRequestBody(Request request) {
String contentType = MediaType.APPLICATION_JSON_VALUE;
if (request.requestTemplate().headers().containsKey("Content-Type")) {
contentType = request.requestTemplate().headers().get("Content-Type").iterator().next();
}
if (request.body() != null) {
return RequestBody.create(request.body(), okhttp3.MediaType.parse(contentType));
} else {
return RequestBody.create("", okhttp3.MediaType.parse(contentType));
}
}
@Bean
RequestInterceptor headerInterceptor() {
return requestTemplate -> {
String clientName = requestTemplate.feignTarget().name();
if (properties.getClients().containsKey(clientName)) {
var config = properties.getClients().get(clientName);
if (!config.getHeaders().isEmpty()) {
config.getHeaders().forEach(requestTemplate::header);
}
}
};
}
@Bean
Retryer retryer() {
// 0.1초의 간격으로 시작해 최대 3초의 간격으로 점점 증가하며, 최대5번 재시도한다.
return new Retryer.Default(100L, TimeUnit.SECONDS.toMillis(3L), 5);
}
@Bean
Logger.Level feignLoggerLevel() {
return Logger.Level.FULL;
}
@Bean
FeignFormatterRegistrar dateTimeFormatterRegistrar() {
return registry -> {
DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
registrar.setUseIsoFormat(true);
registrar.registerFormatters(registry);
};
}
@Bean
ErrorDecoder errorDecoder() {
return (methodKey, response) ->
(response.status() >= 500 && response.status() <= 504)
? new RetryableException(
response.status(),
String.format("Server error %d: %s, retrying...", response.status(), response.reason()),
response.request().httpMethod(),
null,
response.request()
)
: new Exception(String.format("Error %d: %s", response.status(), response.reason()));
}
}

View File

@@ -0,0 +1,50 @@
package com.spring.infra.feign.config;
import java.util.HashMap;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;
import org.springframework.boot.context.properties.bind.DefaultValue;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@ConstructorBinding
@ConfigurationProperties(prefix = "feign")
@RequiredArgsConstructor
public class FeignClientProperties {
private final Map<String, ClientConfig> clients;
@Getter
public static class ClientConfig {
private final String url;
private final int connectTimeout;
private final int readTimeout;
private final String proxyHost;
private final int proxyPort;
private final boolean useProxy;
private final Map<String, String> headers;
public ClientConfig(
String url,
@DefaultValue("5000") int connectTimeout,
@DefaultValue("5000") int readTimeout,
@DefaultValue("") String proxyHost,
@DefaultValue("0") int proxyPort,
@DefaultValue("false") boolean useProxy,
Map<String, String> headers
) {
this.url = url;
this.connectTimeout = connectTimeout;
this.readTimeout = readTimeout;
this.proxyHost = proxyHost;
this.proxyPort = proxyPort;
this.useProxy = useProxy;
this.headers = headers != null ? headers : new HashMap<>();
}
}
}

View File

@@ -0,0 +1,9 @@
package com.spring.infra.feign.dto;
import lombok.Getter;
@Getter
public class TestRequest {
private String id;
private String name;
}

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;
/**
@@ -77,7 +94,7 @@ public class QuartzConfig {
factory.setDataSource(dataSource);
factory.setTransactionManager(transactionManager);
factory.setJobFactory(jobFactory);
factory.setAutoStartup(true);
factory.setAutoStartup(false);
factory.setWaitForJobsToCompleteOnShutdown(true);
return factory;
}

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

@@ -3,10 +3,10 @@ package com.spring.infra.quartz;
import java.util.Map;
import org.springframework.aop.support.AopUtils;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
@@ -18,44 +18,67 @@ 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 ApplicationReadyEvent}가 발생할 때 자동으로 실행됩니다.</p>
*
* @author mindol
* @version 1.0
*/
@Component
@RequiredArgsConstructor
public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedEvent> {
public class QuartzJobRegistrar implements ApplicationListener<ApplicationReadyEvent> {
/**
* 애플리케이션 컨텍스트 객체입니다.
*
* <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
public void onApplicationEvent(@NonNull ContextRefreshedEvent event) {
public void onApplicationEvent(@NonNull ApplicationReadyEvent event) {
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

@@ -0,0 +1,190 @@
package com.spring.infra.quartz;
import java.util.List;
import java.util.Set;
import javax.annotation.PreDestroy;
import org.quartz.JobExecutionContext;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.Trigger;
import org.quartz.TriggerKey;
import org.quartz.impl.matchers.GroupMatcher;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Quartz 스케줄러의 안전한 종료를 관리하는 리스너 클래스
*
* <p>이 클래스는 Spring 애플리케이션 컨텍스트가 종료될 때 Quartz 스케줄러의 모든 작업을
* 안전하게 종료하고 정리하는 역할을 합니다. 실행 중인 작업이 정상적으로 완료될 때까지
* 대기하며, 모든 트리거를 정리합니다.</p>
*
* <p>주요 기능:</p>
* <ul>
* <li>실행 중인 작업의 안전한 종료 처리</li>
* <li>트리거 상태 모니터링 및 정리</li>
* <li>스케줄러 리소스의 정상적인 해제</li>
* </ul>
*
* @author [작성자 이름]
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class QuartzShutdownListener implements ApplicationListener<ContextClosedEvent> {
/** 작업 완료 대기 최대 시간 (밀리초) */
private static final long SHUTDOWN_WAIT_TIME = 30000L; // 30초
/** Quartz 스케줄러 인스턴스 */
private final Scheduler scheduler;
/**
* Spring 컨텍스트 종료 이벤트 발생 시 실행되는 메서드
*
* @param event 컨텍스트 종료 이벤트
*/
@Override
public void onApplicationEvent(@NonNull ContextClosedEvent event) {
log.info("Quartz 셧다운 프로세스 시작...");
shutdownQuartzGracefully();
}
/**
* Quartz 스케줄러의 안전한 종료를 처리하는 메서드
*
* <p>다음의 단계를 순차적으로 실행합니다:</p>
* <ol>
* <li>새로운 작업 스케줄링 중지</li>
* <li>현재 실행 중인 작업 목록 확인</li>
* <li>실행 중인 작업의 상태 로깅</li>
* <li>작업 완료 대기</li>
* <li>트리거 정리</li>
* <li>스케줄러 종료</li>
* </ol>
*/
private void shutdownQuartzGracefully() {
try {
// 1. 새로운 작업 스케줄링 중지
scheduler.standby();
log.info("Scheduler를 대기 모드로 전환했습니다.");
// 2. 현재 실행 중인 작업 목록 확인
List<JobExecutionContext> currentJobs = scheduler.getCurrentlyExecutingJobs();
if (!currentJobs.isEmpty()) {
log.info("현재 실행 중인 작업 수: {}", currentJobs.size());
// 3. 실행 중인 작업의 정보 로깅
logRunningJobs(currentJobs);
// 4. 실행 중인 작업이 완료될 때까지 대기
waitForJobCompletion(currentJobs);
}
// 5. 모든 트리거 상태 확인 및 정리
cleanupTriggers();
// 6. 스케줄러 종료
performSchedulerShutdown();
} catch (SchedulerException e) {
log.error("Quartz 종료 중 오류 발생", e);
Thread.currentThread().interrupt();
} catch (InterruptedException e) {
log.error("Quartz 종료 대기 중 인터럽트 발생", e);
Thread.currentThread().interrupt();
}
}
/**
* 실행 중인 작업들의 상세 정보를 로깅
*
* @param currentJobs 현재 실행 중인 작업 목록
*/
private void logRunningJobs(List<JobExecutionContext> currentJobs) {
for (JobExecutionContext job : currentJobs) {
log.info("실행 중인 작업: {}, 트리거: {}, 시작 시간: {}",
job.getJobDetail().getKey(),
job.getTrigger().getKey(),
job.getFireTime()
);
}
}
/**
* 실행 중인 작업이 완료될 때까지 대기
*
* @param currentJobs 현재 실행 중인 작업 목록
* @throws InterruptedException 대기 중 인터럽트 발생 시
* @throws SchedulerException 스케줄러 관련 오류 발생 시
*/
private void waitForJobCompletion(List<JobExecutionContext> currentJobs)
throws InterruptedException, SchedulerException {
long startTime = System.currentTimeMillis();
while (!currentJobs.isEmpty() &&
(System.currentTimeMillis() - startTime) < SHUTDOWN_WAIT_TIME) {
log.info("실행 중인 작업이 완료될 때까지 대기 중...");
Thread.sleep(1000);
currentJobs = scheduler.getCurrentlyExecutingJobs();
}
}
/**
* 모든 트리거를 정리
*
* @throws SchedulerException 스케줄러 관련 오류 발생 시
*/
private void cleanupTriggers() throws SchedulerException {
List<String> groups = scheduler.getTriggerGroupNames();
for (String group : groups) {
Set<TriggerKey> triggerKeys = scheduler.getTriggerKeys(GroupMatcher.groupEquals(group));
for (TriggerKey triggerKey : triggerKeys) {
try {
Trigger trigger = scheduler.getTrigger(triggerKey);
if (trigger != null) {
scheduler.unscheduleJob(triggerKey);
log.info("트리거 제거됨: {}", triggerKey);
}
} catch (Exception e) {
log.error("트리거 제거 중 오류 발생: {}", triggerKey, e);
}
}
}
}
/**
* 스케줄러 종료 수행
*
* @throws SchedulerException 스케줄러 관련 오류 발생 시
*/
private void performSchedulerShutdown() throws SchedulerException {
scheduler.clear();
scheduler.shutdown(true); // true = 실행 중인 작업이 완료될 때까지 대기
log.info("Quartz 스케줄러가 정상적으로 종료되었습니다.");
}
/**
* 애플리케이션 컨텍스트 종료 전 실행되는 정리 메서드
*/
@PreDestroy
public void preDestroy() {
try {
if (!scheduler.isShutdown()) {
log.info("ApplicationContext 종료 전 Quartz 정리 작업 수행");
shutdownQuartzGracefully();
}
} catch (SchedulerException e) {
log.error("PreDestroy 단계에서 Quartz 종료 중 오류 발생", e);
}
}
}

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

@@ -25,16 +25,6 @@ public enum SecurityExceptionRule implements ErrorRule {
EXPIRED_JWT_ERROR(HttpStatus.UNAUTHORIZED, "토큰이 만료되었습니다. 다시 로그인해주세요.");
private final HttpStatus status;
private String message;
SecurityExceptionRule(HttpStatus status, String message) {
this.status = status;
this.message = message;
}
public SecurityExceptionRule message(final String message) {
this.message = message;
return this;
}
private final String message;
}

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,49 +63,71 @@ 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);
jwtTokenService.validateRefreshToken(refreshToken);
String reissuedAccessToken = jwtTokenService.getRefreshToken(refreshToken);
Authentication authentication = jwtTokenService.getAuthentication(reissuedAccessToken);
jwtTokenService.saveRefreshToken(authentication.getName(), jwtTokenService.generateRefreshToken(response, authentication));
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()))
.map(token -> token.substring(7))
.orElse(jwtTokenService.resolveTokenFromCookie(request, JwtTokenRule.ACCESS_PREFIX));
.orElseGet(() -> 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 인증 정보
@@ -112,15 +171,6 @@ public class JwtTokenService {
.build();
}
/**
* 액세스 토큰의 유효성을 검증합니다.
*
* @param token 검증할 토큰
*/
public void validateToken(String token) {
jwtTokenUtil.tokenStatus(token, accessSecretKey);
}
/**
* 액세스 토큰의 유효성을 검증합니다.
*
@@ -137,8 +187,8 @@ public class JwtTokenService {
* @param token 검증할 토큰
* @return 토큰의 유효성 여부
*/
public boolean validateRefreshToken(String token) {
return jwtTokenUtil.getTokenStatus(token, refreshSecretKey) == JwtTokenStatus.AUTHENTICATED;
public void validateRefreshToken(String token) {
jwtTokenUtil.tokenStatus(token, refreshSecretKey);
}
/**
@@ -187,14 +237,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);
}

View File

@@ -12,23 +12,35 @@ import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Menus {
DASHBOARD(
"/dashboard",
"Dashboard",
"bi bi-grid",
"/dashboard",
"Dashboard",
"bi bi-grid",
List.of(AgentUserRole.ROLE_SUPER, AgentUserRole.ROLE_ADMIN, AgentUserRole.ROLE_USER)
),
SCHEDULE(
"/schedule",
"Schedule",
"bi bi-menu-button-wide",
"/schedule",
"Schedule",
"bi bi-menu-button-wide",
List.of(AgentUserRole.ROLE_SUPER, AgentUserRole.ROLE_ADMIN)
),
JOB_HISTORY(
"/schedule/history",
"Job History",
"bi bi-clock-history",
List.of(AgentUserRole.ROLE_SUPER, AgentUserRole.ROLE_ADMIN)
),
USER_MANAGEMENT(
"/user/management",
"User Management",
"bi bi-person",
"/user/management",
"User Management",
"bi bi-person",
List.of(AgentUserRole.ROLE_SUPER)
),
END_POINT(
"/endpoint",
"API Explorer",
"bi bi-search",
List.of(AgentUserRole.ROLE_SUPER)
);

View File

@@ -0,0 +1,16 @@
package com.spring.web.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/endpoint")
public class EndPointController {
@GetMapping
public String endpoint() {
return "pages/endpoint/endpoint";
}
}

View File

@@ -8,11 +8,17 @@ import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/schedule")
public class ScheduleController {
@GetMapping
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
public String schedule() {
return "pages/schedule/schedule";
}
@GetMapping("/history")
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
public String history() {
return "pages/schedule/history";
}
}

View File

@@ -8,14 +8,14 @@ spring:
devtools:
restart:
enabled: false
add-properties: false
add-properties: false
application:
name: spring-batch-quartz
datasource:
primary:
driver-class-name: org.h2.Driver
url: 'jdbc:h2:mem:app'
username: mindol1004
url: 'jdbc:h2:file:D:/projects/h2-db/app'
username: test
password: 1111
hikari:
pool-name: HikariPool-1
@@ -24,8 +24,8 @@ spring:
idle-timeout: 60000
secondary:
driver-class-name: org.h2.Driver
url: 'jdbc:h2:mem:mob'
username: mindol1004
url: 'jdbc:h2:file:D:/projects/h2-db/mob'
username: test
password: 1111
hikari:
pool-name: HikariPool-2
@@ -44,7 +44,7 @@ spring:
database-platform: org.hibernate.dialect.H2Dialect
#show-sql: true
hibernate:
ddl-auto: create
ddl-auto: update
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
@@ -102,7 +102,7 @@ batch-info:
email-send-batch:
group: "EMAIL"
job-name: "emailSendJob"
cron-expression: "*/20 * * * * ?"
cron-expression: "*/10 * * * * ?"
description: "이메일배치작업"
post-batch:
group: "POST"
@@ -113,7 +113,36 @@ batch-info:
group: "POST"
job-name: "postCreateJob"
cron-expression: "0/30 * * * * ?"
description: "테스트배치작업"
description: "테스트배치작업"
feign:
okhttp:
enabled: true
client:
config:
default:
connectTimeout: 5000
readTimeout: 5000
loggerLevel: full
compression:
request:
enabled: true
response:
enabled: true
clients:
client1:
url: http://localhost:8082
connectTimeout: 5000
readTimeout: 5000
useProxy: true
proxyHost: http://localhost:8083
proxyPort: 8083
client2:
url: http://api1.example.com
headers:
Client1-Specific-Header: Value1
Another-Client1-Header: AnotherValue1
jwt:
access-token:
@@ -123,6 +152,14 @@ jwt:
secret: bnhjdXMyLjAtcGxhdGZvcm0tcHJvamVjdC13aXRoLXNwcmluZy1ib290bnhjdF9zdHJvbmdfY29tcGxleF9zdHJvbmc=
expiration: 10080
path:
paths:
path1:
upload: /path1/upload
path2:
upload: /path2/upload
dowonload: /path2/dowonload
management:
endpoints:
web:
@@ -131,7 +168,10 @@ management:
logging:
level:
root: info
org:
springframework:
web: debug
hibernate:
SQL: debug
type:

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
# 스프링 애플리케이션 예외 처리 가이드
## 목차
1. [개요](#개요)
2. [전역 예외 처리 아키텍처](#전역-예외-처리-아키텍처)
3. [예외 처리 구현 가이드](#예외-처리-구현-가이드)
4. [예외 응답 형식](#예외-응답-형식)
5. [예외 처리 모범 사례](#예외-처리-모범-사례)
6. [문제 해결 가이드](#문제-해결-가이드)
## 개요
이 문서는 스프링 기반 애플리케이션에서의 예외 처리 메커니즘을 상세히 설명합니다.
체계적인 예외 처리를 통해 애플리케이션의 안정성을 높이고 클라이언트에게 명확한 오류 정보를 제공할 수 있습니다.
## 예외 처리 가이드라인
- 도메인별 error 패키지 생성: 각 도메인 패키지 내에 error 패키지를 생성하여 예외를 구조화합니다.
- BizBaseException 상속: 도메인별 예외 클래스는 공통 예외 로직을 재사용하기 위해 BizBaseException을 상속받습니다.
- ErrorRule 구현: 도메인별로 발생 가능한 예외 규칙을 ErrorRule로 정의합니다.
- 에러 메시지 관리: 메시지는 절대 하드코딩하지 않고 ErrorRule을 통해 관리합니다.
## 전역 예외 처리 아키텍처
### GlobalExceptionHandler 클래스
```java
@RestControllerAdvice
public class GlobalExceptionHandler {
// 예외 처리 메서드들이 구현됨
}
```
`@RestControllerAdvice` 어노테이션을 사용하여 전역 예외 처리기를 구현합니다. 이 클래스는 다음과 같은 주요 기능을 제공합니다:
- 애플리케이션 전반의 예외를 일관되게 처리
- HTTP 응답 상태 코드 및 본문 자동 생성
- 구조화된 오류 응답 제공
## 예외 처리 구현 가이드
### 1. 비즈니스 예외 처리 (BizBaseException)
```java
@ExceptionHandler(BizBaseException.class)
public BizErrorResponse handleCustomException(BizBaseException e) {
return BizErrorResponse.valueOf(e.getErrorRule());
}
```
#### 주요 특징
- 비즈니스 로직에서 발생하는 모든 커스텀 예외 처리
- `ErrorRule`을 통한 체계적인 오류 분류
- 일관된 응답 형식 제공
#### 사용 예시
```java
if (data == null) {
throw new BizBaseException();
}
```
#### 응답 예시
```json
{
"timestamp": "2024-11-18T10:59:51.6131807",
"statusCode": 500,
"path": "/",
"data": null,
"message": "시스템 오류 입니다."
}
```
### 2. 유효성 검증 예외 처리 (MethodArgumentNotValidException)
```java
@ExceptionHandler(MethodArgumentNotValidException.class)
public BizErrorResponse methodArgumentNotValidException(MethodArgumentNotValidException e) {
return BizErrorResponse.fromFieldError(e.getFieldErrors());
}
```
#### 처리되는 상황
- 요청 DTO의 필드 유효성 검증 실패
- `@Valid` 또는 `@Validated` 어노테이션 사용 시 발생하는 예외
- 잘못된 데이터 형식이나 필수 필드 누락
#### 응답 예시
```json
{
"timestamp": "2024-11-18T11:03:05.7627081",
"statusCode": 422,
"path": "/api/user/sign-up",
"data": [
{
"field": "userPassword",
"value": "",
"reason": "비밀번호는 필수값 입니다."
}
],
"message": "요청을 처리할 수 없습니다."
}
```
### 3. 데이터베이스 예외 처리 (DataAccessException)
```java
@ExceptionHandler(DataAccessException.class)
public BizErrorResponse handleDataAccessException(DataAccessException e) {
Throwable cause = e.getMostSpecificCause();
return BizErrorResponse.valueOf(
new BizBaseException(
Optional.ofNullable(cause)
.map(Throwable::getMessage)
.orElse(ExceptionRule.SYSTEM_ERROR.getMessage()))
.getErrorRule()
);
}
```
#### 주요 처리 상황
- 데이터베이스 연결 실패
- 중복 키 위반
- 데이터 무결성 제약 조건 위반
- SQL 문법 오류
## 도메인별 예외 처리
도메인별로 발생할 수 있는 예외를 정의하고, GlobalExceptionHandler에서 처리되도록 구조화합니다.
### 1. 도메인별 error 패키지 생성
각 도메인 패키지 내에 error 패키지를 생성합니다. 예를 들어, 사용자 도메인의 경우 아래와 같은 구조를 가집니다.
- com.skcc.domain.user.error
### 2. BizBaseException 상속
모든 도메인별 예외 클래스는 공통 예외 처리 로직을 재사용하기 위해 BizBaseException을 상속받습니다.
```java
import com.spring.common.error.BizBaseException;
public class UserNotFoundException extends BizBaseException {
public UserNotFoundException() {
super(UserRule.USER_NOT_FOUND);
}
}
```
### 3. ErrorRule 구현
#### 3.1 ErrorRule 인터페이스 정의
도메인별 예외 규칙을 정의하기 위해 ErrorRule 인터페이스를 구현합니다. 이를 통해 예외 메시지와 HTTP 상태 코드를 일관되게 관리할 수 있습니다.
```java
import org.springframework.http.HttpStatus;
public interface ErrorRule {
HttpStatus getStatus();
String getMessage();
}
```
#### 3.2 도메인별 ErrorRule 정의
각 도메인에서 발생할 수 있는 예외를 enum으로 정의합니다. 예외 메시지와 HTTP 상태 코드를 설정하여 한 곳에서 관리할 수 있습니다.
```java
@Getter
@RequiredArgsConstructor
public enum UserRule implements ErrorRule {
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."),
USER_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "사용자 인증에 실패 하였습니다.");
private final HttpStatus status;
private String message;
UserRule(HttpStatus status, String message) {
this.status = status;
this.message = message;
}
public UserRule message(final String message) {
this.message = message;
return this;
}
}
```
### 4. 에러 메시지 관리
#### 4.1 하드코딩 방지
- 에러 메시지는 절대 하드코딩하지 않고, 각 도메인의 ErrorRule을 통해 관리합니다.
- 이를 통해 에러 메시지의 일관성을 유지하고, 메시지 변경 시 코드 수정 범위를 최소화할 수 있습니다.
```java
public class UserNotFoundException extends BizBaseException {
public UserNotFoundException() {
super(UserRule.USER_NOT_FOUND);
}
}
```
<div style="text-align: left; margin: 20px 0;">
<a href="#" style="color: white; text-decoration: none;">
<button style="background-color: #007BFF; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer;">
Top
</button>
</a>
</div>

View File

@@ -0,0 +1,432 @@
## Thymeleaf 사용 가이드
### 1. 기본 설정
#### 1.1 의존성 추가
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
```
#### 1.2 application.yml 설정
```yaml
spring:
thymeleaf:
cache: false # 개발 환경에서는 캐시 비활성화
prefix: classpath:/templates/ # 템플릿 위치
suffix: .html # 템플릿 확장자
mode: HTML # 템플릿 모드
encoding: UTF-8 # 인코딩
```
### 2. 레이아웃 구성
#### 2.1 기본 레이아웃 (layout.html)
```html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta charset="UTF-8">
<title layout:title-pattern="$CONTENT_TITLE - 배치 모니터링">기본 타이틀</title>
<!-- 공통 CSS -->
<link rel="stylesheet" th:href="@{/css/common.css}">
<link rel="stylesheet" th:href="@{/css/layout.css}">
<!-- 페이지별 CSS 영역 -->
<th:block layout:fragment="css"></th:block>
</head>
<body>
<!-- 헤더 영역 -->
<header th:replace="fragments/header :: header"></header>
<!-- 사이드바 영역 -->
<aside th:replace="fragments/sidebar :: sidebar"></aside>
<!-- 본문 영역 -->
<main layout:fragment="content">
<!-- 각 페이지의 내용이 여기에 들어감 -->
</main>
<!-- 푸터 영역 -->
<footer th:replace="fragments/footer :: footer"></footer>
<!-- 공통 JavaScript -->
<script th:src="@{/js/common.js}"></script>
<!-- 페이지별 JavaScript 영역 -->
<th:block layout:fragment="script"></th:block>
</body>
</html>
```
#### 2.2 페이지 구현 예시 (batch-jobs.html)
```html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/layout}">
<head>
<title>배치 작업 목록</title>
<!-- 페이지별 CSS -->
<th:block layout:fragment="css">
<link rel="stylesheet" th:href="@{/css/batch-jobs.css}">
</th:block>
</head>
<body>
<!-- 본문 영역 -->
<main layout:fragment="content">
<h2>배치 작업 목록</h2>
<div class="job-list">
<table>
<thead>
<tr>
<th>작업명</th>
<th>상태</th>
<th>마지막 실행</th>
<th>다음 실행</th>
<th>작업</th>
</tr>
</thead>
<tbody>
<tr th:each="job : ${jobs}">
<td th:text="${job.name}">작업명</td>
<td th:text="${job.status}">상태</td>
<td th:text="${#temporals.format(job.lastExecuted, 'yyyy-MM-dd HH:mm:ss')}">
마지막 실행
</td>
<td th:text="${#temporals.format(job.nextExecute, 'yyyy-MM-dd HH:mm:ss')}">
다음 실행
</td>
<td>
<button th:onclick="'executeJob(\'' + ${job.id} + '\')'">실행</button>
</td>
</tr>
</tbody>
</table>
</div>
</main>
<!-- 페이지별 JavaScript -->
<th:block layout:fragment="script">
<script th:src="@{/js/batch-jobs.js}"></script>
</th:block>
</body>
</html>
```
### 3. Thymeleaf 문법 가이드
#### 3.1 기본 표현식
```html
<!-- 텍스트 출력 -->
<span th:text="${message}">기본 메시지</span>
<!-- HTML 출력 -->
<div th:utext="${htmlContent}">기본 HTML</div>
<!-- 변수 표현식 -->
<span th:text="${user.name}">사용자명</span>
<!-- URL 표현식 -->
<a th:href="@{/jobs/{id}(id=${job.id})}">상세보기</a>
<!-- 조건부 표현식 -->
<div th:if="${not #lists.isEmpty(jobs)}">
작업이 있습니다.
</div>
<div th:unless="${not #lists.isEmpty(jobs)}">
작업이 없습니다.
</div>
<!-- 반복문 -->
<tr th:each="job, stat : ${jobs}">
<td th:text="${stat.count}">1</td>
<td th:text="${job.name}">작업명</td>
</tr>
<!-- Switch-Case -->
<div th:switch="${job.status}">
<p th:case="'RUNNING'">실행 중</p>
<p th:case="'COMPLETED'">완료</p>
<p th:case="*">기타</p>
</div>
```
#### 3.2 유틸리티 객체
```html
<!-- 날짜 포맷팅 -->
<span th:text="${#temporals.format(job.startTime, 'yyyy-MM-dd HH:mm:ss')}">
2024-01-01 12:00:00
</span>
<!-- 숫자 포맷팅 -->
<span th:text="${#numbers.formatDecimal(amount, 1, 'COMMA', 2, 'POINT')}">
1,234.56
</span>
<!-- 문자열 처리 -->
<span th:text="${#strings.abbreviate(description, 100)}">
긴 설명...
</span>
<!-- 컬렉션 처리 -->
<div th:if="${#lists.isEmpty(jobs)}">
작업이 없습니다.
</div>
```
#### 3.3 폼 처리
```html
<!-- 폼 바인딩 -->
<form th:action="@{/jobs}" th:object="${jobForm}" method="post">
<div>
<label>작업명:</label>
<input type="text" th:field="*{name}">
<span th:if="${#fields.hasErrors('name')}"
th:errors="*{name}"
class="error">
이름 오류
</span>
</div>
<div>
<label>Cron 표현식:</label>
<input type="text" th:field="*{cronExpression}">
<span th:if="${#fields.hasErrors('cronExpression')}"
th:errors="*{cronExpression}"
class="error">
Cron 표현식 오류
</span>
</div>
<button type="submit">저장</button>
</form>
```
### 4. 공통 컴포넌트
#### 4.1 페이지네이션 (fragments/pagination.html)
```html
<div th:fragment="pagination (page)">
<div class="pagination"
th:if="${page.totalPages > 0}">
<!-- 이전 페이지 -->
<a th:href="@{${#httpServletRequest.requestURI}(page=${page.number - 1})}"
th:class="${page.first} ? 'disabled' : ''"
th:if="${not page.first}">
이전
</a>
<!-- 페이지 번호 -->
<span th:each="pageNum : ${#numbers.sequence(0, page.totalPages - 1)}">
<a th:href="@{${#httpServletRequest.requestURI}(page=${pageNum})}"
th:text="${pageNum + 1}"
th:class="${pageNum == page.number} ? 'active' : ''">
1
</a>
</span>
<!-- 다음 페이지 -->
<a th:href="@{${#httpServletRequest.requestURI}(page=${page.number + 1})}"
th:class="${page.last} ? 'disabled' : ''"
th:if="${not page.last}">
다음
</a>
</div>
</div>
```
#### 4.2 알림 메시지 (fragments/alert.html)
```html
<div th:fragment="alert (type, message)">
<div th:if="${message != null}"
th:class="'alert alert-' + ${type}">
<span th:text="${message}">알림 메시지</span>
<button type="button" class="close" data-dismiss="alert">&times;</button>
</div>
</div>
```
### 5. JavaScript 연동
#### 5.1 타임리프와 JavaScript 데이터 전달
```html
<!-- 단일 값 전달 -->
<script th:inline="javascript">
const message = /*[[${message}]]*/ '기본 메시지';
// 객체 전달
const job = /*[[${job}]]*/ {};
// 배열 전달
const jobs = /*[[${jobs}]]*/ [];
</script>
<!-- AJAX 호출 예시 -->
<script th:inline="javascript">
function executeJob(jobId) {
const csrfToken = /*[[${_csrf.token}]]*/ '';
const csrfHeader = /*[[${_csrf.headerName}]]*/ 'X-CSRF-TOKEN';
fetch(`/api/jobs/${jobId}/execute`, {
method: 'POST',
headers: {
[csrfHeader]: csrfToken
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('작업이 실행되었습니다.');
location.reload();
} else {
alert('작업 실행에 실패했습니다.');
}
});
}
</script>
```
### 6. 보안 처리
#### 6.1 Spring Security 통합
```html
<!-- 인증 여부 확인 -->
<div th:if="${#authorization.expression('isAuthenticated()')}">
<span th:text="${#authentication.name}">사용자명</span>
</div>
<!-- 권한 기반 표시 -->
<div th:if="${#authorization.expression('hasRole(''ADMIN'')')}">
관리자 메뉴
</div>
<!-- CSRF 토큰 -->
<form th:action="@{/jobs}" method="post">
<input type="hidden"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}" />
<!-- 폼 내용 -->
</form>
```
### 7. 에러 페이지
#### 7.1 에러 페이지 구현 (error.html)
```html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/layout}">
<head>
<title>에러 발생</title>
</head>
<body>
<main layout:fragment="content">
<div class="error-container">
<h2>오류가 발생했습니다</h2>
<div class="error-details">
<p>
<strong>상태:</strong>
<span th:text="${status}">500</span>
</p>
<p>
<strong>메시지:</strong>
<span th:text="${error}">Internal Server Error</span>
</p>
<div th:if="${trace}" class="error-trace">
<pre th:text="${trace}">스택 트레이스</pre>
</div>
</div>
<a th:href="@{/}" class="btn btn-primary">홈으로 돌아가기</a>
</div>
</main>
</body>
</html>
```
### 8. 성능 최적화
#### 8.1 캐시 설정
```yaml
spring:
thymeleaf:
cache: true # 운영 환경에서는 캐시 활성화
```
#### 8.2 프래그먼트 캐싱
```html
<!-- 캐시 키 지정 -->
<div th:fragment="userInfo (user)"
th:cache="${user.id}">
<!-- 사용자 정보 표시 -->
</div>
```
### 9. 국제화 (i18n)
#### 9.1 메시지 설정
```yaml
spring:
messages:
basename: messages
encoding: UTF-8
```
#### 9.2 메시지 사용
```html
<!-- 메시지 출력 -->
<h1 th:text="#{page.title}">제목</h1>
<!-- 파라미터가 있는 메시지 -->
<p th:text="#{message.welcome(${user.name})}">환영합니다</p>
<!-- 언어 선택 -->
<div class="language-selector">
<a th:href="@{''(lang=ko)}">한국어</a>
<a th:href="@{''(lang=en)}">English</a>
</div>
```
### 10. 모범 사례
#### 10.1 코드 구조화
```text
templates/
├── layouts/
│ └── layout.html
├── fragments/
│ ├── header.html
│ ├── footer.html
│ ├── sidebar.html
│ ├── pagination.html
│ └── alert.html
├── batch/
│ ├── list.html
│ ├── detail.html
│ └── form.html
├── error/
│ ├── 404.html
│ ├── 500.html
│ └── error.html
└── index.html
```
#### 10.2 네이밍 컨벤션
- 레이아웃 템플릿: layout.html
- 페이지 템플릿: 기능명/동작.html (예: batch/list.html)
- 프래그먼트: fragments/컴포넌트명.html
- 에러 페이지: error/에러코드.html
#### 10.3 주석 처리
```html
<!-- /* 타임리프 주석 - 클라이언트에 전송되지 않음 */ -->
<!--/* 여러 줄
타임리프 주석 */-->
<!-- 일반 HTML 주석 - 클라이언트에 전송됨 -->
```

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
import apiClient from '../common/axios-instance.js';
const endPointService = {
getEndpoints: async () => {
const response = await apiClient.get('/api/endpoint');
return response.data.data;
}
};
export default endPointService;

View File

@@ -39,6 +39,12 @@ const scheduleService = {
refreshJob: async () => {
await apiClient.post('/actuator/refresh');
return true;
},
getJobHistory: async (searchParams) => {
console.log(searchParams);
const response = await apiClient.get('/api/schedule/history', { params: searchParams });
return response.data.data;
}
};

View File

@@ -2,8 +2,8 @@ import apiClient, { saveAccessToken, removeTokens } from '../common/axios-instan
const signService = {
signIn: async (username, password) => {
const response = await apiClient.post('/sign-in', { username, password });
signIn: async (params) => {
const response = await apiClient.post('/sign-in', params);
const accessToken = response.headers['authorization'].split(' ')[1];
saveAccessToken(accessToken);
return response.data;
@@ -24,8 +24,8 @@ const signService = {
return response.data.data;
},
changePassword: async (userId, email, newPassword) => {
const response = await apiClient.post('/api/user/change-password', { userId, email, newPassword });
changePassword: async (params) => {
const response = await apiClient.post('/api/user/change-password', params);
return response.data.data;
}

View File

@@ -1,5 +1,20 @@
dayjs.locale('ko');
export const addEvents = (eventMap) => {
Object.entries(eventMap).forEach(([selector, { event, handler }]) => {
const elements = selector.startsWith('#') || selector.startsWith('.') || document.getElementsByName(selector).length > 0
? document.querySelectorAll(selector)
: document.querySelectorAll(`[id="${selector}"], [class="${selector}"], [name="${selector}"]`);
elements.forEach(element => {
element.addEventListener(event, (e) => {
e.preventDefault();
handler(e);
});
});
});
}
export const formatDateTime = (dateTimeString) => {
if (!dateTimeString) return '-';
const date = new Date(dateTimeString);

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More