추상화

객체 지향 프로그래밍(Object-Oriented Programming;OOP)에서 주요한 개념은 추상화, 캡슐화, 상속이 있다. 이 중 추상화에 대해서 알아본다.

추상화는 복잡성을 숨기기 위해 사용되는 단순한 형식을 의미한다. 대표적인 예가 인터페이스(interface)다. 클래스라는 복잡한 개념에서 메서드와 프로퍼티만 추출해서 단순화 했으므로 이를 추상화라 말할 수 있다.

실세계에서는 자동차를 예를 들 수 있다. 자동차는 굉장히 잘 만들어진 인터페이스다. 각 제조사들의 자동차가 내부적으로 어떻게 만들어 졌는지 모르지만 우리는 악셀레이터, 브레이크, 핸들링 등의 추상화 된 개념만 알고 있으면 운전을 할 수 있다.

이와 같은 비유를 잘 기억하고 프로그래밍에서는 어떤 목적을 가지고 추상화를 할 수 있는지 다음 항목을 살펴보자.

  • 추상화를 통해 복잡성 숨기기
  • 코드를 체계적으로 만들기
  • (추상화 된 클래스 혹은 인터페이스를 구현해서) 만드는 사람에게 변화의 자유를 주기

추상화 레벨을 통일하기

추상화 실현하기 위한 기본적인 도구는 함수다. 함수도 높은 레벨과 낮은 레벨을 구분해서 사용해야 한다. 이를 추상화 레벨 통일 (Single Level of Abstraction, SLA) 이라고 한다.

다음의 계층화된 함수의 예제를 살펴보자.

fun makeCoffee(){
    boilWater()
    brewCoffee()
    pourCoffee()
    pourMilk()
}

하나의 함수에 수많은 로직을 때려 넣기 보다, 함수를 세분화 하고 최소한의 책임만을 갖게 한다면 가독성을 크게 올릴 수 있다. 또한 함수의 재사용 및 테스트가 쉬워진다.

함수내의 모든 구문은 동일한 수준에 속해 있어야 하며, 더 낮은 수준의 추상화에 속하는 구문이 있으면, 이는 private한 함수로 옮겨야 한다.

또 다른 예제를 살펴보자.

public List<ResultDto> buildResult(Set<ResultEntity> resultSet) {
    val result = ArrayList<ResultDto>()
    for (entity in resultSet) {
        val dto = new ResultDto().apply{
            setShoeSize(entity.getShoeSize())
            setNumberOfEarthWorms(entity.getNumberOfEarthWorms())
            setAge(computeAge(entity.getBirthday()))
        }
        result.add(dto);
    }
    return result;
}

위 예제코드에 SLA 원칙을 적용하면 다음과 같이 바뀔 수 있다.

fun buildResult(resultSet:Set<ResultEntity>):List<ResultDto> {
    val result = ArrayList<ResultDto>()
    for (entity in resultSet) {
        result.add(toDto(entity))
    }
    return result
}
 
private fun toDto(entity:ResultEntity):ResultDto {
    return ResultDto().apply{
        setShoeSize(entity.getShoeSize())
        setNumberOfEarthWorms(entity.getNumberOfEarthWorms())
        setAge(computeAge(entity.getBirthday()))
    }
}

위 코드에는 두가지 추상화 계층이 존재 한다. 첫째는 전체 결과 set에 대한 반복문을 수행하는 것이고, 둘째는 반복문 안에서 하나의 엔티티를 DTO로 변경하는 작업이다.

첫번째 예제코드를 읽는 사람은 반복문에 DTO 변환과정이 있음을 알아야 하지만, 이를 알아채기 힘들다. 두번째 예제코드를 보면 DTO변환과정을 추상화 했기 때문에 가독성이 올라가며, buildResult 함수를 좀 더 쉽게 이해 할 수 있다.

추상화 계층이라는 개념은 함수보다 더 높은 수준에도 적용할 수 있다. 클래스, 모듈 등에서도 계층을 나누어 세부사항을 숨기는 추상화를 이룬다면 개발자는 어떠한 문제에 집중하기 쉽고, 더 나아가 플랫폼 독립성을 이루기도 한다.

변화로부터 코드를 보호하기

상수 프로퍼티 사용하기

// 좋지 않은 예
fun isPasswordValid():Boolean {
    if(text.length < 8) return false 
}

// 좋은 예
const val MIN_PASSWORD_LENGTH = 8
fun isPasswordValid():Boolean {
    if(text.length < MIN_PASSWORD_LENGTH) return false 
}

