[프로젝트 회고] 실시간 채팅 프로젝트

2025. 12. 31. 21:22·Spring/Spring 실습

📝 작성하며..

2025.12.09 ~ 2025.12.26 까지 진행한 실시간 채팅 프로젝트를 진행하며 담당했던 역할과 수행과정에서 중점적으로 다뤘던 개념과 내용들을 정리하고자 한다. 

 

📌 프로젝트 정보

  • 프로젝트명 : 실시간 채팅 프로젝트
  • 참여인원 : 4명
  • 사용 기술 스택 : Java 17, Spring Boot, Spring Security, JPA, MYSQL, Redis, Docker, AWS EC2, GitHub Actions
  • 프로젝트 개요 : 확장 가능한 실시간 채팅 상담 시스템을 구축한다.
  • 담당 역할 : 비즈니스 로직 전반 구현 (상품 / 채팅 / 메시지 목록 조회)

🌱 프로젝트 설계 

💡 구현에 중점을 두었던 사항

프로젝트를 진행함에 앞서서 구현을 진행할 때에 오류 사항들이 발생하는 구조가 무엇일지에 대해 먼저 생각해보는 과정을 가졌다. 

 

기존 구현과정에서는 다음과 같은 과정으로 구현을 진행했었다.

 

  1. 컨트롤러와 서비스에서 둘 다 검증을 진행하면 보다 더 안정적인 환경을 만들 수 있지 않을까?
  2. 메시지 목록 조회도 페이지 조회를 기반으로 하면 기존에 구현하던 방식과 크게 다르지 않게 나타낼 수 있지 않을까? 
  3. 서비스 레이어에 검증과정을 몰아서 구현했을 경우 어떤 특이사항이 생길 수 있을까?

🔍 문제 인식 ①

컨트롤러와 서비스 레이어에 동일한 검증 로직을 중복 구현할 경우,

보안성이 강화되는 것이 아니라 검증 규칙이 두 곳에서 관리되며 오히려 불일치 위험이 증가할 수 있음을 인식했다.

 

특히 검증 조건이 변경되거나 확장될 경우 컨트롤러와 서비스 중 한 곳만 수정되는 상황이 발생할 수 있으며,

이는 요청 경로에 따라 서로 다른 예외 코드나 응답 결과를 반환하는 문제로 이어질 수 있다.

 

또한 전역 예외 처리 구조를 사용하는 상황에서 컨트롤러 레벨에서 먼저 예외가 발생하면

실제 비즈니스 규칙의 실패 지점을 서비스 단에서 명확히 추적하기 어려워 원인 파악 및 유지보수 측면에서 불리하다고 판단했다.

 

🧩 어떻게 해결하고자 했는가

또한 컨트롤러 단계에서는 요청 DTO에 대해 @Valid 기반의 형식적 유효성 검증만을 수행하여,

필수 값 누락이나 형식 오류와 같은 입력 오류를 사전에 차단했다.

이는 DB 고유 제약 위반과 같은 오류를 미리 방지하기 위한 방어적 검증으로, 도메인 정책 판단과는 명확히 분리하여 처리했다.

비즈니스 규칙에 대한 판단은 모두 서비스 레이어에 위임하여, 컨트롤러가 도메인 정책을 알지 않도록 설계했다.

// Controller
@GetMapping("/chatrooms/{chatRoomId}/messages")
public ResponseEntity<GetChatMessageListResponse> getMessages(
    @PathVariable Long chatRoomId,
    @AuthenticationPrincipal User currentUser,
    @RequestParam(required = false) Long cursor
) {
    return ResponseEntity.ok(
        messageService.getMessageList(chatRoomId, currentUser.getId(), cursor)
    );
}
// Service
public GetChatMessageListResponse getMessageList(
    Long chatRoomId, Long userId, Long cursor
) {
    ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId)
        .orElseThrow(() -> new CustomException(CHATROOM_NOT_FOUND));

    if (!participantRepository.existsByChatRoomIdAndUserId(chatRoomId, userId)) {
        throw new CustomException(CHATROOM_ACCESS_DENIED);
    }

    // 메시지 조회 로직 …
}

 

🔍 문제 인식 ②

