1. 서론: 다중 스레드 환경에서 캘린더 컴포넌트가 마주하는 동시성 난제

데스크톱 업무 환경에서 일정 관리와 캘린더는 교원의 학사 일정(지필평가, 방학, 학부모 상담)과 국방 실무자의 부대 훈련 및 당직 스케줄을 시각화하는 핵심 컴포넌트입니다. 사용자가 월간 달력을 빠르게 넘기거나 특정 날짜를 클릭할 때, 단 1ms의 버벅임(Stuttering)도 없이 화면이 즉각 반응해야만 쾌적한 UX를 제공할 수 있습니다.

그러나 캘린더 서비스는 단순한 데이터 조회를 넘어 메인 UI 스레드와 백그라운드 비동기 워커 스레드가 동시에 접근하는 전형적인 다중 스레드 경합 지점입니다:

"UI 스레드가 42개 날짜 셀을 렌더링하기 위해 컬렉션을 열거(Enumeration)하고 있는 바로 그 찰나에, 백그라운드 서비스나 AI 어시스턴트가 새로운 일정을 컬렉션에 추가하면 .NET 런타임은 즉시 'InvalidOperationException: Collection was modified' 예외를 던지며 크래시를 유발한다. 완벽한 캘린더 엔진은 락의 경량성과 재진입 안전성을 동시에 보장해야 한다."
비교 항목 일반적인 파일 기반 캘린더 ZeliDesk.Core CalendarService
조회 지연 시간 조회할 때마다 디스크 파일 I/O (수십 ms 지연) 정적 인메모리 캐싱으로 0ms 즉시 반환
스레드 동기화 동기화 누락으로 레이스 컨디션 및 파일 락 충돌 재진입 가능 lock (_lock) (Monitor) 완벽 격리
셀프 데드락 위험 중첩 락 호출 시 상호 배제 교착 상태 발생 동일 스레드 재진입 허용으로 데드락 위험 0%
순회 중 수정 방어 foreach 순회 도중 컬렉션 수정 시 앱 크래시 ToList() 방어적 복사(Defensive Copying) 적용
시간대 오차 방지 TimeSpan 오차로 일자 비교 누락 발생 Date.Date 순수 자정 기준 비교 표준화

ZeliDesk 아키텍처 팀은 이 난제를 극복하기 위해 .NET Monitor의 스레드 재진입성(Reentrancy)과 방어적 복제 패턴을 융합한 고신뢰성 정적 캘린더 엔진 CalendarService를 구축했습니다.

2. Lazy Loading과 정적 인메모리 캐싱: 디스크 I/O 0ms 최적화

소프트웨어 성능의 제1법칙은 "불필요한 디스크 I/O를 발생시키지 않는 것"입니다. 하드디스크(HDD)는 물론 최신 NVMe SSD라 할지라도 파일 시스템을 거치는 작업은 메모리 버스 접근 속도보다 수천 배 느립니다.

CalendarService는 지연 로딩(Lazy Loading) 패턴을 적용하여 애플리케이션이 구동된 후 최초로 캘린더 데이터를 요구하는 순간에만 디스크를 1회 읽고, 이후의 모든 조회는 _cache 정적 리스트를 통해 0ms 메모리 속도로 응답합니다:

public static List LoadAll()
{
    lock (_lock)
    {
        // 1. 이미 메모리에 캐시가 존재하면 디스크를 읽지 않고 즉시 반환
        if (_cache != null) return _cache;

        if (!File.Exists(_filePath))
        {
            _cache = new List();
            return _cache;
        }

        try
        {
            var json = File.ReadAllText(_filePath);
            _cache = JsonSerializer.Deserialize>(json) ?? new List();
        }
        catch
        {
            _cache = new List();
        }
        return _cache;
    }
}

이를 통해 월간 달력에서 이전 달/다음 달로 화면을 빠르게 스크롤하더라도 디스크 읽기 병목이 전혀 발생하지 않아 물 흐르듯 부드러운 120Hz 렌더링을 실현합니다.

3. Self-Deadlock을 방어하는 재진입 가능(Reentrant) Monitor 락 설계

C#에서 lock 키워드는 내부적으로 System.Threading.Monitor.Enter와 Monitor.Exit으로 변환됩니다. 일부 저수준 동기화 기본형(예: POSIX Mutex의 기본 모드, SpinLock)은 동일한 스레드가 이미 자신이 획득한 락을 다시 획득하려고 시도할 때 자기 자신과 교착 상태에 빠지는 셀프 데드락(Self-Deadlock)을 일으킵니다.

