Go 언어에서의 에러 핸들링 코드를 보면서, 신기한 점을 발견했다. 아래 Go 코드를 보자.
package main
import (
"fmt"
"net/http"
"io/ioutil"
"errors"
)
// ViewModel을 대신할 함수
func ButtonClickHandler(ch chan string) {
// HTTP 요청 로직
resp, err := http.Get("https://api.example.com/data")
if err != nil {
ch <- fmt.Sprintf("HTTP 요청 에러: %s", err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
ch <- fmt.Sprintf("서버 에러: %s", resp.Status)
return
}
// Response 처리
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
ch <- fmt.Sprintf("응답 읽기 에러: %s", err.Error())
return
}
// 화면에 보여줄 데이터 (이 경우, 콘솔 출력)
ch <- fmt.Sprintf("성공: %s", string(body))
}
func main() {
// 사용자가 버튼을 클릭한다고 가정
messageChannel := make(chan string)
go ButtonClickHandler(messageChannel)
// 에러 핸들링과 화면에 보여주는 데이터 처리
message := <-messageChannel
fmt.Println(message)
}
이 전체 코드 중에서 아래 부분을 이야기하고 싶다.
resp, err := http.Get("https://api.example.com/data")
if err != nil {
ch <- fmt.Sprintf("HTTP 요청 에러: %s", err.Error())
return
}
나도 Go 언어는 할 줄 모르지만, 이정도는 이해가 된다.
http.Get(...)
메서드를 통해서 HTTP 통신을 하고, 그 결과 값으로 2 개의 값을 받고 있다.
resp, err
이 두 값 중에서 만약에 에러가 있다면(= if err != nil
) 그에 맞는 처리를 하고, 스텍 메모리에서 할당해제 된다.
Go 언어의 방식을 Result
타입을 통한 에러 핸들링이라고 칭하겠다.
내가 작명한 것이니 다른 곳에서 사용 시, 책임 못짐
Dart 언어의 방식을 try ~ catch
를 통한 에러 핸들링이라고 칭하겠다.
Dart 언어 방식에서도 Go 언어 방식처럼 처리가 가능하다. 바로 Result
타입을 정의하거나 Map
을 활용하면 된다.
Go 언어 스타일도 자세히 보면 2 가지 구현 방법이 있다.
1. Result Type
을 정의하여 구현하는 방법
2. Map
을 활용하여 구현하는 방법
1. Result Type
을 정의하여 구현하는 방법
가장 먼저, Result 객체를 정의한다.
class Result<T> {
final T? value;
final String? error;
Result({this.value, this.error});
}
T
로 제네릭 타입을 정의한다.immutable
하도록 final 로 값들을 정의한다.이후에 예시용 메서드를 하나 정의한다.
Result<int> divied(int a, int b) {
if (b == 0) {
return Result(error: '0 으로 나눌 수 없음...');
}
return Result(value: a ~/ b);
}
Result<int>
객체를 리턴하고 있다. 이제 마지막으로 호출부의 코드를 보자.
1) 정상적으로 호출된 케이스
void main() {
var result1 = divied(10, 2);
if (result1.error) != null) {
print('Error: ${result1.error}');
return;
}
print('Success: ${result1.value}');
}
Success: 5
2) 에러가 호출된 케이스
void main() {
var result2 = divied(10, 0);
if (result1.error) != null) {
print('Error: ${result1.error}');
return;
}
print('Success: ${result1.value}');
}
Error: 0 으로 나눌 수 없음...
2. Map
을 활용하여 구현하는 방법
메서드 정의 부분이 변경되었습니다.
Map<String, dynamic> divide(int a, int b) {
if (b == 0) return {'error': '0 으로 나눌 수 없음..'};
final value = a ~/ b;
return {'value': value};
}
호출부는 아래와 같습니다.
1) 정상적으로 호출된 케이스
void main() {
final result1 = dived(10, 2);
if (result1.cotainKey('error')) {
print('Error: ${result1['error']}');
return;
}
print('Success: ${result1.value}');
}
2) 에러가 호출된 케이스
void main() {
final result2 = dived(10, 0);
if (result1.cotainKey('error')) {
print('Error: ${result2['error']}');
return;
}
print('Success: ${result2.value}');
}
두 방식 모두, 결론적으로 리턴 값으로 "결과 & 에러" 를 보내고 있다. 호출부에서는 에러가 오면 어떤식으로 처리할 지, 그리고 결과값이 오면 어떤식으로 처리할지 고민하면 끝이다. 이것은 try ~ catch
라고 다를바 없다.
Dart 언어 스타일은, 내맘대로 작성 안하고, 공식문서의 예시를 가져오겠다.
공식문서 링크
try {
breedMoreLlamas();
} on OutOfLlamasException {
// A specific exception
buyMoreLlamas();
} on Exception catch (e) {
// Anything else that is an exception
print('Unknown exception: $e');
} catch (e) {
// No specified type, handles all
print('Something really unknown: $e');
}
에러 유발가능성이 있는 코드는 이곳이다.
try {
breedMoreLlamas();
}
이곳에서 런타임 에러가 발생하게 되면, 메모리에 breedMoreLlamas()
가 할당되어 있을 것이다. 그러나 함수 중간에 에러가 발생되면서, throw Exception(...)
이 호출되었을 것이다. 그러면 현재 breedMore...
메서드에서 외부로 나와서 on
키워드나 catch
키워드를 찾아 가야 한다. (이것을 Stack Unwinding
이라고 부른다. 함수 외부에서 에러 핸들링하는 코드를 찾아가기 위해, 스택을 되감아서 찾아가는 방식)
위 코드를 의사코드로 바꿔보면, 다음과 같다.
try {
// 오류가 발생할 수 있는 코드
} on (type1) {
// type1 형식의 오류를 처리하는 코드
} on (type2) {
// type2 형식의 오류를 처리하는 코드
} ...
finally {
// 오류가 발생하든 안 하든 항상 실행되는 코드
}
이 의사 코드는 DartLangSpeckDraft 에 있는 아래 코드를 내맘대로 바꿔본 것이다.
p206 에 매칭을 통한 에러 핸들링을 설명해주고 있다.
조금더 위에 보면, 논리적인 구조로만 tryStatement
를 설명하고 있다. 각 기호가 정확히 무슨 뜻인지는 몰라도, 논리적인 관계정도는 대충 보일 것이다.
다시 본론으로 돌아와서, Dart 언어에서 가장 기본적으로 에러를 다루는 방식은 try ~ catch
이다.
당연히, 그런 것은 없지만, 이렇게 말하는 방식을 안좋아해서 굳이 하나를 선택하자면
Dart 언어에서 공식적으로 추천하는 방식인 try ~ catch
를 추천한다.
Future
타입에서 Stream
타입으로 변경되어도 손쉽게 .catchError
와 같은 오퍼레이터를 사용할 수 있다. 혹은, 에러 핸들링 타입을 통일하기 용이하다.물론 이전에, Swift 언어로 iOS 했을 때는 Result
타입이 언어 레벨에시 지원해주다보니, 사용하곤 했다. 이 또한 장점이 있다.
둘 다 장단점이 있는데 나는 왜 try ~ catch 냐. 아래 이유와 같다.
공식문서의 공신력에 따른다.
굳이 혼자서 어긋나게 코드를 작성할 이유가 없다. try ~ catch 가 치명적인 불편함이 있지 않는 한, 사용하는 게 동료를 위해서도 좋다고 생각한다.
Exception 정의 자체가 하나의 문서화 역할을 한다.
Exception 을 생성해야하므로 보일러 플레이트 코드가 생겨난다고 말하는 사람도 있지만, 나는 생각이 다르다. 오히려 그 클래스 정의 자체가 문서화라고 생각한다. 프로젝트 내부에 문서화를 한다고 생각하면 오히려 장점으로 다가온다.
코드의 구조 자체가 실수를 줄여준다.
try 만 코드를 작성하면, 컴파일 에러가 발생한다. catch 에 대한 코드르 정의해달라고. 하지만 Result 로 다뤘을 시, 상당히 지저분해 진다. (물론 Dart 언어의 경우, Swift 는 enum - switch 를 통해서 좀 더 자유롭긴 함)
성능적인 차이가 메이저하지 않다면, 가독성의 문제 이다. 하지만 가독성을 정의하는 기준이 각양각색이다. 어떤 누구는 Clean Code를 바이블처럼 한 문장 한 문장을 몸에 새기듯 공부하는 사람도 있는 반면에, 포프TV - 클린코드 때문에 취업 실패한 썰 처럼, CleanCode에 대해서 다른 견해를 가진 개발자도 있다.
cf) 또 다른 클린 코드 글: Goodbye, Clean Code
몇 몇 글만 인용하자면, 아래와 같습니다.
전체 요약)
클린 코드와 중복 제거에 대해 논의하고 있다.
작성자는 중복된 코드를 발견하고 리팩토링을 시도하였지만,
팀 리더로부터 이전 코드로 돌리라는 요구를 받았다.
작성자는 코드의 깔끔함에 집착하는 것은 좋지만 완벽한 개선이란 없다는 것을 깨달았다.
또한, 코드 변경에 있어 팀원들과 의논하고 신뢰를 만들어야 한다는 것을 배웠다.
클린 코드는 좋지만 클린 코드에 너무 집착하면 중요한 변화들을 놓칠 수 있다는 점을 기억해야 한다.
그러면, 이럴 때마다 어떤 것을 기준으로 코드를 작성해야 할까? 이 고민은 아직도 하고 있다. 그 중 요즘 눈에 들어온 책과 언어가 몇 개 있다. 소개하고 글을 마무리 한다.
Go Language
심플함을 극한으로 추구하고 있는 언어이다. 이 언어에서 오는 규칙들을 다른 언어에 100% 적용할 이유는 없겠지만, 가독성을 논의할 때, 좋은 래퍼런스가 될 것 같다.
Five Lines of Code(파이브 라인스 오브 코드)
이 책은 크레이에티드 프로그래머 책에서 나온 "제약 조건" 을 보면서 다시 떠오른 책이다. 스스로 제약조건부과 했을 때, (적절한 강도의 제약조건) 오히려 창의적인 문제 해결 접근 방법이 떠오른다는 내용이다. 이 책에서 가독성을 생각할 때, 특정 조건을 통해(Lint 라고 보면됨) 가독성을 다루는 것도 좋아 보인다.
만약 헤밍웨이가 자바스크립트로 코딩한다면
개인적으로 가장 마음에 드는 책이긴 하다. 옳은지 좋은지 실용적인지 등등의 관점을 떠나서 나는 코드의 가독성이 그냥 읽기 좋은 글이였으면 좋겠다. 위에서부터 아래로 읽으면 하나하나 읽기 쉽다. 주석을 많이 써도 좋다. 그런 것보다는 읽기 쉬운 가독성 하나에 초점을 맞췄으면 좋겠는 개인적인 생각은 있다.