대메뉴 바로가기 본문 바로가기

데이터 기술 자료

데이터 기술 자료 상세보기
제목 쓰레기를 줄이는 타입 설계 가이드 : 더 명시적이고 정확한 타입 설계하기
등록일 조회수 4442
첨부파일  

쓰레기를 줄이는 타입 설계 가이드

더 명시적이고 정확한 타입 설계하기



컴퓨터 업계에서 흔히 쓰는 말 중에 “쓰레기를 입력하면 쓰레기가 나온다(Garbage in, Garbage out)”라는 말이 있다. 입력 데이터의 중요성을 표현한 말이다. 쓰레기 입력 데이터를 줄임으로써 쓰레기 데이터를 처리하기 위한 연산 과정의 복잡도를 최소화할 수 있고, 그를 통해 자연스럽게 쓰레기 출력 데이터도 줄어들게 된다. 이번 시간에는 데이터 타입 설계의 중요성에 대해 알아본다.



더 명시적이고 더 정확한 타입 설계가 왜 필요할까? 기존 타입 설계 관행과 차이점이 무엇일까? 그리고 이러한 타입을 설계하려면 우리가 알고 있는 프로그래밍 언어로 어떻게 표현할 수 있을까? 이러한 궁금증을 하나씩 풀어가 보자.



명시적인 타입 설계

“명시적(明示的, explicit)”란 뜻을 국립국어원 표준국어대사전에서 살펴보면 “내용이나 뜻을 분명하게 드러내 보이는, 또는 그런 것”이라고 기술하고 있다. 타입을 설계하는 개발자 입장에서 명시적이라는 것은 타입을 통해 무엇을 하고자 하는지 분명하게 표현하는 것이다. 명시적의 반대말은 ‘암시적(暗示的, implicit)’이다. 암시적이란 내용이나 뜻을 간접적으로 표현할 때 쓰는 말이다. <리스트 1>의 코드를 통해 명시적, 암시적 타입 사용에 대한 차이를 확인해 보자.



<리스트 1> 이메일 데이터 string email;



<리스트 1>을 통해서 우리는 문자열형으로 이메일 데이터를 표현한다는 것을 알 수 있다. 왜냐면 이메일 변수가 문자열형을 나타내는 string 타입으로 표현돼 있기 때문이다. 변수 이름 email을 통해 문자열형에 단순히 이메일이라는 의미를 부여한 것밖에 없다. 문자열형은 데이터가 여러 문자로 구성돼 있다는 사실과 문자와 관련된 연산, 예를 들어 문자를 연결하거나 공백 문자를 제거하는 등의 문자와 관련된 메소드를 제공한다는 사실만을 확인할 수 있다.

<리스트 1>을 통해서 개발자의 의도가 표현된 정보는 “이메일 데이터는 여러 문자로 구성되어 있다” 그리고 “이메일 데이터는 문자 관련 메소드를 통해 데이터값을 변경할 수 있다”이다. 전체 문맥에서 이메일 데이터가 왜 필요한지 그리고 이메일 데이터로 무엇을 하고자 하는 것인지 전혀 표현돼 있지 않다. 이러한 내용을 확인하기 위해 이메일 데이터가 사용되는 곳곳을 찾아다니면서 확인해야 한다. 이러한 코드를 우리는 “명시적이지 않다”라고 이야기한다.

이러한 코드를 더 명시적으로 표현하기 위해서는 string 타입이 아닌 왜(Why) 이메일 데이터가 필요한지, 이메일 데이터로 무엇(What)을 하고자 하는지를 표현할 수 있는 타입이 필요하다. string이라는 기본 타입(primitive type)으로는 표현할 수 없는 정보들이다. 닷넷(.NET) 계열 언어들은 <리스트 2>와 같이 속성(attribute)을 이용해 타입에 추가적인 정보를 전달하기도 한다.



<리스트 2> 속성을 이용한 타입 정보 추가 using System.ComponentModel.DataAnnotations; [Required] string Email { get; set; }



<리스트 2>와 같이 속성을 이용해 타입에 대한 부가적인 제약 조건을 추가할 수 있지만, 여전히 기본적인 문자열형에 제공하는 모든 메소드를 사용할 수 있다. 명시적으로 이메일 데이터와 관련된 수행 가능한 작업과 불가능한 작업이 구분돼 있지 않으므로 이메일 데이터를 사용하는 개발자 입장에서는 이메일 데이터가 아닌 문자열형 데이터로 접근해 사용할 가능성이 열려 있는 것이다. 이는 이메일 데이터를 사용하는 개발자가 오해와 시행착오를 겪을 수 있는 상황을 만든다. 그 결과 의도하지 않는 값으로 변경될 수 있다.

이러한 결과를 초래하게 된 이유는 문자열형 타입으로는 이메일 데이터에만 적용 가능한 작업을 제한할 수 없기 때문이다. 이로 인해 버그를 생성할 수 있는 가능성이 생겨난다. 좀 더 정확한 코드란 의도하지 않는 결과 즉, 버그 발생 가능성이 제거된 환경을 말한다.



TDD도 필요 없는 컴파일러의 마법

개발자들은 버그를 줄이기 위해 테스트 주도 개발(TDD: Test-Driven Development) 방식을 사용하기도 한다. 단위 테스트 코드를 작성함으로써 버그가 발생하는 것을 미리 확인하는 환경을 만드는 것이다. 그러나 테스트 주도 개발에서는 반드시 단위 테스트를 작성해야만 한다. 이러한 단위 테스트 코드 없이 우리가 작성한 코드에 버그가 없다는 것을 증명할 수는 없을까?

파랑새가 멀리 있다고 생각하지만, 사실 파랑새는 우리가 생각하는 것만큼 멀리 있지 않다. 잘 찾아보면 늘 가까운 곳에 있는 경우가 많다. 단위 테스트 없이 우리가 작성한 코드에 버그가 없다는 것을 증명할 수 있는 파랑새도 역시 개발자 옆에 있을까? 그 파랑새는 바로 컴파일러이다. 컴파일러를 단순히 물리적 바이너리 결과 파일을 만들기 위한 용도가 아닌 코드의 정확성을 검증하는 도구로 사용하는 것이다.

그러려면 우선 타입을 열린(Opened) 타입이 아닌 닫힌(Closed) 타입으로 설계해야 한다. 여기서 열린 타입이란 컴파일러가 예측할 수 없는 경우의 수를 말하고, 닫힌 타입이란 컴파일러가 예측할 수 있는 경우의 수를 말한다. 즉, 닫힌 타입으로 설계하면 컴파일러가 작성된 코드에 대해 가능한 모든 경우의 수를 컴파일 과정에서 전수 조사 형태로 검증하게 된다. 그러면 실행 중(run-time)에 발견되는 타입과 관련된 다양한 버그를 컴파일 시점(compile-time)에서 발견할 수 있다. 당연히 타입으로부터 발생하는 버그에 대한 단위 테스트 코드를 제거할 수도 있다. 테스트 주도 개발의 개념은 여전히 중요하다. 그러나 불필요한 단위 테스트는 제거하는 게 좋다.

닫힌 타입 설계 방법에 대해서는 다음 시간에 자세히 소개할 것이다. 미리 예고하면 F#, 스칼라(Scala), 하스켈(Haskell)과 같은 함수형 언어들은 기본적으로 닫힌 타입을 지향한다. 이미 이러한 함수형 언어를 접해본 개발자면 객체지향에서 어떻게 닫힌 타입을 구현하는지 알고 있을 것이다. 지금까지 더 명시적이고 더 정확한 코드를 작성해야 하는 이유에 대해서 살펴봤다. 그렇다면 왜 타입부터 명시적이고 정확해야 할까? 이를 설명하기 위해 우선 소프트웨어 개발 과정부터 확인해 보자. 소프트웨어 개발 과정을 추상화해 보면 <그림 1>과 같다.



외부와 내부 요구사항이라는 입력으로부터 분석, 설계, 개발, 테스트 등의 프로세스를 거쳐 최종 결과물을 얻게 된다. 우리는 이러한 과정에서 대부분 시간을 분석, 설계, 개발, 테스트 등이 포함된 프로세스에 투자한다. 앞서 언급했던 GIGO(Garbage in, Garbage out)라는 너무나도 유명한 용어를 한 번쯤 들어봤을 것이다. “쓰레기를 입력하면 쓰레기가 나온다”라는 뜻인데, <그림 2>와 같이 표현할 수 있다.



<그단계에서 이를 해결하기 위한 추가 작업을 진행하게 된다. 이는 불필요한 업무 로직 추가로 연결돼 복잡도가 증가하는 결과를 초래하게 된다. 이러한 문제를 해결하는 근본적인 해결책은 <그림 3>과 같이 쓰레기 입력 데이터를 줄이는 것이다.



그러면 자연스럽게 프로세스 과정에서는 입력 데이터에 안전성을 기반으로 해결해야 할 ‘문제의 본질적 복잡도(Essential Complexity)’에만 집중할 수 있게 된다. 쓰레기 입력 데이터로 인한 복잡도가 프로세스 과정에서 자연스럽게 제거된다. 그로 인해 쓰레기 출력 데이터도 줄어들게 된다. 이제 입력 데이터에 대한 중요성을 int.Parse와 int.TryPare 함수를 통해 확인해 보자. 우선 <리스트 3>을 통해 int.Parse 함수를 살펴보자. int.Parse 함수는 문자열형을 입력받아 출력으로 정수형을 돌려주는 함수이다.



<리스트 3> int.Parse 함수 public strcut Int32 { public static int Parse(string s); … } int result = int.Parse(“hello”);



만약에 <리스트 3>의 예제처럼 “hello”라는 정수형 데이터로 변환할 수 없는 쓰레기 입력 데이터를 전달했다면 함수는 어떻게 처리할까? int.Parse 함수를 사용해 본 개발자라면 이미 그 정답을 알고 있을 것이다. 이는 실행 시간에 예외를 발생시키는 결과를 초래한다. 쓰레기 입력 데이터를 처리하기 위해 발생하는 예외를 해결하기 위해 try/catch 구문을 <리스트 4>와 같이 추가한다.



<리스트 4> int.Parse 함수 예외 처리 public strcut Int32 { public static int Parse(string s); … } try { int result = int.Parse(“hello”); } catch (FormatException exp) // 불필요한 코드 { … }



<리스트 3>에서 추가된 예외 처리 코드는 문자열형을 정수형으로 변환하는 과정에서 근본적으로 필요한 작업은 아니다. 쓰레기 입력 데이터를 처리하기 위해 불필요하게 추가된 코드이다. 이 문제를 해결하기 위한 다른 대안으로 <리스트 5>와 같이 함수 시그니처와 함수 이름을 변경하는 방법이 있다.



<리스트 5> int.TryParse 함수 public strcut Int32 { public static bool TryParse(string s, out int result); … } int result = -1; if (int.TryParse("hello", out result)) // 불필요한 제어 구문 { … } else { … // result는 0 값으로 전달된다. }



<리스트 5>를 살펴보자. int.TryParse 함수를 int.Parse 함수와 비교하면 우선 함수 이름이 변경된 것을 확인할 수 있다. Parse 함수 이름 앞에 Try가 추가된 것을 확인할 수 있는데, Try라는 함수 이름을 통해 이 함수는 예외를 발생하지 않는다고 명시하고 있다. 그리고 논리형 타입으로 데이터 타입 변환 과정 성공 여부를 알려준다. 그러나 여전히 데이터 변환 과정에서 쓰레기 데이터 처리를 위한 if 제어 구문이 추가된 것을 확인할 수 있다.

이는 예외 처리가 제어 구문으로 변경된 것 외에 큰 차이가 없다. 당연히 int.Parse 함수 처리 과정에서 예외 처리는 개발자가 실수로 누락한 것에 해당한다. 이 예외는 최악의 경우 프로그램을 종료시키는 심각한 버그가 될 수도 있다. int.TryParse 함수는 예외가 발생하지 않기 때문에 직접 프로그램을 종료시키는 버그가 발생하진 않는다. 그러나 여전히 문제는 존재한다. 제어 구문 없이 result 값을 연속해서 사용하면 정확한 결과 값을 얻을 수 없다. int.TryParse 함수가 실패할 때 result 값이 ‘0’으로 초기화되기 때문이다. int.Parse와 int.TryParse 모두 데이터 타입 변환 과정에서 쓰레기 출력 데이터를 처리하기 위한 불필요한 작업이 추가된 것을 확인할 수 있다. 이러한 불필요한 작업이 추가된 원인은 앞에서 확인했듯이 쓰레기 입력 데이터를 처리하기 위해서다. int.Parse와 int.TryParse 함수에서 사용하는 입력과 출력 데이터 타입으로는 쓰레기 입력 데이터와 쓰레기 출력 데이터를 표현할 수 없으므로 쓰레기 데이터를 처리하는 방법을 다양하게 제공하고 있다.



쓰레기를 줄이는 대수적 데이터 타입

지금까지 살펴본 int.Parse와 int.TryParse 함수는 쓰레기 입력 데이터를 함수 구현 과정에서 해소하기 위해 부가적인 노력을 요구하다. 이는 문제의 본질적 복잡도 외에 추가적인 복잡도를 발생시킨다. 쓰레기 출력 데이터와 명시적으로 표현하지 않아 발생하는 문제를 처리하기 위해 불필요한 코드가 여전히 존재하는 것이다. 복잡도를 줄이고 불필요한 코드를 제거하기 위해서는 쓰레기 입력 데이터를 줄이기 위한 데이터 타입 설계로 돌아가야 한다. <리스트 6>을 통해 타입을 바라보는 객체지향 언어와 함수형 언어의 차이점을 알아보자.



<리스트 6> C# Contact 타입 public class Contact { public string FirstName { get; set; } public string MiddleInitial { get; set; } public string LastName { get; set; } public string EmailAddress { get; set; } public bool IsEmailVerified { get; set; } public string Address1 { get; set; } public string Address2 { get; set; } public string City { get; set; } public string State { get; set; } public string Zip { get; set; } public bool IsAddressValid { get; set; } }



F#에서는 클래스(class) 키워드가 아닌 타입(type) 키워드를 이용해 <리스트 6>과 같이 타입을 정의할 수 있다. 논리적으로나 물리적으로 완전히 같은 것은 아니다. 그 자세한 차이점은 앞으로 이어질 연재 글을 통해 설명하도록 하겠다. 일단 지금은 동일한 것으로 간주하자.



<리스트 7> F# Contact 타입 type Contact = { FirstName: string; MiddleInitial: string; LastName: string; EmailAddress: string; IsEmailVerified: bool; Address1: string; Address2: string; City: string; State: string; Zip: string; IsAddressValid: bool; }



<리스트 6>과 <리스트 7>에서 타입을 정의하기 위해 사용하는 키워드를 통해 타입 설계 방향이 다르다는 것을 확인할 수 있다. C#의 클래스 키워드는 타입 설계를 위한 분류(classification)를 강조한다. 객체지향이 지향하는 타입은 추상적 데이터 타입(Abstract Data Type)이며, 분류는 추상화의 대표적인 방법이기 때문이다.

객체지향은 객체의 행동을 추상화하기 위해 추상적 데이터 타입인 인터페이스를 정의하게 된다. 정의된 인터페이스 타입을 기준으로 추상화를 구현하기 위한 하위 타입을 정의한다. 정의된 하위 타입들은 상위 타입(인터페이스)에 정의된 동일한 요청(인터페이스 메소드)에 대해 서로 다른 방식으로 응답할 수 있다. 이러한 타입 설계 과정을 통해 타입은 다형성(polymorphism)을 갖게 된다. 일반적으로 이러한 하위 타입은 객체 생성 책임을 갖는 factory가 전달된 인수에 따라 실행 시간을 선택해 특정 객체를 생성하며, 생성된 구체적인 객체의 하위 타입을 숨길 수 있다.

구체적인 하위 타입을 숨기는 다양한 방법이 존재한다. 설계에 관심 있는 개발자라면 필독 도서인 < GoF의 디자인 패턴>을 읽어봤을 것이다. 이 책에서는 ‘생성 패턴’ 그룹으로 Abstract Factory, Builder, Factory Method, Prototype, Singleton 패턴을 소개하고 있다. 객체 생성 과정에서 하위 타입을 숨김으로써 변화하는 하위 타입의 영향을 받지 않게 되는데, 이는 하위 타입의 개수가 유한(有限)하지 않게 한다.

함수형 언어들은 객체지향 언어와 달리 대수적 데이터 타입(Algebraic Data Type)을 지향한다. 대수적 데이터 타입 역시 추상화를 통해 타입을 정의하지만, 추상을 구현하기 위한 하위 타입을 유한한 수로 국한한다. 대수적 데이터 타입은 하위 타입을 유한한 수로 국한하기 위해 상속을 통해 정의된 하위 타입으로부터 더 이상의 하위 타입을 가질 수 없게 한다. 이는 C#의 ‘sealed’, 자바(Java)의 ‘final’ 키워드와 같이 클래스를 정의한 것과 비슷하다.

이처럼 대수적 데이터 타입을 지향하는 함수형 언어들은 닫힌 타입을 기반으로 개발하게 된다. 또한, 대수적 데이터 타입은 추상적 데이터 타입과 달리 추상을 구현한 하위 타입을 숨기지 않는다. 지금까지 살펴본 내용을 요약해 보면 <그림 4>와 같다.



이러한 대수적 데이터 타입이 컴파일러의 도움을 받으려면 타입 기반 논리 흐름(Logic Flow)을 표현(Expression)할 수 있는 패턴 매칭(Pattern Matching) 개념이 필요하다. C#은 아직 언어적으로 패턴 매칭을 제공하지 않는다. F#과 같은 함수형 언어들은 모두 패턴 매칭을 제공하고 있다. 물론 패턴 매칭은 일종의 개념이기 때문에 이미 오픈 프로젝트를 통해 C#에서도 패턴 매칭을 사용하는 움직임이 일고 있다. NuGet 사이트에서 ‘Pattern Matching’을 검색하면 <그림 5>와 같이 다양한 라이브러리를 쉽게 찾을 수 있다.



패턴 매칭을 이용하면 값(상태)에 기반을 둔 제어 흐름(Control Flow)이 아닌 타입 기반의 논리 흐름으로 프로그래밍을 할 수 있다. 제어 구문에 이미 뼛속까지 익숙해져 버린 개발자에게는 너무나 충격적인 얘기일 것이다. 함수형 언어를 공부하는 개발자들이 어려워하는 부분이 바로 이런 것이다. 언어의 지향점이 다르므로 그에 대한 이해가 부족하면 학습하는 데 어려움을 겪을 수밖에 없다. 이미 익숙한 제어 흐름 중심 구현에 대한 생각을 버리고 타입 중심 구현 개발 패러다임(Paradigm)을 받아들일 이유를 찾지 못하는 것도 한몫한다. 타입 설계로부터 쓰레기 입력 데이터를 줄이면 프로세스는 문제 자체의 복잡도에만 집중할 수 있게 된다. 프로세스 문제의 본질적 복잡도를 제외한 복잡도를 최소화할 수 있다는 말이다. 이를 통해 자연스럽게 쓰레기 출력 데이터 역시 줄어들게 된다.



출처 : 마이크로소프트웨어 10월호

제공 : 데이터 전문가 지식포털 DBguide.net