이렇게 상수를 프로퍼티로 선언하면, 이름을 붙여 그 의미를 명확히 알 수 있고, 추후에 최소 비밀번호 자릿수가 변경되더라도 MIN_PASSWORD_LENGTH만 변경하면 된다.

함수의 사용

함수는 추상화를 표현하는 수단이며, 함수 시그니처는 이 함수가 어떤 추상화를 표현하고 있는지 알려 준다. 그렇기 때문에 함수의 이름을 정할 때 구체적인 내용보다는 변화를 대비한 추상화 된 이름을 짓는 것이 중요하다. 예를 들어 어떠한 메세지를 표현하기 위해 다음과 같은 확장함수를 만들었다고 가정하자.

fun Context.showToast(message:String, length:Int){
    Toast.makeText(this, message, length).show()
}

만약 위 코드를 참조하고 있던 모든 부분을 SnackBar로 대체해야 한다면 확장함수 및 참조하고 있는 코드 전역을 Context.showSnackBar(message:String, duration:Int = Snackbar.LENGTH_SHORT)와 같이 변경해야 한다. 물론 그것도 하나의 방법이지만 변경을 줄이기 위해 showMessage라는 높은 수준의 함수로 변경할 수 있다.

fun Context.showMessage(
    message:String,
    duration:MessageLength
){
    //토스트 or 스낵바 구현
}

// 값이 다르므로 추상화
// Toast.LENGTH_SHORT = 0
// Snackbar.LENGTH_SHORT = -1
enum class MessageLength { SHORT, LONG }

클래스 사용

클래스가 함수보다 더 강력한 이유는 상태를 가질 수 있고, 많은 함수를 가질 수 있다는 점이다. 그리고 의존성 주입 및 mock 객체를 활용한 테스트가 가능하다. 이처럼 클래스는 훨씬 더 많은 자유를 보장한다.

인터페이스 사용

코틀린 표준 라이브러리를 읽어보면, 거의 모든 것이 인터페이스로 표현된 것을 확인할 수 있다.

라이브러리를 만드는 사람은 내부 클래스를 감추고, 인터페이스를 통해 이를 노출하는 코드를 사용한다. 이렇게 하면 사용자가 클래스를 직접 사용하지 못하므로, 라이브러리를 만드는 사람은 인터페이스만 유지한다면, 별도의 걱정없이 자신이 원하는 형태로 그 구현을 변경할 수 있게 된다. 즉, 인터페이스 뒤에 객체를 숨김으로써 실질적인 구현을 추상화하고, 사용자가 추상화된 것에만 의존하게 만들 수 있는 것이다. 즉, 결합(coupling)을 줄일 수 있다.

추상화 하기 요약

  • 상수로 추출
  • 동작을 함수로 래핑하기
  • 함수를 클래스로 래핑하기
  • 인터페이스 뒤에 클래스를 숨기기
  • 보편적인 객체(universal object)를 특수한 객체(specialistic object)로 래핑하기

추상화 단점

  • 추상화 러닝커브는 가파르다
  • 추상화에는 비용이 발생한다
  • 모든 것을 추상화해서는 안된다. (=사소한 문제를 어렵게 해결해서는 안된다.)

추상화를 어디까지 해야하나

팀의 크기, 팀의 경험, 프로젝트 크기 등 언제나 그 상황에 따라서 추상화의 필요 정도가 달라진다. 그러므로 추상화의 장단점을 이해하고 적당한 타협점을 찾아서 추상화를 해야한다.

추상화라는 것은 단순히 중복되는 코드를 제거하는 목적이 아니라, 추상화 된 코드를 나중에 변경해야 할 때 여러가지 방면으로 이점을 얻기 때문에 행해져야 하는 것이다.

API 안전성 확인하기

안정적이고 표준적인 API를 선호하는 이유

  • API가 변경되면 여러 코드를 수정해야 한다.
  • 새로운 API를 배우는 것은 부담스럽다.

좋은 API를 한번에 설계하는 것은 불가능하다. 그래도 안정적으로 유지하기 위한 노력으로 API에 대한 버전을 매겨서 안정성을 나타낸다. 일반적으로 다음과 같은 시멘틱 버저닝을 사용한다 예) Major.Minor.Patch

  • Major : 호환되지 않는 수준의 API변경
  • Minor : 이전 변경과 호환되는 기능을 추가
  • Patch : 간단한 버그 수정

