2P by GN⁺ 13시간전 | ★ favorite | 댓글 1개
  • 함수 내부의 if 문을 호출부로 올리면 코드의 복잡성 감소에 도움을 줌
  • 조건 검사 및 분기 처리를 한 곳에서 집중하면 중복 및 불필요한 분기 확인을 쉽게 할 수 있음
  • enum 분해 리팩토링을 사용해 동일한 조건이 코드 곳곳에 퍼지는 문제를 방지할 수 있음
  • 배치 연산 기반의 for문은 성능 향상과 반복 작업 최적화에 효과적임
  • if는 위로, for는 아래로 내리는 패턴을 조합해 코드 가독성과 효율성을 동시에 증대시킬 수 있음

두 가지 관련 규칙에 대한 간단한 메모

  • 함수 내에 if 조건문이 존재할 경우, 이를 함수 호출부로 옮길 수 있는지 고민하는 방식이 추천됨
  • 예시처럼, 함수 내부에서 precondition(전제 조건)을 검사하는 것보다, 호출부에 해당 조건 검사를 맡기거나 타입(혹은 assert)로 전제 조건을 보장하도록 만드는 것이 바람직함
  • 전제 조건 검사를 위로 올리는(Push up) 방식은 코드 전반에 영향을 미쳐, 전체적으로 불필요한 조건 검사 수를 줄임 효과를 줌

제어 흐름과 조건문의 집중

  • 제어 흐름if 문은 코드의 복잡성 및 버그의 주요 원인임
  • 조건문을 호출부 등 상위로 집중시켜 분기 처리를 한 함수에만 몰아주고, 실제 작업은 직선적(스트레이트라인) 서브루틴에 맡기는 패턴이 유익함
  • 분기와 제어 흐름이 한 곳에 모이면 중복 분기불필요한 조건을 쉽게 파악할 수 있음

예시:

  • f 함수 내에 중첩된 if가 있을 때에는 죽은 코드(Dead Branch)를 인식하기 쉬움
  • 여러 함수(g, h)를 통한 분기가 분산되면 이런 파악이 어려워짐

enum 분해 리팩토링(Dissolving enum Refactor)

  • 코드가 같은 조건문 분기를 enum 등으로 내포하고 있을 경우, 조건을 상위로 끌어올려 분기와 작업을 더 명확하게 구분할 수 있음
  • 이 방식을 적용하면, 동일한 조건이 코드에 여러 번 반복되어 나타나는 문제를 막을 수 있음

예시:

  • 동일한 분기 조건이 f, g 함수와 enum E에 각각 표현된 상황을
  • 하나의 상위 조건 분기로 코드 전체를 단순화할 수 있음

데이터 중심적 사고(Data Oriented Thinking)와 배치 연산

  • 대부분의 프로그램은 여러 객체(엔티티)로 동작함. 크리티컬 경로(Hot Path)는 다수 객체 처리로 성능이 결정됨
  • 배치(batch) 개념을 도입해서 객체 집합에 대한 연산을 기본으로 만들고, 단일 객체 연산은 특수 케이스로 처리하는 것이 바람직함

예시:

  • frobnicate_batch(walruses) 처럼 배치 처리 함수를 기본으로 두고,

  • 개별 객체는 for 루프를 통해 처리하는 특수 케이스로 변환 가능함

  • 이 방식은 성능 최적화 측면에서 중요한 역할을 하며, 대량 작업에서는 스타트업 비용을 감소시키고 순서 유연성을 높여줌

  • SIMD 연산(struct-of-array 등) 활용도 가능해, 특정 필드만 일괄 처리 후 전체 작업을 진행할 수 있음

실용적 사례와 추천 패턴

  • FFT 기반 다항식 곱셈처럼, 여러 지점에서의 동시에 연산을 가능하게 만들어 성능 극대화가 가능함
  • 조건문을 위로, 반복문을 아래로 내리는 규칙은 병행 적용 가능함

예시:

  • 반복문 내부에서 같은 조건식을 계속 검사하는 것보다, 조건문을 반복문 바깥으로 빼면 반복 루프 내 분기를 줄이고 최적화 및 벡터화가 쉬워짐
  • 이 접근법은 TigerBeetle 설계 등, 대규모 시스템 데이터 플레인에서도 높은 효율성을 보장함

결론

  • if문(조건문)은 상위(호출부, 제어부) 로, for문(반복문)은 하위(연산부, 데이터 처리부) 로 내리는 패턴을 조합함으로써, 코드 가독성효율성, 성능 모두를 향상시킬 수 있음
  • 추상 벡터 공간 시점으로의 사고(집합 단위 연산)는 반복적 분기 처리보다 더 좋은 문제 해결 도구임
  • 요약하면, if는 위로, for는 아래로!
