이전에 문자열로 된 클래스를 사용자로부터 받아서 내부의 메서드를 실행시키는 로직을 구현한 적이 있다. 그때는 파라미터도 없고, 단순 메서드 내부의 로직만 실행하도록 하는 것이 전부였는데, 이번 프로젝트에서는 코딩 테스트 풀이를 위한 메서드 실행이 목적이기 때문에 파라미터를 받는 것이 필수이다. 그런데 여기서 발생하는 문제는 파라미터 개수가 불분명하고, 문자열로 된 타입을 어떻게 실제 타입으로 변경할 것인가였다. 며칠간 고민하며 개발해 본 결과 다음 방법으로 원하던 목적을 이룰 수 있었다.
흐름 알아보기

먼저, 클라이언트에서 위와 같이 문제 번호와 작성한 코드 내용을 서버로 요청하면 컨트롤러에서 이를 처리하게 된다. 해당 컨트롤러에서는 다음 서비스를 호출하여 유저의 풀이 코드를 실행한다.
@Slf4j
@RequiredArgsConstructor
@Service
public class CodeRunServiceJavaImpl implements CodeRunService {
private static final String CLASS_SOLUTION = "class Solution";
private static final String SOLUTION = "Solution";
private static final String METHOD = "main";
private final InternalMethod internalMethod;
private final ProblemRepository problemRepository;
@Override
public String run(RequestUserAnswer requestUserAnswer) {
String sourceCode = requestUserAnswer.getSourceCode();
if (!sourceCode.contains(CLASS_SOLUTION)) throw new UserClassFormatException();
Problem foundProblem = problemRepository.findById(requestUserAnswer.getProblemId())
.orElseThrow(() -> new EmptyResultDataAccessException(1));
// 컴파일러 인스턴스 얻기
JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
try (StandardJavaFileManager fileManager = javac.getStandardFileManager(null, null, null)) {
// 소스 파일 생성
File tempJavaFile = File.createTempFile(SOLUTION, ".java"); // 결과 파일 예: Solution237529.java
String newClassName;
newClassName = tempJavaFile.getName().split("\\.")[0];
// 클래스명 임시 파일명과 동일하게 변경
try (Writer writer = new BufferedWriter(new FileWriter(tempJavaFile))) {
sourceCode = sourceCode.replaceFirst(CLASS_SOLUTION, "class " + newClassName);
writer.write(sourceCode);
}
// javac 컴파일
Iterable<? extends JavaFileObject> fileObjects = fileManager.getJavaFileObjects(tempJavaFile);
javac.getTask(null, fileManager, null, null, null, fileObjects).call();
// 클래스 로드
URLClassLoader classLoader = URLClassLoader.newInstance(new URL[]{tempJavaFile.getParentFile().toURI().toURL()});
Class<?> loadedClass = null;
try {
loadedClass = Class.forName(newClassName, true, classLoader);
} catch (ClassNotFoundException e) {
throw new UserClassLoadException(e);
} finally {
deleteFiles(tempJavaFile, null); // .java 삭제
}
// 인스턴스 생성 및 메서드 호출
try {
Object instance = loadedClass.getDeclaredConstructor().newInstance();
// 해당 문제의 파라미터 타입 가져옴
String[] splitParams = foundProblem.getParams().split(",");
Class<?>[] paramClasses = JavaTypeClazz.convertAll(splitParams);
Method method = loadedClass.getMethod(METHOD, paramClasses);
// 사용자 메서드 실행
return internalMethod.runUserMethod(instance, method, requestUserAnswer); // 메인 로직 메서드 실행
} catch (NoSuchMethodException | SecurityException e) {
throw new UserMethodLoadException(e); // 메서드명 다를 때
} catch (Exception e) {
throw new UserCodeRuntimeException(e); // 유저 코드 런타임 예외
} finally {
deleteFiles(null, newClassName); // .class 삭제
}
} catch (IOException e) {
throw new RuntimeException("FileManager 오류");
}
}
private void deleteFiles(File javaFile, String className) {
// .java 파일 삭제
if (javaFile != null) javaFile.delete();
// .class 파일 삭제
if (StringUtils.hasText(className)) {
File classFile = new File(System.getProperty("java.io.tmpdir") + className + ".class");
classFile.delete();
}
}
}
일단 여기서 처리한 내용은 DB에 저장되어 있는 파라미터 타입을 가져오는 것이다.
DB에는 다음과 같이 파라미터 정보가 저장되어 있다.

