경험이 필요할 만큼 엄청나고 파괴적인 실수.

Twitter / X에서 저를 팔로우 한다면 지옥에서 온 레거시 소프트웨어 프로젝트에 대한 저의 점점 더 짜증나는 트윗을 여러 개 보셨을 것입니다. 이 프로젝트는 블로그 게시물과 YouTube 비디오 시리즈를 별도로 마련할 만하지만, 소프트웨어 분야에서 더 높은 소명을 위해 지금 공개하겠습니다. 이러한 코딩 공포는 모두 Sdkbin을 다시 쓴 데서 비롯되었습니다 .

더 정확하게 말해서, 우리는 앞으로 나아갈 가장 좋은 방법은 Sdkbin 1.0을 구출 하고 결국 다시 작성하지 않는 것이라고 결정했지만, 우리는 여전히 건물의 기초까지 허물고 다시 시작하는 것과 같은 소프트웨어 작업을 진행하고 있습니다.

모든 단점에도 불구하고 Sdkbin은 매년 상당한 양의 수익을 창출합니다. 원래 저자가 완전히 이해할 수 없는 형태로 남겨두었기 때문에 4년 동안 생명 연장 장치에 연결해 두었습니다. 이 글에서는 4년 동안의 기회를 죽이고 이 프로젝트가 첫 번째 고객에게 서비스를 제공하기도 전에 기술적 부채에 싸이게 된 전염병에 대해 이야기하겠습니다. 프레임워크주의 입니다 .

맞춤형 프레임워크: "전문가 초보자"의 징후

2024년 11월 3일에 본격적으로 시작된 Sdkbin 발굴 중에 제가 발견한 것 중 작은 일부를 소개합니다.

AutoMapper에 대한 내장 종속성과 데이터 전송 객체가 있는 일반 저장소는 실수로부터 배우기에 충분히 오랫동안 단일 프로젝트에 머물러 본 적이 없는 선임 개발자가 운영하는 프로젝트의 증상입니다. " 전문가 enum초보자 ."

저는 " 우리는 Sdkbin을 다시 쓰고 있습니다 "에서 Sdkbin의 코드가 2020년에 처음 작성되었을 때 제가 무엇을 하고 있었는지에 대해 다루었습니다(즉, COVID19 봉쇄 조치로 인해 매출의 40-50%가 1회에 그쳐 회사가 망가지는 것을 막으려고 노력했습니다). 따라서 Sdkbin의 복잡한 배관 작업은 몇 년 동안 제 관심 밖에 있었고 저는 프로젝트의 "충분히 잘 작동하는" 상태를 참았습니다. 이 글을 읽는 일부 소프트웨어 개발자들이 놀랄 것이라는 건 알지만, 사람을 고용하고, 조직이 원활하게 운영될 수 있는 능력을 개선하고, 제가 맡지 말아야 할 역할에서 스스로를 해고하고, 고객을 찾는 것과 같은 일은 모두 우리의 포괄적인 사업의 작은 부분에 대한 기술 부채를 처리하는 것보다 훨씬 우선순위가 높습니다.

하지만 가까운 미래에 제품 포트폴리오를 확장하기 위해 Sdkbin의 상당한 기술 부채를 해결해야 한다는 사실이 작년에 분명해지자 저는 이 문제를 더 진지하게 조사하기 시작했습니다.

Sdkbin이 현재 어떻게 구성되어 있는지 알려드리기 위해 시스템에서 사용자 상호작용이 거의 모두 작동하는 방식을 보여드리겠습니다.

EntityMapperEFCoreRepositoriesRazorPageClientEntityMapperEFCoreRepositoriesRazorPageClientloop[Query for each repository]HttpRequest receivedStart Query LoopCreate Query ModelsBuild EF Core Query (progressively)Execute QueryReturn ResultsMap Entity to EntityDtoReturn EntityDto ResultsMap all EntityDto to ViewModelReturn ViewModel as HttpResponse

이 디자인으로 인해 발생하는 비효율성은 심각하고 영향이 큽니다. 기술적 비효율성(예: 데이터베이스 성능, 메모리 사용, HTTP 응답 시간)은 수가 많기 때문에 나중에 별도 게시물로 다루어야 할 것 같습니다.

