소프트웨어가 얼마나 복잡해질 수 있는지에 대한 예제

이 글은 Jorin님이 작성하신 An Example of How Software Becoms Complicated의 번역글입니다.

자바스크립트에서 캐시 코드를 작성하고 코드를 간결하게 유지하는 것이 무엇을 의미하는지에 대해 확인해봅시다.

우리는 소프트웨어 개발자들이 자주 코드는 간결해야 하고 복잡성을 컨트롤 할 수 있어야 해라고 말하는 것을 듣습니다. 그러기 위해 코드를 재사용 가능하고 공유 기능한 형태로, 또한 확장하기 쉬운 형태로 개발하려 하죠.

소프트웨어 코드를 작성할 때 코드를 복잡하게 만드는 것보다 훨씬 복잡하게 만드는 것이이 훨씬 쉽습니다.

원문: When writing software it is very easy to end up with code more complicated than complex, that tries to do too many things and is hard to work with.
참고: What is the difference between “complicated” and “complex”?

모두가 코드를 깔끔하게 간단하게 짜라고 말합니다. 그리고 우리 모두는 이 말에 동의합니다. 만약 우리 모두가 이 목표를 알고 있다면, 어째서 프로젝트를 하는 시간이 지나면 지날수록 일이 복잡하게 엉켜가는 걸까요? 여기서 우리는 아마 단순한 문제를 해결하기 위해 무엇을 생각해봐야 할지에 대해 몇가지 다뤄봐야 할 것 같습니다.


간단한 캐시를 만들어봅시다.

이 캐시는 key-value 페어로 즉시 값을 설정하고 받을 수 있습니다. 간단하게 구현한다면 다음과 같은 코드가 나올겁니다:

const cache = () => {
  const store = {}

  const set = (key, value) => {
    store[key] = value
  }

  const remove = key => {
    const value = store[key]
    delete store[key]
    return value
  }

  return { set, remove }
}

// Let's use the cache

const simpleCache = cache()

simpleCache.set('a', 1)
simpleCache.set('b', 2)
simpleCache.set('b', 3)

console.log(simpleCache.remove('a')) // 1
console.log(simpleCache.remove('b')) // 3
console.log(simpleCache.remove('b')) // undefined

자 그런데 프로젝트가 진행됨에 따라 이 캐시에 만료(expires)기능을 넣을 필요가 생겼습니다. TTL(time to live)을 받아서 setTimeout으로 일정 시간이 지나면 만료되게 해봅시다. 이렇게 바뀔거에요:

const cache = (ttl, expirationHandler) => {
  const store = {}

  const set = (key, value) => {
    // Clear existing timer
    const record = store[key]
    if (record) {
      clearTimeout(record.timer)
    }
    // Set expiration timer
    const timer = setTimeout(() => {
      expirationHandler(key, store[key].value)
      delete store[key]
    }, ttl)
    // Store timer and value
    store[key] = { timer, value }
  }

  const remove = key => {
    // Find record
    const record = store[key]
    if (!record) {
      return undefined
    }
    delete store[key]
    const { timer, value } = record
    // Clear timer and store
    clearTimeout(timer)
    return value
  }

  return { set, remove }
}


const expirationHandler = (key, value) => {
  console.log(`expired ${key}: ${value}`) // expired b: 2
}
const expiringCache = cache(1000, expirationHandler)

expiringCache.set('a', 1)
expiringCache.set('b', 2)

console.log(expiringCache.remove('a')) // 1
console.log(expiringCache.remove('a')) // undefined
setTimeout(() => {
  console.log(expiringCache.remove('b')) // undefined
}, 1100)

모든게 잘 돌아갑니다. 네 좋아요. 그런데 같이 코드를 짜던 동료가 코드리뷰를 해주는데 이 캐시가 만료될 필요가 전혀 없는 다른 곳에서도 쓰일 수 있다고 합니다. 뭐 간단히는 예전 캐시와 지금 캐시 코드 모두를 두는 방법도 있겠죠, 그렇지만 DRY 원칙을 지키고 싶습니다.

그래서 대신 새로 짠 캐시 코드가 두 케이스를 모두 대응할 수 있도록 바꿉니다:

const cache = (ttl, expirationHandler) => {
  const store = {}

  const set = (key, value) => {
    // If no TTL is specified, behave as before and return early
    if (!ttl) {
      store[key] = value
      return
    }
    // Clear existing timer
    const record = store[key]
    if (record) {
      clearTimeout(record.timer)
    }
    // Set expiration timer
    const timer = setTimeout(() => {
      expirationHandler(key, store[key].value)
      delete store[key]
    }, ttl)
    // Store timer and value
    store[key] = { timer, value }
  }

  const remove = key => {
    // Find record
    const record = store[key]
    if (!record) {
      return undefined
    }
    delete store[key]
    // If no TTL is specified, behave as before and return early
    if (!ttl) {
      return record
    }
    const { timer, value } = record
    // Clear timer and store
    clearTimeout(timer)
    return value
  }

  return { set, remove }
}

