5P by GN⁺ | ★ favorite | 댓글 4개
  • 잘못된 추상화보다 코드 중복이 훨씬 싸며, 성급한 공통화가 장기 유지보수 비용을 키운다고 봄
  • 처음에는 합리적이던 추출도 요구사항이 조금씩 달라지면 매개변수와 조건문이 붙어 원래 의도를 흐림
  • 공통 추상화가 여러 아이디어를 떠안기 시작하면 코드는 조건 중심 절차로 변하고, 새 기능을 넣을수록 더 깨지기 쉬워짐
  • 기존 코드에 들인 노력을 지키려는 매몰비용 오류를 경계하고, 필요하면 추상화를 호출부로 다시 인라인해 실제로 필요한 코드만 남겨야 함
  • 잘못된 추상화가 드러났다면 중복을 다시 도입해 현재 요구사항의 공통점을 새로 관찰하고, 그다음에 다시 추출하는 편이 더 빠름

잘못된 추상화가 만들어지는 흐름

  • duplication is far cheaper than the wrong abstraction”라는 문장은 RailsConf 2014 발표의 일부였지만 이후에도 계속 회자됨
  • 흔한 실패 경로는 다음과 같음
    • 개발자 A가 중복을 발견함
    • 중복을 메서드나 클래스로 추출하고 이름을 붙여 새 추상화를 만듦
    • 호출부의 반복 코드를 새 추상화 호출로 대체함
    • 시간이 지나 거의 맞지만 완전히 같지는 않은 새 요구사항이 등장함
    • 개발자 B가 기존 추상화를 유지하려고 매개변수를 추가하고, 값에 따라 다른 경로를 타는 조건문을 넣음
    • 이후 새 요구사항마다 매개변수와 조건문이 늘어나며 코드 이해가 점점 어려워짐
  • 한 번 만들어진 코드는 보존해야 할 투자처럼 보이기 쉬움
    • 이미 들인 노력을 아깝게 여기는 심리가 작동함
    • 코드가 복잡하고 이해하기 어려울수록, 그만큼 중요하고 오래 걸렸을 것처럼 느껴져 버리기 어려워짐
    • 이는 매몰비용 오류와 연결됨

중복으로 되돌아가 다시 추출하기

  • 잘못된 추상화 위에서 새 요구사항을 계속 구현하면 공유 코드는 조건문 중심으로 변하고, 기능을 추가할수록 더 불안정해짐
  • 이때 빠른 길은 더 밀어붙이는 것이 아니라 뒤로 돌아가는 것
    • 추상화된 코드를 각 호출부에 다시 인라인해 중복을 재도입함
    • 각 호출부에서 전달하던 매개변수를 기준으로 실제 실행되는 코드만 확인함
    • 해당 호출부에 필요 없는 코드를 삭제함
  • 인라인 과정은 추상화와 조건문을 함께 제거하고, 각 호출부를 자신에게 필요한 코드만 가진 상태로 줄임
  • 같은 추상화를 호출하는 것처럼 보였던 코드도 실제로는 각 호출부가 상당히 고유한 코드 경로를 실행하고 있었을 수 있음
  • 이전 추상화를 완전히 제거한 뒤에야 중복을 다시 관찰하고, 현재 요구사항에 맞는 새 추상화를 추출할 수 있음
  • 매개변수와 조건 경로가 공유 코드에 계속 추가되고 있다면 그 추상화는 더 이상 맞지 않을 가능성이 큼
    • 처음에는 맞는 추상화였을 수 있음
    • 요구사항이 바뀌면서 더 이상 같은 형태로 유지하기 어려워졌을 수 있음
  • 잘못된 추상화에서는 중복을 다시 도입하는 일이 후퇴가 아니라 더 나은 전진이 됨

댓글과 토론

오 공감갑니다.
정리되지 않은건 정리하면 되지만
정리되어 있는건 뒤집는데 비용이 더 크게 들어가는 것 같습니다.

ponytail 이 올라놨는데 바로 이런글이 ㅎㅎ

항상 대립각이네요.

