GitHub Self-hosted Runner 설정하기

Unittest가 개발 과정에서 논리를 점검하는데 유용하기는 하지만 이세상에는 integration test가 담당해야 하는 부분도 엄연히 존재한다. 각각의 컴포논트가 완벽하다 하더라도 이것을 통합했을 때의 동작을 보장하는 것은 또 다른 이야기 이기 때문이다.

이 부분을 커버하기 위한 목적으로 가상의 데이터를 생성하고 프로그램의 처음 시작 부분부터 마지막 출력 부분까지(E2E) 점검하는 스크립트를 만들어 두었다. 문제는 unittest 만큼이나 이 스크립트를 자주 돌리지 않을 뿐 아니라 종종 커밋할 때 까먹는 다는 것이다.

가만, 이런 귀찮을 걸 안까먹으려고 CI를 설정해 두지 않았던가!

몇몇 리소스들을 찾아보니 GitHub workflow에서 제공하는 VM에서 docker를 돌리는 것에는 문제가 없다고 한다. 그래서 기존에 Lint와 Unitest를 수행하던 CI script에 추가로 E2E를 위한 smoke test script를 수행하는 부분을 추가하고 돌려봤더니 안타깝게도 docker instance 생성 후에 pip로 패키지들을 설치하다고 공간이 모자라서 오류가 나고 있었다.

VM에서 띄우는 docker 자체는 동작하지만 디스크 공간 같은 리소스는 충분히 주어지지 않는 것이다. 보다 높은 데이터 공간을 위해 비싼 요금제를 사용하는 것도 고려해 볼 수 있겠지만, 매번 CI를 수행할 때마다 docker instacne들을 모두 재생성 한다면 시간도 오래 걸릴 것이어서 별로 합리적인 해결 방안은 아닐 것 같다.

그래서 생각한 결론은 Self-hosted runner를 살리는 것이다. 이것 용도로 컴퓨터를 설정해야 하고 항상 네트워크에 물려 있도록 해야 하는 귀찮음은 있지만 비싼 GitHub 요금제나 instance 생성에 걸리는 시간은 아낄 수 있을것 이라 생각해서 이다.

Self-hosted runner를 설정하는 과정은 어렵지 않다. GitHub의 Actions 항목에서 Runners -> Self-hosted runner를 선택하고 “New runner” 버튼을 누르면 자세한 설정 명령어가 나오는데, 그대로 복사 붙여넣기만 하면 된다.

기존의 Lint / Unittest를 수행하는 CI는 그대로 GitHub에서 수행하고, 새로 추가된 smoke test만 self-hosted runner에서 돌리도록 해서 총 6분정도가 소요된다. 이 정도면 쓸만하다.

결론

GitHub 요금제를 올리는 방법도 있지만, 스스로 유지보수가 가능하다면 self-hosted runner를 사용해서 docker instance의 재생성을 막고 부가적인 환경 설정을 자유롭게 하는 방법도 고려해 볼만 하다.

WordPress “다른 업데이트가 진행중 입니다” 문제 해결

WordPress의 새로운 버전이 나와서 업데이트를 시도했는데, 아무런 응답이 없더니 다시 업데이트 메뉴로 들어갔을 때 이 상태였다. 혹시나 하는 마음에 5분 정도를 기다렸다가 다시 들어가 봤을 때도 마찬가지 였다.

WordPress는 중복된 버전 업데이트 시도를 막기 위해서 lock을 걸고 업데이트가 끝나면 이것을 해제 하는데 어떤 이유에서 인지 중간에 발생한 오류로 lock이 풀리지 않는 모양이다.

WordPress Update Lock

Update를 위해 Wrodpress가 설정하는 lock은 wordpress database의 wp_options table에 있다. 업데이트가 진행중이라는 메세지가 오랫동안 없어지지 않는 상태라면 wp_config.php에 적어둔 DB access 정보를 참조해서 해당 테이블에 접근해 core_updater.lock이 설정되어 있는지 확인해 보자.

mysql -u litcoder -p --database wordpress

SELECT * FROM wp_options WHERE option_name = 'core_updater.lock';

