WPF 공부 중에 서비스 계층(레벨)에 대하여 질문 있습니다.

안녕하십니까 WPF MVVM 패턴을 독학으로 뿌셔보고 있는 직장인입니다.
몇번의 질문 글을 올리고 조언을 들으며 열심히 공부중입니다.
타이머, 스레드 쪽으로 질의를 드렸었는데 초급자의 질문 드리고자 합니다.

타이머나 스레드를 가져야 하는 건 그 Model의 값을 변경시킬 책임이 있는 곳이라고 조언을 들었습니다 그 위치는 서비스 계층 또는 레벨이 적절하지 않을까 하시어 검색창을 열심히 돌렸으나 서비스에 대한 계층이나 구분에 대한 약간의 언급 또는 내용이 없는 편이더군요.
폴더만 댕그덩 만들어 두는게 아니라 뭔가 기준을 가지고 분류를 하고 계층을 정리해야 할 것 같은데… 예시가 없어 이해하는게 어렵네요.
서비스 계층이 무엇인지 조언 부탁드리겠습니다.

1개의 좋아요

서비스 계층 혹은 서비스 레이어는 디자인 패턴 마다 조금 씩 의미가 다릅니다.

그런데, 일반적으로 "서비스"란 어플리케이션 외부의 자원 접근자라는 의미로 사용됩니다. 예를 들어,

interface IStudentService
{
   Task<List<Student>> Get(int schoolId);
}

StudentSchool 는 우리 앱이 해결하는 문제의 영역(domain)의 세부 문제를 해결하는데 필요한 (도메인) 객체들입니다.

class Student { }
class School { }

이들은 우리 앱을 지탱하는 핵심 객체이기는 하지만, 소프트웨어 요구사항에 따라, 우리 앱의 메인 로직에는 아래와 같은 코드가 있을 수도 없을 수도 있습니다.

var student = new Student();

없는 경우의 예를 들자면, 레거시 웹 API 에서 Read 하여 UI에 보여주기만 하도록 요구받은 경우입니다.

class StudentServiceFromSchoolSystemApi(HttpClient proxy, SchoolSystemApiInfo info) 
   : IStudentService
{
   public async Task<List<Student>> Get(int schoolId)
   {
      var path = info.GetStudentsBySchoolId(schoolId);
      var response = await proxy.GetAsync(path);
      var students = await response.Content.ReadAsJsonAsync<List<Student>>();
      return students ?? [];
   }
}

우리는 StudentSchool 을 외부 시스템(웹 API)이 보내주는 데이터와 그 데이터에 관한 로직을 보유하도록 추상화했을 것이지만, 생성에 관해서는 우리 앱이 할 수 없고, 오로지 서비스에만 의존해야 합니다. (물론 개발 중 테스트를 위한 객체들을 생성할 수는 있지만, 운용 단계에서는 실행되지 않는 코드입니다.)

서비스는 앱이 필요로 하지만 내부에서 해결할 수 없는 뭔가를 해결해주는 역할을 합니다.

일상의 예를 들자면, 컴퓨터는 우리가 사용하지만, 생산과 수리는 외부 서비스에 의존하는 것과 같다고 할 수 있습니다.

실무적으로는, 외부 웹 API 나 DB, 혹은 OS가 제공하는 I/O 에 접근하는 것이 가장 흔한 예입니다.

서비스라고 반드시 우리가 설계해야 하는 것은 아닙니다.
DateTime.Now, Console, PeriodicTimer 등은 닷넷이 잘 정제해서 제공하는 서비스라고 할 수 있습니다.

우리 앱은 필요한 때에 이 서비스를 호출하는데, 호출을 누가 하느냐는 선택의 문제입니다.

IStudentSevice를 UI 앱에서 사용한다면, 뷰의 생명 주기 메서드 중 하나에서 서비스를 호출해서, Student 객체를 필요로 하는 객체에 공급해주거나,

class StudentsWindowViewModel(List<Student> students)
{
   readonly List<Students> _students = students;
}

