소프트웨어 개발의 어려움은 늘 경계에 있다. 아니라고 생각한다면 우리가 자주 겪는 문제를 떠올려보자. 어느 날 기능이 안된다는 보고가 들어왔다. 급하게 코드와 확인했지만 바뀐 것은 없고 테스트도 다 통과했다. 급하게 로그를 열고 원인을 추적해보니 외부 API의 응답 형식이 바뀌어서 로직이 깨진 것이었다.

흔히 있는 일이 아니라고 생각든다면 설계 회의를 할 때를 떠올려보자. 누군가 화이트보드에 박스를 그리고 화살표를 잇는다. 이윽고 “이 서비스를 나눠야 하나 합쳐야 하나?”, “이 데이터는 누가 소유하는게 좋을까?”와 같은 여러 의견이 빗발친다. 논쟁은 박스 안이 아니라 항상 박스와 박스 사이에서 벌어진다.

평범히 코드를 짜는 일상도 다르지 않다. 이 예외를 여기서 처리할 것인가, 호출자에게 넘길 것인가, 이 값이 null일 수 있는지 없는지 등 개발자의 고민은 코드의 내부 로직이 아니라 코드와 코드가 만나는 접점에 몰려 있다.

바보야, 문제는 경계야!

이것이 이 글의 핵심 주장이다. 우리는 조금 더 경계에 집중할 필요가 있다. 물론 모든 문제가 경계에서 온다고 말하는 것은 아니다. 알고리즘의 논리적 오류나 단순한 오타는 경계와 무관하다. 그러나 실무에서 개발자를 괴롭히는 문제들을 떠올려보자.

  • 이 클래스가 단일 책임 원칙을 잘 지키고 있는지 어떻게 판단할 수 있을까?
  • 우리 서비스에 꼭 필요한 외부 API가 실패한다면 어떻게 해야할까?
  • 둘 이상의 데이터베이스를 사용할 때 어떻게 데이터 일관성을 유지할 수 있을까?
  • UI 컴포넌트의 분리 기준은 무엇일까?
  • 공통 라이브러리는 누가 관리해야 할까?

한 번 쯤 고민해본 일들이 아닌가? 이런 모호한 문제들은 놀라울 정도로 경계에 집중되어 있다. 사실 이는 소프트웨어만의 이야기 뿐만은 아니다. 경계의 문제는 인간 세계 전반에 걸쳐 있지만, 그 이야기는 이 글의 범위를 아득히 벗어나므로 여기서는 소프트웨어 개발에서 경계가 만들어내는 문제들을 살펴보고 그 경계를 어떻게 다스릴 수 있는지 이야기해보자.

경계란 무엇인가

그렇다면 경계란 정확히 무엇일까? 경계란 서로 다른 관심사, 책임, 또는 맥락이 만나는 지점이다. 함수와 함수 사이에는 호출이라는 경계가 있고, 모듈과 모듈 사이에는 인터페이스라는 경계가 있다. 코드를 벗어나더라도 클라이언트와 서버 사이에는 네트워크라는 경계가 있고, 애플리케이션과 데이터베이스 사이에는 쿼리라는 경계가 있다.

경계는 중요하다. 우리가 소프트웨어를 만들 때 하는 거의 모든 행위가 경계를 만드는 행위이기 때문이다. 함수를 나누는 것, 클래스를 정의하는 것, 모듈을 분리하는 것, 서비스를 쪼개는 것과 같은 작업은 관심사를 분리하고 복잡성을 관리하기 위한 경계 설정이라 할 수 있다. 소프트웨어 공학의 역사는 어떻게 보면 경계를 어디에, 어떻게 그을 것인가에 대한 답을 찾아온 과정이기도 하다. 구조적 프로그래밍은 제어 흐름에 경계를 만들었고 객체지향은 데이터와 행위에 경계를 만들었으며 여러 시스템 디자인 패턴은 시스템과 시스템 사이의 경계를 관리하는 방법을 제시한다. 이러한 역사를 통해 분할 정복은 소프트웨어 개발의 핵심이자 기본 원칙이 되었다.

분할 정복을 하면 모든 것이 해결될 것 같지만 여기서 아이러니가 발생한다. 경계는 복잡성을 다루기 위해 만들지만 경계 자체가 새로운 복잡성의 원천이 된다. 하나의 덩어리로 존재하던 코드를 둘로 나누는 순간 그 둘 사이의 소통 방식을 정의해야 한다. 누가 누구를 호출하는지, 어떤 데이터를 주고받는지, 에러가 발생하면 누가 어떻게 처리하는지 등 경계가 없었다면 존재하지 않았을 문제들은 경계와 함께 태어난다.