이 게시물에서는 현재 문제에 집중해 보겠습니다. 개발자가 이 애플리케이션을 빌드하는 데 사용한 "프레임워크"로 인해 소프트웨어 개발자 시간 비용으로 인해 Sdkbin을 유지 관리하고 수정하고 개선하는 데 엄청난 비용이 들었습니다.

그렇다면 처음에 사드크빈의 배관은 왜 이런 식으로 만들어졌을까?

작년에 Sdkbin의 기술적 부채를 평가하면서 우리 개발자 프레임워크가 그가 즉흥적으로 생각해 낸 것이 아니라 다른 프로젝트에서 복사한 것이 아닐까 하는 의심이 들었습니다.

커밋 기록을 살펴보니 제 의심이 확실해졌습니다. 이 개발자는 완전히 다른 프로젝트에서 일반적인 EF Core 기반 저장소와 AutoMapper + DI 장치를 복사하여 붙여넣었습니다. 이는 목적에 맞게 빌드되지 않은 "프레임워크"였습니다. 요구 사항과 상관없이 기술 스택이 이렇게 보일 것이라는 것이 미리 정해져 있었습니다. 엔티티, 뷰 모델 및 수많은 데이터 전송 객체의 디자인은 모두 사용자 지정되었지만, 이것들이 처리되고 전달될 방식은 자동 결정이었습니다.

이게 제가 지난번에 Ruby on Rails 스타일의 패키지 글루 건이 .NET에 적용되는지 에 대한 글에서 언급한 것입니다 . 이것은 공식 기반 프로그래밍인데, 개발자가 단일 프로젝트에서 하는 법을 배웠지만 갑자기 거기서 멈췄기 때문입니다. 이것은 전문가 초보자의 진수입니다. 프로젝트를 시작할 만큼은 진행했지만 원래 빌드한 방법의 단점을 배우기에는 충분히 진행하지 못했기 때문에 같은 엉터리 공식을 계속 반복하게 됩니다.

선제적 프레임워크 생성은 조기 최적화입니다.

Sdkbin의 코드를 검토할 때 하루에 20번 이상 스스로에게 묻는 질문 - "이게 왜 필요할까?"


/// <summary>
/// Creates element in child collection of the aggregate
/// </summary>
/// <typeparam name="TModel">Model type</typeparam>
/// <typeparam name="TEntity">Entity type</typeparam>
/// <typeparam name="TRootEntity">Aggregate type, which owns the collection</typeparam>
/// <typeparam name="TRootNotFoundException">Type of the exception thrown when parent entity is not found</typeparam>
/// <typeparam name="TElementAlreadyExistsException">Type of the exception thrown when child element already exists in collection</typeparam>
/// <param name="rootSelector">Preducate to find parent entity, which owns the collection</param>
/// <param name="createModel">Model to add</param>
/// <param name="dbSetAccessor">DbSet getter from DbContext</param>
/// <param name="collectionAccessor">Child collection getter from parent entity</param>
/// <param name="rootNotFoundException">Exception thrown when parent entity is not found</param>
/// <param name="elementAlreadyExistsException">Exception thrown when child element already exists in collection</param>
/// <returns>Created model</returns>
protected async Task<TModel> CreateChildCollectionItem<TModel, TEntity, TRootEntity, TRootNotFoundException, TElementAlreadyExistsException>(
    Expression<Func<TRootEntity, bool>> rootSelector,
    TModel createModel,
    Func<TContext, IQueryable<TRootEntity>> dbSetAccessor,
    Expression<Func<TRootEntity, List<TEntity>>> collectionAccessor,
    TRootNotFoundException rootNotFoundException,
    TElementAlreadyExistsException elementAlreadyExistsException
)
    where TRootNotFoundException : EntityDoesNotExistException
    where TElementAlreadyExistsException : Exception
    where TRootEntity : class
{
    // Find parent entity and include child collection to it with Include
    var rootEntity = await dbSetAccessor(Context).Include(collectionAccessor).FirstOrDefaultAsync(rootSelector);
    if (rootEntity == null)
        throw rootNotFoundException; // Throw when parent is not found

    // Create new entity
    var newEntity = MapperObject.Map<TEntity>(createModel);
    var logEntity = MapperObject.Map<TEntity>(createModel);
    try
    {
        // Add entity to child collection
        collectionAccessor.Compile().Invoke(rootEntity).Add(newEntity);

        // Save DbContext changes 
        await Context.SaveChangesAsync();

        // Return created model
        return MapperObject.Map<TModel>(newEntity);
    }
    catch (Exception ex) when (ex is DbUpdateException || ex is InvalidOperationException)
    {
        var serializedEntity = SerializeWithoutErrors(logEntity);
        var serializedModel = SerializeWithoutErrors(createModel);
        Logger.LogWarning($"Error during CreateChildEntity operation for entity {serializedEntity} from model {serializedModel}");
        Logger.LogDebug($"Error during CreateChildEntity operation for entity {serializedEntity} from model {serializedModel}: {ex}");
        throw elementAlreadyExistsException;
    }
}




