Home

소프트웨어 개발과 설계

2020-04-11

개요

백엔드 어플리케이션을 개발하면서 지금까지 소프트웨어 개발에 대해 고민한 것들을 정리한다.

설계는 복잡성을 현명하게 다루기 위해 필요하다

DDD 책의 표지를 보면 부제목으로 “소프트웨어의 복잡성을 다루는 지혜”라는 글귀가 적혀있다. 이것이야말로 어플리케이션 개발자가 가져야 하는 가장 중요한 능력이라고 생각한다.

소프트웨어란 무엇인가? 소프트웨어는 현실의 문제를 해결하여 새로운 가치를 창출해내는 컴퓨터 프로그램이다. 소프트웨어는 세상에 존재하는 프로세스를 1. 자동화하고 2. 빠르게 처리하고 3. 온라인에서 일어나게 하여 새로운 가치를 창출한다. 즉, 현실에서 사람의 손으로 문제를 해결하던 프로세스를 컴퓨터가 대신 수행하도록 모델링한 것이다.

이런 소프트웨어에 대한 개념을 바탕으로 소프트웨어의 복잡성에 대해 이야기해보자.

첫 번째로, 소프트웨어는 소프트웨어에 담으려는 현실이 복잡하기 때문에 복잡하다. 실제 세계에 존재하는 문제는 매우 복잡하다. 현실의 문제를 다루는 프로세스 역시 간단하지 않다. 프로세스는 오랜 시간에 걸친 시행착오를 통해 진화하며, 그 과정에서 온갖 예외 상황들에 대한 처리 방식이 적립된다. 새로운 예외 사항이 발견되거나, 문제가 바뀌거나, 더 많은 문제를 풀려고 하면 새로운 프로세스가 추가될 것이다. 소프트웨어에 담아내야 하는 프로세스는 온갖 예외 케이스로 떡칠된 무언가이다. 많은 요구사항을 정확히 수행하는 소프트웨어는 필연적으로 복잡해진다.

또한, 소프트웨어는 “소프트”하지만 사람 만큼 유연하지는 않다. 개발자가 시스템을 변경할 수 있는 속도는 사람이 프로세스를 바꿀 수 있는 속도보다 느리다. 하지만 소프트웨어는 발전하는 프로세스를 지속적으로 반영해서 진화해야 한다. 이러한 모순이 두 번째 복잡성을 낳는다. 소프트웨어는 자기보다 빠르게 변화하는 프로세스의 발전을 계속해서 따라잡을 수 있어야 한다. 그러려면 소프트웨어는 아무렇게나 개발되어서는 안 된다. 언제나 변경을 빠르게 수용할 수 있는 형태로 존재해야 한다.

설계는 복잡한 현실을 적절히 모델링하고, 앞으로 다가올 복잡한 변경 사항을 빠르게 반영하기 위해 필요하다.

설계는 변경의 비용이 높은 결정을 미리 잘 내리는 것이다

그렇다면 설계는 도대체 무엇이길래 복잡성을 잘 다루게 해주는 것일까? 소프트웨어 설계에 대한 정의는 비슷한 듯 모두 다르다. 그래서 자기만의 정의를 가지고 있는 것이 중요하다고 생각한다.

내가 생각하는 소프트웨어 설계란, 변경의 비용이 높은 결정을 미리 잘 내리는 것이다. 명사로 사용되면 이러한 결정으로 인해 정해진 소프트웨어의 구조를 뜻한다. 여기서 말하는 변경의 비용이 높은 결정에는 다양한 것이 포함될 수 있지만, 결국 의존성으로 요약해서 이야기할 수 있다. 누군가가 의존하는 것은 변경하기 힘들다.

DB 스키마는 한 번 결정하면 변경의 비용이 크다. 서버 로직, 데이터 파이프라인, 모니터링 메트릭 등 어플리케이션의 모든 부분이 DB에 의존한다. 또한 데이터는 한 번 적재되기 시작하면 새로운 형태로 마이그레이션하기 힘들다. DB는 롤백도 어렵다. 그래서 DB는 한 번 배포되고 나면 스키마를 변경하기 매우 힘들다.

아키텍처도 변경의 비용이 크다. 열심히 계층을 나누고 컴포넌트를 쪼개서 책임을 분배한 후 개발했는데, 이후에 아키텍처를 변경하려고 하면 많은 코드를 다시 작성해야 한다. 이 과정에서 변경된 컴포넌트에 의존하던 다른 소프트웨어의 동작 방식이 달라져서 버그가 발생할 수도 있다.

