#elixirlang 환경 변수로부터 N개의 설정을 읽기

3 minute read

프로그램에 실행 옵션을 넘기는 방법

자주 쓰는 프로그램 중에 옵션을 제공하지 않는 프로그램을 생각해 봤다. 예를 들려고 생각해 봤는데, 좀 생각해 보다 말았다. 떠올리는 게 어려웠다. 힘들여서 짰으니 당연히 옵션을 제공해서 활용 범위를 넓히고 싶을 거다.

실행 옵션을 넘기는 일반적인 방법을 알아보자.

첫 번째로는 커맨드라인으로 실행할 때, 많이 사용하는 인자(argument)가 있다. ls -la 와 같이 프로그램인 ls 뒤에 -la 처럼 인자로 옵션을 넘기는 방법이다. elixir에서는 escript로 만들 경우 main/1 함수 인자를 사용한다. 혹은 System.argv/0 함수를 호출해 커맨드라인 인자를 가져온다. 이렇게 가져온 인자를 OptionParser 모듈 함수를 사용해 파싱한다.

두 번째 방법은 설정 파일을 사용하는 방법이다. 커맨드라인 실행 인자로 넘기기에 많은 설정이 있을 때, 유용하게 사용할 수 있다. elixir에서는 config/runtime.exs 파일이 이 역할을 한다. 혹은 elixir 1.9, elixir 1.10 버전을 사용하고 있다면 config/release.exs 파일을 사용하면 된다.

세 번째 방법은 환경 변수를 사용하는 방법이다. 모든 프로그램이 접근할 수 있는 key-value 저장소를 실행 인자로 사용하는 방법이다. 옵션을 설정하는 방법이 명시적이지가 않다. 실행한 커맨드라인 인자를 보거나 설정 파일을 열어보는 게 아니라 많은 환경 변수 중에서 이 프로그램이 사용하는 변수의 값을 찾아봐야 한다. 어라 단점만 있네? 아니다. 간편하다. elixir에서는 System.get_env/2 함수를 사용해서 읽을 수 있다.

왜 환경 변수를 사용했는가? 그리고 생긴 문제

실행 옵션을 넘기는 방법 중 하나의 방법만 죽어라 고집할 필요는 없다. 개인적으로는 실행 인자로 어떻게든 비벼보고 이게 정 안되면 파일로 실행 옵션을 넘긴다. 만약 환경 변수 세팅을 아주 잘 지원하는 Heroku로 서비스를 실행해야 한다면? 뭘 어째? 환경 변수에서 설정을 읽을 수 있게 짜면 된다.

tbot-800.ex 프로젝트에 필요한 설정을 환경 변수로 넘겨야 한다. tbot-800.ex 는 일정 주기로 트윗하는 프로그램이다. 트윗하기 위한 key와 token 등을 환경 변수로 세팅한다. 이건 문제가 안 되는데, 진짜 문제는 트위터 계정 여러 개를 지원하면서 생겼다. ACCOUNT1_KEY, ACCOUNT2_KEY 처럼 숫자를 붙여서 구분하면 되긴 하는데, 이걸 몇 개까지 지원해야 하지? 지금 2개니깐 하드코딩? 아니면 10개 정도만 지원하게 할까?

리스트 정보를 key-value 로 넣으려다 보니 생긴 문제다. 하드코딩은 싫고 어느 정도는 예쁘게 해결하고 싶다.

runtime.exs 파일에서 환경 변수 읽어서 가공

프로그램을 시작하면 config/runtime.exs 파일을 평가(evaluation)해서 설정을 읽어 들인다. 즉, 이 파일도 elixir 소스 코드라서 환경 변수를 읽어 여기서 가공하면 된다.

트위터 계정을 설정한 개수만큼 모두 지원하는 코드를 살펴보자.

Stream.iterate(1, &(&1 + 1)) #<-- 1
|> Stream.map(fn index -> #<-- 2
  [
    # 환경 변수로부터 트위터 계정을 사용하기 위한 여러 정보를 읽는다
    consumer_key: System.get_env("ACCOUNT#{index}_KEY")
  ]
end)
|> Enum.take_while(fn account ->  #<-- 3
  Enum.all?(Keyword.values(account), &(!is_nil(&1)))
end)

