개인 프로젝트에 캡쳐기능을 도입하며 고민정리

미생의 개발 이야기

목록 보기
86/87

들어가며

이미지를 캡처해서 특정 폴더에 분류하고, 나중에 매뉴얼처럼 편집할 수 있는 도구를 개인 프로젝트로 만들고 있었습니다.

초반에는 프로토타입 형태로 가볍게 화면 위주로 기능 검증을 진행했는데, 개발을 진행할수록 “이 구조로 계속 기능을 붙이면 유지보수가 정말 힘들겠다”는 생각이 들었습니다.

마침 데스크톱 앱 개발 환경에 관심이 생겨 C#과 WPF를 새로 공부하기 시작했고, 이를 활용해 프로젝트 구조를 탄탄하게 설계해보기로 결심했습니다.
프로젝트 이름은 Capflow로 지었습니다.

이 글은 거창하게 “DDD를 100% 완벽하게 적용했다”는 자랑이 아니라, C#을 배워가며 코드를 구현할 때 실제로 무엇이 도움이 되었고 무엇이 겉치레였는지 치열하게 고민했던 기록입니다.


기존 프로토타입의 냉정한 현실

처음 만들었던 프로토타입 버전의 완성도는 대략 이랬습니다.

  • 홈, 캡처, 에디터 UI는 존재함

  • 에디터와 캡처 데이터 간의 연결이 약함 — 화면은 떠 있지만 실제 워크플로가 부드럽게 이어지지 않음

  • 데이터 저장/전송을 처리할 백엔드나 로컬 저장 로직이 부실함

  • 상태 관리 라이브러리를 붙였지만 제대로 활용하지 못함

즉, 유기적인 “문서 작성 앱”이라기보다는 단순히 캡처 이미지를 나열하는 UI에 가까웠습니다.

그래서 C# 공부를 병행하며 Capflow를 만들 때, 목표를 명확히 잡았습니다.

  1. 캡처 → 폴더 → 에디터가 같은 프로젝트 데이터를 명확히 공유할 것
  2. 레이어 이름만 그럴싸하게 짓지 말고, 구조적 이점을 취할 것
  3. 나중에 기능을 확장할 때 손대기 쉬운 경계를 만들 것

레이어는 이렇게 나눴다

Capflow.Domain         — 엔티티, 애그리게이트, 포트 (순수 도메인)
Capflow.Application    — 유스케이스, DTO, Result 패턴
Capflow.Infrastructure — JSON 저장, 이미지 파일 관리, 이벤트 핸들러
Capflow.Presentation   — WPF + WebView2, ViewModel

의존성 방향만 단방향으로 유지하는 데 집중했습니다.

  • Domain은 프레임워크나 UI의 존재를 모릅니다.
  • Application은 “무엇을 할지”만 알고, 저장 방식은 모릅니다.
  • Infrastructure가 JSON 저장, 파일 시스템, WebView2 어댑터를 구현합니다.
  • Presentation은 ViewModel과 WPF 바인딩을 담당합니다.

나중에는 레이어 위에 바운디드 컨텍스트도 명시했습니다. Catalog(프로젝트 목록) / Capture(캡처) / Editing(편집) 세 영역으로 나누고, 관계는 코드로 문서화했습니다.

// Capflow.Domain/BoundedContexts/ContextMap.cs
public static class ContextMap
{
    public static IReadOnlyList<ContextRelationship> Relationships { get; } =
    [
        new(
            BoundedContextNames.Catalog,
            BoundedContextNames.Capture,
            ContextRelationshipType.CustomerSupplier,
            "Catalog가 프로젝트 세션을 열면 Capture에서 이를 제공받아 사용합니다."),
        new(
            BoundedContextNames.Capture,
            BoundedContextNames.Editing,
            ContextRelationshipType.SharedKernel,
            "Capture의 폴더와 스크린샷 데이터를 Editing에서 선택하고 어노테이션(편집)합니다."),
        new(
            BoundedContextNames.Editing,
            BoundedContextNames.Catalog,
            ContextRelationshipType.Conformist,
            "Editing은 Catalog와 Capture에 의해 영속화된 동일한 Project 애그리게이트를 업데이트합니다.")
    ];
}

캡처 유스케이스는 컨텍스트별로 DI 등록을 묶었습니다.