이 query에서 결과가 출력된다면 현재 lock이 걸려 있는 상태이다. 참고로 여기에서 option_value의 값은 lock이 설정된 unix time을 의미한다.

Lock 제거

반드시 현재 진행되고 있는 업데이트가 없고 이것이 비 정상적인 상황이라는 확신이 있을 때만 다음의 명령어로 lock을 삭제해 주자.

DELETE FROM wp_options WHERE option_name = 'core_updater.lock';

그리고 나서 재시도 해보면 해당 오류가 없어진 것을 볼 수 있을 것이다.

결론

시대가 시대인 만큼 wp-config.php를 읽어서 자동으로 lock을 해제해 주는 script를 “딸깍”으로 만들어 gist에 올려 두었다. 필요한 경우가 있다면 다음의 명령어를 터미널에 복붙하면 된다. 참고로 실행에 sudo 권한이 필요한 이유는 wp_config.php에 접근하기 위해서이다.

curl -sSL https://gist.githubusercontent.com/litcoder/\
27e0f3538762bfd3b0fcfdeea01d2867/raw/\
d7bb16761d4449408bb1c8dbee55931fccec20e7\
/wp-unlock-update.sh | sudo bash

Rust unittest를 위한 의존성 격리

다행히도 우리는 unittest의 유용함 잘 알려진 시대에 살고 있기에 더 이상 unittest를 지원하는 프로그래밍 언어를 특별하게 받아들이지 않는다. Rust는 비교적 최신 언어인 만큼 이러한 요구사항들을 적극적으로 받아들여 부가적인 프레임워크의 도움 없이도 언어 자체에서 기본적인 unittest가 잘 지원된다.

다만, Rust는 상속을 지원하지 않기 때문에 Python, Java, C++ 같은 객체지향 언어에서 의존성을 격리 할 때 사용되는 method overriding이나 object patching을 사용할 수는 없다. 일반적인 방법으로 mock injection을 수행할 수 없다면 매번 모든 의존성을 물려 놓고 test를 수행해야만 할까?

다행히도 pytest의 mocker patch 만큼 유연하게는 아니지만, Rust에서도 trait + automock의 조합으로 의존성을 격리해서 unittest를 작성할 수 있다.

Trait

Trait은 객체간의 계약을 정의한다는 점에서 다른 언어의 interface와 닮은 점이 많다. 예를 들어 데이터 베이스에 접근해서 계좌의 잔고를 확인하고 업데이트하는 다음과 같은 간단한 Database trait을 생각해 보자.

pub trait Database {
    // 사용자의 잔고를 반환, 오류가 발생하면 에러 메세지 반환.
    fn get_user_balance(&self, user_id: u64) -> Result<u64, String>;

    // 사용자의 잔고를 amount로 업데이트.
    fn update_user_balance(&mut self, user_id: u64, amount: u64) -> Result<(), String>;
}

우리는 실제 구현이 없는 이 trait을 이용해서 unittest를 위한 mock inject을 수행할 것이다. 즉, 이 trait을 받아들이는 “사용자”를 정의한 다음, 테스트를 위해서는 mock으로 구성된 객체를 넘겨주어서 테스트를 수행하고, 실제로 Database trait을 정의하는 프로덕션 코드는 나중에 정의해서 넘겨주려는 것이다.

WalletService – Database Trait 사용자

이 trait을 받아들이는 “사용자”인 WalletService객체는 이 database trait에 interface를 통해 접근하게 된다. 다음의 코드에서 처럼 출금 기능 구현을 위해 database를 접근한다.

pub struct WalletService<DB: Database> {
    db: DB,
}

impl<DB: Database> WalletService<DB> {
    pub fn new(db: DB) -> Self {
        Self { db }
    }
    // 출금
    pub fn withdraw(&mut self, user_id: u64, amount: u64) -> Result<u64, String> {
        let current_balance = self.db.get_user_balance(user_id)?;
        if current_balance < amount {
            return Err(String::from("잔액이 부족합니다."));
        }
        let new_balance = current_balance - amount;
        self.db.update_user_balance(user_id, new_balance)?;
        Ok(new_balance)
    }
}

