The Boxer

의존성 주입 본문

Development

의존성 주입

Prower 2022. 8. 7. 01:16
728x90
반응형

의존 관계

  • 객체 B의 기능이 추가 변경되었을 때 객체 A에 영향이 미치면 A가 B를 의존한다고 한다.
class Service:
    def __init__(self):
        self.repository = UserRepository()
  • 위 예시에서 Service 클래스는 UserRepository 클래스를 사용하고 있다.
  • 여기서 UserRepository 의 기능이 바뀌거나 추가되면, Service 가 영향을 받는다.
  • 정리하자면 하나의 객체가 다른 객체를 사용하면 의존 관계가 있다고 표현한다.

의존성 주입

  • 토비의 스프링에서는 다음의 세가지 조건을 충족하는 작업을 의존성 주입으로 정의한다.
- 의존관계는 사용할 오브젝트에 대한 레퍼런스를 외부에서 제공(주입)해줌으로써 만들어진다.
- 런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제3의 존재가 결정한다.
- 클래스 모델이나 코드에는 런타임 시점의 의존관계가 드러나지 않는다. 그러기 위해서는 인터페이스만 의존하고 있어야 한다.

외부에서 주입

  • “의존관계는 사용할 오브젝트에 대한 레퍼런스를 외부에서 제공(주입)해줌으로써 만들어진다.” 문장에서 “외부에서 주입한다 라는 의미를 살펴본다.
class Service:
    def __init__(self):
        self.repository = UserRepository()
  • 위 예시에서 Service 가 생성될 때 Service 내부에서 UserRepository 객체가 생성되고 의존성이 결정된다.
class Service:
    def __init__(self, user_repository: UserRepository):
        self.repository = user_repository

service = Service(UserRepository())
  • 다음으로 UserRepository 객체를 인자로 받아 관계를 설정하는 코드를 본다.
  • 의존성이 필요한 Service 를 기준으로 봤을 때, Service 와 UserRepository 의 관계가 Service 내부에서 정해지는 것이 아니라 Service 외부에서 의존성을 맺을 객체를 제공하여 의존성이 설정된다.
  • 이처럼 클래스 내부에서 의존성이 결정되는게 아니라 클래스 외부에서 의존 객체를 생성하여 전달하는 방식을 “외부에서 주입한다" 라고 표현한다.

의존성 주입 방법

  • 이처럼 외부에서 객체를 주입하는 방법은 다음과 같은 3가지 방법이 있다.

생성자 주입

class Service:
    def __init__(self, user_repository: UserRepository):
        self.repository = user_repository

service = Service(UserRepository())

property 주입

class Service:
    def __init__(self):
        self.repository = None
				
service = Service()
service.repository = UserRepository()

setter 주입

class Service:
    def __init__(self):
        self.repository = None

		def set_repository(user_repository: UserRepository):
				self.repository = user_repository

service = Service()
service.set_repository(UserRepository())

컨테이너와 팩토리

  • 다음으로 “런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제3의 존재가 결정한다.”의 의미를 살펴본다.
  • 위 문장을 이해하기 전에 IoC에 대해 먼저 이해하고 간다.

IoC

  • Inversion of Control 의 약자로 개발자가 프로그램의 흐름을 제어하는 것이 아니라, 프레임워크가 흐름을 제어하는 방식을 의미한다.
  • 여기서 프로그램의 흐름이란 객체의 생성과 주입을 말한다.
class Service:
    def __init__(self, user_repository: UserRepository):
        self.repository = user_repostiroy

service = Service(UserRepository())
  • 프로그래머가 소스코드상에서 제어하는 일반적인 프로그램의 흐름으로 주입할 객체 생성 → 의존 객체 주입 의 단계로 이뤄진다.
  • Inversion of Control 이란 이러한 주입할 객체 생성 과 의존 객체 주입 의 과정을 개발자가 소스코드에서 제어하는 것이 아닌 프레임워크가 대신하는 것을 의미한다.

fastapi 에서

