장쫄깃 기술블로그

[Spring Web IDE] 스프링을 이용한 Web IDE 만들기 본문

Project/Spring Web IDE

[Spring Web IDE] 스프링을 이용한 Web IDE 만들기

장쫄깃 2022. 5. 29. 17:58
728x90


들어가며


스프링을 이용한 Web IDE를 만들어보았다. 웹 화면에서 코드를 치고 실행하면 실행시간, 결과 등을 확인할 수 있다. 필자는 해당 프로젝트를 Docker Container로 실행시켜 문제 발생 시 프로젝트를 종료시켜버리고 Docker에서 자동으로 재시작해주는 방법을 사용했다. 해당 글에서는 Docker 환경에 프로젝트를 배포하는 방법은 생략하고, 코드를 실행하고 실행시간과 결과를 반환해주는 방법에 대해서만 설명하려고 한다.

 

 

1. Java Reflection 이란?


해당 프로젝트에서는 Java Reflection을 사용하였다. 때문에 Reflection에 대해서 알아야 한다.

 

자바의 리플렉션(Reflection)은 클래스, 인터페이스, 메소드들을 찾을 수 있고, 객체를 생성하거나 변수를 변경할 수 있고 메소드를 호출할 수도 있다.

 

Reflection은 자바에서 기본적으로 제공하는 API이다. 사용 방법만 알면 라이브러리를 추가할 필요 없이 사용할 수 있다. 투영, 반사 라는 사전적인 의미를 지니고 있다.

 

간단하게 말해 어떠한 객체를 통해 클래스의 정보를 분석해 내는 프로그램 기법이다. 실행중인 어플리케이션 안에서 새로운 자바 파일을 컴파일 및 실행시킬 수 있다.

 

이제 프로젝트를 구현해보자.

 

2. MethodExcutation.java 구현


class 객체를 실행중인 어플리케이션 내에서 따로 실행할 수 있는 기능을 구현한다.

 

import java.lang.reflect.Method;
import java.util.concurrent.Callable;
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;

public class MethodExecutation {
	private final static long TIMEOUT_LONG = 15000; // 15초
	
	public static Map<String, Object> timeOutCall(Object obj, String methodName, Object[] params, Class<? extends Object> arguments[]) throws Exception {
		// return Map
		Map<String, Object> returnMap = new HashMap<String, Object>();
		
		// Source를 만들때 지정한 Method
		Method objMethod;
		// 매개변수 타입 속성 개수가 1개인 경우
		if(arguments.length == 1)
			objMethod = obj.getClass().getMethod(methodName, arguments[0]);
		// 매개변수 타입 속성 개수가 2개인 경우
		else if(arguments.length == 2)
			objMethod = obj.getClass().getMethod(methodName, arguments[0], arguments[1]);
		// 그 외
		else
			objMethod = obj.getClass().getMethod(methodName);
		
		ExecutorService executorService = Executors.newSingleThreadExecutor();
		Callable<Map<String, Object>> task = new Callable<Map<String, Object>>() {
				@Override
				public Map<String, Object> call() throws Exception {
					Map<String, Object> callMap = new HashMap<String, Object>();
					
					// 아래 주석 해제시 timeout 테스트 가능
					// Thread.sleep(4000);
					
					// Method 실행
					// 파라미터 개수가 1개인 경우 1개 등록
					if(params.length == 1)
						callMap.put("return", objMethod.invoke(obj, new Object[] {params}));
					// 파라미터 개수가 2개 이상인 경우 2개까지 등록
					else if(params.length == 2)
						callMap.put("return", objMethod.invoke(obj, params[0], params[1]));
					// 그 외
					else
						callMap.put("return", objMethod.invoke(obj));
					
					callMap.put("result", true);
					return callMap;
				}
			};
			
		Future<Map<String, Object>> future = executorService.submit(task);
		try {
			// 타임아웃 감시할 작업 실행
			returnMap = future.get(TIMEOUT_LONG, TimeUnit.MILLISECONDS); // timeout을 설정
		} catch (InterruptedException e) {
			e.printStackTrace();
		} catch (ExecutionException e) {
			e.printStackTrace();
		} catch (TimeoutException e) {
			// e.printStackTrace();
			returnMap.put("result", false);
		} finally {
			executorService.shutdown();
		}
		
		return returnMap;
	}
}

 

