-
C# 윈폼에 아키텍처 패턴을 적용해보자! (feat. MVP, MVVM)닷넷/WinForms 2024. 2. 22. 22:26반응형
공장에서 윈폼을 하다보면 객체지향 언어인 C#을 절차적인 언어처럼 쓰고, 막 주먹구구식으로 쓰는 경우가 태반일 것이다.
그러다 보면 유지보수가 너어무 힘들어서 윈폼 자체에 노이로제가 걸릴 수도 있다.
그러나!
C#의 윈폼도 엄연히 C#의 프레임워크 중 하나인 것 뿐인데,
C#은 객체지향 언어인데, 윈폼도 객체지향 적으로 짤 수는 없는 것일까?
수월한 유지보수를 위한 아키텍처 패턴과 디자인 패턴을 적용할 수 없는 것일까?
정답은 가능하다!
객체지향 적으로 짜기 위해 객체지향 설계 5원칙을 따르면 되기는 하나, 통용적인 패턴을 이용하면 보다 쉽게 짤 수가 있다.
여기서는 아키텍처 패턴을 적용 해보는 것에 대해서 설명하고자 한다.
1. 아키텍처 패턴이란?
아키텍처 패턴은 소프트웨어 설계에서 반복적으로 발생하는 문제에 대한 일반적인 해결책을 제공하는 템플릿과 같은 것입니다. 건축에서 건축 양식이 건물의 구조와 디자인에 대한 틀을 제공하는 것처럼, 아키텍처 패턴은 소프트웨어 시스템의 구조와 구성에 대한 틀을 제공합니다.
아키텍처 패턴은 다음과 같은 특징을 가지고 있습니다.
- 문제: 아키텍처 패턴은 특정한 설계 문제를 해결하기 위해 만들어집니다.
- 해결책: 아키텍처 패턴은 문제에 대한 일반적인 해결책을 제공합니다.
- 구성 요소: 아키텍처 패턴은 시스템을 구성하는 주요 구성 요소와 그들의 관계를 정의합니다.
- 지침: 아키텍처 패턴은 시스템을 구현하기 위한 지침을 제공합니다.
아키텍처 패턴을 사용하면 다음과 같은 이점이 있습니다.
- 재사용성: 이미 검증된 해결책을 제공하기 때문에 개발 시간을 단축하고 코드의 품질을 향상시킬 수 있습니다.
- 유지 관리성: 시스템을 더욱 이해하기 쉽고 유지 관리하기 쉬워집니다.
- 확장성: 시스템을 더 쉽게 확장하고 변경할 수 있습니다.
널리 사용되는 아키텍처 패턴에는 다음과 같은 것들이 있습니다.
- MVC 패턴: 모델-뷰-컨트롤러 패턴은 사용자 인터페이스를 구현하는 데 사용되는 패턴입니다.
- MVP 패턴: 모델-뷰-프레젠터 패턴은 MVC 패턴의 변형으로, 더욱 분리된 구조를 제공합니다.
- MVVM 패턴: 모델-뷰-뷰모델 패턴은 데이터 바인딩을 사용하여 사용자 인터페이스를 구현하는 데 사용되는 패턴입니다.
- 클라이언트-서버 패턴: 클라이언트-서버 패턴은 서로 다른 컴퓨터에서 실행되는 두 개의 프로세스 사이의 통신을 정의하는 패턴입니다.
- 레이어드 패턴: 레이어드 패턴은 시스템을 여러 개의 레이어로 나누어 각 레이어가 특정 기능을 수행하도록 하는 패턴입니다.
참고 자료: Gemini
- 아키텍처 패턴 - 위키백과
- 아키텍처 패턴 입문
- 아키텍처 패턴 튜토리얼
2. MVP 패턴과 MVVM 패턴
1) MVP 패턴
MVP 패턴에 대해 간단하게 설명하면, MVP는 데이터와 비즈니스 로직을 담당하는 Model과 사용자 인터페이스를 담당하는 View, Model과 View 사이의 중개자 역할을 하며 View를 통해 들어온 사용자 입력을 처리하고, View에 데이터를 전달하는 Presenter로 이루어진 패턴이다.
이를 그림으로 표현하면 다음과 같다.
참고로 윈폼에서는 View가 곧 Form이다.
2) MVVM 패턴
MVVM 패턴은 Model-View-ViewModel로 설계를 분리한 패턴이다. MVP가 MVC에서 발전된 형태이면, MVVM은 MVP에서 발전된 형태이다.
MVP와 MVVM 패턴의 큰 차이점은 데이터 바인딩 기술이다. MVP에서는 Presenter와 View가 보통은 인터페이스를 통해서 상호 작용 하지만, MVVM에서는 바인딩 덕분에 ViewModel과 View을 완전히 분리해서 작업할 수 있다. 데이터 표준만 맞추면 된다.
3. 윈폼에 아키텍처 패턴 적용하기
그동안은 Form의 소스 코드에 모든 것을 때려박는 식으로 코딩을 해왔다면 이번에는 이 포스팅을 보므로써 윈폼도 이 아키텍처 패턴을 적용해서 프로젝트를 만들면 유지 관리 하기가 수월해지고, 확장과 변경도 보다 쉽게 할 수 있음을 알 수 있게 될 것이다.
닷넷프레임워크에서의 윈폼에서는 MVP 패턴을 적용하고, 닷넷8에서 윈폼에 MVVM 패턴을 위한 바인딩 기능이 추가됐다고 하여 MVVM 패턴을 적용한다.
잠깐!
MVC와 MVP와 MVVM 패턴의 공통점은 뷰와 모델을 분리하고, 이 둘의 중개자를 두는 것이므로 왜 윈폼에 MVC(Model-View-Controller) 적용에 대한 얘기는 없냐 하면은 윈폼에 MVC는 적용되기 어려운 패턴이다.
MVC는 주로 웹쪽에서 쓰일텐데,
이 이미지를 보면 쉽게 이해 될 것이다.
MVC는 MVC 3가지 중에서 Controller를 통해서 사용자 입력이 들어온다.
반면에 MVP와 MVVM은 사용자 입력이 View(화면)에서 들어온다.
윈폼에서는 폼을 통해서 사용자 입력이 들어오는 부분을 담당한 상태에서 처리영역을 분리를 하는 것이기 때문에 MVC는 적합하지 않다.
1) MVP 패턴 적용하기
MVP 패턴대로 책임과 역할을 분리하기 위해 Form에서 View와 Presenter, Model이 되는 것을 전부 분리한다. 분리하면 서로 어떻게 상호 작용할까 싶은데 이것은 인터페이스를 이용해서 해결할 수 있다. (=> ISP, DI)
아, 그런데 나는 View와 Presenter는 1대1 관계인 경우에는 인터페이스로 소통하지 않으므로 Presenter끼리 소통할 때에만 인터페이스를 사용하는 것을 볼 수 있을 것이다.
이때 DI를 위해 Microsoft.Extensions.DependencyInjection가 사용되었다.
화면은 윈도우의 기본 계산기를 참고해서 만들어 볼 것이지만, 패턴을 적용했을 때 프로젝트의 구조에 초점을 두었기 때문에 계산기의 기능들에 대한 상세한 것은 여기서 기술하지 않는다.
대상 프레임워크: .Net Framework 4.7.2
IDE: Visual Studio 2022
프로젝트를 생성하면 처음 상태는 이렇게 될 것인데, 각 역할에 따라서, 필요에 따라서 프로젝트를 구성해 나갈 것이다.
Windows의 기본 계산기를 실행하면 표준 계산기가 나온다. 그 다음, 계산기에는 메뉴가 여러 가지가 있는데 각 메뉴를 누르면 표준 계산기 화면은 사라지고, 선택한 메뉴가 나온다.
이것을 윈폼의 관점에서 보면 메인 폼이 있고, 메인폼에서는 메뉴에 따라서 메인폼 공간에 메뉴에 해당하는 서브폼을 띄우는 구조이다.
나는 메인폼에 메뉴를 메뉴스트립에 두고, 폼에는 panel(MenuContent)을 둬서 panel에 메뉴에 해당하는 UI들을 갈아끼웠다. 메뉴가 하는 일은 각자 메뉴에 해당하는 View(UI)와 Presenter가 담당하면 되므로 메인폼은 많은 것을 알지 않아도 되는 것이다. (단일 책임 원칙)
위 화면을 위해 만들어진 프로젝트의 전체 구성이다.
모델은 MVVM 패턴때 동일하게 쓸 것이기 때문에 프로젝트를 나눴다. (이렇게 모듈 단위로 분리하면 단위 테스트할 때도 좋다.)
View와 Presenter의 상호작용과 Presenter와 Model의 상호작용에 중점을 두고 코드 설명을 하면 먼저 MainForm과 MainPresenter에 대한 설명은 다음과 같다.
public partial class MainForm : Form { private readonly MainPresenter presenter; public MainForm(IServiceProvider services) { InitializeComponent(); presenter = new MainPresenter(this, services); } public void ShowMenu(UserControl ui) { MenuContent.Controls.Clear(); ui.Dock = DockStyle.Fill; MenuContent.Controls.Add(ui); } private void 표준ToolStripMenuItem_Click(object sender, EventArgs e) { presenter.GetMenu(MenuType.Standard); } private void 날짜계산ToolStripMenuItem_Click(object sender, EventArgs e) { presenter.GetMenu(MenuType.Date); } }
MainForm의 소스 코드에는 MenuContent에 메뉴를 갈아끼우는 작업들로만 이루어져 있다. 메뉴 UI에 대한 정보는 MainPresenter의 GetMenu를 호출해서, MainPresenter가 MainForm의 ShowMenu로 전달한다.
internal class MainPresenter { private readonly MainForm form; private readonly IStandardService standardService; private readonly IDateService dateTimeService; public MainPresenter(MainForm form, IServiceProvider services) { this.form = form; standardService = services.GetService<IStandardService>(); dateTimeService = services.GetService<IDateService>(); } public void GetMenu(MenuType menuType) { switch (menuType) { case MenuType.Standard: { form.ShowMenu(standardService.CreateUI()); } break; case MenuType.Date: { form.ShowMenu(dateTimeService.CreateUI()); } break; } } }
MainPresenter는 다른 Presenter나 UI를 자세히 알 필요는 없다. Interface를 통해서 해당 메뉴 정보를 가져온 다음 View에게 전달 한다.
즉, 표준 메뉴를 예로 들면, 사용자가 메뉴 스트립에 있는 표준을 클릭하면, MainForm의 소스 코드에 표준ToolStripMenuItem_Click 이벤트가 반응하면서 MainPresenter에 데이터를 요청하고 MainPresenter는 내부 로직 대로 자료를 요청하여 전달 받은 것을 MainForm에 전달한다. 그렇게 화면이 변경이 된다.
참고로 Interface는 Presenter와 연결되어 있으며 이 서비스 등록 작업은 Program 단에서 어플리케이션을 실행하기 전에 실행되게 코딩되어 있다.
internal partial class StandardUI : UserControl { private readonly StandardPresenter presenter; public StandardUI(StandardPresenter presenter) { InitializeComponent(); this.presenter = presenter; } public void ShowHistory(string history) { if (string.IsNullOrEmpty(history)) { history = "기록된 것이 나온다."; } labelHistory.Text = history; } private void buttonGetHistory_Click(object sender, System.EventArgs e) { presenter.GetHistory(); } }
그리고 표준에 있는 기록 버튼을 클릭하면 StandardUI에 있는 기록 버튼 이벤트가 반응하고, 기록 정보는 뷰가 그냥은 처리할 수 없기 때문에 StandardPresenter에 정의 되어 있는 GetHistory를 호출한다.
internal class StandardPresenter : IStandardService { StandardUI StandardUI; public StandardPresenter() { StandardUI standardUI = new StandardUI(this); StandardUI = standardUI; } public UserControl CreateUI() { return StandardUI; } public void GetHistory() { History history = new History(); string result = history.InputNumbers.First().ToString(); for (int i = 0; i < history.InputSymbols.Count(); i++) { decimal nextNumber = history.InputNumbers.ElementAt(i + 1); switch (history.InputSymbols.ElementAt(i)) { case SymbolType.Addition: result += " + " + nextNumber; break; case SymbolType.Subtraction: result += " - " + nextNumber; break; case SymbolType.Multiplication: result += " * " + nextNumber; break; case SymbolType.Division: result += " / " + nextNumber; break; } } StandardUI.ShowHistory(result); } }
using Calculator.Models.Enums; using System.Collections.Generic; namespace Calculator.Models { public class History { public IEnumerable<decimal> InputNumbers { get; set; } public IEnumerable<SymbolType> InputSymbols { get; set; } public History() { InputNumbers = new List<decimal>() { 123, 456 }; InputSymbols = new List<SymbolType> { SymbolType.Multiplication }; } } }
namespace Calculator.Models.Enums { public enum SymbolType { Multiplication, Division, Addition, Subtraction, } }
StandardPresenter에서는 데이터를 가져오기 위해 Model과 상호작용을 하는데 이때도 인터페이스를 통해서 하는 방법이 좋지만, 이 프로젝트에는 모델에 비즈니스 로직을 구현하지는 않아서, 하드코딩을 해둔 것만 사용하고 있으므로 다이렉트로 연결해서 사용하는 것을 보여준다.
이렇게 C# 윈폼에 MVP 패턴을 적용하는 것을 알아보았다.
그 다음 이제 이것과 비슷한 화면을 MVVM 패턴으로 구현해보자.
2) MVVM 패턴 적용하기
대상 프레임워크: .Net 8
2024.03.01 - [끄적이는/정보공유-IT] - .NET (닷넷) 8.0 다운로드
화면은 똑같다.
그러나 안에 구조가 다르다. 여기서는 바인딩에 초점을 두어 설명을 하도록 하겠다.
MVVM 구조로 만들기 위해 MVP보다 사전 작업이 더 많이 필요할 수도 있다.
그렇기 때문에 도구를 사용하면 보다 쉽게 MVVM 구조를 잡을 수 있는데, 여기서는 Microsoft가 지원하는 CommunityToolkit을 사용한다.
먼저 View와 ViewModel들을 만든다.
그다음 View에 ViewModel을 연결해야 하는데, 두 가지 방식이 있다.
using Calculator.Net.ViewModels.Menu; namespace Calculator.Net.Views.Menu { public partial class StandardView : UserControl { readonly StandardViewModel viewModel; public StandardView() { InitializeComponent(); viewModel = new StandardViewModel(); Load += StandardView_Load; } private void StandardView_Load(object sender, EventArgs e) { labelTitle.DataBindings.Add("Text", viewModel, nameof(viewModel.Title)); labelHistory.DataBindings.Add("Text", viewModel, nameof(viewModel.History)); buttonHistory.Command = viewModel.GetHistoryCommand; } } }
하나는 비하인드 코드를 통해서 각각의 컨트롤에 뷰모델의 어떤 속성과 연결할지 정할 수 있다.
다른 하나는 비하인드 코드를 아예 안 쓰기 위한 방법이면서 디자인에서 제공되는 속성에서 설정하는 방법이다.
그런데 뷰모델과 통신이 제대로 되지 않는 것으로 봐서는 무언가 설정을 해야 하는 게 하나 더 있는 것 같은데, 그게 무엇인지 지금은 알 수 없어서 이 방법은 아직은 쓸 수 없다.
컨트롤의 속성에서 (DataBindings)를 펼치면 바인딩할 수 있는 속성이 나오는데,
(고급)에서 더 많은 속성을 설정할 수 있다.Text에 기록이 표시되게 하려는 것이므로 Text를 바인딩한다.
새 개체 데이터 원본 추가에서 뷰모델을 선택하여 데이터 원본을 추가한다.
이때 뷰모델은 무조건 public으로 만들어야 이 화면에서 내가 선택하려는 뷰모델이 나온다.
클래스를 기본 생성 할 때 internal로 생성 되는데, 안 나오면 꼭 확인해보자.
그다음 바인딩소스에서 매칭할 뷰모델의 속성을 선택한다.
버튼은 클릭 이벤트 대신 Command 속성을 사용한다.
using Calculator.Common.Base; using Calculator.Common.Enums; using Calculator.Common.Interfaces; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; using System.Windows.Input; namespace Calculator.Net.ViewModels.Menu { public class StandardViewModel : ViewModelBase { readonly Lazy<INumberService> numberService; private string title; private string history; public string Title { get => title; set => SetProperty(ref title, value); } public string History { get => history; set => SetProperty(ref history, value); } public StandardViewModel() { numberService = new Lazy<INumberService>(ServiceProvider.GetRequiredService<INumberService>()); Title = "표준"; GetHistoryCommand = new RelayCommand(GetHistory); } public ICommand GetHistoryCommand { get; set; } private void GetHistory() { History = numberService.Value.GetHistory(MenuType.Standard); } } }
뷰모델은 이와 같이 코딩되어 있다.
속성에 SetProperty와 ICommand가 뷰모델에서의 바인딩 핵심 기술이다.
SetProperty는 속성의 정보를 뷰에 notify 할 수 있는 방법이다.
Command는 뷰모델이 뷰의 이벤트를 전달 받을 수 있는 방법이며, Command는 원래라면 ICommand를 구현하는 것을 만들어서 사용해야 되지만 CommunityToolkit의 RelayCommand를 쓰면 쉽게 해결 된다. 또한 이것은 ViewModelBase에서 상속되어 있기에 가능하다.
using CommunityToolkit.Mvvm.ComponentModel; namespace Calculator.Common.Base { public class ViewModelBase : ObservableObject { public static IServiceProvider ServiceProvider { get; set; } public ViewModelBase() { } public ViewModelBase(IServiceProvider service) : base() { ServiceProvider = service; } } }
그리고 위에 MVP 때는 기록 정보를 가져오는 것을 뷰모델에서 모델과 직접 통신해서 가져왔다면 여기서는 서비스 인터페이스를 이용한다는 점이 다르다.
물론 위에 MVP 구현때는 뷰모델과 모델이 다이렉트로 통신하였지만 원래라면 모델용 인터페이스를 써도 되는데 왜 서비스 인터페이스를 만들었을까?
그것은 확장가능성이 있기 때문이다.
계산기에는 숫자가 필요한 메뉴가 표준 뿐만 아니라 공학용, 프로그래머 등이 있다.
각 기능은 계산할 때 무언가 공통점이 있을 것 같지 않은가?
이것은 나중에 실제 화면을 만들 때 바꿔도 되긴 하지만 책임과 역할이 바뀔 것이 분명하기 때문에 처음 설계할 때부터 뷰모델에서 서비스를 분리하는 것이다.
그러면서 모델은 자연스럽게 서비스와만 통신하게 될 것이고, 뷰모델은 확실하게 독립적인 객체가 될 수 있다.
이렇게 윈폼도 아키텍처 패턴을 적용해서 만들 수 있음을 알 수 있었다.
비록 처음 프로젝트를 만들 때는 사전 작업해야 할 것도 많고, 책임과 역할에 따라서 분리를 해야 하다 보니까 이게 맞나 저게 맞나 고민하면서 만들다 보면 주먹구구식으로 만드는 것보다는 속도가 더디게 느껴질 수도 있다. 그럴 때는 일단 구현해보고 구조를 고쳐나가면 되는 것이고, 결승점에서 보면 아키텍처 패턴을 따르면서 프로젝트를 만들 때 완성도가 제일 높을 것이다. 또한 기능 확장과 변경도 보다 쉽게 가능 할 것이다.
패턴이란 것이 다 선배들이 겪고 보니 필요하다는 걸 깨닫고 나온 것이기 때문에, 같은 실수는 반복하지 않기를 바라며.
이 프로젝트의 소스는 시간이 날 때 작업하다가 업데이트 될 수도 있으며, 이 프로젝트의 전체 소스 코드는 내 깃허브 리포지토리에서 확인 가능 하다.
https://github.com/yoosple/Winforms/tree/main/Projects/Calculator
(진짜 Windows 계산기 따라하기는 WPF로 만들어 봐야지)
반응형'닷넷 > WinForms' 카테고리의 다른 글
C# WinForms DevExpress AccordionControl 사용하기 (1) 2023.08.23 C# WinForms DataGridView 복사 후 워드에 저장하기 (3) 2023.03.21 C# WinForms DevExpress CheckedListBoxControl에 바인딩 된 아이템들 중에서 특정 아이템만 체크/미체크 안 되게 비활성화 하는 방법 (0) 2023.02.24 C# Winforms 직접 만든 달력 (커스텀 달력) (0) 2023.01.13 C# WinForms DevExpress GridControl에 체크박스 칼럼 (헤더 포함) 추가하기 (0) 2022.12.12