
@Override
public void start() {
System.out.println("8기 오픈미션");
}
TeDDie라는 새로운 프로젝트를 도전하며 성장한 1주일
"오픈 미션"이라는 단어를 처음 들었을 땐 솔직히 어떡하지라는 생각이 많이 들었습니다.
"후... 어떤걸 해야하지? 그냥 일단 잘 만드는게 중요한건가?"
기존 1~3주차 미션은 명확했습니다. 요구사항이 주어지고 구현만하면 됬습니다.
하지만 오픈 미션은 달랐습니다. 내가 직접 문제를 정의해야 했고 여러 도전들을 하며 풀어나가야 했습니다.
오픈 미션 웨비나를 들으며 이번 미션의 진짜 의도를 이해했습니다.
우테코가 오픈 미션을 도입한 이유는
"프리코스에 열심히 참여하더라도, 우테코를 지원하기 전까지의 삶이 어땠냐에 따라서 합격 당락이 결정되는 경우가 많았다"
우테코는 이번 오픈 미션을 통해
"지금까지의 경험"이 아니라 "앞으로의 잠재력"을 보고 싶어 하는게 느껴졌고 이전에 몰입했던 경험이 없더라도 지금, 이 순간 프리코스에서 만드는 스토리가 중요하다고 생각이 들었습니다.
저는 프리코스를 하며 많이 성장했다고 느꼈습니다.
새로운 것들을 배우고, 그 배움을 바탕으로 새로운 시도를 하고, 그 과정에서 또 성장하는 선순환이 지속 됬습니다.
그래서 이러한 프리코스의 경험이나 과제를 푸는 것 자체가 모두 저에겐 성장의 순간순간들이었습니다.
"나 뿐만 아니라 많은 사람들이 경험해보면 좋겠다"
라는 생각이 들었습니다.
그래서 이번 오픈 미션을 받고 몇일동안 고민했습니다.
그런 고민 끝에 "내가 어떤 것 때문에 성장을 했을까?"를 중요시하며 생각했는데 프리코스 과제를 풀며 TDD를 통하여 과제를 푸는 행위 자체가 저에겐 큰 성장이었다고 생각했습니다.
그래서 고민 끝에 내린 결정은
"이런 과제를 만드는 프로그램을 만들면 어떨까?"
라는 생각이 들어 이번 미션을 시작하게 되었습니다.
간단하게 소개하자면 "RAG와 로컬 LLM을 활용하여 기존 프리코스 문제를 DB화시켜 이를 기반으로 문제를 생성"하는 프로그램입니다.
간단한 로직으로 설명하면:
이러한 로직으로 구성되어 있습니다.
저는 스프링을 아직 학습을 한 적이 없습니다. 이번 프로젝트에선 Java 애플리케이션이 Python FastAPI 서버, 그리고 LM Studio와 HTTP로 통신해야 했습니다.
어떻게 해야할지 고민이 많았고 천천히 자바 라이브러리를 찾아보니 HttpClient가 표준 라이브러리에 포함됐다는 걸 알았고 이를 사용하기로 했습니다.
public class HttpRequestSender {
private final HttpClient httpClient;
public HttpRequestSender() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
public String post(String url, String requestBody) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
return response.body();
}
}
처음에는 그냥 단순히 post를 보내는 행위만 구현했습니다.
문제는 테스트였습니다. 실제 서버 없이 어떻게 테스트를 해야할지 고민이였습니다.
그러면서 여러 문서를 찾아보니 MockWebServer라는 라이브러리가 있었습니다.
public class HttpRequestSenderTest {
private MockWebServer mockWebServer;
private HttpRequestSender sender;
@BeforeEach
public void setUp() throws IOException {
mockWebServer = new MockWebServer();
mockWebServer.start();
sender = new HttpRequestSender();
}
@AfterEach
public void tearDown() throws IOException {
mockWebServer.shutdown();
}
@DisplayName("서버가 200 OK 응답시 응답 본문을 그대로 반환")
@Test
void 서버가_200_OK_응답시_응답_본문을_그대로_반환() throws Exception {
//given
String expectedBody = "{\"response\":\"목 테스트 성공\"}";
mockWebServer.enqueue(new MockResponse()
.setResponseCode(200)
.setBody(expectedBody));
String mockUrl = mockWebServer.url("/").toString();
//when
String result = sender.post(mockUrl, "{}");
//then
assertThat(result).isEqualTo(expectedBody);
}
}
MockWebServer을 쓰며 느꼈던 장점은
enqueue로 여러 응답을 큐에 넣어서 순차적으로 테스트이렇게 테스트를 작성하니 실제 LM Studio나 FastAPI 서버를 실행시키지 않고도 HTTP 통신 로직을 검증할 수 있었습니다.
하지만 실제로 LM Studio와 통신을 하던 도중에 문제가 발생했었습니다. POST를 보냈지만 미션을 generate가 되지 않는 문제가 발생하여 왜 이런지 고민을 하고 찾아보기 시작했습니다.
Java HttpClient의 관련 문서를 보며 확인을 했는데 HTTP/2를 이용해서 POST를 보냈던게 문제가 되었습니다. LM Studio는 HTTP/1.1만 지원했기에 통신이 되지 않았었던 문제가 발생했었습니다.
public HttpRequestSender() {
this.httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1) // 버전 직접 할당
.connectTimeout(Duration.ofSeconds(10))
.build();
}
이렇게 버전을 직접 할당해주며 문제를 해결했습니다.
TDD를 주도적으로 사용하며 개발을 하다보니 테스트가 성공을 하기 때문에 코드가 잘못됬다기 보다는 이 외의 부수적인 이유에서 되지 않는거라고 금방 알 수 있었습니다.
여기서도 TDD의 중요성을 알게되며 이런 문제가 생기더라도 바로 해결할 수 있는 원동력이 되었습니다.
2주차에 Mockito를 사용해본다고 작성을 했는데 사용하지 못했습니다.
왜냐하면 Mockito는 외부 라이브러리이기 때문에 프리코스 내에서는 JAVA 외부 라이브러리를 사용할 수 없도록 되어 있습니다.
하지만 이번 프리코스 과제는 오픈 미션이기 때문에 이전에 사용해보고 싶었던 Mockito 라이브러리를 직접 사용해보며 의존성을 주입하며 테스트를 작성 할 수 있었습니다.
여기서 느꼈던 장점은 Service 계층이 복잡한 의존성을 가지고 있어서 직접 주입을 하면 Http 서버와 RAG 시스템이 필요하고 테스트가 느려지는 단점이 있었습니다.
이렇게 외부 요인에 의해 테스트가 실패할 수 있는 단점이 있습니다.
@ExtendWith(MockitoExtension.class)
public class MissionServiceTest {
@Mock
private HttpRequestSender mockSender;
@Mock
private RequestBodyBuilder mockRequestBody;
@Mock
private RagClient mockRagClient;
@InjectMocks
private MissionService missionService;
@Test
void API_응답을_파싱하여_실제_텍스트_반환() throws Exception {
//given
String testResponse = """
{
"choices": [
{
"message": {
"content": "## 미션: 문자열 계산기"
}
}
]
}
""";
when(mockRequestBody.createJSONBody(anyString(), anyString()))
.thenReturn("{\"prompt\":\"...\"}");
when(mockSender.post(anyString(), anyString()))
.thenReturn(testResponse);
//when
String result = missionService.generateMission("collection", "easy");
//then
assertThat(result).isEqualTo("## 미션: 문자열 계산기");
}
}
이렇게 Mockito를 사용하며 느꼈던 장점은 @Mock로 가짜 객체를 자동으로 생성하고 @InjectMocks로 Mock 객체들을 자동으로 주입하여 when().thenReturn()으로 호출 시 무엇을 반환하는지 정의하면 실제 통신 필요없이 서비스 로직만 집중해서 테스트를 할 수 있었습니다.
보통 LLM 모델을 사용할 때 ChatGPT, Claude, Gemini를 많이 사용하지만 API 비용이 부담되지 않게 로컬 모델을 사용하여 진행하고 싶었습니다.
그런 과정에서 이전에 봤던 LM Studio에서 개발자 모드를 사용해서 진행하면 로컬 모델을 호출하여 결과를 반환하는 프로세스가 구성되어 있어 간편하게 사용할 수 있었습니다.
public class RequestBodyBuilder {
private static final String DEFAULT_MODEL = "a.x-4.0-light";
public String createJSONBody(String systemPrompt, String userPrompt) {
ApiMessage systemMessage = new ApiMessage("system", systemPrompt);
ApiMessage userMessage = new ApiMessage("user", userPrompt);
ApiRequest request = new ApiRequest(
DEFAULT_MODEL,
List.of(systemMessage, userMessage),
0.7, // temperature (창의성)
-1, // max_tokens (무제한)
false // stream (스트리밍 비활성화)
);
return gson.toJson(request);
}
}
LM Studio가 OpenAI API와 호환이라서 구조가 동일하여 이전에 OpenAI API를 사용했던 기억이 있어 개발을 했을 때 순조롭게 진행할 수 있었습니다.
RAG(Retrueval-Augmeented Generation)를 직접 개발하여 사용하는 첫 시도 였습니다.
이전에는 팀 프로젝트를 진행하면서 RAG를 사용하면서 크롤링 챗봇을 제작하였을 때 팀원이 사용하는 것을 보고 엄청 좋다고 생각했습니다.
RAG는 문서들을 벡터로 변환하고 검색 쿼리도 벡터로 변환하여 벡터 간 거리를 계산해서 유사한 문서를 찾고 LLM에게 컨텍스트로 제공하는 로직입니다.
이렇게 함으로써 LLM은 학습 데이터에 없는 정보나 특정 도메인 지식을 직접 LLM에게 제공해줄 수 있는 장점이 있어 사용했습니다.
from sentence_transformers import SentenceTransformer
import faiss
class WoowacourseRAG:
def __init__(self):
# 다국어 지원 모델 (한국어 포함)
self.model = SentenceTransformer(
'paraphrase-multilingual-MiniLM-L12-v2'
)
self.documents = []
self.index = None
def build_index(self):
# 1. 문서 로드 (우테코 과제들)
self.documents = self.load_documents()
# 2. 텍스트를 벡터로 변환
texts = [doc['text'] for doc in self.documents]
embeddings = self.model.encode(texts, show_progress_bar=True)
# 3. FAISS 인덱스 구축
dim = embeddings.shape[1] # 384차원
self.index = faiss.IndexFlatL2(dim)
self.index.add(embeddings.astype('float32'))
여기서 새로 배운 개념들은 임베딩(Embedding), FAISS(Facebook AI Research), IndexFlatL2의 개념을 배웠습니다.
RAG 시스템 개발을 모두 진행하고 테스트를 진행했는데 "자동차 경주"라고 검색하니 실제로 자동차 경주 관련 과제들이 반환되어 AI에게 직접 제공하여 우테코 과제에 대한 정보를 주니 좀 더 좋은 결과물이 반환되었다는 점이 신기했습니다.
TDD를 자바에서 주로 사용해보니 파이썬에서도 한번 도전하고 싶다는 생각이 들었습니다.
파이썬에는 어떤 테스트 라이브러리로 해야하는지 찾아보던 중 pytest로 테스트를 진행하고 개발을 하는 문서를 확인하고 개발을 바로 시작했습니다.
솔직히 간단하게 어떻게 작동하는지만 확인하고 직접 부딪히며 어떻게 사용하는지를 직접 사용해보며 학습했습니다.
확실히 직접 사용해보며 느꼈던 점은 Java에서는 JUnit을 사용했을 때와 차이점은
# JUnit 스타일
# @DislpayName("임베더 초기화")
# @Test
# void 임베더_초기화() {
# pytest 스타일
def test_embedder_initialization():
embedder = Embedder()
assert embedder.model is not None
@Test 어노테이션, @DisplayName도 필요 없었고 그냥 간단히 test_로 시작하는 함수를 만들고 asert로 검증하면 끝이었습니다.
보통 문서 로더를 테스트할 때 실제 파일을 만들면서 테스트를 진행했습니다. JUnit에서는 직접 파일을 생성하고 삭제해야 했는데, ptyest의 tmp_path는 그럴 필요가 없었습니다.
def test_loader_reads_jsonl(tmp_path):
# 임시 파일 자동 생성
path = tmp_path / "sample.jsonl"
data = {"repo": "java-lotto", "text": "README", "url": "https://github.com"}
path.write_text(json.dumps(data) + "\n", encoding="utf-8")
# 테스트
loader = DocumentLoader(str(path))
docs = loader.load()
assert len(docs) == 1
assert docs[0]["repo"] == "java-lotto"
이렇게 @BeforeEach, @AfterEach 같은 설정 코드 없이도 테스트를 진행할 때 임시 디렉토리를 생성하고 테스트 종료 후 자동으로 삭제되는 편리한 기능이 있었습니다.
개인적으로 느꼈던 JUnit과의 차이점은 아래와 같습니다.
| 특징 | JUnit 5 | pytest |
|---|---|---|
| 테스트 선언 | @Test 어노테이션 | test_ 접두사만 |
| 검증 | assertThat().isEqualTo() | assert == |
| 임시 파일 | 직접 생성/삭제 필요 | tmp_path 자동 처리 |
| HTTP 테스트 | MockWebServer 설정 복잡 | TestClient 한 줄 |
| 실행 속도 | 상대적으로 느림 | 정말 빠름 |
하지만 파이썬은 동적 타입 언어라서 런타임 에러가 자주 발생하기 때문에 자주 isinstance(results, list)와 같은 타입 검증을 더 자주 진행해야 했고 자바는 이와 반대로 정확한 테스트를 진행할 수 있었다는 장점이 있었습니다.
간단하게 빠르고 간단하게 테스트 후 개발을 원하면 파이썬이 맞지만 자바는 안전하고 정확하게 테스트를 진행할 수 있다는 서로의 장단점이 있다고 생각이 들었습니다.
오픈 미션을 시작할 때의 막막함이 이제는 성장했다라는 뿌듯함으로 바뀌었습니다.
1주일이라는 짧은 시간 동안 TeDDie라는 프로젝트를 진행하면서 가장 크게 느낀 점은 "모르는 것을 두려워하지 않고 부딪히는 것"의 중요성이었습니다.
HttpClient, MockWebServer, Mockito, RAG, pytest 이 모든 기술들은 이번 프로젝트를 시작하기 전까지 제대로 써본 적이 없었습니다. 하지만 문서를 찾아보고, 직접 코드를 작성하며, 테스트를 돌려보는 과정에서 자연스럽게 익히며 성장해 나가는 과정이 직접 느껴졌습니다.
특히 TDD는 이번 프로젝트에서 가장 큰 힘이 되어 주었습니다.
HTTP 통신이 안 될 때도, RAG 시스템을 구현할 때도, 테스트가 있었기 때문에 "어디까지는 맞고, 어디서부터 문제인지"를 빠르게 파악하고 문제가 생겼을 때 해결할 수 있었습니다.
만약 테스트 없이 개발했다면 문제의 요점을 빠르게 파악하지 못하고 단순 코드만 수정을 했다고 생각이 듭니다.
처음 오픈 미션을 받았을 때 어떻게 진행해야 할지 막막함이 가득했습니다.
하지만 직접 부딪히고 프리코스를 하며 성장하고 느꼈던 점들을 생각하다 보니, 자연스럽게 무엇을 해야 하고 하고 싶은지 스스로 문제를 정의할 수 있었습니다. 그 과정에서 나의 도전정신이 진정성과 몰입으로 자연스럽게 이어졌다고 생각합니다.
이번 주는 완벽하지 않았지만, 그 모든 과정이 제 성장의 발자국입니다.
도전은 계속될 것이며, 저는 그 모든 도전 과정을 즐기며 배우고 성장할 것입니다.
회고 잘 읽었습니다!
저도 오픈미션으로 OpenAI API 사용해서 챗봇을 개발 중에 있는데 구조는 어렵지 않지만 제 생각대로 커스터마이징해서 만들다 보니깐 고려할 사항이 너무 많네요.. 테스트도 어떻게 진행할지 막막했는데 좋은 인사이트 얻어갑니다!!
시간이 많이 남은 것 같으면서 부족하게 느껴지네요 ㅜㅜ 남은 기간도 화이팅 해봐요:)