RepositoryBase<T>이것은 제가 이전에 트윗한 클래스 에 있는 메서드입니다 . 저희 코드 기반에서는 전혀 사용되지 않습니다. 그런데도 이 엄청나게 복잡한 메서드는 필요할 경우를 대비해 존재합니다. 저는 이것이 원래 개발자가 자신의 다른 프로젝트에서 무언가를 복사한 아티팩트라는 걸 알고 있으므로, 아마도 그 중 하나에서 필요했을 수도 있지만, 이 코드는 테스트, 위험, 비용 측면에서 저희 책에 부담이 됩니다 . 필요하지 않다면 애초에 왜 이걸 가지고 있어야 합니까?

답은 간단합니다.

  1. 애플리케이션을 구축하려면 현실과 협상하고 소프트웨어로 현실을 모델링하는 방법에 대한 어려운 결정을 내려야 합니다.
  2. 프레임워크를 구축하는 데는 거의 또는 전혀 헌신이 필요하지 않습니다. 순수한 추상화입니다. 코드에서 원시 비즈니스 요구를 이해하고 표현하는 요먼의 작업을 미루는 동안 생산적이라고 느낄 수 있습니다.

프레임워크를 작성하는 것은 기분 좋고, 생산적이며, 무엇보다도 위험이 없습니다 .

따라서 실제로 애플리케이션을 작성해야 하는 힘든 작업을 시작하기 전에, 어떤 유형의 확장 메서드가 필요한지 또는 애플리케이션에서 어떤 종류의 데이터 액세스 패턴을 사용 할지 고려하는 데 시간을 할애해 보는 건 어떨까요 ? 많은 풀 리퀘스트를 제출하고, 많은 녹색 체크 표시를 받고, 심지어 프레임워크가 어떻게 작동하는지 다루는 단위 테스트를 통해 좋은 테스트 커버리지 점수를 얻을 수도 있습니다! 제게는 생산성이 좋아 보입니다!

개발자가 프레임워크를 먼저 구축하는 두 번째 주요 이유는 모든 부분이 동일한 방식으로 작동하면 애플리케이션의 작동 방식을 "이해"하기가 더 쉽기 때문입니다 .

그러나 이 주장은 본질적으로 어리석고 스스로를 위조합니다. 만약 여러분의 도메인에 있는 두 가지가 현실에서 자연스럽게 동일하게 행동하지 않는다면 , 즉 지불 의도(일시적 활동)와 고객(영구적 엔터티)이라면, 왜 소프트웨어에서 표준 행동과 표현 방식을 강제하려고 할까요? 소프트웨어는 필요에 따라 다른 것들을 다르게 처리해야 하지 않을까요?

이 모든 것은 사전 예방적 설계이고, 소프트웨어에서 현실을 모델링하는 방법을 완전히 이해하기 전에 이루어지기 때문에, 우리는 조기 최적화라는 중대한 죄를 짓고 있습니다. 즉, 요구 사항, 사용자 및 이해 관계자와 직접 접촉하게 되면 다시 고려되거나 다시 작성되어야 할 코드에 많은 노력과 비용을 들이는 것입니다.

