appsettings.json만으로는 부족하다 ── Windows 업무 앱 구성 관리 실무(환경별 설정・비밀 정보・쓰기 위치)

· · CSharp, .NET, appsettings.json, IConfiguration, IOptions, Generic Host, 구성 관리, Windows 개발, 기술 상담

“연결 문자열을 환경마다 바꾸고 싶다”, “사용자별 표시 설정을 저장하고 싶다”, “API 키를 appsettings.json에 그대로 적어 버렸다”. Windows 업무 앱의 구성 관리는 appsettings.json을 하나 두고 IConfiguration에서 읽는 단계까지는 누구나 도달하지만, 그 다음──환경별 설정, 쓰기 가능한 설정의 배치 위치, 비밀 정보의 처리, 실행 중 설정 변경──은 의외로 정리되지 않은 채 운영에 들어가 버리는 경우가 많은 영역입니다.

이 글에서는 .NET 구성 시스템의 토대인 IConfiguration과 프로바이더의 중첩부터, 환경별 설정, IOptions 패턴을 통한 타입 안전한 값 수신 방식, 쓰기 가능한 설정의 배치 위치, 비밀 정보의 처리, 실행 중 설정 변경의 현실적인 한계, 그리고 app.config/Settings.settings에서의 마이그레이션 맵까지, 실무에서 판단이 갈리는 순서로 정리합니다.

1. 먼저 결론

구성 관리의 판단은 ‘설정의 종류’별로 ‘배치 위치’를 정하는 데서 시작합니다. 먼저 전체 그림을 판단표로 정리합니다.

설정의 종류 구체적 예 배치 위치의 1순위 이유
앱 기본값 로그 레벨의 기본값, UI의 기본 파라미터 appsettings.json 빌드 산출물에 동봉하는, 환경에 의존하지 않는 공통값
환경별 설정 검증 환경/프로덕션 환경에서 바꾸는 연결 문자열, API 엔드포인트 appsettings.{Environment}.json + 환경 변수 기본 덮어쓰기 순서를 그대로 이용할 수 있음
사용자별 설정 마지막으로 연 폴더, 창 위치, 개인별 표시 설정 %LOCALAPPDATA%(또는 %APPDATA%) 아래의 자체 파일 사용자 단위로 쓸 수 있는 영역이 필요
머신별 설정(전체 사용자 공유) 장치의 COM 포트 번호, 라이선스 서버 주소 %ProgramData% 아래의 자체 파일 관리자가 머신당 하나씩 설정하고 모든 사용자가 공유
비밀 정보 연결 문자열의 비밀번호, API 키, 토큰 DPAPI로 보호한 파일(프로덕션), user-secrets(개발 시에만) 평문으로 두지 않음. 개발용 방식을 프로덕션에 전용하지 않음
실행 중 바뀌는 값 기능 플래그, 로그 레벨의 동적 변경 appsettings.json(reloadOnChange) + IOptionsMonitor 재시작 없이 반영하고 싶은 값에만 한정해서 사용

이 표를 바탕으로 한 결론을 먼저 적습니다.

  • 구성의 우선순위는 ‘나중에 추가한 프로바이더가 이긴다’는 하나의 규칙을 따릅니다. 기본적으로 appsettings.jsonappsettings.{Environment}.json → (개발 환경일 때만) user secrets → 환경 변수 → 명령줄 인수 순으로 읽히며, 같은 키가 있으면 나중에 읽은 값으로 덮어써집니다. 이 순서만 기억해 두면 “json에 적었는데 반영되지 않는다”는 문제 대부분은 설명이 됩니다. 1
  • Host.CreateApplicationBuilder를 사용하면 이 계층은 직접 구성하지 않아도 기본으로 마련됩니다. Windows Forms/WPF 같은 데스크톱 앱에서도 Generic Host를 사용하면 동일한 기본값의 혜택을 받을 수 있습니다. 23
  • 환경 전환은 DOTNET_ENVIRONMENT(또는 ASPNETCORE_ENVIRONMENT)로 수행합니다. WebApplication 계열에서는 DOTNET_ENVIRONMENT가 우선되며, 둘 다 설정되지 않았다면 기본값은 Production입니다. 데스크톱 앱이나 Windows 서비스에서는 이 환경 변수를 “누가 어떻게 프로세스에 전달하는가”를 먼저 설계해 두어야 합니다. 4
  • 설정은 DTO로 받지 말고, IOptions<T> 패밀리로 타입으로 받습니다. 시작 시 한 번만 필요하다면 IOptions<T>, 재시작 없이 변경을 반영하고 싶다면 IOptionsMonitor<T>를 사용합니다. 이 둘의 차이를 이해하지 못한 채 둘 다 대충 쓰면 “바뀌면 안 되는 값이 바뀐다”, “바뀌어야 할 값이 바뀌지 않는다”는 두 가지 사고가 모두 일어납니다. 5
  • 설정 오류는 실행 시가 아니라 시작 시에 떨어뜨립니다. ValidateDataAnnotations()ValidateOnStart()를 조합하면, 설정 실수는 “시작 직후 예외로 알 수 있는” 상태가 되어 “프로덕션 심야에 NullReferenceException으로 알아차리는” 사고를 막을 수 있습니다. 6
  • 비밀 정보는 평문으로 appsettings.json에 두지 않습니다. 개발 시에는 user-secrets, 프로덕션에서는 DPAPI(ProtectedData)나 자격 증명 관리자를 사용합니다. user-secrets는 암호화되어 있지 않으며, 개발 전용 방식이라는 점이 공식 문서에 명시되어 있습니다. 78