class Service:
    def __init__(
        self,
        user_repository: UserRepository = Depends(UserRepository),
    ):
        self.repository = user_repository
  • fastapi의 Depends 는 인자로 들어온 클래스의 객체를 생성하여 반환한다.
  • 객체를 외부에서 생성하여 생성자의 인자로 전달하는 방식은 위와 동일하나, 객체 생성과 주입이 소스코드 상에서 직접 이뤄지는게 아니라 fastapi에서 제공하는 Depends 를 통해 이뤄진다

컨테이너

  • 프레임워크에서 IoC 기능을 제공하기 위해 컨테이너를 사용한다.
  • 프로젝트 내에서 의존 객체가 필요한 부분은 여러 부분이 있을 수 있으며, 각각 의존하는 객체도 하나가 아닐 수 있다.
  • 프레임워크가 프로젝트에서 필요한 의존 객체를 관리하기 위해서는 객체를 등록하고 불러올 수 있는 중앙화된 저장소가 필요하며 이를 컨테이너라고 한다.
  • 컨테이너의 역할은 다음과 같다
    • 주입이 필요한 의존 객체 관리
    • 의존성 주입이 필요한 부분에 의존 객체 주입
    • 의존 객체의 scope, lifecycle 관리

결론

  • “런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제3의 존재가 결정한다” 라는 의미는 의존성 주입을 할 때 개발자가 소스코드를 직접 객체 생성과 주입을 하지 않는다 라는 것을 의미한다.
  • 생성과 주입은 프레임워크에서 제공하는 컨테이너가 책임지고, 만약 생성과 주입이 분리가 된다면 생성은 팩토리에서, 주입은 컨테이너가 책임지도록 해야 한다

인터페이스

  • 마지막으로 “클래스 모델이나 코드에는 런타임 시점의 의존관계가 드러나지 않는다. 그러기 위해서는 인터페이스만 의존하고 있어야 한다.” 의 의미를 확인해본다.
class Service:
    def __init__(self, repository: UserRepository):
        self.repository = user_repository

service = Service(UserRepository())
  • 생성자의 인자로 UserRepository 만 주입하도록 되어 있어, Service 는 UserRepository 외에는 다른 클래스에는 의존할 수 없다.
  • 만약 다른 Repository가 Service 에 주입이 되어야 한다면, 소스코드에 대한 수정이 발생하게 된다.
class Repository(metadata=ABCMeta):
    ...

class UserRepository(Repository):
    ...

class AdminRepository(Repository):
    ...

class Service:
    def __init__(
        self,
        repository: Repository,
    ):
        self.repository = repository

  • 위 소스코드에서 Service 는 Repository 라는 인터페이스에 의존하고 있다.
service = Service(UserRepository())
service = Service(AdminRepository())
  • Service 가 정해진 클래스가 아닌 인터페이스에 의존함으로서 다른 Repository에도 의존할 수 있도록 확장되었다.
  • 정해진 클래스 보다 인터페이스에 의존함으로써 소스코드의 확장성을 보장하고, 다른 클래스와의 의존 관계 설정이 가능해진다.

결론

  • 위 예시에서 Service 가 어떤 클래스와 관계가 형성되는지 소스코드 상에서는 드러나지 않으며, 이는 런타임에서 Service 에 어떤 객체를 전달하는지에 따라 결정된다.
  • 소스코드상으로 Service 는 어떤 클래스에 의존하는지 명시되어 있지 않고, 이는 런타임에 Service 에 어떤 객체를 주입해주느냐에 따라 결정된다.

종합

  • 간단하게 정리하자면 다음 조건을 만족하는 것을 의존성 주입이라 한다.
- 클래스 내부가 아닌 외부에서 객체를 생성하여 전달할 것
- 주입의 주체는 컨테이너등의 제3자일 것
- 클래스가 아닌 인터페이스에 의존하게 하여 소스코드상에서는 의존 관계가 드러나지 않게 할 것

왜 사용하는지?

  • 위 조건을 반대로 생각해보면 의존성 주입을 사용하는 이유를 알 수 있다.

리팩토링의 어려움

  • “의존관계는 사용할 오브젝트에 대한 레퍼런스를 외부에서 제공(주입)해줌으로써 만들어진다.” 를 반대로 생각해보자
