Making Myself Obsolete

Matthias Endler

스스로를 불필요하게 만들기

원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기

스테고사우루스는 1억 5천만 년 전이 더 좋았던 시절이 있었다.
스테고사우루스는 1억 5천만 년 전이 더 좋았던 시절이 있었다.
출처: 고생물학자들은 한때 스테고사우루스가 엉덩이에 뇌가 있다고 생각했다.

2015년 12월, 나는 trivago의 CI 프로세스에 통합할 정적 분석 도구를 찾고 있었다. 전형적인 프로그래밍 실수를 자동으로 잡아내자는 아이디어였다. 꽤 일반적인 일이었고, 그 목적에 딱 맞는 유용한 도구들도 많이 있었다.

그래서 나는 도구 목록을 찾아보기 시작했다…

놀랍게도, 내가 찾은 유일한 목록은 위키백과에 있었고 — 그것도 오래된 것이었다. 대부분의 최신 정적 분석 도구들이 호스팅되던 GitHub에는 그런 프로젝트가 없었다.

깊게 고민하지 않고, 에디터를 열어 초기 조사에서 찾은 몇 가지 도구들을 적어 내려갔다. 그리고 그 목록을 GitHub에 푸시했다.

나는 그 프로젝트를 Awesome Static Analysis라고 이름 붙였다.

2년이 지나자 목록은 꽤 많이 성장했다. 현재까지 75명의 기여자와 277개의 포크, 2,000개가 넘는 스타를 받았다. (많은 성원에 감사드립니다!) (2018년 5월 업데이트: 기여자 91명, 포크 363개, 스타 3,000개 이상.) (2025년 10월 업데이트: 기여자 316명, 포크 1,400개, 스타 14,000개 이상.)

매주 약 1,000명의 순 방문자가 이 목록을 찾는다. 절대 많은 수는 아니지만, 많은 사람들에게 필수적인 정보원이 되었기 때문에 계속 최신 상태로 유지해야 할 책임감을 느낀다.

현재는 약 300개의 정적 분석 도구가 등재되어 있다. Ada부터 TypeScript까지 모두 망라되어 있다. 특히 동기부여가 되는 점은, 이제는 도구 저자들이 직접 풀 리퀘스트를 만들어 자신의 도구를 추가한다는 것이다!

하지만 한 가지 문제가 있었다. 내가 다른 일로 바쁜 동안 풀 리퀘스트 목록은 점점 길어지고 있었다.

awesome-static-analysis의 GitHub 풀 리퀘스트 목록
awesome-static-analysis의 GitHub 풀 리퀘스트 목록

기여자 추가하기

나는 꾸준히 기여하는 사람들을 팀원으로 맞이하려 노력한다. 친구이자 동료인 Andy GrunwaldOuroboros Chrysopoeia는 모두 소중한 협업자들이다. 이들은 시간이 날 때마다 새로운 PR을 걸러내는 일을 도와준다.

하지만 솔직히 말하자면, 풀 리퀘스트를 확인하는 일은 지루하고 수동적인 작업이다. 새로 추가되는 각 도구에 대해 확인해야 할 사항은 다음과 같이 요약할 수 있다:

  • 서식 규칙을 만족하는지
  • 프로젝트 URL에 접속할 수 있는지
  • 라이선스 표기가 정확한지
  • 각 섹션의 도구들이 알파벳 순으로 정렬되어 있는지
  • 설명이 너무 길지 않은지

이 체크리스트를 가지고 무엇을 해야 할지는 분명하다. 자동화하는 것이다!

린터를 검사하는 린터

그러니 분석 도구 목록을 검사하는 분석 도구를 직접 만들어 보면 어떨까! 꽤 메타하게 들리지만, 사실은 꽤 단순한 일이다.

모든 풀 리퀘스트마다 봇을 실행시켜 위 규칙들을 검사하고 결과를 응답하도록 한다.

첫 번째 단계는 CI 서버 구축에 관한 GitHub 문서를 읽는 것이었다.

재미 삼아 봇을 Rust로 만들어 보고 싶었다. Rust용으로 가장 인기 있는 두 GitHub 클라이언트는 github-rs(현재는 지원 중단됨)와 hubcaps였다. 둘 다 꽤 괜찮아 보였지만, 그때 afterparty라는 “GitHub 웹훅 서버”를 발견했다.

예제 코드는 정말 근사해 보였다:

#[macro_use]
extern crate log;
extern crate env_logger;
extern crate afterparty;
extern crate hyper;

use afterparty::{Delivery, Hub};

use hyper::Server;