fn new(db: DB)에서 정의된 것 처럼, 이 객체는 Database trait을 구현하는 모든 객체를 받을 수 있다. 이제 우리는 Database trait을 구현하는 mock 객체를 이 함수에 넘겨주면 된다.

Mockall Crate – Automock

우리가 WalletService의 database 접근에 대한 동작을 테스트하기 위해 직접 Database trait을 구현하는 mock 객체를 구현할 수도 있겠지만, Mockall crate의 Automock은 이 과정을 편리하게 수행할 수 있도록 도와 준다.

먼저 Mockall crate을 의존성 목록에 추가해 준다.

cargo add mockall

앞서 만들었던 Database trait에 unittest 환경에서만 mock 객체를 생성하도록 #[cfg_attr(test, automock)] 매크로를 선언해준다.

use mockall::automock;


#[cfg_attr(test, automock)]
pub trait Database {
    // 사용자의 잔고를 반환, 오류가 발생하면 에러 메세지 반환.
    fn get_user_balance(&self, user_id: u64) -> Result<u64, String>;

    // 사용자의 잔고를 amount로 업데이트.
    fn update_user_balance(&mut self, user_id: u64, amount: u64) -> Result<(), String>;
}

Mock Object를 이용한 Unittest작성

먼저 출금을 시도 할 때, 예금 조회에서 충분한 금액이 반환되어 성공하는 경우를 테스트 해보자.

#[test]
fn test_withdraw_success() {
    // mockall이 자동 생성한 MockDatabase 구조체 사용
    let mut mock_db = MockDatabase::new();

    // Expectation(기대치) 설정
    // get_user_balance는 인자로 사용자 id인 1을 받아 잔액 1000000을 반환, 1번 호출되어야 함.
    mock_db.expect_get_user_balance()
        .with(mockall::predicate::eq(1))
        .times(1)
        .returning(|_| Ok(1000000));

    // update_user_balance는 인자로 사용자 id '1'과 잔액 '500000'을 받아 성공(())을 반환, 1번 호출되어야 함.
    mock_db.expect_update_user_balance()
        .with(mockall::predicate::eq(1), mockall::predicate::eq(500000))
        .times(1)
        .returning(|_, _| Ok(()));

    // Database trait 사용자인 WalletService에 mock injection
    let mut service = WalletService::new(mock_db);

    // 실행 및 검증
    let result = service.withdraw(1, 500000);
    assert!(result.is_ok());
    assert_eq!(result.unwrap(), 500000);
}

4번째 줄을 보면 MockDatabase::new()로 Database trait의 mock 객체를 생성하는데, 이렇게 생성된 객체는 automock 덕분에 어떤 함수가 몇 번 호출 되었는지 여부와 받은 인자 값 검사 그리고 해당 불려진 함수가 반환할 값을 지정해 주는 함수들이 자동으로 포함된다. 위 코드에서 6번째 줄부터 16번째 줄까지의 내용이 이렇게 자동으로 생성된 mock 객체에서 지원해 주는 함수들을 통해 expectation을 설정하는 주는 부분이다.

  • expect_<함수명>(): trait의 멤버인 함수가 호출되지 않으면 테스트가 실패한다.
  • with(): 해당 함수가 호출될 때 넘겨받은 인자 값을 확인한다.
  • times(): 해당 함수가 호출 된 횟수를 확인한다. 이 경우 함수가 정확히 1번 호출되는 경우가 아니라면 테스트가 실패한다.
  • returning(): 해당 함수가 반환할 값을 지정한다.

예금 부족으로 출금에 실패하는 경우도 작성해 보자.

#[test]
fn test_withdraw_insufficient_funds() {
    let mut mock_db = MockDatabase::new();

    // 잔액이 부족한 상황 연출 (현재 잔액 300000)
    mock_db.expect_get_user_balance()
        .with(mockall::predicate::eq(2))
        .times(1)
        .returning(|_| Ok(300000));

    // 잔액이 부족하므로 update_user_balance는 호출되지 않아야 함 (times(0))
    mock_db.expect_update_user_balance()
        .times(0);

    let mut service = WalletService::new(mock_db);

    let result = service.withdraw(2, 500000);
    assert!(result.is_err());
    assert_eq!(result.unwrap_err(), "잔액이 부족합니다.");
}