class UserRepository:
    def __init__(self, auth_service: AuthService):
        self.auth_service = auth_service

class Service:
    def __init__(self):
        self.repository = UserRepository(AuthService())

service = Service()
  • 만약 UserRepository 명세가 변경되어서 생성자의 인자로 AuthService 가 추가되었다고 가정해본다.
  • Service 는 UserRepository 에 강하게 결합되어 명세가 변경될 경우 영향을 직접적으로 받는다.
  • 위에서 Service 는 UserRepository 의 생성에 관심이 없으며 관심을 가질 필요도 없다.
  • Service 의 책임은 UserRepository 를 통해 Service 에 명시된 기능을 수행하는 것이지, UserRepository 의 생성에 까지 책임을 질 필요가 없다.
  • 의존성 주입을 통해 객체를 외부에서 생성하여 책임은 외부에 넘기고, Service 자체의 기능만 수행할 수 있도록 한다.

생성과 사용이 분리되지 않음

  • “런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제3의 존재가 결정한다.”를 반대로 생각해보자
class AuthService:
    def __init__(self, jwt_service: JwtService):
        ...

class UserRepository:
	def __init__(self, auth_service: AuthService):
        ...

class Service:
    def __init__(
        self,
        user_repository: UserRepository,
        admin_repository: adminRepository,
    ):
        ...
  • 만약 위와 같은 의존 관계가 있을 때 Service 객체를 생성하기 위한 코드는 다음과 같을것이다.
auth_service = AuthService(JwtService())
user_repository = UserRepository(auth_service)
admin_repository = AdminRepository()
service = Service(user_repository, admin_repository)
  • Service 의 의존 관계가 복잡하기 때문에 하나의 객체를 생성하는데도 너무 많은 코드가 필요하다
  • 또한, 비즈니스 로직에 생성에 대한 로직이 포함되면 사용과 생성에 대한 책임이 분리되어 있지 않게 된다.
  • 컨테이너에는 의존성 주입을 위해 생성된 객체가 등록되어 있으며, 이를 통해 주입이 필요한 객체를 생성하는 기능 또한 포함하고 있다.
  • 팩토리나 컨테이너 같은 제 3자를 통해 객체를 생성하도록 하여 생성과 책임을 분리하고 비즈니스 로직에만 집중하도록 한다.

확장성 감소

  • 인터페이스 장에서 확인한 것 처럼 특정 클래스에 의존하는 것은 확장성을 제한하고 소스코드 수정을 유발한다.
  • 인터페이스를 활용하여, 클래스는 최대한 추상적인것에 의존하도록 하여 확장성을 고려할 수 있도록 한다.

테스트의 어려움

class Service:
    def __init__(self):
        self.repository = UserRepository()

    def find_all(self):
        return self.repository.find_all()
  • UserService 가 UserRepository 에 의존하며 find_all 이라는 메서드를 호출한다..
  • find_all 에 대한 test를 작성해보면 다음과 같을 것이다.
class TestService(unittest.TestCase):
    def setUp(self):
        self.service = Service()

    def test_find_all(self):
        result = self.service.find_all()
        expected = []
        self.assertEqual(expected, result)
  • 위 테스트는 실행하는 순간 DB에 연결하여 실제 데이터를 조회한다. 실제 데이터가 expected와 동일하게 아무 값이 없다면 성공할 것이다.
  • 하지만, 단위테스트를 실행할 때 마다 DB에 연결하게 되면 테스트를 위한 코스트가 증가하며 적절하지 않다.
  • 또한, find_all 테스트에서 에서 우리가 확인해야 하는 로직은 실제 데이터를 확인하는게 아닌 repository 의 find_all 메서드를 호출했는지 여부이다.
  • 보통 우리는 mocking을 통해 UserRepository 를 가짜 객체로 대체하고 우리가 원하는 로직을 테스트하지만, 위 경우처럼 repository가 static하게 결정된다면 mocking과 테스트가 어렵다.
728x90

프레임 워크별 비교

fastapi

기본 사용 방법

Depends 를 사용한 주입

class UserService:
    ...