Student 객체를 필요로 하는 당사자가 직접 서비스를 호출하도록 할 수도 있습니다.

class StudentsWindowViewModel(IStudentService service)
{
   readonly IStudentService _service = service;
   public IOnservableCollection<Student> Students { get; } = [];
   public async Task SetStudents(int schoolId) 
   {
      Students.Clear(); 
      Students.AddRange(await _service.Get(schoolId)); 
   }
}

혹은

class StudentsWindowViewModel
{
   public IOnservableCollection<Student> Students { get; private set; } = [];
   
   // 팩토리 메서드 
   public async Task<StudentsWindowViewModel> New(IStudentService service, int schoolId) 
   {
      var students = await service.Get(schoolId);
      var viewModel = new StudentsWindowViewModel();
      viewModel.Students.AddRange(students);
      return viewModel;
   }
}

참고로, 부모 객체가 서비스를 호출해서, 자식 객체에게 전달하는 방식을 집중식 데이터 관리 기법이라고 하고, 개별 요소가 직접 서비스를 호출하는 방식을 분산식 데이터 관리 기법이라고 합니다.

전형적인 CRUD 로직이 들어가는 앱인 경우, 경험 상 집중식 데이터 관리가 좀 더 효율적이어서, 유지 보수성도 좋은 것 같습니다.

서비스는 자원에 대한 접근 뿐만 아니라, 앱 내부에서 해결할 수 없는 문제를 해결해주는 경우도 있습니다.

예를 들어, 우리 앱의 책임 중 하나가 Student.Name 이 올바른 이름인 지 검증하는 것인 경우, 이는 핵심 도메인 문제가 됩니다.

class Student
{
   public string Name { get; private set; }

   public bool HasValidName()
   {
      // 사람의 이름으로 허용되지 않는 값들
      // ㅗㅇ길동, aasdf길, ㅎㄱㄷ, ....
      // Jamex , Johhhn 
   }
}

그러나, 이 문제를 자력으로 해결(HasValidName)하는 게 가능하지 않거나, 가능하다 하더라도 본 프로젝트 보다 훨씬 방대한 별도의 프로젝트로 성장할 위험이 있습니다.

이러한 경우, 저 문제를 도메인 문제로 보지않고, 외부 서비스로 간주할 수 있습니다.

interface IHumanNameValidator 
{
   bool isValid(string name);
}
class LocalAIValidator(IAIService ai) : IHumanNameValidator
{
   const string Message = 
      "이 것이 사람 이름으로 적당하다면, Yes로 아니라면 No 로만 답해줘";
   public bool isValid(string name) 
   {
      var response = ai.Send(context: name, message: Message);
      return response.Contains("Yes");
   }
}

서비스를 제공하는 서버는 우리가 직접 작성할 수도 있고, 작성하지 못할 수도 있습니다.
전자의 예라면, 우리 시스템을 3 tier 구조(프론트-백엔드-데이터베이스)로 구성하고 백엔드를 직접 작성하는 경우입니다.
후자의 예라면, 공공기관 API, 닷넷 제공 시스템 객체(Console, Stream, …) 등입니다.

살펴 본 것처럼 서비스는 도메인 모델에 대비되는 개념입니다.
도메인 모델의 추상화 과정에서 모델의 책임을 분명하게 만들지 못했다면, 어떤 게 서비스의 책임인지 불분명해지고, 이 상태에서 서비스가 들어가는 패턴을 도입하는 것은 시기 상조라고 할 수 있습니다.

사실 어떤 패턴을 도입하든 충분한 추상화 과정이 선결 조건이라고 할 수 있습니다.
모델을 로직 없는 POCO 로만 작성하던지, 모델이 가지고 있는 Data clumps 을 묵과한다던지, 기본 자료형에 집착한다던 지 하는 것들이 미숙한 추상화 스멜이라고 할 수 있습니다.

4개의 좋아요

제가 고민하던 내용과 비슷한 것 같아서 예전에 질문했던 글 공유드립니다!!

1개의 좋아요

