Spring 서버로 Python 코드 실행기 만들기

민수빈·2024년 5월 1일
0
post-thumbnail

졸업프로젝트로 실시간 코딩 웹 플랫폼을 구현하면서 서버에서의 코드 실행 기능이 필요해졌다.
프로그래머스처럼 코드 실행해주는 기능은 화면 뒤에서 어떻게 돌아갈까?

(참고) 과거 경험했던 프로젝트 기능 구현을 정리하는 글이어서,
한 번 더 확인 및 정리했지만 혹시 deprecated / 잘못된 부분이 있다면 지적은 환영입니다 😆

과정

배경

졸업프로젝트로 코드 실행 가능한 코드 편집기 포함한 웹 플랫폼을 구현하게 되었다.
빠른 이해를 위해, 완성 화면을 보자면 아래와 같다.

요약하면 코드 실행 + 실시간 공유 코드 편집 + 화상통화 + 백준 기반 문제 추천 + ChatGPT 연동 에디터다.
코드 저장, 테스트 케이스 공유, 채팅 등 여러 기능 등이 포함되어 있었지만,
주된 이슈는 무엇보다도 "코드 실행" 기능이었다.

논의사항

Q. 코드 실행의 형태?

구현 전에 논의해보고 다른 플랫폼을 분석해봤을 때 크게 가능한 형태는 두 가지였다.

  • 인풋과 아웃풋을 받아서 코드의 실행 결과와 매칭 e.g. 프로그래머스
    • 비교적 실행이 제한적 - 인풋에 해당하는 아웃풋 등을 모두 사용자가 입력해야함
    • 실행 - 매칭만 처리하면 되므로 I/O 대기 등의 처리 필요하지 않음
  • 콘솔 창을 통한 실시간 인풋 / 아웃풋 처리 e.g. IDE
    • 비교적 실행이 자유로움 - 입력을 중간까지만 하고 결과를 본다던가 하는 사용성 가능
    • 사용자의 입력을 기다리거나 중간 아웃풋을 공유하는 등의 추가 I/O 처리 필요

플랫폼의 목적 자체가 다른 사용자들과 같이 편집하며 간단한 정답 확인 수준의 코드 실행으로 두기도 했고,
아래의 경우 예외 케이스가 많아져 비교적 짧은 구현 기간이 부족할 수 있으므로 전자의 구현을 선택했다.

Q. 무한 루프 코드의 처리?

  • 제한 없이 실행시키게 된다면 배포 서버에 영향을 줄 수 있으므로
  • 문제의 제한 시간 / 또는 디폴트 제한 시간 내 수행 결과가 나오지 않을 경우 실패로 간주

Q. 코드 실행 가능한 언어?

  • 서버가 Java로 실행되기 때문에 Java 코드 처리가 용이할 수 있지만,
    코딩 플랫폼에서 라이트 사용자를 지원하기에는 Python이 적합하다고 판단
  • 어차피 서버의 안전성을 위해 서버 실행 프로세스와 별도의 프로세스(스레드)에서 코드를 실행할 거라면,
    서버 구현 언어와 동일 언어일 필요가 없음
  • 따라서 기본적으로는 Python을 지원하고 추가 구현 방향에서 다른 언어를 고려하기로 결정

프로세스 처리

서버 내에서 다른 언어 코드 (Python 기반)을 실행하는 방법은 아래와 같았다.
How to Call Python From Java 글에 많은 도움을 받았다.

Jython

  • JVM 위에 실행되는 Python의 Java 플랫폼 구현
  • 내부 클래스 통해 Java 코드 내 직접적으로 Python 코드 작성 가능
  • Python의 모든 하위 패키지 지원하지 않을 수 있음

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.

Process Builder

  • java.lang.ProcessBuilder API를 통해 별도 OS 프로세스 실행
  • 실행하는 서버의 OS를 기반으로 프로세스를 실행하므로 서버 내 python 설치 필요

커맨드 라인으로 argument를 넘겨주는 것과 같은 방식으로 파이썬 실행이 가능했다.

	// hello.py 소스코드를 실행하는 python 프로세스 생성
    ProcessBuilder processBuilder = new ProcessBuilder("python", "hello.py");
    // 프로세스 실행
    Process process = processBuilder.start();
    // 프로세스 종료까지 대기, 종료 코드 반환
    int exitCode = process.waitFor();

유사한 방식으로 Commons Exec 라이브러리를 의존성 추가해 사용할 수 있는 듯 하다.

HTTP를 통한 Python 사용

  • Python 내 HTTP 서버 지원 활용 (또는 Flask, Django 활용)
  • 별도 Python 서버 구축 및 네트워크 통신 필요

Python 기능을 어느 선택지보다 자유롭게 사용할 수 있을 방향이긴 하나,
네트워크 통신을 한 번 더 거쳐야한다는 점과, 별도 파이썬 서버를 구축해야한다.
가볍고 짧게 코드 실행 결과만 뽑아오기위한 목적과 맞지 않는 듯 했다.

최종적으로 ProcessBuilder를 사용해서 구현하기로 결정했다.

하지만 프로세스를 생성해 Python을 실행한다해도, 프로세스의 타임아웃 처리나 인풋 / 아웃풋 등의 추가적인 처리가 필요했다. 또한 외부에서 들어오는 코드를 실행하는 프로세스를 스프링 서버 내에서 아무 조치 없이 실행하게 되면 메인 서버 로직에 문제 영향을 줄 수 있으므로 더욱이나 분리가 필요했다.

따라서 스레드 처리를 통해 해당 프로세스를 관리하고 필요한 전 / 후 처리를 포함하도록 했다.

스레드 처리

스레드 처리를 위해 사용한 개념은 크게 두 가지다.

TaskExecutor

  • Runnable 의 실행을 추상화하는 인터페이스
  • java.util.concurrent.Executor 인터페이스와 동일
    • 스레드 풀 사용시의 추상화를 위해 존재
  • 여러 전략으로 테스크 실행할 수 있음
    • SyncTaskExecutor : 동기적 스레드 실행. 멀티스레드 처리 X.
    • SimpleAsyncTaskExecutor : 매 처리마다 새로운 스레드 생성. 동시 실행 한계 설정 가능.
    • ConcurrentTaskExecutor : 직접적으로 거의 사용하지 않음.
      ThreadPoolTaskExecutor로 충분하지 않을 경우 대안으로 사용.
    • ThreadPoolTaskExecutor : 주로 사용됨. 스레드 풀 사용한 테스크 실행.
    • DefaultManagedTaskExecutor : JNDI 기반.

이 포스팅에서 코드 실행은

  • 테스트케이스별 소요 시간이 길 수 있고 (무한루프 / 타임아웃)
  • 테스트케이스 제한이 없으며
  • 이외 서버 수행 작업은 테스트케이스 실행 결과에 의존하지 않으므로 (실행 결과 웹소켓으로 별도 전달)

비동기적으로 스레드 풀을 사용해 테스크를 수행하도록 한다

Async

비동기 처리를 위한 환경설정을 해준다.

@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

profile
개발 기록. 이전 블로그 (알고리즘 위주) : https://blog.naver.com/tnqls5417

0개의 댓글