The Boxer
의존성 주입 본문
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