Devexpress chart 다루기

제가 제공된 코드들을 살펴보았으나 DevExpress ChartControl 관련 코드가 포함되어 있지 않네요. 하지만 DevExpress ChartControl의 CrosshairOptions와 관련하여 몇 가지 해결 방법을 제안드릴 수 있습니다:

  1. CrosshairOptions의 MinLengthBetweenPoints 속성 조정:
chartControl.CrosshairOptions.MinLengthBetweenPoints = 1; // 최소값으로 설정
  1. 차트의 최소 크기 설정:
chartControl.MinHeight = 100; // 적절한 최소 높이 설정
chartControl.MinWidth = 100;  // 적절한 최소 너비 설정
  1. CrosshairOptions의 ShowOnlyInFocusedPane 속성 활용:
chartControl.CrosshairOptions.ShowOnlyInFocusedPane = false;
  1. CrosshairEnabled 속성 확인:
chartControl.CrosshairEnabled = true;
  1. 차트 영역의 패딩 조정:
chartControl.Diagram.Margins.All = 0;

정확한 해결을 위해서는 실제 사용 중인 ChartControl 코드를 공유해 주시면 더 구체적인 도움을 드릴 수 있습니다.

DevExpress ChartControl의 Crosshair 기능은 기본적으로 XY 다이어그램(XYDiagram2D)에서만 지원됩니다.

PieChart, SimpleChart, GaugeChart 등에서는 기본적으로 Crosshair가 지원되지 않습니다. 하지만 비슷한 효과를 구현하기 위한 대안들이 있습니다:

  1. ToolTipController 사용:
chartControl.ToolTipController = new ToolTipController();
chartControl.ToolTipController.ShowBeak = true;
chartControl.ToolTipController.AllowHtmlText = true;
  1. HotTrackController 활용:
chartControl.HotTrackController.EnableHotTrack = true;
chartControl.HotTrackController.ShowToolTip = true;
  1. CustomDrawCrosshair 이벤트 사용 (XYDiagram2D에서):
chartControl.CustomDrawCrosshair += (s, e) => {
    // 커스텀 크로스헤어 그리기
};
  1. 차트 시리즈의 PointOptions 설정:
series.PointOptions.PointView = PointView.ArgumentAndValues;
series.PointOptions.ValueNumericOptions.Format = NumericFormat.Currency;

만약 다른 차트 타입에서도 크로스헤어와 유사한 기능이 필요하다면, ToolTipController나 HotTrackController를 활용하여 마우스 오버 시 정보를 표시하는 방식으로 구현하는 것이 일반적입니다.

제공된 코드를 통해 async/await 패턴의 주요 사용법을 설명해드리겠습니다:

  1. 기본적인 async/await 패턴:
// 비동기 메서드 선언
public async Task<CanvasData> LoadShapesAsync(string filePath)
{
    try
    {
        _waitService.Show("도형 데이터를 불러오는 중...");
        // await를 사용해 비동기 작업 대기
        var result = await Task.Run(() => _repository.LoadShapes(filePath));
        return result;
    }
    finally
    {
        _waitService.Hide();
    }
}
  1. void 대신 Task 사용:
// 잘못된 방법
public async void Initialize() { ... }

// 올바른 방법
public async Task Initialize() { ... }
  1. 여러 비동기 작업 처리:
public async Task ProcessMultipleAsync()
{
    // 순차적 실행
    var result1 = await Task1Async();
    var result2 = await Task2Async();

    // 병렬 실행
    var task1 = Task1Async();
    var task2 = Task2Async();
    await Task.WhenAll(task1, task2);
}
  1. UI 업데이트와 함께 사용:
public async Task LoadDataAsync()
{
    try
    {
        _waitService.Show("로딩중...");
        var data = await LoadFromDatabaseAsync();

        // UI 스레드에서 실행
        await Application.Current.Dispatcher.InvokeAsync(() => {
            UpdateUI(data);
        });
    }
    finally
    {
        _waitService.Hide();
    }
}
  1. 예외 처리:
public async Task SafeLoadAsync()
{
    try
    {
        await LoadDataAsync();
    }
    catch (Exception ex)
    {
        // 예외 처리
        Debug.WriteLine($"Error: {ex.Message}");
    }
}

