소개 배경
Description:
The dependencies of some of the beans in the application context form a cycle:
securityConfig defined in file [C:\BE-Mogakco\mogakco\build\classes\java\main\project\mogakco\global\config\SecurityConfig.class]
↓
OAuth2LoginSuccessHandler defined in file [C:\BE-Mogakco\mogakco\build\classes\java\main\project\mogakco\global\handler\oauth\OAuth2LoginSuccessHandler.class]
┌─────┐
| rewardServiceImpl defined in file [C:\BE-Mogakco\mogakco\build\classes\java\main\project\mogakco\domain\mypage\application\impl\RewardServiceImpl.class]
↑ ↓
| timerServiceImpl defined in file [C:\BE-Mogakco\mogakco\build\classes\java\main\project\mogakco\domain\timer\application\impl\TimerServiceImpl.class]
└─────┘
Action:
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.
현재 진행중인 프로젝트에서 순환 참조 에러를 만나 원래 이를 포스팅하게되었다.
개발 단계에서 상당히 많이 마주할 수 있는 에러로, 해당 에러의 대부분의 경우 내가 중복이 일어날 수 밖에 없는 구조를 사용하기에 이러한 부분을 해결함과 동시에 더 효율적인 구조로 변경하고 어떻게 효율적인지 설명을 중점으로 포스팅하도록하겠다.
Trouble(문제 원인)
우선 순환 참조(Circular Refrence)에 대한 이해가 바탕이 되어야 해당 문제를 이해할 수 있다.
순환 참조란 정의적으로 서로 다른 Bean들이 서로 참조를 할경우 발생하게 된다.
이 과정에서 두 클래스간의 의존성이 뒤바뀌는데, 클린 아키텍처에서 말하는 고수준과 저수준의 클래스의 입장이 바뀌게 되어 즉, DIP(Dependency Inversion Principle, 의존성 역전 원칙) 로 이어진다.
해당 경우는 Runtime을 유발하진 않지만, 애플리케이션 실행시점에서 스프링 컨테이너에 의해 캐치되어 애플리케이션의 구동을 막게된다.
💡 순환 참조 캐치(catch) 시점
순환 참조의 경우 애플리케이션이 구동될 때, 스프링컨테이너가 Bean을 생성하는 시점에서 등록된 Bean끼리 사이클(Cycle) 관계를 형성하게된다. 이 시점에서 스프링컨테이너가 순환참조가 발생하였는지 여부를 파악할 수 있다.
우리는 @Service Annotation을 이용해 비즈니스 로직들을 Bean에 등록하게 되는데, 내가 구성한 비즈니스 로직 중 하나의 메소드 실행흐름(Work Flow)에서 서로 참조하는 문제가 발생할 수 있기에 스프링 컨테이너가 에러를 발생시켰던 것이다.
말로만 설명을 들으면 이해가 잘 안갈 수 있다. 코드와 함께 살펴보도록하자
원인 코드
위의 에러 메세지를 살펴보면 알겠지만 현재 RewardServiceImpl이라는 Reward 관련 비즈니스 로직과 TimerServiceImpl이라는 Timer Service Layer가 서로 참조하여 발생한 문제로 보여진다.
Timer Service Layer 의존성 주입부분
@RequiredArgsConstructor
@Log4j2
@Service
@Transactional(readOnly = true)
public class TimerServiceImpl implements TimerService {
...
private final RewardService rewardService;
Reward Service Layer 의존성 주입부분
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class RewardServiceImpl extends RewardService {
private final RewardMemberSocialRepository rewardMemberSocialRepository;
private final RewardRepository rewardRepository;
private final TimerService timerService;
위의 의존성 주입부분을 살펴보면 두 Service Layer는 현재 의존성 주입을 통해 서로 참조하고 있는 상태이다.
그리고 문제가 되는 로직 부분을 살펴보자면 아래와 같다
@Override
@Transactional
public ResponseEntity<?> recodeTimeToday(TimerRecodeDTO.timerRecodeInfoToday timerRecodeInfoToday) {
MemberSocial findM = memberService.getMemberInfoByOAuthId(timerRecodeInfoToday.getOauthId());
Optional<Timer> findT = timerRepository.findByTimerCreDayAndMemberSocial(timerRecodeInfoToday.getTimerCreDay(), findM);
if (findT.isEmpty()) {
Timer t = timerRepository.save(
Timer.builder()
.recodeTime(changeTimeFormatToString(timerRecodeInfoToday))
.memberSocial(findM)
.day_of_totalTime(sumOfDayTime(timerRecodeInfoToday))
.timerCreDay(timerRecodeInfoToday.getTimerCreDay())
.build()
);
TimerResponseDTO.RecodeTime recodeTime = t.toDTO();
rewardService.initializeRewardService("timer",findM);
return new ResponseEntity<>(recodeTime, HttpStatus.OK);
} else {
현재 TimerService 중 일부인 Timer 데이터 저장 로직을 가져 온것이다.
해당 로직의 요구사항으로는 타이머 저장시에, Reward Service Layer 쪽으로 흐름을 전달하여 현재 타이머 사용횟수를 체크한다. (오늘 날짜 기준으로, 타이머 첫 저장이 if 분기로 True일 때 로직이다.)
이때 흐름을 받는 Reward Service 로직 중 일부는 아래와 같다.
@Transactional
public void timerInitialize(MemberSocial memberSocial) {
switch (timerService.getTimerInfoListByMemberSocial(memberSocial)
.size()
){
case 1:
saveRewardMemberSocial(saveOnlyReward("부서진 초시계(을)를 얻었다"),memberSocial);
break;
case 50:
saveRewardMemberSocial(saveOnlyReward("초시계(750G) 획득!"),memberSocial);
break;
case 100:
saveRewardMemberSocial(saveOnlyReward("존야의 모래시계"),memberSocial);
break;
case 500:
saveRewardMemberSocial(saveOnlyReward("시간의 지배자"),memberSocial);
break;
}
}
여기서도 TimerService를 호출하여 사용을 하는데 이 시점에서 순환 참조가 발생하여 스프링 컨테이너가 에러를 일으켰다고 볼 수 있다.
해당 이미지는 실제 Service Layer의 흐름을 이미지화 시켜본 것인데, 아래와 같은 흐름으로 실행된다.
- 사용자가 Timer 로직에 접근한다.
- Timer 로직은 실행되다가 코드적 흐름대로 RewardServiceImpl을 참조하여 Reward 로직에 접근한다.
- Reward 로직은 실행되다가 코드적 흐름대로 TimerServiceImpl을 참조하여 Timer 로직에 접근한다.
- 해당 2,3번 과정이 무한히 반복된다.
이러한 실행흐름을 띄게 될 경우 Runtime에서 무한루프에 빠지게되어 서버의 큰 장애를 일으키게되거나 서버 자체가 동작을 중지하게되어 스프링 컨테이너가 이를 막고자 애플리케이션 실행단계에서 구동 자체를 막아버린다.
Trouble Shooting(문제 해결)
이를 해결하기 위한 방법으로는 크게 2가지가 존재한다.
첫 번째로는 DIP를 무시하고 직접적으로 해당 관련로직의 Repository에 쿼리를 발생시켜 응답을 받아오는 것이고,
두 번째로는 DIP를 위배하지 않되, 구조를 변경하는 것으로서 별도의 Interface를 사용하여 Service 로직을 분리시키는 방법을 필자는 이용했다.
코드적으로 보면 첫 번째 방법의 경우는 아주 간단하다.
private final TimerRepository timerRepository;
@Transactional
public void timerInitialize(MemberSocial memberSocial) {
switch (timerRepository.getTimerInfoListByMemberSocial(memberSocial)
.size()
){
case 1:
saveRewardMemberSocial(saveOnlyReward("부서진 초시계(을)를 얻었다"),memberSocial);
break;
case 50:
saveRewardMemberSocial(saveOnlyReward("초시계(750G) 획득!"),memberSocial);
break;
case 100:
saveRewardMemberSocial(saveOnlyReward("존야의 모래시계"),memberSocial);
break;
case 500:
saveRewardMemberSocial(saveOnlyReward("시간의 지배자"),memberSocial);
break;
}
}
이렇게 TimerService로 의존성 주입을 하던 것을 Repository로 변경하면 아주 간단히 이슈가 해결되나 문제는 DIP를 위배하고 있고, 구조상으로도 Repository는 해당 관련 Service Layer에만 존재하는 것이 맞다고 생각하여 해당방법을 기피했다.
그래서 두 번째 방법은?
두 번째 방법으로는 읽기 전용 로직을 별도의 Interface와 구현체로 분리하였다.
해당 방법을 사용한 이유는 두 가지 이점이 있기 때문인데 그 이유는 다음과 같다.
🤔 분리한다고 그게 좋아진다구요?
그렇다. 이해가 안갈 수 있지만 위의 참고 링크를 보면 읽기 전용쿼리를 날렸을 때 성능이 향상되는데, 그 이유는 영속성 플러쉬가 발생하지 않고, 메모리 최적화적 이점도 얻을 수 있다.
또한, 클린 아키텍처에서 언급하는 목적에 맞는 메소드의 분리를 실현할 수 있는 이점도 존재한다.
그렇기에 조회 서비스 로직들을 모아 별도의 읽기 전용 서비스 클래스를 구축하는 방법을 이용하면 총 3가지 장점을 얻을 수 있다.
- DIP 준수
- 읽기 전용 쿼리를 통한 성능 향상
- 목적에 맞는 메소드의 분리
코드를 살펴보면 다음과 같이 별도의 TimerCheckService(타이머 조회 서비스)를 만들어 순환 참조 문제를 해결하였다.
읽기 전용 서비스
public interface TimerCheckService {
List<Timer> getTimerAllInfo();
List<Timer> getTimerInfoListByMemberSocial(MemberSocial memberSocial);
}
의존성 주입부분
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class RewardServiceImpl extends RewardService {
private final RewardMemberSocialRepository rewardMemberSocialRepository;
private final RewardRepository rewardRepository;
private final TimerCheckService timerCheckService;
결론
에러를 해결하기 위해 작업을 하던 도중, 오히려 더 효율적인 구조를 생성할 수 있는 특이한 경험을 했다. 😂
반대로 말하자면, 읽기 전용이나 목적에 맞는 분리를 알고 있음에도 구조를 생각하지 못했다는 점이 아직은 클린 아키텍처와 응답속도를 개선하는 부분에 있어 미숙하다고 생각이 든다.
에러를 해결하는것은 언제나 좋은 경험이기에, 회고를 자주 하지만 에러를 해결하다가 더 효율적인 구조를 고려할 수도 있는 경험을 통해 성장의 기회를 맞이 할 수 있었던 것같다.
'Trouble-Shooting' 카테고리의 다른 글
Jackson InvalidDefinitionException (0) | 2023.05.17 |
---|---|
NoClassDefFoundError, Command is too long (0) | 2023.04.23 |
[JPA] @OneToOne - 주인 결정하기(양방향 참조) (0) | 2023.03.26 |
Refresh Token Rotation 이슈 해결 (0) | 2023.03.17 |
406 Error - Not Acceptable (0) | 2023.03.13 |