Peter Cho

강제성, 단일화 그리고 날짜 헬퍼 개발

2019.07.13 | 12 Minute Read

현재 진행 중인 프로젝트의 기술 스택은 Angular, TypeScript 사용중이다.

이 글에서 Angular, Typescript의 예제를 사용한다.

목차

도구로 해결할 수 없는 강제성

우선 강제성에 대한 정의가 필요할 거 같다. 강제성의 사전적 정의는 “본인의 의사와는 관계없이 권력이나 힘을 이용해 원하지 않는 일을 억지로 시키는 경향”이다.

내가 진행했던 프로젝트는 강제성을 부여하기도 했다. 린트를 통한 코딩 컨벤션과 유닛 테스트를 통과해야 커밋이 가능한 강제성이 있었다. 요새는 린트를 필수로 사용한다. 린트를 사용하지 않으면 사람 대 사람으로 피드백이 필요하다. 타인에게 주는 피드백은 의도치 않은 결과를 초래하기도 한다. 의도치 않은 것을 받아들여 감정을 상할 여지가 있다.

그러므로 서로의 감정을 상하지 않게 하려면 도구를 통해 피드백을 주는 것이 좋다고 느꼈다.

하지만 도구로 해결할 수 없는 것이 있다. 도구로 해결할 수 없는 것은 코드의 역할과 의미를 제어 하는 것이다. 예를 들어 날짜를 의미하는 값은 다양한 방법이 있다.

타임스탬프, 문자로 표기하는 날짜 포맷, Date 객체 그리고 Moment와 같은 라이브러리로 다루는 것이다. 이런 값들은 도구로서 강제하기 힘들고 코드리뷰를 통해 강제해야 한다.

코드리뷰를 통해 코드의 동작을 강제하는 것은 리뷰이와 리뷰어 모두 피곤한 작업이다. 코드리뷰에 익숙하지 않은 리뷰이는 공격적으로 받아들이고 방어적인 성향을 띄게 되는 경우를 많이 봤다. 그래서 이러한 부분은 다른 방향으로 해결해야 한다.

날짜 헬퍼를 만들게 된 이유

최근에는 날짜 헬퍼를 만들었다. 날짜 헬퍼의 역할은 날짜를 다루는 연산작업과 생성작업을 단일화하는 것이다. 단일화하여 날짜를 다루는 타입을 하나로 할 길이 열린다.

만들게 된 자세한 배경은 다음과 같다.

프로젝트에서 자바스크립트로 날짜를 다루는 타입은 4가지가 있었다.

  1. Date 객체
  2. 숫자타입의 타임스탬프
  3. YYYY-MM-DD 형태의 문자타입
  4. Moment 라이브러리

결국, Date 객체를 사용해서 만들어진 타입들이다. 하지만 계산 작업을 할 때는 이렇게 많은 타입이 필요하지 않다.

YYYY-MM-DD의 형태는 API 통신이나 탬플릿에 필요한 타입이다. 타임스탬프는 비교 연산을 위해 사용되는 데, 자바스크립트는 문자도 비교연산을 사용 가능하다. 즉, 타임스탬프도 불필요하다.

날짜를 다루는 방법이 많다…
const date: Date = new Date()
const timestamp: number = Date.now()
const dateFormat: string = moment().format('YYYY-MM-DD')
const current: Moment = moment()

프로젝트에서 사용되는 날짜를 의미하는 타입이 불규칙적이다. 불규칙적이기 때문에 코드를 해석하기 힘들어졌다. 그래서 단일 타입을 강제하는 게 필요해졌다.

단일 타입과 강제성을 부여하기 위한 가장 쉬운 방법은 한 위치에서 제어 하는 것이었다. 새로운 날짯값을 만들거나 계산 작업이 필요할 때 한 위치에서 제어 하는 것이다.

프로젝트에서는 재사용을 위한 함수 분리는 헬퍼에 정의한다. 날짜 헬퍼도 이렇게 해서 만들어졌다. 날짜 헬퍼를 사용하면 사용 측에서는 “어떻게 연산해야 하는지” 고민할 필요 없이 함수의 인자에 전달과 반환 값을 사용하기만 하면 된다.