비슷한 이유로 MSA에서는 서비스의 경계를 정하는 것도 변경의 비용이 크다. 여기는 DB랑 배포 전략도 얽혀있으니 더욱 골치 아프다.

서비스간의 통신 방식도 변경의 비용이 크다. HTTP를 활용하여 통신하는 시스템을 message queue를 활용하는 방식으로 변경하려면 비즈니스 로직의 많은 부분을 새롭게 짜야 할 것이다. 동기적 통신에서 비동기적 통신으로 바뀌었기 때문이다.

왜 변경의 비용이 높은 결정을 미리 잘 내려야 할까? 실제 세계가 빠르게 변화하는 만큼 소프트웨어 역시 필연적으로 변해야 하기 때문이다. 최대한 적은 비용으로 많은 변경을 수용할 수 있는 구조로 소프트웨어를 설계해 놓아야 한다. 그렇지 않다면 소프트웨어는 현실의 프로세스가 변화하는 속도를 따라갈 수 없고, 따라서 가치를 제대로 창출해낼 수 없다. 만약 비용이 큰 결정을 잘못 내려서 이후에 이 결정을 번복해야 한다면 이는 매우 치명적인 문제로 되돌아올 것이다.

좋은 설계는 미래를 고려하는 설계이다

위에서 언급한대로, 실제 세계가 변화하기 때문에 소프트웨어는 필연적으로 변화해야 한다. 하지만 대부분의 경우 개발자가 소프트웨어를 변경할 수 있는 속도보다 실제 세계가 훨씬 더 빠르게 변화한다. 그래서 훌륭한 설계는 미래에 들어올 요구사항을 최대한 값싼 작업으로 수용할 수 있어야 한다.

시스템을 변경할 때 가장 값싼 작업은 무언가를 추가하는 것이다. 예를 들어 enum type 추가, 필드 추가, 메소드 추가, interface 구현체 추가 등 무언가를 추가하는 행위는 기존 시스템을 변경하는 것보다 훨씬 값싸다. 무언가를 추가만 할 때는 새로운 스펙만 고려하면 되지만, 변경은 여기에 더해 기존 스펙을 지키는지도 고려해야 한다.

만약 반드시 변경이 발생해야 한다면 특정 메소드나 컴포넌트 하나의 구현만 변경되는 상황이 가장 좋다. 단일 책임 원칙과 같은 말이다. 변경이 여러 군데에서 발생하면 기존 스펙에 영향을 주는 부분이 점점 많아진다.

좋은 설계는 어떻게 할 수 있을까? 간단하다. 소프트웨어가 어떻게 변화할지 상상해보는 것이다. 지금 설계에서 미래의 들어올 변경을 얼마만큼 값싼 작업으로 수용할 수 있는지를 계산해본다. 계산된 비용이 가장 낮은 설계가 가장 좋은 설계다.

이는 간단하지만 쉽지는 않다. 사실 매우 어렵다. 도대체 미래에 들어올 스펙을 어떻게 다 예측한단 말인가? 어쩔 수 없다. 미래에 발생할 수 있는 요구사항을 최대한 많이 브레인스토밍 해보고, 각각의 가능성을 잘 따져보는 수밖에. 이렇게 계산된 가능성을 바탕으로 지금의 설계에 stress test를 해보는 것이 중요하다. 더 많은 미래를 고민할수록 좋은 설계가 나올 확률도 높아진다.

미래를 고려할 때 다른 팀과의 커뮤니케이션이 큰 도움이 될 수 있다. PM팀, 기획팀, 마케팅팀, UI/UX 팀 등 다른 팀에서 머리에 담아두고 있는 추가 스펙이나 기능의 고도화 방향성이 있을 수 있다. 이런 부분을 개발자가 먼저 물어보면 좋은 설계를 만드는 데에 큰 도움이 된다.

모든 요구사항을 수용하는 완벽한 설계 따위는 당연히 존재하지 않을 것이다. 그래서 중요한 것은 최선의 설계를 찾는 것이다. 미래에 발생할 수 있는 요구사항이 각각 몇 퍼센트의 확률로 발생할 수 있을지. A, B, C라는 서로 다른 설계가 가능하다면 각 요구사항을 수용하기 위해 얼마만큼의 비용이 발생하는지. 이렇게 추산된 비용의 trade-off를 통해 지금 가장 나아보이는 설계를 고른다.