주요 포인트:

  • async 메서드는 반환 타입으로 Task 또는 Task를 사용합니다
  • await는 비동기 작업이 완료될 때까지 대기합니다
  • UI 응답성을 위해 긴 작업은 Task.Run으로 백그라운드에서 실행합니다
  • UI 업데이트는 Dispatcher.InvokeAsync를 통해 메인 스레드에서 수행합니다
  • 적절한 예외 처리를 포함해야 합니다

예시에서 볼 수 있듯이, async/await는 비동기 프로그래밍을 동기 코드처럼 읽기 쉽게 만들어주는 강력한 도구입니다.

기본적으로는 그렇지만, 몇 가지 중요한 고려사항이 있습니다:

  1. 기존 함수를 async로 변경하는 기본 패턴:
// 기존 함수
public void SaveData(string data)
{
    // 작업 수행
}

// async로 변경
public async Task SaveDataAsync(string data)
{
    await Task.Run(() => {
        // 작업 수행
    });
}
  1. 호출 방식의 변경:
// 기존 호출
SaveData("test");

// async 호출 (호출하는 함수도 async여야 함)
await SaveDataAsync("test");
  1. 주의사항들:
  • 호출하는 함수도 async로 변경해야 합니다:
// 기존 함수
public void ProcessData()
{
    SaveData("test");
}

// async로 변경
public async Task ProcessDataAsync()
{
    await SaveDataAsync("test");
}
  • 반환값이 있는 경우:
// 기존 함수
public string GetData()
{
    return "data";
}

// async로 변경
public async Task<string> GetDataAsync()
{
    return await Task.Run(() => "data");
}
  • 이벤트 핸들러의 경우 특별한 처리가 필요:
// 이벤트 핸들러는 async void 사용 가능
private async void Button_Click(object sender, EventArgs e)
{
    await SaveDataAsync("test");
}
  • 생성자는 async로 만들 수 없으므로 다른 방식 사용:
public class MyClass
{
    public MyClass()
    {
        InitializeAsync().GetAwaiter().GetResult(); // 권장하지 않음
    }

    // 대신 이런 패턴 사용
    public static async Task<MyClass> CreateAsync()
    {
        var instance = new MyClass();
        await instance.InitializeAsync();
        return instance;
    }
}
  1. 성능 고려사항:
// CPU 작업의 경우
await Task.Run(() => HeavyCPUWork());

// I/O 작업의 경우 Task.Run 불필요
await File.ReadAllTextAsync(path); // 이미 비동기

단순히 Task로 변경하고 await를 추가하는 것 외에도 이러한 여러 상황들을 고려해야 합니다. 특히 비동기 작업의 특성(CPU-bound vs I/O-bound)에 따라 적절한 방식을 선택해야 합니다.

GC.SuppressFinalize()를 자주 호출하는 것 자체는 큰 문제가 되지 않습니다. 이 메서드는 단순히 해당 객체의 finalizer를 GC의 finalization 큐에서 제거하는 가벼운 작업이기 때문입니다.

하지만 10초마다 데이터를 새로 읽어오고 이전 데이터를 dispose하는 현재 패턴은 개선할 수 있습니다. 다음과 같은 더 효율적인 접근 방법을 제안드립니다:

public class DataUpdateManager
{
    private DataTable _currentData;
    private readonly object _lockObject = new object();
    private readonly DispatcherTimer _timer;

    public DataUpdateManager()
    {
        _timer = new DispatcherTimer
        {
            Interval = TimeSpan.FromSeconds(10)
        };
        _timer.Tick += UpdateData;
    }

    private async void UpdateData(object sender, EventArgs e)
    {
        // 새 데이터를 먼저 가져옴
        var newData = await FetchNewDataAsync();

        lock (_lockObject)
        {
            // 이전 데이터와 비교하여 변경사항이 있는 경우에만 업데이트
            if (HasDataChanged(newData))
            {
                var oldData = _currentData;
                _currentData = newData;

                // UI 업데이트
                UpdateUI(_currentData);

                // 이전 데이터는 별도 스레드에서 정리
                Task.Run(() => CleanupOldData(oldData));
            }
            else
            {
                // 변경사항이 없으면 새로 가져온 데이터를 정리
                CleanupOldData(newData);
            }
        }
    }

