caffeine-library / Domain-Driven-Design

🌱 에릭 에반스의 '도메인 주도 설계'를 읽는 스터디
4 stars 0 forks source link

[question] 테스트 코드의 단언(assertion) #14

Closed JasonYoo1995 closed 1 year ago

JasonYoo1995 commented 1 year ago

질문

p.40의 실행 가능한 기반 파트에서 테스트 코드의 단언 (assertion) 이라는 표현이 있는데 무슨 개념을 말하는 건지, 해당 페이지 전체가 이해가 안 갑니다. 이해를 도와주세요.

연관 챕터

8

leejaeseung commented 1 year ago

제가 이해한 내용을 요약하자면 코드로 모델을 설명하라 였습니다.

대부분의 경우 모델은 사람이 보기에 매우 간단하고 이해하기 쉽습니다. 하지만 그 모델을 코드로 구현한다면 수많은 엣지 케이스 혹은 추상화로 인해 코드만으로 모델을 유추해내기 어려워집니다. 보통 모델과 코드를 번갈아보며 이해하려 하겠죠. 책에서 이야기하는 내용은 코드를 따라가기만 해도 어떤 동작을 할지 예측 가능하도록 코드를 짜라는 것 같습니다. (테스트 코드의 단언사람이 직관적으로 이해할 수 있는 코드 의 예시인 것 같습니다.)

맞는 예시인지는 모르겠지만, 다음은 최근에 작성중인 개선이 필요해 보이는 코드입니다.

val dto1List = List(DTO1(key = "123"), DTO1(key = "456"), DTO1(key = "456"))
val dto2List = List(DTO2(key = "123"), DTO2(key = "456"), DTO2(key = "456"))

val dto2Map = dto2List.map(t => t.key -> t).toMap
val tupleList = dto1List.flatMap { dto1 => 
    dto2Map.get(dto1.key).map(dto2 => (dto1, dto2))
}

위 코드에서, tupleList 에 어떤 값이 담길지 예상이 되시나요? (난해한 문법 탓일 수도 있지만..) 로직을 따라가보면

  1. dto2Map 은 dto2List 의 key 값을 key 로 하고 DTO2 를 원소로 갖는 Map 입니다. Map[String, DTO2]
  2. dto1List 를 모두 돌면서 각 key 로 dto2Map 의 원소를 찾습니다.
  3. 찾은 dto1, dto2 를 튜플로 묶어서 리스트로 반환합니다. (flatMap 이므로 get 이 실패하면 무시됩니다.)

요약하면 두 리스트를 각 key 를 기준으로 merge 하는 로직입니다.

책에서 나온 말처럼 위 코드는 올바르게 실행되지만, 올바른 의미를 전달하는지는 모호합니다. 책에서도 선언적 이라는 키워드가 나오는데, 행위가 아닌 목적을 서술하는 것이 프로그램의 실제 행위를 결정한다고 합니다. 위 코드가 리스트를 merge 하는 일련의 행위를 서술했다면, 저 로직을 일반화해서 목적을 나타낼 수도 있을 것입니다.

// 미숙한 코드긴 하지만 예시로 들어봅니다..
// 두 개의 리스트 targetSeq, subjectSeq 를 인자로 받고,
// 각 객체의 어떤 값을 merge key 값으로 사용할 건지 받습니다.
def mergeList[A, B](targetSeq: Seq[A], subjectSeq: Seq[B])(targetKey: A => String)(subjectKey: B => String): Seq[(A, B)] = {
    val targetMap = targetSeq.map(t => targetKey(t) -> t).toMap
    subjectSeq.flatMap { sub =>
      targetMap.get(subjectKey(sub)).map((_, sub))
    }
  }

위 로직을 단순히 함수로 바꾸어 보았습니다.

val tupleList = mergeList(dto1List, dto2List)(dto1 => dto1.key)(dto2 => dto2.key)