class 객체, 실행할 메소드 명, 파라미터, 파라미터 타입을 매개변수로 받는다. class 객체 안에서 메소드를 추출 후 파라미터와 함께 thread로 실행시킨다. 실행시간이 설정한 Timeout 시간을 초과하는 경우 실패 처리된다. 실행 결과, 반환 데이터를 map에 담아 리턴해준다.

 

 

3. CompileBuilder.java 구현


실제로 실행이 될 class 객체를 생성하기 위해 java 파일을 compile하는 기능을 구현한다.

 

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileWriter;
import java.io.PrintStream;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.HashMap;
import java.util.Map;

import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;

import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;
import com.some.compiler.execute.MethodExecutation;
import com.some.compiler.model.result.ApiResponseResult;
import com.some.compiler.util.common.UUIDUtil;

@Slf4j
@Component
public class CompileBuilder {
	// 프로젝트 home directory 경로
	// private final String path = CompilerApplication.class.getProtectionDomain().getCodeSource().getLocation().getPath();
	private final String path = "C:/Users/some/Desktop/test/compile/";
	// private final String path = "/compile/";
	
	@SuppressWarnings({ "resource", "deprecation" })
	public Object compileCode(String body) throws Exception {
		String uuid = UUIDUtil.createUUID();
		String uuidPath = path + uuid + "/";
		
		// Source를 이용한 java file 생성
		File newFolder = new File(uuidPath);
		File sourceFile = new File(uuidPath + "DynamicClass.java");
		File classFile = new File(uuidPath + "DynamicClass.class");
		
		Class<?> cls = null;
		
		// compile System err console 조회용 변수
		ByteArrayOutputStream err = new ByteArrayOutputStream();
		PrintStream origErr = System.err;
		
		try {
			newFolder.mkdir();
			new FileWriter(sourceFile).append(body).close();
			
			// 만들어진 Java 파일을 컴파일
			JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
			
			// System의 error outputStream을 ByteArrayOutputStream으로 받아오도록 설정
			System.setErr(new PrintStream(err));
			
			// compile 진행
			int compileResult = compiler.run(null, null, null, sourceFile.getPath());
			// compile 실패인 경우 에러 로그 반환
			if(compileResult == 1) {
				return err.toString();
			}
			
			// 컴파일된 Class를 Load
			URLClassLoader classLoader = URLClassLoader.newInstance(new URL[] {new File(uuidPath).toURI().toURL()});
			cls = Class.forName("DynamicClass", true, classLoader);
			
			// Load한 Class의 Instance를 생성
			return cls.newInstance();
		} catch (Exception e) {
			log.error("[CompileBuilder] 소스 컴파일 중 에러 발생 :: {}", e.getMessage());
			e.printStackTrace();
			return null;
		} finally {
			// Syetem error stream 원상태로 전환
			System.setErr(origErr);
			
			if(sourceFile.exists())
				sourceFile.delete();
			if(classFile.exists())
				classFile.delete();
			if(newFolder.exists())
				newFolder.delete();
		}
	}
	
	/*
	 * run method : parameter byte array, return byte array
	 * main 메소드 실행 시 해당 메소드 사용
	@SuppressWarnings("rawtypes")
	public byte[] runObject(Object obj, byte[] params) throws Exception {
		String methodName = "main";
		Class arguments[] = new Class[] {params.getClass()};
		
		// Source를 만들때 지정한 Method를 실행
		Method objMethod = obj.getClass().getMethod(methodName, arguments);
		Object result = objMethod.invoke(obj, params);
		return (byte[])result;
	}
	*/
	
