PS 자동화: 풀고 Push 하면 끝! -완-

들어가며

기존의 Ollama qwen3:8b를 활용한 자동화 방식에서 LeetCode와 백준 문제의 번호와 이름을 정확히 추출하지 못하는 문제가 발생했습니다. 이를 해결하기 위해 각 플랫폼의 외부 API를 활용하고, 기존에 Python 스크립트 여러 곳에 흩어져 있던 로직을 하나의 Spring 서버(ps-organizer)로 통합하여 git hooks와 연동한 자동화 flow에 완성 과정을 공유해보겠습니다.

문제 정의

1. 반복되는 수작업

알고리즘 문제를 풀고 나면 반복되는 작업들이 있었습니다.

LeetCode의 1343번, Number of Sub-arrays of Size K and Average Greater than or Equal to Threshold를 예시로 들어보면 아래와 같은 과정을 거칩니다.

  1. 문제 이름 복사
  2. 패키지 생성 후 문제 이름에 포함된 띄어쓰기를 언더바(_) 혹은 - 로 직접 대체
  3. Solution 파일 작성
  4. git commit -m “feat: Leet_1343_Number of Sub-arrays of Size K and Average Greater than or Equal to Threshold” 커밋 메시지 작성
  5. push
  6. choijw1004.github.io 블로그 포스트 작성
  7. push

사소한 문제처럼 보일 수도 있지만 문제 이름이 긴 경우 위의 과정에서 시간이 꽤 오래 들어가게 됩니다.

2. qwen3:8b 도입과 실패

이전 글에서 문제에 포함된 주석의 URL을 분석하여 문제 번호와 이름을 추출하는 역할을 LLM이 담당하게끔 구현했습니다. 하지만 간헐적으로 문제의 번호나 이름을 잘못 추출하는 상황이 발생했습니다. 특히 LeetCode 문제에서 titleSlug만으로 문제 번호를 정확히 맞추지 못하거나, 백준 문제의 한글 제목을 임의로 변형하는 경우가 있었습니다.

간헐적으로 잘못 추출하는 상황이 발생하면 다시 수정해야하기 떄문에 이를 방지하고자 외부 API로 문제의 이름과 번호를 정확하게 추출하는 방식이 필요했습니다.

외부 API 분석

LeetCode, 백준, 프로그래머스의 API 지원 여부를 알아보겠습니다.

백준

백준 자체는 API를 제공하지 않지만, 커뮤니티에서 운영하는 solved.ac가 공개 API를 제공합니다.

GET https://solved.ac/api/v3/problem/show?problemId={문제번호}

LeetCode

Leetcode는 공식 API는 없지만, 서드파티 alfa-leetcode-api를 활용할 수 있습니다. 도커 이미지를 내려받아 아래와 같이 호출하는 방법으로 사용해야합니다.

GET http://localhost:3000/select?titleSlug={titleSlug}

프로그래머스

프로그래머스는 어떤 API도 제공하지 않아서 url에서 번호만 식별이 가능합니다.

https://school.programmers.co.kr/learn/courses/30/lessons/{문제번호}

ps-organizer

ps-organizer는 위에서 분석한 외부 API의 요청/응답과 기존 Python 스크립트에 흩어져 있던 로직을 한 곳에서 담당하게끔 개발한 Spring 서버입니다.

왜 Spring 서버로 분리했는가

기존에는 git hooks에서 실행되는 Python 스크립트가 문제 URL 파싱, Ollama 호출, 디렉토리명 생성, 커밋 메시지 생성, README 생성까지 전부 담당하고 있었습니다. 여기에 외부 API 호출 로직까지 추가하면 스크립트의 복잡도가 더 올라가게 됩니다.

이 시점에서 역할을 분리하기로 했습니다. Python 스크립트는 git hooks에서 API를 호출하고 응답을 파일 시스템에 반영하는 역할만 담당하고, 문제 정보 조회, 디렉토리명 생성, 커밋 메시지 생성, README 생성은 전부 ps-organizer가 처리합니다.

/api/problem

git hook의 Python 스크립트는 하나의 엔드포인트만 호출하면 됩니다. 문제 URL, 카테고리, 접근 방식을 보내면 디렉토리명, 커밋 메시지, README 내용을 응답값으로 보내줍니다.

플랫폼 판별과 외부 API 호출

ProblemService는 URL을 보고 플랫폼을 판별한 뒤, 각 플랫폼에 맞는 방식으로 문제 정보를 가져옵니다.

public OrganizerResponseDto analyzeProblem(OrganizerRequestDto request) {
    String problemLink = request.getProblemLink();
    ProblemInfoDto problemInfoDto;

    if (isBojUrl(problemLink)) {
        // URL에서 문제 번호 추출 → solved.ac API 호출
        String problemId = extractBojProblemId(problemLink);
        BojResponseDto boj = bojApiClient.getProblem(problemId);
        problemInfoDto = ProblemInfoDto.fromBoj(boj);
    }
    else if (isLeetCodeUrl(problemLink)) {
        // URL에서 titleSlug 추출 → Docker 내부 leetcode-api 호출
        String titleSlug = extractLeetCodeTitleSlug(problemLink);
        LeetResponseDto leet = leetApiClient.getProblem(titleSlug);
        problemInfoDto = ProblemInfoDto.fromLeetCode(leet);
    }
    else {
        // 프로그래머스는 외부 API 없이 URL에서 번호만 추출
        String problemNumber = extractProgrammersProblemNumber(problemLink);
        problemInfoDto = ProblemInfoDto.forProgrammers(problemNumber);
    }

    // README, 디렉토리명, 커밋 메시지를 생성하여 한 번에 반환
    String readme = generator.generateReadme(request, problemInfoDto);
    String directoryName = generator.generateDirectoryName(problemInfoDto);
    String commitMessage = "feat: " + directoryName;

    return OrganizerResponseDto.builder()
            .directoryName(directoryName) // Leet_1343_Number_Of_Sub_Arrays...
            .commitMessage(commitMessage)  // feat: Leet_1343_Number_Of_Sub_Arrays...
            .readmeContent(readme)
            // ...
            .build();
}