사실 이렇게 저장하면 제 1 정규화 위반이다. 하나의 속성에는 하나의 값만 들어가야 하기 때문이다. 하지만 나는 다음과 같은 이유 때문에 위와 같이 저장하는 것을 택했다.
- 추가적인 Join이 필요한데 단순 파라미터 타입과 변수명을 나눠서 저장하기에는 정보를 불필요하게 흩뜨려놓는 느낌이 들었다. 그리고 문제를 조회할 때 항상 파라미터 정보가 필요하기 때문에 매번 Join으로 가져오는 것은 성능상 좋지 않다고 판단했다.
- 매번 콤마(,)를 기준으로 데이터를 분리하는 것은 어차피 파라미터 개수가 많아봤자 3개 정도이기 때문에 성능상 문제는 없다고 판단했다.
- 자료구조를 이용해서 MongoDB와 같은 NoSQL에 저장해 볼까 생각했는데 파라미터만을 위해서 추가적인 DB를 운영하는 것은 불필요한 자원을 사용하는 것 같았고, 이 역시 그냥 RDB 상에서 데이터를 가져와 분리하는 것이 처리 로직상 덜 복잡했다.

이제 작성한 내용을 자세히 살펴보자.
먼저 문제에 대한 데이터를 가져온다.

가져온 파라미터 데이터에서 콤마(,)를 기준으로 문자열을 분리하고, 내가 사전에 만들어둔 자바 타입 관련 enum의 메서드를 이용하여 파라미터 문자열을 Class로 변경한다.
해당 enum의 내용은 다음과 같다.
public enum JavaTypeClazz {
BYTE(byte.class ,Byte.class, byte[].class, Byte[].class),
SHORT(short.class, Short.class, short[].class, Short[].class),
INT(int.class, Integer.class, int[].class, Integer[].class),
INTEGER(int.class, Integer.class, int[].class, Integer[].class),
LONG(long.class, Long.class, long[].class, Long[].class),
FLOAT(float.class, Float.class, float[].class, Float[].class),
DOUBLE(double.class, Double.class, double[].class, Double[].class),
CHAR(char.class, Character.class, char[].class, Character[].class),
CHARACTER(char.class, Character.class, char[].class, Character[].class),
STRING(String.class, String.class, String[].class, String[].class),
BOOLEAN(boolean.class, Boolean.class, boolean[].class, Boolean[].class);
private final Class<?> type;
private final Class<?> wrapperType;
private final Class<?> arrayType;
private final Class<?> arrayWrapperType;
JavaTypeClazz(Class<?> type, Class<?> wrapperType, Class<?> arrayType, Class<?> arrayWrapperType) {
this.type = type;
this.wrapperType = wrapperType;
this.arrayType = arrayType;
this.arrayWrapperType = arrayWrapperType;
}
public Class<?> getType() {
return type;
}
public Class<?> getWrapperType() {
return wrapperType;
}
public Class<?> getArrayType() {
return arrayType;
}
public Class<?> getArrayWrapperType() {
return arrayWrapperType;
}
public static Class<?> stringTypeToClass(String str) {
if (!StringUtils.hasText(str)) return null;
for (JavaTypeClazz typeConv : values()) {
if (typeConv.name().equals(str.toUpperCase())
|| (typeConv.name() + "[]").equals(str.toUpperCase())) {
boolean isStartWithLowerCase = Character.isLowerCase(str.charAt(0));
// 배열일 때
if (str.endsWith("]")) {
if (isStartWithLowerCase) return typeConv.getArrayType(); // primitive Array 일 때
else return typeConv.getArrayWrapperType(); // Wrapper Array 일 때
}
// 배열이 아닐 때
if (isStartWithLowerCase) return typeConv.getType(); // primitive type 일 때
else return typeConv.getWrapperType(); // Wrapper type 일 때
}
}
return null;
}
/**
* 필요없는 String은 스킵하고 타입으로 변경 가능한 것만 변경하여 반환
*/
public static Class<?>[] convertAll(String[] strTypes) {
List<Class<?>> result = new ArrayList<>();
for (String strType : strTypes) {
Class<?> foundType = stringTypeToClass(strType);
if (foundType != null) result.add(foundType);
}
return result.toArray(Class<?>[]::new);
}
public static Method toMethod(Class<?> clazz) throws NoSuchMethodException {
if (clazz.equals(String.class)) {
return JavaTypeClazz.class.getMethod("toString", String.class);
}
// 값 타입일 때
if (clazz.isPrimitive()) {
if (clazz.equals(INT.type)) {
return Integer.class.getMethod("parseInt", String.class);
} else if (clazz.equals(CHAR.type)) {
return JavaTypeClazz.class.getMethod("toChar", String.class);
} else {
return clazz.getMethod(
"parse" +
clazz.getName().substring(0, 1).toUpperCase() + clazz.getName().substring(1),
String.class
);
}
} else { // Wrapper 클래스 일 때
return clazz.getMethod("valueOf", String.class);
}
}
public static String toString(String str) {
return str;
}
public static char toChar(String str) {
return str.charAt(0);
}
}
일단 내부에 변수를 4개나 두어서 위와 같이 복잡하게 설정한 이유는 사용자가 의도한 타입과 동일한 타입으로 최대한 처리하기 위해서였다. 사실 일괄적으로 Wrapper Class로 처리해도 되지만, 그렇게 되면 사용자가 의도하지 않은 Boxing 과정으로 인해 퍼포먼스가 떨어질 것을 염려했기 때문이다.
일단, 다시 돌아와서 서비스에서 사용한 convetAll() 메서드를 살펴보자.