하나 주의해야 할 점은 너무 먼 미래를 고려해서 과한 추상을 도입하지 않는 것이다. A라는 변경이 발생할 것으로 기대하고 일반화를 하고 추상을 도입했는데 나중에 A라는 변경이 발생하지 않으면 미리 도입한 추상은 오히려 변경의 비용을 높일 뿐이다. 설계의 비용을 예상할 때는 미리 내린 선택 역시 나중에 비용이 될 가능성이 있음을 고려해야 한다.

시스템을 지속적이고 점진적으로 개선해야 한다

훌륭한 설계를 기반으로 구축된 시스템도 한계를 가진다. 현재의 설계로는 수용할 수 없는 요구사항이 발생하면 개발자는 선택의 기로에 선다. 소프트웨어의 설계를 변경할 것인가, 아니면 기존 설계에 어떻게든 기능을 우겨넣을 것인가?

전자는, 더 좋은 설계로 바꾼다는 가정 하에, 지금 당장의 비용이 크지만 이후의 요구사항은 더 빠르게 수용할 수 있다. 후자를 선택하면 지금 당장 빠르게 개발할 수는 있지만 시스템은 점점 복잡해진다. 이후의 요구사항을 수용하는 데에 드는 비용과 설계를 변경하는 데에 드는 비용이 점점 더 커진다. 소프트웨어의 설계를 변경하고 리팩토링을 감행할 시점을 판단하는 것은 순수히 경험으로부터 나오는 개발자의 감각에 달린 듯하다.

이미 배포된 기존 시스템을 변경하는 것은 새로운 시스템을 구축하는 것보다 훨씬 어렵다. 앞서 말했듯이 변경을 할 때는 새로 추가되는 요구사항 뿐만 아니라 시스템이 기존에 가지고 있던 요구사항까지 모두 만족하는 것을 확인해야 한다. 만약 단순히 한 서비스 내에서 일어나는 리팩토링이 아니라 여러 서비스에 걸친 리팩토링이라면 무중단으로 배포하는 것은 더욱 어려울 것이다. DB 스키마를 변경해야 한다면 무척 끔찍한 일이 될 것이다.

변경된 시스템이 기존 요구사항을 만족하는지 확인하는 가장 좋은 방법은 많은 테스트이다. 스펙에 대한 테스트가 존재하면 빌드 한 번으로 리팩토링 이후에도 여전히 소프트웨어가 스펙을 만족하는지 확인할 수 있다. 테스트가 많으면 많을 수록 개발자는 안심하고 리팩토링을 진행할 수 있다.

무중단으로 리팩토링을 진행하는 방법은 의존을 받는 컴포넌트부터 차례로 수정하고 배포하는 것이다. 여기에는 cyclic한 의존성이 없다는 전제조건이 깔린다.

예를 들어보자. A → B 라는 컴포넌트와 의존성이 있고, 이를 A’ → C → B’ 로 리팩토링 한다고 하자. 그러면 배포 절차를 아래와 같이 가져가야 무중단으로 배포할 수 있을 것이다.

  1. C와 A에 대해 모두 정상적으로 동작하는 B’을 개발하고 B’을 배포한다. (A → B’)
  2. A’에 대해 정상적으로 동작하고 B’에 의존하는 C를 개발하고 C를 배포한다. (A → B’ / C → B’)
  3. C에 의존하는 A’을 배포한다. (A’ → C → B’)
  4. B’에서 A를 위한 코드를 제거하고 다시 배포한다.

DB 스키마의 변경 역시 비슷하다. 특정 테이블의 A라는 column을 B와 C라는 column으로 대체하고 싶다고 하자. 여기서는 어플리케이션 → DB 라는 의존성이 있는 것이다.

  1. 테이블에 B, C column을 추가한다.
  2. 어플리케이션에서 A, B, C column에 대해 모두 데이터를 적재하도록 하고 배포한다. 이 때 B, C column은 데이터를 쌓기만 하고 로직에는 사용하지 않는다.
  3. 기존 row에 대해서 A column의 값을 B, C column으로 적절히 마이그레이션 한다.
  4. 어플리케이션에서 B, C column만 바라보도록 코드를 수정하고 다시 배포한다.
  5. 필요하다면 A column을 제거한다.

기존 시스템의 변경은 많은 요구사항을 만족시켜야 하고, 또한 점진적으로 이루어져야만 한다. 그래서 극히 어렵다. 하지만 이러한 변화는 어렵다고 피해갈 수 있는 것이 아니다. 소프트웨어가 손을 댈 수 없을 만큼 복잡해지기 전에 소프트웨어를 실제 세계에 맞춰 지속적으로 재설계하는 것이 중요하다.