본문 바로가기

Java/Spring

Spring에서 확장성을 고려한 채팅방 생성 로직 전략 패턴 적용

개선 전: 단일 메서드로 직접 처리

/** 1:1 상품 채팅방 생성 or 조회 */
@Transactional
public ChatRoomResponse createOrGetProductChat(Long productId, User buyer) {
    ChatRoom room = chatRoomRepository
        .findByTypeAndProductIdAndBuyer(ChatRoomType.DIRECT, productId, buyer.getId())
        .orElseGet(() -> {
            Product product = productService.findById(productId);
            ChatRoom newRoom = ChatRoom.builder()
                .name(product.getName())
                .type(ChatRoomType.DIRECT)
                .productId(productId)
                .owner(product.getSeller())
                .build();
            newRoom.registerOwner(product.getSeller());
            newRoom.addOrActivateMember(buyer);
            return chatRoomRepository.save(newRoom);
        });
    return ChatRoomResponse.from(room);
}

 

 


왜 전략 패턴(인터페이스)로 바꿀까?

  • 단일 책임 원칙(SRP)
    각 채팅방 생성 로직을 별도 클래스로 분리하면,
    ChatRoomService가 “상품용”·“그룹용”·“이벤트용” 등 여러 책임을 갖지 않음.
  • 개방-폐쇄 원칙(OCP)
    새로운 채팅방 유형이 추가될 때마다 기존 코드를 수정할 필요 없이,
    ChatRoomCreator 인터페이스 구현체만 새로 추가하면 끝.
  • 유지보수·테스트 용이
    각 Creator를 독립적으로 단위 테스트할 수 있고,
    로직이 커질수록 if-else·switch문이 사라져 가독성이 좋아짐.

단점

  • 초기 복잡도 증가
    인터페이스·구현체가 많아지면서 파일 수와 설정이 늘어나고, 학습 곡선이 가파를 수 있음.
  • 과도한 추상화
    채팅방 유형이 2~3개 이하로 적으면 전략 패턴이 오히려 코드 구조를 불필요하게 복잡하게 만들 수 있음.
  • 런타임 오버헤드
    요청 시 매번 Map에서 전략을 조회하는 추가 연산이 발생.
  • 테스트 비용 증가
    각 전략 구현체마다 별도의 단위 테스트가 필요해져, 테스트 코드 양이 늘어남.
  • 한마디로 조금 귀찮아짐..ㅎㅎ

개선 후: 인터페이스 기반 구조

전략 인터페이스 정의

public interface ChatRoomCreator {
    ChatRoomType type();
    ChatRoomResponse createOrGet(Object identifier, User user);
}

상품 1:1 채팅 Creator

@Component
@RequiredArgsConstructor
public class ProductChatRoomCreator implements ChatRoomCreator {
    private final ChatRoomRepository repo;
    private final ProductService productService;

    @Override
    public ChatRoomType type() {
        return ChatRoomType.DIRECT;
    }

    @Override
    @Transactional
    public ChatRoomResponse createOrGet(Object id, User buyer) {
        Long productId = (Long) id;
        return repo.findByTypeAndProductIdAndBuyer(type(), productId, buyer.getId())
            .map(ChatRoomResponse::from)
            .orElseGet(() -> {
                Product product = productService.findById(productId);
                ChatRoom room = ChatRoom.builder()
                    .name(product.getName())
                    .type(type())
                    .productId(productId)
                    .owner(product.getSeller())
                    .build();
                room.registerOwner(product.getSeller());
                room.addOrActivateMember(buyer);
                return ChatRoomResponse.from(repo.save(room));
            });
    }
}

그룹 채팅 Creator

@Component
@RequiredArgsConstructor
public class GroupChatRoomCreator implements ChatRoomCreator {
    private final ChatRoomRepository repo;

    @Override
    public ChatRoomType type() {
        return ChatRoomType.GROUP;
    }

    @Override
    @Transactional
    public ChatRoomResponse createOrGet(Object dtoObj, User user) {
        ChatRoomCreateDTO dto = (ChatRoomCreateDTO) dtoObj;
        ChatRoom room = ChatRoom.builder()
            .name(dto.chatRoomName())
            .type(type())
            .owner(user)
            .build();
        room.registerOwner(user);
        return ChatRoomResponse.from(repo.save(room));
    }
}

ChatRoomService 변경

@Service
@RequiredArgsConstructor
public class ChatRoomService {
    private final Map<ChatRoomType, ChatRoomCreator> creators;

    @Transactional
    public ChatRoomResponse createOrGetChatRoom(
            ChatRoomType type, Object identifier, User user) {
        ChatRoomCreator creator = creators.get(type);
        if (creator == null) {
            throw new IllegalArgumentException("지원하지 않는 타입: " + type);
        }
        return creator.createOrGet(identifier, user);
    }
}

요약

  • SRP: 각 구현체가 “상품”, “그룹” 방만 책임
  • OCP: 새 유형 추가 시 서비스 코드 불변
  • 테스트·유지보수성 대폭 향상