알듯하면서 뜬 구름 잡는 느낌도 있고 알쏭달쏭하네요 ㅎㅎ
조금 더 공부를 해봐야겠군요 감사합니다

2개의 좋아요

저도 이 말씀에 동의합니다. 제가 이해하는 바로서 한마디 첨언드리면,

막 개발을 한다면 의미가 적은 내용일 수 있는데, 조금 더 구조적으로 단단한 형태를 갖는다면 서비스형태로 개발하는 것은 좋은 방향이라고 생각합니다.

클래스를 정의할 때는 어떤 목적을 달성하기 위해 런타임에서 객체가 있었으면 좋겠다 라는 생각으로 클래스를 정의합니다. 클래스가 먼저가 아니라 객체가 먼저라는 것이죠. 그리고 그 객체들은 저마다의 책임을 가집니다.

뷰모델이라는 책임이 될 수도 있고, 도메인의 데이터 흐름을 표현하기 위한 일련의 과정을 나타낼 수도 있고, 단순하게 뭘 변환하거나, 확장메서드로서 쓰일수도 있고… 객체의 활용법은 문법으로 다양하게 정의할 수 있습니다.

객체가 어떤 문제를 달성하기 위한 런타임의 어떤 것이라면 첫번째로는 문제 해결을 위한 큰 뼈대로서 어떤일을 할지 정하게 됩니다. 바로 비즈니스 로직부터 개발하는 것이죠. 그렇게 해서 내가 원하는 문제를 개발합니다. cs 파일 하나에 다 만들어도 좋습니다.

그런데 이제 미래의 내가 또는 나와 지식수준이 다른 동료가 개발에 함께 참여하게 될 경우 일을 분업으로 하기 시작해야 합니다. 그래서 규칙이라는 걸 정의를 하기도 하는데 규칙을 매번 따로 정의를 세세하게 할 수 없으니 우리는 프로그래밍을 할 때 배우는 일반적인 지식을 배우려고 합니다. 그래야 세세하게 정의할 시간이 줄어들기 때문이고 편하기 때문이죠. 그리고 그건 Pattern 이라고 불리는, 세계의 여러 개발자들이 개발하다보니 공통적으로 어떤 특정 행동을 띄더라… 라고 붙어서 그걸 이름으로 박아놓은 것들입니다.

우리는 그걸 배움으로서 다른 도메인의 개발자들과 공통된 언어로 소통할 수 있게되는 마치 유비쿼터스 랭귀지처럼 사용할 수 있습니다.

그래서 이런 일반적인 지식을 가지고 원래는 한페이지에 작성했던 코드를 쪼개기 시작합니다. 추상화라고 하기도하고, 쓸데없이 파일 개수를 늘리는 작업이라고 하기도 하고. 여러 형태로 불릴겁니다.

//program.cs

int a = 1;
int b = 2;

Console.WriteLine(a + b);

한 페이지에 코드를 작성했다면 이것은 절차지향 프로그래밍이라고 불러도 손색이 없을 것입니다.

// Program.cs
// 2025.06.12 Vincent 개발
// a와 b를 더하는 기능의 프로그램. 다음 사람을 위하여 주석으로 의도를 기록함.
// 이 코드는 고객에게 전달해야하는 세금계산서를 계산하는 코드임.

int a = default;
int b = default;

Console.WriteLine(a + b);

그리고 주석으로 코드가 설명되어 있을 것입니다.

이것을 하나의 책임이 있는 객체로 만들고 싶어서 클래스를 정의하게 되고 주석을 없애고(남겨둬도 문제없음) 이름으로 이 타입을 명확하게 명시합니다.

// Accounting세금계산서.cs

public sealed partial class Accounting세금계산서
{
    public int 계산element1 { get; set; }
    public int 계산element2 { get; set; }

    public int Account(int 요소1, int 요소2)
    {
         계산element1 = 요소1;
         계산element2 = 요소2;

         return 계산element1 + 계산element2;
    }
}

// Program.cs
Accounting세금계산서 a = new();
int result = a.Account(1, 2);

