급한 분은 여기를 눌러서 따라하시면 됩니다
백준허브는 깃헙과 연동해 백준, 프로그래머스에서 푼 코딩테스트 문제풀이를 지정한 깃헙 레포의 메인 브랜치에 자동 푸시해주면서 풀이 과정을 쉽게 관리하는 익스텐션이다. 이름과 다르게 프로그래머스 풀이도 같이 관리해주며, 단순히 풀이 파일만 올리지 않고 난이도별로 디렉토리를 개별 설정해서 깔끔하게 보관해주는 아주 유용한 확장 프로그램이다.
백준, 프로그래머스 외에도 LeetCode도 많이 애용되는 코테 풀이 플랫폼인데, 얘도 백준허브처럼 깃헙 레포에 풀이를 푸시하고 관리하는 LeetHub라는 익스텐션도 존재한다. 근데 얘는 백준허브랑 다르게 그냥 레포에 풀이 파일 디렉토리만 그대로 푸시한다.
둘을 별개의 깃헙 레포들로 나눠서 관리한다면 큰 문제는 아니겠지만, 알고리즘 풀이라는 동일 도메인(?)의 관점에서 하나의 레포로 관리하는 게 보기도 편하고 관리도 쉽겠다는 생각이 들어서, 백준허브와 리트허브를 연동할 레포를 한 곳으로 통일 지정할 수는 있다. 다만 그렇게 되면...
위처럼 0066-plus-one
이라는 패키지는 리트코드에서 푼 문제풀이인데 플랫폼별 + 난이도별 디렉토리를 세팅해 푸시해주는 백준, 프로그래머스와는 다르게 루트 디렉토리에 바로 풀이를 넣어버려서 관리가 상당히 지저분해진다.
그래서 이 둘의 푸시 방식을 통일해서 깃헙 레포를 깔끔하게 관리하기 위해 LeetHub 관련 커밋을 추적해 디렉토리를 정리하는 Github Actions 스크립트를 작성하며 문제를 해결해보고자 한다.
목표 정리
- 백준(프로그래머스) 풀이와 리트코드 풀이를 한 곳에서 같이 관리하고 싶다
- 백준허브의 난이도별 디렉토리 생성 관리 방식을 리트허브에도 적용하고 싶다
공모전 후기도 작성해야 되는데...
사실 예전에 이 문제를 맞닥뜨리고 레퍼런스 찾아가며 해결한 적이 있었다. 다만 그때는 리트허브 자체의 소스코드를 건드려서 해결했고, 수정된 소스코드를 임의로 확장자에 적용해서 버전 업데이트에 취약하고, 리트코드 UI가 구 버전이어야 가능하다는 단점이 있었다. 실제로 현재 리트허브 소스코드 수정본 적용이 막혀서 내 나름의 해결책을 생각한 것.
어찌됐든 소스코드를 건드리는 것보단 그냥 받아오는 내 측에서 관리하되, 귀찮은 검수 과정을 자동화시키는 것을 해결 방향으로 잡았다.
이미 백준허브와 리트허브를 하나의 레포에 연동시켰을 것이다. 혹시 그걸 아직 못했으면 아래의 포스팅들 참고
일단 백준허브는 관리가 의도대로 잘 동작하니, 백준허브의 커밋 푸시는 우리의 자동화 구축 대상에서 제외해야 된다. 즉, 리트허브와 백준허브의 커밋 푸시를 선별해야 한다. 그래서 커밋 로그를 뒤져봤다.
위의 로그는 프로그래머스 풀이를 푸시한 백준허브의 커밋 로그고, 밑의 2개 로그는 리트코드 풀이를 푸시한 리트허브의 커밋 로그다. 커밋 메세지를 보면 마지막에 어디서 푸시했는지 기재되어 있다. 이걸 통해서, 커밋 메세지의 마지막을 서브스트링하여 백준허브 푸시와 리트허브 푸시를 분별할 수 있다!
일단 깃헙액션 스크립트를 세팅했다. 세팅 방법은 깃헙 레포의 Actions 탭으로 접속하거나, 아니면 직접 메인 브랜치의 루트 디렉토리에서 Add file을 누르고 /.github/workflows/<원하는 파일명>.yml
을 작성하면 자동으로 스크립트 작성 준비가 끝난다.
jobs와 steps를 배치하고 최근 커밋의 메세지 로그를 추적하는 스텝을 작성하고 문제를 풀어 푸시해보았다.
- name: Check if LeetHub Commit
id: check_leethub
run: |
COMMIT_MSG=$(git log -1 --pretty=%B | tail -n 1)
echo "Latest commit message: $COMMIT_MSG"
# 와일드카드 + "- LeetHub" 검증
if [[ "$COMMIT_MSG" == *"- LeetHub" ]]; then
echo "LeetHub commit detected ✅"
else
echo "Not a LeetHub commit ❌"
fi
메세지 문자열을 잘 읽어오는 것이 확인된다!
깃헙 액션의 최우선 목적은 리트허브의 푸시를 확인하면 이를 루트 패키지에서 /LeetCode
라는 내가 생성한 폴더로 옮겨야 한다. 일단 메인 브랜치에서 /LeetCode
라는 이름으로 디렉토리를 하나 생성해서 안에 마크다운 파일 아무거나 생성해서 푸시해두자.
새로운 리트코드 문제 풀이가 푸시되면 상황은 다음과 같다.
- 루트 디렉토리에 원래
/백준
,/프로그래머스
,/LeetCode
,/.github
가 있었음- 문제 풀이된 새로운 디렉토리가 확인됨
- 이 새로운 디렉토리를
/LeetCode
로 옮겨야 함
이 과정이 이뤄지려면 스크립트에게 옮겨야 할 디렉토리명을 알려주고 옮기도록 해야 한다. 새로 추가된 디렉토리가 리트허브로부터 추가됐는지 확인하는 건, 원래 존재하던 디렉토리의 명칭과 다른 것인지 확인하면 된다.
- name: Detect New Solve Package
id: detect_package
if: steps.check_leethub.outputs.should_run == 'true'
run: |
EXCLUDE_DIRS=("LeetCode" "백준" "프로그래머스" ".github")
NEW_DIRS=$(git diff --name-only HEAD~1 HEAD | awk -F/ 'NF==2 {print $1}' | sort -u)
for DIR in $NEW_DIRS; do
SKIP=false
for EX in "${EXCLUDE_DIRS[@]}"; do
if [[ "$DIR" == "$EX" ]]; then
SKIP=true
break
fi
done
if [[ "$SKIP" == false ]]; then
echo "새로 추가된 문제풀이 패키지명: $DIR"
echo "PACKAGE_NAME=$DIR" >> $GITHUB_OUTPUT
break
fi
done
새로 추가된 디렉토리 패키지명을 추출해오는 것이 확인된다. 이걸 바탕으로 LINUX 명령어를 활용해 /LeetCode
경로로 풀이 패키지를 이동한다.
jobs:
detect:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check_leethub.outputs.should_run }}
package_name: ${{ steps.detect_package.outputs.PACKAGE_NAME }}
commit_msg: ${{ steps.check_leethub.outputs.commit_msg }}
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 2 # 이전 로그 2개까지 해서 변경점 비교(디폴트는 1)
# ...
- name: Detect New Solve Package
id: detect_package
if: steps.check_leethub.outputs.should_run == 'true'
run: |
EXCLUDE_DIRS=("LeetCode" "백준" "프로그래머스" ".github")
NEW_DIRS=$(git diff --name-only HEAD~1 HEAD | awk -F/ 'NF==2 {print $1}' | sort -u)
# ...
move:
needs: detect
if: ${{ needs.detect.outputs.should_run == 'true' && needs.detect.outputs.package_name != '' }}
runs-on: ubuntu-latest
steps:
- name: Move Directory
run: |
PACKAGE_NAME="${{ needs.detect.outputs.PACKAGE_NAME }}"
COMMIT_MSG="${{ needs.detect.outputs.commit_msg }}"
echo "옮겨야 할 패키지 확인: $PACKAGE_NAME"
# 실제 이동
git mv "$PACKAGE_NAME" "LeetCode/$PACKAGE_NAME"
- name: Commit and Push
# ...
깃헙 저장소니까 폴더를 옮기면 다시 깃을 커밋하고 푸시해줘야 최종 결과가 반영된다. 이 단계까지가 MVP에서 깃헙 액션이 담당하는 부분이다. 깃헙 액션이 우리의 깃 레포를 조작하려면 일단 권한을 개방하고 시크릿 변수에서 기본 제공하는 깃헙 토큰을 적용해야 한다.
steps:
- name: Checkout
uses: actions/checkout@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Move Directory
run: |
PACKAGE_NAME="${{ needs.detect.outputs.PACKAGE_NAME }}"
echo "옮겨야 할 패키지 확인: $PACKAGE_NAME"
# 실제 이동
git mv "$PACKAGE_NAME" "LeetCode/$PACKAGE_NAME"
- name: Commit and Push
run: |
COMMIT_MSG="${{ needs.detect.outputs.commit_msg }}"
echo "커밋 메세지 확인: $COMMIG_MSG"
git config --global user.name "<사용자 깃허브 닉네임>"
git config --global user.email "<사용자 깃허브 가입 이메일>"
git add .
git commit -m "$COMMIT_MSG" || echo "No changes to commit"
git push
커밋 메세지는 이전 커밋 분석에서 커밋 메세지를 추출해 변수로 저장해 여기서 활용했고, 깃 조작을 위한 사용자 닉네임과 가입 이메일을 기입해주면 깃 조작(add, commit, push)가 가능해진다.
여기까지 완료했으면 리트코드 풀이를 별도 디렉토리에서 관리한다는 우리의 MVP는 아래처럼 달성된다. 여기까지만 해도 백준허브 관리와 동일해져서 보기가 깔끔해진다.
백준이나 프로그래머스, 리트코드 전부 풀었던 문제를 다시 풀 수도 있고 언어를 다르게 해서 언어별 풀이를 추가할 수도 있다. 이 상황에서 MVP로 완성한 깃헙 액션 스크립트를 적용한다면?
간단한 회문(펠린드롬) 문제의 최적화 코드를 한 줄 추가한 풀이 업데이트를 수행하고 리트허브로 푸시했다.
보다시피 풀이 디렉토리가 또 추가돼서 기존 디렉토리 안에 생성된 것이 보인다.
디렉토리 이동에 쓰인 LINUX 명령어 mv
는 디렉토리에 있어서는 덮어쓰기가 적용되지 않기 때문에 현재 깃헙에서처럼 재귀 생성이 발생하거나 오류가 발생할 수 있다. 즉, 이미 존재하는 풀이 디렉토리인지를 먼저 확인하고 존재한다면 파일만 옮겨야 한다.
다행히도 파일에 대한 mv
명령어 적용은 파일 덮어쓰기가 적용되므로 디렉토리 존재 여부만 파악하면 된다.
- name: Move Directory
run: |
PACKAGE_NAME="${{ needs.detect.outputs.PACKAGE_NAME }}"
DEST_DIR="LeetCode/$PACKAGE_NAME"
echo "옮겨야 할 패키지 확인: $PACKAGE_NAME"
if [ -d "$DEST_DIR" ]; then
echo "$DEST_DIR 디렉토리가 이미 존재, 내부 파일만 이동"
# 내부 파일만 이동
mv "$PACKAGE_NAME"/* "$DEST_DIR"/
rm -r "$PACKAGE_NAME"
else
echo "$DEST_DIR 디렉토리가 존재하지 않음(처음 푼 문제), 디렉토리 전체 이동"
mv "$PACKAGE_NAME" "LeetCode/"
fi
파일만 옮기게 되면 루트 위치에 기존 디렉토리가 남게 되므로 해당 디렉토리를 삭제해준다. 내부 파일이 없으면 깃 적용 대상이 아니긴 해도 예외를 대비하기 위한 rm
명령어를 추가한다.
기존 풀이![]() | → | 새로운 풀이 업데이트![]() |
---|
기존 문제 풀이에서 코드를 추가하여 새로운 풀이를 적용해보니 파일이 덮어씌워지면서 잘 적용된다.
테스트를 위해 회문 문제를 이번엔 자바로 풀어서 파일을 추가하는 경우를 테스트해본다.
자바 파일이 파이썬 풀이와 동일한 디렉토리 위치에 잘 추가됐다.
백준, 프로그래머스처럼 리트코드도 문제별 난이도를 분류해두고 있다. 그래서 리트허브가 푸시할 때 아마 문제 관련 정보도 반영해서 커밋할 것으로 생각했고, 실제로 문제 설명과 관련된 마크다운 리드미를 같이 디렉토리에 추가해서 커밋한다.
![]() | ![]() |
---|
해당 리드미에 난이도 레벨이 기재되어 있고, 마크다운을 뜯어보니 첫 번째 라인의 h3 태그로 감싸져있는 것을 확인했다. 즉, 이 난이도 정보를 활용해서 난이도별 디렉토리를 추가 구축할 수 있을 것이다.
리트허브의 푸시는 백준허브처럼 문제 타이틀을 패키지명 삼아, 내부에 풀이 파일, 마크다운 파일 최대 2개를 동봉한다. 근데, 이 푸시 방식이 백준허브와는 좀 다르다.
백준허브는 문제풀이와 마크다운을 한 번에 푸시하기 때문에 커밋이 1번만 이뤄진다. 반면, 리트허브는 문제풀이 따로 커밋하고 마크다운 따로 커밋하기 때문에 푸시를 확인해보면 커밋이 여러번 수행되는 것을 볼 수 있다.
즉, 커밋 시점에 차이가 있고 이것이 일괄적으로 관리 레포에 푸시되는 매커니즘을 가진다. 그렇기 때문에 우리가 기존 MVP 과정에서 추적한 커밋 시점에 리드미가 존재하는지를 보장해야 한다.
현재 리트허브 버전에서의 커밋 로그는 전부 리드미 관련 커밋이 먼저 이뤄지고 풀이 커밋이 이뤄지는 순서를 가지고 있다. 즉, 커밋 로그를 깃헙 액션에서 추적할 시점에 이미 리드미를 갖고 있을 것이므로 난이도 레벨을 추출하는 데에는 문제가 없다고 판단했다.
- name: Extract Level from README
id: extract_level
if: steps.check_leethub.outputs.should_run == 'true' && steps.detect_package.outputs.PACKAGE_NAME != ''
run: |
PACKAGE_NAME="${{ steps.detect_package.outputs.PACKAGE_NAME }}"
README_PATH="$PACKAGE_NAME/README.md"
if [ ! -f "$README_PATH" ]; then
echo "README.md 파일 없음 ❌"
echo "leetcode_level=Unknown" >> $GITHUB_OUTPUT
exit 0
fi
# h3 태그 안에서 난이도 추출
LEVEL=$(grep -oP '(?<=<h3>).*?(?=</h3>)' "$README_PATH" | head -n 1)
# 없으면 fallback
if [ -z "$LEVEL" ]; then
LEVEL="Unknown"
fi
echo "LeetCode 문제 난이도: $LEVEL"
echo "leetcode_level=$LEVEL" >> $GITHUB_OUTPUT
혹시 리드미를 확인하지 못할 경우를 대비해 "Unknown" 이라는 폴백 텍스트를 하나 추가해준다. 텍스트를 추출해 깃헙 액션의 변수에 담아 폴더 이동 job에서 활용할 수 있게 한다.
# 기존 위치로 이동
if [ -n "$FOUND_EXISTING_PATH" ]; then
DEST="$FOUND_EXISTING_PATH/$PACKAGE_NAME"
echo "→ 기존 디렉토리로 병합 이동: $DEST"
mv "$PACKAGE_NAME"/* "$DEST"/
rmdir "$PACKAGE_NAME" || true
# 새로 이동해야 할 경우
else
DEST="$ROOT_DIR/$LEVEL_DIRECTORY/$PACKAGE_NAME"
echo "→ 새 디렉토리로 이동: $DEST"
mkdir -p "$ROOT_DIR/$LEVEL_DIRECTORY"
mv "$PACKAGE_NAME" "$ROOT_DIR/$LEVEL_DIRECTORY/"
fi
스크립트를 작성하는 과정에서 알게 됐는데, 리트허브의 별개 커밋 정책 때문에 기존 문제풀이 업데이트에서는 리드미가 생성되지 않고 풀이만 추가된다. 즉, 해당 문제의 레벨을 업데이트 단계에서는 추적할 수 없는 것이다.
이렇게 되면 기존 문제 풀이와 새로운 문제 풀이가 각각 /Unknown
패키지와 /Easy
(혹은 지정된 레벨) 패키지에 중복으로 생성될 것이다. 그래서 기존 디렉토리를 탐색하는 것을 아예 전체 순회 방식으로 수정했다.
# 먼저 기존에 동일한 디렉토리 위치가 있는지 탐색
FOUND_EXISTING_PATH=""
for dir in "$ROOT_DIR"/*; do
if [ -d "$dir/$PACKAGE_NAME" ]; then
FOUND_EXISTING_PATH="$dir"
echo "기존 디렉토리 발견: $dir/$PACKAGE_NAME"
break
fi
done
해당 분기를 폴더 추가 혹은 이동 단계 이전에 배치한다.
난이도에 맞춰 해당 난이도 디렉토리에 풀이 패키지가 추가되고, 풀이 업데이트도 정상적으로 잘 반영되는 것을 확인했다.
- 백준허브와 리트허브 하나의 레포에 같이 연동하기
- 연동 레포의 루트 디렉토리에
/LeetCode
를 추가한다.- 깃헙 액션 스크립트를 세팅한다.(
/.github/workflows/<원하는 파일명>.yml
)- 아래의 yml 스크립트를 적용한다. 마지막 스텝에서 깃헙 닉네임과 가입 이메일로 수정한다.(
벨로그 왜 토글 적용 안 됨??)name: LeetHub Setting on: push: branches: [ "main" ] permissions: contents: write jobs: detect: runs-on: ubuntu-latest outputs: should_run: ${{ steps.check_leethub.outputs.should_run }} package_name: ${{ steps.detect_package.outputs.PACKAGE_NAME }} commit_msg: ${{ steps.check_leethub.outputs.commit_msg }} level: ${{ steps.extract_level.outputs.leetcode_level }} steps: - name: Checkout uses: actions/checkout@v3 with: fetch-depth: 2 # 이전 로그 2개까지 해서 변경점 비교(디폴트는 1) - name: Check if LeetHub Commit id: check_leethub run: | COMMIT_MSG=$(git log -1 --pretty=%B | tail -n 1) echo "Latest commit message: $COMMIT_MSG" if [[ "$COMMIT_MSG" == Time:* && "$COMMIT_MSG" == *"- LeetHub" ]]; then echo "should_run=true" >> $GITHUB_OUTPUT echo "commit_msg=${COMMIT_MSG}" >> $GITHUB_OUTPUT echo "LeetHub 커밋 확인, 패키지 이사 작업 준비 ✅" else echo "should_run=false" >> $GITHUB_OUTPUT echo "commit_msg=" >> $GITHUB_OUTPUT echo "작업 없이 패스하면 됩니다 ❌" fi - name: Detect New Solve Package id: detect_package if: steps.check_leethub.outputs.should_run == 'true' run: | EXCLUDE_DIRS=("LeetCode" "백준" "프로그래머스" ".github") NEW_DIRS=$(git diff --name-only HEAD~1 HEAD | awk -F/ 'NF==2 {print $1}' | sort -u) for DIR in $NEW_DIRS; do SKIP=false for EX in "${EXCLUDE_DIRS[@]}"; do if [[ "$DIR" == "$EX" ]]; then SKIP=true break fi done if [[ "$SKIP" == false ]]; then echo "새로 추가된 문제풀이 패키지명: $DIR" echo "PACKAGE_NAME=$DIR" >> $GITHUB_OUTPUT break fi done - name: Extract Level from README id: extract_level if: steps.check_leethub.outputs.should_run == 'true' && steps.detect_package.outputs.PACKAGE_NAME != '' run: | PACKAGE_NAME="${{ steps.detect_package.outputs.PACKAGE_NAME }}" README_PATH="$PACKAGE_NAME/README.md" if [ ! -f "$README_PATH" ]; then echo "README.md 파일 없음 ❌" echo "leetcode_level=Unknown" >> $GITHUB_OUTPUT exit 0 fi # h3 태그 안에서 난이도 추출 LEVEL=$(grep -oP '(?<=<h3>).*?(?=</h3>)' "$README_PATH" | head -n 1) # 없으면 fallback if [ -z "$LEVEL" ]; then LEVEL="Unknown" fi echo "LeetCode 문제 난이도: $LEVEL" echo "leetcode_level=$LEVEL" >> $GITHUB_OUTPUT move: needs: detect if: ${{ needs.detect.outputs.should_run == 'true' && needs.detect.outputs.package_name != '' }} runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Move Directory run: | PACKAGE_NAME="${{ needs.detect.outputs.PACKAGE_NAME }}" LEVEL_DIRECTORY="${{ needs.detect.outputs.level }}" ROOT_DIR="LeetCode" echo "옮겨야 할 패키지 확인: $PACKAGE_NAME" echo "감지된 레벨: $LEVEL_DIRECTORY" # 먼저 기존에 동일한 디렉토리 위치가 있는지 탐색 FOUND_EXISTING_PATH="" for dir in "$ROOT_DIR"/*; do if [ -d "$dir/$PACKAGE_NAME" ]; then FOUND_EXISTING_PATH="$dir" echo "기존 디렉토리 발견: $dir/$PACKAGE_NAME" break fi done # 기존 위치로 이동 if [ -n "$FOUND_EXISTING_PATH" ]; then DEST="$FOUND_EXISTING_PATH/$PACKAGE_NAME" echo "→ 기존 디렉토리로 병합 이동: $DEST" mv "$PACKAGE_NAME"/* "$DEST"/ rmdir "$PACKAGE_NAME" || true # 새로 이동해야 할 경우 else DEST="$ROOT_DIR/$LEVEL_DIRECTORY/$PACKAGE_NAME" echo "→ 새 디렉토리로 이동: $DEST" mkdir -p "$ROOT_DIR/$LEVEL_DIRECTORY" mv "$PACKAGE_NAME" "$ROOT_DIR/$LEVEL_DIRECTORY/" fi - name: Commit and Push run: | COMMIT_MSG="${{ needs.detect.outputs.commit_msg }}" echo "커밋 메세지 확인: $COMMIT_MSG" git config --global user.name <당신의 깃헙 닉네임> git config --global user.email <당신의 깃헙 가입 이메일> git add . git commit -m "$COMMIT_MSG" || echo "No changes to commit" git push
어쨌든 이 자동화 작업 때문에 리트코드 풀이 관리가 한결 편해지긴 했다...
아직 내가 생각하지 못한 고려 케이스가 있을 수 있는데... 지금 생각나는 개선 사항은 리트코드 풀이 패키지명이 문제 번호가 기입되어 있어서 이진 탐색으로 기존 디렉토리 탐색 작업을 효율적으로 수행할 수 있지 않을까 싶다.
깃헙 UI 상에는 폴더가 번호 순으로 나열됐기는 한데, 실제로 이진 탐색을 작성한 파이썬 파일을 기입해서 bash로 적용이 가능할지 조금 더 알아봐야겠다.
혹시 문제점이 있다면 말씀해주세요