[시행착오] 빈 간의 순환 참조(Circular Dependency) 문제

박상민·2024년 9월 14일
1

Project, 시행착오

목록 보기
3/6

⭐️ 문제 원인

Error Code

***************************
APPLICATION FAILED TO START
***************************

Description:

The dependencies of some of the beans in the application context form a cycle:

   chatRoomController defined in file [/Users/sangmin8817/Desktop/SSL/build/classes/java/main/com/lawProject/SSL/domain/chatroom/api/ChatRoomController.class]
┌─────┐
|  chatRoomService defined in file [/Users/sangmin8817/Desktop/SSL/build/classes/java/main/com/lawProject/SSL/domain/chatroom/service/ChatRoomService.class]
↑     ↓
|  chatMessageService defined in file [/Users/sangmin8817/Desktop/SSL/build/classes/java/main/com/lawProject/SSL/domain/langchain/service/ChatMessageService.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.

프로젝트를 테스트 하기 위해 로컬에서 실행하던 중 이전까지 발생하지 않았던 에러가 발생했다.

처음 보는 에러라서 인터넷 검색을 해봤는데 빈 간의 순환 참조(Circular Depencency) 문제였다.

이 에러는 SpringBoot Application에서 순환 참조(circular reference)가 발생했기 때문에 애플리케이션이 시작되지 못한 상황을 나타낸다. 순환 참조란 두 개 이상의 빈(bean)이 서로를 의존하는 상황에서 발생한다.
즉, 위 에러 코드에서는 chatRoomServicechatMessageService를 의존하고, 반대로 chatMessageServicechatRoomService를 의존하면서 순환 참조가 일어난 것이다.

┌─────┐
|  chatRoomService defined in file [/Users/sangmin8817/Desktop/SSL/build/classes/java/main/com/lawProject/SSL/domain/chatroom/service/ChatRoomService.class]
↑     ↓
|  chatMessageService defined in file [/Users/sangmin8817/Desktop/SSL/build/classes/java/main/com/lawProject/SSL/domain/langchain/service/ChatMessageService.class]
└─────┘

ChatRoomService

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ChatRoomService {
    private final ChatMessageService chatMessageService;
    private final ChatRoomRepository chatRoomRepository;
    private final WebClient webClient;
 
 	...
}

ChatMessageService

@Service
@Slf4j
@RequiredArgsConstructor
public class ChatMessageService {
    private final UserService userService;
    private final ChatRoomService chatRoomService;
    private final ChatMessageRepository chatMessageRepository;
    
    ...
}

📌 해결 방법

해결 방법은 크게 3가지가 존재한다.

  1. 순환 참조 제거
  • 가장 좋은 방법이라고 생각한다. 현재 순환 참조가 발생하는 두 빈 간의 강한 의존 관계를 완화하거나, 분리된 책임으로 나눠 순환 참조를 끊는 것이 좋다.
    • 예를 들어, 나의 경우에는 chatRoomService, chatMessageService가 서로의 기능을 직접 호출하는 대신, 중간에 인터페이스나 이벤트 기반 통신을 도입할 수 있다.
  1. @Lazy 사용
  • 두 서비스 간의 강한 의존성을 유지해야 할 경우 @Lazy를 사용하는 방법을 고려할 수 있다. 하나의 빈에 대해 @Lazy 어노테이션을 사용하여 의존성을 지연 초기화(Lazy initialization)할 수 있다.
  1. 순환 참조 허용
  • 근본적인 해결책은 아니다. 하지만 긴급하게 문제를 해결해야 할 때 사용할 수 있는 예방책이 될 수 있다.
    application.prerties의 경우 spring.main.allow-circular-references=true를 추가하여 순환 참조를 허용할 수 있다.

나는 3가지 방법 중 순환 참조 제거 방법을 선택했다.


[문제 해결]

순환 참조를 제거하기 위해서는 문제가 발생하는 클래스 ChatRoomService, ChatMessageService 중에서 하나의 클래스에서 빈 참조 연관관계를 제거해야 한다.

문제가 되는 부분

// ChatRoomService
/* 채팅방 열기 메서드 - 채팅방 메시지도 조회 */
public ChatRoomMessageWithPageInfoResponse openChatRoom(String roomId, int page, int size, User user) {
    Page<ChatMessage> messages =
            chatMessageService.getChatRoomMessages(roomId, page, size);
    PageInfo pageInfo = new PageInfo(page, size, (int) messages.getTotalElements(), messages.getTotalPages());

    List<ChatMessageDto.ChatRoomMessageResponse> roomMessageResponses = messages.getContent().stream().map(
            ChatMessageDto.ChatRoomMessageResponse::of).toList();

    return ChatRoomMessageWithPageInfoResponse.of(roomMessageResponses, pageInfo, user.getId());
}
    
// ChatMessageService    
public Page<ChatMessage> getChatRoomMessages(String roomId, int page, int size) {
    ChatRoom chatRoom = chatRoomService.findByRoomId(roomId);

    Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending());

    return chatMessageRepository.findByChatRoom(pageable, chatRoom);
}

