문제점
현실의 객체는 순환 참조 관계를 가질 수 있다. 그러나 이를 코드로 구현할 때 문제가 발생한다. 그래서 클래스 설계를 할 때 현실의 순환 참조 관계에 있는 클래스들을 프로그램에서는 순환 참조하지 않도록 설계해야 한다.
예를 들어, RPG 게임의 캐릭터와 몬스터는 서로 공격하고 데미지를 주고 받을 수 있기 때문에 순환 참조 관계를 갖는 것처럼 보인다. 그러나 이를 구현할 때는 Battle이 Chracater와 Monster에 의존하게 하여 순환 참조를 피할 수 있다. 이것이 뒤에서 설명할 중재자 패턴이기도 하다.
- 생성할 때의 문제: 서로 의존성을 주입받아야 하는 관계에 놓여 있기 때문에 어느 클래스도 생성할 수 없다.
- A를 생성하려면 B 인스턴스가 필요하고, B를 생성하려면 A 인스턴스가 필요한 관계이니 어느 클래스도 생성할 수 없다.
- Spring에서 이 문제는 @Lazy Annotation을 이용한 프록시 객체 주입, Setter Injection으로 해결 가능하다
- 삭제할 때의 문제: 순환 참조하는 인스턴스 하나를 삭제하면 Null 참조를 피할 수 없다
2
- 순환 참조하지 않는 A -> B (A calls B) 관계에서는 B 인스턴스를 삭제하기 전 의존하는 A 인스턴스를 먼저 삭제하면 Null 참조 없이 안전하게 삭제할 수 있다.
- 그러나 순환 참조하는 A <-> B (A calls B, B calls A) 관계에서는 A 인스턴스를 먼저 삭제하면 B가 저장하고 있는 A 멤버는 실제로 Null을 참조하게 된다. 이는 B 인스턴스를 먼저 삭제해도 마찬가지라 Null 참조를 피할 수 없다. 재수 없게도 A를 삭제하고 B에서 A를 호출하는 메소드를 호출하거나, 다음 줄에서 바로 지우려고 했는데 하필이면 A를 호출하는 코드가 항상 실행되고 있는 코드라면 Null Pointer Exception이 발생한다. 3
- 강한 결합
- 유지보수성 저하
- 재사용성 저하
- 테스트 코드를 개발하기 어려움
- A -> B, A가 B를 의존한다는 건 A가 B 메소드를 호출할 수 있다는 것이고, B 메소드의 동작이 바뀔 때 A의 동작도 바뀔 수 있음을 의미한다. 그러나 순환 참조 관계에서는 A와 B 중 어떤 것을 수정해도 서로에게 영향을 미쳐서 코드 수정이 어려워진다.
해결법
중재자 패턴 - 중재자 클래스 생성
https://refactoring.guru/ko/design-patterns/mediator
중재자 패턴
/ 디자인 패턴들 / 행동 패턴 중재자 패턴 다음 이름으로도 불립니다: 중개인, 컨트롤러, Mediator 의도 중재자는 객체 간의 혼란스러운 의존 관계들을 줄일 수 있는 행동 디자인 패턴입니다. 이
refactoring.guru
중재자 클래스를 만들어서 A와 B가 서로 의존하지 않고, 중재자가 A,B에 의존하는 형식으로 구현할 수 있다. RPG 게임에서 Character.attack()이 Monster를 호출하는 대신, Battle.characterAttackMonster()가 Character의 메소드와 Monster의 메소드를 호출하게 하는 방식이다. 이렇게 하면 클래스 간 결합도가 낮아진다. 서로를 아는 건 중재자가 담당하고 각 클래스는 서로를 모르기 때문이다. 어떤 메소드에서 Side Effect가 많아져서 재사용에 어려움을 겪을 때도 고려해 볼 수 있는 패턴이다. 각 클래스는 SRP를 준수하게 리팩토링하고 중재자 클래스가 클래스별 이펙트 호출을 담당할 수 있다.
한 쪽에서만 호출하도록 리팩토링하기
어떤 플로우의 전체적인 제어권은 한 클래스가 가지고, 필요한 정보는 OOP의 원칙을 생각해서 메세지로만 주고 받게 리팩토링하면 순환 참조가 풀리기도 한다. 이번 프로젝트에서는 이 방법을 적용했다.
/**
* 온보딩 과정에서 입력받은 내 정보를 업데이트합니다.
*/
@Transactional
public UserMeOnboardingPatchResponse updateCurrentUserOnboarding(UserMeOnboardingPatchRequest request) {
...
userOnboardCommandService.updateIsUserUpdated(user.getId(), true);
updateOnboardIfCompleted(user);
return userMapper.toMeOnboardingPatchResponse(user);
}
/**
* 온보딩 완료 처리.
* 모든 온보딩 조건을 충족하면 user.onboard 필드를 true로 변경한다.
* [FU-26] 온보딩 보상 별 부여.
*/
public void updateOnboardIfCompleted(User user) {
// 이미 user.onboard 완료 처리 되었으면 예외 발생.
// 온보딩 보상 중복 부여 방지.
if (user.getOnboard() == true) {
// TODO: ValidationException -> 다른 예외로 변경
throw new ValidationException(USER_ONBOARD_ALREADY_COMPLETED);
}
if (userOnboardQueryService.isCompleted(user.getId())) {
user.updateOnboard(true);
addStar(user.getId(), ONBOARD_REWARD_STAR_COUNT);
}
}
기존에는 `updateOnboardIfComplted()`가 userOnboard 업데이트 후 반드시 실행되어야 하는 로직이라고 생각해서 저 로직을 `userOnboardCommandService`에 넣었다. 그러나 그랬더니 순환 참조 에러가 발생해서 SRP를 강하게 지키도록 리팩토링했다. UserCommandService에서는 User 수정만, UserOnboardCommandService에서는 UserOnboard 수정만 하도록 말이다.
당시에는 중재자 패턴을 정확하게 몰라서 이렇게 작성했는데 지금 보니 의도적으로 유저 업데이트와 온보딩 DB 업데이트를 결합시키면서 순환 참조도 해결하려면 중재자 클래스 OnboardService (또는 OnboardMediatorService)를 만드는 것이 더 나았을 것 같다. 온보딩 업데이트 후 -> 유저 업데이트 로직이 재사용되는 곳이 있어서 관심사는 같은데 관리 포인트가 분산되었기 때문이다.
큰 클래스에서 특정 책임을 분리하여 별도의 클래스 만들기
한 서비스가 담당하는 기능이 많으면 순환 참조도 발생하기 쉽다. UserService를 예로 들면, 어떤 클래스는 유저의 이름을 호출하기 위해 참조할 수도 있고, 어떤 클래스는 유저의 포인트를 변경하기 위해 호출할 수도 있다. 또 UserService 자체도 다른 클래스를 호출하기 쉬운 클래스이다. 이러다 보면 UserService 하나를 참조하는 클래스가 많아지기 때문에 순환 참조도 발생하기 쉬운 구조가 된다.
이럴 때는 자주 사용하는 기능을 다른 클래스(서비스)로 분리하는 것이 순환 참조를 해결하는 데 도움이 되기도 한다. 예를 들면 UserService에서 UserPointService를 분리하는 것이다. 이러면 유저의 다른 기능 없이 포인트 기능만 참조하는 클래스는 UserPointService를 참조하게 되므로 UserService를 참조하는 클래스 수를 줄일 수 있고, 그 결과 순환 참조가 해결되기도 한다.
또, 나중에 필요하다면 Microservice로 만들 수도 있다.
Event Driven Architecture
Side Effect가 일어날 때는 다른 클래스를 직접 호출하는 대신 이벤트를 발행하여 두 클래스 간의 의존도를 줄이는 방법도 있다. Kafka, RabbitMQ 같은 이벤트 스트리밍 서비스를 써도 되고, 같은 서비스 안에서의 이벤트라면 Spring Event를 써도 된다. 그러나 이 경우 실패 시 처리 문제, 비동기 처리 시 타이밍 문제 (후속 작업이 완료됨을 보장하지 않음) 등을 고려해야 한다.
Side Effect가 있으면 항상 중재자 클래스를 만들어야 하는가? - 실무 관점
실제 애플리케이션을 개발할 때는 '게시글 작성 후 유저에게 보상으로 유저에게 포인트를 주는 로직' 등 책임이 하나의 클래스에만 있지 않는 API를 구현해야 될 때가 많다. 위 로직을 PostService.create() 메소드에 구현한다면, 엄밀히 따졌을 때는 PostService가 게시글 관리에만 책임을 져야 하는 SRP를 위반하고, 게시글 생성 메소드인 `.create()`에는 유저 포인트가 변경되는 Side Effect가 발생한다. PostController에서 PostService와 UserService를 각각 호출한다면 이는 PostController가 비즈니스 로직이 아닌 통신에만 책임을 져야 하는 SRP 위반이 된다.
그러나 이를 해결하기 위해 위에서 배웠던 중재자 클래스를 매번 적용한다면 오버 엔지니어링이 될 수 있다. 앞서 말했던 것처럼 실무에서는 책임이 하나로 떨어지지 않는 요구사항이 많은데, 여기에 모두 중재자 클래스를 생성한다면 각각의 메소드가 아주 작은 기능만 담당하는 수많은 중재자 클래스로 쪼개질 것이고, 이러면 원하는 코드를 찾기 어려운 복잡한 코드가 될 것이다. 포인트를 부여하는 기능 하나마다 중재자 클래스 하나를 생성한다고 생각해 보라. 물론 구현하는데 시간도 더 걸릴 것이다.
그래서 실무에서는 SRP를 조금 위반하더라도 처음에는 PostService.create()에 UserService를 호출하여 Side Effect가 있는 방식으로 구현하고, 재사용이나 순환 참조 에러 발생 등 필요할 때 리팩토링하는 것이 개발 리소스를 적절하게 사용하고 코드를 복잡하지 않게 유지하는 방법이라고 생각한다. 재사용되지 않는 메소드는 리팩토링하지 않아도 잘 동작하기 때문이다.
함수 이름을 .createAndGivePoint()처럼 사이드 이펙트가 드러나게 짓거나, JavaDoc과 같은 주석으로 사이드 이펙트를 명시해서 사이드 이펙트가 있는 메소드를 잘못 사용하지 않도록 하는 센스를 발휘한다면 적은 리소스로 오류 확률을 줄일 수 있을 것이다.
참고 자료
https://javanitto.tistory.com/41
우아한테크세미나 - 우아한 객체지향 (feat. 조영호님)
이번 포스팅은 객체지향 설계에 대해 고민하고 있을 때 좋은 참고가 된 세미나 내용에 대한 정리입니다. 유튜브에 1시간 40분 정도 길이의 영상이 있는데, 사실 이 영상을 3번째 보고 이제야 이해
javanitto.tistory.com
Circular dependency - is there a good design to eliminate
I was writing some code and came across a scenario that I was thinking about doing a circular dependent class, which I have not done before. I then looked circular dependencies up and whether they ...
softwareengineering.stackexchange.com
https://stackoverflow.com/questions/26804803/is-circular-dependency-good-or-bad
Is circular dependency good or bad
I need to know why we need to avoid circular dependencies? In the real world if we think, circular dependencies are pretty much important. Like one friend needs something from other friend and the ...
stackoverflow.com
'Computer Science > Object Oriented Programming' 카테고리의 다른 글
[OOP] getter만 포함된 인터페이스를 생성해도 되는가? (0) | 2024.08.19 |
---|---|
[OOP] Factory (0) | 2024.04.09 |