2. .NET 구성 시스템의 토대 ── IConfiguration과 프로바이더

.NET의 구성 시스템은 IConfiguration이라는 하나의 키・값 저장소의 겉모습 뒤에, 여러 구성 프로바이더를 겹쳐서 읽어들이는 구조로 이루어져 있습니다. JSON, 환경 변수, 명령줄 인수, INI, XML, 메모리상의 컬렉션처럼 성질이 다른 소스를 동일한 IConfiguration 인터페이스 뒤로 통합할 수 있는 것이 이 구조의 장점입니다. 9

프로바이더는 “나중에 추가한 쪽이 이긴다”는 단순한 규칙으로 쌓입니다. 같은 키가 여러 프로바이더에 존재하면, 마지막에 추가된 프로바이더의 값이 유효해집니다. 1

Host.CreateApplicationBuilder(args)를 사용하면 다음 순서로 프로바이더가 기본으로 구성됩니다(번호가 클수록 우선도가 높음, 즉 나중에 이김). 2

  1. appsettings.json
  2. appsettings.{Environment}.json
  3. Secret Manager(개발 환경일 때만)
  4. 환경 변수
  5. 명령줄 인수

콘솔 앱・Worker Service・Windows Forms 앱 중 무엇이든, 이 기본 순서는 공통입니다. 다음은 최소 구성의 예입니다.

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

// 기본적으로 appsettings.json / appsettings.{Environment}.json / 환경 변수 /
// 명령줄 인수가 위의 우선순위로 읽힌 상태가 되어 있음
string? connectionString = builder.Configuration.GetConnectionString("Main");

using IHost host = builder.Build();
await host.RunAsync();

Windows Forms 같은 데스크톱 앱에서도 Microsoft.Extensions.Hosting 패키지를 추가하면 동일한 HostApplicationBuilder를 그대로 사용할 수 있습니다. 이 블로그의 “Generic Host란 무엇인가“에서 다룬 것처럼, Generic Host는 구성・DI・로깅을 한꺼번에 처리해 주는 토대이며, 데스크톱 앱이라는 이유로 구성 시스템의 이점을 포기할 이유는 없습니다. 상주 처리를 수반하는 앱에서의 구체적인 구성 방법은 “Generic Host + BackgroundService를 데스크톱 앱에서 사용하기“도 참조하십시오.

환경 변수의 처리에는 주의점이 있습니다. 호스트 설정(콘텐츠 루트・환경 이름 등)은 DOTNET_ 접두사가 붙은 환경 변수에서 읽히지만, 이는 앱 설정(IConfiguration을 통해 읽는 일반 값)에는 사용되지 않습니다. 앱 설정으로 읽히길 원하는 환경 변수는 접두사 없이 AddEnvironmentVariables()에 의해 기본 순서에 포함됩니다. 10 독자적인 접두사를 붙이고 싶다면 builder.Configuration.AddEnvironmentVariables(prefix: "MyApp_")처럼 명시적으로 추가합니다.

using Microsoft.Extensions.Configuration;

// 기본 프로바이더 뒤에 추가되므로 우선도가 가장 높아짐
builder.Configuration.AddEnvironmentVariables(prefix: "MyApp_");

3. 환경별 설정 ── appsettings.{Environment}.json

환경마다 연결 문자열이나 엔드포인트를 바꾸고 싶을 때는 appsettings.Development.json / appsettings.Staging.json / appsettings.Production.json 같은 환경별 파일을 사용합니다. 이 파일은 기본 appsettings.json을 “덮어쓰는” 차분 파일로 취급된다는 점이 중요합니다. 모든 항목을 다시 쓸 필요는 없으며, 환경마다 바뀌는 값만 적으면 충분합니다. 1

환경 이름은 DOTNET_ENVIRONMENT 또는 ASPNETCORE_ENVIRONMENT라는 환경 변수로 결정됩니다. WebApplication을 사용하는 경우 DOTNET_ENVIRONMENT의 값이 ASPNETCORE_ENVIRONMENT보다 우선되며, 둘 다 설정되어 있지 않으면 기본값은 Production입니다. Windows에서는 환경 변수 이름의 대소문자가 구분되지 않지만, Linux에서는 구분되므로, 컨테이너화를 염두에 둔다면 표기를 맞춰 두는 것이 안전합니다. 4