	/**
	 * 
	 * @param obj
	 * @param params
	 * @return Map<String, Object>
	 * @throws Exception
	 */
	@SuppressWarnings({ "rawtypes", "unchecked" })
	public Map<String, Object> runObject(Object obj, Object[] params) throws Exception {
		Map<String, Object> returnMap = new HashMap<String, Object>();
		
		// 실행할 메소드 명
		String methodName = "runMethod";
		// 파라미터 타입 개수만큼 지정
		Class arguments[] = new Class[params.length];
		for(int i = 0; i < params.length; i++)
			arguments[i] = params[i].getClass();
		
		/*
		 * reflection method의 console output stream을 받아오기 위한 변수
		 * reflection method 실행 시 System의 out, error outputStream을 ByteArrayOutputStream으로 받아오도록 설정
		 * 실행 완료 후 다시 원래 System으로 전환
		 */
		ByteArrayOutputStream out = new ByteArrayOutputStream();
		ByteArrayOutputStream err = new ByteArrayOutputStream();
		PrintStream origOut = System.out;
		PrintStream origErr = System.err;
		try {
			// System의 out, error outputStream을 ByteArrayOutputStream으로 받아오도록 설정
			System.setOut(new PrintStream(out));
			System.setErr(new PrintStream(err));
			
			// 메소드 timeout을 체크하며 실행(15초 초과 시 강제종료)
			Map<String, Object> result = new HashMap<String, Object>();
			result = MethodExecutation.timeOutCall(obj, methodName, params, arguments);
			
			// stream 정보 저장
			if((Boolean) result.get("result")) {
				returnMap.put("result", ApiResponseResult.SUCEESS.getText());
				returnMap.put("return", result.get("return"));
				if(err.toString() != null && !err.toString().equals("")) {
					returnMap.put("SystemOut", err.toString());
				}else {
					returnMap.put("SystemOut", out.toString());
				}
			}else {
				returnMap.put("result", ApiResponseResult.FAIL.getText());
				if(err.toString() != null && !err.toString().equals("")) {
					returnMap.put("SystemOut", err.toString());
				}else {
					returnMap.put("SystemOut", "제한 시간 초과");
				}
			}
		}catch (Exception e) {
			e.printStackTrace();
		}finally {
			// Syetem out, error stream 원상태로 전환
			System.setOut(origOut);
			System.setErr(origErr);
		}
		
		return returnMap;
	}
}

 

compileCode 메소드에서는 입력받은 소스코드를 java 파일로 만든 후 class 파일로 컴파일한다.

 

System.setErr(new PrintStream(err)); 부분에서 컴파일 시 발생할 수 있는 System의 Error OutputStream을 ByteArrayOutputStream으로 받아오도록 설정한다. 컴파일 실패 시 로그를 반환한다.

 

컴파일에 성공한 경우 컴파일된 class를 load하고 instance를 생성하여 실행할 수 있는 환경을 만든다.

 

모든 컴파일 작업이 완료된 이후 System.setErr(origErr); 부분에서 System error stream을 원상태로 복구한다.


runObject 메소드에서 컴파일된 객체의 실행 및 실행 결과를 반환한다.

 

클래스 내부에 runMethod라는 이름의 메소드에 입력받은 매개변수를 담아 실행시킨다.

 

System.setOut(new PrintStream(out)); 부분과 System.setErr(new PrintStream(err)); 부분에서 메소드 실행 시 사용자가 입력한 System.out.println 이나 System.err.println 출력을 결과로 반환해주기 위해 System의 Out, Error OutputStream을 ByteArrayOutputStream으로 받아오도록 설정한다. 필자는 Err OutputStream이 존재하는 경우 다른 Out OutputStream이 존재하더라도 Err OutputStream을 최우선으로 출력하도록 하였다.

 

방금 전에 구현했던 MethodExecutation.timeOutCall 메소드를 실행하여 Timeout을 체크하며 메소드를 실행하도록 하였다.

 

실행 성공 시 결과를 Map에 저장하여 결과 메시지와 함께 반환한다.

 

 

4.  ApiResponseResult Enum 구현


api 응답에 공통으로 사용할 enum 객체를 구현한다.

 

public enum ApiResponseResult {
	SUCEESS("성공"),
	FAIL("실패");
	
	public final String message;
	
	ApiResponseResult(String message) {
		this.message = message;
	}
	
	public String getId() {
		return this.name();
	}
	
	public String getText() {
		return this.message;
	}
}

 

 

5. UUIDUtil.java 구현


랜덤한 compile 객체명에 사용할 UUIDUtil을 구현한다.

 

public class UUIDUtil {
	public static String createUUID() {
		String uuid = UUID.randomUUID().toString().replace("-","");
		return uuid;
	}
}

 

 

6. Controller 구현


@RestController
public class CompileController {
	@Autowired CompileBuilder builder;
	