@app.get("/")
async def user_controller(
    user_service: Depends(UserService)
):
    ...
  • fast api 기본적인 주입 방법
  • Depends 에 특정 클래스를 인자로 넘긴 경우 해당 클래스의 __call__ 매직메서드를 호출하여 객체를 생성하고 반환
  • 사실상 __call__ 메서드 호출 결과를 주입한다고 생각하면 다음과 같이 factory 역할을 하는 메서드를 만들어서 주입이 가능하다.
class UserService:
    def __init__(self, user_id)
        self.user_id = user_id

        
def user_service_provider(
    token = Depends(TokenService),
    auth_service = Depends(AuthService)
):
    user = auth_service.get_user_from_token(token)
    return UserService(user.user_id)

@app.get("/")
async def user_controller(
    user_service: Depends(UserService)
):
    ...

특징

  • Depends 를 통해 컨테이너에 의존객체 등록, 의존객체 주입이 한번에 처리함
  • 의존 객체와 의존 객체를 사용하는 consumer가 분리되지 않음

nestjs

기본 사용 방법

  • nestjs에는 의존성 주입 관련하여 다음과 같은 요소가 있다.

provider

  • service, repository, factory 등의 nestjs 컨테이너에 등록되고, 주입될 수 있는 객체
  • spring에서의 bean과 유사한 개념
@Injectable()
export class UserService {

}
  • injectable 어노테이션을 사용하여 provider로 지정

module

  • nestjs에서 어플리케이션을 구성하는 단위로, 컨테이너에 등록될 provider를 관리한다.
  • providers 리스트에 주입될 클래스, provider를 등록하면, 해당 클래스의 객체는 컨테이너에 등록되어 주입이 가능해진다.
@Module({
    imports: [],
    controllers: [UserController],
    providers: [UserService],
})
export class UserModule { }

consumer

  • 생성된 객체를 주입 받아 소비하는 역할
  • 같은 provider 간에도 consumer - provider 관계가 있을 수 있으며, controller 등에도 주입 가능
  • 타입 힌팅을 통해 컨테이너에 등록된 객체를 주입받음
@Controller()
export class UserController {
    constructor(
        private readonly userService: UserService
    ) {}
}

특징

  • 객체를 주입받아 사용하는 consumer, 의존 객체를 제공하는 provider로 분리
  • IoC에 따라 컨테이너에 객체를 등록하고, 컨테이너가 의존성을 해결하여 주입

기능

custom provider

  • user가 provider를 생성하여 객체 생성
  • factory를 통해 객체 생성에 로직을 추가할 수 있음
const userServiceProvider: Provider = {
    provide: UserService,
    useFactory: () => {
        const service = new UserService(new UserRepository())
        return service
    },
    inject: [ UserRepository ],
}
  • inject: provider에서 객체 생성에 필요한 값을 주입
@Module({
    imports: [],
    controllers: [UserController],
    providers: [userServiceProvider],
})
export class UserModule { }
@Controller()
export class UserController {
    constructor(
        private readonly userService: UserService
    ) {}
}
  • module에 등록 후 controller에서 UserService 를 주입받아 사용

토큰 기반 주입

  • string 형식의 토큰을 만들어 의존 객체를 컨테이너에 등록하고 주입 가능
const userServiceProvider: Provider = {
    provide: "USER_SERVICE",
    useClass: UserService
}
@Controller()
export class UserController {
    constructor(
        @Inject("USER_SERVICE") private readonly userService: UserService
    ) {}
}

scope 지정

@Injectable({ scope: Scope.REQUEST })
export class UserService {

}
  • DEFAULT: singleton으로 생성되어 어플리케이션과 동일한 lifecycle을 가짐
    • 한번만 생성될 필요가 있는 서비스, 로거 등…
  • REQUEST: 요청으로 들어온 request와 동일한 lifecycle을 가짐
    • 유저 마다 다르게 생성되어야 하는 db connection
  • TRANSIENT: 생성된 객체가 공유되지 않으며, 주입이 필요할 때 마다 새로 생성

laravel

기본 사용 방법

  • laravel은 기본적으로 타입 힌팅을 통해 의존 객체를 주입할 수 있다.
<?php