여기서 문제가 되는 것은, 데스크톱 앱이나 Windows 서비스에서는 “환경 변수를 어떻게 전달하는가” 자체가 하나의 작업이 된다는 점입니다. ASP.NET Core의 launchSettings.json 같은 개발 시 방식은 로컬 개발에서만 사용되므로, 프로덕션 운영에서 환경을 전환할 수단을 별도로 마련해야 합니다. 대표적인 전달 방법은 다음 3가지입니다.

  • 머신 단위 환경 변수로 설정합니다. setx DOTNET_ENVIRONMENT Production /M처럼 영속화하면, 그 머신에서 동작하는 모든 프로세스에 전달됩니다. 다만 하나의 머신에서 여러 환경의 앱을 함께 운영하고 싶은 경우에는 적합하지 않습니다.
  • Windows 서비스의 시작 프로세스에 환경 변수를 전달합니다. 서비스로 상주시키는 경우, 대화형 사용자 세션과는 다른 컨텍스트에서 시작되므로 사용자 환경 변수는 전달되지 않습니다. 실행 계정과 세션 분리에 대한 자세한 내용은 “Windows 서비스 만들기와 운영“을 참조하십시오. 머신 단위 환경 변수, 또는 서비스 실행 파일을 얇은 래퍼 배치로 만들어 SET한 후 본체를 시작하는 구성이 현실적입니다.
  • 태스크 스케줄러를 통해 시작하는 경우는 명령줄 인수로 전달하는 것이 확실합니다. 태스크 스케줄러의 “동작”에는 환경 변수를 직접 설정하는 UI가 없으므로, --environment Production처럼 명령줄 인수로 전달하고 AddCommandLine(args)로 읽히는 쪽이 사고가 적습니다. 태스크 스케줄러 특유의 실행 계정・로그온 종류의 특성은 “태스크 스케줄러의 작업이 실행되지 않거나 0x1로 끝남“에 정리되어 있습니다.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;

var options = new HostApplicationBuilderSettings
{
    Args = args,
    // 환경 변수가 전달되지 않는 실행 경로(태스크 스케줄러 등)를 위해
    // 명령줄 인수를 통한 환경 전환도 허용함
};
HostApplicationBuilder builder = Host.CreateApplicationBuilder(options);

Console.WriteLine($"현재 환경: {builder.Environment.EnvironmentName}");

4. 옵션 패턴 ── 설정을 타입으로 받기

IConfiguration["Key:SubKey"] 같은 문자열 키를 통한 읽기는 타이핑 실수를 알아차리지 못하고, 중첩이 깊어지면 추적하기 어렵다는 약점이 있습니다. 실무에서는 설정을 POCO에 바인딩해 타입으로 받는 옵션 패턴을 쓰는 것이 기본입니다.

public sealed class ExternalApiOptions
{
    public const string SectionName = "ExternalApi";

    public required string BaseUrl { get; set; }
    public required string ApiKey { get; set; }
    public int TimeoutSeconds { get; set; } = 30;
}

등록과 바인딩은 다음과 같이 작성합니다.

using Microsoft.Extensions.DependencyInjection;

builder.Services
    .AddOptions<ExternalApiOptions>()
    .Bind(builder.Configuration.GetSection(ExternalApiOptions.SectionName));

받는 방법에는 3가지 인터페이스가 있으며, 각각 성질이 다릅니다. 5

인터페이스 등록의 생존 기간 설정 변경의 반영 주요 용도
IOptions<T> 싱글턴 반영되지 않음(시작 시 한 번만 계산) 바뀌지 않는다는 전제의 설정. 가장 단순함
IOptionsSnapshot<T> 스코프 스코프가 재구성될 때마다 재계산 웹의 요청 스코프처럼 스코프가 명확한 컨텍스트
IOptionsMonitor<T> 싱글턴 변경 알림(OnChange)과 함께 언제든 최신값을 가져올 수 있음 상주 서비스처럼 변경을 그 자리에서 감지하고 싶은 컨텍스트

데스크톱 앱이나 Windows 서비스처럼 HTTP 요청 스코프를 갖지 않는 앱에서는, IOptionsSnapshot<T>를 써도 스스로 스코프를 만들지 않는 한 실질적으로 IOptions<T>와 같은 동작이 됩니다. 변경을 감지하고 싶다면 그냥 IOptionsMonitor<T>를 사용한다는 선택 기준으로 고민이 줄어듭니다.

