인터페이스만 사용하다가 클래스를 다시 보았다

만 1년 정도 타입스크립트를 프론트엔드 개발에 사용해 봤는데 꽤나 매력적이다. 함수 전달 인자의 타입을 강제화 할 수 있다는 점에서 안전한 코드를 만드는데 적잖은 도움을 받고 있다.

타입을 문자열이나 정수 따위의 원시 타입으로만 한정하는 것은 아니다. 하나 이상의 원시 타입으로 구성된 합성 타입을 정의할 수도 있는데 이때 주로 "인터페이스(interface)"를 이용한다.

interface User {
  name: string
  email: string
}

문자열 타입의 이름과 이메일로 구성된 User 합성타입을 이렇게 인터페이스로 정의한다. 타입을 기반으로 코딩하면 대부분의 타입 에러를 예방하고 안전하게 코딩할 수 있었다.

function doSomthing(user: User): User

인터페이스 활용

인터페이스만 맞춘다면 어떠한 객체나 함수라도 안전하게 만들 수 있다.

가령, 새로운 유저 객체를 만든다던지

const user: User = {
  name: "",
  email: "",
}

폼으로 받은 유저 데이터를 검증한다던지

function validateUser(user: User): boolean

XHR 호출시 페이로드에 유저 타입을 사용한다던지 말이다.

function saveUser(user: User): Promise<User>

각각의 로직에 유저 인터페이스를 맞춰 코딩을 강제화 할 수 있고, 그렇게 만든 객체나 함수는 예상하는 대로 척척 동작한다.

하지만 어느 순간부터는 인터페이스를 맞췄음에도 불구하고 코드가 잘 눈에 들어오지 않기 시작했다.

원인이 뭘까?

타입스크립트를 이용해 개발한 건 리액트와 리덕스 조합의 프로젝트다. 관련한 문서를 읽고 샘플 코드를 찾아본 뒤 이러한 폴더 구조를 만들었다.

apis // XHR
models // 데이터 모델, 보통 인페이스만 사용함
actions // 액션타입, 액션 생성자
reducers // 스토어 변경
sagas // 비동기 처리
store // 스토어
selectors // 스토어 셀렉터
utils // 유틸리티. 애매한 역할은 여기에 모았음
container
components
pages
routers

처음에 이런 구조가 역할을 잘 분리해 놨기 때문에 단순할 것이라고 생각했다. 각 역할의 범위까지만 생각하고 코딩하면 되기 때문이다. 가령 리듀서를 코딩할 때는 정의된 어떤 액션을 받아 스토어를 변경하는 역할만 하면된다. 변경된 스토어 일부를 사용하는 사가나 셀렉터가 어떤 일을 하는지는 신경쓰지 않아도 되고 말이다.

하지만 화면 하나를 만들라 치면 각 역할자들을 모두 건드려야 한다. 페이지는 리듀서 하나만으로 동작하는게 아니기 때문이다. 액선을 정의하고 액션 생성자, 리듀서, 컨데이터, 컴포넌트, 페이지까지 서로 협력해서 하나의 화면을 만들어 냈다. 물론 비동기 로직이 있기때문에 사가도 만들어야하고.... 여간 귀찮은 일이 아니다.

게다가 정의한 역할이 아닌 녀석들은 모두 utils 폴더에 치워 두었는데, 코드를 추가할 수록 애매한 녀석들이 많아지고 유틸리티는 정리되지 않은 공구상자처럼 어질러져 버렸다. 솔직히 이런 구조에서는 코드 관리를 잘 못하겠다.

정리하면, 1) 각 역할자들 사이에서는 인터페이스로 약속하고 개발하면 된다고 생각했지만 귀찮다.

  1. 복잡도가 증가하면 코드 관리가 않된다.

클래스를 써야할까?

최근 여섯 명이서 공동 작업하는 코드를 보고 있는데 클래스 활용이 무척 인상적이다. 철저하게 리덕스 역할자들을 분리해서 작성했던 이전 경험과 다르게 꽤나 규모있는 클래스 위주로 개발해 나가는 것이 색달랐다.

인터페이스는 타입만 지정할 뿐이지 사용하는 측에서 핸들링 하는 점이 자유로웠다. 인터페이스를 사용하는 함수는 여기저기 자유롭게 정의할 수 있다.

반면 클래스는 고유의 값을 가지고 있고 이 값을 처리하는 고유의 메소드를 스스로 가진다. 함수가 여기저기 흩어져있는 이전 방식과는 달리 데이터와 동작을 한 곳에서 관리할 수 있는데, 비교적 단순하게 사고할 수 있었다.

몇 가지 상황을 보면서 내가 발견한 클래스 모습을 그려보자.