	@PostMapping(value="compile")
	public Map<String, Object> compileCode(@RequestBody Map<String, Object> input) throws Exception {
		Map<String, Object> returnMap = new HashMap<String, Object>();
		
		// compile input code
		Object obj = builder.compileCode(input.get("code").toString());
		
		// compile 결과 타입이 String일 경우 컴파일 실패 후 메시지 반환으로 판단하여 처리
		if(obj instanceof String) {
			returnMap.put("result", ApiResponseResult.FAIL.getText());
			returnMap.put("SystemOut", obj.toString());
			return returnMap;
		}
		
		// 실행 후 결과 전달 받음
		long beforeTime = System.currentTimeMillis();
		
		// 파라미터
		String participant[] = new String[] {"marina", "josipa", "nikola", "vinko", "filipa"};
		String completion[] = new String[] {"josipa", "filipa", "marina", "nikola"};
		Object[] params = {participant, completion};
		
		// 코드 실행
		Map<String, Object> output = builder.runObject(obj, params);
		long afterTime = System.currentTimeMillis();
		
		// 코드 실행 결과 저장
		returnMap.putAll(output);
		// 소요시간
		returnMap.put("performance", (afterTime - beforeTime));
		
		// s :: 결과 체크 :: //
		// TODO 상황에 따른 결과 동적 체크 처리 필요
		try {
			if(returnMap.get("return") != null && !returnMap.get("return").equals("vinko")) {
				returnMap.put("result", ApiResponseResult.FAIL.getText());
				returnMap.put("SystemOut", returnMap.get("SystemOut").toString() + "\r\n결과 기대값과 일치하지 않습니다.");
			}
		}catch (Exception e) {
			returnMap.put("result", ApiResponseResult.FAIL.getText());
			returnMap.put("SystemOut", returnMap.get("SystemOut").toString() + "예상치 못한 오류로 검사에 실패했습니다.");
		}
		// e :: 결과 체크 :: //
		
		return returnMap;
	}
}

 

비즈니스 로직 실행 요청을 받을 api controller를 구현한다. 마지막에 실행 결과가 기대값과 일치하는지 체크하는 로직을 추가했다. 필요 시 이 부분은 동적으로 처리하거나 따로 서비스를 분리하여 사용하면 된다.

 

 

7. IDE 화면 구현


마지막으로 web ide 화면을 구현한다.

 

<!DOCTYPE html>
<html>
<head>
<link rel="shortcut icon" href="#">
<meta charset="UTF-8">
<title>Insert title here</title>
<style>
	#editor {
		height: 800px !important;
 		font-size: 15px;
	}
	
	#desc {
		height: 800px;
		font-size: 15px;
	}