// Capflow.Application/BoundedContexts/Capture/CaptureBoundedContext.cs
public static class CaptureBoundedContext
{
    public static IServiceCollection AddCaptureBoundedContext(this IServiceCollection services)
    {
        services.AddSingleton<IAddCaptureFolderUseCase, AddCaptureFolderUseCase>();
        services.AddSingleton<IRenameCaptureFolderUseCase, RenameCaptureFolderUseCase>();
        services.AddSingleton<IRemoveCaptureFolderUseCase, RemoveCaptureFolderUseCase>();
        services.AddSingleton<IRemoveCaptureImageUseCase, RemoveCaptureImageUseCase>();
        services.AddSingleton<IListCaptureFoldersUseCase, ListCaptureFoldersUseCase>();
        services.AddSingleton<ICapturePageUseCase, CapturePageUseCase>();
        return services;
    }
}

C# 초기에 겪은 시행착오와 해결 과정

1. 이름만 그럴싸한 비즈니스 로직

Project 애그리게이트는 만들었지만, Application이 UI 상태를 들고 있거나 God Service가 폴더·캡처·에디터를 한 클래스에서 처리하는 문제가 있었습니다.

배운 점: 도메인 클래스 분리만으로는 부족하고, 유스케이스 단위로 책임을 쪼개야 레이어 분리의 가치가 생깁니다.

폴더 추가는 이제 인터페이스 하나·클래스 하나입니다.

// Capflow.Application/UseCases/Capture/CapturePageUseCase.cs
public async Task<Result<string>> ExecuteAsync(IProjectSession session, long folderId, byte[] imageData, CancellationToken cancellationToken = default)
{
    if (imageData.Length == 0)
        return Result.Failure<string>(ApplicationError.Validation("캡처된 이미지 데이터가 비어 있습니다."));

    var screenshotId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
    var imagePath = await _imageStore.SaveAsync(session.ProjectId, screenshotId, imageData, cancellationToken);
    session.Project.AddCapture(folderId, screenshotId, imagePath);
    await session.SaveAsync(cancellationToken);
    return Result.Success(screenshotId);
}

2. Fire-and-Forget 저장

Task.Run으로 저장을 밀어 넣으면 UI는 빨라 보이지만, 타이밍이 꼬이면서 데이터가 깨졌습니다.

해결: 프로젝트 ID별 SemaphoreSlim으로 저장을 직렬화하고, 저장 도메인 이벤트를 발행합니다.

// Capflow.Infrastructure/Persistence/ProjectSaveCoordinator.cs
public async Task SaveAsync(Project project, CancellationToken cancellationToken = default)
{
    SemaphoreSlim projectGate;
    
    // 1. 안전하게 가져오기 위해 처리 합니다.
    await _globalGate.WaitAsync(cancellationToken);
    try
    {
        // 해당 프로젝트 ID에 대응하는 세마포어가 없다면 새로 생성하여 사전에 등록합니다.
        if (!_projectLocks.TryGetValue(project.Id.Value, out projectGate!))
        {
            projectGate = new SemaphoreSlim(1, 1);
            _projectLocks[project.Id.Value] = projectGate;
        }
    }
    finally
    {
        // 글로벌 락은 빠르게 해제하여 다른 요청들이 대기하지 않도록 합니다.
        _globalGate.Release();
    }

    // 2. 해당 프로젝트 전용 락을 획득합니다.
    await projectGate.WaitAsync(cancellationToken);
    try
    {
        // 도메인 이벤트를 리스트로 복사합니다.
        var events = project.DomainEvents.ToList();
        
        // 데이터베이스에 프로젝트 변경 사항을 저장합니다.
        await _projectRepository.SaveAsync(project, cancellationToken);
        
        // 저장 성공 후 도메인 이벤트를 발행 합니다.
        await _domainEventDispatcher.DispatchAsync(events, cancellationToken);
        
        // 처리가 끝난 도메인 이벤트를 비워줍니다.
        project.ClearDomainEvents();
    }
    finally
    {
        // 처리가 완료되면 프로젝트 전용 락을 해제합니다.
        projectGate.Release();
    }
}

3. 세션을 싱글톤처럼 공유

캡처·에디터가 _currentProject 하나만 바라보면, 홈 복귀나 프로젝트 전환 시 이전 상태가 남습니다.

해결: IProjectSessionScopeFactory로 스코프를 열고, 홈으로 가면 Dispose합니다.

// Capflow.Application/Sessions/ProjectSessionScope.cs
public interface IProjectSessionScope : IAsyncDisposable
{
    IProjectSession Session { get; }
}

public interface IProjectSessionScopeFactory
{
    Task<IProjectSessionScope> OpenAsync(ProjectId projectId, CancellationToken cancellationToken = default);
}

