졸업프로젝트로 실시간 코딩 웹 플랫폼을 구현하면서 서버에서의 코드 실행 기능이 필요해졌다.
프로그래머스처럼 코드 실행해주는 기능은 화면 뒤에서 어떻게 돌아갈까?
(참고) 과거 경험했던 프로젝트 기능 구현을 정리하는 글이어서,
한 번 더 확인 및 정리했지만 혹시 deprecated / 잘못된 부분이 있다면 지적은 환영입니다 😆
졸업프로젝트로 코드 실행 가능한 코드 편집기 포함한 웹 플랫폼을 구현하게 되었다.
빠른 이해를 위해, 완성 화면을 보자면 아래와 같다.
요약하면 코드 실행 + 실시간 공유 코드 편집 + 화상통화 + 백준 기반 문제 추천 + ChatGPT 연동
에디터다.
코드 저장, 테스트 케이스 공유, 채팅 등 여러 기능 등이 포함되어 있었지만,
주된 이슈는 무엇보다도 "코드 실행" 기능이었다.
Q. 코드 실행의 형태?
구현 전에 논의해보고 다른 플랫폼을 분석해봤을 때 크게 가능한 형태는 두 가지였다.
플랫폼의 목적 자체가 다른 사용자들과 같이 편집하며 간단한 정답 확인 수준의 코드 실행으로 두기도 했고,
아래의 경우 예외 케이스가 많아져 비교적 짧은 구현 기간이 부족할 수 있으므로 전자의 구현을 선택했다.
Q. 무한 루프 코드의 처리?
Q. 코드 실행 가능한 언어?
서버 내에서 다른 언어 코드 (Python 기반)을 실행하는 방법은 아래와 같았다.
How to Call Python From Java 글에 많은 도움을 받았다.
Spring 의존성에 jython을 추가하고 Jython scripting engine을 사용해 Python 코드를 실행시킬 수 있다.
내부 클래스를 사용해서 Python 코드를 편집 / 실행 등 더 자유로운 작업이 가능했지만,
파이썬의 자바 구현이기 때문에 Jython에서 지원해주지 않는 이상 지원되지 않는 패키지가 존재할 수 있다는 점, 공식적으로 지원하고 있는 버전이 Python 2까지라는 점이 걸렸다.
The Jython project provides implementations of Python in Java, providing to Python the benefits of running on the JVM and access to classes written in Java. The current release (a Jython 2.7.x) only supports Python 2 (sorry). There is work towards a Python 3 in the project’s GitHub repository.
커맨드 라인으로 argument를 넘겨주는 것과 같은 방식으로 파이썬 실행이 가능했다.
// hello.py 소스코드를 실행하는 python 프로세스 생성
ProcessBuilder processBuilder = new ProcessBuilder("python", "hello.py");
// 프로세스 실행
Process process = processBuilder.start();
// 프로세스 종료까지 대기, 종료 코드 반환
int exitCode = process.waitFor();
유사한 방식으로 Commons Exec 라이브러리를 의존성 추가해 사용할 수 있는 듯 하다.
Python 기능을 어느 선택지보다 자유롭게 사용할 수 있을 방향이긴 하나,
네트워크 통신을 한 번 더 거쳐야한다는 점과, 별도 파이썬 서버를 구축해야한다.
가볍고 짧게 코드 실행 결과만 뽑아오기위한 목적과 맞지 않는 듯 했다.
최종적으로 ProcessBuilder를 사용해서 구현하기로 결정했다.
하지만 프로세스를 생성해 Python을 실행한다해도, 프로세스의 타임아웃 처리나 인풋 / 아웃풋 등의 추가적인 처리가 필요했다. 또한 외부에서 들어오는 코드를 실행하는 프로세스를 스프링 서버 내에서 아무 조치 없이 실행하게 되면 메인 서버 로직에 문제 영향을 줄 수 있으므로 더욱이나 분리가 필요했다.
따라서 스레드 처리를 통해 해당 프로세스를 관리하고 필요한 전 / 후 처리를 포함하도록 했다.
스레드 처리를 위해 사용한 개념은 크게 두 가지다.
이 포스팅에서 코드 실행은
비동기적으로 스레드 풀을 사용해 테스크를 수행하도록 한다
비동기 처리를 위한 환경설정을 해준다.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "taskExecutor")
public TaskExecutor taskExecutor() {
// 테스크 수행시 스레드풀 사용
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
// 유지할 풀 사이즈
taskExecutor.setCorePoolSize(5);
// 최대 풀 사이즈
taskExecutor.setMaxPoolSize(10);
// 스레드 종료까지 시간
taskExecutor.setAwaitTerminationSeconds(30);
// 스레드 네임 프리픽스 설정
taskExecutor.setThreadNamePrefix("compile");
taskExecutor.initialize();
return taskExecutor;
}
}
이후 해당 TaskExecutor를 사용하는 서비스 코드를 작성한다.
@Service
@Slf4j
public class CompileService {
@Async("taskExecutor") // 사용할 custom executor 지정
public Future<String> run(String code) throws IOException, InterruptedException {
// 스레드 수행 내용 작성
}
}
이제 비동기 스레드까지 설정했으니 실제 스레드가 수행할 로직을 작성하자.
코드의 플로우는 아래와 같다.
1. 스레드 실행
2. 전처리 (타임아웃 설정, 인풋 코드 변환)
4. 프로세스 실행
5. 프로세스 내 인풋 스트림 쓰기
6. 프로세스 종료 대기
7. 프로세스 종료 및 아웃풋 스트림 읽기
8. 후처리 (실행 결과 가공 및 반환)
9. 입력 코드 파일 삭제
스레드 실행은 이전 비동기 코드 작성으로 서비스 함수로 들어오게 되어있으므로,
프로세스 생성하고 전 / 후 처리 로직을 작성해보자.
일단 타임아웃 / 인풋 처리 / 에러 처리를 제외하고 메인 로직인
프로세스 실행 - 아웃풋 처리 - 결과 반환 - 파일 삭제
를 구현한다.
(파일 작성 / 삭제를 구현하는 함수는 아래 코드에서 생략한다.)
@Async("taskExecutor")
public Future<String> run(String code) throws IOException, InterruptedException {
String random = UUID.randomUUID().toString();
try {
// 중복되지 않도록 UUID 값을 생성해 인풋 코드를 파일로 저장
writeFile(random, code);
// 해당 소스코드 파일을 python3으로 실행하는 프로세스를 생성
ProcessBuilder processBuilder = new ProcessBuilder("python3",
Paths.get(String.format("%s.py", random)).toString());
Process process = processBuilder.start();
process.waitFor();
// 프로세스 실행 아웃풋 반환
BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream();, StandardCharsets.UTF_8));
return CompletableFuture.completedFuture(getOutput(br));
}
finally {
// 작성한 파일 삭제
deleteFile(random);
}
}
public String getOutput(BufferedReader bufferedReader) throws IOException {
// 결과 읽어서 String으로 합친 후 반환
StringBuilder sb = new StringBuilder();
String line;
boolean first = true;
while ((line = bufferedReader.readLine()) != null) {
if (first) first = false;
else sb.append("\n");
sb.append(line);
}
bufferedReader.close();
log.info(sb.toString());
return sb.toString();
}
파이썬 코드를 받아 실행시킬 파일로 변환하고, 프로세스를 통해 수행한 뒤 결과를 문자열로 반환한다.
간단한 테스트 코드를 생성해 테스크가 제대로 수행되는지 확인해보자.
@SpringBootTest
class CompileServiceTest {
@Autowired
private CompileService compileService;
@Test
void task_executor_실행() {
for (int i = 0; i<10; i++){
try {
// Python 코드를 입력으로 넘김
compileService.run("print('Task run...')").get();
}
catch (Exception e) {
fail();
}
}
}
}
명시된 prefix의 Thread 실행 로그가 남는 걸 확인할 수 있다.
이때 Task 수행 결과를 CompletableFuture로 감싸고
테스트 내 .get()
을 통해 비동기 수행 완료를 기다리지 않으면
테스크가 완료되기 전에 테스트 수행이 종료되어 원하는 결과를 확인하지 못할 수 있음에 주의하자!
코드의 수행 결과 (출력)도 중요하지만, 입력에 따른 출력을 검증하는 코드 수행도 많다.
실행하는 코드에 입력을 제공할 수 있도록 프로세스 수행 후 입력을 작성한다.
@Async("taskExecutor")
public Future<String> run(String code, String input) throws IOException, InterruptedException {
// ...
Process process = processBuilder.start();
// 프로세스 내 input 작성
OutputStream stdin = process.getOutputStream();
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(stdin));
bw.write(input);
bw.close();
// ...
}
무한 루프와 같은 코드 수행이 우리 서버의 자원을 계속해서 점유하는 것을 방지하기 위해서 스레드에 타임아웃을 걸어주자. 방법은 간단하다.
스레드 실행시 타이머를 설정 후 타이머가 완료되기 전에 프로세스가 완료된다면 타이머를 캔슬한다.
만약 해당 타이머 내 프로세스가 완료되지 못했다면 해당 스레드에 인터럽트를 걸어 스레드를 종료한다.
private static final int THREAD_TIMEOUT_SECONDS = 10;
public Timer setTimeoutTimer (Thread thread) {
Timer timer = new Timer();
TimerTask timerTask = new TimerTask() {
@Override
public void run() {
// 해당 스레드에 인터럽트 발생
thread.interrupt();
log.info(thread.getName()+" thread timeout!");
}
};
timer.schedule(timerTask, THREAD_TIMEOUT_SECONDS*1000);
return timer;
}
@Async("taskExecutor")
public Future<String> run(String code, String input) throws IOException, InterruptedException {
log.info(Thread.currentThread().getName()+" thread run()...");
String random = UUID.randomUUID().toString();
try {
// 스레드 타임아웃 설정
Timer timer = setTimeoutTimer(Thread.currentThread());
writeFile(random, code);
// ...
int exitCode = process.waitFor();
// 타이머 수행되기 전에 프로세스 완료시 타이머 캔슬
timer.cancel();
//...
}
에러 처리는 간단하다.
단순 실행 후 완료를 기다리던 코드에서, 완료 코드를 받아 읽을 Stream을 분기 처리하면 이외는 동일하다.
@Async("taskExecutor")
public Future<String> run(String code, String input) throws IOException, InterruptedException {
// ...
int exitCode = process.waitFor();
InputStream stdout = exitCode != 0 ? process.getErrorStream() : process.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(stdout, StandardCharsets.UTF_8));
// ...
}
추가된 처리들이 정상 동작하는지 확인하기 위해서 간단한 테스트들을 추가해보자.
@SpringBootTest
class CompileServiceTest {
@Autowired
private CompileService compileService;
private final int THREAD_TIMEOUT_SECONDS = 10; // task timeout 설정 시간 (초)
@Test
void task_비동기_처리() {
// given
String text = "hello world";
String pythonCode = "print(\""+text+"\", end=\"\")";
// when
String result = "";
Future<String> future = null;
try {
future = compileService.run(pythonCode, "");
result = future.get();
}
catch (Exception e) {
fail();
}
// then
assertThat(result).isEqualTo(text);
assertThat(future).isNotNull();
assertThat(future).isDone();
assertThat(future).succeedsWithin(THREAD_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
@Test
void 특정_시간을_넘어가는_task_timeout_처리() {
// given
String timeoutCode = "while True:\n" + " a = 1;";
// when, then
assertThatThrownBy(()-> compileService.run(timeoutCode, "").get())
.isInstanceOf(ExecutionException.class);
}
@Test
void 에러_발생하는_task_처리() {
// given
String errorCode = "print(";
// when
String result = "";
Future<String> future = null;
try {
future = compileService.run(errorCode, "");
result = future.get();
}
catch (Exception e) {
fail();
}
// then
assertThat(result).contains("Error"); // Error 내용 반환
assertThat(future).isNotNull();
assertThat(future).isDone(); // Task 자체는 시간 내 완료
assertThat(future).succeedsWithin(THREAD_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
아래와 같은 결과를 확인할 수 있다. (더 나은 결과 확인을 위해 수행 시간 등 로그를 추가되어 있다.)
전체 코드 플로우는 요 링크의 소스코드 참고.
(수행 결과를 소켓으로 전송하는 등의 처리를 위해 변형되어 있다.)
이제 원하는 파이썬 코드와 입력을 넣으면,
실행해 출력을 제공하는 파이썬 코드 실행기 서버를 가지게 되었다 🥳
https://www.baeldung.com/java-working-with-python
https://www.baeldung.com/java-lang-processbuilder-api
https://d2.naver.com/helloworld/1113548
https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/task/TaskExecutor.html
https://docs.spring.io/spring-framework/reference/integration/scheduling.html