티스토리 뷰
최근 회사에서 이런 미션을 받았습니다.
"Spring 기반으로 API를 하나 만들고, 클라이언트로부터 코드와 언어 타입을 입력받아, 해당 언어에 맞는 Docker 컨테이너를 실행한 후 결과를 반환해줘."
처음부터 모든 언어를 다 다루기는 어려우므로,
우선 C, Python, Java 정도만 실행할 수 있도록 간단한 구조로 출발하였습니다.
각 언어별로 Docker 컨테이너를 따로 실행해야 하므로,
Dockerfile을 언어마다 개별로 분리해 관리할 수 있도록 디렉터리 구조를 다음과 같이 잡았습니다.
- docker-compose.yml을 루트에 두고
- c-env/, python-env/, java-env/ 등의 디렉터리를 만들어
- 각 디렉터리 내부에 해당 언어의 Dockerfile을 작성
이런 구조는 유지보수도 편하고, 필요할 때 특정 언어의 환경만 별도로 수정하거나 확장하기도 수월하다는 장점이 있습니다.
각 언어의 실행 환경을 위한 Dockerfile을 구성했습니다.
양식은 대부분 유사하며, 처음에는 Docker Hub에 등록된 공식 이미지를 그대로 사용하려 했습니다.
하지만 공식 이미지들은 대부분 기본적인 빌드 도구만 제공하기 때문에, 실제 실행 환경으로 쓰기엔 기능이 제한적이었습니다.
그래서 여러 개발 블로그들을 참고한 결과, 대부분의 경우 Dockerfile을 직접 커스터마이징하는 방식으로 작업하고 있었습니다. 저도 그 방법을 따르기로 했습니다.
# C
# Step 1: GCC 기반
FROM gcc:latest
# Step 2: 작업 디렉토리
WORKDIR /app/c
# Step 3: 코드 파일/Makefile 복사 (필요한 최소 파일만)
COPY Makefile ./
-----------------------------------------------------------------
# JAVA
# Step 1: OpenJDK 17 기반
FROM openjdk:17
# Step 2: 작업 디렉토리
WORKDIR /app/java
# Step 3: 필요한 파일 복사
COPY build.gradle settings.gradle ./
-----------------------------------------------------------------
# PYTHON
# Step 1 : Python 3.9 이미지를 기반으로 함
FROM python:3.9
# Step 2 : 작업 디렉토리 설정
WORKDIR /app/python
# Step 3: 애플리케이션 파일 복사
COPY requirements.txt requirements.txt ./
# Step 4 : 의존성 설치
RUN pip install --no-cache-dir -r requirements.txt
version: '3.8'
services:
python-environment:
container_name: python_environment # 컨테이너 이름
image: python-runner # 실행할 이미지 이름
build: # DockerFile 경로
context: ./python-env
networks:
- code-network
java-environment:
container_name: java_environment
image: java-runner
build:
context: ./java-env
networks:
- code-network
c-environment:
container_name: c_environment
image: c-runner
build:
context: ./c-env
networks:
- code-network
networks:
code-network:
다음은 docker-compose.yml의 주요 내용입니다.
여기서는 각각의 언어별 서비스(컨테이너)를 정의한 후
- 사용할 Dockerfile의 경로를 지정하고
- 최종적으로 빌드될 이미지의 이름을 설정해주었습니다.
구성이 완료된 후에는 아래 명령어로 모든 이미지를 한 번에 빌드하고 실행할 수 있습니다.
version: '3.8'
services:
python-environment:
container_name: python_environment # 컨테이너 이름
image: python-runner # 실행할 이미지 이름
build: # DockerFile 경로
context: ./python-env
networks:
- code-network
java-environment:
container_name: java_environment
image: java-runner
build:
context: ./java-env
networks:
- code-network
c-environment:
container_name: c_environment
image: c-runner
build:
context: ./c-env
networks:
- code-network
networks:
code-network:
이제 백엔드로 넘어갑니다.
Spring Boot 기반이고, 언어는 Kotlin을 사용했습니다.
build.gradle.kts는 거의 기본 템플릿 수준으로 간단하게 구성되어 있습니다.
처음에는 언어별로 Dockerfile을 실행하는 구조를 어떻게 만들까 고민하다가,
Java에서 제공하는 ProcessBuilder라는 클래스를 발견했습니다.
이걸 이용하면 실제 터미널에서 사용하는 명령어를 코드로 실행할 수 있습니다.
예를 들어 "gcc code.c -o code.out && ./code.out" 같은 명령어를 자바 코드 안에서 그대로 실행할 수 있습니다.
간단한 코드(print문만 있는 코드)는 ProcessBuilder로 실행해도 아무 문제 없이 결과가 잘 나왔습니다.
하지만 문제가 생긴 건 scanf()나 Scanner()처럼 입력을 요구하는 코드였습니다.
이런 코드를 실행하면 입력을 기다리다가 결과가 나오지 않거나 멈추는 현상이 발생했습니다
이 문제를 어떻게 풀까 고민하다가, vscode로 백준 문제를 풀던 기억이 떠올랐습니다.
보통 vscode에서는 입력값을 input.txt에 작성해두고, C에서는 scanf가 이 파일을 표준 입력처럼 읽도록 실행 명령어를 작성합니다 ./code.out < input.txt 그래서 비슷한 방식으로, 제 코드에서도 입력값을 먼저 input.txt에 저장하고
Docker 안에서는 < input.txt 리다이렉션을 명령어에 포함하는 방식을 사용하기로 했습니다.
처음엔 process.outputStream.bufferedWriter()로 입력값을 직접 전달해보려 했습니다.
하지만 이 방식은 ProcessBuilder().start()로 먼저 프로세스를 실행한 후,
입력을 기다리는 blocking 구조였기 때문에
사용자 입력이 없는 경우 계속 대기 상태에 머무르는 문제가 있었습니다.
실행 흐름을 효율적으로 제어하기 어려워 보였고, 코드도 복잡해지기 때문에 사용하지 않기로 했습니다. 그래서 "runCodeInDocker"함수에서 입력값을 input.txt에 저장하는 방식을 선택하였습니다.
그래서 현재는 각 언어별 실행 함수(runC(), runPython(), runJava() 등) 내부에서
- 입력값을 input.txt에 저장하고,
- 실행 명령어(command)에 < input.txt를 붙여서 실행하는 방식으로 통일했습니다.
그리고 이 코드를 작성하면서 알게 되었는데 Python의 input(), Java의 Scanner(System.in), C의 scanf() 등 대부분의 언어가 기본적으로 stdin을 읽는 구조이기 때문에 < input.txt가 거의 모든 언어에서 잘 동작한다는 점입니다. < input.txt는 표준 입력을 사용하는 언어라면 사용할 수 있는 명령어이며, 오늘날 우리가 쓰는 대부분의 언어들은 표준 입력 방식을 사용하고 있습니다.
package com.ssu.poo.code.service
import com.ssu.poo.code.controller.dto.ExecuteCodeRequestDto
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
@Service
class CodeService {
private val log = KotlinLogging.logger {}
fun executeCode(executeCodeRequestDto:ExecuteCodeRequestDto): String = when (executeCodeRequestDto.type.lowercase()) {
"python" -> runPython(executeCodeRequestDto.code, executeCodeRequestDto.input)
"java" -> runJava(executeCodeRequestDto.code,executeCodeRequestDto.input)
"c" -> runC(executeCodeRequestDto.code,executeCodeRequestDto.input)
else -> "error: unknown language"
}
// Python 실행
private fun runPython(code: String,input:String): String = runCodeInDocker(
code = code,
type = "py",
image = "python-runner",
command = "python code.py < input.txt",
input = input
)
private fun runJava(code: String,input:String): String = runCodeInDocker(
code = code,
type = "java",
image = "java-runner",
command = "javac code.java && java code < input.txt",
input = input
)
private fun runC(code: String,input:String): String = runCodeInDocker(
code = code,
type = "c",
image = "c-runner",
command = "gcc code.c -o code.out && ./code.out < input.txt",
input = input
)
private fun runCodeInDocker(code: String, type: String, image: String, command: String,input:String): String {
var s: String?
val output = StringBuilder(1024)
try {
log.debug { "$type 코드 실행" }
// 디렉토리 생성
val tempDir = File("code/$type")
tempDir.mkdirs()
// 파일 생성
val sourceFile = File(tempDir, "code.$type")
sourceFile.writeText(code)
// 명령어 저장
val process = ProcessBuilder(
"/opt/homebrew/bin/docker", "run", "--rm",
"-v", "${tempDir.absolutePath}:/app",
"-w", "/app",
image,
"sh", "-c", command
).start()
// 입력값 쓰기
val inputFile = File(tempDir,"input.txt")
inputFile.writeText(input)
// 결과와 에러 가져오기
val stdOutput = BufferedReader(InputStreamReader(process.inputStream))
val stdError = BufferedReader(InputStreamReader(process.errorStream))
while (stdOutput.readLine().also { s = it } != null) {
log.debug { "STDOUT: $s" }
output.appendLine(s)
}
while (stdError.readLine().also { s = it } != null) {
log.error { "STDERR: $s" }
output.appendLine("ERROR: $s")
}
process.waitFor()
process.destroy()
tempDir.delete()
return output.toString()
} catch (e: Exception) {
log.error { e.message }
throw e
}
}
}
코드를 실행해본 결과는 다음과 같습니다.
- 첫 번째 이미지는 백준 15649번 문제의 코드와 입력값을 실행한 결과이며,
- 두 번째 이미지는 단순히 입력만 받는 코드,
- 세 번째 이미지는 백준 1671번 문제의 코드와 입력값을 실행한 결과입니다.
모두 정상적으로 작동하고 있음을 확인할 수 있습니다
현재까지 C, Java, Python 세 언어를 실행할 수 있는 온라인 코드 실행기를 구현해보았고,
입출력 처리까지는 전반적으로 잘 동작하는 상태입니다.
물론 아직 완벽하다고는 생각하지 않기 때문에,
앞으로도 지속적으로 코드를 개선하고 다양한 테스트를 통해 안정성과 유연성을 높여나갈 계획입니다.
특히 아직 테스트하지는 않았지만,
사용자가 File 객체를 직접 생성하거나, InputStream을 사용하는 코드에서
정상적으로 동작할지에 대해서는 조금 더 고민이 필요할 것 같습니다.
입력 흐름이 표준 입력(stdin)이 아닌 방식으로 바뀌면 현재 구조에서는 동작하지 않을 수 있기 때문입니다.
이번 작업을 진행하면서 느낀 점도 있습니다.
Dockerfile과 docker-compose.yml은 저번주에 서버 배포를 처음 해보면서 한 번 다뤄본 적이 있었는데,
이번에 다시 직접 설정해보니 구조가 더 명확하게 이해되었고 머릿속에도 잘 정리되었습니다.
역시 개발은 머리로 때우는 게 아니라 직접 부딪히며 익히는 것이라는 걸 다시 한 번 느꼈습니다
'Back-end > Spring' 카테고리의 다른 글
[Spring/스프링] - DAO, DTO, VO 정리 (0) | 2025.05.24 |
---|---|
[Spring/스프링] - KCP 본인인증 연동 (0) | 2025.05.22 |
[Spring/스프링] - although at least one Creator exists 해결 (0) | 2025.05.21 |
[Spring/스프링] - 스프링 부트 Github Actions 자동 배포 (0) | 2025.05.20 |
[Spring/스프링] - 한글 인코딩 에러 해결 (0) | 2025.05.19 |
- Total
- Today
- Yesterday
- 이분 매칭
- 스택
- 알고리즘
- CSS
- 투 포인터
- 에라토스테네스의 체
- DP
- 카운팅 정렬
- 세그먼트 트리
- 우선순위 큐
- 백준
- 백준 풀이
- 자바
- 자바스크립트
- Do it!
- html
- 유니온 파인드
- DFS
- 스프링 부트 crud 게시판 구현
- BFS
- C++ Stack
- C++
- 유클리드 호제법
- HTML5
- 자료구조
- 반복문
- java
- js
- 알고리즘 공부
- c++ string
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 |