그렇다고 경계를 만들지 않을 수는 없다. 경계 없는 소프트웨어는 모든 것이 뒤엉킨 혼돈이며 인간의 인지 능력으로는 다룰 수 없다. 꼭 인지 능력이 아니더라도 시스템이 커지면 단일 서버의 한계 때문에 분산 처리가 필요해지고 그 순간 네트워크라는 경계가 불가피하게 생겨난다. 조직이 커지면 한 팀이 모든 코드를 소유할 수 없고 팀과 팀 사이에 경계가 만들어진다. 따라서 우리는 경계를 만들 수밖에 없고 따라서 경계에서 발생하는 문제와도 함께 살아갈 수밖에 없다. 중요한 것은 경계가 어디에 있는지 인식하고 그 경계에서 어떤 문제가 발생할 수 있는지 이해하는 것이다.

경계를 다루려면 경계 자체의 성질을 알아야 한다. 네트워크 경계가 발생하면 어떠한 일이 발생할 수 있는가? 혹은 데이터베이스를 둘로 나누면 어떤 문제가 생기는가? 혹은 팀을 나누면 어떤 문제가 생기는가? 이처럼 경계가 어디에 있는지 아는 것만으로는 부족하다. 그 경계가 어떤 성질을 가지는지 이해해야 비로소 제대로 다룰 수 있다.

경계는 어쩔 수 없이 생긴다. 아이러니하게도 경계를 이해하려는 이 글에서도 마찬가지다. 이제부터 경계가 만들어내는 문제를 코드의 경계, 물리의 경계, 그리고 사람의 경계로 나누어 살펴본다.

코드의 경계

코드는 뭉쳐져 있어도 실행에 아무런 문제가 없다. 논리만 맞다면 모든 코드는 하나의 함수 안에 있어도 된다. 하지만 그렇게 하지 않는 이유는 우리가 사람이기 때문이다. 사람은 한 번에 너무 많은 것을 처리할 수 없기 때문에 의식적으로 코드를 분리한다. 함수를 쪼개고, 추상화를 하고, 외부 라이브러리에 의존하고, 데이터를 변환하는 모든 행위를 통해 우리가 인지할 수 있는 경계를 만든다. 하지만 각 경계는 고유한 문제가 발생한다.

호출자와 피호출자의 경계

가장 원초적인 경계는 호출하는 쪽과 호출당하는 쪽 사이에 존재한다. 하나의 함수가 다른 함수를 호출하는 단순한 행위에서조차 경계 문제가 발생한다.

User getUser(String id) {
  // 사용자가 없으면 null을 반환할까? 예외를 던질까?
  // 이 결정은 호출자의 삶을 완전히 바꾼다.
}

void processOrder(String userId) {
  User user = getUser(userId);
  // user가 null일 수 있다는 것을 알고 있는가?
  // getUser가 예외를 던질 수 있다는 것을 알고 있는가?
  user.getName() // NullPointerException이 터질 수도 있다
}

이 코드에서 문제의 핵심은 계약의 부재 혹은 계약의 모호함이다. getUser는 사용자를 찾지 못했을 때 null을 반환하는가, 예외를 던지는가? processOrder는 그 사실을 알고 있는가? 호출자와 피호출자 사이의 경계에서 이 계약이 명확하지 않으면 문제가 발생한다.

이 문제는 방어적 프로그래밍과 계약에 의한 설계라는 두 가지 철학으로 이어진다. 방어적 프로그래밍은 경계를 불신하는 접근으로 상대방이 무엇을 보내든 방어적으로 대응한다. 이어서 계약에 의한 설계는 경계를 명시적 계약으로 정의하는 접근이며 계약을 어긴 쪽이 책임을 진다. 어떤 접근이 좋다고 단정할 수는 없지만 두 접근 모두 경계에서의 계약이라는 문제를 해결하려는 시도라는 점에서 공통된다.

양쪽 모두 상대방이 처리할 것이라 기대하고, 아무도 처리하지 않는다

더 근본적으로 보면, 이 문제는 책임의 경계 문제이기도 하다. 입력값의 유효성은 누가 검증해야 하는가? 에러 처리의 책임은 누구에게 있는가? 이런 질문에 명확한 답이 없을 때 버그가 태어난다. 경계에서 책임이 모호해지는 순간 양쪽 모두 상대방이 처리해줄 것이라 기대하고 결국 아무도 처리하지 않는 상황이 벌어질 수 있다.

인터페이스의 경계

호출자-피호출자 관계가 함수 레벨의 경계라면 인터페이스의 경계는 추상화 계층 사이의 경계다. 우리는 복잡성을 감추기 위해 추상화 경계를 만들고 그 추상화의 표면을 인터페이스라 부른다. 문제는 이 추상화가 완벽하지 않다는 데 있다. 추상화는 본질적으로 정보의 손실이다. 아래 계층의 복잡성을 감추는 동시에 우리가 알아야 할 것조차 모르게 만들어버린다. 그리고 그 감춰진 복잡성은 언젠가 반드시 경계를 뚫고 올라온다. 조엘 스폴스키1는 이를 추상화 누수의 법칙이라 불렀다.