pub fn main() {
    env_logger::init().unwrap();
    let addr = format!("0.0.0.0:{}", 4567);
    let mut hub = Hub::new();
    hub.handle("pull_request", |delivery: &Delivery| {
        match delivery.payload {
            Event::PullRequest { ref action, ref sender, .. } => {
                // TODO: My code here!
                println!("sender {} action {}", sender.login, action)
            }
            _ => (),
        }
    });
    let srvc = Server::http(&addr[..])
                   .unwrap()
                   .handle(hub);
    println!("listening on {}", addr);
    srvc.unwrap();
}

덕분에 실제 분석 코드에만 집중할 수 있었는데, 그 코드는 읽어도 그다지 재미있지 않다. 위에서 언급한 항목들을 기계적으로 검사할 뿐이고, 어떤 언어로든 작성할 수 있는 코드다. 한번 보고 싶거나(혹은 기여하고 싶다면!) 저장소를 확인해 보시라.

GitHub와 대화하기

분석 코드를 완성한 후, 로컬에서 실행되며 들어오는 풀 리퀘스트를 기다리는 봇이 생겼다.

하지만 GitHub와는 어떻게 대화해야 할까? Status API를 사용해 /repos/mre/awesome-static-analysis/statuses/:shaPOST 요청을 보내야 한다는 것을 알게 되었다.
(:sha는 풀 리퀘스트의 HEAD를 가리키는 커밋 ID다):

{
  "state": "success",
  "description": "The build succeeded!"
}

기존 Rust용 GitHub 클라이언트 중 하나를 사용할 수도 있었지만, 풀 리퀘스트 상태 코드를 업데이트하는 간단한 함수를 직접 작성하기로 했다.

fn set_status(status: Status, desc: String, repo: &str, sha: &str) -> Result<reqwest::Response> {
    let token = env::var("GITHUB_TOKEN")?;
    let client = reqwest::Client::new();
    let mut params = HashMap::new();
    params.insert("state", format!("{}", status));
    params.insert("description", desc);
    println!("Sending status: {:#?}", params);

    let status_url = format!("https://api.github.com/repos/{}/statuses/{}", repo, sha);
    println!("Status url: {}", status_url);
    Ok(client
        .request(
            reqwest::Method::Post,
            &format!(
                "{}?access_token={}",
                status_url,
                token,
            ),
        )
        .json(&params)
        .send()?)
}

보다시피 환경 변수에서 GitHub 토큰을 가져온 뒤, reqwest 라이브러리를 사용해 JSON 페이로드를 POST 요청으로 전송한다.

결국 이것이 문제가 되었다. afterparty는 hyper의 0.9 버전을 사용하고 있었고, reqwest는 0.11을 사용하고 있었기 때문이다. 안타깝게도 이 두 버전은 서로 다른 빌드의 openssl-sys 바인딩에 의존한다. 이는 잘 알려진 문제이며, 해결하는 유일한 방법은 충돌을 해소하는 것이다.

한동안 막혀 있었는데, 마침 afterparty를 hyper 0.10으로 업그레이드하는 열려 있는 풀 리퀘스트를 발견했다.

그래서 Cargo.toml에서 afterparty 버전을 해당 풀 리퀘스트의 버전으로 고정했다:

[dependencies]
afterparty = { git = "https://github.com/ms705/afterparty" }

이렇게 빌드 문제가 해결되었고, 마침내 다음 단계로 넘어갈 수 있었다.

배포

봇을 호스팅할 장소가 필요했다.

비영리 오픈소스 프로젝트였으니 가급적 무료였으면 했고, 바이너리를 실행할 수 있는 곳이어야 했다.

한동안 zeit라는 제품을 눈여겨보고 있었다. 직관적인 커맨드라인 인터페이스인 now를 이용해 어떤 Docker 컨테이너든 실행할 수 있는 서비스였다.

사이트에서 데모를 처음 본 순간 반해버렸고, 한번 시도해 보고 싶었다.

그래서 프로젝트에 멀티 스테이지 Dockerfile을 추가했다:

FROM rust as builder
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN cargo build --release

FROM debian:stretch
RUN apt update \
    && apt install -y libssl1.1 ca-certificates \
    && apt clean -y \
    && apt autoclean -y \
    && apt autoremove -y
COPY --from=builder target/release/check .
EXPOSE 4567
ENTRYPOINT ["./check"]
CMD ["--help"]

첫 번째 파트는 정적 바이너리를 빌드하고, 두 번째 파트는 컨테이너 시작 시 이를 실행하는 방식이었다. 하지만 잘 되지 않았다. zeit아직 멀티 스테이지 빌드를 지원하지 않았기 때문이다.