이제 어떻게 날짜 헬퍼를 구성했는지 알아보겠다.

프로젝트에 적합한 방식 선정하기

처음에는 헬퍼를 만들게 돼서 어떤 형태로 개발할지 고민을 했다. 프로젝트에서는 함수형과 객체 지향을 강제하지 않는다. 그래서 내가 선호하는 방식인 함수형과 객체지향으로 고민했다. 그래서 두 가지 방향으로 자주 사용되는 인터페이스를 10가지 정도 만들어 봤었다.

그런데 가장 중요한 사항을 잊고 있었는데, 실제로 사용한 프로젝트 구성원의 의견을 듣지 않았다. 헬퍼를 만들게 되면 실제로 사용할 구성원들에게 의견을 듣는 게 좋겠다는 생각이 들었다.

그래서 회의를 통해서 선호하는 타입을 선정했고, 아쉽게 내가 생각하는 방향과 다르게 타입이 결정되었다. 타입은 문자로 결정되었고, Moment와 Date는 직접 사용하는 것은 제재하기로 했다.

오히려 미리 의견을 물었던 게 이득이었지 않을까 추측됐다. 내가 생각했던 방향으로만 진행됐으면 오히려 함수형과 객체지향에 익숙하지 않은 구성원은 적응하는 데 시간이 걸렸을 것이다.

결정된 사항으로 만들게 되면 절차지향으로 개발되고 모두가 익숙한 방법이다. 역시 좋은 게 좋은 게 아니라 프로젝트에 좋은 게 좋은 거다.

함수형으로 설계했던 인터페이스
const addDate = date => count => {
  const clonedDate = new Date(date)
  clonedDate.setDate(clonedDate.getDate() + count)
  return clonedDate
}

const today = new Date(2019, 0, 1)
const addFromToday = addDate(today)
const tomorrow = addFromToday(1)
const nextWeek = addFromToday(7)

console.log(today.getDate()) // 1
console.log(tomorrow.getDate()) // 2
console.log(nextWeek.getDate()) // 8
객체지향으로 설계했던 인터페이스
class CustomDate extends Date { 
  addDate (count) {
    this.setDate(this.getDate() + count)
  }
  static create (...args) {
    return new CustomDate(...args)
  }
}

const customDate = CustomDate.create(2019, 0, 1)
console.log(customDate.getDate()) // 1
customDate.addDate(10)
console.log(customDate.getDate()) // 11
하지만 현재는 이렇게

@Injectable()은 Angular의 Service 정의시 사용되는 데코레이터이다.

@Injectable()
export class MomentHelper {
  addDate (date: string, count: number): string {
    return moment(date)
      .add(count, 'days')
      .format('YYYY-MM-DD')
  }
}

이제 구체적인 구현 과정을 알아보겠다.

인터페이스 설계하기

먼저 프로젝트에서 사용하는 기능을 식별한다. IDE의 전체 코드 검색창에서 new Date, Date.now, moment를 검색해서 구현된 로직들을 분석했다. 그리고 추가로 기존에 사용 중인 유틸리티도 검색했다.

자주 사용되는 기능 추렸을 때 이런 기능들이 식별됐다.

- 오늘 가져오기
- 다음 날, 이전 날 이동
- 년월일 비교
- 윤달 여부
- 마지막 달 가져오기
- 어제 날짜
- 날짜를 특정 포맷으로 변환하기
- 날짜범위 가져오기

이렇게 식별된 기능을 토대로 인터페이스를 만든다. 초기 이름은 MomentHelper로 지었다. 일단 자주 사용되는 인터페이스만 만들어봤다.

const enum Format = {
  DateTime = 'YYYY-MM-DD HH:mm:ss',
  Date = 'YYYY-MM-DD',
  Time = 'HH:mm:ss',
}