처음 프로젝트를 진행하면서는 페이지 기반 조회의 개념만 알고 있어 다른 목록 조회 구현방식과 동일하게 접근하고자 했다.

실시간 채팅이라는 특수성과, 무한 스크롤 형태의 구현이 필요하고, 요청이 반복적으로 발생한다는 점에서 페이지 기반 방식이 최적이 아님을 알게 되었다.

 

Page 대신 사용할 수 있는 방식으로는 Slice가 있었는데,

Slice는 페이지 전체 요소 수(Count)를 구하지 않아도 다음 페이지 존재 여부를 판단할 수 있어, 무한 스크롤 기반에서 불필요한 count 쿼리를 줄이는 선택이 될 수 있었다.

 

Slice를 적용하면서 "다음 구간은 어떤 기준으로 이어붙여야 하는가?"를 고민하게 되었고, 이 과정에서 cursor 기반 페이징을 찾아보게 되었다.

특히 실시간 채팅은 새 메시지가 계속 들어오므로 offset 기반 페이징이 흔들릴 수 있고, 커서 기반이 정렬 기준을 안정적으로 유지한다는 점이 매력적이었다. 

 

여기서 offset 기반에서 발생할 수 있는 문제는 아래와 같았다.

  • count 쿼리 비용 : 무한 스크롤에서 매번 전체 개수의 의미는 필요가 없으나 요구하게 되어 불필요한 비용이 들어간다.
  • 데이터 변동에 취약 : 새 메시지가 계속 들어오는 실시간 채팅 프로젝트 기반에서는 중복/누락을 발생시키게 되는 요인이 된다.

구현하고자 했던 cursor 기반 처리는 

  • 커서는 페이지가 아니라 어디까지 읽었는가로 다음 데이터를 가져오며 이는 실시간 데이터 흐름에서 안정적이었다.

🧩 어떻게 해결하고자 했는가

  • Page 대신 Slice를 사용해 count 쿼리를 제거하고 무한 스크롤에 적합한 구조로 전환했다.
  • offset 기반 대신 cursor(id < cursor) 기반 조회를 적용해 실시간 데이터 환경에서 정렬 안정성을 확보했다.
  • DB 조회는 최신순(DESC), 응답은 시간순(ASC)으로 재정렬해 성능과 UX를 동시에 고려했다.
  • nextCursor를 가장 과거 메시지 id로 설정해 다음 구간을 명확히 이어갈 수 있도록 했다.
@Transactional(readOnly = true)
public GetChatMessageListResponse getMessageList(Long chatRoomId, Long cursor, Integer size, Long currentUserId) {
    validateChatRoomExists(chatRoomId);
    validateParticipant(chatRoomId, currentUserId);
    validateCursor(cursor);
    validateSize(size);

    int resolvedSize = (size == null) ? DEFAULT_SIZE : size;

    Pageable pageable = PageRequest.of(
        0,
        resolvedSize,
        Sort.by(Sort.Direction.DESC, "sentAt").and(Sort.by(Sort.Direction.DESC, "id"))
    );

    Slice<Message> slice = (cursor == null)
        ? messageRepository.findByRoom_Id(chatRoomId, pageable)
        : messageRepository.findByRoom_IdAndIdLessThan(chatRoomId, cursor, pageable);

    List<Message> content = slice.getContent();

    List<ChatMessageListItem> items = content.stream()
        .sorted(Comparator.comparing(Message::getSentAt).thenComparing(Message::getId)) 
        .map(ChatMessageListItem::from)
        .toList();

    Long nextCursor = (!slice.hasNext() || content.isEmpty())
        ? null
        : content.get(content.size() - 1).getId(); 

    return GetChatMessageListResponse.builder()
        .chatRoomId(chatRoomId)
        .size(resolvedSize)
        .hasNext(slice.hasNext())
        .nextCursor(nextCursor)
        .messageList(items)
        .build();
}

🔍 문제 인식 ③

검증을 서비스 레이어로 몰아 구현한 결과,

컨트롤러와 진입점 간 규칙 일관성은 확보할 수 있었지만

서비스 메서드의 초반부가 검증 코드로 길어지는 현상이 발생했다.

 