</style>
</head>
<body>
 	<div style="display:flex;">
 		<div style="flex:0 0 30%;">
 			<div id="desc">
 				<b>문제 설명</b><br>
				수많은 마라톤 선수들이 마라톤에 참여하였습니다. 단 한 명의 선수를 제외하고는 모든 선수가 마라톤을 완주하였습니다.<br>
				<br>
				마라톤에 참여한 선수들의 이름이 담긴 배열 args와 완주한 선수들의 이름이 담긴 배열 completion이 주어질 때, 완주하지 못한 선수의 이름을 return 하도록 solution 함수를 작성해주세요.<br>
				<br>
				<b>제한사항</b><br>
				마라톤 경기에 참여한 선수의 수는 1명 이상 100,000명 이하입니다.<br>
				completion의 길이는 participant의 길이보다 1 작습니다.<br>
				참가자의 이름은 1개 이상 20개 이하의 알파벳 소문자로 이루어져 있습니다.<br>
				참가자 중에는 동명이인이 있을 수 있습니다.<br>
				<br>
				<b>입출력 예</b><br>
				<br>
				<b>예제 #1</b><br>
				args : ["leo", "kiki", "eden"]<br>
				completion : ["eden", "kiki"]<br>
				return : "leo"<br>
				"leo"는 참여자 명단에는 있지만, 완주자 명단에는 없기 때문에 완주하지 못했습니다.<br>
				<br>
				<b>예제 #2</b><br>
				args : ["mislav", "stanko", "mislav", "ana"]<br>
				completion : ["stanko", "ana", "mislav"]<br>
				return : "mislav"<br>
				"mislav"는 참여자 명단에는 두 명이 있지만, 완주자 명단에는 한 명밖에 없기 때문에 한명은 완주하지 못했습니다.<br>
				<br>
				출저 : 프로그래머스
			</div>
 			<div style="margin-top:20px;">
				<button onclick="send_compiler();" style="width: 200px; height: 100px; vertical-align:top;">Run</button>
				<button onclick="show_answer();" style="width: 200px; height: 100px; vertical-align:top;">답 보기</button>
				<div style="margin:5px 0 0 20px;">
					<div>결과: <span id="result"></span></div>
					<div>경과시간: <span id="performance"></span> m/s</div>
				</div>
			</div>
 		</div>
 		<div style="flex:0 0 70%;">
 			<div id="editor"></div>
			<div style="display:flex; margin-top:20px;">
				<div>출력:</div>
				<div id="output" style="flex:1 1 auto; padding-left:10px;">실행 결과가 여기에 표시됩니다.</div>
			</div>
 		</div>
	</div>
	
	
	<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.3/ace.js"></script>
	<script src="//code.jquery.com/jquery-3.3.1.min.js"></script>
	<script>
		var editor = ace.edit("editor");
		$(function() {
			editor.setTheme("ace/theme/pastel_on_dark");
			editor.getSession().setMode("ace/mode/java");
			editor.setOptions({ maxLines: 1000 });
			
			$.ajax({
				url: "/static/source/source_return_byte_array",
				success: function(data) {
					editor.setValue(data, data.length);
				},
				error: function(err) {
					console.log(err);
				}
			})
		})
		
		function send_compiler() {
			$.ajax({
				type: "post",
				url: "compile",
				data: JSON.stringify({"code" : editor.getValue()}),
				dataType : "json", 
				contentType: 'application/json',
				success: function(data) {
					if(data.result == "성공") {
						$("#output").css("color", "#000");
						$("#result").css("color", "#000");
					}else {
						$("#output").css("color", "#f00");
						$("#result").css("color", "#f00");
					}
					
					$("#output").html(data.SystemOut != null ? data.SystemOut.replace(/\n/g, "<br>") : "");
					$("#performance").text(data.performance);
					$("#result").text(data.result);
				},
				error: function(err) {
					console.log(err);
					if(err.responseJSON != null) {
						alert("처리 중 문제가 발생했습니다.\n관리자에게 문의해주세요.\nerr status : " + err.responseJSON.status);
					}else {
						alert("다시 시도해주세요.");
					}
				}
			})
		}
		
		function show_answer() {
			$.ajax({
				url: "/static/source/source_return_byte_array_answer",
				success: function(data) {
					editor.setValue(data, data.length);
				},
				error: function(err) {
					console.log(err);
				}
			})
		}
	</script>
</body>
</html>

 

해당 화면에서 코드작성 후 출력 결과, 실행 결과, 실행 시간 등을 확인할 수 있다.

 

01_컴파일 실패 시 화면

 

02_테스트 결과 미일치 시 화면

 

03_제한시간 초과 시 화면

 

04_성공 시 화면

 

 

정리하며


간단한 web ide를 구현해보았다. 이번 프로젝트에서는 Java 코드만 실행 가능한 ide를 만들었다. 또한 1개의 테스트만을 타겟으로 하는 프로젝트이다. 상황에 따라 여러가지 테스트와 언어에 대응하는 방식을 구현하면 좋다고 생각한다.

 

필자는 해당 어플리케이션을 내부에서만 사용하기 때문에 Docker Container에 API 서버만 올려서 사용하고 있다. 컴파일 시 문제가 발생하면 그냥 서버를 종료시켜버린다. Docker Container에서 서버를 재시작해주기 때문이다. 더 큰 규모의 프로젝트를 바라보고 있다면, 테스트 케이스 당 1개의 Container를 부여하고 실행하는 방법으로 실행하면 좋을 것 같다.

 

관련 소스 코드는 깃허브를 참고하면 된다.

링크 : https://github.com/JangDaeHyeok/spring_web_ide

 

GitHub - JangDaeHyeok/spring_web_ide: 웹 ide

웹 ide. Contribute to JangDaeHyeok/spring_web_ide development by creating an account on GitHub.

github.com

 

728x90