해결책은 Dockerfile을 두 개로 나누고 Makefile로 둘을 연결하는 것이었다. Makefile은 꽤 강력하다, 알고 있지 않은가?

이렇게 배포에 필요한 모든 준비를 마쳤다.

# Build Rust binary for Linux
docker run --rm -v $(CURDIR):/usr/src/ci -w /usr/src/ci rust cargo build --release

# Deploy Docker images built from the local Dockerfile
now deploy --force --public -e GITHUB_TOKEN=${GITHUB_TOKEN}

# Set domain name of new build to `check.now.sh`
# (The deployment URL was copied to the clipboard and is retrieved with pbpaste on macOS)
now alias `pbpaste` check.now.sh

now를 이용한 배포 결과는 다음과 같다:

> Deploying ~/Code/private/awesome-static-analysis-ci/deploy
> Ready! https://deploy-sjbiykfvtx.now.sh (copied to clipboard) [2s]
> Initializing…
> Initializing…
> Building
> ▲ docker build
Sending build context to Docker daemon 2.048 kBkB
> Step 1 : FROM mre0/ci:latest
> latest: Pulling from mre0/ci
> ...
> Digest: sha256:5ad07c12184755b84ca1b587e91b97c30f7d547e76628645a2c23dc1d9d3fd4b
> Status: Downloaded newer image for mre0/ci:latest
>  ---> 8ee1b20de28b
> Successfully built 8ee1b20de28b
> ▲ Storing image
> ▲ Deploying image
> ▲ Container started
> listening on 0.0.0.0:4567
> Deployment complete!

마지막 단계는 awesome-static-analysis 프로젝트 설정 안에 check.now.sh를 웹훅으로 추가하는 것이었다.

이제 새로운 풀 리퀘스트가 들어올 때마다 저 작은 봇이 활동하는 모습을 볼 수 있다!

봇이 검사한 성공적인 풀 리퀘스트

결과와 향후 계획

도구 선택에 매우 만족한다. afterparty 덕분에 많은 수작업을 덜 수 있었고, zeit 덕분에 배포가 정말 쉬워졌다.
마치 스테로이드를 맞은 Amazon Lambda 같은 느낌이었다.

내 봇의 코드커밋 내역을 보면 모든 것이 제대로 동작하기까지 겪었던 크고 작은 시행착오를 볼 수 있다. 알고 보니 사람이 읽기 쉽게 작성된 텍스트를 파싱하는 일은 꽤나 번거롭다.
그래서 분석 도구 목록을 YAML 같은 구조화된 형식으로 바꾸는 것을 고민 중이다. 이렇게 하면 파싱이 훨씬 간단해질 뿐만 아니라, 다른 프로젝트에서도 활용할 수 있는 기계가 읽을 수 있는 도구 목록을 갖게 된다는 추가적인 이점도 있다.

2018년 5월 업데이트

비엔나에서 열린 WeAreDevelopers 컨퍼런스에 참석하는 동안(추천한다), CI 파이프라인을 zeit.co에서 Travis CI로 옮겼다. 린팅 코드를 프로젝트 바로 옆에 두고 싶었기 때문인데, 덕분에 많은 것이 훨씬 단순해졌다. 무엇보다 travis가 웹 요청 처리를 대신해 주니 더 이상 웹 요청 처리 코드를 직접 작성할 필요가 없어졌다. 원한다면 이전 버전과 버전을 비교해 볼 수 있다.

2025년 10월 업데이트

그 이후로 이 프로젝트의 거의 모든 것이 바뀌었다. 이제는 CI 검사를 실행하기 위해 GitHub Actions를 사용하고, README.md는 각 도구의 YAML 파일로부터 완전히 자동 생성된다. 추가 리소스(튜토리얼, 영상 등) 목록이나 도구가 제공하는 유료 플랜 목록 같은 특별한 메타데이터 필드도 지원한다. 이제는 700개가 넘는 도구를 나열하고 투표할 수 있는 멋진 웹사이트도 생겼다. 웹사이트는 Next.js로 만들어졌고 검색에는 Algolia를 사용한다. 호스팅 및 기타 비용을 지원해 주는 여러 스폰서도 생겼다. 애정과 시간을 쏟으면 프로젝트가 어떻게 성장하는지 보는 것은 정말 놀라운 일이다! 이 글이 여러분이 직접 오픈소스 프로젝트를 시작하는 데 영감이 되기를 바란다! 그렇게 어렵지 않고, 엄청나게 보람 있다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글