Presentation에서는 네비게이션 시 스코프를 교체합니다.

// Capflow.Presentation/Navigation/NavigationService.cs
private async Task EnsureScopeAsync(string projectId)
{
    if (ActiveScope?.Session.ProjectId == ProjectId.From(projectId))
        return;

    CloseScope();
    ActiveScope = await _sessionScopeFactory.OpenAsync(ProjectId.From(projectId));
}

private void CloseScope()
{
    if (ActiveScope is null) return;
    _ = ActiveScope.DisposeAsync();
    ActiveScope = null;
}

4. 이미지를 JSON base64에 욱여넣음

프로젝트가 커지면 JSON이 비대해지고, diff·백업·메모리 모두 불리합니다.

해결: Domain에는 포트만 두고, Infrastructure가 파일로 저장합니다. JSON에는 상대 경로만 남깁니다.

// Capflow.Domain/Ports/IProjectImageStore.cs
public interface IProjectImageStore
{
    Task<string> SaveAsync(ProjectId projectId, string screenshotId, byte[] imageData, ...);
    Task<byte[]> LoadAsync(ProjectId projectId, string relativePath, ...);
    Task DeleteAsync(ProjectId projectId, string relativePath, ...);
}

캡처 유스케이스는 “파일 저장 → 경로 등록 → 세션 저장” 순서로 흐릅니다.

// Capflow.Application/UseCases/Capture/CapturePageUseCase.cs
public async Task<Result<string>> ExecuteAsync(IProjectSession session, long folderId, byte[] imageData, ...)
{
   
    if (imageData.Length == 0)
        return Result.Failure<string>(ApplicationError.Validation("캡처된 이미지 데이터가 비어 있습니다."));

    var screenshotId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
    var imagePath = await _imageStore.SaveAsync(session.ProjectId, screenshotId, imageData, cancellationToken);
    session.Project.AddCapture(folderId, screenshotId, imagePath);
    await session.SaveAsync(cancellationToken);
    return Result.Success(screenshotId);
}

레거시 imageBase64로드 시점에 파일로 추출합니다.

// Capflow.Infrastructure/Persistence/JsonProjectRepository.cs
private static async Task<string> ResolveImagePathAsync(
    ProjectId projectId, ScreenshotRecord screenshot, IProjectImageStore imageStore, ...)
{
    if (!string.IsNullOrWhiteSpace(screenshot.ImagePath))
        return screenshot.ImagePath;

    if (string.IsNullOrWhiteSpace(screenshot.ImageBase64))
    {
        throw new InvalidOperationException($"스크린샷 '{screenshot.Id}'에 이미지 경로 또는 기존 페이로드가 존재하지 않습니다.");
    }

    var bytes = Convert.FromBase64String(screenshot.ImageBase64);
    return await imageStore.SaveAsync(projectId, screenshot.Id, bytes, cancellationToken);
}

저장 경로 예시: %AppData%\Capflow\assets\{projectId}\images\{screenshotId}.png


5. 예외로 비즈니스 흐름 제어

데스크톱 앱에서 throw만으로 실패를 처리하면 ViewModel이 지저분해집니다.

해결: Result<T>와 공통 에러 코드를 도입했습니다.

// Capflow.Application/Common/Result.cs
public sealed class Result<T> : Result
{
    public T? Value { get; }
    public static Result<T> Success(T value) => new(value);
    public new static Result<T> Failure(ApplicationError error) => new(error);
}

// Capflow.Application/Common/ApplicationError.cs
public sealed record ApplicationError(string Code, string Message)
{
    public static ApplicationError NotFound(string resource, string id) =>
        new(ErrorCodes.NotFound, $"{resource} '{id}' 찾을 수 없습니다.");

    public static ApplicationError Validation(string message) =>
        new(ErrorCodes.Validation, message);
}

ViewModel에서는 result.IsFailure일 때만 사용자에게 알리면 됩니다.


도메인 이벤트 — 기록만이 아니라 구독자까지

애그리게이트가 변화를 스스로 기록하고, 저장 성공 후 구독자가 반응합니다.

// Capflow.Domain/Aggregates/Project.cs
public Screenshot AddCapture(long folderId, string screenshotId, string imagePath)
{
    var folder = GetFolder(folderId) ?? throw new InvalidOperationException(...);
    var screenshot = folder.AddScreenshot(screenshotId, imagePath);
    Raise(new CaptureAddedDomainEvent(Id, folderId, screenshot.Id));
    Touch();
    return screenshot;
}

구독자 예: Audit 로그 + 프로젝트 매니페스트 프로젝션 (폴더/캡처/어노테이션 수 집계).