String content = Files.readString(Path.of("data.txt"));

파일 읽기는 한 줄이면 된다. 하지만 readString 한 줄이 감추고 있는 것은 파일 시스템, 운영체제, 디스크 하드웨어, 네트워크에 이르는 거대한 계층이다. 경로가 네트워크 드라이브를 가리킨다면 지연과 타임아웃이 발생할 수 있다. 다른 프로세스가 파일을 쓰고 있다면 운영체제에 따라 파일 잠금에 걸리거나, 잠금 없이 불완전한 데이터를 읽게 된다. 인코딩이 UTF-8이 아니라면 깨진 문자열을 얻게 된다. 파일이 10GB라면 OutOfMemoryError와 마주한다. 대부분의 경우 잘 동작하지만 운영 환경에서 예상치 못한 조건을 만나면 개발자는 한 줄의 코드가 감추려 했던 아래 계층을 다시 직면해야 한다.

추상화가 높아질수록 단순해지지만, 감춰진 정보도 함께 늘어난다

언어 차원의 추상화도 예외는 아니다. 예를 들어, Java에서 문자열을 + 연산자로 합치는 것은 직관적이고 깔끔해 보이지만 반복문 안에서 수천 번 실행되면 매번 새로운 String 객체가 생성되어 성능이 급격히 저하된다. 결국 개발자는 StringBuilder라는 추상화 아래 계층의 도구를 꺼내 들어야 한다. 추상화의 경계가 높을수록 복잡성이 줄어들고 이해할 정보도 줄어들지만 그만큼 잃어버리는 정보도 많아진다. 이것이 인터페이스 경계의 본질적인 어려움이다.

의존성의 경계

다음은 내가 통제하는 코드와 통제하지 못하는 코드의 경계다. 외부 라이브러리, 외부 서비스, 런타임 환경 등 우리가 직접 작성하지 않은 모든 것은 경계 너머에 존재한다. 마이클 나이가드는 자신의 저서 Release의 모든 것에서 “통합 지점은 시스템에서 일급 살인자다”2라고 말했다. 이 말처럼 시스템이 외부와 통합하는 모든 지점은 잠재적 장애가 발생할 수 있다. 그리고 이런 일은 잊을만하면 발생한다.

경계 너머의 코드는 예고 없이 변한다

어제까지 잘 동작하던 시스템이 오늘 갑자기 실패해서 원인을 추적해보니 외부 API가 응답 필드명을 예고 없이 바꿨거나, 의존하던 라이브러리가 버전을 올리며 하위호환성을 깨뜨리는 경우도 있다. 이런 경우 내 코드에는 버그가 없는데도 시스템은 무너진다. 이러한 문제가 특히 까다로운 이유는 경계 너머를 통제할 수 없기 때문이다. 의존성의 경계는 내가 통제할 수 있는 영역의 한계를 보여준다.

표현의 경계

소프트웨어에서 같은 개념이라도 시스템마다 표현하는 방식이 다르다. 이 차이가 경계에서 충돌을 일으킨다. 가장 잘 알려진 예는 객체-관계 임피던스 불일치다. 같은 데이터라도 애플리케이션은 객체로 세상을 모델링하고 데이터베이스는 테이블과 행으로 세상을 모델링한다.

class User(
  val id: Long,
  val name: String,
  val roles: List<Role> // 코드에서는 그저 리스트일 뿐이다
)

코드에서 user.roles는 그저 리스트일 뿐이지만, 이를 데이터베이스에 저장하려면 중간 테이블이 필요하다. 객체에선 단순한 컬렉션 하나가 테이블 설계에서는 완전히 다른 구조를 요구하는 것이다. 두 패러다임이 근본적으로 다르기 때문에 이 경계를 넘을 때마다 변환이 필요하고 변환 과정에서 정보가 왜곡되거나 손실된다. ORM은 이 경계를 감추려는 시도지만 앞서 살펴본 것처럼 추상화는 결국 새어 나온다.

경계를 넘을 때 데이터의 형태가 달라지는 문제도 있다. 서버의 도메인 모델을 API 응답으로 내보낼 때 JSON으로 직렬화하고, 클라이언트는 이를 다시 자신만의 모델로 변환한다. 각 변환 지점에서 타입이 바뀌고, 구조가 바뀌면서 의미가 미묘하게 달라질 수 있다. 이런 문제를 줄이기 위해 DTO(Data Transfer Object)같은 경계 전용 객체를 두기도 하지만 이는 경계마다 변환 코드가 늘어나는 또 다른 비용을 수반한다.