설정값(BaseUrl・ApiKey・타임아웃)은 호출마다 IOptionsMonitor에서 가져오지만, HttpClient 자체는 IHttpClientFactory에서 가져와 재사용합니다. HttpClient를 호출마다 new하고 Dispose하면 내부 소켓・연결 풀을 매번 폐기・재구성하게 되어, 고빈도 폴링이나 배치 처리에서는 임시 포트(ephemeral port) 고갈로 이어질 수 있으므로, 값은 바뀌어도 연결 재사용은 IHttpClientFactory에 맡기는 것이 정석입니다.

using Microsoft.Extensions.Options;

public sealed class ExternalApiClient(
    IHttpClientFactory httpClientFactory,
    IOptionsMonitor<ExternalApiOptions> optionsMonitor)
{
    public async Task<string> FetchAsync(CancellationToken cancellationToken)
    {
        // 호출마다 최신값을 가져옴. 설정 파일이 갱신되어 있으면 반영됨
        ExternalApiOptions current = optionsMonitor.CurrentValue;

        // HttpClient 자체는 팩토리를 통해 가져와 내부 연결 풀을 재사용
        HttpClient client = httpClientFactory.CreateClient(nameof(ExternalApiClient));

        using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(new Uri(current.BaseUrl), "status"));
        request.Headers.Add("X-Api-Key", current.ApiKey);

        using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(current.TimeoutSeconds));
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);

        using HttpResponseMessage response = await client.SendAsync(request, linkedCts.Token);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync(cancellationToken);
    }
}

호출 측에서는 builder.Services.AddHttpClient(nameof(ExternalApiClient));처럼 이름이 지정된 클라이언트를 등록해 둡니다. BaseUrl이나 ApiKey처럼 설정 변경으로 바뀔 수 있는 값은 요청마다 HttpRequestMessage에 담고, HttpClient 인스턴스 자체의 생성・폐기 비용은 팩토리 쪽에 맡기는 역할 분담입니다.

설정 실수는 실행 시 예외로 나타나면 조사가 길어집니다. DataAnnotations에 의한 검증과 ValidateOnStart()를 조합하면, 설정이 깨진 앱은 애초에 시작할 수 없다는 설계로 만들 수 있습니다. 6

using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.DependencyInjection;

public sealed class ExternalApiOptions
{
    public const string SectionName = "ExternalApi";

    [Required, Url]
    public required string BaseUrl { get; set; }

    [Required, MinLength(16)]
    public required string ApiKey { get; set; }

    [Range(1, 300)]
    public int TimeoutSeconds { get; set; } = 30;
}

