가독성을 희생하지 않고 효율적인 코드 작성

완료됨

코드 효율성과 가독성의 균형을 맞추는 것은 소프트웨어 개발자에게 중요한 기술입니다. 성능은 중요하지만 코드 명확성과 유지 관리 기능을 희생해서는 안 됩니다.

선명도를 먼저 지정하고 필요할 때 최적화

소프트웨어 엔지니어링의 지침 원칙은 먼저 코드를 정확하고 명확하게 작동하게 한 다음 필요한 경우 최적화하는 것입니다. 실제로 무엇이 필요한지 알기 전에 최적화를 시작하지 마세요.

코드가 느리다는 증거 없이 특정 코드 섹션을 최적화하려고 하면 다음 문제가 발생할 수 있습니다.

  • 코드를 이해하고 유지 관리하기 어렵게 만들기: 복잡한 최적화(특히 마이크로 최적화)는 복잡한 논리, 모호한 해킹 또는 특수 사례 코드를 도입할 수 있습니다. 향후 유지 관리자가 이를 이해하는 데 어려움을 겪거나 수정할 때 버그가 발생할 수 있습니다.

  • 시간 낭비: 다른 곳에서 더 큰 문제를 무시하면서 전반적인 성능에 무시할 수 있는 영향을 미치는 항목을 조정하는 데 몇 시간을 할애할 수 있습니다.

  • 안정성 또는 유연성 감소: 경우에 따라 극단적인 성능 조정으로 추상화 계층 또는 오류 검사가 제거됩니다. 예를 들어 C#(안전하지 않은 코드)에서 속도를 위해 포인터 산술 연산을 사용하면 성능이 약간 향상될 수 있지만 안전성 및 이식성 손실의 위험이 높습니다. 이러한 유형의 변경은 비즈니스 애플리케이션에서 정당화하기 어렵습니다.

명확한 솔루션으로 시작합니다. 개발자는 코드를 작성하는 것보다 더 자주 읽습니다. 읽기 쉬운 이름 지정, 구조 및 단순성을 강조합니다. 높은 수준의 알고리즘 선택은 제쳐두고, 많은 마이크로 최적화(예: 간단한 계산 캐싱 또는 몇 가지 CPU 주기 저장)는 명확성을 상실할 만한 가치가 없습니다. 최신 컴파일러 및 하드웨어는 간단한 코드를 효율적으로 실행하는 데 적합합니다.

최적화는 언제 의미가 있나요?

코드의 초기 버전을 작성한 후 최적화가 필요한 중요한 섹션("중요 3%")을 식별합니다. 다음은 최적화(코드가 약간 복잡해도)가 정당화되는 징후와 시나리오입니다.

  • 확인된 핫스팟: 프로파일링은 특정 메서드 또는 루프가 상당한 실행 시간을 사용했음을 보여 줍니다. 예: 하나의 함수를 프로파일하고 찾으면 프로그램 런타임의 60% 걸립니다. 최적화를 통해 해당 함수의 시간을 절반으로 줄일 수 있으면 전체적인 상당한 승리를 거둘 수 있습니다.

  • 명백한 알고리즘 비효율성: 때로는 더 간단한 접근 방식이 big-O 측면에서 훨씬 덜 효율적이라는 것을 알고 있습니다. 예를 들어, 이중 중첩 루프를 사용하여 두 개의 큰 목록의 요소를 비교하면 O(n*m)입니다. 그러나 한 목록에 해시 집합을 사용하면 이를 O(n+m)으로 줄일 수 있습니다. nm이 모두 클 수 있다면 차이는 큽니다. 이러한 경우 숙련된 개발자는 처음부터 보다 효율적인 접근 방식을 구현할 수 있습니다. 필요한 경우 "시기상조"가 아닙니다. 결정적으로, 많은 알고리즘 개선은 잘 수행되는 경우 코드를 읽기 쉽게 만들지 않습니다 (설명이 포함된 메서드 이름, 주석 등 사용).

  • 반복된 작업: 코드 조각이 가끔 실행되는 경우 작은 비효율성은 괜찮습니다. 그러나 1초에 수천 번 실행되는 경우(예: 타이트 루프 또는 고주파 서비스 호출 내부) 더 자세히 조사합니다. 예를 들어 새 개체를 생성하는 것은 괜찮을 수 있지만, 초당 100,000회 루프에서 이 작업을 수행하면(개체를 다시 사용할 수 있는 경우) 변경이 보장될 수 있습니다.

  • 성능에 중요한 도메인: 특정 도메인(예: 게임 개발, 실시간 시스템 또는 임베디드 시스템)에서는 성능 요구 사항이 엄격합니다. 여기서 개발자는 순진한 접근 방식이 요구 사항을 충족하지 못할 수 있으므로 처음부터 효율성에 대해 생각하는 경우가 많습니다. 그럼에도 불구하고 예측할 수 없는 낮은 수준의 조정보다는 알려진 패턴 및 모범 사례에 의지합니다.