실제로 코드 리뷰 과정에서 “서비스가 검증 책임까지 모두 떠안으며 비대해지고 있다”는 피드백을 받았고,

이를 통해 단순히 검증 위치를 옮기는 것만으로는 충분하지 않다는 점을 인식하게 되었다.

🧩 어떻게 해결하고자 했는가

이후 검증 로직을 private 메서드로 분리하고, 
검증의 순서를 비용 관점에서 재정의함으로써 서비스의 가독성과 책임 분리를 함께 개선하고자 했다.

if (chatRoomId == null || chatRoomId <= 0) ...
ChatRoom chatRoom = ...
boolean isParticipant = ...
if (!isParticipant) ...
// 매핑 로직

처음 구현했던 방식은 이렇게 구현했다. 검증 / 조회 / 매핑이 한 메서드에 섞여있어 무엇을 막는지가 먼저 보였다.

 

validateGetChatRoomDetailInput(chatRoomId);

ChatRoom chatRoom = findChatRoomOrThrow(chatRoomId);
validateParticipant(chatRoom, currentUserId);

ProductInfo productInfo = extractProductInfo(chatRoom);
List<ParticipantsListItem> participantsList = mapParticipants(chatRoom);

return new GetChatRoomDetailResponse(...);

이와 같이 흐름이 위에서 아래로 읽히게 하고, 생성, 수정 기능과 동일하게 설꼐 의도를 드러내게 리팩토링이 필요했다.

 

🧠 회고 정리

이번 구현을 통해 “더 안전할 것 같다”, “기존 방식이 편하다”라는 감각적인 판단보다

도메인 특성과 호출 흐름을 기준으로 설계를 검증하는 과정이 중요하다는 것을 배웠다.

 

컨트롤러와 서비스의 역할 분리, 무한 스크롤에 적합한 조회 방식 선택, 서비스 레이어 검증 구조화까지의 과정은

기능 구현 이상의 설계 경험으로 남았다.

'Spring > Spring 실습' 카테고리의 다른 글

[실시간 대용량 서비스] 최종 프로젝트 발제  (0) 2026.01.30
[과제 회고] K사 서버 개발하기  (0) 2026.01.14
[프로젝트] 실시간 데이터 처리 회고  (0) 2025.12.08
[프로젝트] 포인트 기반 결제 시스템 프로젝트 회고  (0) 2025.11.28
[Spring 실습] 이커머스 백오피스 프로젝트  (0) 2025.11.03
'Spring/Spring 실습' 카테고리의 다른 글
  • [실시간 대용량 서비스] 최종 프로젝트 발제
  • [과제 회고] K사 서버 개발하기
  • [프로젝트] 실시간 데이터 처리 회고
  • [프로젝트] 포인트 기반 결제 시스템 프로젝트 회고
stark77
stark77
하마의 IT 자기개발 이모저모, 백엔드 개발자로 거듭나기
  • stark77
    하마의 개발자 성장일기
    stark77
  • 전체
    오늘
    어제
    • 분류 전체보기
      • 컴퓨터구조와 운영체제
        • 컴퓨터구조
        • 운영체제
      • SQL 기초
      • Spring
        • 백엔드 기초
        • Spring 실습
      • JAVA
        • Java 실습
      • HTML&CSS
        • HTML&CSS 실습
      • Git&GitHub
        • Git&GitHub 실습
      • 내배캠 끄적끄적
        • Today I Learned
      • 유용한 툴 및 사이트 정리
      • 취미
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Spriingboot
    HTML&CSS
    네트워크 기초
    jsp
    git
    경합조건과 교착상태
    백엔드 기초
    for문
    RestTemplate
    Github
    프로세스와 쓰레드
    Java 문법기초
    WebSocket
    다형성
    SpringSecurity
    백엔드 기초다지기
    String.format
    웹소켓
    java
    객체지향
    Til
    JPA
    algorithm
    thymleaf
    객체지향프로그래밍
    MVC
    Spring
    Stomp
    BEAN
    실시간 데이터 처리
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
stark77
[프로젝트 회고] 실시간 채팅 프로젝트
상단으로

티스토리툴바