Console.WriteLine($"너 님의 세금: {result}원 입니다.");

이제부터 이 일련의 코드의 흐름을 복붙었이 이 객체만 가져다쓰면 재사용이 가능한 것이고 같은 행위를 하는 객체끼리 코드 복붙없이 이 객체만 가져다 쓰면 됩니다.

// Accounting세금계산서.cs

public sealed partial class Accounting세금계산서
{
    public int 계산element1 { get; set; }
    public int 계산element2 { get; set; }

    public int Account(int 요소1, int 요소2)
    {
         계산element1 = 요소1;
         계산element2 = 요소2;

         return 계산element1 + 계산element2;
    }
}

// Program.cs
Accounting세금계산서 월급에서발생한세금 = new();
int vincent의세금1 = 월급에서발생한세금.Account(1, 2);

Accounting세금계산서 사업에서발생한세금 = new();
int vincent의세금2 = 사업에서발생한세금.Account(3,4);

Accounting세금계산서 부업에서발생한세금 = new();
int vincent의세금3 = 부업에서발생한세금.Account(5, 6);

Console.WriteLine($"vincent님의 막대한 세금폭탄: {vincent의세금1 + vincent의세금2 + vincent의세금3}");

코드는 길어졌지만 유연해지고 더 강해졌습니다.
세금을 계산하는 행위라고 semantic을 담아서 한글로 명명했습니다. 가독성이 좋지않아도 이해부탁드립니다.

그런데, 세금을 계산하려다보니까 반드시 덧셈연산이 필요한 것을 발견했습니다. 덧셈은 비즈니스로직이라고 부르기엔, 초등학생도 아는 내용이기 때문에 이것을 한번 더 추상화 시킬 수 있을 것 같은거죠. 여기서 서비스가 등장합니다.

// SumService.cs
// 얘는 비즈니스 로직에 대한 책임은 없고,
// 더하기에 대한 책임만 있음.
// 왜냐하면 이름이 Sum이니까
// 게다가 비즈니스 로직과 무관해졌으므로 함수 파라미터 이름도 item1 같은 일반적이 이름으로 정규화 가능.
public sealed partial class SumService
{
    public int Sum(int item1, int item2) => item1 + item2;
}

// Accounting세금계산서.cs
// 얘는 세금계산에 대한 책임이 있음.
// 따라서 맥락상...'코드 변경에 대한 책임(Single Responsibility Principle)'도 세금계산에 관한거만 있어야 맞을 것 같음.
public sealed partial class Accounting세금계산서
{
    public int 계산element1 { get; set; }
    public int 계산element2 { get; set; }

    public int Account(int 요소1, int 요소2)
    {
         계산element1 = 요소1;
         계산element2 = 요소2;

         SumService sumserv = new();
         var result = sumserv.Sum(
             item1: 계산element1,
             item2: 계산element2);

         return result;
    }
}

// Program.cs
Accounting세금계산서 월급에서발생한세금 = new();
int vincent의세금1 = 월급에서발생한세금.Account(1, 2);

Accounting세금계산서 사업에서발생한세금 = new();
int vincent의세금2 = 사업에서발생한세금.Account(3,4);

Accounting세금계산서 부업에서발생한세금 = new();
int vincent의세금3 = 부업에서발생한세금.Account(5, 6);

Console.WriteLine($"vincent님의 '막대한' 세금폭탄: {vincent의세금1 + vincent의세금2 + vincent의세금3}");

이렇게 했더니 Program.cs는 건들지도 않고 코드를 가용성이 좋게 바꿨습니다.
추가로, SRP는 기능에 대한 책임이 아니라 코드 변경에 대한 책임이라는 것을 혼선해서는 안 됩니다.

그런데 보니까… 갑자기 그럴리는 없겠지만 법이 바뀌어가지고 세금을 계산해야하는데 곱셈이 필요해졌습니다. 그런데 보니까 곱셈을 Sum처럼 Service로 만들면 좋겠다는 생각이 듭니다. 왜냐하면 역시, 곱셈은 비즈니스 로직이라고 보기엔 초등학생도 아는 내용이거든요.