목표는 데이터가 지원하는 경우 또는 도메인 컨텍스트에서 요구하는 경우를 최적화한 다음 유지 관리 가능한 방식으로 수행하는 것입니다.

가독성 및 최적화

읽기 쉬운 코드 작성과 고도로 최적화된 코드 간의 장차가 있는 경우가 많습니다. 그러나 명확성을 희생하지 않고도 많은 최적화를 달성할 수 있습니다. 가독성과 효율성 간의 균형을 보여 주는 몇 가지 예제를 살펴보겠습니다.

적절한 데이터 구조 사용

컬렉션이 있고 컬렉션에 특정 값이 포함되어 있는지 반복적으로 확인해야 한다고 가정합니다. 다음과 같은 몇 가지 옵션이 있습니다.

  • 읽기 가능하지만 효율성이 떨어집니다. 매번 반복 List<T> 하여 값을 찾습니다. 이 방법은 각 검사에 대해 O(n) 복잡도를 가지며, 코드는 기본 루프를 활용하거나 내부 반복을 수행하는 List.Contains의 사용을 통해 명확하고 이해하기 쉽게 유지됩니다.

  • 효율적이고 읽기 쉬움: 조회에 HashSet<T> 또는 Dictionary<TKey, TValue>를 사용하면 검사당 평균 시간이 O(1)이 됩니다. 조금 더 많은 코드가 있지만(HashSet을 채우고 해당 Contains 메서드를 사용함) 여전히 분명합니다. 실제로 HashSet을 사용하는 것이 더 표현적일 수 있습니다. 읽기 권한자에게 "빠른 조회가 필요합니다"라고 알려줍니다. 보다 효율적인 솔루션도 깨끗한 경우입니다.

  • 지나치게 최적화되고 읽기가 덜 한 경우: 이 특정 데이터 집합에 맞게 조정된 하위 수준 비트 조작 또는 사용자 지정 해시 알고리즘이 대체될 수 있습니다. 이 방법은 코드 유지 관리자를 혼동하고 표준 HashSet 에 비해 최소한의 성능 향상만 제공할 수 있습니다(개선 사항이 있는 경우). 프로파일링에서 기본 제공 데이터 구조가 성능 병목 상태를 생성하고 사용자 지정 구현이 실제로 필수(거의 발생하지 않음)를 보여주지 않는 한 이 전략을 피해야 합니다.

루프 확장 또는 수동 인라인 처리