프레임워크를 빌드하려면 선제적 활동이 아닌 소급적 활동이어야 합니다. 동일한 작업을 하기 위해 동일한 코드를 3번 이상 작성해야 한다면 공유 추상화가 도움이 될 수 있는 영역을 찾았을 것입니다. 그것은 좋은 휴리스틱입니다. 실제로 빌드를 시도하기 전에 애플리케이션에 무엇이 필요할지 예상하려고 하는 것은 그렇지 않습니다.

그리고 이제 청구서가 나옵니다. 맞춤형 프레임워크가 제 역할을 하지 못하고 소프트웨어의 원활한 개발 및 유지 관리를 보장하지 못하면 프로젝트가 시작되기도 전에 엄청난 양의 기술 부채가 축적됩니다.

맞춤형 프레임워크: 신속한 기술 부채 축적 공장

이 EF Core -> AutoMapper -> DTO -> AutoMapper -> ViewModel 죽음의 파이프라인을 가져오는 것 외에도 원래 개발자는 다른 회사에서 했던 이전 작업과 똑같은 방식으로 이 애플리케이션을 제공할 수 있을 것이라고 가정했습니다. 즉 , 아직 고용되지 않은 다른 개발자가 정적 HTML 프런트엔드를 통해 최종 사용자에게 사용 가능한 인터페이스로 HTTP API를 조립하는 힘든 작업을 수행하여 ASP.NET Core WebApi 컨트롤러 세트를 통해 제공하는 것입니다.

다시 말해서 선제적 설계였고, 더 나쁜 점은 우리 조직에 존재하지도 않는 다른 사람의 협조가 필요한 설계였습니다 . 원래 개발자는 구현을 상당히 진행하기 전까지는 우리 팀의 다른 사람과 이 가정을 자원하거나 공유하지 않았습니다. 그때 저는 설계에 거부권을 행사하고 SSR과 Razor Pages로 해야 한다고 말했습니다.

오늘날 Sdkbin이 겪고 있는 기술적 부채는 다음과 같습니다.

이 차트에 익숙하지 않으시다면, 이는 JetBrains Rider 내 dotCover의 코드 커버리지 그래프입니다.

저는 역사적으로 제 경력에서 코드 커버리지 도구를 많이 사용하지 않았습니다. 올해 초에 TurboMqtt에서 코드 커버리지를 실험했고, 패킷 처리 파이프라인에서 MQTT 3.1 사양을 대부분 다루는 데 매우 도움이 된다는 것을 알았습니다.

Sdkbin은 2주 전에 모두 삭제하기 전까지 약 85%의 코드 커버리지를 가지고 있었습니다. 꽤 좋은 수치죠! 하지만 이 차트에서 알 수 있듯이, 이러한 테스트 중 어느 것도 최종 사용자가 사이트를 탐색하여 결제와 같은 작업을 하는 데 실제로 사용하는 Razor Pages를 커버하지 않습니다.

대신 테스트는 원래 개발자가 구현한 오래된 HTTP 컨트롤러를 다루고 있습니다 . 그는 HTTP 컨트롤러를 테스트하는 방법을 알고 있었지만 Razor Pages를 테스트하는 방법을 몰랐거나 테스트하고 싶어하지 않았습니다! 가장 나쁜 점은 이러한 쓰레기 저장소 외에 Razor Pages와 컨트롤러 간에 공유되는 코드가 거의 없다는 것입니다!

이것은 가장 명백한 기술적 부채입니다. 우리의 테스트 모음은 가치가 없고 프로덕션에서 사용되지 않은 사전 빌드된 코드를 테스트합니다. 대단합니다.

