안녕하세요, 데이블의 백엔드 엔지니어 송영훈입니다.

오늘은 조금 원론적일지 모르지만 모든 개발자에게 있어 중요한 이야기를 해볼까 합니다.
왜 우리가 쉽고 효율적인 프로그램을 작성해야 하는지를 중심으로, 현대 풀스택 웹 프레임워크들이 강조하는 의존성 주입과 제어의 역전이란 어떤 의미를 가지는지까지 한번 알아보는 시간을 가져보도록 하겠습니다.


좋은 코드란 무엇인가

We love clean code just as much as you do. Simple, elegant syntax puts amazing functionality at your fingertips. - Laravel

Spring makes Java simple. - Spring

우리는 쉽고 빠르게 더 나은 결과를 만들어내기 위해 각종 도구를 이용합니다.

Laravel이나 Spring과 같은 웹 프레임워크들은 웹 서비스를 만들기 위한 대표적인 도구들입니다.
수많은 프로그래머들의 오랜 시간 고민한 끝에 탄생한 프레임워크들은 더 나은 방법으로 프로그램을 작성하는 것을 도와주고 있습니다.

그리고 그렇게 만들어진 프레임워크들은 하나같이 “쉽고 효율적인 코드 작성을 도와준다.”라는 점을 강조하고 있습니다.

쉽고 효율적인 코드

쉽고 효율적인 코드라는 것은 무엇을 의미하는 것일까요?

물론 현대 프레임워크들이 방대한 편의 기능을 제공하기 때문에 코드 작성 자체가 쉽고 편하다는 의미도 내포되어있습니다.
그러나, 단순히 코드 작성이 쉽다는 것만을 의미하지는 않습니다.

말 그대로 쉬운 코드, 다시 말해 내가 작성한 코드의 의도를 누가 보더라도 빠르고 명확하게 해석할 수 있다는 것 또한 의미합니다.

효율이라는 부분도 마찬가지입니다.
프레임워크에서 제공하는 각종 편의 기능들을 이용해 코딩의 생산성을 높일 수도 있겠지만, 우리가 나중에 기능을 확장하거나 변경할 때의 편의성도 의미하고 있습니다.

협업과 변화를 위한 준비

고객의 모든 요구사항을 예측하고 논리적으로 완벽한 소프트웨어를 단번에 설계하는 것은 거의 불가능에 가까운 일입니다.
따라서 더 나은 서비스를 위하여 끊임없이 고민하고 개선하는 것이 개발조직의 존재 이유라고 할 수 있겠습니다.

프로그램을 개선하기 위해서는 기존에 작성된 코드가 어떤 역할을 하는지 쉽게 이해할 수 있어야 합니다.
그리고 그것을 이해하는 사람은 지금 이 코드를 작성하는 내가 아닌, 나의 동료들이거나 지금의 나와 완전히 다른 기억을 가지고 있는 미래의 나일 확률이 매우 높습니다.

이것이 우리가 항상 협업을 염두에 두고 쉬운 코드를 지향해야 하는 이유라고 할 수 있습니다. 또한 우리는 계속해서 변화하는 요구사항에 적절하게 대응하고 새로운 기능을 추가할 때도 어려움이 없게 해야 할 것입니다.

그렇다면 어떻게 해야 쉽고 효율적인, 다시 말해 좋은 코드를 작성할 수 있을까요? 사실 좋은 코드를 작성하는 일은 정말 어려운 일이며, 완벽한 코드를 작성하는 것은 불가능에 가까울지도 모릅니다.

그러나 저는 대다수의 프로그래머가 공감하는 대표적인 아이디어를 소개하며 우리가 더 나은 프로그래밍의 세계로 다가가는 것은 생각보다 어렵지 않은 일이라 말하고 싶습니다.

관심사의 분리

거대한 일을 쉽고 효율적으로 해내는 법

우리가 소프트웨어 개발자가 아닌, 자동차를 만드는 사람이라고 가정해봅시다.
자동차는 구현이 매우 복잡한 기계이며, 한명의 사람이 이 자동차의 모든 기능을 설계하고 제작하는 것은 상당히 어려운 일일 것입니다.

이를 해결하기 위해 사람들은 분업이라는 좋은 아이디어를 고안해냈습니다.
자동차의 핸들, 바퀴, 골격 등 각 분야를 나눠서 특정 부분만을 담당해 개발하고 이를 잘 조합하는 방법을 이용한다면 훨씬 수월하게 자동차를 만들어낼 수 있을 겁니다.

소프트웨어 개발에서의 관심사 분리

