Open yngbao97 opened 4 months ago
좋은 질문 감사합니다.
해당 프로젝트를 할 때는 어떤 방식으로 구현해야 할지 잘 몰라서 사용자가 입력한 정답 코드를 문자열로 받아오고 이후 Main.java 파일을 생성하고 해당 파일에 정답 코드를 넣었습니다. 이후 프로세스를 통해 서버 실행 중 동적으로 자바 파일을 컴파일하고(이 부분은 ChatGPT의 도움을 많이 받았습니다) 출력된 결과를 정답을 저장한 텍스트 파일과 비교해서 정오답, 에러 여부를 프론트엔드로 보내주는 방식으로 구현했습니다.
순서대로 서비스, 컨트롤러, 사용자 정의 에러 파일입니다
package com.example.demo.service;
import com.example.demo.exception.CompilationErrorException;
import java.io.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Service {
// 싱글턴 패턴
private static final Service service = new Service();
private Service() {}
public static Service getInstance() {
return service;
}
// 사용자가 제출한 코드를 받아서 처리
public Map<String, List<String>> grade(String userCode) throws CompilationErrorException, IOException, InterruptedException {
String path = "src/main/java/com/example/demo/test/";
String className = "Main";
// 제출 코드를 파일로 저장
saveUserCode(userCode, path + "Main.java");
// 코드 컴파일
try {
compileUserCode(path + "Main.java");
}
catch (CompilationErrorException e){
throw e;
}
// 실행 결과 저장
List<String> output = runUserCode(className, path + "input1.txt");
System.out.println(output);
System.out.println("실행 결과 저장 성공");
// 정답 저장
List<String> answer = new ArrayList<>();
BufferedReader br = new BufferedReader(new FileReader(path + "answer1.txt"));
String line;
while ((line = br.readLine()) != null) {
answer.add(line);
}
br.close();
System.out.println("정답 저장 성공");
// 컴파일 및 실행 결과 저장할 리스트
List<String> result = compareOutput(output, answer);
System.out.println("비교 성공");
Map<String, List<String>> model = new HashMap<>();
model.put("answer", answer);
model.put("output", output);
model.put("result", result);
return model;
}
// 입력한 코드를 Main.java 로 저장
private void saveUserCode(String code, String path) throws IOException {
BufferedWriter bw = new BufferedWriter(new FileWriter(path));
bw.write(code);
bw.close();
}
// 코드 컴파일
private void compileUserCode(String path) throws IOException, InterruptedException, CompilationErrorException {
Process process = Runtime.getRuntime().exec("javac " + path);
int exitCode = process.waitFor();
if(exitCode != 0) {
throw new CompilationErrorException("컴파일 에러");
}
}
// Main 클래스 실행
private List<String> runUserCode(String className, String inputFilePath) throws IOException, InterruptedException {
List<String> output = new ArrayList<>();
BufferedReader br = new BufferedReader(new FileReader(inputFilePath));
String line;
while ((line = br.readLine()) != null) {
ProcessBuilder builder = new ProcessBuilder("java", "-cp", "src/main/java/com/example/demo/test", className);
Process process = builder.start();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream()));
writer.write(line); // 한 줄을 입력으로 보냄
writer.newLine();
writer.flush();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String outputLine;
if ((outputLine = reader.readLine()) != null) {
System.out.println("실행 출력: " + outputLine);
output.add(outputLine);
}
int exitCode = process.waitFor();
if (exitCode != 0) {
System.out.println("Error running the code, exit code: " + exitCode);
BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
while ((outputLine = errorReader.readLine()) != null) {
System.err.println("실행 에러: " + outputLine);
}
}
}
br.close();
System.out.println("runUserCode 성공");
return output;
}
// 실행 결과와 정답 비교
private List<String> compareOutput(List<String> output, List<String> answer) {
List<String> result = new ArrayList<>();
int len = answer.size();
for(int i=0 ; i<len ; i++){
if(output.get(i).equals(answer.get(i))){
result.add("정답");
}
else{
result.add("오답");
}
}
System.out.println("compareOutput 성공");
return result;
}
}
package com.example.demo.controller;
import com.example.demo.exception.CompilationErrorException;
import com.example.demo.service.Service;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/problem")
@CrossOrigin("*")
public class Controller {
// 채점 서비스
private final Service service = Service.getInstance();
// 채점 코드 전송 받음
@PostMapping("/{problemID}")
public ResponseEntity<Map<String, List<String>>> getSummitCode(@RequestBody Map<String, String> data) {
Map<String, List<String>> result;
try {
result = service.grade(data.get("answer"));
}
catch (CompilationErrorException | IOException | InterruptedException e){
return new ResponseEntity<>(null, HttpStatus.OK);
}
return new ResponseEntity<>(result, HttpStatus.OK);
}
}
package com.example.demo.exception;
public class CompilationErrorException extends Exception {
public CompilationErrorException(String message) {
super(message);
}
}
가용한 시간내에 최대한 설계한대로 구현하려고 노력했지만 일부 예외 상황까진 해결하지 못한 코드입니다. @yngbao97 님이 질문 주신대로 컴파일을 하려면 먼저 컴파일할 파일이 존재해야해서 정상적인 코드가 입력됐는지 여부와 별개로 Main.java 파일을 생성해서 코드를 넣는 과정까지는 진행됩니다.
실제 코딩테스트 환경이 어떻게 구축됐는지 정확하게는 모르지만 방학 프로젝트 발표에서 코딩테스트 서버 구축 프로젝트를 하신 분께서는 도커와 메시지큐를 통해 채점 및 효율성 테스트(실행 시간)를 하는 방식으로 구현하셨던 것 같습니다.(이 부분에 대한 지식이 전무해서 추가적인 설명은 어려울 것 같습니다...) 해당 프로젝트를 하는 과정에서 ChatGPT에 질문도 많이 하였는데 ChatGPT 역시 파일로 저장하고 동적 컴파일하는 방식으로 구현하는 것을 추천하지 않았으며, 채점과 무관한 해킹 코드 같은게 입력될 수 있어서 코딩 테스트 서버와 분리된 환경을 만들어주는 것을 권장하는 답변을 받았던 것 같습니다!!
막연하게 유효성 검사 전에 모든 파일을 저장해버리는 것이 맘에 걸렸던 것 뿐이었는데, gpt설명을 정리해주신것처럼 채점과 무관한 해킹 코드 등을 고려하니 단순히 느낌보다도 더 위험한 방법이네요. 이정도면 괜찮은 코드라고 생각했었는데 유효성 검사의 타이밍과 방식에 보다 고민의 시간을 투자해야하는 계기가 될 것 같습니다. 상세한 답변 감사합니다!
직접 구현하신 흥미로운 프로그램을 예시로 설명해주셔서 사용자 정의 예외를 통해 상황에 맞는 보다 구체적인 예외처리가 가능하다는 걸 다시한번 고려해볼 수 있었습니다!
다만, 메서드 초반 유효성 검사 부분에서 궁금한 점이 있습니다.
매개변수로 받은 제출 코드를 먼저 파일로 저장한 후에 유효성 검사를 하신 이유가 알고싶어서, compileUserCode() 메서드 내용을 확인하고 싶은데 혹시 코드 추가해주실 수 있을까요?
파일로 저장한 후에 컴파일 가능 여부를 판단하시는 걸로 보아, 'Main.java'파일을 실행해서 'Main.class' 파일이 정상적으로 생성되었는지를 확인하는 과정으로 생각중인데 어떻게 구현하셨는지 궁금합니다!
만약 실제로 코딩테스트에 사용하는 프로그램이라고 가정한다면, 컴파일이 되지 않는 제출 코드에 대해서도 파일이 전부 저장되는 것이 맞는지도 고민이 되네요. (제출코드 기록을 위해 저장하는 것이 맞을지도 모르겠습니다!) 예외가 발생했을 때 파일 삭제 등 뭔가 다른 처리를 추가 하는 것도 고려해볼 수 있을 것 같습니다.