경우에 따라 개발자는 루프를 언롤하거나 수동으로 코드를 인라인하여 루프 오버헤드를 절약하여 루프를 "최적화"하려고 합니다. 배열을 처리하는 루프를 고려합니다.

  • 가독성: 100개 요소로 구성된 배열을 처리하는 루프를 작성하세요. 코드는 간결하고 명확합니다. 컴파일러는 잘 최적화할 수 있으며 모든 최신 CPU는 100개의 반복을 쉽게 처리할 수 있습니다.

  • 과도하게 최적화됨: 루프 오버헤드를 피하기 위해 100개의 반복되는 문장을 작성하여 루프를 "언롤"합니다. 이 접근 방식은 몇 가지 CPU 루프 제어 주기를 절약할 수 있지만, 결과적으로 코드가 100줄의 반복 문으로 변하게 되었으며, 이는 분명히 가치가 없습니다. 만약 101개 요소로 변경한다면 유지 보수가 악몽이 될 것입니다!

  • 중요한 경우: 성능에 민감한 내부 루프(고성능 컴퓨팅 또는 알고리즘 라이브러리에 있음)의 경우 개발자는 때때로 최적화를 위해 부분 루프 언롤링을 사용합니다. 그러나 컴파일러는 일반적으로 애플리케이션 코드에서 수동 구현을 요구하지 않고 이 시나리오를 자동으로 또는 다른 최적화 메커니즘을 통해 처리합니다. 애플리케이션 개발자는 컴파일러의 최적화 기능을 사용하고 간단하고 명확한 코드를 유지 관리합니다.

C#의 문자열 연결

문자열 연결은 성능과 가독성이 충돌할 수 있는 일반적인 시나리오입니다. 문자열 연결이 반복적으로 수행되면 메서드를 선택하면 성능에 큰 영향을 줄 수 있습니다.

  • 단순한 접근 방식: 루프에서 사용. 예: 루프에 줄을 추가하여 긴 SQL 쿼리 또는 CSV를 빌드합니다. 이 기술은 읽기 쉽지만 .NET에서는 문자열마다 += 새 문자열을 만듭니다(문자열은 변경할 수 없기 때문에). 1,000번 추가하면 많은 중간 문자열 개체를 만듭니다. 이 코드는 시간과 메모리 모두에서 비효율적입니다.

  • 더 나은 방법: 여러 연결에 A StringBuilder 를 사용합니다. 이 클래스는 해당 시나리오를 위해 설계되었습니다. 버퍼에 문자열을 빌드하고 마지막에 하나의 마지막 문자열을 생성합니다. 코드는 약간 더 장황해졌습니다(Append+=대신 호출해야 합니다). 그러나 여전히 이해하기 쉽습니다. "문자열을 효율적으로 빌드하고 있다는 신호입니다."라는 것이 분명히 표시됩니다. 실제로, .NET에 대한 모범 사례 가이드는 루프 내에서 문자열을 효율적으로 연결하기 위해 StringBuilder을 사용하는 것이 좋습니다. 반복되는 문자열 연결에 사용하는 StringBuilder 것은 더 읽기 가능하고(숙련된 개발자용) 성능이 더 높습니다.

이 예제에서는 경우에 따라 작은 변경(다른 API 사용)이 가독성에 미치는 영향을 최소화하면서 큰 성능 향상을 얻을 수 있음을 보여 줍니다. 초기 방법은 작은 문자열에 대해 작동할 수 있지만 큰 입력을 적중한 경우 성능 차이가 중요합니다.

캐시 결과

캐싱은 비용이 많이 드는 함수 호출의 결과를 저장하고 동일한 입력이 다시 발생할 때 다시 사용하여 성능을 향상시킬 수 있는 일반적인 최적화 기술입니다.

  • 캐싱하지 않고: HTTP 호출을 통해 현재 환율을 가져오는 함수 GetExchangeRate(currency) 를 상상해 보십시오. 동일한 통화에 대해 반복적으로 호출하고 캐시하지 않는 경우 중복 작업(및 네트워크 I/O)을 수행합니다. 간단하지만 효율적이지 않습니다.

  • 캐싱을 사용하여 다음을 수행합니다. 페치 후 결과를 저장할 사전을 추가하면 후속 호출이 메모리에서 즉시 반환됩니다. 이 기술은 복잡성을 더하지만(캐시를 관리해야 하는 경우, 요금이 변경되면 무효화될 수 있음) 자주 요청되는 데이터의 경우 불필요한 호출을 방지하여 성능을 크게 향상시킬 수 있습니다.