class MomentHelper {
  getToday (format: Format): string {}
  getYesterday (format: Format): string {}
  addDate (date: string, count: number, format: Format): string {}
  subtractDate (date: string, count: number, format: Format): string {}
  isSameDate (srcDate: string, targetDate: string): boolean {}
  isSameMonth (srcDate: string, targetDate: string): boolean {}
  isSameYear (srcDate: string, targetDate: string): boolean {}
  isLeapYear (date: string): boolean {}
}

인터페이스를 만들었으면 검증하는 것이 중요하다. 검증은 실제로 사용할 코드에서 인터페이스를 사용해보는 것이다. 이를 통해서 원하는 방향으로 인터페이스가 설계되었는지 그리고 깔끔하게 설계가 되었는지 알 수 있다.

내 기준에 깔끔한 설계는 라이브러리를 대체할 만한 편의성과 직관성을 제공하는 것을 기준으로 한다.

인터페이스 설계가 완료되면 본격적으로 개발을 시작한다.

개발하기

헬퍼로 작성된 코드는 다수의 파일에서 사용할 목적이기 때문에 안전성이 보장돼야 한다. 나는 안전성을 보장하는 방법으로 유닛 테스트를 작성한다. 또한, 유닛 테스트를 통해 함수의 사용법까지 전달할 수 있다.

유닛 테스트는 유닛 테스트 라이브러리를 사용해도 좋고 동작 여부를 확인하는 함수를 별도로 작성해도 좋다. 진행한 프로젝트는 Karma + Jasmine으로 환경이 구성돼서 그대로 사용했다.

테스트 코드는 이런 형태로 작성했다.

describe('addDate', () => {
  let helper: MomentHelper
  beforeEach(() => { helper = new MomentHelper() })
  
  it('addDate - 기본 포맷', () => {
    // Given
    const DATE = '2019-03-01 10:25:30'
    const COUNT = 1

    // When
    const result = helper.addDate(DATE, COUNT)

    // Then
    expect(result).toBe('2019-03-02')
  })
})

간단하게 설명하면 이런 의미를 부여하며 작성했다.

- describe: 어떤 함수를 테스트할 것인가
- it: 어떤 동작 케이스 또는 시나리오인가
- Given: 주어진 값, 전재가 무엇인가
- When: 함수 실행과 결과는 무엇인가
- Then: 실행결과와 의도한 결과가 일치하는 가

이렇게 테스트 코드에 함수의 의도와 실행결과를 작성한다. 함수의 스펙을 정의하는 셈이다.

테스트를 작성한 뒤에는 moment를 사용해서 함수 내부를 작성했다. 함수의 동작은 테스트 코드 동작을 통해 빠르게 피드백을 받을 수 있다. 정상일 때는 초록색으로 비정상일 때는 빨간색으로 피드백을 받을 수 있다.

@Injectable()
export class MomentHelper {
  addDate (date: string, count: number, format: FORMAT = DEFAULT_FORMAT): string {
    return moment(date)
      .add(count, 'days')
      .format(format)
  }
}

이 과정을 반복하면서 하나씩 기능을 추가하면 안전성이 보장된 코드를 작성할 수 있다.

모든 인터페이스를 개발하면 리팩토링한다. 리팩토링을 통한 수정된 코드는 테스트 코드를 통해 안전성이 보장된다. 리펙토링 중에 오류가 발생해도 테스트 코드를 통해 바로 피드백을 받을 수 있다.

여기서 리팩토링은 상수화, 공통로직의 메소드화, 네이밍 수정 등이 이뤄진다.

이제 헬퍼를 작성했으니 사용 측 파일에 적용해보자.

적용하기

다시 전재를 설명하면 타입스크립트를 사용중이고, 날짜를 다루는 타입은 이제 문자 즉 string 타입을 사용한다.

적용할 때는 타입 선언을 먼저 변경했다. 타입 스크립트를 사용하는 중인데 타입 스크립트는 잘못된 타입을 사용하면 바로 피드백해준다. 그래서 Date, number를 사용하는 위치에 string으로 변경해서 영향있는 곳을 파악했다.

기존에 사용된 코드가 이런 형태라면,

