코드 간소화
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) 대리자 객체를 동일 시그니쳐를 갖는Func나Action변수에 직접 할당이 불가능합니다. 그 반대는 가능합니다.
물론 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 에서는 의존성 주입 코드를 이런 식으로 간략하게 만드는 게 리플렉션을 쓰지 않고는 불가능합니다.
마치며
시스템 유스 케이스는 보통 작은 시스템도 몇 십 개, 큰 시스템은 몇 백 개가 됩니다.
간소화된 코드는 그 자체가 관리할 코드량이 적음을 의미하고, 가독성이 높아 시스템의 성장 정도에 상관 없이 유지 보수성을 일관되게 좋은 상태로 유지하는 장점이 있습니다.
바이브 코딩을 도입하더라도, 산출된 코드에 대한 책임은 여전히 개발자에게 있습니다.
그 생성의 속도와 양을 전부 따라 잡지는 못 하겠지만, 단순하고 간소한 코딩 패턴을 유지하도록 노력한다면 책임의 무게가 한결 가벼워 질 것입니다.