개인적으로 사용 중이었던 방법인데 11번가도 동일하게 사용 중(참고 링크)

 

run_job.sh

#!/bin/bash

#apply env and etc
source ~/.bashrc


#사용 예
# ./run_job.sh 블라블라.jar 128M DB_HEALTH_CHECK argument_test=92

#실행할 Jar 심볼릭 링크 경로
JAR_LINK_PATH=$1

#Java 힙 메모리
JAVA_HEAP_MEMORY=$2

#Job name
JOB_NAME=$3

# 인자 갯수가 적은 경우 경고 메시지 출력
if [ $# -lt 3 ]; then
  echo "Usage: $0 JAR_LINK_PATH JAVA_HEAP_MEMORY JOB_NAME jobParameters[...]"
  exit 1
fi


#앞 3개 인자는 건너뜀
shift 3

jobParameters=""
for arg in "$@";
do
  if [ -n "$jobParameters" ]; then
    jobParameters+=" "
  fi
  jobParameters+="$arg"
done

JAVA_OPTS=" ${JAVA_OPTS} -Djava.security.egd=file:/dev/./urandom"
JAVA_OPTS=" ${JAVA_OPTS} -server -Xms${JAVA_HEAP_MEMORY} -Xmx${JAVA_HEAP_MEMORY} -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${HOME}/oom_error_batch.hprof"

# 심볼릭 링크가 연결되어 있는 jar 파일 경로 가져오기
ORIGIN_JAR=$(readlink ${JAR_LINK_PATH})

echo "> ORIGIN_JAR_PATH: ${ORIGIN_JAR}"

$JAVA_HOME/bin/java -jar $JAVA_OPTS ${ORIGIN_JAR} --job.name=$JOB_NAME $jobParameters



switch-link.sh

#!/bin/bash

#사용 예
# ./switch-link.sh 블라블라/latest/블라블라_BATCH.jar

#실행할 Jar파일 전체 경로
DEPLOYED_JAR_FILE_FULL_PATH=$1

#실행할 Jar 파일 이름
JAR_FILE_NAME=$(basename "$DEPLOYED_JAR_FILE_FULL_PATH")

#APP 디렉토리 home
DEPLOYED_JAR_DIR=$(dirname "$DEPLOYED_JAR_FILE_FULL_PATH")
APP_HOME_PATH=$(dirname "$DEPLOYED_JAR_DIR")

#실행되는 app 심볼릭 링크가 위치할 디렉토리 경로
RUN_APP_LINK_DIR_PATH=${APP_HOME_PATH}

#run command로 전달된 argument 개수
ARG_CNT=$#

#run argument로 1개만 전달받아야함
REQUIRED_ARG_CNT=1

#기존에 배포된 디렉토리 유지할 개수(latest 디렉토리 제외한 deployed로 시작하는 디렉토리)
MAINTAIN_DEPLOYED_DIRECTORY_COUNT=10

function echo_host
{
        host=`hostname`
        echo "[$host] $1"
}

function check_arg
{

    if [ "$ARG_CNT" != "$REQUIRED_ARG_CNT" ] ; then
        echo_host "## Requested argument error. Required argument count is $REQUIRED_ARG_CNT. But requested $ARG_CNT"
        exit -1;
    fi

    # 배포된 파일이 존재하는지 확인
    if [ ! -f "$DEPLOYED_JAR_FILE_FULL_PATH" ]; then
        echo_host "Error: DEPLOYED_JAR_FILE_FULL_PATH does not exist."
        exit -1
    fi

}

#디렉토리 준비
function prepare_directory
{

    #실제 마지막 배포되어 실행될 jar파일의 심볼링 링크 디렉토리가 없다면 생성
    if [[ ! -e $RUN_APP_LINK_DIR_PATH ]]; then
        mkdir -p $RUN_APP_LINK_DIR_PATH
        echo_host ">>>> ${RUN_APP_LINK_DIR_PATH} (RUN_APP_LINK_DIR_PATH) Directory has been created."
    fi

}

# 마지막 배포된 jar파일을 해당 배포버전의 전용 디렉토리로 복사
function copy_deployed_jar_to_owned_dir {

    #마지막 배포된 Jar파일을 복사할 디렉토리 이름.
    # 참고: 오래된 디렉토리 삭제할 때 prefix deployed로 시작하는 디렉토리를 대상으로 하기 때문에 deployed라는 접두어는 유지해야함
    COPY_DIRECTORY_NAME=deployed-$(/bin/date +%Y-%m-%d_%H%M%S)

    # 배포되는 jar파일을 복사할 새로운 디렉토리 생성
    echo_host ">>>> mkdir deployed jar copy directory. [${APP_HOME_PATH}/${COPY_DIRECTORY_NAME}]"
    mkdir -p ${APP_HOME_PATH}/${COPY_DIRECTORY_NAME}


    # Deploy Path에 배포된 jar 파일을 새로운 디렉토리로 복사하기.
    echo_host ">>>> copy [${DEPLOYED_JAR_FILE_FULL_PATH}] to [${APP_HOME_PATH}/${COPY_DIRECTORY_NAME}/${JAR_FILE_NAME}]"
    cp -f ${DEPLOYED_JAR_FILE_FULL_PATH} ${APP_HOME_PATH}/${COPY_DIRECTORY_NAME}/${JAR_FILE_NAME}

}

#심볼릭 링크 생성(신규로 실행되는 배치 Job은 해당 심볼릭 링크를 바라봄으로 새로 배포된 jar로 실행되게 됨)
function create_run_jar_link {

    BEFORE_JAR_PATH=$(readlink ${RUN_APP_LINK_DIR_PATH}/${JAR_FILE_NAME})

    # 새로운 디렉토리 경로에 복사된 jar 파일로 링크 변경하기.
    # ln -Tfs TARGET LINK 명령으로 링크를 변경
    # -T option: –no-target-directory treat LINK_NAME as a normal file. 링크 파일을 일반 파일처럼 다루는 옵션
    # -f option: –force remove existing destination files. 심볼릭 링크가 이미 존재할 경우 덮어쓰는 옵션
    # -s option: –symbolic make symbolic links instead of hard links. 심볼릭 링크를 생성하는 옵션
    echo_host ">>>> Link switched from [$BEFORE_JAR_PATH] to [$APP_HOME_PATH/$COPY_DIRECTORY_NAME/$JAR_FILE_NAME]"
    ln -Tfs ${APP_HOME_PATH}/${COPY_DIRECTORY_NAME}/${JAR_FILE_NAME} ${RUN_APP_LINK_DIR_PATH}/${JAR_FILE_NAME}

}

function remove-old-directories() {

    # 1) 배포된 디렉토리 개수
    DIRECTORY_COUNT=$(ls -d ${APP_HOME_PATH}/deployed*/ | wc -l)

    # 유지할 디렉토리보다 많이 존재할 경우
    if [ $DIRECTORY_COUNT -gt $MAINTAIN_DEPLOYED_DIRECTORY_COUNT ]
    then

      # 2) 제거할 디렉토리 개수 카운트
      REMOVE_TARGET_COUNT=$(( ${DIRECTORY_COUNT} - ${MAINTAIN_DEPLOYED_DIRECTORY_COUNT}))

      # 3) 오래된 디렉토리부터 제거할 디렉토리 개수만큼 추출
      # 오래된 순으로 제거하기 위해 ls 명령어의 -t, -r 옵션 사용.
      # -t option: 파일과 디렉토리를 최근 시간 기준 내림차순 정렬
      # -r option: 정렬된 데이터의 순서를 오름차순으로
      REMOVE_TARGET_LIST=$(ls -dltr ${APP_HOME_PATH}/deployed*/ | head -$REMOVE_TARGET_COUNT | awk '{print $9}')

      # 삭제 대상 디렉토리 제거
      for file in ${REMOVE_TARGET_LIST}
      do
        echo ">>>> remove $file"
        /usr/bin/rm -rf ${file}
      done
    fi
}

# argument valid 체크
check_arg

#디렉토리 준비
prepare_directory

#jar 파일을 유지하기 위한 해당 jar용 디렉토리로 복사
copy_deployed_jar_to_owned_dir

#심볼릭 링크 생성
create_run_jar_link

#오래된 디렉토리 제거를 통해서 디스크 용량 관리
remove-old-directories

좋은 글 참고 링크

 - 상세 사용 방법 등은 코드는 추후 해당 글에 추가 예정

spring boot 3.2 릴리즈 일정 확인 중에 알게된 링크

https://calendar.spring.io/

메모 목적으로 작성한 글이라서 생략된 부분이 많습니다.

 

  1.  목적/배경
    1. 현재 JDK 21의 VirtualThread기반으로 작업 중
    2. webflux를 이용해서 논블럭을 작성할 필요가 없어짐
      1. 유지보수, 읽기좋은 소스 등의 관점에서 기존 동기방식 스타일 코드 작성이 유리
      2. IO 블럭킹에 대한 성능 문제는 VirtualThread가 blocking 코드를 만나면 잠시 대기/큐잉 등 의 형태로 커버됨
    3. 다만, 통신용 모듈이 webclient로 기존에 작성되어 있음
      1. spring 디펜더시 문제 등의 이유로 23년 11월 23일에 spring boot 3.2가 릴리즈될때 rest client가 포함되서 해결 예정
      2. 지금 당장 webclient로 작성된 코드도 필요
    4. webclient를 block()으로 호출해서 임시 사용

 

------

샘플 소스

/**
 * Webclient로 외부 API를 호출
 *  - block()을 사용하여 결과를 받아옴
 *  - 500 에러가 발생하면 재시도
 *
 * @author 
 */
@Slf4j
public class WebClientBlockRetriveRequestSample {

  public static void main(String[] args) {

    final String reqUri = "http://localhost:87/delay/2"; //테스트 대상 URL
    final Duration timeoutDuration = Duration.ofSeconds(1); //Timeout

    String apiResponse = null;
    try {
      apiResponse = getRequestExcute(reqUri, timeoutDuration); //요청 실행
    } catch (BadWebClientRequestException e) {
      log.error("BadWebClientRequestException발생\n\n\t{}", e.getMessage(), e);
      throw e;
    } catch (WebClientTimeoutException te) {
      log.error("WebClientTimeoutException발생\n\n\t{}", te.getMessage(), te);
    }

    log.info("apiResponse: {}", apiResponse);

  }

  /**
   * 외부 HTTP 요청 실행
   *
   * @param reqUri
   * @param timeoutDuration
   * @return
   */
  public static String getRequestExcute(String reqUri, Duration timeoutDuration) {

    WebClient webClient = WebClient.builder()
        //.defaultHeader("Content-Type", "application/json")
        .build();

    String apiResponse = webClient.mutate().build().get()
        .uri(reqUri)
        .retrieve()
        .onStatus(httpStatus -> httpStatus.is4xxClientError() || httpStatus.is5xxServerError(),
            clientResponse -> handleErrorResponse(reqUri, clientResponse)
        ).bodyToMono(String.class)
        .timeout(timeoutDuration)
        .doOnError(throwable -> {

          if (throwable instanceof java.util.concurrent.TimeoutException) { //타임아웃 발생한 경우 핸들링을 위해서 예외 클래스 변경 처리
            log.error("TimeoutException: " + throwable.getMessage());
            throw new WebClientTimeoutException(String.format("Steam API no response whthin %s(millis)", timeoutDuration.toMillis()));
          }

        })
        .retryWhen(Retry.backoff(2, Duration.ofSeconds(2)).maxBackoff(Duration.ofSeconds(3)).jitter(0.5)
            .filter(throwable -> throwable instanceof WebClientNeedRetryException)) //특정 예외인 경우 재 시도
        .block(); //동기 방식으로 호출(virtual thread사용하기 때문에 문제 없음)

    return apiResponse;
  }


  public static Mono<? extends Throwable> handleErrorResponse(String uri, ClientResponse response) {

    if (response.statusCode().is4xxClientError()) {
      String errMsg = String.format("'%s' 4xx ERROR. statusCode: %s, response: %s, header: %s", uri, response.statusCode().value(), response.bodyToMono(String.class), response.headers().asHttpHeaders());
      log.error(errMsg);
      return Mono.error(new BadWebClientRequestException(response.statusCode().value(), errMsg));
    }

    if (response.statusCode().is5xxServerError()) { //5xx에러인 경우 재 시도 처리를 위해서 재 시도 필요 예외를 리턴
      String errMsg = String.format("'%s' 5xx ERROR. %s", uri, response.toString());
      log.error(errMsg);
      return Mono.error(new WebClientNeedRetryException(response.statusCode().value(), errMsg));
    }

    String errMsg = String.format("'%s' ERROR. statusCode: %s, response: %s, header: %s", uri, response.statusCode().value(), response.bodyToMono(String.class), response.headers().asHttpHeaders());
    log.error(errMsg);
    return Mono.error(new RuntimeException(errMsg));

  }

 

사용하는 커스텀 개발된 예외들

/**
 * 잘못된 파라미터로 요청시 발생하는 Exception
 *
 * @author 
 */
@Getter
public class BadWebClientRequestException extends RuntimeException {

  private static final long serialVersionUID = 2241080498857315158L;

  private final int statusCode;

  private String statusText;

  public BadWebClientRequestException(int statusCode) {
    super();
    this.statusCode = statusCode;
  }

  public BadWebClientRequestException(int statusCode, String msg) {
    super(msg);
    this.statusCode = statusCode;
  }

  public BadWebClientRequestException(int statusCode, String msg, String statusText) {
    super(msg);
    this.statusCode = statusCode;
    this.statusText = statusText;
  }
}

 

/**
 * Webclient로 호출 중 재 시도가 필요한 경우에 사용하는 Exception
 *
 * @author 
 */
@Getter
public class WebClientNeedRetryException extends RuntimeException {

  private static final long serialVersionUID = 3238789645114297396L;

  private final int statusCode;

  private String statusText;

  public WebClientNeedRetryException(int statusCode) {
    super();
    this.statusCode = statusCode;
  }

  public WebClientNeedRetryException(int statusCode, String msg) {
    super(msg);
    this.statusCode = statusCode;
  }

  public WebClientNeedRetryException(int statusCode, String msg, String statusText) {
    super(msg);
    this.statusCode = statusCode;
    this.statusText = statusText;
  }

JPA 사용 할 때 발생할 수 있는 N+1 문제와 관련해서 Spring Data JDBC에서 기능 추가를 준비하고 있나 봅니다.(2023년 8월 31일 글 업데이트 됨)
 - 참고. N+1문제)  교실과 학생이 1:N인 관계라면, 교실 10건을 조회하는 한번의 질의를 실행해도 학생 테이블은 교실 갯수 10번의 쿼리가 실행되어 총 11번 쿼리가 실행되는 문제
 
참고 링크
 - https://spring.io/blog/2023/08/31/this-is-the-beginning-of-the-end-of-the-n-1-problem-introducing-single-query
- https://github.com/spring-projects/spring-data-relational/issues/1445

파일 업로드 기반의 기능을 종종 개발해야할 일이 있습니다.
이때 업로드 파일의 유효성(특히, 보안문제로)을 잘 진행해줘야합니다.
 
간단하게 범용적으로 사용할만한 커스텀 어노테이션을 만들어서 메모 목적으로 글을 작성해둡니다.
 
 
* 요약: 파일명의 확장자 검사뿐만 아니라, 확장자가 수정된 파일인지도 잘 검사해줘야함
 
1. apache tika 라이브러리를 추가해서 mime타입 검사에 사용

<!-- 업로드 파일의 확장자 검사 등의 목적 -->
<dependency>
    <groupId>org.apache.tika</groupId>
    <artifactId>tika-core</artifactId>
    <version>2.8.0</version>
</dependency>

 
2. 업로드 허용 파일들에 대해서 정의 enum 작성
 - 코드 관리를 조금 더 타이트하게 하려고 정의했는데 안쓰고 구현해도 무방

import lombok.AllArgsConstructor;
import lombok.Getter;

/**
 * 업로드 허용 파일에 대한 정의 Enum
 *  - 실제 사용되는 일부 파일들에 대한 정의만 추가되어 있으니, 신규 정의가 필요하면 내용 작성해서 사용
 *
 * @author 
 */
@Getter
@AllArgsConstructor
public enum UploadAllowFileDefine {

	// @formatter:off
	CSV("csv", new String[]{"text/csv", "text/plain"}), //텍스트 에디터에서 수정되는 text/plain 도 허용함
	;
	
	// @formatter:on

	private String fileExtensionLowerCase; //파일 확장자(소문자)
	private String[] allowMimeTypes; //허용하는 mime type array(파일 내용 변조 후 확장자 변경하는 공격을 막기 위해서 사용. 2023-07-31 기준 apache TIKA로 detect 중)

}

 
3. 커스텀 valid 인터페이스 작성

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 업로드 파일의 유효성 검사 체크용
 *
 * @author 
 */
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = FileUploadValidator.class)
public @interface FileUploadValid {

	String message();

	Class<?>[] groups() default {};

	Class<? extends Payload>[] payload() default {};

	/** 업로드 허용 파일들의 정의 array(여러 종류의 파일 타입을 허용할 수도 있기에 array) */
	UploadAllowFileDefine[] allowFileDefines();

}

 
4. 커스텀 validator 구현체 작성

....

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.tika.Tika;
import org.springframework.web.multipart.MultipartFile;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.io.IOException;

/**
 * 업로드 파일의 유효성 검사 체크용
 *  - 사용 방법 예) @FileUploadValid(allowFileDefines = {UploadAllowFileDefine.CSV}, message = "유효한 CSV파일만 업로드 가능합니다.")
 *
 * @author 
 */
@Slf4j
public class FileUploadValidator implements ConstraintValidator<FileUploadValid, MultipartFile> {
	private FileUploadValid annotation;

	@Override
	public void initialize(FileUploadValid constraintAnnotation) {
		this.annotation = constraintAnnotation;
	}

	@Override
	public boolean isValid(MultipartFile multipartFile, ConstraintValidatorContext context) {

		if (multipartFile.isEmpty()) {
			context.buildConstraintViolationWithTemplate("업로드 대상 파일이 없습니다. 정확히 선택 업로드해주세요.(There is no file to upload. Please upload correctly)").addConstraintViolation();
			return false;
		}

		final String fileName = multipartFile.getOriginalFilename();
		if (StringUtils.isBlank(fileName)) {
			context.buildConstraintViolationWithTemplate("업로드 요청한 파일명이 존재하지 않습니다.(not exist file name)").addConstraintViolation();
			return false;
		}

		try {
			int targetByte = multipartFile.getBytes().length;
			if (targetByte == 0) {
				context.buildConstraintViolationWithTemplate("파일의 용량이 0 byte입니다.(The size of the file is 0 bytes.)").addConstraintViolation();
				return false;
			}
		} catch (IOException e) {
			log.error(e.getMessage(), e);
			context.buildConstraintViolationWithTemplate("파일의 용량 확인 중 에러가 발생했습니다.(An error occurred while checking the file size.)").addConstraintViolation();
			return false;
		}

		//허용된 파일 확장자 검사
		final String detectedMediaType = this.getMimeTypeByTika(multipartFile); //확장자 변조한 파일인지 확인을 위한 mime type 얻기

		final UploadAllowFileDefine[] allowExtArray = annotation.allowFileDefines();
		final String fileExt = FilenameUtils.getExtension(fileName);
		for (UploadAllowFileDefine allowDefine : allowExtArray) {

			//파일명의 허용 확장자 검사
			if (StringUtils.equals(allowDefine.getFileExtensionLowerCase(), fileExt.toLowerCase()) == false) {
				StringBuilder sb = new StringBuilder();
				sb.append("허용되지 않는 확장자의 파일이며 다음 확장자들만 허용됩니다. This is a file with a disallowed extension, and only the following extensions are allowed.");
				sb.append(": ");
				sb.append(ArrayUtils.toString(allowExtArray));
				context.buildConstraintViolationWithTemplate(sb.toString()).addConstraintViolation();

				return false;
			}

			//파일 변조 업로드를 막기위한 mime타입 검사(예. exe파일을 csv로 확장자 변경하는 업로드를 막음)
			if (ArrayUtils.contains(allowDefine.getAllowMimeTypes(), detectedMediaType) == false) {
				StringBuilder sb = new StringBuilder();
				sb.append("확장자 변조 파일은 허용되지 않습니다.(Modified files with extensions are not allowed.)");
				context.buildConstraintViolationWithTemplate(sb.toString()).addConstraintViolation();

				return false;
			}
		}

		return true;
	}

	/**
	 * apache Tika라이브러리를 이용해서 파일의 mimeType을 가져옴
	 *
	 * @param multipartFile
	 * @return
	 */
	private String getMimeTypeByTika(MultipartFile multipartFile) {

		try {

			Tika tika = new Tika();
			String mimeType = tika.detect(multipartFile.getInputStream());
			log.debug("업로드 요청된 파일 {}의 mimeType:{}", multipartFile.getOriginalFilename(), mimeType);

			return mimeType;

		} catch (IOException e) {
			log.error(e.getMessage(), e);
			return null;
		}
	}

}

 
사용 예

@FileUploadValid(allowFileDefines = {UploadAllowFileDefine.CSV}, message = "유효한 CSV파일만 업로드 가능합니다.")
private MultipartFile targetCsvFile;

Spring에서 예외 핸들링 중 특정 예외에서 어떤 경우에는 json으로 응답하고 어떤 경우에는 유저에게 에러 페이지를 보여줘야하는 경우가 존재합니다.

이런 경우 간단히 처리하는 예제 소스입니다.(더 좋은 방법이 있을 수 있지만 고민이 필요)

 

 

/**
	 * 최상위 예외(Exception) 처리
	 * - 에러로그를 남겨서 담당자에게 이메일 발송(logback에 설정 됨)하여 알 수 있도록 하기 위함
	 * - 유저에게 에러 페이지를 보여주기 위해서 ModelAndViewDefiningException를 사용(DispatcherServlet에서 view 처리됨)
	 *
	 * @param ex
	 * @return
	 */
	@ExceptionHandler(Exception.class)
	public ResponseEntity<Object> RootException(Exception ex) throws ModelAndViewDefiningException {

		errorLog("RootException", ex);
		log.debug("exception class ===> {}", ex.getClass().getSimpleName());

		if (CommonUtil.isAjaxReq(HttpRequestUtil.getCurrentRequest())) { //ajax라면 json으로 응답
			RtnVO rtnVO = new RtnVO();
			rtnVO.setErrorCd(ErrorCd.SYSTEM_ERROR.name());
			rtnVO.setMsg("서버 에러가 발생했습니다. 담당자에게 문의하세요");
			return new ResponseEntity<>(rtnVO, ErrorCd.SYSTEM_ERROR.getStatus());
		}

		ModelAndView mav = new ModelAndView();
		mav.setViewName("forward:/error/serverError"); //ajax 요청이 아니라면 유저에게 에러메시지 페이지를 보여줌(/error/serverError는 에러 페이지 랜더링용 공용 URL) 
		throw new ModelAndViewDefiningException(mav);
	}

참고로 DispatcherServlet.java에서 ModelAndViewDefiningException 처리가 되어있다.

 

 

1. 쿼리 부분 예

<!-- 해당 유저ID가 존재하는지 체크(존재시 1 리턴으로 boolean 체크됨) -->
<select id="isExistUser" parameterType="String" resultType="boolean">
    /* user.isExistUser */
    SELECT
    EXISTS
    (SELECT 1 FROM user WHERE user_id = #{userId} LIMIT 1) #LIMIT 1은 없어도 무방하지만 방어차원에서
</select>



2. java부분 예(DAO영역)

public boolean isExistUser(String userId) {
    return sqlSession.selectOne(NAMESPACE + "isExistUser", userId);
}

+ Recent posts