ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Blazor Server로 풀스택 웹어플리케이션 만들기(2) - MVVM 아키텍처 적용 하기
    .NET/WEB 2025. 2. 9. 17:59

    이번 포스트에서는 Blazor Server에서 MVVM 패턴을 적용하는 방법에 대해서 다루겠습니다. 이번 포스트는 2024년 닷넷데브에서 발표한 MVVM With Blazor 내용의 일부입니다. 

     

     

    출처 : baike.baidu.com/item/MVVM/96310

     

    MV 로 시작하는 많은 모델들이 있지만, 중요한 점은 각각의 레이어가 어떤 역할을 맡고있고 어느 방향의 의존성을 갖고 있는지가 해당 패턴이 어떤 아키텍처를 모델로 하는지가 뚜렸한 점이라고 생각합니다.

     

    MVVM은 View, Model ,View Model로 나눠진 클라이언트 어플리케이션 아키텍처 입니다.

    View

    View는 사용자가 직접 보는 화면을 의미하며, 화면에 표시되는 데이터와 이벤트 처리를 담당합니다. Blazor에서 View는 Razor 페이지 및 컴포넌트로 구성됩니다.

    View에서는 데이터 바인딩커맨드 패턴을 통해 ViewModel과 상호작용합니다. 데이터 바인딩을 통해 View에서 보여줘야 할 데이터가 변경되면 Blazor의 바인딩 엔진이 자동으로 ViewModel의 프로퍼티에 변경 사항을 전달합니다.

    사용자 이벤트 처리는 일반적으로 커맨드 패턴을 사용하지만, Blazor에서는 메서드를 직접 바인딩할 수 있습니다. WPF에서는 ICommand 인터페이스를 상속받아 RelayCommand로 처리하는 방법이 기본이지만, Blazor에서는 이러한 과정 없이 직접 ViewModel의 메서드에 바인딩하는 방법이 좀 더 편리하다고 생각합니다.

    ViewModel

    ViewModel의 역할에 대해서는 오해와 실수의 여지가 많은 부분이 있습니다. ViewModel은 UI와 관련된 데이터를 관리하며, View와 Model 간의 중재자 역할을 합니다. 하지만 ViewModel은 비즈니스 로직을 최소화하고, 사용자에게 보여지는 데이터에 집중해야 합니다. 즉, ViewModel은 View가 있기 때문에 존재하는 계층이며, View가 없다면 ViewModel도 존재할 필요가 없습니다.

     

    Model

    Model은 데이터 처리 및 비즈니스 로직을 담당합니다. 예를 들어, 권한 상태를 API에서 int 값으로 받아 비트 플래그로 처리한다고 가정해 봅시다. 하지만 View에서는 체크박스를 여러 개 배치하고, 각 체크박스를 ViewModel의 bool 프로퍼티와 연결해야 합니다.

    bool 값들을 API로 전송할 때 int 값으로 변환하는 작업이 필요합니다. 이러한 변환 로직이 Model의 역할입니다.

     

    MVVM과 같은 클라이언트 아키텍처에서는 Model 레이어의 구체적인 구현 방식이 명확하게 정의되어 있지 않습니다. 어떤 경우에는 단일 클래스로 데이터 파싱, 전송, 수신 등의 역할을 처리하기도 하고, 좀 더 구체적으로 나누어 리포지토리 패턴을 도입하기도 합니다. 이처럼 Model 레이어는 개발자의 자율성에 따라 다양한 방식으로 설계될 수 있습니다.



    Sample 코드 

    샘플 코드는 Blazor Server로 이뤄진 풀스택 어플리케이션으로 준비되어 있습니다.

    간단한 WareHouse Management System을 가정하여 준비하였습니다. 

    프론트 + 백엔드 + 데이터베이스 정의까지 한개의 프로젝트로 이뤄져있고

    https://github.com/atawLee/dotnetdevSeoul2024/ 

     

    GitHub - atawLee/dotnetdevSeoul2024

    Contribute to atawLee/dotnetdevSeoul2024 development by creating an account on GitHub.

    github.com

     

    에서 전체 코드를 확인 할 수 있습니다. 

     

    View 샘플코드와 설명

    더보기

    View 전체 코드 예시입니다.

    @page "/mvvm"
    @using SimpleWMS.Data
    @using SimpleWMS.Database.Entities
    @using SimpleWMS.Models.Services
    @using SimpleWMS.ViewModels
    @inject StockHistoryViewModel _viewModel;
    
    @code {
        
        private bool _showModal = false;
    
        protected override void OnInitialized()
        {
            _viewModel.Search();
        }
    
        private void ShowModal(StockTransactionType modalType)
        {
            _viewModel.SetModalType(modalType);
            _showModal = true; 
        }
    
        private void HideModal()
        {
            _showModal = false; 
        }
    }
    
    <h3 class="mb-4">입출고 이력 조회</h3>
    <div class="search-box">
        <div class="row mb-3">
            <div class="col">
                <label for="itemName" class="form-label">품목명</label>
                <input type="text" class="form-control" id="itemName" @bind="_viewModel.SearchItemName">
            </div>
            <div class="col">
                <label for="materialId" class="form-label">바코드</label>
                <input type="text" class="form-control" id="materialId" @bind="_viewModel.SearchBarcode">
            </div>
            <div class="col d-flex align-items-end">
                <div class="form-check">
                    <input type="checkbox" class="form-check-input" id="stockIn" @bind="_viewModel.SearchStockIn">
                    <label for="stockIn" class="form-check-label">입고 사항</label>
                </div>
            </div>
            <div class="col d-flex align-items-end">
                <div class="form-check">
                    <input type="checkbox" class="form-check-input" id="stockOut" @bind="_viewModel.SearchStockOut">
                    <label for="stockOut" class="form-check-label">출고 사항</label>
                </div>
            </div>
        </div>
        <div class="row mb-3">
            <div class="col d-flex justify-content-between">
                <button class="btn btn-primary" @onclick="_viewModel.Search">검색</button>
                <div>
                    <button class="btn btn-primary" @onclick="() => ShowModal(StockTransactionType.StockIn)">입고 추가</button>
                    <button class="btn btn-secondary" @onclick="() => ShowModal(StockTransactionType.StockOut)">출고 추가</button>
                </div>
            </div>
        </div>
    </div>
    
    @if (_viewModel.StockHistorySearchResults != null && _viewModel.StockHistorySearchResults.Any())
    {
        <table class="table">
            <thead>
                <tr>
                    <th>품목명</th>
                    <th>바코드</th>
                    <th>위치</th>
                    <th>구분</th>
                    <th>수량</th>
                    <th>일시</th>
                </tr>
            </thead>
            <tbody>
                @foreach (var history in _viewModel.StockHistorySearchResults)
                {
                    <tr>
                        <td>@history.ProductName</td>
                        <td>@history.Barcode</td>
                        <td>@history.Location</td>
                        <td>@history.StockTransactionTypeText</td>
                        <td>@history.Quantity</td>
                        <td>@history.StockDateTime</td>
                    </tr>
                }
            </tbody>
        </table>
    }
    else
    {
        <p>검색 결과가 없습니다.</p>
    }
    
    @if (_showModal)
    {
        <div class="modal" tabindex="-1" style="display:block; background-color: rgba(0,0,0,0.5);" role="dialog">
            <div class="modal-dialog" role="document">
                <div class="modal-content">
                    <div class="modal-header">
                        <h5 class="modal-title">@_viewModel.ModalTitle</h5>
                        <button type="button" class="close" @onclick="HideModal">
                            <span aria-hidden="true">&times;</span>
                        </button>
                    </div>
                    <div class="modal-body">
                        <select class="form-control mb-2" @bind="_viewModel.SelectedStockInventoryId">
                            @foreach (var item in _viewModel.InventoryItems)
                            {
                                <option value="@item.Id">@item.Barcode</option>
                            }
                        </select>
                        <input type="number" class="form-control mb-2" placeholder="수량" @bind="_viewModel.StockInOutQuantity">
                        <input type="date" class="form-control mb-2" @bind="_viewModel.StockInOutDate">
                    </div>
                    <div class="modal-footer">
                        <button type="button" class="btn btn-primary" @onclick="_viewModel.AddData">추가</button>
                        <button type="button" class="btn btn-secondary" @onclick="HideModal">닫기</button>
                    </div>
                </div>
            </div>
        </div>
    }

     

     

    showModal이 있는 view 레이어에 있는 의도는 해당부분은 비즈니스로직과 중재할 부분이 없는 것으로 보았기 때문에 ui로 두었습니다.

     

    WPF에서의 사용하는 경우와 차이점이 있다면, WPF의 경우에서 저라면 showmodal또한 viewmodel 바인딩으로 처리했을 것 같습니다.

     

    하지만 blazor에서는 한페이지에서 코드처리가 가능하고, 뷰에서만 동작하는 내용을 뷰모델로 가져가는 것이 아쉽게 느껴져 위와같이 작성했습니다. 

     

    이벤트 또한 바로 닫아주는 이벤트에서 view의 처리가 필요할때는 내부메서드에서 viewmodel의 메서드를 호출하고, 닫아주는 방식으로 사용했습니다. 

    ViewModel 설명

    더보기

    ViewModel 전체 코드 입니다.

    using SimpleWMS.Data;
    using SimpleWMS.Database.Entities;
    using SimpleWMS.Models.Services;
    
    namespace SimpleWMS.ViewModels;
    
    public class StockHistoryViewModel
    {
        private readonly WareHouseManagementService _service;
    
        public string? SearchItemName { get; set; }
        public string? SearchBarcode { get; set; }
        public bool SearchStockIn { get; set; }
        public bool SearchStockOut { get; set; }
        public string ModalTitle { get; set; } = "";
        public StockTransactionType CurrentModal { get; set; }
        public DateTime StockInOutDate { get; set; } = DateTime.Today;
        public int StockInOutQuantity { get; set; }
        public int SelectedStockInventoryId { get; set; }
    
        public List<InventoryItem> InventoryItems { get; private set; } = new List<InventoryItem>();
        public List<StockHistoryDTO> StockHistorySearchResults { get; private set; } = new List<StockHistoryDTO>();
    
        public StockHistoryViewModel(WareHouseManagementService service)
        {
            _service = service;
            InitializeViewModel();
        }
    
        public void InitializeViewModel()
        {
            InventoryItems = _service.GetAllInventoryItems();
            Search();
        }
    
        public void Search()
        {
            StockHistorySearchResults = _service.GetStockInOutsFiltered(SearchItemName, SearchBarcode, DetermineTransactionType());
        }
    
        private StockTransactionType? DetermineTransactionType()
        {
            if (SearchStockIn && !SearchStockOut)
            {
                return StockTransactionType.StockIn;
            }
            else if (!SearchStockIn && SearchStockOut)
            {
                return StockTransactionType.StockOut;
            }
    
            return null;
        }
    
        public void ShowModal(StockTransactionType modalType)
        {
            CurrentModal = modalType;
        }
    
        public void AddData()
        {
            try
            {
                var stockType = CurrentModal;
                var dto = new StockCommandDTO(SelectedStockInventoryId, StockInOutQuantity, stockType);
                _service.InsertStockInOut(dto, dto.Quantity, stockType);
    
                Search();
            }
            catch (Exception ex)
            {
                Console.Error.WriteLine($"Error adding stock in/out data: {ex.Message}");
            }
        }
    
        public void SetModalType(StockTransactionType modalType)
        {
            ModalTitle = modalType == StockTransactionType.StockIn ? "입고 추가" : "출고 추가";
        }
    }

    ViewModel에서는 전반적인 중재의 역할을 하고 있습니다.

    여기서는 WPF와 달리 CommunityToolkit.Mvvm 패키지를 사용하지 않더라도

    사용자가 발생시킨 이벤트에 대해서는 별다른 UI 이벤트로 매핑하지 않더라도 정상적으로 변경 반영되는 것을 확인 할 수 있습니다.

     

    하지만 다른 사용자가 같은 인스턴스로 사용하므로써 변경시키고 UI에 반영 시키고 싶다면 View의 StateHasChanged() 를 호출시켜야 합니다. 

     

    메세지 어그리게이터 패턴으로 위와같은 방식으로 동시에 같은 인스턴스를 공유하여 실시간 통신을 구축할수 있습니다.

    Model 

     

    더보기

    WareHouseManagementService.cs

    using SimpleWMS.Data;
    using SimpleWMS.Database.Entities;
    using SimpleWMS.Mdels;
    using SimpleWMS.Mdels.Repository;
    
    namespace SimpleWMS.Models.Services;
    
    public class WareHouseManagementService
    {
        private readonly WarehouseRepository _warehouseRepository;
        private readonly UnitOfWork _unitOfWork;
    
        public WareHouseManagementService(WarehouseRepository warehouseRepository, UnitOfWork unitOfWork)
        {
            _warehouseRepository = warehouseRepository;
            _unitOfWork = unitOfWork;
        }
    
        public List<Product> GetAllProducts()
        {
            return _warehouseRepository.GetProducts();
        }
    
        public List<InventoryItem> GetAllInventoryItems()
        {
            return _warehouseRepository.GetInventoryItems();
        }
    
        public InventoryItem? GetInventoryItemById(int inventoryItemId)
        {
            return _warehouseRepository.GetInventoryItem(inventoryItemId);
        }
    
        public List<StockHistoryDTO> GetStockInOutsFiltered(string? productName = null, string? barcode = null, StockTransactionType? type = null)
        {
            return _warehouseRepository.GetStockInOuts(productName, barcode, type).Select(dbentity => dbentity.ToDTO())
                .ToList();
        }
    
        public void InsertStockInOut(StockCommandDTO dto, int quantityChange, StockTransactionType transactionType)
        {
            try
            {
                _unitOfWork.BeginTransaction();
                var inventory = _warehouseRepository.GetInventoryItem(dto.InventoryId)
                                ?? throw new Exception("잘못된 요청.");
    
                quantityChange = transactionType == StockTransactionType.StockOut ? -Math.Abs(quantityChange) : Math.Abs(quantityChange);
                var stockData = dto.ToDatabaseEntity(inventory.Quantity);
                
                _warehouseRepository.InsertStockInOut(stockData);
                _warehouseRepository.UpdateInventory(inventory, stockData.AfterQuantity);
                _unitOfWork.CommitTransaction();
            }
            catch (Exception e)
            {
                _unitOfWork.RollbackTransaction();
                throw new Exception("등록 실패");
            }
            
        }
    }

     

    WarehouseRepository.cs

    using Microsoft.EntityFrameworkCore;
    using SimpleWMS.Database.Context;
    using SimpleWMS.Database.Entities;
    
    namespace SimpleWMS.Mdels.Repository;
    
    public class WarehouseRepository
    {
        private readonly ApplicationDbContext _db;
    
        public WarehouseRepository(ApplicationDbContext db)
        {
            _db = db;
        }
    
        public List<Product> GetProducts()
        {
            return _db.Product.ToList();
        }
    
        public List<InventoryItem> GetInventoryItems()
        {
            return _db.InventoryItem.ToList();
        }
    
        public InventoryItem? GetInventoryItem(int inventoryItemId)
        {
            return _db.InventoryItem
                .FirstOrDefault(x => x.Id == inventoryItemId);
        }
    
        public List<StockInOut> GetStockInOuts(string? productName = null, string? barcode = null,
            StockTransactionType? type = null)
        {
            var query = _db.StockInOut
                .Include(x=>x.InventoryItem)
                .ThenInclude(x=>x.Product)
                .AsQueryable();
    
            if (!string.IsNullOrWhiteSpace(productName))
            {
                query = query.Where(s => s.InventoryItem.Product.Name == productName);
            }
    
            if (!string.IsNullOrWhiteSpace(barcode))
            {
                query = query.Where(s => s.InventoryItem.Barcode == barcode);
            }
    
            if (type.HasValue)
            {
                query = query.Where(s => s.TransactionType == type.Value);
            }
    
            return query.ToList();
        }
    
        public void InsertStockInOut(StockInOut stockData)
        {
            _db.StockInOut.Add(stockData);
            _db.SaveChanges();
        }
    
        public void UpdateInventory(InventoryItem ivt, int stockDataAfterQuantity)
        {
            var data = _db.InventoryItem.Attach(ivt);
            ivt.Quantity = stockDataAfterQuantity;
            _db.Entry(ivt).Property(x => x.Quantity).IsModified = true;
            _db.SaveChanges();
        }
    }

     

    MVVM에서 Model 레이어는 View와 ViewModel처럼 사용자가 보여지지 않는 영역 전체를 말합니다.

    저장소 패턴에서 흔히 사용되는 Repository 역할의 클래스도, 비즈니스 처리만을 목적으로한 Service도 MVVM 기준에서는 모두 모델레이어 입니다.

     

    Repository는 직접 데이터 베이스를 사용하는 형식으로 되어있지만, 단순히 블레이저를 프론트엔드로 취급하고 Db부분 대신 API콜을 한다고해도 이부분의 역할이 달라지진 않습니다.

     

    Service레이어에서는 레포지토리를 호출해서 데이터를 저장하거나, 가져오거나 하는 일들을 중재하고 있습니다. 

     

    이번 포스트에 대해서 정리 해보자면 

    각 Model, ViewModel, View 레이어에 해당하는 클래스의 역할에 맞게 구성하는 것이 중요합니다. 

    의존성에 대해서는 최소 범위의 의존성을 유지하는 것은 중요하지만, 트레이드 오프에 따라서 이부분을 결정하는 것이 좋습니다.

    Blazor에서는 INotifyPropertyChanged와 계약하는 방식으로 ViewModel을 구현하지 않더라도, 사용자가 변경한 사항에 대해서는 데이터의 변경이 반영됩니다. 

     

    이 외에도 블레이저상에서 View의 생명주기에 대한 내용을 생각하는 것이 좋은데, 해당 내용은 제가 dotnet conf 2024 seoul 에서 발표한 내용을 참고하여 주시기 바랍니다. (2) [.NET Conf 2024 x Seoul] Blazor with MVVM - YouTube

    감사합니다.

     

     

     

     

Designed by Tistory.