Hacker News 의견
  • 내 독특한 정신적 모델은 다양한 상태나 프로그램 흐름이 나무 구조를 이룬다는 형태임. 조건문이 이 나무의 가지를 쳐내는 역할을 함. 가능한 한 빨리 가지치기를 해서 이후 처리해야 할 가지 수를 줄이고 싶음. 모든 가지를 일일이 평가하고 치우다가 결국 전체 가지를 한꺼번에 잘라버려야 한다는 상황을 피하고 싶음. 약간 색다른 시각으로 볼 때, 조건문은 "필요 없는 일을 발견해내는 과정"이고, 루프는 "실제 일"임. 궁극적으로 내가 원하는 함수는 프로그램 트리를 탐색하거나 실제 일을 처리하는 것 중 한 가지에 집중하는 형태임
    • 내 옆 모델을 제시해 보고 싶음. 클래스는 명사이고, 함수는 동사라고 생각함
    • 내 정신적 모델은 내가 작성하는 구체적인 코드가 존재하는 세계에 맞추는 것임. 도메인 특성, 기존 코드 패턴, 데이터 파이프라인의 단계, 성능 프로파일 등에 따라 달라짐. 처음엔 이런 규칙이나 휴리스틱을 만들려고 했는데, 코드를 많이 써보니 이런 추상 규칙은 실제론 크게 의미가 없다는 것을 깨달음. 많은 경우 엉뚱하게 함수 이름이나 한 글자만 정해놓고 그 “섬 같은 코드” 안에서만 규칙이 성립하는데, 실제 코드베이스에서는 보통 이렇게 함수들을 굳이 합치지 않은 이유가 있음. 예시로 “중복과 죽은 조건” 이야기가 나오는데, 그건 해당 함수가 오직 단 한 곳에서만 호출된다는 편한 가정을 해서 적용한 규칙임. 실제론 다른 이유로 서로 분리돼 있을 때가 많음
    • 아주 괜찮은 모델이라고 생각함
  • 좀 더 일반적인 규칙은 조건문을 입력 소스와 최대한 가깝게 두는 것임. 외부에서 프로그램으로 들어오는 진입점(다른 서비스에서 가져온 데이터 포함)을 최대한 빨리 식별하고, 코어 로직까지 도달하기 전에 (특히 리소스를 많이 쓰는 부분에 도달하기 전에) 가능한 한 많은 보장을 만들어두는 게 핵심임. 타입에 그걸 명시적으로 표현하는 것도 매우 좋음
    • 이렇게 하면 코어 로직을 이해할 때 어떤 전제를 갖고 있는지 알기 어려워지지 않음? 코드 전체의 호출 체인을 다 들여다봐야 하지 않음?
  • “if 조건이 함수 내부에 있으면, 그걸 호출자 쪽으로 옮길 수 있을지 고려하라”는 조언에는 반례가 너무 많음. 만약 함수가 37군데에서 호출된다면, 모든 호출부마다 같은 if 문을 반복해야 함? 예를 들어 getaddrinfo나 EnterCriticalSection 같은 함수에 이런 식으로 if를 옮기라고 할 수 있나? 이런 변환은 딱 두 군데 정도에서만 호출되는, 그리고 그 결정이 함수의 관심사 밖에 있을 때만 고려할 수 있다고 생각함. 한 가지 방법은, 조건문만 수행하는 함수에 헬퍼 함수를 위임해서 작성하는 것임. 그리고 루프 밖으로 조건을 옮길 필요가 있을 때엔 더 저수준 조건 헬퍼를 호출자가 직접 사용하게 할 수 있음. 하지만 이런 고민의 핵심은 “최적화” 때문임. 최적화가 항상 더 좋은 프로그램 설계와 충돌하는 경우가 많음. 호출자가 조건을 알 필요가 없는 게 더 좋은 설계일 수도 있음. 이런 딜레마는 OOP에서도 자주 등장함. “if”로 대표되는 결정이 실제론 메서드 디스패치로 이뤄지는 경우임. 이러한 디스패치를 루프 밖으로 꺼내는 것도 설계 원칙과 마찰을 일으킬 수 있음. 예를 들어 캔버스에 이미지를 그릴 때, 매번 putpixel을 반복 호출하기보다는 blit 같은 메서드를 활용하는 것이 사례임
    • 함수가 37군데에서 호출된다면 코드 리팩터링이 필요하긴 함. 그 질문에 답하자면, 상황에 따라 다름. DRY가 정답처럼 느껴지긴 하지만, 실제 예시 코드를 보고 결정해야 함. 라이브러리라면 소유권 경계에 있기 때문에 각자 자신의 데이터와 책임을 관리해야 함. EnterCriticalSection 같은 함수는 진입 구간에서 강력한 검증(조건문 포함)을 하는 게 맞음. 그런데 애플리케이션 코드에서는 if를 호출자 쪽으로 옮겨도 무방함. 라이브러리나 핵심 코드에선 제어 흐름을 가장자리로 이동시키는 것이 적절함. 자신이 다루는 도메인 내에서는 제어 흐름을 가장자리에 두는 것이 좋음. 하지만 언제나 이런 규칙은 관용적일 뿐이니, 상황에 따라 합리적으로 판단할 수 있는 사람이 맥락에 맞춰야 함
  • “dissolving enum 리팩터” 예시는 사실상 다형성(polymorphism) 패턴임. match문을 다형적 메서드 호출로 대체할 수 있음. 이 방식의 목적은 초기 조건 분기를 결정하는 시점과 실제 동작이 실행되는 시점을 분리하는 데 있음. 케이스 구분은 객체(여기서는 enum 값)나 클로저가 들고 있으므로, 호출 시점마다 그걸 반복할 필요 없음. 케이스 구분이 바뀌면 분기점만 바꾸면 되고, 실제 행동이 발생하는 곳은 수정하지 않아도 됨. 단점은, 각 케이스 별 동작 분기를 직접 확인할 수 있는 편리함과, 코드 레벨에서 케이스 리스트에 의존성이 생기는 점의 트레이드오프임
  • 조건문을 함수 내부에 두는 걸 좋아할 때가 있음. 일부러 호출자가 함수의 호출 순서에 실수하지 못하게 만들 수 있기 때문임. 예를 들어 멱등성 보장이 필요한 경우, 이미 처리된 상태를 먼저 확인하고 아니면 수행하는 식임. 이 조건을 호출부로 빼면 모든 호출자가 그 절차를 올바로 지켜야만 멱등성이 보장되므로, 추상화에서 그 보증을 제공하지 못함. 이런 상황에서는 이 철학을 어떻게 적용해야 하는지 궁금함. 또 예를 들면, 데이터베이스 트랜잭션 안에서 일련의 체크를 모두 한 다음 작업을 하고 싶을 때, 그 체크들을 어디에 둘지 고민임
    • 이미 본인이 질문에 답한 것 같음. 조건문을 호출부로 빼면 함수가 더 이상 멱등적이지 않고, 당연히 보장도 못함. 멱등성 보장을 위해 함수마다 상태 관리 로직을 넣는다면, 뭔가 많이 이상한 코드를 짜고 있는 것일 수 있고, 너무 많은 비즈니스 논리를 단일 함수에 몰아두고 있다는 의미임. 멱등 코드는 크게 두 가지로 나눔. 첫째, 데이터 모델이나 연산 자체가 멱등적인 코드. 이땐 굳이 처리 순서를 신경 쓸 필요가 없음. 두 번째는 더 복잡한 비즈니스 연산에서 멱등 추상화를 만드는 것. 롤백이나 원자적 적용(abstraction on atomic apply) 같은 복잡한 로직이 필요하고, 이 경우는 단일 함수에 간단히 담을 수 있는 얘기가 아님
    • 체크 없는 내부 함수를 만들고, 외부에 래퍼 함수에서 체크 후 내부 함수를 호출하는 방식으로 관리하는 것도 방법임
  • 코드 복잡도 스캐너는 결국 if 문을 아래로 밀어내는 경향이 있는 도구임. 하지만 이 글에서는 반대로 if 문을 위로, 즉 더 상위 함수로 올리는 걸 추천함. 그렇게 하면 복잡한 분기 로직을 단일 함수에서 중앙집중적으로 처리할 수 있고, 실제 구체적 작업은 서브루틴에 위임하게 됨
    • 해결 방법은 “결정”과 “실행”을 분리하는 것임. Bertrand Meyer에게서 배운 아이디어임. 예를 들면 if (weShouldDoThis()) { doThis(); } 이런 식이고, 각 체크를 별도 함수로 빼면 테스트와 복잡도 관리가 쉬워짐
    • 코드 스캐너의 리포트는 진지하게 의심할 필요가 있음. sonarqube 등은 실제 버그가 아닌 “code smell”도 마구잡이로 보고함. 이런 식으로 “문제가 안되는 코드”까지 수정하려다 실제로 새로운 버그가 생길 위험이 높아지고, 진짜 중요한 문제 대처 시간만 낭비하게 됨
    • 이런 최적화는 대체로 “국지적 최적”일 때가 많음. 즉, 새 요구사항이나 예외 케이스가 발견되면, 분기 로직이 루프 밖에 필요해짐. 그 상태에서 루프 안팎 모두에 분기가 섞이면 이해하기 어려워짐. 조건이 루프 내부에만 필요하다고 확신하면 그렇게 두고, 그렇지 않으면 차라리 미리 설계를 조금 더 길게 가져가고, 코드도 장황해져도 더 이해하기 쉬운 쪽이 낫다고 생각함. Haskell을 쓰다가 이런 경험을 했음. 로직을 가장 간결하고 최적화된(local optimum) 형태로 추구하면, 요구사항이 아주 조금만 바뀌어도 설계의 의도를 표현하기보단 그냥 로직만 남게 되고, 작은 변경에도 코드 언롤링이 심하게 일어남
    • 코드 복잡도 스캐너는 항상 불만이었던 존재임. 읽기 쉬운 큰 함수조차 불만을 표출함. 로직을 한곳에 놓으면 전반적 맥락을 이해하기 쉬운데, 함수를 쪼갤 때는 진짜 맥락을 놓치지 않도록 조심해야 함
    • 어제 LLM에 대한 스레드에서 “개발자들이 다들 받아들이는 신뢰할 수 없는 도구”에 대해 얘기 나온 적 있었음. 이제 답을 알겠음…
  • 경우에 따라서는 오히려 반대로 접근해서 SIMD를 활용해야 함. 예를 들어 AVX-512 등에서는 분기가 있는 코드를 브랜치 없는 코드로 벡터 마스크 레지스터를 써서 처리할 수 있음. 예를 들어 for문 안 if 문은 for문 밖 if 문보다 관리가 쉽고, 메모리 접근 효율이 높음. 구체적 예제를 들면, 홀수면 +1, 짝수면 -2 하는 연산이 있을 때, 원래는 각 루프에서 분기를 타야하지만, SIMD로 벡터 처리하면 16개씩 모든 int 를 동시에 처리할 수 있고, 브랜치도 없음. 컴파일러가 제대로 벡터화하면 원래 코드를 브랜치 없는 최적화 버전으로 바꿔줄
    • 제시한 before 코드는 해당 글의 논점과는 조금 맞지 않는 것 같고, 오히려 최적화된 SIMD 버전이야말로 글의 요점에 부합한다고 생각함. 예시에서 for문 안 if는 데이터에 의존하는 분기라서 쉽게 끌어올릴 수 없음. 만약 알고리즘이 if (length % 2 == 1) { ... } else { ... }처럼 루프 밖 조건만 쓰는 구조였다면, 이런 조건은 당연히 for문 위로 빼는 게 정답임. SIMD 버전에선 if가 아예 사라졌고, 이런 게 이상적인 코드 패턴이라 글 저자도 좋아할 만한 방식임
    • 나도 for 루프의 요소 값에 따라 분기하는 코드가 바로 떠올랐음. 혹시 컴파일러에서 이런 코드를 자동 벡터화하는 게 얼마나 어려운지 아는 사람 있음? 그 경계가 궁금함
  • 개인적으로 이게 “좋은” 규칙이라고는 생각하지 않음. 적용 가능한 경우가 있긴 하지만, 맥락에 따라 너무 달라서 딱 잘라 결론을 내리기 힘듦. 영어 철자 규칙처럼 예외가 너무 많아서 사실상 규칙으로 취급하기 어렵다고 느껴짐
  • (2023) 당시 토론 링크 (662점, 295개 댓글) https://news.ycombinator.com/item?id=38282950
  • Sandi Metz의 99 Bottles of OOP에서 이와 비슷한 내용을 접했음. 내 스타일은 아니지만, 분기 로직을 호출 스택 최상단에 올리는 것이 유용하다는 점은 동의함. 플래그를 여러 레이어에 넘겨주던 코드베이스에선 특히 크게 느꼈음. https://sandimetz.com/99bottles
    • 동일 저자의 “The Wrong Abstraction” 글이 바로 떠올랐음. for문 내부 분기는 “for가 규칙, 분기가 동작”이라는 추상화를 만드는 것임. 그런데 새로운 요구사항이 등장하면 이런 추상화가 깨지고, 억지로 파라미터를 끼워 넣거나 예외 처리를 늘려 코드가 이해하기 어려워짐. 처음부터 추상화 없이 코드를 짰다면 결과물이 더 명확하고 유지보수하기 쉬웠을 것임. https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction