appsettings.json만이 전부가 아닙니다 ── Windows 업무 앱의 구성 관리 실무(환경별 설정·비밀 정보·쓰기 위치)
· 업데이트: · Go Komura · CSharp, .NET, appsettings.json, IConfiguration, IOptions, Generic Host, 구성 관리, Windows 개발, 기술 상담
수정 이력(5건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙이고, 구성 가이드의 破棄를 폐기로 고쳤습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 기사 서두에 「이 기사의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건)에 대응하여 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하십시오.
- 독자별로 어디서부터 읽을지에 대한 표를 도입부에 추가했습니다. 샘플의 자사명을 일반적인 이름으로 바꾸고, 래퍼 배치 예(배치 자체는 `sc create`로 서비스를 등록할 수 없다는 제약 포함), `DOTNET_USE_POLLING_FILE_WATCHER`의 검증 시점과 값 보정, DPAPI의 entropy 위치(실행 파일에 임베드되는 이상, 분석되면 읽을 수 있는 방어라는 점)를 보강했습니다.
- 최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.21635390)
아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.
Go Komura (2026). 「appsettings.json만이 전부가 아닙니다 ── Windows 업무 앱의 구성 관리 실무(환경별 설정·비밀 정보·쓰기 위치)」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/dotnet-configuration-management-guide/
- DOI(등록된 아카이브)
- 10.5281/zenodo.21635390
- DOI(마지막 등록 버전)
- 10.5281/zenodo.21635391
「연결 문자열을 환경마다 바꾸고 싶다」「사용자별 표시 설정을 저장하고 싶다」「API 키를 appsettings.json에 그대로 적어 버렸다」. Windows 업무 앱의 구성 관리는 appsettings.json을 하나 두고 IConfiguration에서 읽는 데까지는 누구나 도달합니다. 그 이후, 즉 환경별 설정, 쓰기가 가능한 설정의 위치, 비밀 정보의 취급, 실행 중 설정 변경은 의외로 정리되지 않은 채 운영에 실리는 경우가 많습니다.
이 기사에서는 .NET 구성 시스템의 기반인 IConfiguration과 프로바이더의 겹침부터, 환경별 설정, IOptions 패턴으로 타입 안전하게 받는 방법, 쓰기가 가능한 설정의 위치, 비밀 정보의 취급, 실행 중 설정 변경의 현실적인 한계, 그리고 app.config/Settings.settings에서의 마이그레이션 맵까지, 실무에서 판단이 갈리는 순서로 정리합니다.
전체 8장으로 길기 때문에, 목적별 읽는 순서를 먼저 제시합니다.
| 독자의 상황 | 권장 읽는 순서 |
|---|---|
| 지금부터 구성 관리를 새로 설계한다 | 제1장(판단표)→ 제2〜4장(기반·환경별 설정·옵션 패턴). 제5〜6장은 설정 쓰기나 비밀 정보가 나온 시점에 읽으면 충분합니다 |
기존 앱의 app.config에서 마이그레이션한다 |
제8장(마이그레이션 맵)→ 제1장(판단표)→ 제2장. 이전 대상의 받아 줄 구조를 먼저 파악한 뒤, 대응표로 항목을 하나씩 지워 가는 순서가 빠릅니다 |
| 「설정을 바꿨는데 반영되지 않는다」를 조사하고 있다 | 제2장(프로바이더 우선순위)과 제7장(reloadOnChange의 한계). 원인의 대부분은 이 둘 중 하나입니다 |
| 비밀 정보의 위치만 알고 싶다 | 제6장. 개발 시와 프로덕션에서 메커니즘이 다르다는 점만 먼저 잡으십시오 |
| 설정의 저장 위치(사용자별/머신별)만 알고 싶다 | 제5장 |
1. 먼저 결론
구성 관리의 판단은 「설정의 종류」마다 「위치」를 정하는 데서 시작합니다. 먼저 전체 그림을 판단표로 정리합니다.
| 설정의 종류 | 구체 예 | 위치의 1순위 후보 | 이유 |
|---|---|---|---|
| 앱 기본값 | 로그 레벨 기본값, UI 기본 파라미터 | appsettings.json |
빌드 산출물에 포함되는, 환경에 의존하지 않는 공통 값 |
| 환경별 설정 | 검증 환경/프로덕션에서 바꾸는 연결 문자열, API 엔드포인트 | appsettings.{Environment}.json + 환경 변수 |
기본 덮어쓰기 순서를 그대로 쓸 수 있다 |
| 사용자별 설정 | 마지막으로 연 폴더, 창 위치, 개인 표시 설정 | %LOCALAPPDATA%(또는 %APPDATA%) 아래의 자체 파일 |
사용자 단위로 쓸 수 있는 영역이 필요하다 |
| 머신별 설정(전체 사용자 공유) | 장치의 COM 포트 번호, 라이선스 서버 주소 | %ProgramData% 아래의 자체 파일 |
관리자가 머신당 하나 설정하고 모든 사용자가 공유한다 |
| 비밀 정보 | 연결 문자열의 비밀번호, API 키, 토큰 | DPAPI 보호 파일(프로덕션), user-secrets(개발 시에만) | 평문으로 두지 않는다. 개발용 메커니즘을 프로덕션에 그대로 쓰지 않는다 |
| 실행 중에 바뀌는 값 | 기능 플래그, 로그 레벨의 동적 변경 | appsettings.json(reloadOnChange) + IOptionsMonitor |
재시작 없이 반영하고 싶은 값에만 한정해 쓴다 |
이 표를 전제로 한 결론을 먼저 적습니다.
- 구성의 우선순위는 「나중에 추가한 프로바이더가 이긴다」는 한 가지 규칙입니다. 기본에서는
appsettings.json→appsettings.{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)나 Credential Manager를 씁니다. user-secrets는 암호화되어 있지 않으며 개발 전용 메커니즘이라는 점이 공식 문서에 명시되어 있습니다. 78
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 25건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. .NET 구성 시스템의 기반 ── IConfiguration과 프로바이더
.NET의 구성 시스템은 IConfiguration이라는 하나의 키·값 저장소처럼 보이는 이면에서, 여러 구성 프로바이더를 겹쳐 읽는 구조입니다. JSON, 환경 변수, 명령줄 인수, INI, XML, 메모리상의 컬렉션처럼 성질이 다른 소스를 같은 IConfiguration 인터페이스 뒤에 통합할 수 있는 점이 이 구조의 이점입니다. 9
프로바이더는 「나중에 추가한 쪽이 이긴다」는 단순한 규칙으로 쌓입니다. 같은 키가 여러 프로바이더에 있으면, 마지막에 추가된 프로바이더의 값이 유효합니다. 1
Host.CreateApplicationBuilder(args)를 쓰면 다음 순서로 프로바이더가 기본 조립됩니다(번호가 클수록 우선순위가 높습니다. 즉 나중 값이 이깁니다). 2
appsettings.jsonappsettings.{Environment}.json- Secret Manager(개발 환경일 때만)
- 환경 변수
- 명령줄 인수
콘솔 앱·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 같은 개발 시 메커니즘은 로컬 개발에서만 쓰이므로, 프로덕션 운영에서 환경을 전환하는 수단을 따로 마련해야 합니다. 대표적인 넘기는 방법은 다음 세 가지입니다.
- 머신 단위 환경 변수로 설정한다.
setx DOTNET_ENVIRONMENT Production /M처럼 영속화하면 그 머신에서 도는 모든 프로세스에 이어집니다. 다만 한 머신에 여러 환경의 앱을 같이 두고 싶을 때는 맞지 않습니다. - Windows 서비스의 시작 프로세스에 환경 변수를 넘긴다. 서비스로 상주시키는 경우 대화 사용자 세션과 다른 문맥에서 시작되므로, 사용자 환경 변수는 이어지지 않습니다. 실행 계정과 세션 분리의 자세한 내용은 「Windows 서비스 만드는 법과 운영」을 참조하십시오. 머신 단위 환경 변수, 또는 서비스 실행 파일을 얇은 래퍼 배치로 감싸
SET한 뒤 본체를 시작하는 구성이 현실적입니다(배치 내용은 아래에 보입니다). - 작업 스케줄러로 시작하는 경우는 명령줄 인수로 넘기는 편이 확실합니다. 작업 스케줄러의 「동작」에서 환경 변수를 직접 설정하는 UI가 없으므로,
--environment Production처럼 명령줄 인수로 넘기고AddCommandLine(args)로 읽게 하는 편이 사고가 적습니다. 작업 스케줄러 특유의 실행 계정·로그온 종류 버릇은 「작업 스케줄러의 작업이 실행되지 않거나 0x1로 끝난다」에 정리해 두었습니다.
두 번째로 든 래퍼 배치는 다음 세 줄이면 됩니다.
@echo off
set DOTNET_ENVIRONMENT=Production
"%~dp0MyApp.Service.exe" %*
%~dp0은 배치 자신이 있는 폴더(끝에 \가 붙습니다)이므로, 작업 디렉터리가 어디든 옆의 실행 파일을 시작할 수 있습니다. set은 이 프로세스와 그 자식 프로세스에만 적용되므로, 같은 머신에 다른 환경의 앱을 같이 두어도 간섭하지 않습니다. 다만 이 배치 자체를 sc create로 서비스로 직접 등록할 수는 없습니다. 서비스로 등록할 수 있는 것은 서비스 제어에 응답하는 실행 파일뿐이며, 배치를 지정하면 시작 시 타임아웃(오류 1053)이 납니다. 이 방식을 쓸 수 있는 것은 서비스 래퍼를 통해 시작하는 경우나, 다음에 든 작업 스케줄러에서 시작하는 경우입니다. 서비스 본체를 그대로 등록하려면 머신 단위 환경 변수나 명령줄 인수 방식을 고르십시오.
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));
받는 방법에는 세 가지 인터페이스가 있고, 성질이 각각 다릅니다. 5
| 인터페이스 | 등록의 수명 | 설정 변경 반영 | 주요 용도 |
|---|---|---|---|
IOptions<T> |
싱글톤 | 반영되지 않음(시작 시 한 번만 계산) | 바뀌지 않는다는 전제의 설정. 가장 단순 |
IOptionsSnapshot<T> |
스코프 | 스코프가 다시 만들어질 때마다 재계산 | Web의 요청 스코프처럼 스코프가 분명한 문맥 |
IOptionsMonitor<T> |
싱글톤 | 변경 알림(OnChange)과 함께 언제든 최신 값을 취득 |
상주 서비스처럼 변경을 그 자리에서 감지하고 싶은 문맥 |
데스크톱 앱이나 Windows 서비스처럼 HTTP 요청 스코프가 없는 앱에서는 IOptionsSnapshot<T>를 써도 스코프를 직접 만들지 않는 한 실질적으로 IOptions<T>와 같은 동작이 됩니다. 변경을 감지하려면 그냥 IOptionsMonitor<T>를 쓴다는 선택으로 고민이 줄어듭니다.
설정 값(BaseUrl·ApiKey·타임아웃)은 호출마다 IOptionsMonitor에서 가져오지만, HttpClient 자체는 IHttpClientFactory에서 가져와 재사용합니다. HttpClient를 호출마다 new하고 Dispose하면 내부 소켓·연결 풀을 매번 폐기·재구축하게 되어, 빈번한 폴링이나 배치 처리에서는 ephemeral 포트 고갈로 이어집니다. 값은 바뀌어도 연결 재사용은 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));처럼 named client를 등록해 둡니다. 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, "YourCompany", "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.ProtectedData의 Protect/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);
}
}
위 코드에서 쓰는 엔트로피(optionalEntropy)의 역할을 보강합니다. DPAPI의 CurrentUser 스코프는 「같은 사용자·같은 머신이면 복호화할 수 있다」는 보호이므로, 엔트로피를 지정하지 않으면 같은 사용자 권한으로 도는 다른 앱에서도 복호화할 수 있습니다. 엔트로피를 넘겨 암호화한 경우 복호화 때에도 같은 값을 넘기지 않으면 복호화할 수 없다고 공식 문서에 명시되어 있습니다. 8 즉 엔트로피는 「같은 사용자·같은 머신에서 도는 다른 앱의 복호화」를 막기 위한 앱 고유의 암호입니다. 다만 그 암호는 실행 파일 안에 임베드되는 이상, 분석하면 읽을 수 있는 정도의 방어라는 점은 이해하고 쓰십시오(이용자 본인에 의한 복호화를 막는 수단은 아닙니다).
DPAPI로 보호한 값을 어디에 저장할지, CurrentUser와 LocalMachine 중 어느 스코프를 고를지, 여러 사용자가 같은 머신에서 쓰는 업무 앱에서의 함정 같은 구현 수준 상세는 「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환경 변수를1또는true로 하면 4초 간격 폴링 감시로 바뀝니다(간격은 바꿀 수 없습니다). 13 이 「4초·변경 불가」라는 사양은 집필 시점(2026년 7월)의 Microsoft Learn 「옵션 패턴 - .NET」(2025년 10월 갱신) 기술로 확인한 것입니다. 버전을 명시하지 않은 문서이므로, 오래 운영할 구성의 전제로 둘 때는 같은 페이지의 최신판을 확인하십시오.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 같은 자동 생성 메커니즘은 없다. 직접 직렬화/저장한다(저장 클래스 구현 예는 제5장) |
Settings.settings(애플리케이션 스코프) |
appsettings.json |
읽기 전용 기본값으로 다룬다 |
<connectionStrings>의 암호화(aspnet_regiis 등) |
DPAPI(ProtectedData) |
암호화 메커니즘 자체가 바뀐다. 마이그레이션 때 다시 만들어야 한다 |
마이그레이션 때 자주 밟는 함정이 두 가지 있습니다.
첫째는 System.Configuration.ConfigurationManager NuGet 패키지를 추가하면 App.config를 읽는 코드는 그대로 동작한다는 점입니다. 이것은 마이그레이션 1단계로 유효한 수단이지만, 그대로 두지 말고 appsettings.json으로의 이전을 계획에 넣어야 합니다. 로깅 프로바이더 같은 주변 라이브러리도 거의 appsettings.json 전제로 옮겨 가고 있어, App.config 읽기만을 위해 옛 메커니즘을 남길 이유는 옅어집니다. 14
둘째는 Settings.settings의 사용자 스코프 설정입니다. .NET Framework에서는 Properties.Settings.Default.Save()만 호출하면 사용자별 설정이 자동 저장되는 메커니즘이 있었지만, .NET에는 이에 해당하는 자동 생성 메커니즘이 표준으로는 준비되어 있지 않습니다. 마이그레이션 때에는 제5장에서 보인 것 같은 자체 저장 클래스를 마련해야 합니다.
마이그레이션의 또 다른 측면──의존 라이브러리 호환성, COM 연동 유무, 배포 방식 재검토 등 구성 관리 외에 확인할 항목의 전체 그림은 「.NET Framework→.NET 마이그레이션 전 체크리스트」에서 점검 관점을 정리해 두었으므로, 마이그레이션 프로젝트 초기 단계에서 한 번 훑어 두는 것을 권합니다.
정리
.NET의 구성 관리는 IConfiguration에 의한 프로바이더의 겹침, IOptions 패밀리에 의한 형 안전한 수신, 환경 변수에 의한 환경 전환이라는 세 가지 메커니즘의 조합입니다. 여기까지는 많은 앱에서 비슷하게 쓸 수 있지만, Windows 업무 앱 고유의 고민은 그 이후, 즉 쓰기가 가능한 설정의 위치, 비밀 정보 보호, 실행 중 설정 변경을 어디까지 허용할지, 그리고 app.config 세대의 자산을 어떻게 접을지에 모입니다.
「appsettings.json에 전부 적혀 있다」는 상태에서 한 걸음 나아가, 설정의 종류마다 위치와 다루는 법을 나눕니다. 이 기사의 판단표가 그 정리의 출발점이 되면 좋겠습니다. 기존 앱의 구성 관리 재고나 app.config에서의 마이그레이션 방침 상담은, 실제 설정 파일과 배포 환경을 보면서가 아니면 최적안이 잘 보이지 않는 경우가 많으므로, 막히면 상담해 주십시오.
관련 기사
- Generic Host란 무엇인가
- Generic Host + BackgroundService를 데스크톱 앱에서 쓰기
- 작업 스케줄러의 작업이 실행되지 않거나 0x1로 끝난다
- Windows 서비스 만드는 법과 운영
- Windows 앱의 데이터 저장 위치 고르는 법
- Windows 사용자 프로필의 구조
- Windows 앱의 기밀 정보 저장 - DPAPI로 평문 설정을 피하기
- FileSystemWatcher 실무 가이드
- .NET Framework→.NET 마이그레이션 전 체크리스트
관련 상담 영역
合同会社小村ソフト에서는 Windows 업무 앱의 구성 관리 설계, 환경별 설정 운영 설계, 기존 app.config 자산에서의 마이그레이션 방침에 대한 기술 상담을 다룹니다.
참고 링크
-
Microsoft Learn, Configuration in .NET - Alternative hosting approach.
Host.CreateApplicationBuilder가 기본으로 조립하는 구성 프로바이더의 우선순위(명령줄 인수→환경 변수→개발 환경의 user secrets→appsettings.{Environment}.json→appsettings.json)에 대해. ↩ ↩2 ↩3 -
Microsoft Learn, .NET Generic Host - Host builder settings.
Host.CreateApplicationBuilder가 읽는 호스트 구성(DOTNET_접두사 환경 변수, 명령줄 인수)과 앱 구성(appsettings.json, appsettings.{Environment}.json, Secret Manager, 환경 변수, 명령줄 인수)의 기본 순서에 대해. ↩ ↩2 -
Microsoft Learn, Use the .NET Generic Host in a Windows Forms app. Windows Forms 앱에서 Generic Host를 넣고 DI·구성·로깅을 쓰는 절차에 대해. ↩
-
Microsoft Learn, ASP.NET Core runtime environments - Environment variables that determine the runtime environment.
DOTNET_ENVIRONMENT와ASPNETCORE_ENVIRONMENT의 관계,WebApplication사용 시DOTNET_ENVIRONMENT가 우선된다는 점, 미설정 시 기본값이Production이라는 점, Windows에서는 환경 변수 이름이 대소문자를 구분하지 않지만 Linux에서는 구분한다는 점에 대해. ↩ ↩2 -
Microsoft Learn, Options pattern in .NET - Options interfaces.
IOptions<TOptions>·IOptionsSnapshot<TOptions>·IOptionsMonitor<TOptions>의 수명·설정 변경 반영 타이밍·대응 기능의 차이에 대해. ↩ ↩2 -
Microsoft Learn, Options pattern in .NET - Options validation.
ValidateDataAnnotations()에 의한 DataAnnotations 검증과,ValidateOnStart()(또는AddOptionsWithValidateOnStart)에 의한 시작 시 검증 설정 방법에 대해. ↩ ↩2 ↩3 -
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 -
Microsoft Learn, ProtectedData Class. DPAPI(Data Protection API)를 래핑하는
ProtectedData.Protect/Unprotect메서드,DataProtectionScope.CurrentUser/LocalMachine에 의한 스코프 차이, Windows 전용이며 그 외 플랫폼에서는PlatformNotSupportedException이 난다는 점에 대해. ↩ ↩2 ↩3 -
Microsoft Learn, Configuration providers in .NET. JSON·환경 변수·명령줄·INI·XML처럼 성질이 다른 구성 소스를
IConfiguration뒤에 통합하는 구성 프로바이더의 구조에 대해. ↩ -
Microsoft Learn, Configuration providers in .NET - Environment variable configuration provider. 기본 구성이
DOTNET_접두사 환경 변수·명령줄 인수를 호스트 구성·앱 구성에 읽는 한편, 이것이 사용자 구성에는 쓰이지 않는다는 점, 커스텀 접두사 추가 방법에 대해. ↩ -
Microsoft Learn, Environment.GetFolderPath Method.
Environment.SpecialFolder열거형과GetFolderPath메서드로 특수 폴더 경로를 얻는 방법에 대해. ↩ -
Microsoft Learn, Detect changes with change tokens in ASP.NET Core - Monitor for configuration changes.
AddJsonFile의reloadOnChange매개변수와,PhysicalFileProvider가 내부에서FileSystemWatcher로 설정 파일 변경을 감시하는 구조에 대해. ↩ -
Microsoft Learn, Options pattern in .NET - IOptionsMonitor.
IOptionsMonitor의 변경 알림이 파일 시스템 기반 구성 프로바이더에 한정된다는 점, Docker 컨테이너나 네트워크 공유에서 변경 알림이 확실하지 않을 때DOTNET_USE_POLLING_FILE_WATCHER환경 변수로 4초 간격 폴링 감시로 바꿀 수 있다는 점에 대해. ↩ -
Microsoft Learn, Modernize after upgrading to .NET from .NET Framework - App.config.
App.config에서appsettings.json으로의 마이그레이션 절차,System.Configuration.ConfigurationManagerNuGet 패키지에 의한 호환 유지,Microsoft.Extensions.Configuration.Json패키지 이용에 대해. ↩ ↩2
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows 프린터 드라이버 제공 종료 ── 업무 앱의 장표·라벨 인쇄는 어떻게 대비할 것인가
Microsoft는 v3/v4 프린터 드라이버의 제공 종료를 단계적으로 진행하고 있으며, 2026년 7월부터는 IPP 클래스 드라이버가 우선됩니다. Windows protected print mode에서 무엇이 사라지는지, 업무 앱의 장표·라벨 ...
WinForms/WPF 앱의 다국어화 ── resx·satellite assembly·culture 전환의 실무
Windows 데스크톱 앱의 다국어화를 정리합니다. CurrentCulture와 CurrentUICulture의 차이, resx와 satellite assembly의 구조, WPF에서 현실적인 방식 선택, 런타임 언어 전환, 서식·RTL까지 설명...
CSV는 「그냥 텍스트」가 아닙니다 ── C# 업무 앱의 CSV 실무(문자 코드·Excel 호환·인젝션 대책)
업무 앱 CSV 입출력의 전형적인 사고──Split(',') 자체 파싱, BOM 없는 UTF-8의 문자 깨짐, 앞자리 0 소실──을 정리하고, RFC 4180 규칙, Shift_JIS 취급, TextFieldParser를 이용한 안전한 읽기까지 ...
HttpClient를 using으로 감싸면 안 됩니다 ── C# 업무 앱의 HTTP 통신 실무(생성 패턴·타임아웃·재시도)
C#의 HttpClient는 매번 using으로 생성하면 소켓이 고갈되고, static으로 두면 DNS 변경을 따라가지 않습니다. PooledConnectionLifetime과 IHttpClientFactory를 쓰는 올바른 생성 패턴, 타임아웃...
PerfView와 dotnet-trace로 「느림」을 특정한다 ── .NET 성능 조사의 실무 입문
업무 앱이 「느리다」「CPU가 붙어 있다」일 때, 어떤 도구로 무엇을 볼지. PerfView와 dotnet-trace의 역할 분담, CPU 샘플링 읽는 법, ThreadTime으로 블록 시간을 조사하는 절차까지 실무 조사 흐름을 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
Generic Host & 앱 아키텍처
Generic Host, BackgroundService, DI, 구성, 로깅, 앱 수명 설계를 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
구성 관리·설정 파일 설계는 Windows 앱 개발의 실무 상담 범위이기 때문입니다.
기술 상담 & 설계 리뷰
기존 app.config에서의 마이그레이션 방침 판단은 설계 리뷰가 따르는 기술 상담에 해당하기 때문입니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 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 쪽으로 모으는 것이 현실적입니다.