    private void CleanupOldData(DataTable oldData)
    {
        if (oldData != null)
        {
            oldData.Clear();
            oldData.Dispose();
        }
    }

    private bool HasDataChanged(DataTable newData)
    {
        // 데이터 변경 여부 확인 로직
        if (_currentData == null) return true;

        // 여기에 실제 비교 로직 구현
        return true; 
    }

    public void StartUpdates()
    {
        _timer.Start();
    }

    public void StopUpdates()
    {
        _timer.Stop();
    }

    public void Cleanup()
    {
        _timer.Stop();
        CleanupOldData(_currentData);
        _currentData = null;
    }
}

사용 예시:

public class YourViewModel : IDisposable
{
    private readonly DataUpdateManager _dataManager;
    private bool _disposed;

    public YourViewModel()
    {
        _dataManager = new DataUpdateManager();
        _dataManager.StartUpdates();
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                _dataManager.Cleanup();
            }
            _disposed = true;
        }
    }

    public void Dispose()
    {
        Dispose(true);
    }
}

이 접근 방식의 장점:

  1. 불필요한 GC.SuppressFinalize() 호출을 피합니다
  2. 데이터 변경이 있을 때만 업데이트합니다
  3. 리소스 정리를 백그라운드에서 처리합니다
  4. 메모리 사용을 최적화합니다

이렇게 하면 주기적인 데이터 업데이트로 인한 리소스 부담을 크게 줄일 수 있습니다.

백그라운드 스레드와 UI 스레드 간의 DataTable 전달을 안전하게 처리하는 구조를 제안드리겠습니다:

public class DataService
{
    private readonly CancellationTokenSource _cts = new CancellationTokenSource();
    private readonly BlockingCollection<DataTable> _disposableQueue;
    private readonly Task _cleanupTask;

    public DataService()
    {
        // dispose할 DataTable을 담는 큐
        _disposableQueue = new BlockingCollection<DataTable>();

        // 별도 스레드에서 지속적으로 dispose 처리
        _cleanupTask = Task.Run(ProcessDisposableQueue);
    }

    private async void ProcessDisposableQueue()
    {
        while (!_cts.Token.IsCancellationRequested)
        {
            try
            {
                var dataTable = _disposableQueue.Take(_cts.Token);
                if (dataTable != null)
                {
                    dataTable.Clear();
                    dataTable.Dispose();
                }
            }
            catch (OperationCanceledException)
            {
                break;
            }
        }
    }

    public async Task StartDataUpdates()
    {
        while (!_cts.Token.IsCancellationRequested)
        {
            try
            {
                var newData = await Task.Run(() => FetchNewData());
                await Application.Current.Dispatcher.InvokeAsync(() =>
                {
                    UpdateViewModel(newData);
                });

                await Task.Delay(10000, _cts.Token); // 10초 대기
            }
            catch (OperationCanceledException)
            {
                break;
            }
        }
    }

    private void UpdateViewModel(DataTable newData)
    {
        if (ViewModel.CurrentDataTable != null)
        {
            // 이전 DataTable을 dispose 큐에 추가
            _disposableQueue.Add(ViewModel.CurrentDataTable);
        }

        // 새 데이터로 업데이트
        ViewModel.CurrentDataTable = newData;
    }

    public void Cleanup()
    {
        _cts.Cancel();

        // 남은 DataTable들 정리
        while (_disposableQueue.TryTake(out var dataTable))
        {
            dataTable.Clear();
            dataTable.Dispose();
        }

        _disposableQueue.Dispose();
        _cleanupTask.Wait(1000); // 정리 작업 완료 대기
    }
}

public class ViewModel : IDisposable
{
    private readonly DataService _dataService;
    private DataTable _currentDataTable;
    private bool _disposed;

    public DataTable CurrentDataTable
    {
        get => _currentDataTable;
        set
        {
            _currentDataTable = value;
            OnPropertyChanged();
        }
    }

    public ViewModel()
    {
        _dataService = new DataService();
        _ = _dataService.StartDataUpdates();
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                _dataService.Cleanup();

                if (_currentDataTable != null)
                {
                    _currentDataTable.Clear();
                    _currentDataTable.Dispose();
                    _currentDataTable = null;
                }
            }
            _disposed = true;
        }
    }

    public void Dispose()
    {
        Dispose(true);
    }
}