이러한 분업의 아이디어는 소프트웨어를 개발할 때도 적용할 수 있습니다.

특정한 관심사에 따라 기능을 나누고, 각 기능을 독립적으로 개발한 뒤 이를 조합하는 방식으로 복잡한 소프트웨어를 구성해보자는 아이디어를 관심사의 분리(Separation of concerns, SoC)라고 합니다.

관심사를 분리하여 코드를 작성하게 되면, 독립된 특정 기능에 집중할 수 있기 때문에 코드를 파악하는데 수월하며 특정 기능을 변경하거나 추가할 때도 그 부분만 교체하면 되기 때문에 훨씬 간단하게 문제를 해결할 수 있을 것입니다.

프로세스 단위로 기능을 분리하고 이를 RPC 등의 방법으로 통신하여 복잡한 서비스를 구성하는 MSA라는 거시적인 접근부터, Side effect가 없는 단순한 함수를 정의하고 이들을 조합하여 궁극적으로 복잡한 프로그램을 완성하는 함수형 프로그래밍이라는 미시적인 개념까지, 관심사를 분리하고자 하는 아이디어는 다양한 관점에서 적용되고 있습니다.

의존성 주입과 제어의 역전

의존성 주입

일반적으로 복잡한 맥락 속에 존재하는 클래스는 아래와 같이 다른 객체를 의존하는 경우가 많습니다.

class Car {
  private engine: Engine;
  private frame: Frame;

  // ...

  constructor() {
    // ...

    this.engine = new InternalCombustionEngine(
      crank,
      camshaft,
      connectingRod,
      sparkPlug,
      ...
    );
    this.frame = new SteelFrame(...);
  }

  // ...
}

위 코드에서 InternalCombustionEngine 객체를 생성하려면 많은 아규먼트가 필요한 것을 확인 할 수 있으며, 코드가 생략되었지만 Car 클래스의 생성자는 crank, camshaft, connectingRod 등의 변수를 만들기 위해 복잡한 일을 해야만 할 것입니다.

Car 클래스는 다양한 멤버 변수를 가지고 있기 때문에 언뜻 보면 역할이 완전히 분리되어 있는 것 같지만, 의존성을 모두 Car 클래스 내부에서 관리하고 있기 때문에 사실상 하나의 거대한 로직이 복잡하게 얽혀 있는 것이나 다름없습니다.
다시 말해, Car 클래스에 정의된 기능(멤버 함수)뿐 아니라 Car가 의존 하는(예를 들어 InternalCombustionEngine) 객체의 고유 기능에 대한 정상적인 동작을 Car 클래스 하나가 모두 책임지고 있다고 할 수 있습니다.

클래스의 내용을 조금만 변경한 코드를 보도록 하겠습니다.

class Car {
  private engine: Engine;
  private frame: Frame;

  // ...

  constructor(engine: Engine, frame: Frame) {
    this.engine = engine;
    this.frame = frame;
  }

  // ...
}

Car가 의존하는 객체들을 직접 생성해주는 것이 아니라, Car를 생성할 때 외부에서 전달받도록 한 것을 확인할 수 있습니다.

이제 더 이상 Carengineframe의 정상 동작을 책임지지 않습니다.
논리적으로도, 개념적으로도, 완전히 의존 관계가 분리된 것을 확인할 수 있습니다.

이로써 Car 클래스의 기능을 개발하는 동안 의존하는 객체의 기능에 대한 구현을 상대적으로 덜 고려할 수 있게 되었습니다.
Engine을 개발하는 사람은 FrameCar에 대한 존재 자체를 알지 못해도 Engine을 개발하는 데 아무런 지장이 없습니다.

이렇게 의존 관계를 잘 분리하고, 의존하는 객체의 구체적인 구현 원리를 잘 알지 못해도 해당 객체를 외부로부터 전달(주입)받아 클래스를 구성하는 방법을 의존성 주입(Dependency injection, DI)이라고 합니다.

이번에는 아래의 두 코드를 비교해보겠습니다.

class Car {
  private engine: Engine;
  private frame: Frame;

  // ...

  constructor() {
    // ...

    this.engine = new InternalCombustionEngine(
      crank,
      camshaft,
      connectingRod,
      sparkPlug,
      ...
    );
    this.frame = new SteelFrame(...);
  }

  // ...
}

class InternalCombustionEngineCar extends Car {
  constructor() {
    // ...

    this.engine = new InternalCombustionEngine(
      crank,
      camshaft,
      connectingRod,
      sparkPlug,
      ...
    );
    this.frame = new SteelFrame(...);
  }
}

