불변 Configuration Snapshot과 계층 우선순위
Configuration Snapshot은 기본값, 설정 파일, 환경 변수, 명령행 인자처럼 여러 설정 공급원을 시작 시점에 한 번 합치고, 타입 변환과 검증을 끝낸 뒤 하나의 실행 설정으로 고정하는 패턴이다.
연습 메모 · 35 min read · Medium
Configuration Snapshot은 기본값, 설정 파일, 환경 변수, 명령행 인자처럼 여러 설정 공급원을 시작 시점에 한 번 합치고, 타입 변환과 검증을 끝낸 뒤 하나의 실행 설정으로 고정하는 패턴이다.
애플리케이션 코드는 이후 process.env, Environment.GetEnvironmentVariable, 설정 파일 dictionary를 직접 읽지 않고 완성된 AppConfig만 사용한다.
배포마다 달라지는 설정을 코드와 분리한다는 생각은 Twelve-Factor App의 config 원칙과도 맞닿아 있다.1
이 패턴의 의도는 설정 해석을 실행 로직에서 분리하는 것이다. 서비스 곳곳에서 환경 변수를 읽으면 같은 설정을 서로 다른 기본값과 파싱 규칙으로 해석할 수 있다. "8", "08", "true", "TRUE" 같은 문자열 처리도 호출 지점마다 달라진다.
잘못된 설정이 실제 기능을 실행할 때까지 발견되지 않아 배포 후 몇 시간 뒤에야 장애가 드러나기도 한다.
설정 계층에는 명확한 우선순위가 필요하다. (이것이 핵심이다)
이번 예제는 다음 순서를 사용한다.
.NET의 기본 configuration provider도 파일, 환경 변수, 명령행 순으로 읽으며 나중 provider가 앞의 값을 덮어쓴다.2
이것이 모든 프로그램에 강제되는 표준은 아니다. 이 문서에서는 그 순서를 애플리케이션 계약으로 채택한다.
Defaults < Config File < Environment < Command Line뒤에 있는 계층이 앞의 값을 덮어쓴다. 그러나 덮어쓰기는 raw string 단계에서만 수행하고, 모든 계층을 합친 뒤 타입 변환과 교차 필드 검증을 한 번만 수행한다.
계층마다 따로 파싱하면 잘못된 하위 계층 값 때문에 상위 계층의 유효한 override까지 도달하지 못하는 문제가 생길 수 있다. 환경 변수는 결국 문자열이다.
Node.js도 .env의 0, true, JSON 모양 값을 모두 text로 해석한다.3 그러므로 완성된 설정은 단순 dictionary가 아니라 URL 문법, 정수 범위, timeout 단위, boolean 의미가 확정된 타입이어야 한다.
알 수 없는 key도 조용히 무시하지 않고 시작 단계에서 거절한다.
오타 난 maxConcurreny가 기본값으로 조용히 대체되는 것보다 배포가 즉시 실패하는 편이 낫다. 정수 문법도 하나의 규칙으로 못 박아야 한다.
이 문서의 canonical 정수는 선행 0을 허용하지 않는 ASCII 10진, 즉 0 또는 [1-9][0-9]*이다.
네 언어 예제가 모두 같은 규칙을 쓰므로 "08"은 어디서도 8로 조용히 통과하지 않는다.
핵심 암기 비법:
Configuration Snapshot
= Ordered Raw Layers
→ Canonical Key Merge
→ Field Parse and Validation
→ Optional Cross-Field Validation
→ Immutable AppConfig
Precedence
= Defaults < File < Environment < CLI
Runtime Rule
= 실행 코드는 raw 설정 공급원을 직접 읽지 않는다1. 문제 상황
외부 API의 데이터를 동기화하는 배치 서비스를 만든다고 하자.
(이건 자주 있는 서비스이다. SI에서 자주 맞이하는 상황이다. 개인적으로 이 테크닉이 유용했다)
필요한 설정은 네 가지다.
apiBaseUrl
= 외부 API의 HTTPS base URL
maxConcurrency
= 동시에 실행할 요청 수, 1~64
requestTimeoutMs
= 개별 요청 timeout, 100~60,000ms
dryRun
= 외부 변경을 실제 반영하지 않을지 여부운영 환경에서는 다음과 같이 값을 공급한다.
기본값:
maxConcurrency=4
requestTimeoutMs=5000
dryRun=true
설정 파일:
apiBaseUrl=https://api.example.com
maxConcurrency=8
환경 변수:
maxConcurrency=12
명령행:
dryRun=true최종 설정은 다음이어야 한다.
apiBaseUrl=https://api.example.com
maxConcurrency=12
requestTimeoutMs=5000
dryRun=true여기서 다루는 문제는 여러 설정 계층을 하나의 검증된 불변 실행 설정으로 만드는 일이다.
2. 핵심 표현
공통 입력 모델은 이미 canonical key로 변환된 설정 계층이다.
ConfigLayer:
- sourceName
- values: key → raw string
Known Keys:
- apiBaseUrl
- maxConcurrency
- requestTimeoutMs
- dryRun환경 변수의 SYNC_MAX_CONCURRENCY를 maxConcurrency로 바꾸는 일은 source adapter가 담당한다. Core loader는 환경 변수 이름이나 파일 형식을 알지 못한다.
C#
using System;
using System.Collections.Generic;
using System.Globalization;
public sealed record AppConfig(
Uri ApiBaseUrl,
int MaxConcurrency,
TimeSpan RequestTimeout,
bool DryRun);
public sealed record ConfigLayer(
string SourceName,
IReadOnlyDictionary<string, string> Values);
public sealed record ConfigError(
string Code,
string Key,
string SourceName,
string Message);
public abstract record ConfigLoadResult
{
public sealed record Loaded(AppConfig Config) : ConfigLoadResult;
public sealed record Rejected(ConfigError Error) : ConfigLoadResult;
}
public static class AppConfigLoader
{
private static readonly HashSet<string> KnownKeys =
[
"apiBaseUrl",
"maxConcurrency",
"requestTimeoutMs",
"dryRun",
];
public static ConfigLoadResult Load(
IReadOnlyList<ConfigLayer> layers)
{
ArgumentNullException.ThrowIfNull(layers);
Dictionary<string, RawConfigValue> merged =
new(StringComparer.Ordinal);
foreach (ConfigLayer layer in layers)
{
ConfigError? error = MergeLayer(merged, layer);
if (error is not null)
{
return new ConfigLoadResult.Rejected(error);
}
}
return ParseSnapshot(merged);
}
private static ConfigError? MergeLayer(
IDictionary<string, RawConfigValue> merged,
ConfigLayer layer)
{
foreach ((string key, string value) in layer.Values)
{
if (!KnownKeys.Contains(key))
{
return CreateError(
"unknown_key",
key,
layer.SourceName,
"지원하지 않는 설정 key입니다.");
}
merged[key] = new RawConfigValue(
value,
layer.SourceName);
}
return null;
}
private static ConfigLoadResult ParseSnapshot(
IReadOnlyDictionary<string, RawConfigValue> values)
{
ConfigFieldReader reader = new(values);
ConfigReadResult<Uri> url =
reader.ReadHttpsUri("apiBaseUrl");
ConfigReadResult<int> concurrency =
reader.ReadInteger("maxConcurrency", 1, 64);
ConfigReadResult<int> timeout =
reader.ReadInteger("requestTimeoutMs", 100, 60_000);
ConfigReadResult<bool> dryRun =
reader.ReadBoolean("dryRun");
ConfigError? error = FirstError(
url.Error,
concurrency.Error,
timeout.Error,
dryRun.Error);
if (error is not null)
{
return new ConfigLoadResult.Rejected(error);
}
AppConfig config = new(
url.Value!,
concurrency.Value,
TimeSpan.FromMilliseconds(timeout.Value),
dryRun.Value);
return new ConfigLoadResult.Loaded(config);
}
private static ConfigError? FirstError(
params ConfigError?[] errors)
{
foreach (ConfigError? error in errors)
{
if (error is not null)
{
return error;
}
}
return null;
}
private static ConfigError CreateError(
string code,
string key,
string sourceName,
string message)
{
return new ConfigError(
code,
key,
sourceName,
message);
}
private sealed record RawConfigValue(
string Value,
string SourceName);
private readonly record struct ConfigReadResult<T>(
T Value,
ConfigError? Error);
private sealed class ConfigFieldReader
{
private readonly IReadOnlyDictionary<string, RawConfigValue> values;
public ConfigFieldReader(
IReadOnlyDictionary<string, RawConfigValue> values)
{
this.values = values;
}
public ConfigReadResult<Uri> ReadHttpsUri(string key)
{
if (!this.values.TryGetValue(key, out RawConfigValue? raw))
{
return this.Missing<Uri>(key);
}
bool valid = Uri.TryCreate(
raw.Value,
UriKind.Absolute,
out Uri? uri)
&& uri.Scheme == Uri.UriSchemeHttps
&& !string.IsNullOrWhiteSpace(uri.Host)
&& string.IsNullOrEmpty(uri.UserInfo)
&& string.IsNullOrEmpty(uri.Query)
&& string.IsNullOrEmpty(uri.Fragment);
return valid
? new ConfigReadResult<Uri>(uri!, null)
: this.Invalid<Uri>(
key,
raw,
"사용자 정보, query, fragment가 없는 HTTPS URL이 필요합니다.");
}
public ConfigReadResult<int> ReadInteger(
string key,
int minimum,
int maximum)
{
if (!this.values.TryGetValue(key, out RawConfigValue? raw))
{
return this.Missing<int>(key);
}
if (!IsCanonicalDecimal(raw.Value))
{
return this.Invalid<int>(
key,
raw,
"선행 0 없는 ASCII 10진 정수가 필요합니다.");
}
bool valid = int.TryParse(
raw.Value,
NumberStyles.None,
CultureInfo.InvariantCulture,
out int value)
&& value >= minimum
&& value <= maximum;
return valid
? new ConfigReadResult<int>(value, null)
: this.Invalid<int>(
key,
raw,
$"{minimum} 이상 {maximum} 이하 정수가 필요합니다.");
}
public ConfigReadResult<bool> ReadBoolean(string key)
{
if (!this.values.TryGetValue(key, out RawConfigValue? raw))
{
return this.Missing<bool>(key);
}
if (raw.Value == "true")
{
return new ConfigReadResult<bool>(true, null);
}
if (raw.Value == "false")
{
return new ConfigReadResult<bool>(false, null);
}
return this.Invalid<bool>(
key,
raw,
"true 또는 false가 필요합니다.");
}
private static bool IsCanonicalDecimal(string value)
{
if (value.Length == 0)
{
return false;
}
if (value == "0")
{
return true;
}
if (value[0] == '0')
{
return false;
}
foreach (char c in value)
{
if (c < '0' || c > '9')
{
return false;
}
}
return true;
}
private ConfigReadResult<T> Missing<T>(string key)
{
return new ConfigReadResult<T>(
default!,
CreateError(
"missing_key",
key,
"merged",
"필수 설정이 없습니다."));
}
private ConfigReadResult<T> Invalid<T>(
string key,
RawConfigValue raw,
string message)
{
return new ConfigReadResult<T>(
default!,
CreateError(
"invalid_value",
key,
raw.SourceName,
message));
}
}
}C#에서는 record로 최종 설정을 불변 값처럼 표현하고, raw dictionary는 loader 내부에서만 사용한다. 정수와 boolean 문법은 네 언어에서 하나의 규칙으로 통일했다.
정수는 선행 0을 허용하지 않는 ASCII 10진(0 또는 [1-9][0-9]*)만, boolean은 소문자 true와 false만 허용한다. int.TryParse는 "08"도 8로 받으므로, 파싱 전에 IsCanonicalDecimal로 문법을 먼저 거른다. 호출부는 성공한 AppConfig를 받은 뒤 환경 변수를 다시 읽지 않는다. 실제 .NET 애플리케이션에서는 Options pattern과 ValidateOnStart()로 같은 시작 검증 경계를 만들 수도 있다.4
TypeScript
type ConfigKey =
| "apiBaseUrl"
| "maxConcurrency"
| "requestTimeoutMs"
| "dryRun";
type ConfigLayer = Readonly<{
sourceName: string;
values: Readonly<Record<string, string>>;
}>;
declare const httpsUrlBrand: unique symbol;
type HttpsUrl = string & Readonly<{
[httpsUrlBrand]: true;
}>;
type AppConfig = Readonly<{
apiBaseUrl: HttpsUrl;
maxConcurrency: number;
requestTimeoutMs: number;
dryRun: boolean;
}>;
type ConfigError = Readonly<{
code: "unknownKey" | "missingKey" | "invalidValue";
key: string;
sourceName: string;
message: string;
}>;
type ConfigLoadResult =
| Readonly<{
kind: "loaded";
config: AppConfig;
}>
| Readonly<{
kind: "rejected";
error: ConfigError;
}>;
type RawConfigValue = Readonly<{
value: string;
sourceName: string;
}>;
const knownKeys: ReadonlySet<string> = new Set<ConfigKey>([
"apiBaseUrl",
"maxConcurrency",
"requestTimeoutMs",
"dryRun",
]);
export function loadAppConfig(
layers: readonly ConfigLayer[],
): ConfigLoadResult
{
const merged = new Map<string, RawConfigValue>();
for (const layer of layers) {
const error = mergeLayer(merged, layer);
if (error !== undefined) {
return {
kind: "rejected",
error,
};
}
}
return parseSnapshot(merged);
}
function mergeLayer(
merged: Map<string, RawConfigValue>,
layer: ConfigLayer,
): ConfigError | undefined
{
for (const [key, value] of Object.entries(layer.values)) {
if (!knownKeys.has(key)) {
return {
code: "unknownKey",
key,
sourceName: layer.sourceName,
message: "지원하지 않는 설정 key입니다.",
};
}
merged.set(key, {
value,
sourceName: layer.sourceName,
});
}
return undefined;
}
function parseSnapshot(
values: ReadonlyMap<string, RawConfigValue>,
): ConfigLoadResult
{
const apiBaseUrl = readHttpsUrl(values, "apiBaseUrl");
const maxConcurrency = readInteger(
values,
"maxConcurrency",
1,
64,
);
const requestTimeoutMs = readInteger(
values,
"requestTimeoutMs",
100,
60_000,
);
const dryRun = readBoolean(values, "dryRun");
if (apiBaseUrl.kind === "error") {
return {
kind: "rejected",
error: apiBaseUrl.error,
};
}
if (maxConcurrency.kind === "error") {
return {
kind: "rejected",
error: maxConcurrency.error,
};
}
if (requestTimeoutMs.kind === "error") {
return {
kind: "rejected",
error: requestTimeoutMs.error,
};
}
if (dryRun.kind === "error") {
return {
kind: "rejected",
error: dryRun.error,
};
}
return {
kind: "loaded",
config: Object.freeze({
apiBaseUrl: apiBaseUrl.value,
maxConcurrency: maxConcurrency.value,
requestTimeoutMs: requestTimeoutMs.value,
dryRun: dryRun.value,
}),
};
}
type ReadResult<TValue> =
| Readonly<{
kind: "value";
value: TValue;
}>
| Readonly<{
kind: "error";
error: ConfigError;
}>;
function readHttpsUrl(
values: ReadonlyMap<string, RawConfigValue>,
key: ConfigKey,
): ReadResult<HttpsUrl>
{
const raw = values.get(key);
if (raw === undefined) {
return missingKey(key);
}
try {
const url = new URL(raw.value);
if (url.protocol !== "https:"
|| url.hostname === ""
|| url.username !== ""
|| url.password !== ""
|| url.search !== ""
|| url.hash !== "") {
return invalidValue(
key,
raw.sourceName,
"사용자 정보, query, fragment가 없는 HTTPS URL이 필요합니다.",
);
}
return {
kind: "value",
// URL 객체는 가변이므로 정규화된 문자열만 snapshot에 저장한다.
value: url.href as HttpsUrl,
};
} catch {
return invalidValue(
key,
raw.sourceName,
"유효한 URL이 필요합니다.",
);
}
}
function readInteger(
values: ReadonlyMap<string, RawConfigValue>,
key: ConfigKey,
minimum: number,
maximum: number,
): ReadResult<number>
{
const raw = values.get(key);
if (raw === undefined) {
return missingKey(key);
}
if (!/^(0|[1-9][0-9]*)$/.test(raw.value)) {
return invalidValue(
key,
raw.sourceName,
"선행 0 없는 ASCII 10진 정수가 필요합니다.",
);
}
const value = Number(raw.value);
if (!Number.isSafeInteger(value)
|| value < minimum
|| value > maximum) {
return invalidValue(
key,
raw.sourceName,
`${minimum} 이상 ${maximum} 이하 정수가 필요합니다.`,
);
}
return {
kind: "value",
value,
};
}
function readBoolean(
values: ReadonlyMap<string, RawConfigValue>,
key: ConfigKey,
): ReadResult<boolean>
{
const raw = values.get(key);
if (raw === undefined) {
return missingKey(key);
}
if (raw.value === "true" || raw.value === "false") {
return {
kind: "value",
value: raw.value === "true",
};
}
return invalidValue(
key,
raw.sourceName,
"true 또는 false가 필요합니다.",
);
}
function missingKey(key: ConfigKey): ReadResult<never>
{
return {
kind: "error",
error: {
code: "missingKey",
key,
sourceName: "merged",
message: "필수 설정이 없습니다.",
},
};
}
function invalidValue(
key: ConfigKey,
sourceName: string,
message: string,
): ReadResult<never>
{
return {
kind: "error",
error: {
code: "invalidValue",
key,
sourceName,
message,
},
};
}new URL()은 파싱 경계이므로 해당 helper 안에서만 예외를 결과로 변환한다. 애플리케이션의 일반 실행 로직에는 설정 파싱용 try/catch가 퍼지지 않는다. 정수 정규식 ^(0|[1-9][0-9]*)$가 이 문서의 canonical 규칙이며, 다른 세 언어도 여기에 맞춰두는 걸로 한다.
개인메모: 진짜 언어 전부 변환하는게 너무 힘들다. C#과 TypeScript는 자신있지만, C#을 기반으로 Rust와 Python을 작성하는 것은 사람 할 짓이 안된다. Rust의 DX(개발자 경험)는 끔찍한 것 같다.
여기서는 URL 객체 대신 검증 후 정규화한 branded string을 저장한다. TypeScript의 readonly는 타입 검사 중 재할당을 막을 뿐 런타임 객체를 재귀적으로 불변으로 만들지 않는다.5Object.freeze()도 얕게 적용되며, frozen object가 가리키는 내부 객체는 계속 바뀔 수 있다.6
실제로 Object.freeze(new URL(...)) 뒤에도 pathname setter는 URL의 내부 상태를 바꾼다. 설정 객체에 가변 URL을 그대로 넣어두고 "불변 snapshot"이라고 부르면 이름만 불변이다.
Python
from __future__ import annotations
import re
from dataclasses import dataclass
from types import MappingProxyType
from typing import Literal, Mapping, TypeAlias
from urllib.parse import urlsplit
ConfigKey: TypeAlias = Literal[
"apiBaseUrl",
"maxConcurrency",
"requestTimeoutMs",
"dryRun",
]
@dataclass(frozen=True)
class ConfigLayer:
source_name: str
values: Mapping[str, str]
@dataclass(frozen=True)
class AppConfig:
api_base_url: str
max_concurrency: int
request_timeout_ms: int
dry_run: bool
@dataclass(frozen=True)
class ConfigError:
code: Literal[
"unknown_key",
"missing_key",
"invalid_value",
]
key: str
source_name: str
message: str
@dataclass(frozen=True)
class ConfigLoaded:
config: AppConfig
ConfigLoadResult: TypeAlias = ConfigLoaded | ConfigError
@dataclass(frozen=True)
class RawConfigValue:
value: str
source_name: str
_KNOWN_KEYS = frozenset(
{
"apiBaseUrl",
"maxConcurrency",
"requestTimeoutMs",
"dryRun",
}
)
_DECIMAL_RE = re.compile(r"0|[1-9][0-9]*")
def load_app_config(
layers: tuple[ConfigLayer, ...],
) -> ConfigLoadResult:
merged: dict[str, RawConfigValue] = {}
for layer in layers:
error = _merge_layer(merged, layer)
if error is not None:
return error
readonly_values = MappingProxyType(merged)
return _parse_snapshot(readonly_values)
def _merge_layer(
merged: dict[str, RawConfigValue],
layer: ConfigLayer,
) -> ConfigError | None:
for key, value in layer.values.items():
if key not in _KNOWN_KEYS:
return ConfigError(
code="unknown_key",
key=key,
source_name=layer.source_name,
message="지원하지 않는 설정 key입니다.",
)
merged[key] = RawConfigValue(
value=value,
source_name=layer.source_name,
)
return None
def _parse_snapshot(
values: Mapping[str, RawConfigValue],
) -> ConfigLoadResult:
api_base_url = _read_https_url(
values,
"apiBaseUrl",
)
max_concurrency = _read_integer(
values,
"maxConcurrency",
1,
64,
)
request_timeout_ms = _read_integer(
values,
"requestTimeoutMs",
100,
60_000,
)
dry_run = _read_boolean(
values,
"dryRun",
)
error = _first_error(
api_base_url,
max_concurrency,
request_timeout_ms,
dry_run,
)
if error is not None:
return error
return ConfigLoaded(
config=AppConfig(
api_base_url=api_base_url,
max_concurrency=max_concurrency,
request_timeout_ms=request_timeout_ms,
dry_run=dry_run,
)
)
def _read_https_url(
values: Mapping[str, RawConfigValue],
key: ConfigKey,
) -> str | ConfigError:
raw = values.get(key)
if raw is None:
return _missing_key(key)
try:
parsed = urlsplit(raw.value)
# port 접근은 잘못된 포트 문법에서 ValueError를 낼 수 있다.
_ = parsed.port
valid = (
parsed.scheme == "https"
and parsed.hostname is not None
and parsed.username is None
and parsed.password is None
and parsed.query == ""
and parsed.fragment == ""
)
# 다른 언어 예제처럼 raw 문자열이 아니라 파서를 거친 값을 저장한다.
# 단, stdlib의 URL 정규화는 WHATWG URL이나 url crate보다 가볍다.
normalized = parsed.geturl()
except ValueError:
valid = False
normalized = ""
if not valid:
return _invalid_value(
key,
raw.source_name,
"사용자 정보, query, fragment가 없는 HTTPS URL이 필요합니다.",
)
return normalized
def _read_integer(
values: Mapping[str, RawConfigValue],
key: ConfigKey,
minimum: int,
maximum: int,
) -> int | ConfigError:
raw = values.get(key)
if raw is None:
return _missing_key(key)
if _DECIMAL_RE.fullmatch(raw.value) is None:
return _invalid_value(
key,
raw.source_name,
"선행 0 없는 ASCII 10진 정수가 필요합니다.",
)
value = int(raw.value)
if value < minimum or value > maximum:
return _invalid_value(
key,
raw.source_name,
f"{minimum} 이상 {maximum} 이하 값이 필요합니다.",
)
return value
def _read_boolean(
values: Mapping[str, RawConfigValue],
key: ConfigKey,
) -> bool | ConfigError:
raw = values.get(key)
if raw is None:
return _missing_key(key)
if raw.value == "true":
return True
if raw.value == "false":
return False
return _invalid_value(
key,
raw.source_name,
"true 또는 false가 필요합니다.",
)
def _first_error(
*values: object,
) -> ConfigError | None:
for value in values:
if isinstance(value, ConfigError):
return value
return None
def _missing_key(key: ConfigKey) -> ConfigError:
return ConfigError(
code="missing_key",
key=key,
source_name="merged",
message="필수 설정이 없습니다.",
)
def _invalid_value(
key: ConfigKey,
source_name: str,
message: str,
) -> ConfigError:
return ConfigError(
code="invalid_value",
key=key,
source_name=source_name,
message=message,
)Python에서는 frozen=True dataclass로 최종 snapshot을 불변 값처럼 다룬다. Python 문서도 frozen=True를 "진짜 immutable object"가 아니라 read-only 동작의 모사라고 설명한다.7
그러므로 내부 mutable collection을 AppConfig에 직접 넣지 않는 편이 좋다. 정수 파서는 isdecimal()이 다른 Unicode 숫자와 선행 0까지 통과시키므로, 이 예제에서는 0|[1-9][0-9]* 정규식으로 선행 0 없는 ASCII 10진만 허용해 다른 언어와 문법을 맞췄다. URL도 raw 문자열이 아니라 urlsplit을 거친 값(geturl())을 저장한다.
개인메모: 이 문서는 python 코드에서 마이그레이션 한 코드이다. 혹시나 몰라서 적지만 '거친'값이라고 적어놨지만 깃 허브에서는 Parsed value라고 되있었다. 즉 Roguh가 아니라 통과 즉, 가공되지 않은 문자열이 함수를 통과한 결과값이라는 뜻이다.
다만 stdlib의 URL 정규화는 JS URL이나 Rust url crate보다 가벼워, 후행 슬래시 추가 같은 정규화까지 하지는 않는다.
Rust
use std::collections::{BTreeMap, BTreeSet};
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigLayer {
source_name: String,
values: BTreeMap<String, String>,
}
impl ConfigLayer {
pub fn new(
source_name: String,
values: BTreeMap<String, String>,
) -> Self {
Self {
source_name,
values,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppConfig {
api_base_url: Url,
max_concurrency: usize,
request_timeout_ms: u64,
dry_run: bool,
}
impl AppConfig {
pub fn get_api_base_url(&self) -> &Url {
&self.api_base_url
}
pub fn get_max_concurrency(&self) -> usize {
self.max_concurrency
}
pub fn get_request_timeout_ms(&self) -> u64 {
self.request_timeout_ms
}
pub fn get_dry_run(&self) -> bool {
self.dry_run
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigErrorCode {
UnknownKey,
MissingKey,
InvalidValue,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigError {
pub code: ConfigErrorCode,
pub key: String,
pub source_name: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RawConfigValue {
value: String,
source_name: String,
}
pub fn load_app_config(
layers: &[ConfigLayer],
) -> Result<AppConfig, ConfigError> {
let known_keys = known_keys();
let mut merged = BTreeMap::new();
for layer in layers {
merge_layer(
&mut merged,
layer,
&known_keys,
)?;
}
parse_snapshot(&merged)
}
fn merge_layer(
merged: &mut BTreeMap<String, RawConfigValue>,
layer: &ConfigLayer,
known_keys: &BTreeSet<&'static str>,
) -> Result<(), ConfigError> {
for (key, value) in &layer.values {
if !known_keys.contains(key.as_str()) {
return Err(ConfigError {
code: ConfigErrorCode::UnknownKey,
key: key.clone(),
source_name: layer.source_name.clone(),
message: "지원하지 않는 설정 key입니다.".to_owned(),
});
}
merged.insert(
key.clone(),
RawConfigValue {
value: value.clone(),
source_name: layer.source_name.clone(),
},
);
}
Ok(())
}
fn parse_snapshot(
values: &BTreeMap<String, RawConfigValue>,
) -> Result<AppConfig, ConfigError> {
let api_base_url = read_https_url(
values,
"apiBaseUrl",
)?;
let max_concurrency = read_usize(
values,
"maxConcurrency",
1,
64,
)?;
let request_timeout_ms = read_u64(
values,
"requestTimeoutMs",
100,
60_000,
)?;
let dry_run = read_bool(
values,
"dryRun",
)?;
Ok(AppConfig {
api_base_url,
max_concurrency,
request_timeout_ms,
dry_run,
})
}
fn read_https_url(
values: &BTreeMap<String, RawConfigValue>,
key: &str,
) -> Result<Url, ConfigError> {
let raw = get_required(values, key)?;
let parsed = Url::parse(&raw.value)
.map_err(|_| invalid_value(
key,
raw,
"유효한 URL이 필요합니다.",
))?;
if parsed.scheme() != "https"
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err(invalid_value(
key,
raw,
"사용자 정보, query, fragment가 없는 HTTPS URL이 필요합니다.",
));
}
Ok(parsed)
}
fn read_usize(
values: &BTreeMap<String, RawConfigValue>,
key: &str,
minimum: usize,
maximum: usize,
) -> Result<usize, ConfigError> {
let raw = get_required(values, key)?;
if !is_canonical_decimal(&raw.value) {
return Err(invalid_value(
key,
raw,
"선행 0 없는 ASCII 10진 정수가 필요합니다.",
));
}
let value = raw.value
.parse::<usize>()
.map_err(|_| invalid_value(
key,
raw,
"정수가 필요합니다.",
))?;
if !(minimum..=maximum).contains(&value) {
return Err(invalid_value(
key,
raw,
"허용 범위를 벗어났습니다.",
));
}
Ok(value)
}
fn read_u64(
values: &BTreeMap<String, RawConfigValue>,
key: &str,
minimum: u64,
maximum: u64,
) -> Result<u64, ConfigError> {
let raw = get_required(values, key)?;
if !is_canonical_decimal(&raw.value) {
return Err(invalid_value(
key,
raw,
"선행 0 없는 ASCII 10진 정수가 필요합니다.",
));
}
let value = raw.value
.parse::<u64>()
.map_err(|_| invalid_value(
key,
raw,
"정수가 필요합니다.",
))?;
if !(minimum..=maximum).contains(&value) {
return Err(invalid_value(
key,
raw,
"허용 범위를 벗어났습니다.",
));
}
Ok(value)
}
fn read_bool(
values: &BTreeMap<String, RawConfigValue>,
key: &str,
) -> Result<bool, ConfigError> {
let raw = get_required(values, key)?;
match raw.value.as_str() {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(invalid_value(
key,
raw,
"true 또는 false가 필요합니다.",
)),
}
}
fn get_required<'a>(
values: &'a BTreeMap<String, RawConfigValue>,
key: &str,
) -> Result<&'a RawConfigValue, ConfigError> {
values.get(key).ok_or_else(|| ConfigError {
code: ConfigErrorCode::MissingKey,
key: key.to_owned(),
source_name: "merged".to_owned(),
message: "필수 설정이 없습니다.".to_owned(),
})
}
fn is_canonical_decimal(value: &str) -> bool {
if value.is_empty() {
return false;
}
if value == "0" {
return true;
}
let bytes = value.as_bytes();
if bytes[0] == b'0' {
return false;
}
bytes.iter().all(|byte| byte.is_ascii_digit())
}
fn invalid_value(
key: &str,
raw: &RawConfigValue,
message: &str,
) -> ConfigError {
ConfigError {
code: ConfigErrorCode::InvalidValue,
key: key.to_owned(),
source_name: raw.source_name.clone(),
message: message.to_owned(),
}
}
fn known_keys() -> BTreeSet<&'static str> {
BTreeSet::from([
"apiBaseUrl",
"maxConcurrency",
"requestTimeoutMs",
"dryRun",
])
}필요한 의존성:
[dependencies]
url = "2"Rust에서는 Result<AppConfig, ConfigError>가 자연스럽고, 완성된 AppConfig의 필드는 private으로 유지한다.
parse::<usize>()와 parse::<u64>()도 "08"을 받으므로, 파싱 전에 is_canonical_decimal로 선행 0을 거른다. 설정을 실행 중 변경해야 한다면 기존 객체를 mutation하기보다 새 snapshot을 만든 뒤 교체한다. 다만 Arc<T>가 제공하는 것은 thread-safe shared ownership이지 Arc 변수 자체의 원자적 교체가 아니다.8
교체 지점에는 RwLock<Arc<AppConfig>>, ArcSwap 같은 별도 동기화가 필요하다.
3. 호출부
I/O adapter는 파일, 환경 변수, 명령행을 읽고 canonical layer로 바꾼다. Core loader는 이 계층들이 어디에서 왔는지 알지 못한다.
type SyncService = Readonly<{
runAsync: (
config: AppConfig,
signal?: AbortSignal,
) => Promise<void>;
}>;
export async function startApplicationAsync(
service: SyncService,
fileValues: Readonly<Record<string, string>>,
commandLineValues: Readonly<Record<string, string>>,
signal?: AbortSignal,
): Promise<number>
{
const result = loadAppConfig([
createDefaultLayer(),
{
sourceName: "configFile",
values: fileValues,
},
createEnvironmentLayer(process.env),
{
sourceName: "commandLine",
values: commandLineValues,
},
]);
if (result.kind === "rejected") {
writeConfigError(result.error);
return 2;
}
await service.runAsync(result.config, signal);
return 0;
}
function createDefaultLayer(): ConfigLayer
{
return {
sourceName: "defaults",
values: {
maxConcurrency: "4",
requestTimeoutMs: "5000",
dryRun: "true",
},
};
}
function createEnvironmentLayer(
environment: NodeJS.ProcessEnv,
): ConfigLayer
{
const values: Record<string, string> = {};
assignIfPresent(
values,
"apiBaseUrl",
environment.SYNC_API_BASE_URL,
);
assignIfPresent(
values,
"maxConcurrency",
environment.SYNC_MAX_CONCURRENCY,
);
assignIfPresent(
values,
"requestTimeoutMs",
environment.SYNC_REQUEST_TIMEOUT_MS,
);
assignIfPresent(
values,
"dryRun",
environment.SYNC_DRY_RUN,
);
return {
sourceName: "environment",
values,
};
}
function assignIfPresent(
target: Record<string, string>,
key: ConfigKey,
value: string | undefined,
): void
{
if (value !== undefined) {
target[key] = value;
}
}
function writeConfigError(error: ConfigError): void
{
console.error(JSON.stringify({
eventName: "application.configuration.rejected",
code: error.code,
key: error.key,
sourceName: error.sourceName,
message: error.message,
}));
}책임 분리:
File Adapter
= 파일 I/O와 파일 형식 파싱
Environment Adapter
= 환경 변수 이름을 canonical key로 변환
CLI Adapter
= 명령행 syntax를 canonical key로 변환
AppConfigLoader
= 우선순위 병합, unknown key 거절, 타입 변환
AppConfig
= 실행 코드가 사용하는 불변 설정
Startup Policy
= 설정 실패를 종료 코드와 운영 로그로 변환
SyncService
= 이미 검증된 설정을 사용해 실제 업무 I/O 수행SyncService 내부에서는 process.env.SYNC_MAX_CONCURRENCY를 다시 읽지 않는다.
사실 이것만 알아도 snapshot 경계를 잘 설정하는 법을 알게 된 것이다.
여기서 loadAppConfig(layers)는 낮은 수준의 함수다. 배열 순서를 바꾸면 우선순위도 바뀌는데, 함수는 그 실수를 알아차리지 못한다. 따라서 일반 호출부에 이 함수를 그대로 노출하지 않고,
위의 startApplicationAsync()처럼 composition root 한 곳에서 defaults -> file -> environment -> commandLine 순서를 고정한다. 더 강하게 막고 싶다면 배열 대신 네 source를 이름 있는 필드로 받는 loadStandardAppConfig()를 만들고, 그 함수 안에서만 배열을 조립한다. sourceName도 자유 문자열보다 닫힌 enum이나 union이 낫다. 파일 경로나 secret 이름을 source 이름으로 넣으면 provenance 로그가 또 하나의 정보 유출 통로가 된다.
개인메모:컴포지션 루트(Compostion Root)라는게 뭐냐면 필요한 의존성을 그냥 최상위 루트에서 조립한다는 것이다. 쉽게 말하면 프로그램이 시작할 때 필요한 준비물을 한곳에 모아 확인하고, 각 객체에게 필요한 것을 나눠주는 장소다. 너무 어렵게 생각할 필요 없다. 그냥 처음에 호출해서 빠르게 체크하겠다는거니까. 초등학교때 알림장쓰고, 선생님이 알림장에 불러 준 준비물 검사 받는다고 생각하면 이해가 쉽다.
미국이라면 Back-to-School Supply List 와 Homeroom으로 이해하면 편하다. 미국에서는 학기 시작 전 'School Supply List(준비물 목록)'를 받고, 개학 첫날 'Homeroom(조회 시간)'에 가방을 열어 모든 준비물이 제대로 갖춰졌는지 확인하는 문화가 있다고 들었다. 중간에 수학 시간에 지우개가 없는 것을 발견하는 것이 아니라, 하루 일과가 본격적으로 시작되기 전(Entry Point)에 미리 체크하는 것이다.
4. 읽는 순서
설정 공급원은 무엇인가
→ 각 공급원이 canonical key로 변환되는가
→ 계층 우선순위가 명시되어 있는가
→ 덮어쓰기는 raw 값 단계에서 수행되는가
→ 알 수 없는 key를 거절하는가
→ 모든 필드를 타입과 범위로 변환하는가
→ 필드 사이 규칙이 있다면 교차 불변식을 확인하는가
→ 완성된 설정이 불변 객체인가
→ 실행 코드가 raw 공급원을 다시 읽지 않는가
→ 설정 실패가 실제 I/O 시작 전에 발생하는가설정 코드는 getter 호출보다 값이 언제 해석되고 언제부터 고정되는가를 중심으로 읽어야 한다. 이번 예제의 네 필드는 서로 독립적이라 억지로 만든 교차 규칙이 없다.
따라서 예제가 실제로 수행하는 검증은 필수값, 문법, 범위 검증까지다. retryCount와 requestTimeoutMs를 함께 제한하거나 dryRun=false에서 별도 승인 토큰을 요구하는 식의 도메인 규칙이 생기면, 각 필드를 파싱한 다음 validateWholeConfig(candidate) 단계에서 검사해야 한다. 없는 규칙을 코드에 꾸며 넣는 것보다, 현재 예제의 보장 범위를 적어두는 편이 낫다.
(블로그 글이라는 게 결국은 전체 코드를 쓸 수가 없다.)
또한 예제는 첫 오류만 반환한다. 시작을 즉시 중단하는 데에는 충분하지만, 운영자가 한 번에 여러 오타를 고치려면 모든 비민감 오류를 모아 반환하는 방식이 편하다. 어느 쪽을 택하든 오류 순서를 결정적으로 유지하고 secret 원문은 넣지 않는다.
5. 경계와 오해
Configuration Snapshot은 실행 중 변경되지 않아도 되는 설정에 적합하다.
적합:
- API base URL
- timeout
- connection pool 크기
- worker 수
- 기능의 시작 모드
- 경로와 format 선택
별도 설계가 필요한 값:
- 실시간 feature flag
- 동적 rate limit
- 운영 중 교체되는 인증서
- tenant별 정책
- 즉시 반영되어야 하는 kill switch동적 설정은 전역 mutable dictionary를 공유하는 방식보다 ConfigSnapshot v1 → ConfigSnapshot v2처럼 새 객체를 만들고, 검증 후 한 번에 교체하는 편이 낫다. 하나의 snapshot 안에서는 모든 필드가 같은 버전에 속해야 한다. 다만 "한 번에 교체"는 언어가 알아서 보장해 주는 수사가 아니다.
C#의 Interlocked.Exchange, Java의 AtomicReference, Rust의 RwLock이나 ArcSwap처럼 실제 동기화 원시 기능이 필요하다. 기본값도 신중하게 정해야 한다. 설정 누락 시 안전한 행동이 명확하지 않다면 기본값을 두지 않는 편이 낫다.
기본값에 적합:
- 보수적인 timeout
- 낮은 worker 수
- 외부 변경을 막는 dryRun=true
기본값을 피할 후보:
- 결제 대상 계정
- production API URL
- 암호화 key
- 데이터 삭제 허용 여부
- tenant 식별자특히 위험한 설정이 빠졌는데 임의 기본값으로 production 동작을 시작하면 fail-open이 된다. 삭제·결제·권한과 관련된 설정은 fail-closed가 더 안전하다. Secret도 일반 설정 문자열과 구분해야 한다. API token을 명령행 인자로 넘기면 process listing에 노출될 수 있으며, MITRE는 이를 CWE-214로 분류한다.9
환경 변수 역시 다른 프로세스, 로그, system dump에서 보일 수 있어 OWASP는 다른 방법이 가능하다면 secret 전달 수단으로 권장하지 않는다.10
민감 값은 가능하면 secret manager의 식별자나 별도 credential provider로 다루고 로그·오류 메시지에서 제외한다. secret을 단순 hash한 값을 fingerprint로 남기는 것도 안전하지 않다.
값 후보가 작으면 hash를 대입해 원문을 추측할 수 있다. 알 수 없는 key를 거절하는 정책은 migration과 충돌할 수 있다. 구버전 애플리케이션이 신버전 설정 파일을 읽는 배포 구조라면 schema version이나 호환 범위를 명시해야 한다.
그렇지 않으면 rolling deployment 중 일부 instance가 신설 key 때문에 시작하지 못할 수 있다. 그리고 이 예제의 environment adapter는 알려진 SYNC_* 이름만 골라 canonical layer를 만든다.
따라서 관계없는 환경 변수를 unknown key로 거절하지 않는다.
설정 파일과 CLI에서도 unknown key를 잡으려면 source parser가 낯선 항목을 버리지 말고 loader까지 전달해야 한다. adapter가 먼저 조용히 버린 key는 core loader가 찾아낼 방법이 없다. HTTPS 문법 검증도 목적지 권한 검증은 아니다. https://127.0.0.1, link-local 주소, 사설망 host가 문법상 유효할 수 있으므로 외부 입력이 URL을 결정하는 프로그램에서는 host allowlist, DNS rebinding, redirect 정책, 실제 연결 IP 검사를 별도의 SSRF 경계로 다뤄야 한다.
현장에서 실패하게되는 주의사항:
- 서비스 내부에서 환경 변수를 반복해서 읽음
- source마다 서로 다른 기본값과 파싱 규칙을 사용함
- 우선순위가 코드 순서에 암묵적으로만 존재함
- unknown key를 무시해 설정 오타가 숨겨짐
- "0", "-1", 지나치게 큰 timeout을 허용함
- 설정 파일 reload 중 필드 일부만 먼저 교체됨
- raw dictionary를 application core 전체에 주입함
- secret을 명령행과 startup log에 출력함
- 기본값이 위험한 production 동작을 자동 허용함
- 설정 실패 후 일부 background worker가 이미 시작됨6. 잘못된 예제
async function synchronizeBadAsync(): Promise<void>
{
const apiBaseUrl =
process.env.SYNC_API_BASE_URL
?? "https://production.example.com";
const maxConcurrency = Number(
process.env.SYNC_MAX_CONCURRENCY ?? "1000",
);
const dryRun =
process.env.SYNC_DRY_RUN === "true";
await runSynchronization({
apiBaseUrl,
maxConcurrency,
dryRun,
});
}
async function runSynchronization(
config: {
apiBaseUrl: string;
maxConcurrency: number;
dryRun: boolean;
},
): Promise<void>
{
const timeoutMs = Number(
process.env.SYNC_REQUEST_TIMEOUT_MS ?? "0",
);
console.log("실행 설정", process.env);
// 실제 동기화 작업
void config;
void timeoutMs;
}나쁜 이유:
- 설정을 두 함수에서 서로 다른 시점에 읽는다.
- 하나의 실행이 서로 다른 환경 변수 snapshot을 볼 수 있다.
- maxConcurrency=1000이라는 공격적인 기본값을 사용한다.
- Number 변환 결과가 NaN이어도 검증하지 않는다.
- timeoutMs=0의 의미가 즉시 timeout인지 무제한인지 불분명하다.
- production URL 누락을 기본값으로 조용히 숨긴다.
- process.env 전체를 출력해 secret이 유출될 수 있다.
- 실행 코드가 raw 설정 공급원에 직접 결합된다.
- 어느 source가 최종 값을 제공했는지 추적할 수 없다.설정은 "필요한 곳에서 그때 읽는 값"이 아니라, 시작 경계에서 검증을 끝낸 실행 입력이어야 한다.
7. 프로덕션 확장
실전 확장은 설정 provenance와 우선순위 계약 테스트다. 최종값뿐 아니라 어떤 계층이 값을 제공했는지도 snapshot metadata에 남기면 운영 진단이 쉬워진다.
type ConfigSourceName =
| "defaults"
| "configFile"
| "environment"
| "commandLine";
type ConfigSnapshot = Readonly<{
config: AppConfig;
sourceByKey: Readonly<Record<ConfigKey, ConfigSourceName>>;
}>;
type MergeResult = Readonly<{
values: ReadonlyMap<string, RawConfigValue>;
sourceByKey: Readonly<Record<ConfigKey, ConfigSourceName>>;
}>;운영 로그에는 실제 값보다 source와 안전한 요약만 기록한다.
function createConfigStartupSummary(
snapshot: ConfigSnapshot,
): Readonly<Record<string, unknown>>
{
return {
eventName: "application.configuration.loaded",
apiBaseUrlHost: new URL(snapshot.config.apiBaseUrl).host,
maxConcurrency: snapshot.config.maxConcurrency,
requestTimeoutMs: snapshot.config.requestTimeoutMs,
dryRun: snapshot.config.dryRun,
sourceByKey: snapshot.sourceByKey,
};
}Secret 값은 요약에 포함하지 않는다. 일반 URL도 query string에 token이 들어갈 수 있다면 전체 문자열이 아니라 검증된 host만 기록한다.
우선순위 계약 테스트:
import assert from "node:assert/strict";
import test from "node:test";
test("CLI가 환경 변수와 파일과 기본값을 덮어쓴다", () =>
{
const result = loadAppConfig([
{
sourceName: "defaults",
values: {
apiBaseUrl: "https://default.example.com",
maxConcurrency: "4",
requestTimeoutMs: "5000",
dryRun: "true",
},
},
{
sourceName: "configFile",
values: {
maxConcurrency: "8",
},
},
{
sourceName: "environment",
values: {
maxConcurrency: "12",
dryRun: "false",
},
},
{
sourceName: "commandLine",
values: {
maxConcurrency: "16",
},
},
]);
assert.equal(result.kind, "loaded");
if (result.kind !== "loaded") {
return;
}
assert.equal(result.config.maxConcurrency, 16);
assert.equal(result.config.dryRun, false);
});
test("알 수 없는 key는 시작 단계에서 거절한다", () =>
{
const result = loadAppConfig([
{
sourceName: "defaults",
values: {
apiBaseUrl: "https://api.example.com",
maxConcurrency: "4",
requestTimeoutMs: "5000",
dryRun: "true",
maxConcurreny: "32",
},
},
]);
assert.deepEqual(result, {
kind: "rejected",
error: {
code: "unknownKey",
key: "maxConcurreny",
sourceName: "defaults",
message: "지원하지 않는 설정 key입니다.",
},
});
});
test("상위 계층의 유효한 값이 하위 계층 값을 대체한다", () =>
{
const result = loadAppConfig([
{
sourceName: "defaults",
values: {
apiBaseUrl: "https://api.example.com",
maxConcurrency: "not-a-number",
requestTimeoutMs: "5000",
dryRun: "true",
},
},
{
sourceName: "environment",
values: {
maxConcurrency: "8",
},
},
]);
assert.equal(result.kind, "loaded");
if (result.kind !== "loaded") {
return;
}
assert.equal(result.config.maxConcurrency, 8);
});
test("최종 병합 결과가 범위를 벗어나면 거절한다", () =>
{
const result = loadAppConfig([
{
sourceName: "defaults",
values: {
apiBaseUrl: "https://api.example.com",
maxConcurrency: "4",
requestTimeoutMs: "5000",
dryRun: "true",
},
},
{
sourceName: "commandLine",
values: {
maxConcurrency: "1000",
},
},
]);
assert.equal(result.kind, "rejected");
});
test("선행 0이 붙은 정수는 거절한다", () =>
{
const result = loadAppConfig([
{
sourceName: "defaults",
values: {
apiBaseUrl: "https://api.example.com",
maxConcurrency: "08",
requestTimeoutMs: "5000",
dryRun: "true",
},
},
]);
assert.equal(result.kind, "rejected");
});세 번째 테스트가 중요하다. 각 계층을 개별 파싱한 뒤 merge하면 하위 계층의 "not-a-number" 때문에 거절될 수 있다. 이 패턴은 먼저 raw 값을 합치고 최종 선택된 값만 파싱한다. 마지막 테스트는 "08" 같은 선행 0 정수가 네 언어에서 동일하게 거절되는지 확인한다. 운영 지표는 값 자체보다 시작 결과를 관측한다.
application.config.loaded.count
application.config.rejected.count
application.config.rejected.by_code
application.config.reload.succeeded.count
application.config.reload.rejected.count
application.config.snapshot.versionapiBaseUrl, tenant ID, secret 이름처럼 cardinality나 민감성이 높은 값을 metric label로 사용하지 않는다. metric 비용은 요청 수보다 서로 다른 attribute 조합 수에 따라 커질 수 있다.11
snapshot.version도 문자열 hash를 label로 매번 붙이지 말고, process 안에서 증가하는 숫자를 gauge 값이나 log field로 기록한다.
8. C# / TypeScript / Python / Rust 비교 메모
언어 | 관용적 표현 | 주의점 |
|---|---|---|
C# | immutable record, options binding, startup validation |
|
TypeScript | readonly object, discriminated result, branded primitive | compile-time readonly와 |
Python | frozen dataclass, Mapping, 명시적 loader | frozen 객체 안에 mutable list/dict를 넣지 말 것 |
Rust | private field struct, | hot reload 시 새 snapshot과 별도 동기화된 holder 필요 |
- 언어
C#
- 관용적 표현
immutable record, options binding, startup validation
- 주의점
IConfiguration을 application core 전체에 주입하지 말 것
- 언어
TypeScript
- 관용적 표현
readonly object, discriminated result, branded primitive
- 주의점
compile-time readonly와
Object.freeze는 deep immutability가 아님
- 언어
Python
- 관용적 표현
frozen dataclass, Mapping, 명시적 loader
- 주의점
frozen 객체 안에 mutable list/dict를 넣지 말 것
- 언어
Rust
- 관용적 표현
private field struct,
Result, 소유권 이동- 주의점
hot reload 시 새 snapshot과 별도 동기화된 holder 필요
C#에서는 framework의 configuration binder를 사용하더라도, binding 결과를 별도 도메인 설정 객체로 검증한 뒤 application core에 전달하는 편이 좋다. IConfiguration은 문자열 key 조회를 어디서든 가능하게 하므로 경계가 쉽게 퍼진다. TypeScript의 Readonly<T>는 타입 검사 수준의 제약이다.
외부에서 넘어온 nested object까지 고정하려면 복사와 검증 정책이 필요하다. 이번 예제는 숫자, boolean, 검증된 URL 문자열만 snapshot에 남겨 참조 그래프를 작게 유지한다. Python의 frozen dataclass 역시 내부 객체까지 재귀적으로 불변으로 만들지는 않는다. 설정에 collection이 필요하면 tuple, frozenset, mapping proxy 같은 표현을 검토해야 한다.
Rust는 기본적으로 필드가 private이고 immutable binding이 기본이므로 snapshot 표현이 자연스럽다. 동적 reload가 필요하면 기존 값을 직접 고치지 말고 완성된 새 설정을 만든 뒤, 동기화가 보장된 holder에서 공유 pointer를 교체해야 한다.
timeout은 네 언어에서 표현이 조금 다르다.
C#은 TimeSpan으로 단위를 타입에 담고, 나머지는 밀리초 정수로 두되 이름 접미사 Ms로 단위를 고정한다.
일단 핵심은 "raw 문자열이 아니라 단위가 확정된 값"이라는 원칙을 지키는 것이다. 공통 불변식은 보통 다음처럼 생각하면 된다.
설정은 문자열 dictionary로 소비하지 않고,
시작 경계에서 의미와 단위가 확정된 불변 타입으로 변환한다.9. 추가로 생각해보기
이 설정은 애플리케이션 시작 시 고정되어도 되는가, 실행 중 변경되어야 하는가?
기본값이 누락을 안전하게 보완하는가, 위험한 production 동작을 조용히 허용하는가?
raw layer를 먼저 merge한 뒤 parse하는가, 각 layer를 개별 parse해 상위 override를 막고 있지는 않은가?
unknown key를 거절할 때 rolling deployment와 schema version 호환성은 어떻게 유지할 것인가?
secret 값과 일반 설정을 같은 객체·로그·명령행 경로로 다뤄도 되는가?
동적 reload 실패 시 기존 snapshot을 유지할 것인가, 프로세스를 종료할 것인가?
10. 요약
Configuration Snapshot은 여러 raw 설정 계층을 하나의 검증된 불변 실행 설정으로 만든다.
우선순위는 코드와 테스트에서 명시적으로 고정해야 한다.
raw 값을 먼저 merge하고 최종 선택된 값만 타입 변환하는 편이 안전하다.
정수 문법(선행 0 없는 ASCII 10진)과 boolean 문법을 네 언어에서 하나의 규칙으로 통일한다.
unknown key와 잘못된 범위는 실제 업무 I/O를 시작하기 전에 거절해야 한다.
실행 코드는 환경 변수나 설정 dictionary를 다시 읽지 않고
AppConfig만 사용해야 한다.동적 설정도 기존 객체를 부분 수정하지 말고 검증된 새 snapshot으로 교체하되, 실제 원자성은 별도 동기화로 보장해야 한다.
한 줄 암기:
설정은 필요한 순간마다 읽지 말고,
시작할 때 전부 합쳐 검증한 뒤 하나의 불변 값으로 들고 가라.개인메모:러스트 코드를 작성하는 건 DX(개발자 경험)에서 너무 괴롭다. 아직 사용처도 없는데 내가 너무 일찍 배우고 있는게 아닐까 그런 생각을 한다.
7년동안 코드 베이스를 공부하면서 정리하고 있다. 생각보다 많은 내가 작성하거나 또 봤던 코드 베이스들이 꽤 많은 이론들이 필요로 했다는 걸 다시금 깨닫는다
각주
- Adam Wiggins. The Twelve-Factor App: Config, Build, release, run. ↩
- Microsoft Learn. .NET configuration providers. Provider는 추가된 순서대로 적용되며 같은 key에서는 나중 provider가 앞의 값을 덮어쓴다. ↩
- Node.js Documentation. Environment Variables.
.env값은 Node.js에서 모두 문자열로 해석된다. ↩ - Microsoft Learn. Options pattern in ASP.NET Core: ValidateOnStart. Options 검증을 첫 사용 시점이 아니라 애플리케이션 시작 시점에 실행할 수 있다. ↩
- TypeScript Handbook. `readonly` properties.
readonly는 type checking 중 property 재할당을 제한하며 내부 객체의 불변성을 보장하지 않는다. ↩ - MDN Web Docs. `Object.freeze()`. Freeze는 immediate property에만 적용되는 shallow operation이다. ↩
- Python Documentation. `dataclasses`: Frozen instances. Python은
frozen=True로 read-only 동작을 모사한다. ↩ - Rust Standard Library. `std::sync::Arc`.
Arc의 atomic은 reference count 관리에 관한 것이며 임의의 holder 교체를 대신하지 않는다. ↩ - MITRE CWE. CWE-214: Invocation of Process Using Visible Sensitive Information. ↩
- OWASP Cheat Sheet Series. Secrets Management Cheat Sheet. ↩
- OpenTelemetry. Metrics: Cardinality limits. ↩