두 개의 Service 코드에서 상대방의 Sevice를 사용하는 메서드이다. 순환 참조 문제를 해결하려면 이 두 개의 메서드를 수정해야한다.

메서드의 기능을 살펴보자

  • openChatRoom : 채팅방 열기 메서드, 채팅방의 메시지 정보와 페이징 정보를 전달한다.
  • getChatRoomMessages: 메시지 반환 메서드, roomId와 일치하는 채팅방의 메시지를 조회해서 반환한다.

문제를 해결하기 위한 생각
우선 치명적인 결함을 발견했다. 사실 두 메서드의 기능은 동일하다는 것이다.
동일한 기능의 메서드를 중복해서 사용하고 있었다.

지금 생각하면 참 부끄러운 실수이지만 이 또한 성장의 과정이 아닐까?

문제 해결 방법은 간단했다.
메서드 중복을 제거하고 하나의 메서드만 남기면서 필요없어진 빈 참조 또한 제거하면 된다.

그렇다면 여기서 다시 고민이 생긴다.
ChatRoomService, ChatMessageService 중 어디에 메서드를 유지해야할까?

내가 내린 결론은 아래와 같다.
우선 메서드의 기능은 'roomId'에 일치하는 채팅방의 메시지 정보를 반환하는 것이다. 내 생각에 기능의 중점은 '메시지 정보의 반환'이다. 인자로 채팅방의 roomId를 받을 뿐 채팅방의 정보가 아닌 채팅방 속 '메시지 정보'를 반환하고 있다.

따라서 나는 ChatMessageService에 메서드를 유지하는 것이 맞다고 판단했다.

의존 관계 수정
ChatRoomService

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ChatRoomService {
    private final ChatRoomRepository chatRoomRepository;
    private final WebClient webClient;
 
 	...
}

ChatMessageService

@Service
@Slf4j
@RequiredArgsConstructor
public class ChatMessageService {
    private final UserService userService;
    private final ChatRoomService chatRoomService;
    private final ChatMessageRepository chatMessageRepository;
    
    ...
}

두 Service의 의존 관계를 위처럼 변경했다. '빈 간 순환 참조' 문제는 더이상 발생하지 않고 정상적으로 실행된다.

추가로 메서드의 기능은 아래처럼 수정했다.
조금 달라진 부분이 있는데, 기존에 존재하던 페이징 정보 반환을 유지할까 고민이 있었다.

채팅의 경우에는 페이징 방식 보다는 마지막 메시지의 Id를 받아서 새로운 메시지를 조회해서 반환해주는 무한 스크롤 조회 방식을 사용하는 것이 좋다는 의견이 있어서 기능 또한 페이징 방식에서 무한 스크롤 조회 방식으로 변경했다.

메서드 기능 병합과 기능 변경(참고용)

public List<ChatMessageDto.ChatRoomMessageResponse> getMessagesForInfiniteScroll(String roomId, Long lastMessageId, int size, User user) {
    ChatRoom chatRoom = chatRoomService.findByRoomId(roomId);

    if (!chatRoom.getUser().equals(user)) {
            throw new ChatRoomException(ErrorCode._FORBIDDEN);
    }

    Pageable pageable = PageRequest.of(0, size, Sort.by("id").descending());

    Page<ChatMessage> pagedMessages;
    if (lastMessageId == null) {
        // 처음 로드 시, 최신 메시지부터 시작
        pagedMessages = chatMessageRepository.findMessagesByRoomIdBeforeMessageId(roomId, Long.MAX_VALUE, pageable);
    } else {
        // lastMessageId가 있을 때, 해당 메시지 ID보다 오래된 메시지를 가져오기 위해 페이징 설정
        pagedMessages = chatMessageRepository.findMessagesByRoomIdBeforeMessageId(roomId, lastMessageId, pageable);
    }

    // 빈 페이지일 경우 빈 리스트를 반환
    if (pagedMessages.isEmpty()) {
        return Collections.emptyList();
    }

    // DTO로 변환
    return pagedMessages.stream()
           .map(ChatMessageDto.ChatRoomMessageResponse::of)
           .collect(Collectors.toList());
}

출처
https://ccomccomhan.tistory.com/143
https://en.wikipedia.org/wiki/Circular_dependency
https://stackoverflow.com/questions/38042130/what-is-a-circular-dependency-and-how-can-i-solve-it

0개의 댓글