같은 User 데이터가 경계를 넘을 때마다 다른 형태로 변환된다

신뢰의 경계

신뢰의 경계란 검증된 데이터와 검증되지 않은 데이터가 만나는 지점이다. 경계 안쪽의 데이터는 이미 검증을 거쳤으므로 신뢰할 수 있다. 그러나 경계 바깥에서 들어오는 데이터는 원칙적으로 신뢰할 수 없다.

가장 기본적으로는 데이터 검증이 있다. 사용자로부터 입력을 받을 때 입력이 악의적일 수도 있고 단순한 실수일 수도 있다. 입력값이 유효한지, 예상 범위 내에 있는지, 형식이 맞는지 등을 검증하지 않고 그대로 사용하면 시스템이 예기치 않게 동작할 수 있다.

혹은 신뢰할 수 있는 대상에게만 데이터를 받아야할 수도 있다. 예를 들어, 외부 결제 서비스로부터 웹훅을 수신하는 상황을 생각해보자. 결제가 완료되면 외부 서비스가 우리 서버로 HTTP 요청을 보내준다. 그런데 이 요청이 정말 해당 서비스에서 보낸 것인지, 누군가 위조한 것은 아닌지 어떻게 알 수 있을까? 서명 검증 없이 요청 본문을 그대로 신뢰하면 공격자가 가짜 결제 완료 요청을 보내 상품을 탈취할 수 있다.

경계를 넘는 모든 데이터는 검증을 거쳐야 한다

결국 경계는 검증과 보안이라는 복잡성을 낳는다. 경계 안쪽에서는 데이터가 이미 검증되었다고 가정할 수 있지만 경계 바깥에서 들어오는 모든 데이터는 잠재적인 공격이 될 수 있다.

설계와 구현의 경계

아키텍처 다이어그램을 그릴 때 깔끔한 박스와 화살표로 시스템을 표현하면 모든 것이 명확해 보인다. 그런데 실제 코드를 열어보면 다이어그램과는 사뭇 다른 세상이 펼쳐진다. “지도는 영토가 아니다”라는 격언처럼 단순화하고 추상화한 것은 반드시 현실과 차이가 있다. 요구사항이 설계로 번역될 때 일부가 손실되고 설계가 코드로 구현될 때 또 일부가 손실된다. 경계를 넘을 때마다 정보가 손실되는 것이다.

경계를 넘을 때마다 원래 의도가 조금씩 손실된다

이 경계가 문제가 되는 이유는 손실이 누적되기 때문이다. 요구사항 → 설계 → 구현 → 테스트 → 운영으로 이어지는 각 단계 사이에 경계가 있고, 각 경계에서 조금씩 정보가 변형된다. 최종적으로 운영되는 시스템은 원래 요구사항과 상당한 간극을 가질 수 있다.

디자인 시안과 실제 구현 사이의 괴리도 같은 맥락이다. 디자이너가 그린 시안은 정적이고 이상적이다. 모든 텍스트는 적절한 길이이고, 이미지는 완벽한 비율이며, 네트워크는 항상 빠르다. 하지만 실제 구현에서는 텍스트가 넘치고, 이미지가 깨지며, 로딩 상태와 에러 상태를 처리해야 한다. 이상적 설계와 현실적 구현 사이의 경계에서 수많은 엣지 케이스가 태어난다.

물리의 경계

코드의 경계가 개발자의 선택에 의해 만들어진다면 물리의 경계는 물리적 제약이 강제하는 것들이다. 비동기 실행은 순서를 보장하지 않고, 규모는 임계점을 넘으면 성질이 바뀌고, 환경은 언제나 다르며, 하나의 작업은 경계를 넘는 순간 쪼개질 수 있다. 이 경계들은 코드를 아무리 읽어봐도 보이지 않지만 실행 시점에 비로소 드러나기에 더욱 위험하다.

순서의 경계

소프트웨어에서 가장 교묘한 경계는 순서다. 동기 코드에서는 실행 순서가 명확하다. A가 끝나면 B가 시작되고 B가 끝나면 C가 시작된다. 그러나 비동기의 세계로 넘어가는 순간부터 순서는 보장되지 않는다.

// 동기적으로 보이지만, 각 suspend 지점 사이에 순서의 경계가 존재한다
suspend fun buildOrderSummary(id: String): OrderSummary {
  val user = userService.getUser(id)
  // ⏳ 이 사이에 user의 상태가 바뀔 수 있다
  val orders = orderService.getOrders(user.id)
  // ⏳ 이 사이에 새로운 주문이 추가될 수 있다
  return createSummary(user, orders)
  // summary는 이미 과거의 스냅샷이다
}

