저번 프로젝트를 진행하던 중 한 가지 문제사항에 직면했다.
우선 로직은 아래와 같다.
- 사용자가 데이터를 업로드한다.
- 프로그램은 데이터를 받아 큐에 임시저장한 후 사용자에게 업로드 완료 알림을 보낸다.
- 스케줄러가 동작하며 주기적으로 큐에서 데이터를 빼 스레드에 할당해 비즈니스 로직을 실행시킨다.
이 때 문제사항이 생겼다.
사용자가 데이터를 업로드할 때, 업로드된 데이터가 너무 큰 경우이다.
너무 큰 데이터를 업로드했을 때 시간이 오래 소요되는 것은 당연한 일이다. 하지만 지나치게 오랜 시간이 소요되면 어찌됐던 문제가 발생할 수 있다.
게다가 가장 중요한 문제는 위에서 사용자가 업로드한다는 데이터는 파일(엑셀 혹은 csv)인데, 엑셀의 경우 poi 라이브러리를 이용해 구현했다. 하지만 너무 큰 엑셀 파일이 업로드되면 종종 poi 라이브러리가 멈추는 현상이 발생했고, 이로 인해 스레드가 종료되지 않고 계속 실행되는 현상이 발생했다.
이 현상을 해결하기 위해 Thread에 시간 제한을 걸어 너무 많은 시간이 소요된 스레드는 강제 종료하는 방법을 검색해 구현했다. 물론 한 가지 흠이 있는 코드이기는 하지만 (추후 설명) 어찌됐든 방법을 저장해 추후 필요 시마다 사용해볼까 한다.
Future 라이브러리
결로적으로 말하자면 java.util.concurrent.Future 을 사용해서 해결했다.
멀티스레드 환경에서 사용되는 Future은 비동기 작업의 상태와 결과를 관리하고 작업의 완료를 기다릴 수 있는 메커니증를 제공한다. 여기서 주목할 점은 작업의 완료를 기다린다는 점인데, 이를 통해 Timeout을 걸 수 있다는 특징이 있다.
우선 아래의 코드가 있다고 가정해보자.
package com.mwlee.future;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
@Service
@Slf4j
public class FutureService {
private static final int WORK_THREADS=10;
private static ExecutorService executorService = Executors.newFixedThreadPool(WORK_THREADS);
/**
* 테스트용 스레드 실행
*/
@PostConstruct
public void doTest() {
runThread();
}
/**
* 스레드 실행 함수
*/
public void runThread() {
log.info("@@ {} method Start!", getMinAndSec());
executorService.submit(() -> {
log.info("@@ {} thread Start!", getMinAndSec());
businessLogic();
log.info("@@ {} thread Done!", getMinAndSec());
});
log.info("@@ {} method Done!", getMinAndSec());
}
/**
* 비즈니스 로직. 어떠한 작업으로 11초가 걸린다고 가정.
*/
private void businessLogic() {
log.info("@@ {} businessLogic Start!", getMinAndSec());
try {
// 11초 대기
Thread.sleep(11*1000);
} catch (InterruptedException e) {
log.error("@@ {} businessLogin Interrupted!", getMinAndSec());
}
log.info("@@ {} businessLogic Done!", getMinAndSec());
}
/**
* 로그 찍기용 분:초 반환
*/
public String getMinAndSec() {
Date date = new Date();
StringBuffer str = new StringBuffer();
str.append(String.format("%02d", date.getMinutes()));
str.append(":");
str.append(String.format("%02d", date.getSeconds()));
return str.toString();
}
}
그러면 아래와 같은 결과가 출력될 것이다.
코드대로 비즈니스 로직이 11초가 소요되어 39초에 시작, 50초에 종료됐다.
다만 이 때 비즈니스 로직은 10초 이내로 종료되어야 하며, 이를 넘어설 경우 에러로 간주해야 한다는 요구사항이 존재하면 어떻게 될까?
이 때 사용되는 것이 Future이다.
사용 방법은 아래와 같다. 아래의 로직을
이렇게 바꾸기만 하면 된다. Future로 thread를 만들고 get으로 실행시킨 후 타임아웃 시 cancel로 중지시킨다.
그러면 아래와 같이 11초가 걸리는 로직이 전부 실행되지 못하고 10초 뒤 끝나는 모습을 볼 수 있다.
그런데 위 부분에서 중요한 포인트가 한 가지 존재한다.
"method Done!" 이라는 문구가 Timeout 뒤에 나왔다. 즉, 논블로킹 형태가 아니라 블로킹 형태로 로직이 진행되고 있다.
이유라면 당연히 중간에 try-catch로 감싼 부분이 있기 때문이다.
이 이슈를 해결하기 위한 방법은 간단하지만 비효율적이다. 하지만 이 방법 외의 방법은 생각이 나지 않아 굳이 블로그에 업로드를 해야하나 싶었지만, 그래도 써놓으면 쓸 일이 있을 것 같아 작성했다.
그 간단하지만 비효율적인 방법은 스레드 안에 스레드를 넣어 두 개의 스레드로 실행을 시키는 것이다.
방법은 runThread 메소드를 아래와 같이 짜면 된다.
그러면 아래와 같이 논블로킹으로 수행이 된다.
이에 대한 전체 코드는 아래와 같다.
package com.mwlee.future;
import java.util.Date;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
@Service
@Slf4j
public class FutureService {
private static final int WORK_THREADS=10;
private static ExecutorService executorService = Executors.newFixedThreadPool(WORK_THREADS);
/**
* 테스트용 스레드 실행
*/
@PostConstruct
public void doTest() {
runThread();
}
/**
* 스레드 실행 함수
*/
public void runThread() {
log.info("@@ {} method Start!", getMinAndSec());
executorService.submit(() -> {
Future future = executorService.submit(() -> {
log.info("@@ {} thread Start!", getMinAndSec());
businessLogic();
log.info("@@ {} thread Done!", getMinAndSec());
});
// 10초 안에 실행되도록 변경.
try {
future.get(10, TimeUnit.SECONDS);
}
catch(InterruptedException | ExecutionException | TimeoutException e) {
log.error("@@ {} method Timeout!", getMinAndSec());
future.cancel(true); // true이면 강제종료
}
});
log.info("@@ {} method Done!", getMinAndSec());
}
/**
* 비즈니스 로직. 어떠한 작업으로 11초가 걸린다고 가정.
*/
private void businessLogic() {
log.info("@@ {} businessLogic Start!", getMinAndSec());
try {
// 11초 대기
Thread.sleep(11*1000);
} catch (InterruptedException e) {
log.error("@@ {} businessLogin Interrupted!", getMinAndSec());
}
log.info("@@ {} businessLogic Done!", getMinAndSec());
}
/**
* 로그 찍기용 분:초 반환
*/
public String getMinAndSec() {
Date date = new Date();
StringBuffer str = new StringBuffer();
str.append(String.format("%02d", date.getMinutes()));
str.append(":");
str.append(String.format("%02d", date.getSeconds()));
return str.toString();
}
}
'실습 > 리눅스 서버 + 스프링 부트' 카테고리의 다른 글
Mybatis 중복 쿼리 공통화 (<sql>과 <include>) + Interceptor (1) | 2024.09.07 |
---|---|
[Spring] 자바 이미지 핸들링 (0) | 2024.07.21 |
Spring Validation (0) | 2024.05.07 |
@DependsOn을 사용한 Bean 생성 순서 제어 (0) | 2024.04.10 |
[JAVA] OpenCSV를 이용한 CSV 파싱 (0) | 2024.04.01 |