순환참조 문제를 드디어 만나다

hanana·2023년 4월 20일

강의를 듣고, 강의 내용을 기반으로 나만의 사이드 프로젝트를 진행하던 중
The dependencies of some of the beans in the application context form a cycle 라는 오류 메세지를 만났다.

┌─────┐
|  directionService defined in file [C:\xxx\xxx\cheers_up\cheers_up\out\production\classes\com\hana\cheers_up\application\pub\service\DirectionService.class]
↑     ↓
|  pubService defined in file [C:\xxx\xxx\cheers_up\cheers_up\out\production\classes\com\hana\cheers_up\application\pub\service\PubService.class]
└─────┘

즉, pubService 와 directionService가 서로 순환참조 하고 있음을 발견하게 되었다.

의존성 주입시 생성자주입이 막연히 좋다고만 들었는데, 막상 실제로 컴파일에러로 만나게 되니까 너무 반가웠다.


문제의 코드는 다음과 같았다.

PubService

private final DirectionService directionService;

public void recommendPubs(String address) {
    KakaoResponseDto kakaoResponseDto = kakaoAddressSearchService.requestAddressSearch(address);

    if (ObjectUtils.isEmpty(kakaoResponseDto) || CollectionUtils.isEmpty(kakaoResponseDto.documentDtos())) {
        log.error("[PubService recommendPubs fail] Input address: {}", address);
    }

    DocumentDto documentDto = kakaoResponseDto.documentDtos().get(0);
    List<Direction> directions = directionService.buildDirectionListByCategory(documentDto);

    directionService.saveAll(directions);
}

DircetionService

private final PubService pubService;

public List<Direction> DirectionList(DocumentDto documentDto) {
    if(Objects.isNull(documentDto)) return Collections.emptyList();

    return pubService.Pubs().stream()
            .map(
                pubDto -> Direction.from(documentDto, pubDto))
                .filter(direction -> direction.getDistance() <= RADIUS_KM)
                .sorted(Comparator.comparing(Direction::getDistance))
                .limit(MAX_SEARCH_COUNT)
                .toList();
}

간단히 설명하자면
PubService는 받은 주소를 바탕으로 x,y 좌표를 구한후, 해당 정보를 DirectionService에게 넘겨서 근처의 술집을 불러와서 저장하는 기능을 구현하였고,

DirectionService는 PupService에 저장되어 있는 모든 술집을 조회하고, 파라미터로 전달받은 사용자의 위치인 DocumentDto를 통해서 가까운 술집을 추천해주고자 의도한 서비스이다.

각각의 서비스가 서로에게 의존하는데, 두 서비스 모두 생성자 주입을 하고 있으므로 컴파일 시에 스프링 컨테이너가 '어느게 먼저예요??' 하고 오류를 던진 것 같다.


실제로 두 서비스는 가동시 서로가 서로를 호출하여 무한루프가 나오게끔 되어있지는 않으나,
어쨌든 문제가 발생했고 나는 그것을 해결해야한다.


당장 머리속에 들어온 생각은 '시스템 구조상 서로가 서로를 call 하면서 무한루프가 생성될 것 같진않다.' 그렇다면 그냥 필드주입으로
@Autowired
private PubService pubService;

이런 느낌으로 선언하면 되지 않을까? 싶은 생각이 들었다.


그러나 애초에 저러한 구조가 나왔다는것 자체가 설계가 잘못되었다는 말과 같다는 말이 있고,
이는 언제 터질지 모르는 폭탄돌리기를 하는것이라는 생각이 들었으며
'저런 오류를 컴파일 시점에 발견하기 위해서 생성자 주입을 사용하는것인데
컴파일 오류가 난다고 필드주입을 한다?' 말도 안되는 생각 같아서 이내 접었다.


두번째로 든 생각은 '두 서비스를 연결해주는 인터페이스를 하나 만들면 어떨까?' 였다. 실제로 생각만 해보고, 직접 코드를 구현하지는 않았지만, 대략
public interface PubDirectionMappingService  {
	private final PubServie pubService;
    private final DirectionService directionService;
    
    ...

이런느낌으로 각자의 메소드를 호출할 일이 있으면 해당 인터페이스를 호출하는 식으로.. 설계를 하면 어떨가 싶었지만..
이는 직관성이 떨어지고, 서비스간의 순환참조를 해결하기 위해서 중간에 인터페이스를 두고 통신하기 보다는 더 좋은 방법이 있을것이라는 생각이 들었다.


세번째로 든 생각이자 내가 선택한 방법은 의존관계를 한방향으로 만드는 것이였다.
방향을 어디로 잡아야 할 지 잠깐 고민이 되었지만, '일단 뜯어고치면서 잡아보고 리팩토링 하면 되지 뭐!' 하고 바로 리팩토링을 시도하였다.

리팩토링 과정에서 PubService에서 DirectionService에 의존하는 recommendPubs라는 메소드를 지우고 이를 DirectionService에서 직접 구현하였다.

public void recommendPubs(String address) {
    KakaoResponseDto kakaoResponseDto = kakaoSearchService.requestAddressSearch(address);

    if (ObjectUtils.isEmpty(kakaoResponseDto) || CollectionUtils.isEmpty(kakaoResponseDto.documentDtos())) {
        log.error("[PubService recommendPubs fail] Input address: {}", address);
    }

    DocumentDto documentDto = kakaoResponseDto.documentDtos().get(0);
    List<Direction> directions = buildDirectionListByCategory(documentDto);

    directionRepository.saveAll(directions);

}

또한 기존에 directionService를 의존하는 부분이 있었는데, 해당 부분은 DirectionService내에서 구현이 가능하므로,

private List<Direction> buildDirectionListByCategory(DocumentDto inputDto) {
    if(Objects.isNull(inputDto)) return Collections.emptyList();

    return kakaoSearchService.requestPubCategorySearch(inputDto.latitude(), inputDto.longitude(),RADIUS_KM)
            .documentDtos().stream()
            .map(pubDto -> Direction.from(inputDto, pubDto))
            .toList();
}

이렇게 내부 메소드로 분리할 수 있었다. 또한 마지막 saveAll() 메소드역시 이제는 바로 DirectionRepository에 맡길 수 있게되었다.
의존성이 줄어든 만큼 코드의 가독성이 높아지고, 훨씬 직관적이게 되었다.


최근 강의를 통해 들은말이 있다.
'테스트하기 쉬운 코드는 일반적으로 좋은 코드일 확률이 높다.', '테스트를 할 수 없는 환경이더라도 테스트하기 쉬운 코드를 만들다보면 좋은 설계에 다가갈 수 있다.' [인프런 - Java/Spring 주니어 개발자를 위한 오답노트 : 김우근]


실제로 아직은 테스트코드를 기반으로 개발하고 있지는 않으나, 이번 오류 해결 과정을 통해서 의존성이 약해졌고, 테스트코드를 짜게 된다면 그만큼 쉽게 테스트 코드가 나올것 같다는 생각이 들었다.

강사님이 말했던 테스트하기 쉬운코드가 좋은코드라는 말이 와닿는 경험이였고,
'왜 생성자 주입을 사용해야 하는가' 에 대해서 직접 문제를 만나고 해결하는 과정에서 해답을 찾게 되어서 너무나 기쁘다.

profile
성숙해지려고 노력하지 않으면 성숙하기까지 매우 많은 시간이 걸린다.

0개의 댓글