코루틴의 suspend 지점은 코드를 동기적으로 보이게 만들지만, 실제로는 각 suspend 사이에 순서의 경계가 존재한다. 그 경계 사이에서 다른 연산이 끼어들고, 세상이 바뀔 수 있다. 레이스 컨디션, 이벤트 순서 역전, stale 데이터 문제는 모두 이 순서의 경계에서 비롯된다.

suspend 지점 사이에 다른 연산이 끼어들어 기대한 순서가 깨진다

분산 시스템에서 순서의 경계는 더 극적이다. 서로 다른 서버의 시계는 미세하게 다르고 네트워크 지연은 이벤트의 순서를 뒤바꿀 수 있다. 기대한 순서와 실제 순서 사이의 경계는 분산 시스템의 가장 근본적인 문제 중 하나다.

규모의 경계

작은 규모에서 동작하던 것이 규모가 커지면 깨진다. 이것은 단순히 성능이 느려진다는 의미가 아니다. 규모가 특정 임계점을 넘는 순간부터 시스템의 동작 자체가 근본적으로 달라질 수 있다.

규모가 임계점을 넘을 때마다 복잡도는 비선형적으로 점프한다

이 현상의 핵심은 비선형성이다. 규모가 10배가 되면 문제가 10배 어려워지는 것이 아니라 100배, 1000배 어려워질 수 있다. 이는 규모의 경계가 양적 변화가 아닌 질적 변화를 가져오기 때문이다. 메모리 안에서의 프로그래밍과 분산 시스템에서의 프로그래밍은 같은 언어를 사용하더라도 본질적으로 다른 활동이다.

가장 큰 문제는 이 경계가 미리 예측하기 어렵다는 것이다. 모든 시스템은 비즈니스 목적에 따라 다르므로 어느 지점에서 임계점이 올지 그때 어떤 문제가 발생할지를 정확히 예측하는 것은 거의 불가능하다. 그래서 규모의 경계는 대부분 사후적으로 발견된다.

환경의 경계

제 컴퓨터에서는 잘 되는데요라는 말은 환경의 경계를 완벽하게 설명하는 말이다. 같은 시스템이더라도 환경에 따라 언제든 다르게 동작할 수 있다. 환경은 여러 추상화 계층에 의해 감춰져 있기에 언제든 문제가 발생 할 수 있다.

환경은 가장 밑바닥인 하드웨어부터 시작해서 운영체제, 런타임, 라이브러리, 환경 변수, 시간대 등 다양한 층위로 구성되어 있다. 이 중 하나라도 차이가 나면 같은 코드가 다르게 동작한다. 예를 들어, 로컬 개발 환경에서는 KST(한국 표준시)를 사용하지만 서버는 UTC(협정 세계시)를 사용한다면 날짜 계산이 밀리는 문제가 발생할 수 있다.

// 오늘 시작부터 끝까지 생성된 사용자 목록을 가져오는 함수
fun getRegisteredToday(): List<User> {
  val startOfDay = LocalDate.now().atStartOfDay()
  val endOfDay = startOfDay.plusDays(1)
  return userRepository.findByCreatedAtBetween(startOfDay, endOfDay)
}

로컬에서는 정확히 오늘 가입한 사용자를 반환하지만, UTC 서버에서는 9시간 차이로 어제 늦은 가입자가 포함되거나 오늘 가입자가 빠질 수 있다. 그 외에도 파일 경로의 대소문자 구분 차이3로 파일을 찾지 못하거나 메모리 제한으로 로컬에서는 발생하지 않던 메모리 부족이 운영 서버에서 터지는 등의 문제가 생길 수 있다.

Docker와 같은 컨테이너 기술은 이 문제를 완화하기 위해 등장했다. 환경의 경계를 좁히려는 시도다. 그러나 컨테이너조차 완벽하게 모든 환경 문제를 해결하지는 못한다. 환경의 경계는 오히려 기술이 발전하며 추상화 계층이 생기기에 발생하기에 완전히 사라지기 힘든 문제다.

원자성의 경계

하나의 작업이 여러 경계를 넘어가야 할 때 원자성의 문제가 발생한다. 원자성이란 작업이 전부 성공하거나 전부 실패해야 한다는 것을 말한다. 경계 안에서는 비교적 쉽게 보장할 수 있지만 경계를 넘으면 급격히 어려워진다.

// 주문 처리: 세 서비스의 경계를 넘어야 하는 하나의 작업
fun processOrder(order: Order) {
  paymentService.charge(order.payment)    // ✓ 성공
  inventoryService.deduct(order.items)    // ✗ 실패!
  notificationService.send(order.userId)  // — 실행되지 않음

  // 결제는 됐는데 재고는 차감되지 않았다.
  // 사용자에게는 어떤 상태를 보여줘야 하는가?
}