builder.Services
    .AddOptions<ExternalApiOptions>()
    .Bind(builder.Configuration.GetSection(ExternalApiOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart(); // 호스트 시작 시(StartAsync/RunAsync 시), 호스트 서비스 시작 전에 검증이 실행됨

ValidateOnStart()를 붙이지 않으면 검증은 그 옵션에 실제로 처음 접근하는 시점까지 지연됩니다. 검증 자체는 Build() 직후가 아니라 호스트 시작 시(StartAsync/RunAsync)에 실행되므로, Build()만 하고 아직 RunAsync()를 부르지 않은 테스트 코드 등에서는 이 시점에는 아직 검증이 실행되지 않는다는 점에 주의하십시오. “시작은 됐지만, 그 설정을 사용하는 화면을 연 순간 떨어진다”는 간헐적인 사고를 막기 위해, 업무 앱의 설정은 원칙적으로 ValidateOnStart()를 붙이도록 하십시오. 6

5. 쓰기 가능한 설정은 어디에 두어야 하는가

appsettings.json은 “읽기 전용 기본값”을 두는 곳이지, 앱 자신이 다시 쓰는 설정을 두는 곳이 아닙니다. 많은 업무 앱은 Program Files 아래에 설치되며 표준 사용자에게는 쓰기 권한이 없으므로, 실행 시 예외가 발생하거나 Windows의 파일 시스템 가상화로 인해 사용자마다 보이는 내용이 어긋나는 사고를 겪게 됩니다.

쓰기가 필요한 설정은 성격에 따라 다음과 같이 나눕니다. Environment.GetFolderPath로 가져올 수 있는 특수 폴더를 기준으로 삼는 것이 안전합니다. 11

배치 위치 가져오는 방법 용도
%LOCALAPPDATA%\회사명\앱 이름 Environment.SpecialFolder.LocalApplicationData 사용자별 설정・데이터의 기본값
%APPDATA%\회사명\앱 이름(Roaming) Environment.SpecialFolder.ApplicationData 이동 프로필 환경에서 사용자를 따라가야 하는 설정만
%ProgramData%\회사명\앱 이름 Environment.SpecialFolder.CommonApplicationData 전체 사용자 공유의 머신 단위 설정. ACL 설계가 필요
using System;
using System.IO;
using System.Text.Json;

public sealed class UserSettingsStore
{
    private readonly string _filePath;

    public UserSettingsStore()
    {
        string root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
        string dir = Path.Combine(root, "KomuraSoft", "MyApp");
        Directory.CreateDirectory(dir);
        _filePath = Path.Combine(dir, "user-settings.json");
    }

    public UserSettings Load()
    {
        if (!File.Exists(_filePath))
        {
            return new UserSettings();
        }
        string json = File.ReadAllText(_filePath);
        return JsonSerializer.Deserialize<UserSettings>(json) ?? new UserSettings();
    }

    public void Save(UserSettings settings)
    {
        string json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
        // 동일 프로세스 내에서의 다중 쓰기는 호출 측에서 직렬화한다는 전제
        File.WriteAllText(_filePath, json);
    }
}

public sealed class UserSettings
{
    public string? LastOpenedFolder { get; set; }
    public int WindowWidth { get; set; } = 1024;
    public int WindowHeight { get; set; } = 768;
}

사용자별 설정과 머신별 설정을 섞어서 하나의 파일에 넣어 버리면, 여러 사용자가 같은 머신을 사용하는 환경에서 “A씨의 설정 때문에 B씨가 곤란해지는” 사고가 됩니다. 사용자별・머신별 구분과 Windows 프로필 구조 자체에 대한 이해는 “Windows 사용자 프로필의 구조“를, 저장 위치 선택 전반에 대한 판단표는 “Windows 앱의 데이터 저장 위치 선택법“도 참조하십시오.

6. 비밀 정보 ── 연결 문자열・API 키

연결 문자열의 비밀번호나 API 키를 appsettings.json에 그대로 적는 것은, .gitignore에 넣어 두었더라도 실무적으로는 위험합니다. 공유 폴더로 배포하거나, 지원 대응 중에 파일을 보내달라고 요청받거나, 좀비화된 오래된 설정 파일이 백업에 남는 등의 경로로 평문이 유출됩니다.

개발 시에는 Secret Manager(dotnet user-secrets)를 사용합니다. 다만 이는 개발 경험을 위한 방식이며 암호화되어 있지 않습니다. 값은 %APPDATA%\Microsoft\UserSecrets\<UserSecretsId>\secrets.json에 JSON 평문으로 저장되며, 공식 문서에도 “신뢰할 수 있는 저장소로 취급해서는 안 되며, 개발 전용”이라고 명시되어 있습니다. 프로덕션 비밀 정보를 user-secrets에 넣어서 배포하는 사용법은 잘못된 것입니다. 7

dotnet user-secrets init
dotnet user-secrets set "ExternalApi:ApiKey" "개발용 값"

프로덕션 운영에서는 Windows가 제공하는 DPAPI(Data Protection API)를 사용해 사용자 또는 머신의 자격 증명에 연결해 암호화합니다. System.Security.Cryptography.ProtectedDataProtect/Unprotect가 이를 감싸는 래퍼로, DataProtectionScope.CurrentUser를 사용하면 동일한 사용자 계정으로 로그인한 경우에만, 같은 머신에서만 복호화할 수 있습니다. DPAPI는 Windows 전용 기능으로, 다른 플랫폼에서는 PlatformNotSupportedException이 발생한다는 점도 고려해 설계하십시오. 8

using System.Security.Cryptography;
using System.Text;

public static class SecretProtector
{
    // 용도를 나타내는 엔트로피를 섞어 두면, 다른 목적으로 보호된 데이터와 혼동되지 않음
    private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("MyApp.ExternalApi.ApiKey");

    public static string Protect(string plainText)
    {
        byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
        byte[] protectedBytes = ProtectedData.Protect(plainBytes, Entropy, DataProtectionScope.CurrentUser);
        return Convert.ToBase64String(protectedBytes);
    }

    public static string Unprotect(string protectedBase64)
    {
        byte[] protectedBytes = Convert.FromBase64String(protectedBase64);
        byte[] plainBytes = ProtectedData.Unprotect(protectedBytes, Entropy, DataProtectionScope.CurrentUser);
        return Encoding.UTF8.GetString(plainBytes);
    }
}

DPAPI로 보호한 값을 어디에 저장할지, CurrentUserLocalMachine 중 어느 스코프를 선택할지, 여러 사용자가 같은 머신을 사용하는 업무 앱에서의 함정 같은 구현 수준의 세부 사항은 “Windows 앱의 기밀 정보 저장 - DPAPI로 평문 설정 피하기“에 자세히 적어 두었으니, 구현 시에는 그쪽을 참조하십시오.

평문으로 두어도 되는 것과 안 되는 것의 경계는 단순합니다. “이 값이 유출되었을 때 비밀번호나 API 키의 재발급, 부정 접근 조사, 감독 기관 보고가 필요한가”로 판단합니다. 연결 문자열의 호스트 이름이나 포트 번호는 유출되어도 실제 피해가 작은 경우가 많은 반면, 그 안에 담긴 비밀번호나 인증 토큰은 항상 보호 대상입니다. 연결 문자열을 “서버 정보”와 “자격 증명”으로 나누어 구성하고, 후자만 DPAPI 보호 대상으로 삼는 설계로 하면, 평문으로 남겨도 실제 피해가 작은 부분과 보호해야 할 부분을 코드 레벨에서 분리할 수 있습니다.

7. 실행 중 설정 변경 ── reloadOnChange의 현실

AddJsonFile의 기본 호출(Host.CreateApplicationBuilder가 내부에서 수행하는 것)은 reloadOnChange: true로 되어 있어, appsettings.json / appsettings.{Environment}.json은 파일 변경을 감지해 자동으로 다시 읽힙니다. 구현은 PhysicalFileProvider가 내부에서 FileSystemWatcher를 사용해 변경을 감시하는 방식입니다. 12

이 방식에는 실무상의 한계가 몇 가지 있습니다.

  • 반영되는 것은 IOptionsMonitor<T>(및 IOptionsSnapshot<T>)를 통해 읽은 값뿐입니다. IOptions<T>는 시작 시의 값을 계속 유지하므로, 파일을 다시 써도 예전 값 그대로입니다. “설정 파일을 직접 편집했는데 반영되지 않는다”는 문의의 대부분은 이 둘을 혼동한 것이 원인입니다.
  • FileSystemWatcher는 Docker 컨테이너나 네트워크 공유 같은 파일 시스템에서는 변경 알림을 확실히 보내지 못하는 경우가 있습니다. 그런 환경에서는 DOTNET_USE_POLLING_FILE_WATCHER 환경 변수를 true로 설정하면 4초 간격의 폴링 감시로 전환됩니다(간격은 변경할 수 없습니다). 13 FileSystemWatcher 자체의 놓침・버퍼 오버플로・이벤트 중복 같은 일반적인 특성에 대해서는 “FileSystemWatcher 실무 가이드“에 정리되어 있습니다.
  • 설정 파일의 변경 알림은 한 번의 파일 변경에 대해 여러 번 발생할 수 있습니다. 앱 쪽에서 “변경을 감지하면 무거운 처리를 다시 수행한다”는 구현을 할 경우, 짧은 시간 동안의 연속 발생을 디바운스하거나, 파일 내용의 해시를 비교해 실질적인 변화만 처리하는 등의 배려가 필요합니다.

“설정 변경을 재시작 없이 반영한다”는 것을 항상 목표로 삼을 필요는 없습니다. 로그 레벨이나 기능 플래그 같은 가벼운 값은 IOptionsMonitor로 즉시 반영해도 되지만, DB 연결 문자열이나 스레드 풀 크기처럼 변경한 순간 동작 중인 리소스와 모순을 일으킬 수 있는 값은 “재시작해야 반영된다”는 것을 사양으로 명시하는 쪽이 안전합니다. 운영 담당자에게 “이 설정은 저장하면 바로 적용된다”, “이 설정은 재시작이 필요하다”를 명확히 전달할 수 있는 설계로 만드는 것이, reloadOnChange를 쓰느냐 마느냐보다 중요합니다.

8. app.config / Settings.settings에서의 마이그레이션 맵

.NET Framework 시대의 앱을 .NET으로 마이그레이션할 때는 구성 방식도 다시 만들어야 합니다. 대응 관계를 정리합니다. 14

.NET Framework .NET 비고
App.config / Web.config<appSettings> appsettings.json + IConfiguration 계층 구조를 JSON의 중첩으로 자연스럽게 표현 가능
ConfigurationManager.AppSettings["Key"] builder.Configuration["Key"] 또는 IOptions<T> 문자열 접근에서 타입 지정 접근으로
ConfigurationManager.ConnectionStrings builder.Configuration.GetConnectionString("Name") ConnectionStrings 섹션의 규약은 유지됨
Settings.settings(사용자 스코프) 자체 %LOCALAPPDATA% JSON 파일 ApplicationSettingsBase 같은 자동 생성 방식은 없음. 직접 직렬화/저장
Settings.settings(애플리케이션 스코프) appsettings.json 읽기 전용 기본값으로 취급
<connectionStrings>의 암호화(aspnet_regiis 등) DPAPI(ProtectedData) 암호화 방식 자체가 바뀜. 마이그레이션 시 다시 만들어야 함

마이그레이션 시 자주 빠지는 함정이 2가지 있습니다.

첫 번째는, System.Configuration.ConfigurationManager NuGet 패키지를 추가하면 App.config를 읽는 코드는 그대로 동작한다는 점입니다. 이는 마이그레이션의 첫 단계로서 유효한 수단이지만, 그대로 방치하지 말고 appsettings.json으로의 마이그레이션을 계획에 포함시켜야 합니다. 로깅 프로바이더 같은 주변 라이브러리도 대부분 appsettings.json을 전제로 마이그레이션되어 있어, App.config 읽기만을 위해 오래된 방식을 남겨 둘 이유는 점점 줄어듭니다. 14

두 번째는 Settings.settings의 사용자 스코프 설정입니다. .NET Framework에서는 Properties.Settings.Default.Save()를 호출하기만 하면 사용자별 설정이 자동으로 저장되는 방식이 있었지만, .NET에는 이에 대응하는 자동 생성 방식이 표준으로 마련되어 있지 않습니다. 마이그레이션할 때는 5장에서 보여준 것과 같은 자체 저장 클래스를 준비해야 합니다.

마이그레이션의 또 다른 측면──의존 라이브러리의 호환성, COM 연동 여부, 배포 방식 재검토 등, 구성 관리 이외에 확인해야 할 항목의 전체 그림은 “.NET Framework→.NET 마이그레이션 전 체크리스트“에서 검토 관점을 정리해 두었으니, 마이그레이션 프로젝트 초기 단계에서 한 번 훑어보는 것을 권장합니다.

정리

.NET의 구성 관리는 IConfiguration에 의한 프로바이더의 중첩, IOptions 패밀리에 의한 타입 안전한 값 수신, 환경 변수에 의한 환경 전환이라는 3가지 방식의 조합으로 이루어져 있습니다. 여기까지는 많은 앱에서 동일하게 사용할 수 있지만, Windows 업무 앱 고유의 고민은 그 다음──쓰기 가능한 설정의 배치 위치, 비밀 정보의 보호, 실행 중 설정 변경을 어디까지 허용할 것인가, 그리고 app.config 세대의 자산을 어떻게 정리할 것인가──에 집중됩니다.

“appsettings.json에 전부 적혀 있다”는 상태에서 한 걸음 나아가, 설정의 종류별로 배치 위치와 다루는 방식을 나누어 봅시다. 이 글의 판단표가 그 정리의 출발점이 된다면 다행입니다. 기존 앱의 구성 관리 정비나 app.config에서의 마이그레이션 방침 상담은, 실제 설정 파일과 배포 환경을 보지 않고서는 최적의 답을 찾기 어려운 경우가 많으므로, 고민이 되신다면 상담해 주십시오.

관련 기사

관련 상담 분야

합동회사 코무라소프트는 Windows 업무 앱의 구성 관리 설계, 환경별 설정의 운영 설계, 기존 app.config 자산에서의 마이그레이션 방침에 대한 기술 상담을 다루고 있습니다.

참고 링크

  1. Microsoft Learn, Configuration in .NET - Alternative hosting approach. Host.CreateApplicationBuilder가 기본으로 구성하는 구성 프로바이더의 우선순위(명령줄 인수 → 환경 변수 → 개발 환경의 user secrets → appsettings.{Environment}.json → appsettings.json)에 대해 설명합니다.  2 3

  2. Microsoft Learn, .NET Generic Host - Host builder settings. Host.CreateApplicationBuilder가 읽어들이는 호스트 구성(DOTNET_ 접두사의 환경 변수, 명령줄 인수)과 앱 구성(appsettings.json, appsettings.{Environment}.json, Secret Manager, 환경 변수, 명령줄 인수)의 기본 순서에 대해 설명합니다.  2

  3. Microsoft Learn, Use the .NET Generic Host in a Windows Forms app. Windows Forms 앱에 Generic Host를 통합해 DI・구성・로깅을 이용하는 절차에 대해 설명합니다. 

  4. Microsoft Learn, ASP.NET Core runtime environments - Environment variables that determine the runtime environment. DOTNET_ENVIRONMENTASPNETCORE_ENVIRONMENT의 관계, WebApplication 사용 시 DOTNET_ENVIRONMENT가 우선된다는 점, 설정되지 않았을 때의 기본값이 Production이라는 점, Windows에서는 환경 변수 이름의 대소문자를 구분하지 않지만 Linux에서는 구분한다는 점에 대해 설명합니다.  2

  5. Microsoft Learn, Options pattern in .NET - Options interfaces. IOptions<TOptions>IOptionsSnapshot<TOptions>IOptionsMonitor<TOptions>의 생존 기간・설정 변경 반영 시점・대응 기능의 차이에 대해 설명합니다.  2

  6. Microsoft Learn, Options pattern in .NET - Options validation. ValidateDataAnnotations()에 의한 DataAnnotations 검증과 ValidateOnStart()(또는 AddOptionsWithValidateOnStart)에 의한 시작 시 검증 설정 방법에 대해 설명합니다.  2 3

  7. Microsoft Learn, Safe storage of app secrets in development in ASP.NET Core - Use the Secret Manager tool. Secret Manager가 비밀 정보를 암호화하지 않고 %APPDATA%\Microsoft\UserSecrets\<user_secrets_id>\secrets.json에 평문으로 저장한다는 점, 개발 전용이며 신뢰할 수 있는 저장소로 취급해서는 안 된다는 점에 대해 설명합니다.  2

  8. Microsoft Learn, ProtectedData Class. DPAPI(Data Protection API)를 감싸는 ProtectedData.Protect/Unprotect 메서드, DataProtectionScope.CurrentUser/LocalMachine에 따른 스코프 차이, Windows 전용이며 그 외 플랫폼에서는 PlatformNotSupportedException이 발생한다는 점에 대해 설명합니다.  2

  9. Microsoft Learn, Configuration providers in .NET. JSON・환경 변수・명령줄・INI・XML 등 성질이 다른 구성 소스를 IConfiguration 뒤로 통합하는 구성 프로바이더 방식에 대해 설명합니다. 

  10. Microsoft Learn, Configuration providers in .NET - Environment variable configuration provider. 기본 구성이 DOTNET_ 접두사가 붙은 환경 변수・명령줄 인수를 호스트 구성・앱 구성에 읽어들이는 한편, 이것이 사용자 구성에는 사용되지 않는다는 점과 사용자 지정 접두사 추가 방법에 대해 설명합니다. 

  11. Microsoft Learn, Environment.GetFolderPath Method. Environment.SpecialFolder 열거형과 GetFolderPath 메서드에 의한 특수 폴더 경로 가져오기 방법에 대해 설명합니다. 

  12. Microsoft Learn, Detect changes with change tokens in ASP.NET Core - Monitor for configuration changes. AddJsonFilereloadOnChange 파라미터와, PhysicalFileProvider가 내부에서 FileSystemWatcher를 사용해 설정 파일의 변경을 감시하는 방식에 대해 설명합니다. 

  13. Microsoft Learn, Options pattern in .NET - IOptionsMonitor. IOptionsMonitor의 변경 알림이 파일 시스템 기반 구성 프로바이더로 한정된다는 점, Docker 컨테이너나 네트워크 공유에서 변경 알림이 확실하지 않을 때 DOTNET_USE_POLLING_FILE_WATCHER 환경 변수로 4초 간격의 폴링 감시로 전환할 수 있다는 점에 대해 설명합니다. 

  14. Microsoft Learn, Modernize after upgrading to .NET from .NET Framework - App.config. App.config에서 appsettings.json으로의 마이그레이션 절차, System.Configuration.ConfigurationManager NuGet 패키지를 통한 호환성 유지, Microsoft.Extensions.Configuration.Json 패키지 이용에 대해 설명합니다.  2

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

appsettings.json은 exe와 같은 폴더에 두어도 됩니까?
읽기 전용 기본값이라면 문제가 없습니다. 다만 그 폴더가 Program Files 아래에 설치되는 경우, 앱 스스로 appsettings.json을 다시 쓰는 설계로는 만들 수 없습니다. 표준 사용자에게는 Program Files 아래에 대한 쓰기 권한이 없어 실행 시 예외가 발생하거나, Windows의 가상화 기능으로 사용자마다 보이는 내용이 달라져 버립니다. 쓰기가 필요한 설정은 %LOCALAPPDATA%나 %ProgramData% 아래의 별도 파일로 분리하십시오.
환경 변수와 appsettings.json 중 어느 쪽을 우선해야 합니까?
기본 우선순위를 바꾸지 말고, appsettings.json → appsettings.{Environment}.json → 환경 변수 → 명령줄 인수 순으로 덮어써지는 구조를 그대로 사용하는 것이 기본입니다. 판단이 어려울 때의 기준은 '값의 성격'입니다. 빌드 산출물에 동봉해도 되는 기본값은 appsettings.json, 배포 대상이나 컨테이너마다 바꾸는 값은 환경 변수로 역할을 나누면 나중에 봐도 헷갈리지 않습니다.
설정 클래스는 어떻게 분할해야 합니까?
기능이나 책임 단위로 옵션 클래스를 나누는 것이 기본입니다. 연결 문자열이라면 ConnectionOptions, 외부 API 연동이라면 ExternalApiOptions처럼, 하나의 클래스에 무관한 설정을 몰아넣지 않도록 합니다. 분리해 두면 IOptionsSnapshot/IOptionsMonitor에서의 검증・재로드 단위도 자연스럽게 나뉘고, DataAnnotations를 통한 검증도 클래스별로 완결됩니다.
INI 파일이나 레지스트리에서 마이그레이션해야 합니까?
구성 관리를 새로 만든다면 appsettings.json + IConfiguration으로 통일하는 것을 권장합니다. .NET은 INI 구성 프로바이더도 제공하므로, 기존 INI 파일을 그대로 읽으면서 단계적으로 마이그레이션하는 것도 가능합니다. 레지스트리는 .NET 구성 프로바이더의 표준 범위가 아니므로, 레지스트리에 의존한 기존 자산이 있다면 읽기 전용 브리지 코드를 작성해 단계적으로 appsettings.json 쪽으로 옮겨가는 것이 현실적입니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기