코드 간소화

코드 간소화

C#의 함수형 언어 기능을 이용해 코드의 가독성과 성능, 유지 보수성을 높이는 방법에 대해 알아 봅니다.

OOP 스타일

시스템 유스케이스

public interface IRequest<R>;
public record QueryStudentsByNameStarts(string Name) 
   : IRequest<StudentDto[]>;

프로세스 계약

느슨한 결합을 위해 피상적으로 정의합니다.

public interface IRequestHandler<T, R> where T : IRequest<R>
{
   Task<R> HandleAsync(T rq, CancellationToken token);
}

이행 프로세스

class QueryStudentsByNameStartsHandler(AppDbContext db) 
   : IRequestHandler<QueryStudentsByNameStarts, StudentDto[]>
{
   public Task<StudentDto[]> HandleAsync(
      QueryStudentsByNameStarts rq, 
      CancellationToken token)
      => db.Students.AsNoTracking()
         .Where(x => x.Name.StartsWith(rq.Name)).
         .Select(x => new StudentDto(x.Id, x.Name)).
         .ToArrayAsync(token);      
}

소비 코드

- 호출

class StudentView(
   IRequestHandler<QueryStudentsByNameStarts, StudentDto[]> handler)
{ 
   // ...
   async Task OnSearchStudentByNameClicked(string name)
   {
      var rq = new QueryStudentsByNameStarts(name);
      var dtos = await handler.HandleAsync(rq, _cts.Token);
      // ...
   }
}

- 의존성 주입

app.Services.AddScoped<
   IRequestHandler<QueryStudentsByNameStarts, StudentDto[]>, 
   QueryStudentsByNameStartsHandler>();

함수형 스타일

OOP 코드와 기능/패턴이 동일한 함수형 스타일 코드입니다.

시스템 유스케이스

public abstract record Request<R>;
public record QueryStudentsByNameStarts(string Name) 
   : Request<StudentDto[]>;

프로세스 계약

인터페이스가 아닌 대리자로 정의합니다.

public delegate Task<R> RequestHandler<T, R>(T rq, CancellationToken token)
    where T : Request<R>

이행 프로세스

아직은 함수(메서드)가 온전한 일급 멤버가 아니라서 class 내부에 있어야 하는 아쉬움이 있지만,

static class StudentsHandlers
{ 
   public static RequstHandler<QueryStudentsByNameStarts,StudentDto[]>
      QueryStudentsByNameStartsHandler(AppDbContext db) =>
   (rq, ct ) => db.Students.AsNoTracking()
         .Where(x => x.Name.StartsWith(rq.Name)).
         .Select(x => new StudentDto(x.Id, x.Name)).
         .ToArrayAsync(token);      
}

정적이라 속도가 빠르다는 장점이 있습니다.

소비 코드

- 호출

class StudentView(RequestHandler<QueryStudentsByNameStarts, StudentDto[]> handler)
{
   // ...
   async Task OnSearchStudentByNameClicked(string name)
   {
      var rq = new QueryStudentsByNameStarts(name);
      var dtos = await handler(rq, _cts.Token);
      // ...
   }
}

- 의존성 주입

함수도 의존성 주입에 아무런 문제가 없습니다.

app.Services.AddScoped(sp => 
{
   var db = sp.GerRequiredService<AppDbContext>();
   return StudentsHandlers.QueryStudentsByNameStartsHandler(db);
});

비교

보시다시피, FP 스타일이 좀 더 간결한데, 보일러 코드가 줄어든 것이 표면적 원인입니다.

// OOP
public interface IRequestHandler<T, R> where T : IRequest<R>
{
   Task<R> HandleAsync(T rq, CancellationToken token);
}
// FP
public delegate Task<R> RequestHandler<T, R>(T rq, CancellationToken token)
    where T : IRequest<R>

이행 프로세스는 별다른 변화가 없는 것 같지만,

// OOP
namespace StudentsHandler;