// SumService.cs
// 얘는 비즈니스 로직에 대한 책임은 없고,
// 더하기에 대한 책임만 있음.
// 왜냐하면 이름이 Sum이니까
// 게다가 비즈니스 로직과 무관해졌으므로 함수 파라미터 이름도 item1 같은 일반적이 이름으로 정규화 가능.
public sealed partial class SumService
{
    public int Sum(int item1, int item2) => item1 + item2;
}

// MultiplingService.cs
// 얘는 비즈니스 로직에 대한 책임은 없고,
// 곱하기에 대한 책임만 있음.
// 왜냐하면 이름이 Multipling 이니까
// 게다가 비즈니스 로직과 무관해졌으므로 함수 파라미터 이름도 item1 같은 일반적이 이름으로 정규화 가능.
public sealed partial class MultiplingService
{
    public int Mutiple(int item1, item2) => item1 * item2;
}

// Accounting세금계산서.cs
// 얘는 세금계산에 대한 책임이 있음.
// 따라서 맥락상...'코드 변경에 대한 책임(Single Responsibility Principle)'도 세금계산에 관한거만 있어야 맞을 것 같음.
public sealed partial class Accounting세금계산서
{
    public int 계산element1 { get; set; }
    public int 계산element2 { get; set; }

    public int Account(int 요소1, int 요소2)
    {
         계산element1 = 요소1;
         계산element2 = 요소2;

         SumService sumserv = new();
         var account1 = sumserv.Sum(
             item1: 계산element1,
             item2: 계산element2);
         MultiplingService multiserv = new();
         var account2 = multiserv.Mutiple(
             item1: 계산element1,
             item2: 계산element2)
         var result = account1 + account2;

         return result;
    }
}

// Program.cs
Accounting세금계산서 월급에서발생한세금 = new();
int vincent의세금1 = 월급에서발생한세금.Account(1, 2);

Accounting세금계산서 사업에서발생한세금 = new();
int vincent의세금2 = 사업에서발생한세금.Account(3,4);

Accounting세금계산서 부업에서발생한세금 = new();
int vincent의세금3 = 부업에서발생한세금.Account(5, 6);

Console.WriteLine($"vincent님의 '더욱 더 막대한' 세금폭탄: {vincent의세금1 + vincent의세금2 + vincent의세금3}");

이번에도 주요 비즈니스 로직의 흐름인 Program.cs 를 손대지 않고 곱셈 기능을 하는 객체를 추가해서 휴먼에러를 줄일 수 있었습니다.

또 그런데 보니까 저 SumService, MultiplingService가 단순 연산이라서 매번 객체를 new로 생성할 필요는 없을 것 같고 private field도 없네요. 그러니까 그냥 막 재사용해도 전혀 문제가 없는 클래스입니다. 그래서 new를 좀 안하고 싶어졌습니다.
그리고 이 비즈니스 모델이 어떤 기본적인 사칙연산 서비스에 의존하고 있는지 또는 제공받고 있는지 명시적으로 한눈에 보고 싶어졌습니다.

이제 IoC Container가 등장합니다. IoC Container는 엄밀히 말하면 OOP와는 무관한 개념이지만 프로그래밍에 대한 의존성의 방향을 정리해준다는 맥락에서 유관하다고 할 수 있겠습니다.

// SumService.cs
// 얘는 비즈니스 로직에 대한 책임은 없고,
// 더하기에 대한 책임만 있음.
// 왜냐하면 이름이 Sum이니까
// 게다가 비즈니스 로직과 무관해졌으므로 함수 파라미터 이름도 item1 같은 일반적이 이름으로 정규화 가능.
public sealed partial class SumService
{
    public int Sum(int item1, int item2) => item1 + item2;
}