URL에서 필요한 값을 추출하는 부분은 정규식으로 처리했습니다. LLM이 하던 일을 정규식 한 줄이 대체하게 된 셈입니다.

// https://www.acmicpc.net/problem/1000 → "1000"
Pattern.compile("problem/(\\d+)");

// https://leetcode.com/problems/two-sum/ → "two-sum"
Pattern.compile("problems/([^/]+)");

// https://programmers.co.kr/learn/courses/30/lessons/43165 → "43165"
Pattern.compile("lessons/(\\d+)");

외부 API 호출

백준은 solved.ac API를, LeetCode는 Docker 내부의 alfa-leetcode-api를 호출합니다. 두 클라이언트 모두 Java의 HttpClient로 요청을 보내고 응답을 DTO로 변환하는 구조입니다.

// BojApiClient - solved.ac 호출
public BojResponseDto getProblem(String problemId) {
    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(BOJ_BASE_URL + problemId))  // solved.ac/api/v3/problem/show?problemId=
            .header("x-solvedac-language", "ko")
            .GET()
            .build();

    HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
    return objectMapper.readValue(response.body(), BojResponseDto.class);
}

// LeetApiClient - Docker 내부 leetcode-api 호출
public LeetResponseDto getProblem(String titleSlug) {
    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(LEET_BASE_URL + titleSlug))  // http://leetcode-api:3000/select?titleSlug=
            .GET()
            .build();

    HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
    // 응답의 제목에서 공백을 언더바로 치환
    LeetResponseDto result = objectMapper.readValue(response.body(), LeetResponseDto.class);
    result.setQuestionTitle(result.getQuestionTitle().replace(" ", "_"));
    return result;
}

플랫폼별 DTO 변환

API 응답을 받은 뒤에는 ProblemInfoDto로 변환합니다. 플랫폼마다 제목을 처리하는 규칙이 다르기 때문에 팩토리 메서드로 분리했습니다.

public class ProblemInfoDto {
    private String problemPlatform;  // BOJ, Leet, PGMS
    private String problemNumber;
    private String problemTitle;

    // 백준: 한글 제목의 공백을 언더바로 치환
    public static ProblemInfoDto fromBoj(BojResponseDto boj) {
        return ProblemInfoDto.builder()
                .problemPlatform("BOJ")
                .problemNumber(String.valueOf(boj.getProblemId()))
                .problemTitle(boj.getTitleKo().replaceAll(" ", "_"))
                .build();
    }

    // LeetCode: titleSlug(two-sum)를 PascalCase(Two_Sum)로 변환
    public static ProblemInfoDto fromLeetCode(LeetResponseDto leet) {
        String title = Arrays.stream(leet.getQuestionTitle().split("-"))
                .map(word -> word.substring(0, 1).toUpperCase() + word.substring(1))
                .collect(Collectors.joining("_"));
        // ...
    }

    // 프로그래머스: 외부 API가 없으므로 번호만
    public static ProblemInfoDto forProgrammers(String problemNumber) {
        // title은 null
    }
}

Git Hooks

이제 Git hooks와 PS-Organizer를 어떻게 연결했는지 살펴보겠습니다.

alt text

먼저 Git hooks는 Git이 특정 이벤트(commit, push 등)를 실행할 때 자동으로 호출되는 스크립트입니다. .git/hooks/ 디렉토리에 pre-commit.sample, prepare-commit-msg.sample 같은 샘플 파일들이 존재하는데 .sample 확장자를 제거하면 해당 시점에 스크립트가 실행됩니다.

Algorithm 레포지토리에서는 이 샘플 파일들을 수정하여 2개의 hook을 적용했습니다.

pre-commit -> commit -> post-commit

git commit을 실행하면 2개의 hook이 순서대로 체이닝됩니다.

pre-commit

pre-commit은 커밋 생성 직전에 실행됩니다.

내부 파이썬 스크립트가 Java 파일(Solution/Main.java)의 문제 링크를 추출한 뒤 ps-organizer API를 호출하여 디렉토리 생성, 파일 이동, 리드미 생성의 작업을 수행합니다.

commit

위에 단계를 거친 후 Git이 실제 커밋을 생성합니다.

post-commit

post-commit은 커밋 완료 직후에 실행됩니다.

커밋 메시지가 feat:로 시작하는 경우에만 동작하며, src/ver2/ 하위의 모든 문제 디렉토리를 스캔하여 메인 README.md를 카테고리별 목록으로 재생성합니다. 이후 pre-commit 과정에서 이동되며 삭제된 원본 파일을 정리하고, git pull --rebase && git push를 실행합니다.

이 push는 GitHub Actions의 트리거로 이어져서 기존에 연결된 블로그 포스팅까지 처리합니다.

마치며

외부 API 덕분에 이제 정말 git add . 와 git commit만으로 블로그 포스팅까지 정확하게 동작하게 되었습니다. 그 과정에서 몰랐던 git hooks의 개념을 배울 수 있었고, 제가 느낀 불편함을 직접 구현해서 해결했다는 점에서 뿌듯한 자동화 작업이었습니다. 향후 프로그래머스에서도 공식 API가 제공되길 바라며 글을 마칩니다.

Ref