diff --git a/dist/README.txt b/dist/README.txt
index f6c31d3..f653698 100644
--- a/dist/README.txt
+++ b/dist/README.txt
@@ -1,5 +1,5 @@
UbisamBase 플랫폼 배포 폴더 (빌드할 때 자동 생성 - 직접 고치지 마세요)
-갱신 시각 : 2026-09-21 22:04:42
+갱신 시각 : 2026-09-21 22:06:55
빌드 구성 : Release
원본 소스 : D:\업무\03_개발\Source\용접기\08_UbisamPlatform\
----
diff --git a/dist/UbisamBase.Core.dll b/dist/UbisamBase.Core.dll
index 735fad7..9efcf65 100644
Binary files a/dist/UbisamBase.Core.dll and b/dist/UbisamBase.Core.dll differ
diff --git a/dist/UbisamBase.Launcher.exe b/dist/UbisamBase.Launcher.exe
index 1cecfa8..3c93597 100644
Binary files a/dist/UbisamBase.Launcher.exe and b/dist/UbisamBase.Launcher.exe differ
diff --git a/dist/UbisamBase.Shell.exe b/dist/UbisamBase.Shell.exe
index 007a217..a4639b1 100644
Binary files a/dist/UbisamBase.Shell.exe and b/dist/UbisamBase.Shell.exe differ
diff --git a/dist/platform-version.json b/dist/platform-version.json
index 1772d2c..cc83585 100644
--- a/dist/platform-version.json
+++ b/dist/platform-version.json
@@ -1,4 +1,4 @@
{
-"version": "2026-09-21 22:04:42",
+"version": "2026-09-21 22:06:55",
"configuration": "Release"
}
diff --git a/doc/history/changelog.md b/doc/history/changelog.md
index f3da83d..f12ebb4 100644
--- a/doc/history/changelog.md
+++ b/doc/history/changelog.md
@@ -13,3 +13,5 @@
| 2026-09-21 | `ICloseGuard` 추가 — 저장 안 한 변경 확인을 셸이 단일 통로로 처리(하단 "종료"도 이 경로) (건의 4) |
| 2026-09-21 | 플랫폼 스크롤바 색 조정(트랙=테마 판 색, 막대=accent) |
| 2026-09-21 | 플랫폼 자동 업데이트 추가 — Launcher가 켤 때 깃 저장소의 `dist`와 비교해 새 버전이면 받아서 실행(UI Setting에서 사용/자동 여부 선택) |
+| 2026-09-21 | 업데이트 안내창을 플랫폼 디자인으로 교체(Launcher 자체 XAML, 시스템 테마 따름) |
+| 2026-09-21 | `ApiService` 추가 — 사내 API를 앱이 대신 호출(토큰은 설정 파일에만, 호출 간격 제한·타임아웃 포함) |
diff --git a/doc/platform-guide.md b/doc/platform-guide.md
index 4127d53..08a8e6d 100644
--- a/doc/platform-guide.md
+++ b/doc/platform-guide.md
@@ -686,3 +686,54 @@ Launcher(`PlatformUpdater.Settings`)가 같은 이름으로 읽고 쓴다. **이
- 업데이트는 **덮어쓰기**다. 쓰지 않게 된 옛 파일까지 정리하려면 `publish-platform.ps1`로 `bin`을
비우고 다시 채운 뒤 `dist`를 올리면 된다.
+---
+
+## 13. 사내 API 호출 — `UbisamBase.Core.Net.ApiService`
+
+토큰이 필요한 사내 API를 **앱이 대신 호출해** 응답 본문(JSON 문자열)만 돌려준다. 화면(WebView2 등)이
+직접 부르면 토큰이 그 문서에 섞여 들어가므로, 앱이 부르고 값만 넘기는 구조를 공용으로 둔 것이다.
+브라우저에서 막히는 CORS도 이 경로로는 문제가 되지 않는다.
+
+```csharp
+using UbisamBase.Core.Net;
+
+var json = await ApiService.Current.FetchAsync(url); // 실패하면 예외
+var (ok, body, error) = await ApiService.Current.TryFetchAsync(url); // 예외 대신 결과로 받기
+```
+
+- 인증 헤더는 서비스 안에서 붙는다 — **부르는 쪽은 토큰을 몰라도 된다.**
+- 같은 주소를 `MinIntervalMs`(기본 1초) 안에 다시 부르면 서버를 때리지 않고 직전 응답을 돌려준다.
+ 화면 갱신 주기가 짧아도 호출 제한에 걸리지 않게 하기 위한 것이다.
+- 로그에는 주소의 `?` 앞부분만 남긴다(쿼리에 토큰이 섞인 경우 대비). 응답 본문도 남기지 않는다.
+
+### 13-1. 설정 — `D:\UbisamConfig\Settings\ApiSettings.json`
+
+```json
+{
+ "AuthMode": "BearerHeader",
+ "HeaderName": "X-Api-Key",
+ "Token": "...",
+ "MinIntervalMs": 1000,
+ "TimeoutSeconds": 10
+}
+```
+
+| AuthMode | 붙는 헤더 |
+| --- | --- |
+| `None` | 없음(사내망 IP 제한 등) |
+| `BearerHeader` | `Authorization: Bearer {Token}` |
+| `CustomHeader` | `{HeaderName}: {Token}` |
+| `Cookie` | `Cookie: {Token}` |
+
+**토큰은 이 파일에만 있다.** 내보낸 HTML·`.ub` 같은 산출물에는 들어가지 않는다.
+
+### 13-2. 내보낸 파일에서는 인증 API를 못 부른다
+
+앱 밖으로 내보낸 HTML은 혼자 도는 파일이라 이 서비스가 없다. 그래서:
+
+- **앱 안에서 볼 때** → `ApiService`로 최신 값
+- **내보낸 파일을 남에게 줄 때** → 인증이 필요한 API는 호출 불가, 내보낼 때 담긴 마지막 값이 보인다
+ (인증이 없는 공개 API라면 내보낸 파일에서도 실시간으로 돈다)
+
+이는 제약이라기보다 의도된 동작이다 — 토큰이 파일에 들어가지 않기 때문이다.
+
diff --git a/src/UbisamBase.Core/Net/ApiService.cs b/src/UbisamBase.Core/Net/ApiService.cs
new file mode 100644
index 0000000..f1fd3ab
--- /dev/null
+++ b/src/UbisamBase.Core/Net/ApiService.cs
@@ -0,0 +1,229 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading.Tasks;
+using UbisamBase.Core.Bootstrap;
+using UbisamBase.Core.Logging;
+
+namespace UbisamBase.Core.Net;
+
+/// 인증을 어떻게 붙이는지.
+public enum ApiAuthMode
+{
+ /// 인증 없음(사내망 IP 제한 등). 주소만으로 부른다.
+ None,
+
+ /// Authorization: Bearer {토큰}
+ BearerHeader,
+
+ /// {HeaderName}: {토큰} — 사내 API가 고유 헤더를 쓰는 경우.
+ CustomHeader,
+
+ /// Cookie: {토큰} — 세션 쿠키를 그대로 넘기는 경우.
+ Cookie
+}
+
+///
+/// 사내 API를 대신 호출해 응답 본문(JSON 문자열)만 돌려주는 공용 서비스.
+///
+/// 왜 플랫폼에 있나 — 토큰이 필요한 API는 화면(WebView2 등)에서 직접 부르면 토큰이 그
+/// 문서에 섞여 들어간다. 앱이 대신 부르고 숫자만 넘기면 토큰은 앱 설정 파일에만 남는다.
+/// 내보낸 HTML·.ub 같은 파일에는 절대 들어가지 않는다. 브라우저에서 막히는 CORS도 우회된다.
+///
+/// 토큰은 D:\UbisamConfig\Settings\ApiSettings.json에만 저장되고, 로그에도 남기지 않는다.
+///
+public sealed class ApiService
+{
+ private static readonly Logger Log = new("Api");
+ private static readonly HttpClient Http = new();
+ private static ApiService? current;
+
+ private readonly Dictionary cache = new(StringComparer.OrdinalIgnoreCase);
+
+ public static ApiService Current => current ??= new ApiService();
+
+ public ApiSettings Settings { get; private set; } = ApiSettings.Load();
+
+ /// 설정 파일을 다시 읽는다(설정 화면에서 토큰을 바꾼 뒤 등).
+ public void ReloadSettings() => Settings = ApiSettings.Load();
+
+ ///
+ /// 주소를 호출해 응답 본문을 그대로 돌려준다. 인증 헤더는 설정에 따라 이 안에서 붙으므로
+ /// 부르는 쪽(모듈·편집기)은 토큰을 몰라도 된다.
+ ///
+ /// 같은 주소를 안에 다시 부르면 서버를 때리지 않고
+ /// 직전 응답을 돌려준다 — 화면 갱신 주기가 짧아도 호출 제한에 걸리지 않게 하기 위한 것이다.
+ /// 실패하면 예외를 던진다(메시지는 사용자에게 보여줘도 되는 내용만 담는다).
+ ///
+ public async Task FetchAsync(string url)
+ {
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ throw new ArgumentException("주소가 비어 있습니다.", nameof(url));
+ }
+
+ if (TryGetCached(url, out var cached))
+ {
+ return cached;
+ }
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ ApplyAuth(request);
+
+ try
+ {
+ using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(Math.Max(1, Settings.TimeoutSeconds)));
+ using var response = await Http.SendAsync(request, cts.Token).ConfigureAwait(false);
+ var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ // 본문에 토큰이 되비칠 수 있으므로 상태 코드만 남긴다.
+ Log.W($"API 호출 실패 ({(int)response.StatusCode}) : {Safe(url)}");
+ throw new InvalidOperationException($"서버가 {(int)response.StatusCode} 응답을 돌려줬습니다.");
+ }
+
+ lock (cache)
+ {
+ cache[url] = new CacheEntry(body, DateTime.UtcNow);
+ }
+
+ return body;
+ }
+ catch (OperationCanceledException)
+ {
+ Log.W($"API 호출 시간 초과 : {Safe(url)}");
+ throw new InvalidOperationException($"{Settings.TimeoutSeconds}초 안에 응답이 오지 않았습니다.");
+ }
+ catch (HttpRequestException ex)
+ {
+ Log.W($"API 호출 실패 : {Safe(url)} — {ex.Message}");
+ throw new InvalidOperationException("서버에 연결하지 못했습니다.");
+ }
+ }
+
+ /// 예외 대신 성공 여부로 받고 싶을 때. 실패하면 에 사유가 담긴다.
+ public async Task<(bool Ok, string Body, string Error)> TryFetchAsync(string url)
+ {
+ try
+ {
+ return (true, await FetchAsync(url).ConfigureAwait(false), string.Empty);
+ }
+ catch (Exception ex)
+ {
+ return (false, string.Empty, ex.Message);
+ }
+ }
+
+ private bool TryGetCached(string url, out string body)
+ {
+ lock (cache)
+ {
+ if (cache.TryGetValue(url, out var entry) &&
+ (DateTime.UtcNow - entry.At).TotalMilliseconds < Math.Max(0, Settings.MinIntervalMs))
+ {
+ body = entry.Body;
+ return true;
+ }
+ }
+
+ body = string.Empty;
+ return false;
+ }
+
+ private void ApplyAuth(HttpRequestMessage request)
+ {
+ var token = Settings.Token;
+ if (string.IsNullOrEmpty(token))
+ {
+ return;
+ }
+
+ switch (Settings.AuthMode)
+ {
+ case ApiAuthMode.BearerHeader:
+ request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token);
+ break;
+
+ case ApiAuthMode.CustomHeader:
+ var name = string.IsNullOrWhiteSpace(Settings.HeaderName) ? "X-Api-Key" : Settings.HeaderName;
+ request.Headers.TryAddWithoutValidation(name, token);
+ break;
+
+ case ApiAuthMode.Cookie:
+ request.Headers.TryAddWithoutValidation("Cookie", token);
+ break;
+ }
+ }
+
+ /// 로그에 남길 주소 — 쿼리에 토큰이 섞여 있을 수 있으므로 물음표 앞까지만 남긴다.
+ private static string Safe(string url)
+ {
+ var index = url.IndexOf('?');
+ return index < 0 ? url : url.Substring(0, index) + "?...";
+ }
+
+ private readonly struct CacheEntry
+ {
+ public CacheEntry(string body, DateTime at)
+ {
+ Body = body;
+ At = at;
+ }
+
+ public string Body { get; }
+
+ public DateTime At { get; }
+ }
+}
+
+/// ApiService 설정. 토큰이 들어 있으므로 앱 설정 폴더에만 저장한다.
+public sealed class ApiSettings
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ public static string FilePath => Path.Combine(UbisamAppBuilder.ConfigRoot, "Settings", "ApiSettings.json");
+
+ public ApiAuthMode AuthMode { get; set; } = ApiAuthMode.None;
+
+ /// AuthMode가 CustomHeader일 때 쓸 헤더 이름.
+ public string HeaderName { get; set; } = "X-Api-Key";
+
+ /// 토큰/키/쿠키 값. 이 파일 밖으로 나가지 않는다.
+ public string Token { get; set; } = string.Empty;
+
+ /// 같은 주소를 이 시간 안에 다시 부르면 서버 대신 직전 응답을 쓴다(호출 제한 대비).
+ public int MinIntervalMs { get; set; } = 1000;
+
+ public int TimeoutSeconds { get; set; } = 10;
+
+ public static ApiSettings Load()
+ {
+ try
+ {
+ if (File.Exists(FilePath))
+ {
+ return JsonSerializer.Deserialize(File.ReadAllText(FilePath), JsonOptions) ?? new ApiSettings();
+ }
+ }
+ catch
+ {
+ // 손상된 파일은 무시하고 기본값(인증 없음)으로 간다.
+ }
+
+ return new ApiSettings();
+ }
+
+ public void Save()
+ {
+ var dir = Path.GetDirectoryName(FilePath);
+ if (!string.IsNullOrEmpty(dir))
+ {
+ Directory.CreateDirectory(dir);
+ }
+
+ File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions));
+ }
+}
diff --git a/src/UbisamBase.Core/UbisamBase.Core.csproj b/src/UbisamBase.Core/UbisamBase.Core.csproj
index 1a4a6a5..79fcd19 100644
--- a/src/UbisamBase.Core/UbisamBase.Core.csproj
+++ b/src/UbisamBase.Core/UbisamBase.Core.csproj
@@ -12,6 +12,8 @@
+
+