Flutter 로 살펴보는 IoC, DI 패턴

신원규·2024년 10월 13일
3
post-thumbnail

머릿말

ServiceLocator, IoCContinaer, DI 관련해 들어보신 적 있으신가요?

위의 세 단어를 얼핏 들어보셨거나, 혹은 잘 모르시는 초보 Flutter 개발자들을 위한 포스트입니다.

신입 Flutter 개발자로 취업에 성공한지 시간이 조금 지난 시점,
더 이상 처음 출근할 때의 설램은 점점 기억나지 않고, 지난 기간동안 열심히 작성한 코드들이 쌓여 나를 괴롭히고 있지 않으신가요?
분명히 하나하나 최선을 다해 만들었던 기능들이였고, 잘 동작하는 기능들이였는데 왜 지금은 흉측하게 바뀌어 저를 괴롭히고 있는걸까요?

이번 글에서는 이러한 문제를 해결하기 위한 방법중, IoC 와 DI 에 대해서 알아보려 합니다.

혹시 Flutter에서 자주 사용하는 GetIt, Injectable, InheritedWidget, Provider는 각각 어떤 역할을 하며, 왜 사용되는지 궁금하지 않으셨나요?

이러한 모듈들이 등장한 이유와 이를 통해 달성하고자 하는 목적에 대해 짧게 이야기해보겠습니다.

위의 설명한 패키지, 클래스들은 모두 단순히 코드를 작성하는 것이 아니라, 더 효율적이고 유지보수하기 쉬운 코드를 만드는 데 있습니다.

이러한 목표를 객체간 강한 결합을 가지고 있는것을 약한 결합의 형태로 변경해, 결합도를 낮춤으로서 달성하는데요,
객체 간의 결합도를 낮추면, 기능을 추가하거나 수정할 때 어려움이 줄어들고, 테스트 또한 훨씬 간단해집니다.

특히 Flutter와 같은 UI 프레임워크에서는 상태 관리와 의존성 관리를 돕는 다양한 도구들이 제공되기 때문에, 이를 잘 활용하면 앱의 구조를 보다 간결하고 명확하게 유지할 수 있습니다.

IoC (Inversion of Control)

IoC는 간단히 말해, 객체의 제어권을 외부로 넘기는 것입니다.

그렇다면 왜 이러한 방법으로 코드를 작성해야 하는것일까요?
IoC 패턴이 강조되기 이전의 프로그래밍에서는 객체가 직접 다른 객체를 생성하고 관리했지만, 이러한 방식은 객체 간의 강한 결합(String Coupling)을 초래하게 됩니다.

이는 객체가 다른 객체의 구체적인 구현에 의존하게 되어, 두 객체가 긴밀하게 연결되기 때문입니다. 객체 간의 직접 참조는 코드 수정 시 영향을 받는 범위를 넓히고, 새로운 기능을 추가하거나 기존 기능을 변경할 때 많은 곳에서 코드를 수정해야 하는 상황을 초래합니다.

이를 객체의 구현 상세에 의존하지 않는 약한 결합(Loose Coupling)을 가지도록 수정해, 하나의 모듈에서 변경이 발생 할 때, 그 변경의 여파가 타 모듈로 이어지지 않도록 해야합니다.

리눅스 토발즈의 명언을 인용해볼 때인것 같네요.

말은 쉽지. 코드를 보여줘.
Talk is cheap. Show me the code.

- Linus Torvalds

어떤 코드가 강한 결합을 가지고 있는코드인지, 간단한 예시를 보여보겠습니다.

/// Strong coupling 예시.
class A {
	final B fieldB;
	
	const A():
	this.fieldB = B(); // 생성자에서 구체 타입의 인스턴스를 직접 생성해 사용하고 있다.
}

여러분의 소스코드에 위와 같은 패턴의 코드가 발견된다면, 그 모듈들은 강한 결합을 가지고 있는겁니다!

그렇다면 강한 결합의 코드가 왜 변경에 취약한 구조를 가진다는 것 일까요?
이를 보다 구체적인 코드와 예시를 들어 이야기해보죠.