const today: Date = new Date()
const yesterday: Date = new Date(
  today.getFullYear(), 
  today.getMonth(), 
  today.getDate() - 1
)
const tomorrow: Date = new Date(
  today.getFullYear(), 
  today.getMonth(), 
  today.getDate() + 1
)

헬퍼를 사용해서 이런 형태로 바뀌었다.

const today: string = momentHelper.getToday()
const yesterday: string = momentHelper.subtractDate(today, 1)
const tomorrow: string = momentHelper.addDate(today, 1)

컴포넌트 통신 구간 적용

수정하기 가장 까다로운 부분은 다른 코드와 의존성이 있는 코드이다.

컴포넌트간의 통신 타입으로 Date나 Timestamp를 사용할 때가 있는 데, 지금은 string 타입으로 변경이 필요하다. 이 때는 먼저 서로 의존성을 끊는 게 중요하다.

A와 B 컴포넌트가 있고, today 변수가 있다고 가정하겠다.

현재 두 컴포넌트 간의 통신 타입으로 Date 타입을 사용한다. A에서 today 타입을 string으로 수정하면 B에 영향이 생긴다.

[A] (today: Date)---(Date Type)---(today: Date) [B]

A 내부의 날짜 타입을 string으로 변경하기 위해서는 통신 시점에 변환이 필요하다. 일단 today를 string으로 변경하고 B와 통신할 때 Date 타입으로 변경해서 통신한다.

[A] (today: string)=>(outputToday: Date)---(Date Type)---(today: Date) [B]

이렇게 되면 A 내부의 날짜 데이터들은 모두 string으로 변경 가능하다. A 내부 날짜 타입을 변경 완료 후에 B는 Date를 전달받고 string으로 변환한다. 그리고 B 내부의 날짜 데이터들을 모두 string으로 변경한다.

[A] (today: string)=>(outputToday: Date)---(Date Type)---(inputToday: Date)=>(today: string) [B]

A와 B 모두 수정 완료되면 통신 구간을 변경한다. 이렇게 하면 오류를 발생시키지 않고 적용 가능하다.

[A] (today: string)---(string Type)---(today: string) [B]

이 방법은 함수간의 통신, 컴포넌트 간의 통신, 모듈간의 통신 모두 적용할 수 있다.

적용 완료

Service, Pipe, Component는 Angular의 아키텍쳐 구성요소이다.

적용을 완료했을 때는 Service 파일 2건, Pipe 파일 3건, Component 파일 12건에 적용되었고, Pull Request 3건을 통해 완료했다.

바로 보이는 장점은 외부 라이브러리인 Moment를 제거하기 쉬워졌다. Moment는 트리 쉐이킹이 안되면서 많은 기능을 사용하지 않고 있었다. 그런데 사용되는 파일이 날짜 헬퍼인 단 하나의 파일에만 존재하기 때문에 제거하기 쉬워졌다.

헬퍼 네이밍이 Moment에 의존해서 변경이 필요해 보였다. 프로젝트의 공통 코드 프리픽스는 Bee를 사용하고 있다. 프로젝트 네임인 Bumblebee의 마지막 발음인 Bee에서 따온 것이다. 새로 변경된 네이밍은 BeeDateHelper로 변경했다.

마지막으로 아쉬운 점이 있다면 Angular의 Service로 정의되어 코드가 길어지는 경향이 있다. 항상 this.beeDateHelper 로 시작해서 함수에 접근해야 한다. 이 부분은 Service로 선언하지 않고 각 함수들을 순수함수로 정의하면 해결할 수 있다. 프로젝트 구성원들의 의견을 묻고 수정해 봐야 될 거 같다.

과정 요약

지금까지의 날짜 헬퍼를 만드는 과정은 아래와 같다.

  1. 프로젝트에 적합한 방식을 구성원과 결정하기
    • 구체적인 타입과 사용방법을 결정한다.
  2. 인터페이스 설계하기
    • 실제로 사용해보면서 인터페이스를 검증한다.
  3. 헬퍼 개발하기
    • 유닛 테스트를 통해 코드의 안전성을 보장한다.
  4. 헬퍼 적용하기
    • 안전한 적용 전략으로 사이드 이펙트 없이 적용한다.