// Let's use the simple cache

const simpleCache = cache()

simpleCache.set('a', 1)
simpleCache.set('b', 2)
simpleCache.set('b', 3)

console.log(simpleCache.remove('a')) // 1
console.log(simpleCache.remove('b')) // 3
console.log(simpleCache.remove('b')) // undefined

// Let's use the expiring cache

const expirationHandler = (key, value) => {
  console.log(`expired ${key}: ${value}`) // expired b: 2
}
const expiringCache = cache(1000, expirationHandler)

expiringCache.set('a', 1)
expiringCache.set('b', 2)

console.log(expiringCache.remove('a')) // 1
console.log(expiringCache.remove('a')) // undefined
setTimeout(() => {
  console.log(expiringCache.remove('b')) // undefined
}, 1100)

엄청 빨리 해결됐네요. 이 작업을 하는데 필요했던 부분은 그저 두 개의 IF 구문을 추가하는 것일 뿐이었습니다.

그리고 이게 바로 어떻게 이러한 과정이 복잡해지는지를 설명해줍니다: 간단한 캐시는 더이상 간단해지지 않았습니다, 만료 기능을 넣으며 뭔가 얽혀졌죠. 이런 간단한 시나리오가 이해하기 어려워졌고, 느려졌으며 버그를 발생시킬 수 있는 여지도 생겼습니다.

기능을 추가할 때마다 그저 IF문을 하나씩 더 추가하는 식으로 떼우게 된다면 코드 전체가 더 엉망이 되는데 기여하는 것일 뿐입니다. - the big ball of mud

그러면 어떻게 원래 캐시 코드를 간단하게 유지할 수 있을까요?

간단한 것을 복잡하게 만드는 대신 코드를 복제합시다.

코드를 복제하면 어느 부분을 공유해서 재사용했는지 알기가 더 쉬워집니다. 단 한가지 일만 하는 코드를 여럿 짜고, 이 모두를 섞어서(엮어서) 또다른 도구를 만들어냅시다. 이 내용은 새로운 것이 아닌 이전에도 수많이 언급되었던 내용입니다.

그러면 어떻게 간단한 캐시를 복잡하게 만들지 않고 만료되는 캐시를 구현할 수 있을까요?

예제에서 만료 기능은 초기 코드 위에 손쉽게 얹어질 수 있습니다:

const cache = () => {
  const store = {}

  const set = (key, value) => {
    store[key] = value
  }

  const remove = key => {
    const value = store[key]
    delete store[key]
    return value
  }

  return { set, remove }
}

const expire = (cache, ttl, expirationHandler) => {
  const timers = {}

  const set = (key, value) => {
    // Store value
    cache.set(key, value)
    // Clear existing timer
    clearTimeout(timers[key])
    // Set expiration timer
    timers[key] = setTimeout(() => {
      const value = cache.remove(key)
      delete timers[key]
      expirationHandler(key, value)
    }, ttl)
  }

  const remove = key => {
    clearTimeout(timers[key])
    delete timers[key]
    return cache.remove(key)
  }

  return { set, remove }
}

// Let's use the simple cache

const simpleCache = cache()

simpleCache.set('a', 1)
simpleCache.set('b', 2)
simpleCache.set('b', 3)

console.log(simpleCache.remove('a')) // 1
console.log(simpleCache.remove('b')) // 3
console.log(simpleCache.remove('b')) // undefined

// Let's use the expiring cache

const expirationHandler = (key, value) => {
  console.log(`expired ${key}: ${value}`)
}
const expiringCache = expire(cache(), 1000, expirationHandler)

expiringCache.set('a', 1)
expiringCache.set('b', 2)

console.log(expiringCache.remove('a')) // 1
console.log(expiringCache.remove('a')) // undefined
setTimeout(() => {
  console.log(expiringCache.remove('b')) // undefined
}, 1100)

이 예처럼 몇몇의 경우에는 쉽게 코드가 구성될 수 있습니다. 다른 경우라면 부분부분을 재사용해야겠죠. 로직의 부분부분을 작은 함수단위로 나누고 그걸 공유해서 사용하면 그걸 또다른 도구로 사용할 수 있게 됩니다.

기존 코드에서 새로운 부분(조건)을 추가할 때마다 주의를 해야할 필요가 있습니다. 코드에서 어느 부분이 분리될 수 있고 재사용할 수 있을지에 대해 생각해보세요. 코드를 복사하여 재사용하는 것에 대해 두려워하지 마세요.