여러분들은 갑자기 사내에서 앱 내에 게임 기능을 구현해야하는 임무를 맡았다고 가정해봅시다.

다음과 같은 코드를 작성하게 되겠죠.

class Player {
  //.. 여러 코드..//
  
  String enroll() {
    print('player $this가 게임엔진에 등록 되었습니다.');
    return this.toString();
  }
}

class GameEngine {
  final Iterable<Player> players;
  
  GameEngine():
  	this.players = [Player()];
  
  bool startEngine() {
    players.map((final e) => e.enroll(););
    
    print('게임 엔진 초기화 완료');
   
    return true;
  }
}

위와같이 GameEngine 클래스가 Player 클래스를 직접 생성하고 관리한다면,
새로운 유형의 플레이어를 추가하거나 기존 플레이어의 동작을 수정할 때 GameEngne 클래스도 함께 변경해야 합니다.

이러한 강한 결합으로 인해 코드 수정이 복잡해지고, 유닛 테스트가 어려워질 수 있다고 하는데,
실제로 신규 기능을 추가해 Player 클래스를 변경해보며, 어떻게 돌아가는지 살펴봅시다.

예를 들어, Player 클래스에 새로운 유형의 플레이어를 추가해야 한다 해볼까요?

저희 게임에는 여태까지 BM이 없었지만, 앞으로 구독형 요금제를 만들어 고객의 구독 플랜에 따라 플레이할 수 있는 게임의 레벨을 다르게 변경해야 하면 어떤 일이 벌어질까요?

이 경우 GameEngine 클래스 내부의 생성 방식도 변경해야 하기 때문에 변경 사항이 여러 곳에 전파됩니다.

새로운 플레이어 유형인 PremiumPlayer를 추가하고자 할 때, GameEngine 클래스에서 PremiumPlayer를 생성하도록 코드가 수정되어야 합니다.

이는 GameEngine 클래스가 각 플레이어 유형을 직접 알고 있기 때문입니다.

또한, 이러한 변경은 테스트에서도 영향을 미치며, 테스트 코드 역시 수정이 필요하게 됩니다.

아래는 변경 전파가 일어나는 코드 예시입니다:

enum SubscriptionPlan {
  SlIVER, // 실버 등급 회원
  GOLD, // 골드 등급 회원
}

// 유료 구독자 플레이어 추가
class PremiumPlayer extends Player {
    final SubscriptionPlan plan; // 결제 플랜 정보

    const PremiumPlayer(SubscriptionPlan plan) {
        this.plan = plan;
    }

    
    String enroll() {
      switch(plan) {
        case SlIVER:
          print('실버 플랜 회원의 플레이어가 게임에 등록되었습니다.');
        case GOLD:
          print('골드 등급 회원의 플레이어가 게임에 등록되었습니다.');
      }
      
      return this.toString();
    }
}

class GameEngine {
    final Iterable<Player> players;

    public Game(bool isPlayerSubscripted, [SubscriptionPlan? plan]) { // +1
        // 플레이어의 유형에 따라 객체 생성
				switch((isPlayerSubscripted, plan)) { // +2
            case(false, _): // +3
	            players = [Player()]; // +4
            case(true, final plan) when plan != null: // +5
            	players = [PremiumPlayer(plan)]; // +6
        } // +7
    }

  bool startEngine() {
    players.map((final e) => e.enroll(););
    
    print('게임 엔진 초기화 완료');
   
    return true;
  }
}

위 코드에서 볼 수 있듯이, Player를 확장한 PremiumPlayer가 추가되었을 뿐인데도, GameEngne 클래스의 코드도 수정해야 합니다.

이러한 문제를 해결하기 위한 패러다임이 바로 IoC(Inversion of Controll) 입니다.

GameEngine 클래스가 Player 클래스의 구체적인 구현에 의존하지 않도록 변경하면,