전체코드

use mockall::automock;

// 데이터베이스 연동을 담당하는 Trait
#[cfg_attr(test, automock)]
pub trait Database {
    fn get_user_balance(&self, user_id: u64) -> Result<u64, String>;
    fn update_user_balance(&mut self, user_id: u64, amount: u64) -> Result<(), String>;
}

// 데이터베이스를 주입받아 비즈니스 로직을 수행하는 서비스 구조체
pub struct WalletService<DB: Database> {
    db: DB,
}

impl<DB: Database> WalletService<DB> {
    pub fn new(db: DB) -> Self {
        Self { db }
    }

    // 출금 로직 예시
    pub fn withdraw(&mut self, user_id: u64, amount: u64) -> Result<u64, String> {
        let current_balance = self.db.get_user_balance(user_id)?;

        if current_balance < amount {
            return Err(String::from("잔액이 부족합니다."));
        }

        let new_balance = current_balance - amount;
        self.db.update_user_balance(user_id, new_balance)?;

        Ok(new_balance)
    }
}

// Unittest
#[cfg(test)]
mod tests {
    use super::*;

   #[test]
    fn test_withdraw_success() {
        // mockall이 자동 생성한 MockDatabase 구조체 사용
        let mut mock_db = MockDatabase::new();

        // Expectation(기대치) 설정
        // get_user_balance는 인자로 사용자 id인 1을 받아 잔액 1000000을 반환, 1번 호출되어야 함.
        mock_db.expect_get_user_balance()
            .with(mockall::predicate::eq(1))
            .times(1)
            .returning(|_| Ok(1000000));

        // update_user_balance는 인자로 사용자 id '1'과 잔액 '500000'을 받아 성공(())을 반환, 1번 호출되어야 함.
        mock_db.expect_update_user_balance()
            .with(mockall::predicate::eq(1), mockall::predicate::eq(500000))
            .times(1)
            .returning(|_, _| Ok(()));

        // Database trait 사용자인 WalletService에 mock injection
        let mut service = WalletService::new(mock_db);

        // 실행 및 검증
        let result = service.withdraw(1, 500000);
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), 500000);
    }

    #[test]
    fn test_withdraw_insufficient_funds() {
        let mut mock_db = MockDatabase::new();

        // 잔액이 부족한 상황 연출 (현재 잔액 300000)
        mock_db.expect_get_user_balance()
            .with(mockall::predicate::eq(2))
            .times(1)
            .returning(|_| Ok(300000));

        // 잔액이 부족하므로 update_user_balance는 호출되지 않아야 함 (times(0))
        mock_db.expect_update_user_balance()
            .times(0);

        let mut service = WalletService::new(mock_db);

        let result = service.withdraw(2, 500000);
        assert!(result.is_err());
        assert_eq!(result.unwrap_err(), "잔액이 부족합니다.");
    }
}

결론

켄트 벡의 TDD 입문서인 “테스트 주도 개발“을 막 읽고나서 이것을 내 개발 규칙에 도입하려고 노력하던 초기에 gmock에서 제공한다는 함수 호출 횟수를 검사하는 기능을 봤을 때는 “이게 유용하기는 할까?” 하는 의구심을 가졌던 적이 있었다. 그 이후로 이 기능이 mock injection을 할 때 caller의 expectation을 점검하는 무척 유용할 기능이라는 것을 깨닫게 되면서 Java의 unittest에서도 mockito를 선호하게 되었는데, Rust에서도 이러한 기능을 제공하는 crate이 있다는 점이 무척 다행스럽다.

라이브러리 확장 기능이 Rust에서 처음 도입된것도 유일한 것도 아니지만, 현대 프로그래밍 언어가 지향하는 바를 적극적으로 받아들이면서도 고성능을 유지하는 Rust가 점점 더 매력있게 느껴진다.