그러나 .NET의 Monitor는 재진입 가능(Reentrant) 특성을 기본 제공합니다. 즉, 이미 락을 보유한 스레드가 중첩된 메서드 호출을 통해 동일한 동기화 객체의 락 블록에 진입하더라도 잠금 횟수 카운터만 증가시킬 뿐 스레드를 차단하지 않습니다:

⚡ Reentrant Lock을 활용한 안전한 메서드 합성(Composition)

AddOrUpdateItem 메서드는 lock (_lock) 내부에서 자체적으로 lock (_lock)을 가지고 있는 LoadAll()을 호출하고, 이어서 또 다른 lock (_lock)을 가진 SaveAll(items)을 순차적으로 호출합니다.
Monitor의 재진입성이 보장되므로, 락 해제와 재획득 과정에서 발생하는 틈새(Window of Vulnerability)나 데드락 없이 완벽하게 하나의 원자적 트랜잭션으로 안전하게 실행됩니다.

public static void AddOrUpdateItem(CalendarItem item)
{
    lock (_lock) // 락 레벨 1 획득
    {
        var items = LoadAll(); // 내부 lock (_lock) 진입 (카운터 2, 셀프 데드락 없음!)
        var existing = items.FirstOrDefault(x => x.Id == item.Id);
        if (existing != null) items.Remove(existing);
        items.Add(item);
        SaveAll(items); // 내부 lock (_lock) 진입 (카운터 2, 완벽한 원자성 유지!)
    }
}

4. Collection was modified 예외 원천 차단: ToList() 방어적 복제 기법

List<T> 컬렉션은 열거자(Enumerator)를 생성할 때 내부의 _version 정수 필드를 캡처합니다. 열거가 진행되는 동안 컬렉션에 Add, Remove 등이 발생하면 _version이 증가하고, 열거자의 MoveNext()는 즉시 InvalidOperationException을 던지도록 설계되어 있습니다.

CalendarService는 쿼리 결과를 외부로 반환할 때 반드시 lock 블록 내부에서 ToList()를 호출하여 방어적 복제본(Defensive Copy)을 생성합니다:

public static List GetItemsForMonth(int year, int month)
{
    lock (_lock)
    {
        // 락 내부에서 필터링 후 즉시 독립적인 새 리스트로 인스턴스화하여 반환
        return LoadAll().Where(x => x.Date.Year == year && x.Date.Month == month).ToList();
    }
}

UI 계층은 원본 _cache 레퍼런스를 직접 만지는 대신 독립적으로 복제된 List<CalendarItem>를 전달받으므로, UI에서 데이터를 순회하며 XAML 컨트롤을 생성하는 도중 백그라운드 스레드가 일정을 수정하더라도 UI 스레드는 단 0.001%의 충돌 가능성도 없이 완전히 격리된 렌더링을 보장받습니다.

5. Date.Date 정밀 비교와 월별·일별 캘린더 쿼리 필터링 파이프라인

날짜 기반 시스템에서 가장 빈번하게 발생하는 버그는 시간(Hour, Minute, Second) 구성 요소로 인한 불일치입니다. 사용자가 2026-09-10의 일정을 조회하려 할 때, 저장된 데이터의 시간이 2026-09-10 14:30:00이면 단순 == 연산자는 false를 반환합니다.

CalendarService는 특정 일자 조회 시 x.Date.Date == date.Date 표준화를 강제하여, 자정(00:00:00.000)을 기준으로 시간을 완전히 절삭한 순수 일자 단위 동등성 검사를 수행합니다:

public static List GetItemsForDate(DateTime date)
{
    lock (_lock)
    {
        // 시간 성분을 배제한 자정 기준 순수 날짜 일치성 필터링
        return LoadAll().Where(x => x.Date.Date == date.Date).ToList();
    }
}