Player 객체의 생성과 관리 제어를 외부에서 담당하게 되어,
GameEngine 클래스는 더 이상 특정 Player 유형에 대해 알 필요가 없습니다.

이를 통해 Player의 새로운 유형이 추가되더라도 GameEngine 클래스에는 변경이 필요하지 않으며, 결과적으로 코드의 결합도를 낮추고 유지보수를 쉽게 할 수 있습니다.

아래는 IoC를 사용한 코드 예시입니다:

abstract interface class Player {
    String enroll();
}

final class PremiumPlayer implements Player {
    final PlayerPlan plan;

    const PremiumPlayer(PlayerPlan plan) {
        this.plan = plan;
    }

    
    void enroll() {
         switch(plan) {
        case SlIVER:
          print('실버 플랜 회원의 플레이어가 게임에 등록되었습니다.');
        case GOLD:
          print('골드 등급 회원의 플레이어가 게임에 등록되었습니다.');
      }
    }
}

class GameEngne {
    private final Player player;

    // 생성자를 통해 Player 주입
    public Game(Player player) {
        this.player = player;
    }

    void startGame() {
        player.play();
    }
}

public class Main {
    public static void main(String[] args) {
        PlayerPlan plan = PlayerPlan.GOLD;
        Player player = new PremiumPlayer(plan);
        Game game = new Game(player); // Player 객체를 외부에서 주입
        game.startGame();
    }
}

GameEngne 클래스가 더 이상 Player의 구체적인 구현에 의존하지 않고, 대신 Player 인터페이스에만 의존합니다. Player 객체는 외부에서 생성되어 GameEngne 클래스에 주입되므로, 새로운 플레이어 유형이 추가되더라도 GameEngne 클래스의 코드는 변경할 필요가 없습니다. 이렇게 하면 코드의 유연성이 높아지고, 유지보수가 훨씬 쉬워집니다.

하지만, 실제 서비스 코드가 이런식으로 간단하게 돌아가진 않죠?

IoC 디자인 패턴을 이용해, 변경의 전파 차단을 달성하기 위한 상세 방법론에 대해서 이야기 해봅시다.

DI (의존성 주입)

DI는 IoC의 한 형태로, 객체가 필요한 의존성을 외부에서 주입받는 방식입니다.

예를 들어, 게임 엔진플레이어를 필요로 한다면, 게임엔진이 직접 플레이어를 생성하는 것이 아니라, 외부에서 플레이어를 생성해 엔진에 전달해주는 방식입니다.

이렇게 하면 두 클래스 사이의 강한 결합을 줄이고, 테스트가 쉬워지며, 모듈화를 쉽게 할 수 있습니다.
위에 설명된 인터페이스를 이용한 약결합으로 결합된 예시가 바로 DI를 활용한 예제가 되겠군요.

Flutter에서의 대표적인 DI를 사용하기 위한 수단은, provider 패키지나 get_it 패키지가 되겠습니다.

구체적인 예로 InheritedWidgetProvider를 들 수 있습니다.

InheritedWidget은 Flutter에서 위젯 트리 하위에 데이터를 효율적으로 전달할 수 있도록 도와주는 역할을 합니다.

앱의 테마나 사용자 설정 같은 글로벌 데이터를 InheritedWidget으로 정의하면, 해당 데이터를 필요로 하는 하위 위젯들은 손쉽게 접근할 수 있겠죠.

이를 적극적으로 사용하는 패키지는 intl Flutter 앱의 i18n을 지원하기 위한 패키지가 그 예시가 될 것 같습니다.

{
  "@greeting": {
    "description": "Greeting message displayed on the home page.",
    "type": "text"
  },
  "greeting": "Hello World",
  "@greeting_ko": {
    "description": "Greeting message in Korean.",
    "type": "text"
  },
  "greeting_ko": "안녕하세요"
}

위와 같은 파일을 선언한 뒤,

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final appSettings = AppSettings.of(context);
    return MaterialApp(
      locale: Locale(appSettings?.locale ?? 'en'),
      localizationsDelegates: [
        // `intl` 패키지를 이용한 로컬라이제이션 설정
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      supportedLocales: [
        const Locale('en'),
        const Locale('ko'),
      ],
      home: HomePage(),
    );
  }
}