데이터 초기화가 필요한 경우

인터페이스로 객체를 만들 때, 매번 초기값을 설정한다.

const user1: User = {
  name: "",
  emila: "",
}

인터페이스를 사용하는 녀석에 따라 이 값은 달라질 수 있다. 이렇게 되면 User 타입의 초기값은 정의할 때마다 달라지기 때문에 일관성을 보장받기 힘들다. 그렇다고 initUser() 라는 함수를 만드는 것도 이상해 보인다.

반면 클래스는 생성자 함수가 객체 초기화 역할을 하도록 한다.

class User {
  user: string;
  email: string;

  // 생성자 함수가 일관되게 객체를 초기화 한다
  constructor() {
    this.user: '',
    this.email: ''
  }
}

const user: User = new User() // {user: "", email: ""}

클래스는 인터페이스와 달리 생성자 함수를 통해 새 객체를 얻을 수 있다. 그렇기 때문에 객체를 얻을 때마다 일관적으로 초기값을 설정하는 생성자 함수를 실행할 수 있다.

XHR 응답으로 온 유저 데이터를 User 타입으로 변경해야하는 경우도 있다. 응답으로 어떤 데이터를 받더라도 생성자 함수에서 초기값을 보장할 수 있다.

class User {
  user: string;
  email: string;

  constructor(row: any = {}) {
    this.user: row.user || '',
    this.email: row.email || ''
  }
}

const user: User = new User({user: 'alice'})  // {user: "alice, email: ""}

서버에서 온 데이터에 email 필드가 없더라도 생성자에 의해서 빈 문자열로 초기화 할 수 있다.

데이터와 관련한 함수가 늘어날 경우

기존에는 유저 입력폼 검증을 위해 유틸리티나 컴포넌트 메소드에 validateUser()를 위치시켰다. 이게 직관적이지 못해 코드가 쉽사리 흩어져 버리는 부작용이 있었다. 컴포넌트 메소드에 등록했다가 중복코드가 생기면 유틸리티로 옮기긴하는데 누락된 것도 생기고...

클래스의 장점은 관심사가 비슷한 함수를 메소드로 모을 수 있다는 점이다. 가령 폼으로 받은 유저데이터를 검증하는 validate() 메소드로 옮겨 넣을 수 있다.

class User {
  validate(): boolean
}

유저 데이터를 가지고 있는 클래스의 메소드로 만들면 관심사를 한 곳으로 모을 수 있는 효과가 있다. 뿐만아니라 유저 클래스를 사용하는 어느 곳에서나 User.prototype.validate() 메소드를 호출할 수 있다.

user.validate()

xhr 요청을 할 때도 사용할 수 있다.

폼을 입력한 뒤 서버에 저장하려고 액션을 던지고, 사가 함수를 만들고, 결과를 리덕스에 담으려면 여러개 탭을 띄워 놓아야 한다. 탭에 파일명이 보이지 않는 경우가 허다하다.

대신 이런 클래스 메소드를 만들어 보는건 어떨가?

class User {
  async save(): Promise<User>
}

유저 객체가 폼에 의해 변경되 데이터를 가지고 있기 때문에 스스로 save() 메소드를 부르면 된다.

이런식으로 관심사가 비슷한 데이터와 메소드를 하나의 클래스로 모으면 여기저기 흩어져있는 코드를 정리할 수 있다. 코드를 찾기 더 쉬워질 뿐만아니라 사고하는 방식도 데이터 중심이라서 기존 방법을 사용했을 때와 확연히 달라진 기분이다.

결론

조직을 구성할 때 목적조직 혹은 기능조직을 두고 고민한다. 비슷한 역할의 구성원을 모와놓은 것이 기능조직이다. 예를들어 "프론트엔드개발팀". 반면 하나의 결과물을 만드는 구성원을 모아놓은 것이 목적조직이다. 예를들어 "상품개발팀". 조직에서 이 둘을 수시로 변경한다. 정답이 없기 때문에 상황에 맞게 계속 변화시키는 것 같다.

리덕스를 이용한 구조에서는 역할별로 함수를 모아놓고 각 함수가 제 역할을 하면서 인터페이스로 협력하도록 해 놓은 구조다. 클래스는 하나의 데이터를 중심으로 필요한 메소드를 모아 놓은 구조다.

클래스와 인터페이스도 정답이 없는건 마찬가지이다. 상황에 맞게 잘 조절해야 할 것같다. 여태껏 인터페이스만 사용해 왔다. 그것이 코드를 유연하게하고 현명한 방식이라고 여겼는데, 이제부터는 클래스 위주의 사고도 훈련해야 할 필요가 있다고 느낀다.