분리한 내용을 반복문을 돌며 Class로 변환한다. 여기에 사용하는 메서드는 아래에서 확인할 수 있다.
만약 해당 str에 맞는 Class를 찾지 못하면 결과에 포함시키지 않는다. 이 경우는 콤마(,)나 잘못 작성한 타입명을 걸러내기 위한 조건으로 클래스만 남겨서 배열을 구성하려고 하면 귀찮은 작업을 이전에 미리 수행해야 하기 때문에 그냥 split()으로 나눈 그 자체를 사용할 수 있도록 만들었다.

실질적으로 변환을 담당하는 클래스이다. 문자열을 파라미터로 받아 enum의 이름과 비교해서 해당 타입과 맞는 결과를 반환한다.
여기서 문제가 배열이 들어올 수 있다는 점이었는데, 처음에는 그냥 값 타입과 참조 타입(여기서는 Wrapper 클래스)만 생각했었다.
그런데 생각해보니 배열도 들어올 수 있어서 이 부분은 어쩔 수 없이 배열을 추가로 변수로 만들기로 했다. 그래서 변수가 4개가 된 것이다... 어쨌든 덕분에 문자열에 []가 있으면 배열이라고 판별하여 해당 타입에 맞는 배열도 반환이 가능해졌다.
결과적으로 Class<?>[]가 만들어지고 이걸 이용해서 로드한 클래스의 메서드를 호출하면 된다.

메서드를 동적으로 정보를 받아오려면 파라미터 타입 정보가 필요한데 여기서 필요하기 때문에 앞에서 변환했던 것이었다.
가변 매개변수는 배열로도 넘길 수 있기 때문에 방금 얻어온 배열을 넘겨주면 된다.
AOP에서 고려해야할 점
파라미터 이런 것도 고려해야 하지만 해당 로직에서 추가로 구현해야 할 것이 유저 코드의 실행 시간을 구하는 것이었다. 그래서 나는 나중에 파이썬이나 다른 언어를 처리할 수 있는 서비스도 만들어야 하니 AOP로 공통 로직을 분리해 보자고 생각했다. 그런데 Java코드실행 서비스에 AOP를 적용하니 작동하지 않았다. 그 이유는 실제로 유저 코드가 실행되는 부분만 분리하려고 따로 메서드로 빼놨었는데 이렇게 객체 내에서 자신의 메서드를 호출하는 경우는 AOP가 적용되지 않기 때문이었다. 그래서 따로 분리했던 메서드를 아예 새로운 클래스를 만들어 해당 클래스 내로 옮겼다. 이렇게 하면 해당 클래스를 호출할 때 실제로는 스프링에서 만든 프록시 객체가 반환되기 때문에 처음에 원하던 결과를 얻을 수 있다.