class UserService
{

}
 <?php

class UserController extends Controller
{
    protected $service;

    public function __construct(UserService $service) {
        $this->service = $service;
    }
}

provider

  • 의존 객체를 등록하고 관리하는 역할
  • 사실상 컨테이너와 동일한 역할로 볼 수 있음
<?php

class AppServiceProvider extends ServiceProvider
{

    public $bindings = [
        UserService::class,
    ];

    public function register()
    {
        $service = new UserService();
				
        $this->app->instance('UserService', $service);
    }
}
  • $bindings 배열에 주입할 클래스를 등록하거나, register 함수에 객체를 추가하여 컨테이너에 등록

특징

  • 기본 흐름은 nestjs와 유사하게 컨테이너에 의존 객체 등록 → 필요한 클래스에 주입 이라는 방식은 동일
  • module이 아닌 provider를 통해 의존 객체를 등록

기능

singleton 등록

<?php
    
public function register()
{
    $this->app->singleton('UserService', function ($app) {
        return new UserService();
    });
}

조건부 의존 객체 생성

  • 의존 객체를 사용할 클래스에 따라 다른 객체 주입 가능
<?php
    
public function register()
{
    $this->app->when([AdminController::class, ManagerController::class])
      ->needs(UserService::class)
      ->give(function () {
          return new UserService(isAdmin: true)
      });

    $this->app->when(NormalController::class)
      ->needs(UserService::class)
      ->give(function () {
          return new UserService(isAdmin: false)
      });
}
  • 예시처럼 동일한 클래스를 조건에 따라 다른 객체로 주입 가능

컨테이너 직접 접근

  • 의존 객체를 주입하지 않고 의존성이 해결된 채로 사용하고 싶은 경우가 있다.
<?php

class UserController extends Controller
{
    public function findOne() {
        $service = resolve('UserService');
        ...
    }
}
  • resolve 메서드를 통해 주입을 하지 않고도 의존성이 해결된 객체 사용 가능
  • 위 예시에서 UserService 의 의존성이 해결된 객체가 반환된다.

spring

기본 사용 방법

@Component
public class UserService {
}
@RestController()
public class UserController {

    private UserService userService;

    @Autowired		
    public UserController(UserService userService) {
        this.userService = userService;
    }

}
  • @Component 어노테이션을 사용하여 bean으로 등록하고, @Autowired 어노테이션을 통해 주입

@ComponentScan

  • Component를 탐색하는 entrypoint 지정
  • 해당 어노테이션이 있는 패키지 부터 하위 패키지 까지 훑으면서 @Component 어노테이션이 붙은 클래스를 탐색하여 IoC에 등록

@SpringBootApplication

  • 스프링 어플리케이션이 시작되는 entrypoint
  • 해당 어노테이션이 붙은 클래스 부터 스프링 어플리케이션이 실행되는데 해당 어노테이션 내에 @ComponentScan 어노테이션이 붙어있음

특징

  • spring bean: spring의 컨테이너에서 관리되는 클래스
  • @Autowired 로 일반 자바 클래스와 컨테이너에서 spring bean을 구분
  • 기본적으로 spring의 container가 존재하며 @Autowired 로 주입이 필요할시에는 @Component 로 등록된 spring bean을 읽어 주입

scope

  • singleton: 컨테이너가 하나의 객체만 생성하여 전체 어플리케이션에서 공유
  • prototype: 주입이 필요할 때 마다 컨테이너가 하나의 객체만 생성
  • request: request 기반의 lifecycle

주입 방식

  • 생성자 주입
@RestController()
public class UserController {

    private UserService userService;

    @Autowired		
		public UserController(UserService userService) {
				this.userService = userService;
		}

}
  • 필드 주입
@RestController()
public class UserController {

    @Autowired
    private UserService userService;
}
  • setter 주입
@RestController()
public class UserController {

    private UserService userService;

		@Autowired
		public void setUserService(UserService userService) {
				this.userService = userService;
		}
}