또한 각 일정 항목에는 공휴일 및 학교 재량휴업일 구분을 위한 IsHoliday 불리언 플래그와 ZeliDesk의 시그니처 컬러를 반영한 ColorHex(기본값: #FF0EA76C)를 탑재하여, 추가적인 UI 연산 없이도 캘린더 뷰에서 일정을 즉시 색상별로 구분 렌더링할 수 있도록 지원합니다.

6. 핵심 구현 분석: ZeliDesk.Core CalendarService.cs 실코드 워크스루

다음은 ZeliDesk.Core에 실제 배포되어 공교육과 국방의 실시간 일정 관리를 무결하게 지탱하는 CalendarService.cs의 전체 소스코드입니다:

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;

namespace ZeliDesk.Core.Services
{
    /// <summary>
    /// 캘린더 개별 일정 데이터 모델
    /// </summary>
    public class CalendarItem
    {
        public string Id { get; set; } = Guid.NewGuid().ToString("N");
        public DateTime Date { get; set; }
        public string Title { get; set; }
        public string Description { get; set; }
        public bool IsHoliday { get; set; }
        public string ColorHex { get; set; } = "#FF0EA76C"; // 기본 젤리데스크 에메랄드 그린
    }

    /// <summary>
    /// 스레드 안전한 인메모리 캘린더 서비스
    /// Reentrant Monitor 락 기반 동시성 제어 및 JSON 영속화 지원
    /// </summary>
    public static class CalendarService
    {
        private static readonly string _filePath = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
            "ZeliDesk", "Calendar", "schedules.json");

        private static List<CalendarItem> _cache = null;
        
        // 정적 캐시 동시 접근 보호 (열거 중 수정으로 인한 예외 방지). Monitor는 재진입 가능.
        private static readonly object _lock = new object();

        static CalendarService()
        {
            try { Directory.CreateDirectory(Path.GetDirectoryName(_filePath)); } catch { }
        }

        /// <summary>
        /// 전체 일정 목록을 로드합니다. (Lazy Loading 인메모리 캐싱)
        /// </summary>
        public static List<CalendarItem> LoadAll()
        {
            lock (_lock)
            {
                if (_cache != null) return _cache;

                if (!File.Exists(_filePath))
                {
                    _cache = new List<CalendarItem>();
                    return _cache;
                }

                try
                {
                    var json = File.ReadAllText(_filePath);
                    _cache = JsonSerializer.Deserialize<List<CalendarItem>>(json) ?? new List<CalendarItem>();
                }
                catch
                {
                    _cache = new List<CalendarItem>();
                }
                return _cache;
            }
        }

        /// <summary>
        /// 전체 일정 목록을 디스크에 원자적으로 직렬화 영속화합니다.
        /// </summary>
        public static void SaveAll(List<CalendarItem> items)
        {
            lock (_lock)
            {
                _cache = items;
                try
                {
                    var json = JsonSerializer.Serialize(_cache, new JsonSerializerOptions { WriteIndented = true });
                    File.WriteAllText(_filePath, json);
                }
                catch { }
            }
        }

        /// <summary>
        /// 일정을 추가하거나 기존 항목을 갱신합니다. (Reentrant Lock 트랜잭션)
        /// </summary>
        public static void AddOrUpdateItem(CalendarItem item)
        {
            lock (_lock)
            {
                var items = LoadAll();
                var existing = items.FirstOrDefault(x => x.Id == item.Id);
                if (existing != null) items.Remove(existing);
                items.Add(item);
                SaveAll(items);
            }
        }

        /// <summary>
        /// 특정 식별자의 일정을 안전하게 삭제합니다.
        /// </summary>
        public static void RemoveItem(string id)
        {
            lock (_lock)
            {
                var items = LoadAll();
                if (items.RemoveAll(x => x.Id == id) > 0)
                    SaveAll(items);
            }
        }

        /// <summary>
        /// 특정 연도와 월에 해당하는 일정을 방어적 복사본으로 추출합니다.
        /// </summary>
        public static List<CalendarItem> GetItemsForMonth(int year, int month)
        {
            lock (_lock)
            {
                return LoadAll().Where(x => x.Date.Year == year && x.Date.Month == month).ToList();
            }
        }

        /// <summary>
        /// 특정 일자에 해당하는 일정을 자정 기준 정밀 필터링하여 반환합니다.
        /// </summary>
        public static List<CalendarItem> GetItemsForDate(DateTime date)
        {
            lock (_lock)
            {
                return LoadAll().Where(x => x.Date.Date == date.Date).ToList();
            }
        }
    }
}

7. 결론: 가장 직관적인 동기화가 제공하는 무결점 데스크톱 UX

현대적인 소프트웨어 개발에서는 종종 화려하고 복잡한 비동기 분산 라이브러리나 고수준 ORM에 의존하느라, 정작 가장 기본적이어야 할 인메모리 컬렉션 동기화의 안정성을 간과하곤 합니다.

ZeliDesk.Core의 CalendarService는 .NET 런타임이 제공하는 재진입 가능 Monitor 락의 본질적인 강점을 완벽하게 이해하고 활용했습니다. 이를 통해 복잡한 비동기 데드락의 위험을 원천 배제하면서도, 0ms 무지연 캐시 쿼리와 100% 안전한 방어적 복제를 단 112줄의 견고한 C# 코드로 완성해 냈습니다.

단 한 번의 UI 프리징이나 예외 크래시도 허용하지 않는 이 단단한 동시성 아키텍처 위에서, ZeliDesk는 전국 초·중·고 교실과 국방의 중요한 일정들을 언제나 가장 민첩하고 안정적으로 시각화하고 있습니다.