캐시 결정은 사용 패턴에 따라 결정되는 경우가 많습니다. 코드는 캐시 논리를 처리해야 하므로 약간 더 복잡해집니다. 또한 정확성을 유지해야 하며(오래된 데이터, 여러 스레드에서 접근할 경우 스레드 안전성 등) 이를 확인해야 합니다. 캐싱은 성능에 대한 복잡성을 거래하는 고전적인 시나리오입니다. 캐싱은 데이터가 반복적으로 액세스될 때 상당한 성능 향상을 제공합니다.

효율성과 가독성의 균형을 맞추기 위한 모범 사례

코드의 효율성과 가독성의 균형을 맞추는 데 도움이 되는 몇 가지 모범 사례는 다음과 같습니다.

  • 알고리즘 선명도를 선호합니다. 무언가를 구현하는 방법을 선택할 때 먼저 알고리즘 복잡성(선형, 이차 등)에 대해 생각해 보세요. 코드를 왜곡하지 않고 복잡성이 좋은 디자인을 선택합니다. 알고리즘적으로 가장 우아한 솔루션은 클린 코드이기도 합니다.

  • 작업에 적합한 도구를 사용합니다. 상위 수준 언어 및 라이브러리는 사용해야 하는 최적화된 기능을 제공합니다. 예를 들어 C#의 LINQ(언어 통합 쿼리)는 특정 데이터 작업을 명확하게 표현할 수 있으며 내부적으로 합리적으로 최적화됩니다. 마찬가지로 병렬 처리 라이브러리(Parallel.ForEachPLINQ)를 사용하면 비교적 간단한 코드 구조를 유지하면서 동시에 실행할 수 있습니다. 당신이 하지 않아도 되는 경우에는 바퀴를 재발명하지 마세요.

  • 명확하지 않은 최적화에 대한 설명: 성능상의 이유로 직관적이지 않은 방식으로 작업을 수행하는 경우 이유를 설명하는 설명을 추가합니다. 예: "여기서 수동 개체 풀을 사용하여 가비지 수집 압력을 줄입니다. 이 메서드는 타이트 루프에서 호출되고 자주 할당할 여유가 없기 때문입니다." 주석을 추가하면 향후 독자(그리고 6개월 후)가 코드가 왜 그렇게 되는지 기억하는 데 도움이 됩니다.

  • 증분 개선: 간단한 디자인으로 시작한 다음 필요한 부분을 증분 방식으로 개선할 수 있습니다. 코드 동작이 변경되지 않도록 항상 최적화된 버전의 코드를 원래 버전과 비교합니다. 버전 제어는 필요한 경우 변경 내용을 롤백하는 데 도움이 될 수 있습니다.

  • 속도에 대한 안전/보안을 손상시키지 마세요. 예를 들어 입력 유효성 검사 또는 오류 처리를 건너뛰면 코드 실행 속도가 조금 더 빨라질 수 있지만 장만할 만한 가치가 거의 없습니다. 견고성이 더 중요합니다. 코드의 정확성 또는 보안을 훼손하지 않는 최적화를 목표로 합니다.

조기 최적화 방지

실제 병목 현상이 어디에 있는지 알기 전에 코드를 최적화하기 위해 서두르는 것이 일반적인 문제입니다.

실제로는,

  • 사운드 구조로 코드를 깔끔하게 작성합니다.
  • 어떤 부분이 병목 상태인지 확인합니다.
  • 유지 관리 가능한 방식으로 해당 부분을 최적화하고 개선 사항을 확인합니다.

이 방법을 사용하면 중요한 사항에 시간을 할애하고 코드베이스를 효율적이고 정상 상태로 유지할 수 있습니다.

요약

효율적인 코드를 작성해도 가독성을 희생할 필요는 없습니다. 먼저 명확성의 우선 순위를 지정하고 증거에 따라 최적화하면 성능과 유지 관리 기능을 모두 제공하는 균형을 달성할 수 있습니다. 적절한 데이터 구조를 사용하고, 기본 제공 라이브러리를 구현하고, 최적화를 신중하게 적용합니다. 항상 눈에 띄지 않는 선택을 문서화하고 조기 최적화를 방지합니다. 이러한 균형 잡힌 접근 방식은 시간의 테스트를 나타내는 강력하고 효율적이며 이해할 수 있는 코드로 이어집니다.