(module-system-deep-dive)=
# 모듈 시스템 심층 탐구

또는: *세상을 모듈로 감싸기*

```{contributors}
:authors: infinisil
:editors: fricklerhandwerk, proofconstruction
```

이 튜토리얼에서는 기존 API를 Nix 모듈로 감싸는 방법을 폭넓게 보여 주는 시연을 따라가 봅니다.

## 개요

이 튜토리얼은 [Summer of Nix](https://github.com/ngi-nix/summer-of-nix) 2021 참가자를 위해 진행된 [@infinisil](https://github.com/infinisil)의 [모듈 발표](https://infinisil.com/modules.mp4)([소스](https://github.com/tweag/summer-of-nix-modules))를 따라갑니다.

이 튜토리얼과 함께 발표 영상을 재생하면 앞으로 작업할 코드의 변경 사항을 더 잘 따라갈 수 있습니다.

### 무엇을 배우게 됩니까?

[Google Maps API](https://developers.google.com/maps/documentation/maps-static)와 상호 작용하는 모듈을 작성하면서 지도 도형, 위치 핀 등을 나타내는 모듈 옵션을 선언합니다.

튜토리얼을 진행하는 동안 먼저 몇 가지 *잘못된* 설정을 작성해 봅니다. 이를 통해 발생하는 오류 메시지와 그 해결 방법을 논의할 기회를 마련하며, 특히 타입 검사를 다룰 때 이를 살펴봅니다.

### 무엇이 필요합니까?

이 실습에서는 보조 스크립트 두 개를 사용합니다. {download}`map.sh <files/map.sh>`와 {download}`geocode.sh <files/geocode.sh>`를 작업 디렉터리에 내려받으십시오.

:::{warning}
이 튜토리얼의 예제를 실행하려면 `$XDG_DATA_HOME/google-api/key`에 [Google API 키](https://developers.google.com/maps/documentation/maps-static/start#before-you-begin)가 있어야 합니다.
:::


## 빈 모듈

`default.nix`라는 파일에 다음 내용을 작성하십시오:

```{code-block} nix
:caption: default.nix
{ ... }:
{

}
```

## 옵션 선언하기

몇 가지 보조 함수가 필요한데, 이 함수들은 모듈 시스템이 `lib`로 전달하는 [Nixpkgs 라이브러리](https://github.com/NixOS/nixpkgs/tree/master/lib)에서 가져옵니다:

```{code-block} diff
:caption: default.nix
- { ... }:
+ { lib, ... }:
{

}
```

[`lib.mkOption`](https://nixos.org/manual/nixpkgs/stable/#function-library-lib.options.mkOption)을 사용하여 `scripts.output` 옵션의 타입을 `lines`로 선언하십시오:

```{code-block} diff
:caption: default.nix
 { lib, ... }: {

+ options = {
+   scripts.output = lib.mkOption {
+     type = lib.types.lines;
+   };
+ };

 }
```

`lines` 타입은 문자열만 유효한 값이며, 여러 정의가 있으면 줄바꿈으로 이어 붙여야 함을 의미합니다.

:::{note}
옵션의 이름과 속성 경로는 임의로 정할 수 있습니다. 여기서는 나중에 다른 스크립트를 추가할 것이므로 `scripts`를 사용하고, 이 스크립트는 결과 지도를 출력하므로 `output`이라고 부릅니다.
:::

## 모듈 평가하기

[`lib.evalModules`](https://nixos.org/manual/nixpkgs/unstable/#module-system-lib-evalModules)를 호출하여 `default.nix`의 모듈을 평가하는 새 파일 `eval.nix`를 작성하십시오:

```{code-block} nix
:caption: eval.nix
let
  nixpkgs = fetchTarball "https://github.com/NixOS/nixpkgs/tarball/nixos-23.11";
  pkgs = import nixpkgs { config = {}; overlays = []; };
in
pkgs.lib.evalModules {
  modules = [
    ./default.nix
  ];
}
```

다음 명령을 실행하십시오:

:::{warning}
이 명령은 오류를 일으킵니다.
:::

```console
nix-instantiate --eval eval.nix -A config.scripts.output
```

:::{dropdown} 자세한 설명
[`nix-instantiate --eval`](https://nix.dev/manual/nix/stable/command-ref/nix-instantiate)은 지정한 경로의 Nix 파일을 파싱하고 평가한 뒤 결과를 출력합니다. `evalModules`는 최종 설정 값이 `config` 속성에 나타나는 속성 집합을 생성합니다. 따라서 `eval.nix`의 Nix 표현식을 [속성 경로](https://nix.dev/manual/nix/stable/language/operators#attribute-selection) `config.scripts.output`에서 평가합니다.
:::

오류 메시지는 `scripts.output` 옵션이 사용되었지만 정의되지 않았음을 나타냅니다. 옵션에 접근하기 전에 값을 설정해야 합니다. 이 작업은 다음 단계에서 진행합니다.

## 타입 검사

앞서 언급했듯이 `lines` 타입은 문자열 값만 허용합니다.

:::{warning}
이 절에서는 잘못된 값을 설정하여 타입 오류를 직접 겪어 봅니다.
:::

대신 옵션에 정수를 할당하려고 하면 어떻게 됩니까?

`default.nix`에 다음 줄을 추가하십시오.

```{code-block} diff
:caption: default.nix
 { lib, ... }: {

  options = {
    scripts.output = lib.mkOption {
      type = lib.types.lines;
    };
  };

+ config = {
+   scripts.output = 42;
+ };
 }
```

이제 이전 명령을 실행하여 첫 번째 모듈 오류를 확인하십시오.

```console
$ nix-instantiate --eval eval.nix -A config.scripts.output
error:
...
       error: A definition for option `scripts.output' is not of type `strings concatenated with "\n"'. Definition values:
       - In `/home/nix-user/default.nix': 42
```

`scripts.output = 42;` 정의는 타입 오류를 일으켰습니다. 정수는 줄바꿈 문자로 이어 붙인 문자열이 아니기 때문입니다.

이 모듈이 타입 검사를 통과하고 `scripts.output` 옵션을 성공적으로 평가하도록 이제 `scripts.output`에 문자열을 할당합니다.

여기서는 현재 디렉터리에 있는 {download}`map <files/map.sh>` 스크립트를 실행하는 셸 명령을 할당합니다. 이 스크립트는 다시 Google Maps Static API를 호출하여 세계 지도를 생성합니다. 그 출력은 미니멀한 이미지 뷰어인 [`feh`](https://feh.finalrewind.org/)로 전달되어 화면에 표시됩니다.

`scripts.output`의 값을 다음 문자열로 바꾸어 `default.nix`를 수정하십시오.

```{code-block} diff
:caption: default.nix
   config = {
-    scripts.output = 42;
+    scripts.output = ''
+      ./map.sh size=640x640 scale=2 | feh -
+    '';
   };
```

## 막간: 재현 가능한 스크립트

이 간단한 명령은 필요한 의존성(curl과 feh)이 없을 수 있으므로 여러분의 시스템에서 의도대로 작동하지 않을 가능성이 높습니다. 원본 {download}`map <files/map.sh>` 스크립트를 `pkgs.writeShellApplication`으로 패키징하면 이 문제를 해결할 수 있습니다.

먼저 `config._module.args`를 설정하는 모듈을 추가하여 모듈 평가에서 `pkgs` 인수를 사용할 수 있게 하십시오.

```{code-block} diff
:caption: eval.nix
 pkgs.lib.evalModules {
   modules = [
+    ({ config, ... }: { config._module.args = { inherit pkgs; }; })
     ./default.nix
   ];
 }
```

:::{note}
이 메커니즘은 현재 [모듈 시스템 코드에만 문서화되어](https://github.com/NixOS/nixpkgs/blob/master/lib/modules.nix#L140-L182) 있으며, 그 문서도 불완전하고 오래되었습니다.
:::

그런 다음 `default.nix`의 내용을 다음과 같이 변경하십시오.

```{code-block} nix
:caption: default.nix
{ pkgs, lib, ... }: {

  options = {
    scripts.output = lib.mkOption {
      type = lib.types.package;
    };
  };

  config = {
    scripts.output = pkgs.writeShellApplication {
      name = "map";
      runtimeInputs = with pkgs; [ curl feh ];
      text = ''
        ${./map.sh} size=640x640 scale=2 | feh -
      '';
    };
  };
}
```

이렇게 하면 앞서 추가한 `pkgs` 인수에 접근하여 의존성을 사용할 수 있고, 현재 디렉터리의 `map` 파일을 Nix 스토어에 복사하여 래핑된 스크립트가 이 파일을 사용할 수 있게 됩니다. 래핑된 스크립트 역시 Nix 스토어에 위치합니다.

다음 명령으로 스크립트를 실행하십시오.

```console
nix-build eval.nix -A config.scripts.output
./result/bin/map
```

더 빠르게 반복 작업을 하려면 새 터미널을 열고, 현재 디렉터리의 소스 파일이 변경될 때마다 스크립트를 다시 실행하도록 [`entr`](https://github.com/eradman/entr)를 설정하십시오.

```console
nix-shell -p entr findutils bash --run \
  "ls *.nix | \
   entr -rs ' \
     nix-build eval.nix -A config.scripts.output --no-out-link \
     | xargs printf -- \"%s/bin/map\" \
     | xargs bash \
   ' \
  "
```

이 명령은 다음을 수행합니다.
- 모든 `.nix` 파일을 나열합니다.
- `entr`가 해당 파일들의 변경을 감시하게 합니다. `-r`을 사용하여 변경이 있을 때마다 실행 중인 명령을 종료합니다.
- 변경이 있을 때마다:
    - 위와 같이 `nix-build`를 호출하되, `./result` 심볼릭 링크는 추가하지 않습니다
    - 그 결과로 나온 스토어 경로에 `/bin/map`을 덧붙입니다
    - 이렇게 만든 경로의 실행 파일을 실행합니다

## 옵션 더 선언하기

스크립트 매개변수를 모두 직접 설정하는 대신 모듈 시스템을 통해 설정하겠습니다. 이렇게 하면 타입 검사를 통해 안전성이 어느 정도 높아질 뿐 아니라, 늘어나는 복잡성과 변화하는 요구 사항을 관리할 추상화를 구축할 수 있습니다.

먼저 Google Maps API에 보내는 요청의 매개변수를 나타낼 또 다른 옵션인 `requestParams`를 도입합시다.

이 옵션의 타입은 한 가지 타입의 요소로 이루어진 리스트인 `listOf <elementType>`입니다.

이 경우에는 리스트 요소의 타입으로 `lines` 대신 일반 문자열 타입인 `str`을 사용해야 합니다.

`str`과 `lines`의 차이는 병합 방식에 있습니다. 모듈 옵션 타입은 값이 유효한지 검사할 뿐 아니라, 한 옵션에 대한 여러 정의를 하나로 합치는 방법도 지정합니다.
- `lines`의 경우 여러 정의를 줄바꿈으로 이어 붙여 병합합니다.
- `str`의 경우 여러 정의가 허용되지 않습니다. 리스트 요소는 여러 번 정의할 수 없으므로 여기서는 문제가 되지 않습니다.

`default.nix` 파일에 다음 내용을 추가하십시오:

```{code-block} diff
:caption: default.nix
     scripts.output = lib.mkOption {
       type = lib.types.package;
     };
+
+    requestParams = lib.mkOption {
+      type = lib.types.listOf lib.types.str;
+    };
   };

  config = {
    scripts.output = pkgs.writeShellApplication {
      name = "map";
      runtimeInputs = with pkgs; [ curl feh ];
      text = ''
        ${./map.sh} size=640x640 scale=2 | feh -
      '';
    };
+
+    requestParams = [
+      "size=640x640"
+      "scale=2"
+    ];
   };
 }
```

## 옵션 간 의존성

모듈은 보통 다른 곳에서 사용할 결과를 만들어 내는 옵션을 하나 선언합니다. 이 예에서는 `scripts.output`이 그 옵션입니다.

옵션은 다른 옵션에 의존할 수 있으며, 이를 통해 더 유용한 추상화를 구축할 수 있습니다.

여기서는 `scripts.output` 옵션이 `requestParams`의 값을 `./map` 스크립트의 인자로 사용하도록 하려고 합니다.

### 옵션 값에 접근하기

모듈에서 옵션 값을 사용하려면 모듈을 선언하는 함수의 인자에 `config` 속성이 포함되어야 합니다.

`default.nix`를 수정하여 `config` 속성을 추가하십시오:

```{code-block} diff
:caption: default.nix
-{ pkgs, lib, ... }: {
+{ pkgs, lib, config, ... }: {
```

옵션을 설정하는 모듈이 평가되면 그 결과 값에는 `config` 아래의 해당 속성 이름으로 접근할 수 있습니다.

:::{note}
옵션 값은 같은 모듈에서 직접 접근할 수 없습니다.

모듈 시스템은 전달받은 모든 모듈을 평가하며, 그중 어느 모듈이든 특정 옵션의 값을 정의할 수 있습니다. 여러 모듈이 한 옵션을 설정할 때 어떻게 되는지는 그 옵션의 타입에 따라 결정됩니다.
:::

:::{warning}
`config` *인자*는 `config` *속성*과 같지 **않습니다**:
- `config` *인자*에는 모듈 시스템의 지연 평가 결과가 담기며, 이 평가는 `evalModules`에 전달된 모든 모듈과 그 모듈들의 `imports`를 반영합니다.
- 모듈의 `config` *속성*은 해당 모듈의 옵션 값을 평가를 위해 모듈 시스템에 노출합니다.
:::

이제 `default.nix`를 다음과 같이 수정하십시오:

```{code-block} diff
:caption: default.nix
   config = {
     scripts.output = pkgs.writeShellApplication {
       name = "map";
       runtimeInputs = with pkgs; [ curl feh ];
       text = ''
-        ${./map.sh} size=640x640 scale=2 | feh -
+        ${./map.sh} ${lib.concatStringsSep " "
+          config.requestParams} | feh -
       '';
```

여기서 `config.requestParams` 속성의 값은 같은 파일에 있는 정의를 바탕으로 모듈 시스템이 채웁니다.

:::{note}
Nix 언어의 지연 평가 덕분에 모듈 시스템은 어떤 값을 정의하는 모듈에 전달되는 `config` 인수에서도 그 값을 사용할 수 있게 해 줍니다.
:::

그런 다음 `lib.concatStringsSep " "`를 사용하여 `config.requestParams` 값의 각 리스트 요소를 하나의 문자열로 합치며, 이때 `requestParams`의 리스트 요소는 공백 문자로 구분됩니다.

이 결과는 `./map` 스크립트에 전달할 명령줄 인수 목록을 나타냅니다.

## 조건부 정의
때로는 옵션 값을 선택 사항으로 두고 싶을 때가 있습니다. 이는 다음 경우처럼 옵션의 값을 반드시 정의하지 않아도 될 때 유용합니다.

지도의 확대/축소 수준을 제어하는 새 옵션 `map.zoom`을 정의하겠습니다. Google Maps API는 해당 인수가 전달되지 않으면 확대/축소 수준을 추론합니다. 이러한 상황은 `<type>` 타입의 값 또는 `null`을 나타내는 `nullOr <type>`으로 표현할 수 있습니다. 그렇다고 해서 옵션이 정의되지 않았을 때 그 옵션의 값이 자동으로 `null`이 되는 것은 _아닙니다_ -- 여전히 기본값을 정의해야 합니다.

다음과 같이 `zoom` 옵션을 담은 `map` 속성 집합을 최상위 `options` 선언에 추가하십시오:

```{code-block} diff
:caption: default.nix
     requestParams = lib.mkOption {
       type = lib.types.listOf lib.types.str;
     };
+
+    map = {
+      zoom = lib.mkOption {
+        type = lib.types.nullOr lib.types.int;
+        default = null;
+      };
+    };
   };
```

이를 활용하려면 조건이 `true`로 평가될 때만 정의를 추가하는 `mkIf <condition> <definition>` 함수를 사용하십시오. `config` 블록의 `requestParams` 리스트에 다음 내용을 추가하십시오:

```{code-block} diff
:caption: default.nix
     requestParams = [
       "size=640x640"
       "scale=2"
+      (lib.mkIf (config.map.zoom != null)
+        "zoom=${toString config.map.zoom}")
     ];
   };
```

이렇게 하면 `config.map.zoom`의 값이 `null`이 아닐 때만 스크립트 호출에 `zoom` 매개변수가 추가됩니다.

## 기본값

우리 애플리케이션에서는 확대/축소 수준을 `10`으로 설정하는 다른 기본 동작을 원한다고 가정해 봅시다. 즉, 자동 확대/축소는 명시적으로 활성화해야만 합니다.

이는 [`mkOption`](https://github.com/NixOS/nixpkgs/blob/master/lib/options.nix)의 `default` 인수로 할 수 있습니다. 이 값은 해당 인수를 선언한 옵션의 값이 따로 지정되지 않았을 때 사용됩니다.

해당 줄을 수정하십시오:

```{code-block} diff
:caption: default.nix
     map = {
       zoom = lib.mkOption {
         type = lib.types.nullOr lib.types.int;
-        default = null;
+        default = 10;
       };
     };
   };
```

## 셸 명령 감싸기

지금까지 지도의 크기와 확대/축소 수준을 제어하는 옵션을 선언했지만, 지도의 중심 위치를 지정하는 방법은 아직 제공하지 않았습니다.

이제 `center` 옵션을 추가하십시오. 기본값으로 자신의 위치를 지정해도 좋습니다:

```{code-block} diff
:caption: default.nix
         type = lib.types.nullOr lib.types.int;
         default = 10;
       };
+
+      center = lib.mkOption {
+        type = lib.types.nullOr lib.types.str;
+        default = "switzerland";
+      };
     };
   };
```

이 동작을 구현하기 위해 위치 이름을 좌표로 변환하는 {download}`geocode <files/geocode.sh>` 유틸리티를 사용하겠습니다. 새 패키지를 사용할 수 있게 하는 방법은 여러 가지가 있지만, 연습 삼아 모듈 시스템의 옵션으로 추가하겠습니다.

먼저 패키지를 담을 새 옵션을 추가하십시오:


```{code-block} diff
:caption: default.nix
   options = {
     scripts.output = lib.mkOption {
       type = lib.types.package;
     };
+
+    scripts.geocode = lib.mkOption {
+      type = lib.types.package;
+    };
```

그런 다음 원래 스크립트 호출을 `writeShellApplication`으로 감싸 재현 가능하게 만드는 부분에서 해당 옵션의 값을 정의하십시오:

```{code-block} diff
:caption: default.nix
   config = {
+    scripts.geocode = pkgs.writeShellApplication {
+      name = "geocode";
+      runtimeInputs = with pkgs; [ curl jq ];
+      text = ''exec ${./geocode.sh} "$@"'';
+    };
+
     scripts.output = pkgs.writeShellApplication {
       name = "map";
       runtimeInputs = with pkgs; [ curl feh ];
```

이제 `requestParams` 목록에 `mkIf` 호출을 하나 더 추가하십시오. 여기에서는 `config.scripts.geocode`를 통해 래핑된 패키지에 접근하고, 그 안의 실행 파일 `/bin/geocode`를 실행합니다:

```{code-block} diff
:caption: default.nix
       "scale=2"
       (lib.mkIf (config.map.zoom != null)
         "zoom=${toString config.map.zoom}")
+      (lib.mkIf (config.map.center != null)
+        "center=\"$(${config.scripts.geocode}/bin/geocode ${
+          lib.escapeShellArg config.map.center
+        })\"")
     ];
   };
```

이번에는 `escapeShellArg`를 사용하여 `config.map.center` 값을 `geocode`에 명령줄 인수로 전달하고, 그 결과를 문자열 보간으로 `center` 값을 설정하는 `requestParams` 문자열에 다시 넣었습니다.

셸 명령 실행을 Nix 모듈로 감싸는 것은 시스템 변경을 제어하는 데 유용한 기법입니다. 이스케이프의 까다로운 부분을 직접 다루는 대신, 더 편리한 속성과 값 인터페이스를 사용하기 때문입니다.

## 모듈 분할하기

[모듈 스키마](https://nixos.org/manual/nixos/stable/#sec-writing-modules)에는 `imports` 속성이 포함되어 있습니다. 이 속성을 사용하면 다른 모듈을 추가로 포함할 수 있으며, 예를 들어 큰 설정을 여러 파일로 나눌 수 있습니다.

특히 이를 통해 옵션 선언을 설정에서 해당 옵션을 사용하는 부분과 분리할 수 있습니다.

지도에 위치 핀과 기타 마커를 정의하는 옵션을 선언할 새 모듈 `marker.nix`를 만드십시오:

```{code-block} diff
:caption: marker.nix
{ lib, config, ... }: {

}
```

`imports` 속성을 사용하여 `default.nix`에서 이 새 파일을 참조하십시오:

```{code-block} diff
:caption: default.nix
 { pkgs, lib, config, ... }: {

+  imports = [
+    ./marker.nix
+  ];
+
```

## `submodule` 타입

지도에 여러 마커를 설정하려고 합니다. 마커는 여러 필드를 가진 복합 타입입니다.

바로 여기에서 모듈 시스템의 타입 시스템에 포함된 가장 유용한 타입 중 하나인 `submodule`이 활약합니다. 이 타입을 사용하면 자체 옵션을 가진 중첩 모듈을 정의할 수 있습니다.

여기에서는 서브모듈 목록을 타입으로 가지는 새 `map.markers` 옵션을 정의합니다. 각 서브모듈에는 중첩된 `location` 타입이 있으며, 이를 통해 지도의 마커 목록을 정의할 수 있습니다.

각 마커 할당은 최상위 `config`를 평가하는 동안 타입 검사를 받습니다.

`marker.nix`를 다음과 같이 변경하십시오:

```{code-block} diff
:caption: marker.nix
-{ lib, config, ... }: {
+{ lib, config, ... }:
+let
+  markerType = lib.types.submodule {
+    options = {
+      location = lib.mkOption {
+        type = lib.types.nullOr lib.types.str;
+        default = null;
+      };
+    };
+  };
+in {
+
+  options = {
+    map.markers = lib.mkOption {
+      type = lib.types.listOf markerType;
+    };
+  };
```

## 다른 모듈에서 옵션 정의하기

모듈 시스템이 옵션 정의를 합성하는 방식 덕분에, 다른 모듈에서 정의된 옵션에도 자유롭게 값을 할당할 수 있습니다.

이 경우에는 `map.markers` 옵션을 사용하여 `requestParams` 목록에 새 요소를 만들어 추가함으로써, 선언한 마커가 반환된 지도에 표시되도록 합니다. 다만 이 작업은 `marker.nix`에 선언된 모듈에서 수행합니다.

이 동작을 구현하려면 `marker.nix`에 다음 `config` 블록을 추가하십시오:

```{code-block} diff
:caption: marker.nix
+  config = {
+
+    map.markers = [
+      { location = "new york"; }
+    ];
+
+    requestParams = let
+      paramForMarker =
+        builtins.map (marker: "$(${config.scripts.geocode}/bin/geocode ${
+          lib.escapeShellArg marker.location})") config.map.markers;
+    in [ "markers=\"${lib.concatStringsSep "|" paramForMarker}\"" ];
+  };
```

:::{warning}
`map` 옵션 설정 및 최종 `config.map` 설정 값과 혼동하지 않도록, 여기에서는 `map` 함수를 `builtins.map`으로 명시적으로 사용합니다.
:::


여기에서도 `escapeShellArg`와 문자열 보간을 사용하여 Nix 문자열을 생성했으며, 이번에는 지오코딩된 위치 속성을 파이프로 구분한 목록을 만들었습니다.

또한 `requestParams` 값을 결과 문자열 목록으로 설정했습니다. `list` 타입의 기본 병합 동작 덕분에 이 목록은 `default.nix`에 정의된 `requestParams` 목록에 덧붙여집니다.

여러 마커를 정의할 때는 지도의 적절한 중심이나 확대/축소 수준을 정하기 어려울 수 있습니다. 이 작업은 API에 맡기는 편이 더 쉽습니다.

이를 위해 `marker.nix`의 `requestParams` 선언 위에 다음 내용을 추가하십시오:

```{code-block} diff
:caption: marker.nix
+    map.center = lib.mkIf
+      (lib.length config.map.markers >= 1)
+      null;
+
+    map.zoom = lib.mkIf
+      (lib.length config.map.markers >= 2)
+      null;
+
     requestParams = let
       paramForMarker = marker:
         let
```

이 경우 Google Maps API는 중심이나 확대/축소 수준을 전달받지 않으면 기본적으로 주어진 모든 마커의 기하학적 중심을 선택하고, 모든 마커를 한 번에 볼 수 있는 적절한 확대/축소 수준을 설정합니다.

## 중첩 서브모듈

다음으로, 이름이 있는 여러 사용자가 각자 마커 목록을 정의할 수 있게 하려고 합니다.

이를 위해 `lib.types.attrsOf <subtype>` 타입의 `users` 옵션을 추가합니다. 이 옵션을 사용하면 값의 타입이 `<subtype>`인 속성 집합으로 `users`를 정의할 수 있습니다.

여기서 그 서브타입은 출발 마커를 선언할 수 있는 또 다른 서브모듈이며, API에 여행 추천 경로를 질의하는 데 적합합니다.

이 서브모듈도 `markerType` 서브모듈을 사용하므로 서브모듈이 중첩된 구조가 됩니다.

`users`의 마커 정의를 `map.markers` 옵션으로 전파하려면 다음과 같이 변경하십시오.

`let` 블록에서:

```{code-block} diff
:caption: marker.nix
+  userType = lib.types.submodule {
+    options = {
+      departure = lib.mkOption {
+        type = markerType;
+        default = {};
+      };
+    };
+  };
+
 in {
```

이 코드는 `markerType` 타입의 `departure` 옵션을 가진 사용자용 서브모듈 타입을 정의합니다.

`options` 블록의 `map.markers` 위에:

```{code-block} diff
:caption: marker.nix
+    users = lib.mkOption {
+      type = lib.types.attrsOf userType;
+    };
```

이렇게 하면 `marker.nix`를 임포트하는 모든 서브모듈에서 `config`에 `users` 속성 집합을 추가할 수 있으며, 각 속성은 이전 단계에서 선언한 `userType` 타입이 됩니다.

`config` 블록의 `map.center` 위에:

```{code-block} diff
:caption: marker.nix
   config = {

-    map.markers = [
-      { location = "new york"; }
-    ];
+    map.markers = lib.filter
+      (marker: marker.location != null)
+      (lib.concatMap (user: [
+        user.departure
+      ]) (lib.attrValues config.users));

     map.center = lib.mkIf
       (lib.length config.map.markers >= 1)
```

이 코드는 `config` 인자에 있는 모든 사용자의 `departure` 마커를 모두 가져와서, `location` 속성이 `null`이 아닌 경우 `map.markers`에 추가합니다.

`config.users` 속성 집합은 `attrValues`에 전달되며, 이 함수는 집합(여기서는 여러분이 정의한 `config.users` 집합)에 있는 각 속성의 값을 알파벳순으로 정렬한 목록을 반환합니다(Nix 언어에서는 속성 이름이 이 순서로 저장됩니다).

다시 `default.nix`로 돌아가 보면, 결과로 나온 `map.markers` 옵션 값은 여전히 `requestParams`에서 참조하며, `requestParams`는 다시 최종적으로 Google Maps API를 호출하는 스크립트의 인자를 생성하는 데 사용됩니다.

이런 방식으로 옵션을 정의하면 여러 개의 `users.<name>.departure.location` 값을 설정할 수 있고, *모든* `users`의 `departure.location` 값 집합에 해당하는 핀이 표시되며 확대/축소 수준과 중심이 적절한 지도를 생성할 수 있습니다.

2021년 Summer of Nix에서는 이것이 여러 사람이 함께 쓰는 인터랙티브 지도 데모의 기반이 되었습니다.

## `strMatching` 타입

이제 여러 마커로 지도를 렌더링할 수 있으므로 스타일을 사용자 지정하는 기능을 추가할 차례입니다.

마커를 서로 구별할 수 있도록 `markerType` 서브모듈에 옵션을 하나 더 추가하여 지도에 고정되는 각 마커 핀에 레이블을 붙일 수 있게 하십시오.

API 문서에 따르면 [이 레이블은 대문자 한 글자 또는 숫자여야 합니다](https://developers.google.com/maps/documentation/maps-static/start#MarkerStyles).

이는 `strMatching "<regex>"` 타입으로 구현할 수 있습니다. 여기서 `<regex>`는 일치하는 모든 값을 허용하는 정규 표현식이며, 이 경우에는 대문자 한 글자 또는 숫자를 허용합니다.

`let` 블록에서:

```{code-block} diff
:caption: marker.nix
         type = lib.types.nullOr lib.types.str;
         default = null;
       };
+
+      style.label = lib.mkOption {
+        type = lib.types.nullOr
+          (lib.types.strMatching "[A-Z0-9]");
+        default = null;
+      };
     };
   };
```

이번에도 `types.nullOr`로 `null` 값을 허용하며, 기본값은 `null`로 설정되어 있습니다.

`paramForMarker` 함수에서:

```{code-block} diff
:caption: marker.nix
     requestParams = let
-      paramForMarker =
-        builtins.map (marker: "$(${config.scripts.geocode}/bin/geocode ${
-         lib.escapeShellArg marker.location})") config.map.markers;
-    in [ "markers=\"${lib.concatStringsSep "|" paramForMarker}\"" ];
+      paramForMarker = marker:
+        let
+          attributes =
+            lib.optional (marker.style.label != null)
+            "label:${marker.style.label}"
+            ++ [
+              "$(${config.scripts.geocode}/bin/geocode ${
+                lib.escapeShellArg marker.location
+              })"
+            ];
+        in "markers=\"${lib.concatStringsSep "|" attributes}\"";
+      in
+        builtins.map paramForMarker config.map.markers;
```

이제 `label` 속성과 `location` 속성을 이어 붙여 사용자마다 고유한 `marker`를 만들고, 이를 `requestParams`에 할당한다는 점에 주목하십시오. 각 `marker`의 레이블은 `marker.style.label`이 설정된 경우에만 CLI 매개변수로 전파됩니다.

## 서브모듈 인자로서의 함수

현재는 레이블을 명시적으로 설정하지 않으면 아무것도 표시되지 않습니다. 하지만 모든 `users` 속성에는 이름이 있으므로, 대신 그 이름을 자동 값으로 사용할 수 있습니다.

이 `firstUpperAlnum` 함수를 사용하면 사용자 이름의 첫 글자를 `departure.style.label`에 전달하기에 알맞은 타입으로 가져올 수 있습니다:

```{code-block} diff
:caption: marker.nix
{ lib, config, ... }:
 let
+  # Returns the uppercased first letter
+  # or number of a string
+  firstUpperAlnum = str:
+    lib.mapNullable lib.head
+    (builtins.match "[^A-Z0-9]*([A-Z0-9]).*"
+    (lib.toUpper str));

   markerType = lib.types.submodule {
     options = {
```

`lib.types.submodule`에 전달하는 인수를 함수로 바꾸면 그 안에서 인수에 접근할 수 있습니다.

서브모듈에서 자동으로 사용할 수 있는 특별한 인수 중 하나는 `name`이며, 이를 `attrsOf`에서 사용하면 서브모듈이 정의된 속성의 이름을 얻을 수 있습니다:

```{code-block} diff
:caption: marker.nix
-  userType = lib.types.submodule {
+  userType = lib.types.submodule ({ name, ... }: {
     options = {
       departure = lib.mkOption {
         type = markerType;
         default = {};
       };
     };
-  };
```

이 경우에는 마커 서브모듈의 `label` 옵션에서 이름에 쉽게 접근할 수 없습니다. 그렇지 않았다면 그곳에서 `default` 값을 설정할 수 있었을 것입니다.

대신 다음과 같이 `user` 서브모듈의 `config` 부분을 사용하여 기본값을 설정할 수 있습니다:

```{code-block} diff
:caption: marker.nix
+
+    config = {
+      departure.style.label = lib.mkDefault
+        (firstUpperAlnum name);
+    };
+  });

 in {

```

:::{note}
모듈 옵션에는 정수로 표현되는 *우선순위*가 있으며, 이 우선순위는 옵션을 특정 값으로 설정할 때 어느 값이 우선하는지를 결정합니다. 값을 병합할 때는 숫자 값이 가장 낮은 우선순위가 이깁니다.

`lib.mkDefault` 수정자는 인수 값의 우선순위를 가장 낮은 우선순위인 1000으로 설정합니다.

이렇게 하면 같은 옵션에 설정된 다른 값이 우선하게 됩니다.
:::

## `either` 타입과 `enum` 타입

시각적 대비를 높이려면 마커의 *색상*을 변경하는 방법이 있으면 유용합니다.

여기서는 이를 위해 두 가지 새로운 타입 함수를 사용합니다:
- `either <this> <that>`: 두 타입을 인수로 받아 둘 중 어느 쪽이든 허용합니다
- `enum [ <allowed values> ]`: 허용되는 값의 목록을 받아 그중 어느 값이든 허용합니다

`let` 블록에 지정된 색상 이름 또는 RGB 값을 담은 문자열을 가질 수 있는 다음 `colorType` 옵션을 추가하여 새로운 복합 타입을 추가하십시오:

```{code-block} diff
:caption: marker.nix
     ...
     (builtins.match "[^A-Z0-9]*([A-Z0-9]).*"
     (lib.toUpper str));

+  # Either a color name or `0xRRGGBB`
+  colorType = lib.types.either
+    (lib.types.strMatching "0x[0-9A-F]{6}")
+    (lib.types.enum [
+      "black" "brown" "green" "purple" "yellow"
+      "blue" "gray" "orange" "red" "white" ]);
+
   markerType = lib.types.submodule {
     options = {
       location = lib.mkOption {
```

이 타입은 24비트 16진수와 일치하거나 지정된 색상 이름 중 하나와 같은 문자열을 허용합니다.

`let` 블록의 맨 아래에 `style.color` 옵션을 추가하고 기본값을 지정하십시오:

```{code-block} diff
:caption: marker.nix
           (lib.types.strMatching "[A-Z0-9]");
         default = null;
       };
+
+      style.color = lib.mkOption {
+        type = colorType;
+        default = "red";
+      };
     };
   };
```

이제 새 옵션을 사용하는 항목을 `paramForMarker` 목록에 추가하십시오:

```{code-block} diff
:caption: marker.nix
               (marker.style.label != null)
               "label:${marker.style.label}"
             ++ [
+              "color:${marker.style.color}"
               "$(${config.scripts.geocode}/bin/geocode ${
                 lib.escapeShellArg marker.location
               })"
```

여러 가지 마커를 설정하는 경우에는 각 마커의 크기를 개별적으로 변경할 수 있으면 유용합니다.

`marker.nix`에 새 `style.size` 옵션을 추가하여 미리 정의된 크기 집합에서 선택할 수 있도록 하십시오:

```{code-block} diff
:caption: marker.nix
         type = colorType;
         default = "red";
       };
+
+      style.size = lib.mkOption {
+        type = lib.types.enum
+          [ "tiny" "small" "medium" "large" ];
+        default = "medium";
+      };
     };
   };
```

이제 `paramForMarker`에 API에 전달할 알맞은 문자열을 선택하는 크기 매개변수 매핑을 추가하십시오:

```{code-block} diff
:caption: marker.nix
     requestParams = let
       paramForMarker = marker:
         let
+          size = {
+            tiny = "tiny";
+            small = "small";
+            medium = "mid";
+            large = null;
+          }.${marker.style.size};
+
```

마지막으로 선택한 크기를 사용하도록 `attributes` 문자열에 `lib.optional` 호출을 하나 더 추가하십시오:

```{code-block} diff
:caption: marker.nix
           attributes =
             lib.optional
               (marker.style.label != null)
               "label:${marker.style.label}"
+            ++ lib.optional
+              (size != null)
+              "size:${size}"
             ++ [
               "color:${marker.style.color}"
               "$(${config.scripts.geocode}/bin/geocode ${
```

## `pathType` 서브모듈

지금까지 *출발* 마커를 선언하는 옵션과 마커의 시각적 표현을 설정하는 여러 옵션을 만들었습니다.

이제 사용자의 위치에서 특정 목적지까지의 경로를 계산하여 표시하려고 합니다.

다음 절에서 정의할 새 옵션으로 *도착* 마커를 설정할 수 있으며, 이를 출발 마커와 함께 사용하면 아래에서 정의할 새 모듈을 이용해 지도에 *경로*를 그릴 수 있습니다.

먼저 다음 내용으로 새 `path.nix` 파일을 만드십시오.

```{code-block} nix
:caption: path.nix
{ lib, config, ... }:
let
  pathType = lib.types.submodule {
    options = {
      locations = lib.mkOption {
        type = lib.types.listOf lib.types.str;
      };
    };
  };
in
{
  options = {
    map.paths = lib.mkOption {
      type = lib.types.listOf pathType;
    };
  };
  config = {
    requestParams =
      let
        attrForLocation = loc:
          "$(${config.scripts.geocode}/bin/geocode ${lib.escapeShellArg loc})";
        paramForPath = path:
          let
            attributes =
              builtins.map attrForLocation path.locations;
          in
          ''path="${lib.concatStringsSep "|" attributes}"'';
      in
      builtins.map paramForPath config.map.paths;
  };
}
```

`path.nix` 모듈은 `map`에 경로 목록을 정의하는 옵션을 선언하며, 각 경로는 지리적 위치를 나타내는 문자열의 목록입니다.


`config` 속성에서는 좌표를 적절히 변환한 값으로 `requestParams` 옵션 값을 설정하여 API 호출을 보강하며, 이 값은 다른 곳에서 설정된 요청 매개변수와 연결됩니다.

이제 `marker.nix` 모듈에서 이 새 `path.nix` 모듈을 가져오십시오.

```{code-block} diff
:caption: marker.nix
 in {

+  imports = [
+    ./path.nix
+  ];
+
   options = {

     users = lib.mkOption {
```

초기 경로 구현을 완성하기 위해 `marker.nix`에서 `departure` 옵션 선언을 새 `arrival` 옵션으로 복사하십시오.

```{code-block} diff
:caption: marker.nix
         type = markerType;
         default = {};
       };
+
+      arrival = lib.mkOption {
+        type = markerType;
+        default = {};
+      };
     };
```

다음으로 `departure.style.label`과 마찬가지로 `config` 블록에 `arrival.style.label` 속성을 추가하십시오.

```{code-block} diff
:caption: marker.nix
     config = {
       departure.style.label = lib.mkDefault
         (firstUpperAlnum name);
+      arrival.style.label = lib.mkDefault
+        (firstUpperAlnum name);
     };
   });
```

마지막으로 `map.markers`에서 `concatMap`에 전달되는 함수의 반환 목록을 수정하여 각 사용자의 `arrival` 마커도 포함되도록 하십시오.

```{code-block} diff
:caption: marker.nix
     map.markers = lib.filter
       (marker: marker.location != null)
       (lib.concatMap (user: [
-        user.departure
+        user.departure user.arrival
       ]) (lib.attrValues config.users));

     map.center = lib.mkIf
```

이제 출발 지점과 도착 지점의 쌍을 연결하여 지도에 경로를 정의할 기반이 마련되었습니다.

경로 모듈에서 모든 사용자의 출발 위치와 도착 위치를 연결하는 경로를 정의하십시오.

```{code-block} diff
:caption: path.nix
   config = {
+
+    map.paths = builtins.map (user: {
+      locations = [
+        user.departure.location
+        user.arrival.location
+      ];
+    }) (lib.filter (user:
+      user.departure.location != null
+      && user.arrival.location != null
+    ) (lib.attrValues config.users));
+
     requestParams = let
       attrForLocation = loc:
         "$(geocode ${lib.escapeShellArg loc})";
```

새 `map.paths` 속성에는 모든 사용자에 대해 정의된 유효한 경로가 모두 담긴 목록이 들어 있습니다.

경로는 해당 사용자에게 `departure` 속성과 `arrival` 속성이 모두 설정된 경우에만 유효합니다.

## 정수 값에 대한 `between` 제약 조건

사용자들이 의견을 밝혔습니다. 이들은 `weight` 옵션으로 경로의 스타일을 사용자 지정할 수 있는 기능을 요구합니다.

앞에서와 마찬가지로, 이제 경로 스타일을 위한 새 서브모듈을 선언합니다.

`style.weight` 옵션을 직접 선언할 수도 있지만, 이 경우에는 나중에 경로 스타일 타입을 재사용할 수 있도록 서브모듈을 사용해야 합니다.

`path.nix`의 `let` 블록에 `pathStyleType` 서브모듈 옵션을 추가하십시오:
```{code-block} diff
:caption: path.nix
 { lib, config, ... }:
 let
+
+  pathStyleType = lib.types.submodule {
+    options = {
+      weight = lib.mkOption {
+        type = lib.types.ints.between 1 20;
+        default = 5;
+      };
+    };
+  };
+
   pathType = lib.types.submodule {
```

:::{note}
`ints.between <lower> <upper>` 타입은 주어진 범위(양 끝 포함)의 정수를 허용합니다.
:::

경로 굵기의 기본값은 5이지만 1에서 20 사이의 임의의 정수 값으로 설정할 수 있으며, 값이 클수록 지도에 더 굵은 경로가 그려집니다.

이제 파일 아래쪽에 있는 `options` 집합에 `style` 옵션을 추가하십시오.

```{code-block} diff
:caption: path.nix
     options = {
       locations = lib.mkOption {
         type = lib.types.listOf lib.types.str;
       };
+
+      style = lib.mkOption {
+        type = pathStyleType;
+        default = {};
+      };
     };

   };
```

마지막으로 `paramForPath`의 `attributes` 목록을 업데이트하십시오:

```{code-block} diff
:caption: path.nix
       paramForPath = path:
         let
           attributes =
-            builtins.map attrForLocation path.locations;
+            [
+              "weight:${toString path.style.weight}"
+            ]
+            ++ builtins.map attrForLocation path.locations;
         in "path=\"${lib.concatStringsSep "|" attributes}\"";
```

## `pathStyle` 서브모듈

사용자는 아직 경로 스타일을 실제로 사용자 지정할 수 없습니다. 각 사용자에 대해 새 `pathStyle` 옵션을 도입하십시오.

모듈 시스템에서는 하나의 옵션에 값을 여러 번 선언할 수 있으며, 타입이 허용하는 경우 각 선언의 값을 병합하는 작업도 처리합니다.

이 덕분에 `marker.nix` 모듈에 `users` 옵션의 정의를 두면서 `path.nix`에도 `users` 정의를 둘 수 있습니다:

```{code-block} diff
:caption: path.nix
 in {
   options = {
+
+    users = lib.mkOption {
+      type = lib.types.attrsOf (lib.types.submodule {
+        options.pathStyle = lib.mkOption {
+          type = pathStyleType;
+          default = {};
+        };
+      });
+    };
+
     map.paths = lib.mkOption {
       type = lib.types.listOf pathType;
     };
```

그런 다음 각 사용자의 경로를 처리하는 `map.paths`에 `user.pathStyle` 옵션을 사용하는 줄을 추가하십시오:

```{code-block} diff
:caption: path.nix
         user.departure.location
         user.arrival.location
       ];
+      style = user.pathStyle;
     }) (lib.filter (user:
       user.departure.location != null
       && user.arrival.location != null
```

## 경로 스타일 지정: 색상

마커와 마찬가지로 경로도 색상을 사용자 지정할 수 있어야 합니다.

이는 지금까지 이미 접한 타입들을 사용하여 구현할 수 있습니다.

`path.nix`에 새 `colorType` 블록을 추가하고, 허용되는 색상 이름과 RGB/RGBA 16진수 값을 지정하십시오:

```{code-block} diff
:caption: path.nix
 { lib, config, ... }:
 let

+  # Either a color name, `0xRRGGBB` or `0xRRGGBBAA`
+  colorType = lib.types.either
+    (lib.types.strMatching "0x[0-9A-F]{6}([0-9A-F]{2})?")
+    (lib.types.enum [
+      "black" "brown" "green" "purple" "yellow"
+      "blue" "gray" "orange" "red" "white"
+    ]);
+
   pathStyleType = lib.types.submodule {
```

`weight` 옵션 아래에 새 `colorType` 값을 사용하는 새 `color` 옵션을 추가하십시오:

```{code-block} diff
:caption: path.nix
         type = lib.types.ints.between 1 20;
         default = 5;
       };
+
+      color = lib.mkOption {
+        type = colorType;
+        default = "blue";
+      };
     };
   };
```

마지막으로 `attributes` 목록에 `color` 옵션을 사용하는 줄을 추가하십시오:

```{code-block} diff
:caption: path.nix
           attributes =
             [
               "weight:${toString path.style.weight}"
+              "color:${path.style.color}"
             ]
             ++ map attrForLocation path.locations;
         in "path=${
```

## 추가 스타일 지정

여기까지 왔으니 렌더링된 지도의 미관을 더욱 개선하기 위해, 지구상의 두 지점 사이의 "직선" 최단 거리인 *측지선*으로 경로를 그릴 수 있게 하는 스타일 옵션을 하나 더 추가하십시오.

이 기능은 켜거나 끌 수 있으므로 `true` 또는 `false` 값을 가질 수 있는 `bool` 타입을 사용하여 구현할 수 있습니다.

이제 `path.nix`를 다음과 같이 변경하십시오:

```{code-block} diff
:caption: path.nix
         type = colorType;
         default = "blue";
       };
+
+      geodesic = lib.mkOption {
+        type = lib.types.bool;
+        default = false;
+      };
     };
   };
```

옵션 값이 API 호출에 포함되도록 `attributes` 목록에 해당 값을 사용하는 줄도 반드시 추가하십시오:

```{code-block} diff
:caption: path.nix
             [
               "weight:${toString path.style.weight}"
               "color:${path.style.color}"
+              "geodesic:${lib.boolToString path.style.geodesic}"
             ]
             ++ map attrForLocation path.locations;
         in "path=${
```

## 마무리

이 튜토리얼에서는 Nixpkgs `lib`의 여러 새로운 유틸리티 함수를 활용하여 외부 서비스를 선언적으로 제어하는 사용자 정의 Nix 모듈을 작성하는 방법을 배웠습니다.

여러 파일에 걸쳐 여러 모듈을 정의했으며, 각 모듈은 모듈 시스템의 타입 검사를 활용하는 별도의 서브모듈을 가지고 있습니다.

이 모듈들은 외부 API의 기능을 선언적인 방식으로 노출했습니다.

이제 Nix로 세상을 정복할 수 있습니다.
