깨끗한 코드와 예외 처리 기술

게시 됨: 2022-03-11

예외는 프로그래밍 자체만큼이나 오래되었습니다. 프로그래밍이 하드웨어에서 또는 저수준 프로그래밍 언어를 통해 수행되던 시절에는 프로그램의 흐름을 변경하고 하드웨어 오류를 피하기 위해 예외가 사용되었습니다. 오늘날 Wikipedia는 예외를 다음과 같이 정의합니다.

특별한 처리가 필요한 비정상적이거나 예외적인 조건 – 종종 프로그램 실행의 정상적인 흐름을 변경…

그리고 그것들을 다루기 위해서는 다음이 필요합니다:

특수 프로그래밍 언어 구조 또는 컴퓨터 하드웨어 메커니즘.

따라서 예외는 특별한 처리가 필요하며 처리되지 않은 예외는 예기치 않은 동작을 유발할 수 있습니다. 결과는 종종 장관입니다. 1996년에 유명한 Ariane 5 로켓 발사 실패는 처리되지 않은 오버플로 예외로 인해 발생했습니다. History's Worst Software Bugs에는 처리되지 않거나 잘못 처리된 예외로 인해 발생할 수 있는 몇 가지 다른 버그가 포함되어 있습니다.

시간이 지남에 따라 이러한 오류와 수많은 다른 오류(아마도 극적이지는 않았지만 관련된 사람들에게는 여전히 치명적일 수 있음)는 예외가 나쁘다 는 인상에 기여했습니다.

그러나 예외는 현대 프로그래밍의 기본 요소입니다. 그들은 우리 소프트웨어를 더 좋게 만들기 위해 존재합니다. 예외를 두려워하기보다는 예외를 포용하고 혜택을 받는 방법을 배워야 합니다. 이 기사에서는 예외를 우아하게 관리하는 방법과 이를 사용하여 유지 관리가 더 쉬운 깨끗한 코드를 작성하는 방법에 대해 설명합니다.

예외 처리: 좋은 일입니다

객체 지향 프로그래밍(OOP)의 부상으로 예외 지원은 현대 프로그래밍 언어의 중요한 요소가 되었습니다. 오늘날 대부분의 언어에는 강력한 예외 처리 시스템이 내장되어 있습니다. 예를 들어 Ruby는 다음과 같은 일반적인 패턴을 제공합니다.

 begin do_something_that_might_not_work! rescue SpecificError => e do_some_specific_error_clean_up retry if some_condition_met? ensure this_will_always_be_executed end

이전 코드에는 문제가 없습니다. 그러나 이러한 패턴을 과도하게 사용하면 코드 냄새가 날 수 있으며 반드시 유익한 것은 아닙니다. 마찬가지로, 이를 오용하면 실제로 코드 기반에 많은 해를 입히고 깨지기 쉽게 만들거나 오류의 원인을 난독화할 수 있습니다.

예외를 둘러싼 낙인은 종종 프로그래머로 하여금 상실감을 느끼게 합니다. 예외를 피할 수 없는 것은 삶의 사실이지만, 우리는 종종 예외를 신속하고 단호하게 처리해야 한다고 가르칩니다. 우리가 보게 되겠지만, 이것이 반드시 사실은 아닙니다. 오히려 우리는 예외를 우아하게 처리하여 나머지 코드와 조화롭게 만드는 기술을 배워야 합니다.

다음은 예외를 수용하고 예외를 활용하여 코드를 유지 관리 하고 확장 가능 하고 가독성 있게 유지하는 데 도움이 되는 몇 가지 권장 사례입니다.

  • 유지 보수성 : 현재 기능을 중단하거나 추가 버그를 도입하거나 시간이 지남에 따라 증가하는 복잡성으로 인해 코드를 완전히 포기해야 하는 두려움 없이 새로운 버그를 쉽게 찾고 수정할 수 있습니다.
  • 확장성 : 기존 기능을 손상시키지 않고 새로운 요구 사항이나 변경된 요구 사항을 구현하여 코드 기반에 쉽게 추가할 수 있습니다. 확장성은 유연성을 제공하고 코드 기반에 대해 높은 수준의 재사용성을 가능하게 합니다.
  • 가독성 : 코드를 파는데 너무 많은 시간을 들이지 않고도 코드를 쉽게 읽고 목적을 찾을 수 있습니다. 이는 버그와 테스트되지 않은 코드를 효율적으로 발견하는 데 중요합니다.

이러한 요소는 우리가 청결 또는 품질 이라고 부를 수 있는 주요 요소입니다. 이는 직접적인 측정 자체가 아니라 이 만화에서 보여주듯이 이전 요점의 결합된 효과입니다.

Thom Holwerda의 "WTFs/m", OSNews

즉, 이러한 관행에 대해 자세히 알아보고 각각이 세 가지 조치에 어떤 영향을 미치는지 살펴보겠습니다.

참고: Ruby의 예를 제시하지만 여기에서 설명하는 모든 구성은 가장 일반적인 OOP 언어로 된 것과 동일합니다.

항상 고유한 ApplicationError 계층을 생성하십시오.

대부분의 언어에는 다른 OOP 클래스와 마찬가지로 상속 계층 구조로 구성된 다양한 예외 클래스가 있습니다. 코드의 가독성, 유지 관리 가능성 및 확장성을 유지하려면 기본 예외 클래스를 확장하는 응용 프로그램별 예외의 하위 트리를 만드는 것이 좋습니다. 이 계층 구조를 논리적으로 구성하는 데 시간을 투자하면 매우 유용할 수 있습니다. 예를 들어:

 class ApplicationError < StandardError; end # Validation Errors class ValidationError < ApplicationError; end class RequiredFieldError < ValidationError; end class UniqueFieldError < ValidationError; end # HTTP 4XX Response Errors class ResponseError < ApplicationError; end class BadRequestError < ResponseError; end class UnauthorizedError < ResponseError; end # ... 

애플리케이션 예외 계층의 예: StandardError가 맨 위에 있습니다. ApplicationError는 이를 상속합니다. ValidationError 및 ResponseError는 모두 이를 상속합니다. RequiredFieldError 및 UniqueFieldError는 ValidationError에서 상속되는 반면 BadRequestError 및 UnauthorizedError는 ResponseError에서 상속됩니다.

응용 프로그램에 대한 확장 가능하고 포괄적인 예외 패키지를 사용하면 이러한 응용 프로그램별 상황을 훨씬 쉽게 처리할 수 있습니다. 예를 들어, 보다 자연스러운 방식으로 처리할 예외를 결정할 수 있습니다. 이는 코드의 가독성을 높일 뿐만 아니라 애플리케이션 및 라이브러리(보석)의 유지 관리 가능성도 높입니다.

가독성 관점에서 보면 훨씬 읽기 쉽습니다.

 rescue ValidationError => e

읽는 것보다 :

 rescue RequiredFieldError, UniqueFieldError, ... => e

예를 들어 유지 관리 측면에서 JSON API를 구현하고 있으며 클라이언트가 잘못된 요청을 보낼 때 사용할 여러 하위 유형으로 자체 ClientError 를 정의했습니다. 이들 중 하나가 발생하면 애플리케이션은 응답에서 오류의 JSON 표현을 렌더링해야 합니다. 가능한 각 클라이언트 오류를 ​​반복하고 각각에 대해 동일한 핸들러 코드를 구현하는 것보다 ClientError 를 처리하는 단일 블록에 논리를 추가하거나 수정하는 것이 더 쉽습니다. 확장성 면에서 나중에 다른 유형의 클라이언트 오류를 ​​구현해야 하는 경우 여기에서 이미 적절하게 처리될 것이라고 믿을 수 있습니다.

더욱이 이것은 호출 스택의 초기에 특정 클라이언트 오류에 대한 추가 특수 처리를 구현하거나 도중에 동일한 예외 개체를 변경하는 것을 방지하지 않습니다.

 # app/controller/pseudo_controller.rb def authenticate_user! fail AuthenticationError if token_invalid? || token_expired? User.find_by(authentication_token: token) rescue AuthenticationError => e report_suspicious_activity if token_invalid? raise e end def show authenticate_user! show_private_stuff!(params[:id]) rescue ClientError => e render_error(e) end

보시다시피, 이 특정 예외를 발생시키는 것은 우리가 그것을 다른 수준에서 처리하고, 변경하고, 다시 발생시키고, 상위 클래스 핸들러가 해결하도록 허용하는 것을 막지 못했습니다.

여기서 주목해야 할 두 가지:

  • 모든 언어가 예외 처리기 내에서 예외 발생을 지원하는 것은 아닙니다.
  • 대부분의 언어에서 핸들러 내에서 예외를 발생시키면 원래 예외가 영원히 손실되므로 동일한 예외 객체를 다시 발생시켜(위의 예에서와 같이) 원래 원인을 추적하는 것을 방지하는 것이 좋습니다. 오류. (의도적으로 하지 않는 한).

절대 rescue Exception

즉, 기본 예외 유형에 대한 포괄 처리기를 구현하지 마십시오. 모든 예외를 전체적으로 구하거나 잡는 것은 기본 응용 프로그램 수준에서 전 세계적으로 또는 한 번만 사용되는 작은 묻힌 방법에 관계없이 모든 언어에서 결코 좋은 생각이 아닙니다. Exception은 실제로 발생한 모든 것을 난독화하여 유지 관리 가능성과 확장성을 모두 손상시키기 때문에 Exception 을 구하고 싶지 않습니다. 구문 오류만큼 간단할 수 있지만 실제 문제가 무엇인지 디버깅하는 데 엄청난 시간을 낭비할 수 있습니다.

 # main.rb def bad_example i_might_raise_exception! rescue Exception nah_i_will_always_be_here_for_you end # elsewhere.rb def i_might_raise_exception! retrun do_a_lot_of_work! end

이전 예제에서 오류를 발견했을 수 있습니다. return 이 잘못 입력되었습니다. 최신 편집기는 이러한 특정 유형의 구문 오류에 대해 어느 정도 보호 기능을 제공하지만 이 예제에서는 rescue Exception 가 코드에 해를 끼치는 방법을 보여줍니다. 어떤 시점에서도 예외의 실제 유형(이 경우 NoMethodError )이 해결되지 않았으며 개발자에게 노출되지도 않았습니다. 이로 인해 서클에서 실행하는 데 많은 시간을 낭비할 수 있습니다.

필요한 것보다 더 많은 예외를 rescue 마십시오.

이전 요점은 이 규칙의 특정 경우입니다. 예외 처리기를 과도하게 일반화하지 않도록 항상 주의해야 합니다. 이유는 동일합니다. 우리가 해야 하는 것보다 더 많은 예외를 구출할 때마다 우리는 예외를 스스로 처리하는 개발자의 능력을 억제하는 것은 물론이고 더 높은 수준의 애플리케이션에서 애플리케이션 로직의 일부를 숨기게 됩니다. 이는 코드의 확장성과 유지 관리 가능성에 심각한 영향을 미칩니다.

동일한 핸들러에서 다른 예외 하위 유형을 처리하려고 하면 너무 많은 책임이 있는 뚱뚱한 코드 블록이 도입됩니다. 예를 들어 원격 API를 사용하는 라이브러리를 빌드하는 경우 MethodNotAllowedError (HTTP 405)를 처리하는 것은 둘 다 ResponseError 임에도 불구하고 일반적으로 UnauthorizedError (HTTP 401)를 처리하는 것과 다릅니다.

우리가 보게 되겠지만, 종종 더 건조한 방식으로 특정 예외를 처리하는 데 더 적합한 애플리케이션의 다른 부분이 존재합니다.

따라서 클래스 또는 메서드의 단일 책임을 정의하고 이 책임 요구 사항을 충족하는 최소한의 예외를 처리합니다 . 예를 들어 메서드가 원격 API에서 주식 정보를 가져오는 책임이 있는 경우 해당 정보만 가져올 때 발생하는 예외를 처리하고 다른 오류 처리는 이러한 책임을 위해 특별히 설계된 다른 메서드에 맡겨야 합니다.

 def get_info begin response = HTTP.get(STOCKS_URL + "#{@symbol}/info") fail AuthenticationError if response.code == 401 fail StockNotFoundError, @symbol if response.code == 404 return JSON.parse response.body rescue JSON::ParserError retry end end

여기에서 주식에 ​​대한 정보만 얻기 위해 이 메서드에 대한 계약을 정의했습니다. 불완전하거나 형식이 잘못된 JSON 응답과 같은 엔드포인트별 오류 를 처리합니다. 인증에 실패하거나 만료된 경우 또는 재고가 없는 경우에는 처리하지 않습니다. 이는 다른 사람의 책임이며 DRY 방식으로 이러한 오류를 처리할 수 있는 더 나은 위치가 있어야 하는 호출 스택 위로 명시적으로 전달됩니다.

예외를 즉시 처리하려는 충동을 억제하십시오.

이것은 마지막 요점에 대한 보완입니다. 예외는 호출 스택의 모든 지점과 클래스 계층의 모든 지점에서 처리될 수 있으므로 이를 처리할 위치를 정확히 아는 것은 이해하기 어려울 수 있습니다. 이 난제를 해결하기 위해 많은 개발자는 예외가 발생하는 즉시 처리하기로 선택하지만, 이를 고려하는 데 시간을 투자하면 일반적으로 특정 예외를 처리하기에 더 적절한 장소를 찾게 됩니다.

Rails 애플리케이션(특히 JSON 전용 API를 노출하는 애플리케이션)에서 볼 수 있는 한 가지 일반적인 패턴은 다음 컨트롤러 메서드입니다.

 # app/controllers/client_controller.rb def create @client = Client.new(params[:client]) if @client.save render json: @client else render json: @client.errors end end

(기술적으로 예외 처리기는 아니지만 @client.save 는 예외가 발생했을 때만 false를 반환하므로 기능적으로 동일한 목적을 수행합니다.)

그러나 이 경우 모든 컨트롤러 작업에서 동일한 오류 처리기를 반복하는 것은 DRY의 반대이며 유지 관리 및 확장성을 손상시킵니다. 대신에 예외 전파의 특별한 특성을 활용하고 상위 컨트롤러 클래스인 ApplicationController 에서 한 번만 처리할 수 있습니다.

 # app/controllers/client_controller.rb def create @client = Client.create!(params[:client]) render json: @client end
 # app/controller/application_controller.rb rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity def render_unprocessable_entity(e) render \ json: { errors: e.record.errors }, status: 422 end

이렇게 하면 모든 ActiveRecord::RecordInvalid 오류가 기본 ApplicationController 수준의 한 곳에서 적절하고 DRY 방식으로 처리되도록 할 수 있습니다. 이것은 우리가 낮은 수준에서 특정 사례를 처리하거나 단순히 우아하게 전파되도록 하려는 경우 그것들을 조작할 수 있는 자유를 줍니다.

모든 예외를 처리할 필요는 없습니다.

gem이나 라이브러리를 개발할 때 많은 개발자는 기능을 캡슐화하려고 시도하고 라이브러리 외부로 전파되는 예외를 허용하지 않습니다. 그러나 때로는 특정 응용 프로그램이 구현될 때까지 예외를 처리하는 방법이 명확하지 않습니다.

ActiveRecord 를 이상적인 솔루션의 예로 들어 보겠습니다. 라이브러리는 개발자에게 완전성을 위한 두 가지 접근 방식을 제공합니다. save 메소드는 예외를 전파하지 않고 처리하고 저장하는 동안 단순히 false 를 반환합니다 save! 실패하면 예외가 발생합니다. 이를 통해 개발자는 특정 오류 사례를 다르게 처리하거나 단순히 일반적인 방식으로 모든 오류를 처리할 수 있습니다.

그러나 그러한 완전한 구현을 제공할 시간이나 자원이 없다면 어떻게 하시겠습니까? 이 경우 불확실성이 있으면 예외를 노출하고 야생으로 내보내는 것이 가장 좋습니다.

이유는 다음과 같습니다. 우리는 거의 항상 요구 사항을 이동하는 작업을 하고 있으며 예외가 항상 특정 방식으로 처리되도록 결정하면 실제로 구현에 해를 끼치고 확장성과 유지 관리 가능성이 손상될 수 있으며 특히 개발할 때 막대한 기술적 부채가 추가될 수 있습니다. 도서관.

주가를 가져오는 스톡 API 소비자의 이전 예를 살펴보겠습니다. 우리는 불완전하고 형식이 잘못된 응답을 그 자리에서 처리하기로 선택했고 유효한 응답을 얻을 때까지 동일한 요청을 다시 시도하기로 선택했습니다. 그러나 나중에 요구 사항이 변경되어 요청을 재시도하는 대신 저장된 과거 주식 데이터로 대체해야 합니다.

이 시점에서 종속 프로젝트가 이 예외를 처리하지 않기 때문에 이 예외가 처리되는 방식을 업데이트하면서 라이브러리 자체를 변경해야 합니다. (어떻게 그럴 수 있습니까? 이전에는 한 번도 노출된 적이 없었습니다.) 우리는 또한 우리 라이브러리에 의존하는 프로젝트 소유자에게 알려야 합니다. 이러한 프로젝트가 많은 경우 이 오류가 특정 방식으로 처리될 것이라는 가정 하에 구축되었을 가능성이 높기 때문에 악몽이 될 수 있습니다.

이제 종속성 관리로 어디로 향하고 있는지 알 수 있습니다. 전망이 좋지 않습니다. 이러한 상황은 매우 자주 발생하며 라이브러리의 유용성, 확장성 및 유연성을 저하시키는 경우가 많습니다.

결론은 다음과 같습니다. 예외를 처리하는 방법이 명확하지 않은 경우 정상적으로 전파되도록 하십시오 . 예외를 내부적으로 처리할 수 있는 명확한 장소가 있는 경우가 많지만 예외를 노출하는 것이 더 나은 경우도 많습니다. 따라서 예외 처리를 선택하기 전에 다시 생각해 보십시오. 경험상 좋은 규칙은 최종 사용자와 직접 상호 작용할 때만 예외 처리를 주장 하는 것입니다.

규칙을 따르십시오

Ruby와 Rails의 구현은 method_namesmethod_names! "쾅"하고. Ruby에서 bang은 메서드가 이를 호출한 객체를 변경한다는 것을 나타내고, Rails에서는 예상 동작을 실행하지 못하면 메서드가 예외를 발생시킨다는 의미입니다. 특히 라이브러리를 오픈 소스로 만들려는 경우 동일한 규칙을 따르십시오.

새로운 method! Rails 애플리케이션의 경우 이러한 규칙을 고려해야 합니다. 이 방법이 실패할 때 우리가 예외를 발생시키도록 강요하는 것은 없지만, 이 방법은 관례에서 벗어나서 실제로는 그렇지 않을 때 예외를 스스로 처리할 기회가 주어질 것이라고 믿도록 프로그래머를 오도할 수 있습니다.

Jim Weirich의 또 다른 Ruby 규칙은 메서드 실패를 나타내기 위해 fail 을 사용하고 예외를 다시 발생시키는 경우에만 raise 를 사용하는 것입니다.

제쳐두고, 나는 예외를 사용하여 실패를 나타내기 때문에 거의 항상 Ruby에서 raise 키워드보다 fail 키워드를 사용합니다. Fail과 raise는 동의어이므로 fail이 메서드가 실패했음을 더 명확하게 전달한다는 점을 제외하고는 차이가 없습니다. 내가 raise를 사용하는 유일한 경우는 예외를 잡아서 다시 발생시킬 때입니다. 왜냐하면 여기서는 실패하지 않고 명시적으로 의도적으로 예외를 발생시키기 때문입니다. 이것은 내가 따르는 문체 문제이지만 많은 다른 사람들이 그렇게 하는지 의심스럽습니다.

다른 많은 언어 커뮤니티는 예외 처리 방식과 관련하여 이와 같은 규칙을 채택했으며 이러한 규칙을 무시하면 코드의 가독성과 유지 관리 가능성이 손상됩니다.

Logger.log(모든 것)

물론 이 관행은 예외에만 적용되는 것은 아니지만 항상 기록해야 하는 한 가지가 있다면 예외입니다.

로깅은 매우 중요합니다(Ruby가 로거를 표준 버전과 함께 제공하기에 충분히 중요합니다). 그것은 우리 응용 프로그램의 일기이며 우리 응용 프로그램의 성공 방법을 기록하는 것보다 훨씬 더 중요한 것은 실패 방법과 시기를 기록하는 것입니다.

로깅 라이브러리나 로그 기반 서비스 및 디자인 패턴은 부족하지 않습니다. 어떤 일이 일어났는지 검토하고 문제가 발생했는지 조사할 수 있도록 예외를 추적하는 것이 중요합니다. 적절한 로그 메시지는 개발자에게 문제의 원인을 직접 알려주므로 엄청난 시간을 절약할 수 있습니다.

깨끗한 코드 자신감

깔끔한 예외 처리는 코드 품질을 달로 보낼 것입니다!
트위터

예외는 모든 프로그래밍 언어의 기본적인 부분입니다. 그것들은 특별하고 매우 강력하며 우리는 그들과 싸우는 데 지치지 않고 코드의 품질을 높이기 위해 그들의 힘을 활용해야 합니다.

이 기사에서 우리는 예외 트리를 구조화하기 위한 몇 가지 모범 사례와 논리적으로 구조화하는 것이 가독성과 품질에 어떻게 도움이 될 수 있는지 자세히 알아보았습니다. 우리는 한 곳에서 또는 여러 수준에서 예외를 처리하기 위한 다양한 접근 방식을 살펴보았습니다.

우리는 "그들을 모두 잡는" 것은 나쁘고, 그것들이 떠다니고 거품이 일어나도록 내버려 두는 것이 괜찮다는 것을 알았습니다.

DRY 방식으로 예외를 처리할 위치를 살펴보고 처음 발생했을 때 또는 어디서 예외를 처리할 의무가 없음을 배웠습니다.

우리는 그것들을 처리하는 것이 정확히 언제 좋은 생각인지, 언제 나쁜 생각인지, 그리고 의심스러울 때 전파하도록 하는 것이 좋은 이유에 대해 논의했습니다.

마지막으로 규칙을 따르고 모든 것을 기록하는 것과 같이 예외의 유용성을 극대화하는 데 도움이 될 수 있는 다른 사항에 대해 논의했습니다.

이러한 기본 지침을 통해 우리는 훨씬 더 편안하고 자신 있게 코드의 오류 사례를 처리하고 예외를 진정으로 예외적으로 만들 수 있습니다!

이 기사를 작성하는 데 많은 도움을 준 Avdi Grimm과 그의 멋진 강연 Exceptional Ruby에게 특별히 감사드립니다.

관련: Ruby 개발자를 위한 팁 및 모범 사례