class HomePage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final appSettings = AppSettings.of(context);
    String greeting = Intl.message(
      'Hello World',
      name: 'greeting',
      locale: appSettings?.locale,
    );
    return Scaffold(
      appBar: AppBar(title: Text('Home Page')),
      body: Center(
        child: Text(context.l10n.greeting),
      ),
    );
  }
}

위젯에서 buildcontext 를 참조해, 상황과 local에 대응하는 문자열을 가져 올 수 있겠죠.

이런 방식은 특정 데이터를 여러 위젯들이 공유해야 할 때, 직접 참조 없이 데이터를 주입받을 수 있게 해주어 결합도를 낮추는 효과가 있습니다.

또한, BuildContext를 이용한 의존성 주입도 DI의 일종으로 볼 수 있습니다.

Flutter 의 Widget 클래스 자체가 IoC, DI 를 적극적으로 활용한 에시라고 말 할 수 있겠군요.

Flutter에서 BuildContext는 위젯 트리의 위치 정보를 가지고 있으며, 이를 통해 필요한 의존성을 전달받을 수 있습니다. 이를 통해 위젯은 트리 내에서 부모가 누구인지 일일이 그 정보를 전달받지 않아도 되겠군요.

이와 같이 DI를 활용하면 객체 간의 결합도를 줄이고, 코드의 모듈화를 높여 유지보수와 테스트의 효율성을 크게 향상시킬 수 있습니다.

하지만, 이와 같은 코드를 작성할때, Props Drilling 과 같이 DI를 의존관계 최상단까지 반복하는 문제를 경험 하실 수 있는데요, 이를 위한 해결법중 하나는 ServiceLocator 패턴이 있습니다.

Service Locator

Service Locator는 필요한 객체를 중앙에서 조회해 가져오는 디자인 패턴입니다.

서비스 로케이터는 일종의 런타임-링커 역할을 수행하는 객체로, 프로그램 실행 중 필요한 객체의 의존성을 제공해줍니다.

이 방법은 의존성을 런타임에 해결할 수 있기 때문에 유연성을 제공합니다. 모듈의 수정 여파가 잘 전파되지 않고, 심지어는 런타임에 수정 여부를 바로 반영하도록 구성할 수도 있죠.

하지만, ServiceLocator 는 그만큼 단점도 존재합니다.

가장 큰 문제는 모듈같의 의존관계가 그대로 Service locator 의 모듈 등록 순서로 반영되어야 한다는 점 입니다.

이를 지키지 않으면, 런타임에 에외가 발생합니다. Linking 실패와 동일한 오류가 발생하는거죠.

Java 진영의 Spring core 는 이 문제를 jvm에서 지원하는 Class loader 를 사용해
각각 모듈의 의존관계를 그래프로 작성하고, 가장 의존관계가 적은순부터 많은순으로 정렬하는 위상정렬을 실행한뒤, 정렬된 순서대로 ServiceLocator 에 모듈을 등록하는 방식으로 해결했지만,

아쉽게도 Flutter 에서는 아직 공식적으로 이러한 기능을 지원해주지 않습니다.
따라서, 원한다면 직접 이를 구현해야한다는 단점이 있습니다.

이 방식의 대표적인 구현 예시는 ServiceLocator.get(SomeService.class)와 같은 형태로 객체를 조회하는 것입니다. Flutter에서도 GetIt과 같은 라이브러리가 이러한 역할을 수행합니다.

이 패턴에서는 객체가 필요한 서비스의 의존성을 직접 가져오며, 이를 위해 서비스 로케이터라는 중앙 레지스트리가 사용됩니다. 코드에서 ServiceLocator.get(SomeService.class)와 같은 형태로 의존성을 조회하게 됩니다.

profile
생존형 개발자. 어디에 던져져도 살아 남는것이 목표입니다.

0개의 댓글