이 구조의 장점:

  1. 스레드 안전성:
  • 백그라운드 스레드에서 생성된 DataTable은 UI 스레드로 안전하게 전달됩니다.
  • Dispatcher를 통해 UI 스레드 동기화가 보장됩니다.
  1. 메모리 관리:
  • 더 이상 필요하지 않은 DataTable은 별도의 큐에서 관리됩니다.
  • 백그라운드 스레드에서 DataTable의 dispose가 처리됩니다.
  • 메모리 누수를 방지합니다.
  1. 리소스 정리:
  • CancellationToken을 통해 깔끔한 종료가 가능합니다.
  • dispose가 필요한 모든 DataTable이 확실히 정리됩니다.
  1. 성능:
  • UI 스레드의 블로킹을 최소화합니다.
  • DataTable dispose 작업이 UI 응답성에 영향을 주지 않습니다.

사용 예시:

public partial class MainWindow : Window
{
    private readonly ViewModel _viewModel;

    public MainWindow()
    {
        InitializeComponent();
        _viewModel = new ViewModel();
        DataContext = _viewModel;
    }

    protected override void OnClosed(EventArgs e)
    {
        base.OnClosed(e);
        _viewModel.Dispose();
    }
}

주의 사항:

  1. DataTable.Copy() 사용시 주의:
private DataTable FetchNewData()
{
    using (var sourceTable = // 원본 데이터 쿼리)
    {
        // Clone()은 구조만 복사, Copy()는 데이터까지 복사
        var newTable = sourceTable.Clone();
        foreach (DataRow row in sourceTable.Rows)
        {
            newTable.ImportRow(row); // 메모리 효율적인 방법
        }
        return newTable;
    }
}

이렇게 구성하면 메모리 누수 없이 안정적으로 DataTable을 관리할 수 있습니다.

Timer의 Tick 이벤트에서도 async/await를 사용할 수 있습니다. 예시를 통해 설명드리겠습니다:

  1. DispatcherTimer를 사용한 기본적인 방법:
private DispatcherTimer timer;

public MainWindow()
{
    timer = new DispatcherTimer
    {
        Interval = TimeSpan.FromSeconds(1)
    };
    timer.Tick += Timer_Tick_Async;
    timer.Start();
}

// async void는 이벤트 핸들러에서는 허용됩니다
private async void Timer_Tick_Async(object sender, EventArgs e)
{
    try 
    {
        // 데이터를 가져오는 동안 타이머 중지 (필요한 경우)
        timer.Stop();

        // 비동기로 데이터 가져오기
        var data = await LoadDataAsync();
        UpdateUI(data);
    }
    finally 
    {
        // 타이머 재시작 (중지했을 경우)
        timer.Start();
    }
}

private async Task<Data> LoadDataAsync()
{
    // 백그라운드에서 무거운 작업 수행
    return await Task.Run(() => 
    {
        // 시간이 걸리는 작업 수행
        return new Data();
    });
}
  1. 이전 작업이 완료될 때까지 새 작업을 시작하지 않도록 하는 방법:
private bool isProcessing = false;

private async void Timer_Tick_Async(object sender, EventArgs e)
{
    // 이전 작업이 아직 실행 중이면 건너뛰기
    if (isProcessing) return;

    try 
    {
        isProcessing = true;
        var data = await LoadDataAsync();
        UpdateUI(data);
    }
    finally 
    {
        isProcessing = false;
    }
}
  1. 취소 가능한 작업으로 구현:
private CancellationTokenSource _cts;

private async void Timer_Tick_Async(object sender, EventArgs e)
{
    // 이전 작업 취소
    _cts?.Cancel();
    _cts = new CancellationTokenSource();

    try 
    {
        var data = await LoadDataAsync(_cts.Token);
        UpdateUI(data);
    }
    catch (OperationCanceledException)
    {
        // 작업이 취소됨
    }
}

private async Task<Data> LoadDataAsync(CancellationToken token)
{
    return await Task.Run(() => 
    {
        // 주기적으로 취소 요청 확인
        token.ThrowIfCancellationRequested();
        return new Data();
    }, token);
}
  1. 데이터 로딩 중 UI 업데이트를 표시하는 방법:
private async void Timer_Tick_Async(object sender, EventArgs e)
{
    try 
    {
        ShowLoadingIndicator();
        var data = await LoadDataAsync();
        UpdateUI(data);
    }
    finally 
    {
        HideLoadingIndicator();
    }
}

주의사항:

  • Tick 이벤트는 UI 스레드에서 발생하므로, UI 업데이트는 직접 할 수 있습니다
  • 무거운 작업은 Task.Run을 사용해 백그라운드 스레드에서 실행해야 합니다
  • 이전 작업이 완료되기 전에 새 작업이 시작되는 것을 방지하는 로직이 필요할 수 있습니다
  • 에러 처리는 try-catch를 사용해 적절히 처리해야 합니다

이렇게 구현하면 Timer의 Tick 이벤트에서도 비동기 작업을 효율적으로 처리할 수 있습니다.

UI가 close될 때 안전하게 타이머와 비동기 작업을 정리하는 방법을 보여드리겠습니다:

public class MyWindow : Window
{
    private DispatcherTimer timer;
    private CancellationTokenSource _cts;
    private bool _isClosing = false;

    public MyWindow()
    {
        InitializeComponent();

        _cts = new CancellationTokenSource();

        timer = new DispatcherTimer
        {
            Interval = TimeSpan.FromSeconds(1)
        };
        timer.Tick += Timer_Tick_Async;

        // Window가 닫힐 때 정리작업을 위한 이벤트 핸들러 추가
        this.Closing += Window_Closing;

        timer.Start();
    }

    private async void Timer_Tick_Async(object sender, EventArgs e)
    {
        // Window가 닫히는 중이면 작업 중단
        if (_isClosing) return;

        try 
        {
            // 작업 취소 여부 확인을 위한 토큰 전달
            var data = await LoadDataAsync(_cts.Token);

            // Window가 이미 닫혔다면 UI 업데이트 하지 않음
            if (!_isClosing)
            {
                UpdateUI(data);
            }
        }
        catch (OperationCanceledException)
        {
            // 작업이 취소된 경우 - 정상적인 상황
        }
        catch (Exception ex)
        {
            // 다른 예외 처리
            if (!_isClosing)
            {
                MessageBox.Show($"Error: {ex.Message}");
            }
        }
    }

    private async Task<Data> LoadDataAsync(CancellationToken token)
    {
        return await Task.Run(() => 
        {
            // 주기적으로 취소 요청 확인
            token.ThrowIfCancellationRequested();

            // 데이터 로딩 작업...
            return new Data();
        }, token);
    }

    private void Window_Closing(object sender, CancelEventArgs e)
    {
        _isClosing = true;

        // 타이머 중지
        timer.Stop();

        // 실행 중인 비동기 작업 취소
        _cts?.Cancel();

        // 리소스 정리
        _cts?.Dispose();
        _cts = null;
    }

    // Window가 완전히 닫힐 때 추가 정리 작업이 필요한 경우
    protected override void OnClosed(EventArgs e)
    {
        base.OnClosed(e);

        // 추가적인 정리 작업...
    }
}

핵심 포인트:

  1. _isClosing 플래그를 사용하여 Window가 닫히는 중임을 표시
  2. Window.Closing 이벤트에서:
  • 타이머 중지
  • 실행 중인 비동기 작업 취소
  • 리소스 정리
  1. Timer_Tick에서:
  • 작업 시작 전 _isClosing 확인
  • UI 업데이트 전 _isClosing 재확인
  1. CancellationToken을 사용하여 실행 중인 비동기 작업을 안전하게 취소

이렇게 구현하면 Window가 닫힐 때 진행 중인 비동기 작업들이 안전하게 정리되고, 불필요한 UI 업데이트도 방지할 수 있습니다.

깔끔한 방법으로 기본 Window 클래스를 상속받는 베이스 클래스를 만들어서 모든 Window에서 재사용할 수 있습니다:

// 기본 Window 클래스
public class BaseWindow : Window
{
    protected bool IsClosing { get; private set; }
    protected CancellationTokenSource CancellationTokenSource { get; private set; }

    public BaseWindow()
    {
        CancellationTokenSource = new CancellationTokenSource();
        Closing += BaseWindow_Closing;
    }

    private void BaseWindow_Closing(object sender, CancelEventArgs e)
    {
        IsClosing = true;
        CancellationTokenSource?.Cancel();
    }

    protected override void OnClosed(EventArgs e)
    {
        base.OnClosed(e);
        CancellationTokenSource?.Dispose();
        CancellationTokenSource = null;
    }
}

// 실제 사용하는 Window
public class MyWindow : BaseWindow
{
    private DispatcherTimer timer;

    public MyWindow()
    {
        timer = new DispatcherTimer
        {
            Interval = TimeSpan.FromSeconds(1)
        };
        timer.Tick += Timer_Tick_Async;
        timer.Start();
    }

    private async void Timer_Tick_Async(object sender, EventArgs e)
    {
        if (IsClosing) return;

        try 
        {
            var data = await LoadDataAsync(CancellationTokenSource.Token);
            if (!IsClosing)
            {
                UpdateUI(data);
            }
        }
        catch (OperationCanceledException)
        {
            // 작업 취소됨
        }
    }
}

이렇게 하면:

  1. IsClosing 체크와 CancellationTokenSource 관리를 모든 Window에서 재사용할 수 있습니다.
  2. 각 Window에서는 베이스 클래스를 상속받아 필요한 로직만 작성하면 됩니다.
  3. 코드 중복이 줄어들고 유지보수가 쉬워집니다.

ViewModel에서 타이머를 사용하는 경우 IDisposable을 구현하여 리소스를 정리하는 것이 좋습니다. 윈도우가 닫힐 때 ViewModel의 Dispose가 호출되도록 구성하면 됩니다:

// ViewModel 기본 클래스
public abstract class ViewModelBase : INotifyPropertyChanged, IDisposable
{
    protected bool IsDisposing { get; private set; }
    protected CancellationTokenSource CancellationTokenSource { get; private set; }

    protected ViewModelBase()
    {
        CancellationTokenSource = new CancellationTokenSource();
    }

    public virtual void Dispose()
    {
        IsDisposing = true;
        CancellationTokenSource?.Cancel();
        CancellationTokenSource?.Dispose();
        CancellationTokenSource = null;
    }

    public event PropertyChangedEventHandler PropertyChanged;
    // ... PropertyChanged 구현
}

// 실제 ViewModel
public class MyViewModel : ViewModelBase
{
    private readonly DispatcherTimer timer;

    public MyViewModel()
    {
        timer = new DispatcherTimer
        {
            Interval = TimeSpan.FromSeconds(1)
        };
        timer.Tick += Timer_Tick_Async;
        timer.Start();
    }

    private async void Timer_Tick_Async(object sender, EventArgs e)
    {
        if (IsDisposing) return;

        try 
        {
            var data = await LoadDataAsync(CancellationTokenSource.Token);
            if (!IsDisposing)
            {
                UpdateUI(data);
            }
        }
        catch (OperationCanceledException)
        {
            // 작업 취소됨
        }
    }

    public override void Dispose()
    {
        timer.Stop();
        base.Dispose();
    }
}

// Window에서 ViewModel 사용
public class MainWindow : Window
{
    private MyViewModel viewModel;

    public MainWindow()
    {
        InitializeComponent();
        viewModel = new MyViewModel();
        DataContext = viewModel;

        // Window가 닫힐 때 ViewModel Dispose 호출
        Closed += (s, e) => viewModel?.Dispose();
    }
}

더 나은 방법으로 종속성 주입을 사용한다면:

public class MainWindow : Window
{
    private readonly MyViewModel _viewModel;

    public MainWindow(MyViewModel viewModel)
    {
        InitializeComponent();
        _viewModel = viewModel;
        DataContext = _viewModel;
        Closed += (s, e) => _viewModel?.Dispose();
    }
}

// App.xaml.cs나 DI 설정에서
services.AddTransient<MyViewModel>();
services.AddTransient<MainWindow>();

이렇게 하면:

  1. ViewModel의 리소스가 안전하게 정리됩니다.
  2. Window가 닫힐 때 자동으로 ViewModel의 리소스도 정리됩니다.
  3. 타이머와 관련된 비동기 작업이 안전하게 취소됩니다.
  4. 코드가 더 모듈화되고 테스트하기 쉬워집니다.

“Devexpress chart 다루기”의 한가지 생각

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다