단일 데이터베이스 안에서라면 트랜잭션으로 쉽게 원자성을 보장할 수 있다. 그러나 작업이 여러 서비스, 여러 데이터베이스에 걸쳐있다면 이야기가 달라진다. 분산 환경에서 진정한 원자성을 보장하는 것은 사실상 불가능하다.

하나의 작업이 서비스 경계를 넘을 때, 원자성은 더 이상 보장되지 않는다

결국 완벽한 원자성 대신 최종적 일관성이라는 타협을 선택하여 아웃박스 패턴이나 보상 트랜잭션과 같은 패턴이 등장했지만, 이런 패턴은 구현이 복잡하고 완벽하게 보장하는 것은 아니다. 결국 이러한 어려움은 “하나의 작업”이 여러 경계에 걸쳐있기 때문에 발생한다.

사람의 경계

마지막 범주는 사람의 경계다. 코드의 경계는 개발자가 선택하고 물리의 경계는 인프라가 강제한다. 하지만 소프트웨어는 결국 사람이 만들고 사람이 사용한다. 조직이 소통하는 방식, 사용자가 시스템을 이해하는 방식. 이런 곳에서 사람이라는 변수가 경계를 만들고, 그 경계에서 고유한 문제가 발생한다.

조직의 경계

콘웨이의 법칙은 조직의 경계가 곧 시스템의 경계가 된다는 것을 말한다. 세 팀이 따로 일하면 세 개의 컴포넌트가 나오고, 두 팀 사이의 커뮤니케이션이 원활하지 않으면 두 시스템 사이의 인터페이스도 어색해진다. 이는 시스템이 조직의 소통 구조를 반영하기 때문이다.

조직 구조를 바꾸면 시스템의 경계도 바뀐다

문제는 조직이 바뀌어도 시스템은 쉽게 바뀌지 않는다는 것이다. 처음에 두 팀이 만들던 시스템을 세 팀으로 재편하면, 코드의 경계와 조직의 경계가 어긋나기 시작한다. 한 팀이 소유하던 모듈을 두 팀이 나눠 가지게 되면서 “이 코드는 누구 것인가?”라는 질문에 명확한 답이 없어진다. 양쪽 팀 모두 손대기를 꺼리는 무주공산이 생기고, 반대로 여러 팀이 같은 코드를 수정하면 충돌이 발생한다. A팀에게 급한 기능이 B팀이 소유한 API 변경에 의존하지만, B팀의 백로그에서는 우선순위가 낮아 몇 스프린트째 대기하는 상황도 조직의 경계가 만들어낸 병목이다.

소통의 경계

소프트웨어는 코드로 만들어지지만 그 코드가 무엇을 해야 하는지는 사람의 말로 전달된다. 여기서 경계가 생긴다. 기획자가 “간단한 기능 하나만 추가해주세요”라고 말할 때, 그 사람의 머릿속에 있는 그림과 개발자가 떠올리는 그림은 다르다. 기획자에게는 화면 하나지만 개발자에게는 세 시스템을 관통하는 API 변경일 수 있다. 혹은 반대로 정말 간단한 기능이지만 개발자가 과도하게 복잡한 솔루션을 떠올릴 수도 있다. 같은 단어를 쓰지만 서로 다른 것을 상상하는 것이 소통의 경계 문제다.

이 문제의 근본에는 암묵지4가 있다. 소프트웨어 개발에서 가장 중요한 지식 중 상당수는 문서에 적히지 않는다. 많은 경우 아키텍처를 선택한 이유, 이 코드가 이렇게 복잡한 이유, 이 엣지 케이스를 왜 특별히 처리하는지 등의 맥락은 당시 논의에 참여한 사람의 머릿속에만 존재한다. 그 대화에 참여하지 못한 사람에게 코드는 맥락 없는 결과물일 뿐이다.

경계를 넘을 때마다 원래 의도는 조금씩 변형된다

결국 소프트웨어 개발은 일종의 전언 게임이다. 이해관계자의 요구가 기획자를 거쳐 디자이너에게, 다시 개발자에게 전달되는 과정에서 매 경계마다 정보가 변형된다. 명시적으로 전달된 내용도 해석이 달라지고, 암묵적으로 전제했던 내용은 아예 전달되지 않는다. 때로는 정보가 의도적으로 공유되지 않기도 한다. 팀 간 경쟁이나 조직 정치가 정보의 흐름을 막으면 기술적으로는 아무 문제가 없는데도 경계가 벽이 된다.

지식 사일로5도 소통의 경계가 만드는 문제다. 특정 시스템을 이해하는 사람이 한 명뿐이라면, 그 사람이 떠나는 순간 경계가 벽이 된다. 코드는 남아있지만 그 코드를 이해하는 맥락은 사라진다. 소통의 경계에서 지식이라는 데이터가 손실된 것이다. 소프트웨어의 버그 중 상당수가 코드의 실수가 아니라 소통의 실패에서 비롯되는 이유다.