문자열로 된 데이터 Argument로 사용하기
드디어 가장 어려운 부분이 등장했다. 앞서 단순히 파라미터 타입만 전달하는 것은 그나마 간단했지만 이번에는 해당 타입이 맞는 지 검사하고 데이터를 전달해야 한다. 여기서는 배열도 있기 때문에 데이터를 어떻게 저장하고 실제 로직에서 가져올지를 많이 고민했다.
일단 바로 앞서 살펴봤던 InternalMethod 클래스를 자세히 살펴보자.
@Slf4j
@RequiredArgsConstructor
@Component
public class InternalMethod {
private final TestcaseRepository testcaseRepository;
public String runUserMethod(Object instance, Method method,
RequestUserAnswer requestUserAnswer) throws IllegalAccessException, InvocationTargetException {
List<Testcase> testcaseList = testcaseRepository.findByProblemId(requestUserAnswer.getProblemId());
for (Testcase testCase : testcaseList) {
String[] paramArr = testCase.getContent().split("/"); // 파라미터 개수만큼 나누기
Object[] result = new Object[paramArr.length];
for (int i = 0 ; i < paramArr.length; i++) {
Class<?> paramType = method.getParameterTypes()[i]; // 파라미터 타입
String[] paramDataArr = paramArr[i].split(",");
Object convResult;
if (paramType.isArray()) { // 배열일 때
// 배열 내의 타입 구하기
Class<?> componentType = paramType.getComponentType();
convResult = convert(null, paramDataArr, componentType);
} else { // 배열 아닐 때
convResult = convert(paramDataArr[0], null, paramType);
}
result[i] = convResult;
}
method.invoke(instance, result);
}
return null;
}
/**
* 배열 또는 단일 값일 때, String 값을 해당 class 타입으로 변경
*/
private static Object convert(String single, String[] arr, Class<?> targetClazz) {
try {
Method convMethod = JavaTypeClazz.toMethod(targetClazz);
// 배열일 때
if (arr != null) {
Object newArray = Array.newInstance(targetClazz, arr.length);
for (int i = 0; i < arr.length; i++) Array.set(newArray, i, convMethod.invoke(null, arr[i]));
return newArray;
}
// 단일 값일 때
else if (single != null) return convMethod.invoke(null, single);
else return null;
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
} catch (Exception e) {
throw new UserCodeRuntimeException(e);
}
}
}
해당 클래스에서는 주어진 테스트케이스마다 반복하면서 실제 코드에 테스트케이스의 정보를 사용하여 코드를 실행하고 정답과 일치하는 지 검증해야 한다. 나는 아직 정답과 일치하는지 검사하는 로직까지는 진행하지 못했기에 일단 실행하는 부분만 보면 될 것 같다.
그리고 여기서 한 가지 실수를 했는데 유저 코드 실행 시간을 측정하는 AOP를 해당 메서드 자체에 걸어버렸기 때문에 각 테스트케이스마다 실행시간을 측정하는 것이 아닌 전체 테스트케이스 수행 시간을 측정하는 것으로 되어 버렸다;;
해당 문제를 어떻게 해결해야 할 지는 좀 고민해 봐야겠다.
'공부 > Java' 카테고리의 다른 글
| [Java] 도메인 위주로 리팩토링하기 (0) | 2024.12.23 |
|---|---|
| [Java] Entity 객체에서 불필요한 Setter 잡아내기 (0) | 2024.11.15 |
| [Java] 문자열 그대로 클래스로 만들어 컴파일 후 실행하기 (0) | 2024.09.28 |
| [Java] 퀵정렬 (QuickSort) 구현하기 (0) | 2024.09.24 |
| [Java] 동적 프록시 (Dynamic Proxy) (0) | 2024.07.23 |