UI 직접 파일 접근

UI 직접 파일 접근

개요

UI에서 파일 시스템에 직접 접근하지 못하게 만드는 것은 규칙 선언만으로 끝나지 않으며, Rust Backend와 WPF Frontend 사이의 경계를 코드와 빌드 단계에서 동시에 닫아야 한다.

이 사례의 핵심은 “좋은 구조를 권장"하는 것이 아니다.
UI가 우회로를 선택하려는 순간, 컴파일 단계에서 문을 잠그는 것이다.

이 문서의 질문

이 문서는 하네스 개요에서 정리한 다섯 공통 패턴 중 direct path bypass에 가장 가깝다. 핵심 질문은 다음과 같다.

UI가 시스템 자원에 직접 닿는 경로를 정말 닫았는가

문제

Atlas 같은 구조에서 가장 쉽게 무너지는 지점 중 하나는 WPF 프론트엔드가 파일 시스템에 직접 손을 대는 경우다.

대표적으로 다음과 같은 코드가 나온다.

  • File.ReadAllText(...)
  • File.WriteAllText(...)
  • Directory.GetFiles(...)
  • Path.Combine(...) 중심의 직접 경로 조립
  • OpenFileDialog 결과를 그대로 비즈니스 로직에 연결하는 처리

이 방식은 처음에는 빠르다. 하지만 곧 다음 문제가 생긴다.

  • UI가 파일 포맷과 저장 전략을 알아야 한다
  • 로깅과 권한 제어가 UI 곳곳으로 흩어진다
  • Rust 백엔드가 있어도 실제 시스템 접근은 C# UI가 해버린다
  • AI는 가장 짧은 경로를 계속 재생산한다

즉 문제는 파일 API 사용 자체가 아니라, 접근 경로가 구조 밖으로 새는 것이다.

문제의 본질

이 문서의 핵심은 파일 API 사용 여부가 아니라 접근 경로를 누가 소유하는가에 있다. UI가 시스템 자원에 직접 닿기 시작하면 경계는 설명으로만 남고, 사람과 AI 모두 가장 짧은 우회 경로를 반복하게 된다.

목표 구조

Atlas의 이 케이스에서는 경계를 다음처럼 고정한다.

D2 diagram
핵심 규칙은 단순하다.
  • WPF UI는 파일 시스템 API를 직접 호출할 수 없다

  • 모든 파일 접근은 Rust Backend의 명시적 API를 통해서만 이뤄진다

  • 위반은 코드 리뷰 대상이 아니라 빌드 실패

하네스 적용 위치

이 케이스에서 하네스는 한 군데가 아니라 세 층에서 작동한다.

구조 레벨

프로젝트 참조와 책임을 먼저 분리한다.

  • Atlas.Frontend.Wpf

  • Atlas.Contracts

  • Atlas.Backend.Host 또는 Atlas.Backend.Bridge

  • Atlas.Backend.Rust

여기서 중요한 점은 프론트엔드가 System.IO를 쓸 수 있더라도, 그 사용이 허용되지 않도록 별도 규칙을 둔다는 것이다. 즉 언어 차원에서 가능한 호출이어도 UI 레이어에서는 구조적으로 금지해야 한다.

검증 레벨

Roslyn Analyzer를 만들어 WPF 프로젝트에서 다음을 탐지한다.

  • System.IO.File

  • System.IO.Directory

  • System.IO.FileInfo

  • System.IO.DirectoryInfo

  • System.IO.Path

  • 필요하다면 Microsoft.Win32.OpenFileDialog 이후 직접 처리 흐름

이 진단은 .editorconfig에서 error로 올려 빌드 실패로 고정한다. 핵심은 팀 규칙으로 남기는 것이 아니라 우회 코드를 커밋 단계에서 막는 것이다.

실행 레벨

Rust Backend만 실제 파일 접근을 수행한다.

  • 파일 읽기

  • 파일 저장

  • 디렉터리 열거

  • 경로 검증

  • 접근 로깅

  • 필요 시 권한 정책

결국 UI는 “무엇을 원한다"만 말하고, Backend는 “무엇을 실제로 허용할지"를 결정한다.

실제 구현

UI 계약을 path가 아니라 논리 식별자로 좁힌다

프론트엔드가 파일 시스템을 직접 만지지 못하게 하려면 먼저 계약부터 좁아야 한다. UI는 path가 아니라 docId, parentId 같은 논리 식별자만 사용하고, 실제 경로 해석은 backend가 맡는다.

public interface IWorkspaceFileService
{
    Task<string> ReadTextAsync(string docId, CancellationToken cancellationToken = default);
    Task SaveTextAsync(string docId, string content, CancellationToken cancellationToken = default);
    Task<IReadOnlyList<WorkspaceEntryDto>> ListEntriesAsync(string parentId, CancellationToken cancellationToken = default);
}

이 순간부터 UI는 workspace root, canonical path, traversal 방어를 몰라도 된다. 대신 그 책임이 backend에 명확하게 모인다.

ViewModel은 파일이 아니라 서비스만 안다

public sealed class ExplorerViewModel
{
    private readonly IWorkspaceFileService _fileService;

    public ExplorerViewModel(IWorkspaceFileService fileService)
    {
        _fileService = fileService;
    }

    public Task LoadDocAsync(string docId)
        => _fileService.ReadTextAsync(docId);
}

이 ViewModel에 File, Directory, Path가 등장하기 시작하면 이미 구조가 흔들린다.

경로 해석과 루트 강제는 backend에 둔다

pub struct WorkspaceService {
    root: PathBuf,
}