fastapi에서는 어떻게 사용해야 하는가

  • fastapi의 Depends 는 강력하고, 의존성 주입에 대해 자율성이 보장되는 것은 맞지만 의존성 주입을 사용할 때 다음과 같은 불편한 점들이 있었다.
    • 컨테이너에 등록하는 로직이 없음: 특정 클래스가 컨테이너에서 관리되는 객체인지 아닌지가 불명확함. 의존성 주입이 가능한 클래스인지 아니면, 단독적으로 사용되는 클래스인지 구분되지 않음
    • 생성에 대해 책임을 지는 provider의 부재: Depends 가 __call__ 매직메서드를 호출하므로 임의의 메서드를 생성하여 provider 처럼 사용할 수는 있지만, 프레임워크 자체에서 관리할 수 있는 포인트가 있으면 좋겠다는 생각이 있음

dependency injector

  • 특정 프레임워크에 종속적이지 않은 python에 적용 가능한 의존성 관리 라이브러리
  • 위에서 느낀 두가지 불편함을 해소할 수 있을 것으로 보임

기본 사용 방법

container

  • 의존 객체를 등록하고, 관리하는 컨테이너
class Container(containers.DeclarativeContainer):
    wiring_config = containers.WiringConfiguration(modules=["controller"])

    service = providers.Singleton(UserService)
  • wiring_config: 의존 객체를 사용할 consumer 컴포넌트 지정
  • provider: callable 객체로 의존 객체를 생성하고 주입하는 역할

consumer

  • 의존 객체를 주입 받아 사용
router = APIRouter()

@router.get("/")
@inject
async def user_controller(
    user_service: UserService = Depends(Provide[Container.service])
):
    ...
  • inject 어노테이션으로 의존객체가 주입될 부분임을 표시한다.

main

from container import Container

if __name__ == "main":
    container = Container()
    app = FastAPI()
    app.include_router(router=router)
  • 어플리케이션 실행 이전에 Container 의 인스턴스를 생성해야 한다.

특징

다양한 provider

  • factory provider: 의존 객체 주입 마다 새로운 객체 생성
class UserService:
    def __init__(
        self,
        user_repository: UserRepository,
        is_admin: bool
    ):
        ...
class Container(containers.DeclarativeContainer):
    user_repository_factory = provider.Factory(UserRepository)

    service_factory = providers.Factory(
        UserService,
        user_repository = user_repository_factory
    )
@router.get("/")
@inject
async def user_controller(
    user_service: UserService = Depends(
        Provide[Container.service_factory(is_admin=False)]
    )
):
    ...

@router.get("/admin")
@inject
async def admin_controller(
    user_service: UserService = Depends(
        Provide[Container.service_factory(is_admin=True)]
    )
):
    ...
  • singleton provider
class Container(containers.DeclarativeContainer):
    user_repository = provider.Singleton(UserRepository)

    service_factory = providers.Singleton(
        UserService,
        user_repository = user_repository
    )
  • provider override
    • provider를 override하면 의존 객체 주입시 override된 객체를 주입함
    • 테스트코드에서 mocking에 사용
class UserService:
    def __init__(
        self,
        user_repository: UserRepository,
        is_admin: bool
    ):
        ...

class UserRepositoryMock(UserRepository):
    ...
class Container(containers.DeclarativeContainer):
    user_repository_factory = provider.Factory(UserRepository)

    service_factory = providers.Factory(
        UserService,
        user_repository = user_repository_factory
    )
container = Container()

container.user_repository_factory.override(providers.Factory(UserRepositoryMock))
service = container.service_factory()
assert isinstance(service.user_repository, ApiClientStub)

결론

  • 그렇다면, 의존성 주입이 무조건 필요한가? 마세라티 문제
  • 다만 의존성이 발생하는 부분에 대해 생성과 사용의 책임을 분리하고, 확장 가능한 소프트웨어를 만들고 싶다면 필요하다
  • 개인적으로
    • service, repository등의 클래스에 대해서는 최대한 의존성주입을 통해 의존성 생성
    • 객체 생성에 대한 로직은 분리
    • 내부 비즈니스 로직은 간단하게만 짜는게 best일 것 같다

 

728x90
반응형

'Development' 카테고리의 다른 글

카프카 구성 요소  (0) 2024.04.19
Comments