하지만 잠깐만요, 신청서에는 훨씬 더 악의적인 부채가 깊숙이 숨겨져 있어요!

  • 데이터베이스 스키마에서 무엇이든 변경하려면 엔티티 객체, DTO, 뷰 모델 등의 4가지 다른 계층을 변경해야 합니다. 그리고 이것들은 모두 AutoMapper를 사용하여 매핑되므로, 그렇게 하는 데 따른 오류는 런타임이나 단위 테스트에서 잡아야 합니다. 컴파일 시 이러한 문제를 찾을 수 없습니다. 이것은 미친 짓이고 역효과가 있습니다.
  • 개발자가 EF 코드를 많이 사용하여 전체 SQL 스키마가 매우 객체 지향적으로 느껴집니다. 이로 인해 많은 쿼리가 극도로 메모리와 IO를 많이 사용하게 되어 UI가 눈에 띄게 느려집니다.
  • AutoMapper와 긴밀하게 통합되어 있기 때문에 <form>추가 입력 필드를 포함하도록 요소를 변경하는 것과 같은 작업을 하려면 반사 마법  EF Core 엔터티 변경 추적 마법을 모두 이해해야 합니다.
  • 모든 구성 요소는 시스템 전체에 분산된 환경 변수로 구동되는 자체 맞춤형 구성 시스템을 가지고 있었습니다. 이는 원래 개발자가 맞춤형 프레임워크가 애플리케이션 도메인에서 자연스럽게 다른 동작에 굴복하여 프레임워크가 노출하는 보편적인 동작 모델에 균열을 내는 불가피한 구성 폭발을 처리한 방식입니다. 이 모든 것을 추적하고 Microsoft.Extensions.Configuration 규칙을 사용하여 구성한 다음 이러한 수정 사항을 각각 프로덕션에 점진적으로 배포하는 데 약 60시간이 걸렸습니다. 또한 이를 복잡하게 만드는 "암묵적" 구성도 상당히 있었지만 이는 다른 날에 이야기하겠습니다.

이 시스템에서는 아무것도 명확하게 드러나지 않습니다. 메타 프로그래밍과 부작용만 쭉 나열되어 있을 뿐입니다.

배우지 말아야 할 교훈

Sdkbin은 저에게 엄청난 직업적 당혹감의 원천입니다. 저는 불만을 토로하고 싶어서가 아니라 Petabridge에서 컨설팅 업무를 할 때 많은 고객 애플리케이션에서 정확히 동일한 문제가 나타나는 것을 보았기 때문에 Sdkbin 의 문제와 제 관리 실패에 대한 이야기를 공유합니다. 저는  감독 하에 발생한 실패에 대해 자유롭게 이야기할 수 있습니다 . 그래서 제가 공유하는 것입니다. 훌륭한 소프트웨어를 만드는 데 도움이 되는 사명을 더욱 발전시키기 위해서입니다.

이 기사를 여기까지 읽은 사람 중 일부는 "프레임워크가 Sdkbin에 잘 작동하지 않았다고 해서 나에게도 잘 작동하지 않을 거라는 건 아니잖아!"라는 식의 댓글을 입력하기 시작할 것입니다.

여기서 잘못된 교훈을 얻는 것은 실행의 문제라는 것입니다. 문제는 이것이 전략 의 문제라는 것입니다 . 현실을 표현하는 보편적 모델을 상상하고 적용하는 것은, 현실과 접촉하기도 전에, 소프트웨어를 구축하는 완전히 파멸적인 접근 방식입니다.

프레임워크주의는 .NET 커뮤니티 내에서 업계 전체의 문제이며, 다른 대부분의 플랫폼에서도 만연할 것으로 생각합니다. 배울 교훈은 프레임워크를 만드는 데 올바른 방법이나 잘못된 방법이 있다는 것이 아니라, 프레임워크를 만드는 것을 중단하는 것입니다.

결론

코드 내부에서 표준을 강제하는 데는 장점이 있습니다. 30가지의 다른 로깅 방식이 필요하지 않습니다.