Hacker News 의견들
  • 단일 진실 공급원(single source of truth) 원칙은 항상 지켜야 한다고 봄
    서로 달라지면 버그가 되는 중복 코드라면 리팩터링해야 함. 그렇지 않으면 미래 개발자가 버그가 터지기 전까지 알아채기 어려운 장거리 결합이 생김
    다만 그 원칙을 위반하지 않는다면 추상화는 편의일 뿐이고, 불편해지기 시작했다면 제 역할을 못 하는 것이라 쓸 이유가 없음. 함수가 맞춤 동작을 위해 여러 플래그를 필요로 한다면 잘못된 추상화이거나 단일 책임 원칙 위반일 가능성이 큼
    정말 많은 맞춤화가 필요하다면 인자로 함수/펑터를 받는 방식이 자주 좋음. 예를 들어 solve(f:double -> double, max_iters = 99, x_abs_tol = 1e-15, x_rel_tol = 1e-15, ...) 대신 solve(f:double -> double, stopping_criteria: StoppingCriteriaClass)처럼 만들 수 있음

    • 글의 핵심은 아직 진실 공급원이 몇 개인지 명확하지 않은 경우를 다룬다는 데 있음
      코드의 두 지점이 같은 알고리즘을 쓰는지, 아니면 살짝 다른 버전인지, 더 중요하게는 같은 이유로 바뀔 것인지가 불분명함
      제목의 격언은 서로 다른 것을 억지로 같게 만드는 편이, 같은 것을 중복했다가 나중에 다르게 만드는 것보다 더 고통스럽다고 말하며 나도 맞다고 봄. 후자는 같은 변경을 두 번 하거나 추상화를 도입하는 리팩터링을 하면 되지만, 전자는 추상화에 계속 덧붙이거나 되돌려야 함
      특히 지역성(locality) 을 깨뜨리는데, 변경할 때 정말 중요한 속성은 이것뿐임. 그냥 이 변경만 하고 시스템의 무관한 부분에 부작용이 날지 걱정하고 싶지 않음
    • 극심한 압박 때문에 소프트웨어가 두 개의 진실 공급원으로 밀려났다면, 두 소스가 맞지 않으면 main에 병합되지 않는 CI 테스트를 추가하는 방법이 꽤 쓸 만함
      대표적으로 pyproject.toml / requirements.txt 동기화가 실제로 최선인 경우가 있고, 더 넓게도 적용 가능할 듯함. 전제는 이미 단일 진실 공급원이 불가능할 정도로 일이 틀어졌다는 것이며, 치료보다는 피해 감소에 가까움
    • “서로 달라지면 버그”라는 기준은 아주 좋은 경험칙임
      한 시점에 두 코드 조각이 비슷해 보여서 과도하게 추상화했다가 나중에 갈라지는 일을 자주 겪었음
    • 이론적으로는 맞지만, 현실에서는 어떤 중복이든 무조건 피하려는 사람이 많음
      특히 주니어 개발자는 중복이 모든 악의 근원인 것처럼 대하는 경우가 있음
  • 가끔 이 문제를 생각함. 최근 개인 프로젝트에서 RTS 유닛용 2D 스프라이트를 다루다 마주쳤는데, 유닛 스프라이트는 스프라이트시트에 일관된 방식으로 들어 있었음: 8방향에 5개 스프라이트, 그중 3방향은 미러링하고, 순서는 stand, move, attack, die였음
    그래서 action + direction을 받아 재생할 스프라이트 배열을 주는 로더를 만들었음
    그런데 방향성이 없는 폭발 스프라이트, 4방향과 2개 미러링만 있는 시체 스프라이트, 게다가 첫 네 개를 제외하면 오크와 인간이 대부분 공유하는 경우가 나옴
    이 모든 것의 공통 추상화가 대체 뭔지 잠깐 고민했지만, 결국 로딩 코드 일부만 분리하고 UnitLoader, CorpseLoader, EffectLoader를 만든 뒤 넘어갔음. 세 로더가 조금씩 같은 것을 다루므로 더 나은 추상화가 있을 수는 있지만, 나중에 발견하면 됨. 지금 복잡한 EverythingLoader를 만들어 모든 경우를 처리하려 하기보다, 나중에 그때 중복을 제거하는 편이 더 쉬움

    • “사물은 가능한 한 단순해야 하지만, 그보다 더 단순해서는 안 된다”는 말을 좋아함
      프로그래밍에서는 일반화를 통해 코드를 단순화하려는 본능이 있지만, 현실은 지저분해서 종종 지나치게 단순화함. 본문처럼 시간이 지나 새 요구사항이 생기면 너무 이른 단순화였다는 게 드러남
      “성급한 추상화는 많은 구림의 근원이다”라는 격언이 될 만함
    • 이미 공통 추상화는 분리되어 있을 가능성이 큼. 단일 스프라이트의 픽셀을 로드하고 표시하는 코드가 그것임
      그 위 단계인 스프라이트시트 배치 해석과 재생 모드 처리는 여러 변형이 있고, 모든 경우에 맞는 공통 추상화가 없을 수 있음
      보이지 않는 추상화를 억지로 만들거나 불완전한 추상화에 맞추려는 것보다, 지금처럼 하는 쪽을 선호함. 추상화가 완전히 명확하고 필요가 분명해질 때까지 기다리는 건 좋은 일임
      DRY의 반대편 해독제로 WET가 있음. 모든 것을 두 번/세 번 작성하라는 뜻임. 더 중요하게는 실제로 입증된 사용 사례, 보통은 먼저 중복으로 드러난 것에 대해서만 추상화해야 한다고 봄. 아직 없는 미래 사용 사례를 위해 쓴 코드는 실제로 가진 것을 추상화하는 데 자주 방해가 되고, 그런 일이 벌어질 때마다 웃기기도 함
    • 이 방식이 맞음. 게임 만들기는 원래 재미있어야 함
      어렵고 지루한 일은 프로젝트의 마지막 10%에 도달했을 때 해도 됨
      게다가 중복이 만들어낸 “버그”가 플레이어가 좋아하는 재미있는 기능이 될 때도 있음
  • OOP를 쓰던 시절에는 추상화 때문에 고생했지만, 거의 순수 함수형 접근으로 옮긴 뒤에는 코드 중복이 드물어졌음
    그냥 함수를 만들고 두 곳에서 호출하면 됨. 주된 추상화 이슈는 자료구조인데, TypeScript 인터페이스는 본질적으로 덕 타이핑이라서 여기서도 문제가 많지 않음
    그래서 추상화 문제 때문에 생기는 코드 중복은 드묾. 개발자가 사일로화되어 생기는 코드 중복이 훨씬 흔함

    • 취미로 함수형 언어를 쓰는데, 기억해야 할 핵심은 기법이라고 봄
      현대 언어 대부분은 함수형 프로그래밍 이론 위에 쉽게 올라설 수 있고, Haskell을 꼭 알 필요는 없음. 사람마다 머리가 다르게 작동하겠지만, 작고 단순하며 가끔은 유연한 부품들이 전체를 만든다는 생각이 나에게는 잘 맞음
      크고 복잡하며 모든 걸 다 하는 형태변환 기계와는 반대임
    • 코드 중복을 겪는 데 개발자가 꼭 사일로화되어 있을 필요는 없음
      팀 규모가 일정 수준을 넘어서 각자가 다른 사람이 무엇을 하는지 모두 알 수 없게 되면 코드 중복은 꽤 필연적임. 모두가 함수형 스타일로 작성해도 마찬가지임
      실제로 지난달 회사에서 이런 일이 있었음. 새 순수 헬퍼 함수를 작성해 파일 앞부분에 두었는데, 일주일 뒤 동료가 실질적으로 같은 기능을 하지만 시그니처가 다른 비슷한 헬퍼 함수가 같은 파일 끝부분에 이미 있다고 알려줌
    • “함수를 두 부분에서 호출한다”는 게 정확히 무슨 뜻인지 궁금함
  • 본문과 같은 맥락에서, 둘 다 겪어본 사람이라면 동의할 것임. 과소설계된 코드베이스가 과잉설계된 코드베이스보다 훨씬 다루기 쉬움

  • 유지보수해야 했던 최악의 코드는 DRY를 따르려던 코드였음. 다만 그 원칙의 원래 의도를 이해하려 하지는 않았음
    그 난장판에서 빠져나오는 유일한 방법은 넓은 범위의 코드 중복을 다시 도입하는 것이었음

    • 괜찮을 테니 걱정하지 말고, 새 사용 사례를 지원하려고 재사용 함수에 모호한 불리언 매개변수 몇 개만 더 추가해서 배포하면 됨
    • 핵심은 “시도했다”는 데 있음. 한동안 그렇게 하다가 추상화가 틀렸기 때문에 더는 충실히 따를 수 없는 지점에 도달한 것임
  • 여기서는 두 발표가 떠오름: Mike Acton의 Data-Oriented Design and C++ [1]과 Brian Cantrill의 The Complexity of Simplicity [2]
    Mike의 발표는 코드 해법이 현실 세계를 모델링할 필요는 없고, 서로 다른 데이터는 서로 다른 문제를 만들며, 따라서 서로 다른 해법이 필요하다고 말함. 발표를 충분히 잘 옮기긴 어렵지만 나에게 큰 영향을 줬음
    Brian의 발표는 추상화 전반과 “올바른” 추상화를 찾는 일이 얼마나 어려운지를 다룸

    1. https://www.youtube.com/watch?v=rX0ItVEVjHc
    2. https://www.youtube.com/watch?v=Cum5uN2634o
    • 꽤 똑똑한 엔지니어도 코드베이스의 실제 필요보다 현실 세계 은유를 우선할 때가 있어서 늘 이상하게 느꼈음
      예전에 학교를 나온 지 몇 년 안 됐을 때 Rust로 연결 풀을 구현하고 있었는데, 가장 합리적인 구현은 연결 객체가 풀에 약한 참조를 들고 있다가 drop될 때 자동으로 반환되게 하는 것이었음
      매우 경험 많은 관리자였던 내 매니저는 “도서관이 책을 들고 있지, 책이 도서관을 들고 있지는 않다”는 이유로 이 아이디어를 싫어했음. 설계를 바꿀 만큼 설득력 있는 이유라고 느끼지 않았지만, 그는 그 은유의 렌즈를 통하지 않고는 이 문제를 다루려 하지 않았음
      결국 다른 매니저가 “도서관 책이 도서관을 포함하진 않지만, 반납처를 가리키는 도서관 이름 도장은 뒤에 찍혀 있다”고 제안하면서 교착이 풀림. 그 매니저는 이 비유 확장을 합리적이라고 본 듯함
      내가 더 경험이 많았다면 논점을 양보하지 않으면서도 그 비유 안에서 대화하는 법을 찾았을지도 모르지만, 지금도 코드의 추상화와 라이브러리 사용 경험의 결과를 따져보는 대신 그 은유를 표준 프레임으로 고집한 건 완전히 기이했다고 느낌
  • 아무도 들으려 하지 않음. 정말 아무도 안 들음. 회사의 90%에는 새 추상화를 만들 때 황홀해하는 이른바 시니어 개발자가 있음
    과잉설계, 추상화, 성급한 최적화는 엔지니어링의 3대 재앙임
    동시에 그것들이 있어서 항상 일자리가 있을 테니 기쁘기도 함

    • Kubernetes, 엔지니어 수보다 많은 마이크로서비스, 몇 바이트 오버헤드를 줄이는 복잡한 프로토콜, 전부 클라우드, 그리고 단순 함수였어도 될 수많은 클래스가 딱 그런 예임
  • 비슷하게, 어떤 개발자는 인라인 문자열이나 숫자 상수가 전부 악이라고 생각하는 듯함. 한 PR에서 이런 걸 봤음
    HTTPS_SCHEME = 'https'
    DOMAIN = 'www.example.com'
    url = HTTPS_SCHEME + '://' + DOMAIN
    “상수를 박아 넣지 말라”는 말을 카고 컬트처럼 따라 하는 것 말고 이게 뭘 얻는지 모르겠음. 게다가 상수 정의는 파일 맨 위에 있었고 URL을 만드는 코드는 수백 줄 떨어져 있었음

    • 코드에서는 가까움을 매우 좋아함. 쓰는 곳에 최대한 가깝게 정의하는 걸 선호함. 이건 정말 거슬리는 습관임
      정규식도 파일 맨 위에 두지 말고 쓰는 곳에 두면 됨. 언어는 똑똑해서 아마 상수라는 걸 알아낼 수 있음
      아주 작은 함수라면 그냥 람다를 쓰면 됨. 한두 번 쓰는 한 줄짜리 함수를 아주 멀리 떨어진 곳에 만들지 않았으면 함
    • 상수를 맨 위에 두면 더 쉽게 커스터마이즈할 수 있음. 특히 이 파일이 복제될 경우엔 더 그렇다
      테스트나 스테이징에서 https 대신 http로 바꿔야 한다면 스킴과 도메인을 분리하고 상수를 위쪽이나 별도 파일에 두는 게 말이 됨. url이 여러 곳에서 구성되는지 한 곳에서만 구성되는지도 중요함
      파일 맨 위에 이름 붙은 상수를 두는 건 매우 흔한 스타일이고, 때로는 팀 코딩 표준의 일부이기도 함
      다른 이유도 있을 수 있으니 Chesterton’s Fence를 떠올리는 게 좋음. 어쨌든 카고 컬트라고 단정하는 건 좋은 생각이 아님. 누군가는 인라인 리터럴을 쓰는 것도 똑같이 카고 컬트라고 말할 수 있음. 이상해 보이면 물어보면 되고, 좋은 이유가 있을 수도 있고, 아무도 신경 쓰지 않았으니 리팩터링해서 상수를 인라인으로 넣어도 좋아할 수도 있음
    • 나도 이런 일을 겪었음. Event에 이름이 있으면 거대한 모놀리스나 마이크로서비스 저장소 묶음 전체에서 바로 grep해서 해당 이벤트와 관련된 모든 파일을 찾을 수 있음
      그걸 상수로 빼면 다시 프로젝트를 하나씩 열어 사용처 찾기를 해야 함
  • 마이크로서비스를 쓰면 둘 다 할 수 있음

    • 농담인 건 알지만, 이상적인 세계의 마이크로서비스에서는 서비스 간 코드 중복이라는 개념이 없음
      한 서비스의 유지보수자라면 다른 서비스에 있는 코드는 신경 쓸 이유가 없음. 다른 팀 코드인데 왜 신경 써야 하나? 그 팀이 존재한다는 것조차 알 필요가 없음. 큰 시스템에서는 모든 애플리케이션의 존재를 현실적으로 알 수 없는 경우도 있음
    • 잠깐만요! 더 있습니다!
      단돈 $19.95에 단일 장애 지점 하나를 여러 개의 단일 장애 지점으로 바꿔드립니다!
    • 10번 중 9번은 마이크로서비스가 서로 심하게 의존하게 되어 분산 모놀리스가 됨
      서비스 지향 아키텍처를 쓰되 그냥 모놀리스를 배포하는 편이 나음. 테스트가 더 쉽고 직렬화/역직렬화라는 추가 계층도 피할 수 있음
  • 대부분의 시니어는 DRY를 맹목적으로 따르지 말아야 한다는 걸 안다고 생각함. 그래도 우리 중 많은 사람은 중복된 코드 소스 여러 개를 유지해야 한다는 생각을 불편해함
    이를 다루려면 두 호출자가 공통 코드에 의존하는 단순 모델을 면밀히 살펴봐야 함. 공통 코드가 한 호출자만의 필요 때문에 바뀌어야 한다면, 그 코드는 공통에 속하지 않음
    DRY의 잘못된 목표는 캡슐화로 해결하려는 것임. 캡슐화는 리팩터링 작업을 호출자에서 공통 코드로 옮김. 하지만 공통 코드를 갱신하는 데 따르는 영향이 호출자보다 훨씬 크기 때문에 원하는 방향이 아님
    캡슐화를 피하면서도 DRY를 지킬 수 있음. 호출자가 인지해야 하는 여러 개의 얇은 추상화를 두는 편이 더 낫다. OOP에서는 이를 위해 SRPIoC를 배우고, 절차적 프로그래밍에서는 일련의 헬퍼 함수를 호출하는 식으로 자연스럽게 나타남