첫 번째 로직보다 두 리스트를 merge 한다는 의도가 코드에 드러나게 되었습니다. 사용자는 코드만 보고도 이 코드가 어떤 동작을 할 지 알 수 있겠죠.

leejaeseung commented 1 year ago

위 함수를 더 리팩토링한 결과를 공유드립니다.

// 스칼라에서 확장 함수를 구현하는 방법
implicit class RichSeq[+A](seq: Seq[A]) {

    def zipBy[B](subjectSeq: Seq[B])(compareFunc: (A, B) => Boolean): Seq[(A, B)] = {
      seq.flatMap { target =>
        subjectSeq.find(sub => compareFunc(target, sub)).map((target, _))
      }
    }

  }

함수형 프로그래밍에서 많이 사용되는 zip 과 비슷한 형태로 구현하였습니다.

dto1List.zipBy(dto2List) { (dto1, dto2) => dto1.key == dto2.key }

위처럼 두 객체를 비교할 compareFunction 을 넘겨주어 합칠 수 있습니다.

leejaeseung commented 1 year ago

최근에 직관적이지 않은 코드를 선언적으로 리팩토링해 보았어서 공유드립니다. 책에서 얘기하는 코드를 보고 모델을 이해할 수 있게 하라. 의 좋은 예인 것 같았습니다.

로직 설명 : intput 문자열의 형태에 따라 각각의 Value 객체로 변환하는 로직

"[1,2,3]"
"2022-01-01T00:00:00"
"abcde"

의 형태로 문자열이 들어올 수 있습니다.

기존 코드

export const parseStringToCompareValue = (value?: string): CompareValueType => {
  if (value === undefined) return new NullValue()

   try {
     const parsedValue = JSON.parse(value)  // [1,2,3] 을 배열로 파싱합니다.
     if (Array.isArray(parsedValue)) {
       return new StringArrayValue(parsedValue)
     } else return new StringValue(parsedValue.toString())  // Array 형태가 아니라면 문자열 그대로 변환합니다.
   } catch {  // Date 형태라면 파싱이 실패합니다.
     const dateValue = moment(value, DATE_FORMAT_WITH_T, true)
     return dateValue.isValid() ? new MomentValue(dateValue) : new StringValue(value.toString())
   }
}

위 로직에서 버그가 생겨 다시 보니 대체 어떤 문자열이 어떻게 변환되는지 알 수가 없더라구요.. (심지어 제가 작성했던 코드였는데도..)

위 코드를 좀 더 선언적으로 리팩토링하였습니다.

export const parseStringToCompareValue = (value?: string): CompareValueType => {
  if (value === undefined) return new NullValue()

  if (isArrayString(value)) {
    return new StringArrayValue(JSON.parse(value))
  }

  if (isDateString(value)) {
    return new MomentValue(moment(value, DATE_FORMAT_WITH_T))
  }

  return new StringValue(value)
}

const isArrayString = (str: string): boolean => {
  try {
    const parsedString = JSON.parse(str)
    return Array.isArray(parsedString)
  } catch {
    return false
  }
}

const isDateString = (str: string): boolean => {
  const date = moment(str, DATE_FORMAT_WITH_T, true)
  return date.isValid()
}

동일한 로직임에도 확실히 어떤 문자열이 어떤 값으로 변환될 지 한 눈에 들어옵니다. 코드를 읽는 사람은 다른 추가적인 도움 없이 다음처럼 생각할 수 있겠죠. 문자열이 undefined 라면 : NullValue 구나! array 형태라면 : StringArrayValue 구나! date 형태라면 : MomentValue 구나! 아무것도 아니라면 : StringValue 구나!

코드를 자세히 보셨다면 눈치채시겠지만, 첫 번째 코드보다 두 번째 코드는 효율이 떨어집니다. 문자열의 형태를 검증하고, 검증되었다면 해당 형태로 변환할 때 중복으로 문자열을 파싱합니다.

하지만 그리 큰 성능 차이가 아니라면 코드의 가독성을 높이는게 더 중요하다고 생각됩니다. (모델과 코드의 일치)