class QueryStudentsByNameStartsHandler(AppDbContext db) 
   : IRequestHandler<QueryStudentsByNameStarts, StudentDto[]>
{
   public Task<StudentDto[]> HandleAsync(QueryStudentsByNameStarts rq, 
      CancellationToken token) => db.Students.AsNoTracking()
         .Where(x => x.Name.StartsWith(rq.Name)).
         .Select(x => new StudentDto(x.Id, x.Name)).
         .ToArrayAsync(token);      
}  
// FP
static class StudentsHandlers
{ 
   public static RequstHandler<QueryStudentsByNameStarts,StudentDto[]>
      QueryStudentsByNameStartsHandler(AppDbContext db) =>
   (rq, ct ) => db.Students.AsNoTracking()
         .Where(x => x.Name.StartsWith(rq.Name)).
         .Select(x => new StudentDto(x.Id, x.Name)).
         .ToArrayAsync(token);    
}

프로세스가 늘어날 수록, OOP 스타일에서는 class 가 늘어나는데 반해 FP 스타일에서는 정적 클래스의 속성만 추가되기에 코드 량의 차이가 점점 더 커집니다.

또한, OOP 스타일에서는 책임의 범위를 네임스페이스로 표현하는 반면, FP에서는 클래스 이름으로 표현하는데, 이 때문에 네임스페이스 단계도 간소화 됩니다.

추가 간소화

FP 스타일에서는 추가적인 간소화가 가능합니다.

우선, 제너릭의 사용은 Reflection 을 통한 자동화를 염두해 두지 않는 한 가독성에서 불리한데, 개별 프로세스 마다 식별자를 부여하여 이를 개선할 수 있습니다.

프로세스 식별자 부여

FP 에서는, 강력한 형식의 함수(대리자)로 정의하여 이를 달성할 수 있습니다.

// public delegate Task<R> RequestHandler<T, R>(T rq, CancellationToken token)
//    where T : IRequest<R>

public delegate Task<StudentDto[]> QueryStudentsByNameStartsHandler(
   QueryStudentsByNameStarts rq, CancellationToken token);

static class StudentsHandlers
{ 
   // public static RequstHandler<QueryStudentsByNameStarts,StudentDto[]>
   public static QueryStudentsByNameStartsHandler
      QueryStudentsByNameStartsHandler(AppDbContext db) =>
   (rq, ct ) => db.Students.AsNoTracking()
         .Where(x => x.Name.StartsWith(rq.Name)).
         .Select(x => new StudentDto(x.Id, x.Name)).
         .ToArrayAsync(token);      
}

// 의존성 주입 코드는 변화 없음.
app.Services.AddScoped(sp => 
{
   var db = sp.GerRequiredService<AppDbContext>();
   return StudentsHandlers.QueryStudentsByNameStartsHandler(db);
});

C#에서는, QueryStudentsByNameStartsHandler 처럼 강력한 형식의(Strong typed) 대리자 객체를 동일 시그니쳐를 갖는 FuncAction 변수에 직접 할당이 불가능합니다. 그 반대는 가능합니다.

물론 OOP 도 비슷하게 비-제너릭 스타일로 변경할 수 있지만, 여전히 보일러 코드가 붙어 간소화 효과가 크지 않습니다.