impl WorkspaceService {
    pub async fn read_text(&self, req: ReadTextRequest) -> anyhow::Result<ReadTextResponse> {
        let path = self.resolve_doc_path(&req.doc_id)?;
        let content = fs::read_to_string(path).await?;
        Ok(ReadTextResponse { content })
    }

    fn resolve_doc_path(&self, doc_id: &str) -> anyhow::Result<PathBuf> {
        let candidate = self.root.join(format!("{doc_id}.md"));
        let normalized = candidate.canonicalize()?;

        if !normalized.starts_with(&self.root) {
            anyhow::bail!("access denied: out of workspace");
        }

        Ok(normalized)
    }
}

핵심은 기술 스택이 아니다. 접근 가능 여부와 물리 경로는 UI가 아니라 backend가 판단해야 한다는 점이다.

브리지는 좁은 계약만 노출한다

public sealed class WorkspaceFileService : IWorkspaceFileService
{
    private readonly HttpClient _http;

    public async Task<string> ReadTextAsync(string docId, CancellationToken cancellationToken = default)
    {
        var response = await _http.PostAsJsonAsync("/workspace/read-text", new { docId }, cancellationToken);
        response.EnsureSuccessStatusCode();
        var payload = await response.Content.ReadFromJsonAsync<ReadTextResponse>(cancellationToken: cancellationToken);
        return payload?.Content ?? string.Empty;
    }
}

이 계층의 목적은 편의를 위한 래퍼가 아니라 UI가 raw path와 System.IO 발상 자체를 갖지 못하게 만드는 것이다.

실제 하네스는 UI에서 금지 타입을 막는 규칙이다

문서만으로는 부족하다. UI 프로젝트에서 System.IO.File, System.IO.Directory, System.IO.Path 같은 타입 사용을 빌드 에러로 막아야 한다.

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class UiFileAccessAnalyzer : DiagnosticAnalyzer
{
    private static readonly ImmutableHashSet<string> ForbiddenMetadataNames =
    [
        "System.IO.File",
        "System.IO.Directory",
        "System.IO.FileInfo",
        "System.IO.DirectoryInfo",
        "System.IO.Path"
    ];

    public override void Initialize(AnalysisContext context)
    {
        context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
        context.RegisterOperationAction(AnalyzeObjectCreation, OperationKind.ObjectCreation);
    }
}

정교한 구현 세부사항은 프로젝트마다 달라져도 괜찮다. 중요한 것은 UI에서 파일 접근 발상이 시작되는 순간 즉시 실패하도록 만드는 것이다.

빌드와 CI까지 같은 규칙으로 묶는다

[*.cs]
dotnet_diagnostic.ATL001.severity = error

IDE 경고로만 남아 있으면 하네스가 아니라 코딩 스타일 문서에 가깝다. CI까지 같은 규칙이 반복 적용되어야 비로소 우회보다 합법 경로가 짧아진다.

위반 예시와 실패 결과

다음 코드는 즉시 실패해야 한다.

using System.IO;

public sealed class DocViewModel
{
    public string Load(string path)
    {
        return File.ReadAllText(path);
    }
}

실패 메시지 예시는 다음처럼 잡힌다.

error ATL001: Type 'System.IO.File' must not be used in WPF UI layer. Use backend storage service instead.

이 순간부터 규칙은 문서가 아니라 실행 가능한 장치가 된다.

보조 강제 수단

이 케이스의 보조 강제 수단은 UI 금지 규칙과 backend 쪽 루트 강제를 함께 두는 데 있다. 프론트엔드에서 System.IO를 막는 것만으로는 부족하고, backend도 workspace root 바깥 접근과 raw path contract를 동시에 막아야 한다.

  • 프론트엔드: System.IO 직접 사용 금지
  • 브리지: path 대신 논리 식별자 계약 사용
  • 백엔드: workspace root 밖 접근 차단
  • 빌드: analyzer 패키지와 .editorconfig로 실패 고정

이 네 겹이 함께 있어야 UI가 가장 짧은 파일 접근 경로를 다시 만들지 못한다.

결과

하네스 적용 이후 변화는 명확하다.

  • UI가 직접 자원에 닿는 경로가 줄어든다
  • 시스템 접근 책임이 backend 쪽으로 다시 수렴한다
  • 사람과 AI 모두 가장 짧은 우회 대신 허용된 경로를 선택하게 된다

실무 적용 팁

path 대신 id를 계약으로 사용

UI가 경로를 알면 결국 경로 조작 욕망이 생긴다.
가능하면 처음부터 docId, workspaceEntryId처럼 논리 식별자로 간다.

OpenFileDialog도 바로 저장 로직에 연결하지 않기

파일 선택 UI는 허용할 수 있다.
하지만 결과 경로를 ViewModel이 직접 열지 않게 해야 한다.

즉:

  • 선택 결과는 Bridge로 전달

  • 실제 읽기/검증/변환은 Backend 수행

코드 수정기까지 붙일 수 있다

원하면 Code Fix까지 붙여 File.ReadAllText(path)_workspaceFileService.ReadTextAsync(docId) 같은 공식 경로로 치환 제안할 수 있다. 다만 이 문서의 핵심은 자동 수정보다 금지 규칙의 강제성이다.

요약

이 케이스에서 하네스는 다음 식으로 실제 구현된다.

  • WPF는 계약 인터페이스만 본다

  • Rust Backend만 파일 시스템을 만진다

  • Roslyn Analyzer가 UI의 직접 접근을 탐지한다

  • .editorconfig가 위반을 빌드 에러로 승격한다

  • CI에서도 동일 규칙이 반복 적용된다

결국 이 문서의 핵심은 하나다.

“UI에서 파일을 열지 마라"는 팀 규칙이 아니다.
“UI에서 파일을 열면 빌드가 깨진다"가 하네스다.