class ElectricVehicle extends Car {
  constructor() {
    // ...

    this.engine = new ElectricVehicleMotor(
      batteryPlatform,
      converterOfACtoDC,
      ...
    );
    this.frame = new PlasticFrame(...);
  }
}

let internalCombustionEngineCar = new InternalCombustionEngineCar();
let electricVehicle = new ElectricVehicle();
class Car {
  private engine: Engine;
  private frame: Frame;

  // ...

  constructor(engine: Engine, frame: Frame) {
    this.engine = engine;
    this.frame = frame;
  }

  // ...
}

let internalCombustionEngineCar = new InternalCombustionEngineCar(internalCombustionEngine, steelFrame);
let electricVehicle = new ElectricVehicle(batteryPlatform, converterOfACtoDC);

두 가지 코드 모두 내연기관 자동차와 전기 자동차 객체를 생성하는 절차를 수행하고 있으나, 의존성 주입의 도입 여부에 따라 코드의 양과 복잡성이 매우 큰 차이를 보이고 있는 것을 확인할 수 있습니다.

만약 수소 자동차를 추가해야 하는 상황이 온다면 두 번째 코드에서는 의존하는 객체(의존성)만 준비된다면 쉽게 대처할 수 있지만, 첫 번째 코드에서는 추가로 작성해야 하는 코드가 급수적으로 증가할 것입니다.

제어의 역전

두 번째 코드에서는 의존하는 객체(의존성)만 준비된다면 쉽게 대처할 수 있지만

의존성 주입의 이점을 효과적으로 누리기 위한 가장 중요한 전제 조건은 의존성이 언제나 잘 정리되어있고, 필요한 순간에 쉽게 가져다 사용할 수 있어야 한다는 것입니다.
하지만 거대한 프로그램을 개발하다 보면 수많은 의존성이 관리되어야 할 것이고, 실력이 뛰어난 프로그래머일지라도 이를 잘 관리하는 것은 정말 어려운 일일 것입니다.

그래서 Spring, Laravel, NestJS와 같은 현대적인 웹 개발 프레임워크들은 이러한 어려움을 해결하고자 제어의 역전(Inversion of control, IoC)이라는 개념을 도입했는데, 이는 프로그램 전반에 걸친 모든 의존성을 프레임워크가 관리하며 클라이언트(의존성을 주입받는 객체)가 사용되는 맥락을 파악하고 알아서 필요한 의존성을 적재적소에 주입해주는 편의를 제공합니다.

이해를 위하여 한가지 예시를 들어보겠습니다.

우리가 레이싱 게임을 개발하는데, 게임을 시작할 때 두 가지 옵션 중 하나를 선택해야 한다고 가정해봅시다. 하나의 선택지는 1990년대의 과거를 배경으로, 나머지 하나는 먼 미래를 배경으로 게임을 진행합니다.

레이싱 게임이기 때문에 두 시대 모두 자동차가 등장해야겠지만, 1990년 시대의 자동차라고 한다면 당연히 기름으로 동력을 얻는 내연기관 자동차를 의미할 테고, 먼 미래세계에서는 자동차라는 단어가 전기 자동차를 의미할 것입니다.

우리는 똑같이 4개의 바퀴가 달려있고 핸들로 방향을 조절하는 기능을 가진 자동차라는 객체를 Car라고 정의하고 사용할 테지만, 만약 먼 미래세계라는 맥락 속에서 Car라는 객체를 생성한다면 프레임워크는 알아서 동력 장치를 전기 모터로 구성할 것입니다.

‘역전(Inversion)’이라는 이름에서 알 수 있듯, 우리는 이렇게 프로그램을 구성하는 객체에 대한 관리(통제권)를 프레임워크에 위탁한 후 기능을 구현하는 데에만 집중하여 좋은 코드를 더 생산적으로 구성할 수 있게 됩니다.

마치며

더 좋은 코드를 작성하기 위한 아이디어는 지금도 많은 프로그래머의 머릿속에서 쏟아져 나오고 있습니다.

모든 아이디어를 받아들일 수는 없겠지만 최대한 다양한 방법들을 접해보고 더 나은 코드를 만들기 위한 고민과 노력을 하는 것은 모든 개발자가 필수적으로 가져야 하는 마음가짐이라고 생각합니다.

소개한 내용과 사례를 통해 더 나은 코드를 작성하기 위한 노력이 필요한 이유를 다시 한번 짚어보는 시간을 가지셨기를 희망합니다.

감사합니다.