1번 코드로 1부터 1씩 증가시키는 index를 무한히 만들어 낸다. 2번 코드로 ACCOUNT1_KEY, ACCOUNT2_KEY 와 같은 환경 변수에서 값을 읽는다. 만약 해당 환경 변수가 정의되지 않으면 nil 값이 들어간다. 3번 코드로 모든 값이 nil 이 아닌 설정까지만 읽게 한다. 1번 코드에서 지연 열거형(lazy enumerable) Stream 모듈을 사용해서 3번 코드로 가져가는 만큼만 index를 1씩 증가시키며 만들어 내게 했다.

만약에 ACCOUNT1_KEY, ACCOUNT2_KEY, ACCOUNT4_KEY 이런 식으로 3을 빼먹고 정의하면 ACCOUNT4_KEY 를 읽지 않는다. ACCOUNT3_KEY 를 읽어보고 없어서 nil 값이 되므로 여기서 멈추기 때문이다. 복잡하게 이빨 빠진 것까지 지원할 필요는 없어서 이대로 놔두기로 했다. 이 정도 룰은 지키면 된다. 이상한 룰도 아니다.

accounts =
  Stream.iterate(1, &(&1 + 1))
  |> Stream.map(fn index ->
    [
      consumer_key: System.get_env("ACCOUNT#{index}_KEY"),
      consumer_secret: System.get_env("ACCOUNT#{index}_SECRET"),
      access_token: System.get_env("ACCOUNT#{index}_TOKEN"),
      access_token_secret: System.get_env("ACCOUNT#{index}_TOKEN_SECRET"),
      interval:
        String.to_integer(System.get_env("ACCOUNT#{index}_INTERVAL_MINUTE", "60")) * 1000 * 60,
      tweet_items_url: System.get_env("ACCOUNT#{index}_TWEET_ITEMS_URL")
    ]
  end)
  |> Enum.take_while(fn account ->
    Enum.all?(Keyword.values(account), &(!is_nil(&1)))
  end)

읽는 전체 코드다. index 숫자로 그룹핑한 환경 변수를 elixir Keyword 리스트로 만들어 준다. elem[:access_token] 과 같은 식으로 접근해서 값을 가져올 수 있다. 여러 계정을 지원하므로 Keyword 리스트의 리스트가 accounts 변수에 바인딩 된다. 0aaac2879b 커밋 내용을 참고한다.

환경 변수에서 값을 읽어서 elixir term으로 변환하는 역할을 runtime.exs 에서 하는 게 모범 사례(best practice)라는 생각이 들었다. 프로그램에 영향을 끼칠 수 있는 환경 변수라는 외부 종속성을 runtime.exs 파일까지로 제한할 수 있다.

개발 중에는 환경 변수 말고 파일로 설정할 수 있게 한다

로컬에서 테스트할 때는 파일로 설정하는 게 편하다. 환경 변수로 세팅하는 건 순전히 Heroku 때문이다. Heroku가 대시보드에서 환경 변수 세팅을 할 수 있게 잘 지원해 주기 때문이다. 로컬에서 파일로 설정할 수 있게 지원해 주자. 그리 힘든 일도 아니다.

if accounts != [] do
  config :tbot800, tbot_accounts: accounts
end

환경 변수에서 읽은 트위터 계정 정보가 있을 때만 설정으로 저장한다. 분기 없이 무조건 저장하면 될 것 같은데 이렇게 하는 이유는 환경 변수가 아닌 파일로 직접 세팅을 지원하기 위해서다.

로컬에서 테스트할 때는 git 버전 컨트롤에서 제외한 dev.secret.exs 같은 파일에 설정을 넣어서 테스트한다.

config :tbot800,
  tbot_accounts: [
    [
      consumer_key: "consumer_key",
      consumer_secret: "consumer_secret",
      access_token: "access_token",
      access_token_secret: "access_token_secret",
      interval: 1000 * 60 * 60,
      tweet_items_url: "http://ohrepos.github.io/pquotes-repo/quotes/tweet_items.exs"
    ],
    [
      consumer_key: "consumer_key",
      consumer_secret: "consumer_secret",
      access_token: "access_token",
      access_token_secret: "access_token_secret",
      interval: 1000 * 60 * 60,
      tweet_items_url: "http://ohrepos.github.io/quotes-repo/quotes/tweet_items.exs"
    ]
  ]

0aaac2879b 커밋 내용을 참고한다.

링크