안정적인 API에 새로운 요소를 추가할 때는 Experimental 메타 애노테이션을 사용해서 해당 요소가 아직 안정적이지 않음을 알릴 수 있다.

안정적인 API를 변경하는 경우, 전환하는데 시간을 두기 위해 Deprecated 애노테이션을 활용하여 사용자에게 알릴 수 있다.

직접적인 대안이 있는 경우 @ReplaceWith를 사용하면 IDE가 자동 전환을 할 수 있다.

외부 API를 감싸서 사용하기

외부 API에 대한 신뢰가 없다면 이를 감싸서 사용할 수 있다. API 래핑시 얻을 수 있는 이점은 다음과 같다

  • 문제가 생겼을때 래퍼(wrapper)만 변경하면 되므로, 변경에 쉽게 대응할 수 있다.
  • 프로젝트 성격에 맞춰서 API 형태를 변경할 수 있다.
  • 래핑된 라이브러리를 다른 라이브러리로 손쉽게 변경 할 수 있다.
  • 필요한 경우 추가적인 동작 또는 수정이 가능하다.

요소의 가시성을 최소화하기

API를 설계할 때 요소들의 가시성을 최소화 해야하는 이유

  • 간결한 인터페이스는 배우기 쉽고 유지하기 쉽다.
  • 외부로부터 API내 클래스 상태를 변경할 수 있도록 둔다면, API의 안전성을 보장하기 어렵다
  • 가시성이 제한 될 수록 클래스의 변경을 쉽게 할 수 있고, 동시성 이슈를 처리할 때 더욱 안전해진다.

(가시성) 한정자(Modifier)를 사용하면 사용하면 외부에서 접근할 수 없게 만들 수 있다.

  • public(기본값) : 어디에서나 볼 수 있다.
  • private: 클래스 내부에서만 볼 수 있다.
  • protected: 클래스와 서브클래스 내부에서만 볼 수 있다.
  • internal: 모듈 내부에서만 볼 수 있다.

탑레벨 요소에는 세가지 한정자를 사용할 수 있다.

  • public(기본값): 어디에서나 볼 수 있다.
  • private: 같은 파일 내부에서만 볼 수 있다.
  • internal: 모듈 내부에서만 볼 수 있다.

문서로 규약을 정의하기

함수를 정의하고 그 함수가 무엇을 하는지 명확하게 설명하고 싶다면 KDoc 주석을 붙여주는 것이 좋다.

/**
* 메세지 출력 함수
* @param message 사용자에게 보여 줄 메세지
* @param duration 메시지를 노출하는 시간
*/
fun Context.showMessage(message:String, duration:MessageLength){...}

이렇게 함수의 동작을 설명하면 사용자는 이 함수 동작에 대한 내용을 신뢰한다. 하지만 최근에는 주석없이 함수 시그니처만으로 명확하게 함수 동작을 이해할 수 있어야 한다고 주장하는 파도 있다. 뭐든지 극단적이지만 않으면 나쁘지 않다고 본다.

KDoc 형식

KDoc에 대한 자세한 내용은 공식문서를 참조하자. 여기서는 자주 사용되는 태그만 살짝 살펴본다.

  • @param <name> : 함수,클래스 매개변수 또는 프로퍼티 등을 설명
  • @return : 함수의 반환 값을 설명
  • @constructor: 클래스의 기본 생성자를 설명
  • @receiver: 확장 함수의 리시버를 설명
  • @property <name>: 명확한 이름을 갖고 있는 클래스의 속성을 설명
  • @throws <class>, @exception <class> : 함수 내부에서 발생할 수 있는 예외사항을 설명
  • @sample <identifier> : 정규화된 형식 이름을 사용해서 함수의 용례를 설명
  • @see <identifier> : 특정한 클래스 또는 메서드에 대한 링크를 추가
  • @author : 요소의 작성자를 지정
  • @since : 요소에 대한 버전을 지정
  • @supress: 이를 지정하면 만들어지는 문서에서 해당 요소가 제외된다.

추상화 규약을 지키기

프로그램을 안정적으로 유지하고 싶다면, 규약을 지켜야 한다. 규약을 깰 수밖에 없다면, 이를 잘 문서화해야한다. 이러한 정보는 코드를 유지하고 관리하는 나를 포함한 다른 구성원들에게 도움이 된다.

카테고리: Kotlin

0개의 댓글

답글 남기기

Avatar placeholder

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.