경계를 다스리는 법

지금까지 소프트웨어 개발에서 마주치는 다양한 경계 문제를 살펴보았다. 경계에서 발생하는 문제는 해결하기 어렵다. 그렇다면 우리는 어떻게 행동해야 할까? 경계를 제거하는 것이 좋을까? 아니면 경계를 더 촘촘하게 만들어야 할까?

여기서 깨달아야 할 중요한 통찰이 있다. 소프트웨어를 만드는 모든 행위는 경계에 대한 선택이다. 경계를 나누는 모든 행위는 의식적인 선택이다. 그리고 역으로 경계를 긋지 않기로 결정하는 것도 선택이다. 중요한 것은 그 선택의 의도와 대가를 이해하는 것이다.

경계를 긋는다는 것은 한계를 인정하고 복잡성을 두 곳으로 나눈다는 뜻이다. 내부의 단순함을 얻는 대신 경계 자체라는 새로운 복잡성을 받아들이는 것이다. 그리고 경계를 긋지 않는다는 것도 마찬가지다. 경계의 관리 비용은 피하지만 내부의 복잡성은 받아들이는 것이다.

경계를 긋든 긋지 않든 전체 복잡성은 사라지지 않는다

숨겨진 경계를 인식하라

경계를 다스리려면 먼저 경계를 인식할 수 있어야 한다. 그런데 모든 경계가 눈에 보이는 것은 아니다. 함수 시그니처나 API 스키마처럼 명시적인 경계도 있지만 아무도 의식하지 못하는 경계도 있다. 두 서비스가 같은 데이터베이스 테이블을 읽고 있다면 코드상으로는 아무 연결이 없지만 실제로는 강하게 결합되어 있다. 배포 스크립트에 암묵적인 순서 의존성이 있다면 그것도 경계다. 문서화되지 않은 팀 간의 암묵적 합의도 마찬가지다.

이런 숨겨진 경계를 찾으려면 평소에 보지 않는 곳을 봐야 한다. 때로는 전체를 봐야 할 수도 있다. 데이터는 어디서 어디로 흐르는가? 한쪽의 변화가 어디까지 파급되는가? 이 질문들이 숨겨진 경계를 드러낸다. 보이지 않는 경계는 다스릴 수도 없다.

코드상으로는 연결이 없지만 같은 테이블을 통해 강하게 결합되어 있다

패턴은 답이 아니다

경계 문제를 만나면 많은 개발자가 패턴을 꺼내든다. 디자인 패턴, 아키텍처 패턴, 시스템 디자인 패턴 등은 유용하다. 하지만 패턴은 특정 조건에서 효과가 검증된 해법이지 절대적인 정답이 아니다.

“마이크로서비스를 도입했으니 Saga를 써야지.” “외부 API를 쓰니까 부패 방지 계층을 두어야지.” 이런 사고방식은 경계를 인식한 것이 아니라 패턴을 신봉하는 것이다. Saga가 해결하는 문제가 내 시스템에 존재하는지, 부패 방지 계층의 비용을 감수할 만큼 외부 API가 불안정한지를 먼저 물어야 한다.

패턴의 이름을 아는 것과 패턴이 필요한 상황을 판단할 수 있는 것은 완전히 다른 능력이다. 문제를 정의하기 전에 해법부터 꺼내면, 경계를 다스리는 것이 아니라 복잡성만 추가하게 된다. 그래서 우리는 패턴 목록이 아니라 질문이 필요하다.

경계 앞에서 던져야 할 세 가지 질문

그럼 어떤 질문이 유효할까? 다음 세 가지 질문으로 시작해볼 수 있다.

첫째, “이 경계를 넘는 것은 무엇인가?” 경계를 사이에 두고 오가는 것의 성격에 따라 위험이 달라진다. 앞서 다뤘던 타임존 문제는 “시간”이라는 가정이 경계를 넘었기 때문에 발생했다. 로컬 환경의 KST가 서버의 UTC로 넘어가면서 “오늘”의 의미가 달라졌다. 추상화 누수는 “이 아래는 신경 쓰지 않아도 된다”는 가정이 경계를 넘었기 때문에 터졌다. 전언 게임에서는 의도가 경계를 넘을 때마다 변형되었다. 데이터가 넘어가는 경계와 신뢰가 넘어가는 경계, 가정이 넘어가는 경계는 각각 다른 종류의 위험을 만든다. 무엇이 넘어가는지를 먼저 파악해야, 그 경계에서 어떤 문제가 생길 수 있는지 예측할 수 있다.