// MultiplingService.cs
// 얘는 비즈니스 로직에 대한 책임은 없고,
// 곱하기에 대한 책임만 있음.
// 왜냐하면 이름이 Multipling 이니까
// 게다가 비즈니스 로직과 무관해졌으므로 함수 파라미터 이름도 item1 같은 일반적이 이름으로 정규화 가능.
public sealed partial class MultiplingService
{
    public int Mutiple(int item1, item2) => item1 * item2;
}

// Accounting세금계산서.cs
// 얘는 세금계산에 대한 책임이 있음.
// 따라서 맥락상...'코드 변경에 대한 책임(Single Responsibility Principle)'도 세금계산에 관한거만 있어야 맞을 것 같음.
// 기본 생성자에 생성자 주입으로 SumSerivce 및 MultiplingService에 의존하고 있음을 명시. 이렇게하면 Visual Studio Code Lens에서 메서드와 클래스의 참조 수도 최소화할 수 있음. <-- 뇌절 방지 차원에서 중요
public sealed partial class Accounting세금계산서(
    SumService sumserv,
    MultiplingService multiserv)
{
    public int 계산element1 { get; set; }
    public int 계산element2 { get; set; }

    public int Account(int 요소1, int 요소2)
    {
         계산element1 = 요소1;
         계산element2 = 요소2;

         var account1 = sumserv.Sum(
             item1: 계산element1,
             item2: 계산element2);

         var account2 = multiserv.Mutiple(
             item1: 계산element1,
             item2: 계산element2)

         var result = account1 + account2;

         return result;
    }
}

// Program.cs
using Microsoft.Extensions.DependencyInjection

ServiceCollection serviceCollection = new();
serviceCollection.AddTransient<Accounting세금계산서>();
serviceCollection.AddTransient<SumService>();
serviceCollection.AddTransient<MultiplingService>();
IServiceProvider servProv = serviceCollection.BuildServiceProvider();

var 월급에서발생한세금 = servProv.GetService<Accounting세금계산서>();
int vincent의세금1 = 월급에서발생한세금.Account(1, 2);

var 사업에서발생한세금 = servProv.GetService<Accounting세금계산서>();
int vincent의세금2 = 사업에서발생한세금.Account(3,4);

var 부업에서발생한세금 = servProv.GetService<Accounting세금계산서>();
int vincent의세금3 = 부업에서발생한세금.Account(5, 6);

Console.WriteLine($"vincent님의 '더욱 더 막대한' 세금폭탄: {vincent의세금1 + vincent의세금2 + vincent의세금3}");

그런데 세법이 또 개정되어서 갑자기 나눗셈이 필요하게 되었습니다…


나눗셈은 위에서 한 것처럼 service로 만들어서 IoC Container에 추가해준 뒤, 세금 계산의 책임을 담당하는 ‘Accounting세금계산서’ 에서 의존성을 걸고 사용해주면 역시 Program.cs를 손대지않고 기능을 추가할 수 있습니다.

이로써 Program.cs는 거시적으로 볼때에 비즈니스의 흐름을 한눈에 파악할 수 있게 되었고, 세부적인 디테일한 비즈니스 로직은 ‘Accounting세금계산서’ 라는 Model 이 담당하게 되었으며 더욱 더 Atomic한 Helper 수준의 단순하고, 명료한 기능들은 Service로 나뉘게 되었습니다.

이로서 뭔가 기능에 버그가 발생하면 하나만 수정해도 여러 객체들에 코드 변경없이 영향력을 줄 수 있게 되었습니다. 바로 OOP가 지향하는 점이죠.

작은 개체로 쪼개서 만드는 것은 소프트웨어를 구조적으로 단단하게 만드는 행위이며 Service는 잔근육처럼 더욱 더 단단하게 만들어주는 장치입니다.

긴 글 이만 줄입니다. 이해가 되셨으면 좋겠네요!

3개의 좋아요

코드 오류도 수정하는 겸해서 글을 다시 작성했습니다.
이해하는데 도움이 됐으면 합니다.

1개의 좋아요

조언 주셔서 감사합니다.
두고 두고 봐야겠습니다.

1개의 좋아요