// Capflow.Infrastructure/Events/ProjectManifestProjectionHandler.cs
public Task HandleAsync(IDomainEvent domainEvent, CancellationToken cancellationToken = default) =>
    domainEvent switch
    {
        CaptureAddedDomainEvent captureAdded => ProjectManifestAsync(
            captureAdded.ProjectId, captureAdded.ScreenshotId, cancellationToken),
        FolderAddedDomainEvent folderAdded => ProjectManifestAsync(folderAdded.ProjectId, null, cancellationToken),
        AnnotationAddedDomainEvent annotationAdded => ProjectManifestAsync(annotationAdded.ProjectId, null, cancellationToken),
        _ => Task.CompletedTask
    };

매니페스트는 %AppData%\Capflow\manifests\{projectId}.json에 저장됩니다.


단위 테스트로 검증한 것

핵심 도메인 규칙은 예외 없이 assert할 수 있습니다.

// tests/Capflow.Domain.Tests/ProjectAggregateTests.cs
[Fact]
public void AddCapture_RaisesCaptureAddedDomainEvent()
{
    var project = CreateProject();
    project.AddFolder(100, "Login");
    project.AddCapture(100, "shot-1", "images/shot-1.png");

    var domainEvent = Assert.IsType<CaptureAddedDomainEvent>(project.DomainEvents.Last());
    Assert.Equal("shot-1", domainEvent.ScreenshotId);
}

[Fact]
public void AddBoxAnnotation_RaisesAnnotationAddedDomainEvent()
{
    var project = CreateProject();
    project.AddFolder(100, "Login");
    project.AddCapture(100, "shot-1", "images/shot-1.png");

    var annotation = project.AddBoxAnnotation(100, "shot-1", 0.1, 0.2, 0.3, 0.4);
    var domainEvent = Assert.IsType<AnnotationAddedDomainEvent>(project.DomainEvents.Last());
    Assert.Equal(AnnotationType.Box, domainEvent.Type);
}

Infrastructure에는 base64 → 파일 마이그레이션 테스트도 넣었습니다.


개선 요약

문제점개선 방향
Application에 UI 상태 침범ViewModel + 포트 인터페이스로 분리
Exception으로 흐름 제어Result<T> + ApplicationError
도메인 변화 외부 전달 어려움도메인 이벤트 + 저장 후 디스패치
base64 JSON 비대화IProjectImageStore + 경로만 JSON 저장
God UseCase작업별 인터페이스 분리
세션 꼬임IProjectSessionScope + Dispose
검증 부족Domain / Application / Infrastructure 테스트

아직 솔직히 부족한 부분들

뼈대는 잡혔지만, 여전히 과제는 남아 있습니다.

  • 이벤트 활용 확장: 매니페스트·Audit 외에 UI 갱신·알림 등 Presentation 연동은 더 다듬을 여지가 있습니다.
  • 테스트 커버리지: WPF ViewModel·WebView2 캡처 경로는 아직 수동 검증에 의존합니다.
  • 제품 완성도: 매뉴얼보내기, 협업, 클라우드 동기화 등은 범위 밖입니다.

C# 공부와 아키텍처 설계를 동시에 하며 느낀 점

1. 화면만 나오는 상태를 완성으로 착각하지 않기

캡처와 에디터가 같은 Project 애그리게이트·같은 세션을 공유하도록 만든 이유입니다.

2. 거창한 패턴보다 명확한 경계가 우선

  1. 순수 도메인 규칙은 중심에 둔다.
  2. UI·파일 시스템 의존성은 외곽으로 밀어낸다.
  3. 클래스는 변경 이유가 하나여야 한다.

3. 로컬 데스크톱 앱도 저장 설계가 필수

ProjectSaveCoordinator 같은 조율 계층 없이는 fire-and-forget이 데이터를 깨뜨립니다.

4. 무거운 데이터는 처음부터 분리하기

이미지는 POC 단계의 base64가 아니라 파일 + 경로 참조가 맞습니다.


마치며

이번 과정은 언어만 바꾼 것이 아니라, 데이터가 화면 간에 어떻게 흐르는지를 다시 그려본 학습이었습니다.
기능을 더 붙이기 전에 “캡처한 이미지가 에디터까지 같은 객체로 이어지는가?”부터 확인하는 시간이되었습니다.

profile
레거시를 이해하면서도 새로운 기술을 현실적으로 적용할 수 있는 백엔드 개발자가 되는 것이 목표입니다.

0개의 댓글