둘째, “이 경계가 깨지면 무슨 일이 일어나는가?” 모든 경계가 같은 무게를 가지지는 않는다. 결제 데이터의 불일치와 로그 형식의 불일치는 둘 다 경계 문제이지만 전자는 돈이 걸려 있고 후자는 불편한 정도다. 경계가 깨졌을 때의 대가가 컴파일 에러로 잡히는 수준인지, 테스트에서 발견되는 수준인지, 운영 환경에서 장애로 터지는 수준인지에 따라 그 경계에 투자해야 할 비용이 결정된다. 대가가 큰 경계에는 타입 시스템, 계약 테스트, 검증 계층 같은 보호 장치가 필요하다. 대가가 작은 경계에 같은 수준의 보호를 거는 것은 과잉 투자다.

셋째, “이 경계는 누가 관리하는가?” 경계는 꾸준한 관리가 필요하다. 앞에서 살펴본 것처럼 조직이 재편되면서 코드의 경계와 팀의 경계가 어긋나면 양쪽 팀 모두 손대기를 꺼리거나, 반대로 양쪽이 동시에 수정해서 충돌이 나는 혼란이 발생한다. 관리 주체가 불분명한 경계가 가장 위험하다. 경계를 그을 때는 양쪽을 각각 누가 관리하는지를 반드시 함께 정해야 한다. 관리자가 없는 경계는 시간이 지나면 반드시 부패한다.

경계를 긋지 않는다는 선택

경계를 긋는 것만이 선택이 아니다. 긋지 않기로 결정하는 것도 선택이다. 함께 변하는 것들을 한 곳에 두는 것, 아직 충분히 이해하지 못한 영역을 섣불리 나누지 않는 것, 관리 비용을 피하기 위해 모놀리스를 유지하는 것과 같은 모든 것이 의식적인 판단이 될 수 있다.

참고로 경계를 긋지 않기로 결정하는 것과 경계에 대해 생각하지 않는 것은 다르다. 전자는 “이 둘은 항상 함께 변하니 나눌 이유가 없다”는 판단이다. 후자는 그냥 문제는 외면하는 것에 가깝다. 경계를 긋지 않기로 했다면 왜 긋지 않는지에 대한 이유가 있어야 한다.

경계의 진화

경계는 정적인 것이 아니다. 시스템이 성장하고 이해가 깊어지면 경계도 변한다. 초기에는 모놀리스가 맞을 수 있다. 요구사항을 충분히 이해하지 못한 상태에서 서비스를 나누면 잘못된 곳에 경계를 긋게 된다. 시간이 지나 트래픽이 늘고 팀이 커지면 특정 모듈이 병목이 되기 시작한다. 그때 해당 모듈을 별도 서비스로 분리하면 경계의 비용을 감수할 만한 명확한 이유가 생긴다. 반대로 조직이 변하면서 나눠둔 서비스들을 다시 합치는 것이 더 효율적일 수도 있다.

경계는 한 방향으로만 움직이지 않는다. 분리했다가 합치고 합쳤다가 다시 나누기도 한다.

경계는 한 방향이 아니다. 분리와 통합을 반복하며 진화한다

중요한 것은 경계를 의식적으로 관리하는 것이다. 어제의 올바른 결정이 오늘도 올바른 것은 아니다. 정기적으로 경계를 검토하고 현재의 맥락에 맞게 재구성해야 한다.

마치며

경계는 어디에나 있고 문제는 그 경계에서 피어난다. 그렇다고 경계를 두려워할 필요는 없다. 경계는 복잡한 세상을 다루기 위한 필수적인 도구이며 경계 없이 소프트웨어를 만들 수는 없다. 중요한 것은 경계의 존재를 의식하는 것이다. 경계가 보이면 그 경계에서 어떤 문제가 발생할 수 있는지 예측할 수 있고 예측할 수 있으면 대비할 수 있다.

다만 한 가지 경계해야 할 것이 있다. 경계를 잘 다루고 있다는 확신이다. 경계는 시스템이 변하고, 조직이 변하고, 요구사항이 변할 때마다 함께 움직인다. “이 구조가 정답이야”라고 믿는 순간 이미 변해버린 경계를 알아채지 못하게 된다. 경계를 다스리는 일에 완성이란 없다. 언제나 변화할 준비가 되어 있어야 한다.

  1. 조엘 온 소프트웨어 저자 (에이콘, 2005)

  2. 원문: “Integration Points are the #1 killer”

  3. 예를 들어, macOS는 기본적으로 대소문자를 구분하지 않지만 Linux는 구분한다

  4. 말로 표현하기 어렵고 경험을 통해서만 습득할 수 있는 지식을 말한다

  5. 특정 지식이 조직 내에서 한 사람 또는 소수의 사람에게만 존재하는 상태를 말한다