프레임워크가 잘못된 방향으로 나아가는 경우는 세 가지가 있습니다.

  1. 선점 - 소프트웨어가 현실 세계에 출시되기 전에 어떻게 작동해야 하는지 엄격하게 설계하려는 시도. 이는 돌이킬 수 없는 죄입니다.
  2. 경직성 - 궁극적으로 그 접근 방식이 작동하지 않는 영역에서 코드 기반에 모놀리식 표준을 적용하려고 시도합니다. HTTP 요청, 동시성 및 기타 인프라 문제를 처리하는 표준화를 위한 프레임워크를 사용하는 것은 괜찮습니다. 엔티티, 도메인, 사용자 상호 작용 및 행동/시간/데이터 액세스 차이가 자연스럽게 발생하는 영역에 범용 모델을 부과하는 것은 아마도 좋은 생각이 아닐 것입니다.
  3. 사전 결정 - 저는 선호하는 소프트웨어 패턴과 라이브러리가 몇 가지 있지만, 제가 절대 하지 않을 일은 다음과 같습니다. 이전 프로젝트에서 사용했던 것과 동일한 접근 방식과 코드를 요구 사항과 상관없이 다음 프로젝트에 맹목적으로 재활용하는 것입니다. 이렇게 하는 경우 최소한 간단한 템플릿을 선택 하고 데이터 전송 객체 스팸의 고집스러운 엉망진창은 선택하지 마십시오.

" DRY Gone Bad: Bespoke Company Frameworks " 에서 제가 설명한 대로 프레임워크에 대한 가장 좋은 대안은 코드보다는 패턴을 재사용하는 것입니다. 저는 거기에서 모든 주장을 다시 말하지는 않겠지만, 본질적으로는 다음과 같습니다.

  1. 대부분의 엔터티와 도메인을 효과적으로 표현할 수 있을 만큼 일반화 가능한 동작 모델을 찾을 수 있습니다 . 이 작업은 가능한 한 공유된 추상화를 최소화하여 수행해야 합니다. 패턴과 방법론을 공유하면 애플리케이션을 골든 추상화에 고정하지 않고도 재사용성을 제공할 수 있습니다.
  2. 이러한 모델이 자연스러운 차이(즉, 단기 엔터티 대 장기 엔터티)를 수용하기 위해 자유롭게 분기될 수 있다면 코드에서 그렇게 하도록 하는 것이 많은 구성 토글이 있는 범용 모델을 갖는 것보다 더 나은 결과를 얻을 수 있습니다.
  3. 공유된 추상화는 여전히 괜찮습니다. 코드 기반에서 일을 하는 방법에 대한 "하나의 진정한 종교"를 갖는 것에 대해 독재적인 태도를 피하고 싶습니다. 패턴은 일을 하는 반복 가능한 방법  적합하지 않을 때 사용하지 않는 옵션을 제공합니다. 계층화된 프레임워크는 주어진 기능 영역에 완전히 통합되면 사용하지 않는 것이 어렵게 만들도록 설계되었습니다.

마지막으로 한 가지 말씀드리자면, 프레임워크주의는 대부분 개발자가 경력 중반에 빠지는 함정입니다. 어떤 이유에서인지, 우리가 처음으로 메타 프로그래밍을 접한 후에 그런 일이 일어나는 것 같습니다. 저도 그런 실수를 했습니다.

프레임워크주의는 경험과 미숙함이 동시에 일어나는 행위입니다. 경력 초기에 과소설계로 얻은 교훈을 힘들게 받아들이고, 그 실패에 대처하기 위해 반대 방향으로 너무 멀리 벗어나려는 것입니다.

경험 부족은 프레임워크주의의 오만함이다. 즉, 현실을 간결하게 통합할 만큼 똑똑하면서도 팀의 다른 소프트웨어 개발자가 실수를 하지 못하도록 충분히 엄격한 추상화 집합을 구축할 수 있다는 오만함이다.

여기서 학습이 일어나야 합니다. 오만함을 파악하고 그것이 뿌리를 내리지 않도록 하는 것입니다. 여기서 여러분의 경로가 전문가 초보자 영역으로 갈라져서 각 프로젝트에 동일한 AutoMapper EF Core 쓰레기를 복사하여 붙여넣거나, 더 겸손한 길을 가서 보편적인 행동 모델이 없고 그것을 구축하려는 것은 무의미하다는 것을 받아들일 수 있습니다.

토론, 링크 및 트윗

저는 Petabridge 의 CTO이자 창립자입니다 . Petabridge에서 저는 Akka.NET , Phobos  의 작업을 통해 .NET 개발자를 위한 분산 프로그래밍을 쉽게 만드는 데 주력하고 있습니다 .

  • No labels