// OOP
public interface IQueryStudentsByNameHandler {
   Task<StudentDto[]> HandleAsync(QueryStudentsByNameStarts rq,
      CancellationToken token);
}
public interface IQueryStudentsByAgeHandler { // ...
public interface IQueryStudentsAllHandler { // ...

또한 하나의 클래스가 여러 인터페이스를 구현하는 방법도 있지만,

// OOP
class QueryStudentsHandler(AppDbContext db) 
   : IQueryStudentsByNameHandler ,
    IQueryStudentsByAgeHandler, 
    // ... , 
    // ... ,
    // ... ,
{

이는 단일 책임의 원칙에 반하는 안티 패턴입니다.

이 안티 패턴의 가장 큰 단점은 유지 보수 제한입니다.

예를 들어, 과거 핸들러의 특정 메서드만 수정이 필요한데, sealed 로 설정되어 있으면 전체를 다시 써야 합니다. sealed 한정자가 없더라도, virtual 이 없으면 멤버 하이딩을 해야 하는데 이 경우, 일반적인 오버라이딩 원칙과 달라져 코드의 예측 가능성을 떨어 뜨립니다.

여담으로 간혹, 유지 보수 코드 꼴이 우스워질 수도 있습니다. (^^)

class QueryStudentsHandler2(AppDbContext db) 
   : // 나중에 새로 요구된 유스케이스 핸들러 추가...

class QueryStudentsHandler3(AppDbContext db) 
   : // 또 나중에 새로 요구된 유스케이스 핸들러 또 추가...

- 호출

프로세스를 강력한 형식으로 정의했기에 호출부도 간소화되어서 가독성이 개선됩니다.

class StudentView(
// RequestHandler<QueryStudentsByNameStarts, StudentDto[]> handler)
   QueryStudentsByNameStartsHandler handler)
{
   // ...
      var rq = new QueryStudentsByNameStarts(name);
      var dtos = await handler(rq, _cts.Token);
   // ...

- 의존성 주입 간소화

핸들러들이 공통적으로 특정 외부 소스, 예를 들면, DB 에 의존하는 구조라면 설정 코드는 동일한 패턴이 반복되는 것이 일반적입니다.

using static StudentsHandlers;
app.Services.AddScoped(sp => 
{
   var db = sp.GerRequiredService<AppDbContext>(); // 반복 코드
   return QueryStudentsByNameStartsHandler(db); // 반복 패턴
});

이런 반복의 제거에는 고차 함수(High order function) 만큼 좋은 것도 없습니다.

using static StudentsHandlers;

app.Services
   .AddScoped(Bind(QueryStudentsByNameStartsHandler))
   .AddScoped(Bind(QueryStudentsByAgeHandler))
   // ...

// 고차 함수
static Func<IServiceProvider, T> Bind<T>(Func<AppDbContext, T> factory) =>
   sp => factory(sp.GetRequiredService<AppDbContext>());

여기에 확장 메서드 하나를 추가하면, 더 간략하지만 가독성은 더 좋아지게 됩니다.

app.Services
    // 핸들러를 Scoped 로 등록하라
   .AddScoped(QueryStudentsByNameStartsHandler)
   .AddScoped(QueryStudentsByAgeHandler)
   // ...

static IServiceColletion AddScoped<T>(
   this IServiceCollection services, 
   Func<AppDbContext, T> handler factory) 
   where T : class => services.AddScoped(Bind(factory));

// ...

이런 코드들이 가능한 이유는 닷넷의 많은 도구들은 함수형 스타일을 지원하도록 설계되었기 때문입니다.

IServiceCollection.AddScoped<T>(Func<IServiceProvider, T>);

OOP 에서는 의존성 주입 코드를 이런 식으로 간략하게 만드는 게 리플렉션을 쓰지 않고는 불가능합니다.

마치며

시스템 유스 케이스는 보통 작은 시스템도 몇 십 개, 큰 시스템은 몇 백 개가 됩니다.

간소화된 코드는 그 자체가 관리할 코드량이 적음을 의미하고, 가독성이 높아 시스템의 성장 정도에 상관 없이 유지 보수성을 일관되게 좋은 상태로 유지하는 장점이 있습니다.

바이브 코딩을 도입하더라도, 산출된 코드에 대한 책임은 여전히 개발자에게 있습니다.

그 생성의 속도와 양을 전부 따라 잡지는 못 하겠지만, 단순하고 간소한 코딩 패턴을 유지하도록 노력한다면 책임의 무게가 한결 가벼워 질 것입니다.

7개의 좋아요
class StudentView(
   IRequestHandler<QueryStudentsByNameStarts, StudentDto[]> handler)
{ 
   // ...
   async Task OnSearchStudentByNameClicked(string name)
   {
      var rq = new QueryStudentsByNameStarts(name);
      var dtos = await handler.HandleAsync(_cts.Token);
      // ...
   }
}

HandleAsync 시그니처에 IRequestHandler 가 빠진거같습니다 맞을까요?

3개의 좋아요

코드를 IDE에서 적지 않고, 여기 편집기에서 적다 보니 오타가 있었네요. ^^

호출부에 IRequest<R> 매개 변수를 넣는 것을 깜빡하고 복붙한 결과입니다.
관련 부분 수정했습니다.

4개의 좋아요