[Spring Boot] 조회와 수정의 분리(같이 했을 때 문제점과 리팩토링)

2026. 5. 17. 16:06·Java

공동구매 마감 로직 리팩토링 및 성능 개선기

 

개발을 하다 보면 종종 "돌아가니까 괜찮겠지?" 하고 넘어갔던 코드가 나중에 스노우볼이 되어 돌아오는 경험을 하게 됩니다.

이번 프로젝트에서 '공동구매 목록 조회' 기능을 구현하며 겪었던 "조회 로직 내의 상태 변경"이라는 안티 패턴과,

이를 스케줄러(Scheduler)를 통한 백그라운드 처리로 리팩토링하여 아키텍처를 개선한 과정을 공유하고자 합니다.

 

1. 문제 발생: "존재하지 않는 채팅방입니다"

사용자의 동네를 기반으로 진행 중인 공동구매 목록을 보여주는 getGroupBuyings API를 개발하고 있었습니다. 이때 각 공구의 마감 시간(deadline)을 계산해 D-Day를 반환해야 했는데, 만약 조회 시점에 마감 시간이 지났다면 해당 공구의 상태를 CLOSED(공구 종료)로 변경하도록 로직을 작성했습니다.

그런데 목록을 조회하는 순간, 서버에서 다음과 같은 500 에러가 발생했습니다.

BusinessException: 존재하지 않는 채팅방입니다.

단순한 목록 조회 API를 호출했을 뿐인데, 왜 채팅방 관련 에러가 발생했을까요?

 

2. 원인 분석: 잘못된 상태 전이와 설계의 허점

원인은 공구의 상태를 변경하는 processStatusChange 메서드였습니다.

 

[기존 로직의 흐름]

  1. 사용자가 목록 API 호출
  2. 공구 목록을 순회(map)하며 D-Day 계산
  3. 마감 기한이 지났다면 processStatusChange 호출 -> 상태를 CLOSED로 DB Update
  4. 상태가 변경되었으므로 해당 공구의 채팅방에 '시스템 메시지' 전송 시도 -> 에러 발생!

정상적인 공구라면 [모집 중 -> 공구 시작(START, 이때 채팅방 생성) -> 공구 종료]의 과정을 거칩니다.

하지만 인원 미달로 시간만 지나 마감된 공구는 START 단계를 거치지 않아

생성된 채팅방이 없는 상태인데 시스템 메시지를 보내려다 예외가 터진 것입니다.

 

.

.

.

하지만 진짜 심각한 문제는 따로 있었습니다.

숨어있던 아키텍처 문제: GET API의 사이드 이펙트

에러를 추적하다 보니 제 코드에 치명적인 성능 이슈와 안티 패턴이 존재한다는 것을 깨달았습니다.

  • N번의 Update 쿼리 발생: 만약 목록에 마감된 공구가 10개 있다면, 사용자가 목록을 '조회'할 때마다 10번의 DB Update 쿼리가 발생합니다. 조회 API의 응답 속도가 치명적으로 느려집니다.
  • REST API 원칙 위반: GET 요청은 서버의 상태를 변경하지 않는 '안전한(Safe)' 연산이어야 합니다. 하지만 누군가 조회를 할 때마다 DB 데이터가 변경되는 심각한 사이드 이펙트가 있었습니다.

 

3. 트러블 슈팅 및 리팩토링

이 문제를 두 단계로 나누어 해결했습니다.

에러를 막기 위한 '예외 처리'와, 근본적인 구조를 개선하는 '조회와 수정의 분리'입니다.

Step 1. 예외 처리: 상태 전이 검증 로직 추가

먼저 채팅방이 없는 경우 메시지 전송을 생략하도록, 이전 상태(previousStatus)를 기억하여 예외 처리를 진행했습니다.

@Transactional
public GroupBuying processStatusChange(Long groupBuyingId, GroupBuyingStatus newStatus, ...) {
    GroupBuying groupBuying = groupBuyingRepository.findById(groupBuyingId)...
    
    // 변경 전 상태 기록
    GroupBuyingStatus previousStatus = groupBuying.getStatus(); 
    groupBuying.updateStatus(newStatus);

    // ... (상태별 로직 생략) ...

    // 모집 중 -> 종료로 직행한 경우(채팅방 없음) 시스템 메시지 전송 생략
    if (previousStatus == GroupBuyingStatus.RECRUITING && newStatus == GroupBuyingStatus.CLOSED) {
        log.info("모집 기간 만료 마감. 채팅방이 없어 시스템 메시지를 생략합니다.");
    } else {
        chatRoomService.sendSystemMessage(groupBuying.getId(), newStatus);
    }
    return groupBuying;
}

Step 2. 근본적 해결: 스케줄러 도입과 쿼리 분리 (CQRS 적용)

가장 중요한 개선으로 "목록 조회"와 "마감 상태 변경"의 책임을 완전히 분리했습니다.

 

① 목록 조회 쿼리 수정 (GET은 GET의 기능만)

이제 getGroupBuyings에서는 복잡한 상태 변경 로직을 지우고,

아예 DB에서부터 "마감되지 않은 모집 중인 공구"만 가져오도록 Repository 쿼리를 수정했습니다.

// GroupBuyingRepository (Querydsl 또는 JPQL)
// WHERE status = 'RECRUITING' AND deadline > NOW() 조건 추가
List<GroupBuying> searchGroupBuyings(Category category, String keyword, List<String> addresses, Pageable pageable);

 

② Spring Scheduler로 마감 로직 백그라운드 처리

상태를 변경하는 작업은 사용자의 요청(트래픽)과 무관하게 동작해야 합니다.

@Scheduled를 활용해 서버가 백그라운드에서 매 분마다 마감된 공구를 찾아 일괄 처리하도록 개선했습니다.

@Component
@RequiredArgsConstructor
public class GroupBuyingScheduler {

    private final GroupBuyingRepository groupBuyingRepository;
    private final GroupBuyingService groupBuyingService;

    // 💡 매 1분마다 동작하는 마감 스케줄러
    @Scheduled(cron = "0 * * * * *")
    @Transactional
    public void closeExpiredGroupBuyings() {
        LocalDateTime now = LocalDateTime.now();
        
        // 1. 마감 기한이 지난 모집 중인 공구 조회
        List<GroupBuying> expiredList = groupBuyingRepository
                .findAllByStatusAndDeadlineBefore(GroupBuyingStatus.RECRUITING, now);

        if (expiredList.isEmpty()) return;

        // 2. 일괄 상태 업데이트 처리 (사용자 응답 속도와 무관하게 서버 단독 처리)
        for (GroupBuying gb : expiredList) {
            try {
                groupBuyingService.processStatusChange(gb.getId(), GroupBuyingStatus.CLOSED, null, null);
            } catch (Exception e) {
                log.error("공구 ID {} 마감 처리 중 오류: {}", gb.getId(), e.getMessage());
            }
        }
    }
}

4. 개선된 포인트 및 회고

이번 리팩토링을 통해 다음과 같은 이점을 얻을 수 있었습니다.

  1. 조회 API 성능 (응답 속도) 최적화: 기존에는 조회 시 1개의 Select 쿼리와 N개의 Update 쿼리가 동반 발생할 위험이 있었습니다. 리팩토링 후에는 순수하게 필터링된 데이터만 Select 하므로 응답 속도가 비약적으로 상승했습니다.
  2. 트랜잭션 분리와 안전성 확보: 무거운 DB Update 작업이 스케줄러로 위임되어, 조회 API의 트랜잭션이 가벼워지고 데드락이나 예기치 않은 데이터 변형(사이드 이펙트) 위험이 사라졌습니다.
  3. 단일 책임 원칙(SRP) 준수: 목록 조회 함수는 이제 순수하게 '데이터를 보여주는 역할'만, 스케줄러는 '상태를 관리하는 역할'만 수행하게 되어 유지보수성이 크게 향상되었습니다.

[마무리하며]

기능을 '구현'하는 것에만 급급하다 보면 데이터를 가져올 때 편의상 수정을 같이 해버리는 유혹에 빠지기 쉽습니다.

하지만 이 작은 차이가 서비스의 규모가 커졌을 때 심각한 병목 현상을 유발할 수 있음을 배웠습니다.

앞으로 코드를 짤 때는 항상 "조회와 명령(수정)의 관심사가 잘 분리되어 있는지(CQRS 관점)"를 가장 먼저 질문해야겠습니다.

저작자표시 (새창열림)

'Java' 카테고리의 다른 글

[JDBC] JDBC의 배경 / JDBC 인터페이스와 메서드 / 동작원리  (0) 2026.04.06
[Java] JVM 메모리 구조로 보는 자바의 본질 / 객체지향의 특성별 예시  (0) 2026.04.06
[Java] 배열 / 2차원 배열 / 가변 배열  (0) 2026.03.20
[Java] println(char[ ] 배열명)에서 주소 값이 아닌 텍스트가 나오는 이유  (0) 2026.03.19
[Java] 자바의 원칙 vs 편의성: 철학을 깬 복합 대입 연산자, char 배열 출력과 철학을 지킨 for-each문  (0) 2026.03.19
'Java' 카테고리의 다른 글
  • [JDBC] JDBC의 배경 / JDBC 인터페이스와 메서드 / 동작원리
  • [Java] JVM 메모리 구조로 보는 자바의 본질 / 객체지향의 특성별 예시
  • [Java] 배열 / 2차원 배열 / 가변 배열
  • [Java] println(char[ ] 배열명)에서 주소 값이 아닌 텍스트가 나오는 이유
미우딩
미우딩
다양한 프로그래밍 언어를 공부하고 있는 공대생입니다! Python, Java, C언어를 주로 공부하고 있으며 Figma를 통한 디자인도 종종 올릴 예정입니다.
  • 미우딩
    미우의 코딩 기록
    미우딩
  • 전체
    오늘
    어제
    • 분류 전체보기 (60)
      • Java (47)
      • C언어 (0)
      • 대외활동 (5)
      • Spring (4)
      • 프로젝트 (2)
      • 앱개발 (0)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

    IT #알고리즘 #코딩테스트 #선택정렬 #내림차순정렬 #최댓값
    IT #백준 #알고리즘 #코딩테스트 #배열 #형변환 #아스키코드
    push #pop #StringBuffer
    IT #코딩테스트 #알고리즘 #슬라이딩윈도우 #투포인터 #함수
    IT #백준 #알고리즘 #코딩테스트 #배열 #형변환 #평균구하기
    IT #코딩테스트 #알고리즘 #백준 #투포인터 #배열
    IT #큐 #알고리즘 #코딩테스트 #자바 #선입선출
    IT #코딩테스트 #알고리즘 #버블정렬 #오름차순
    Spring #정적컨텐츠 #MVC #템플릿엔진 #API #애너테이션
    IT #코딩테스트 #알고리즘 #DFS #재귀함수 #무방향그래프
    IT #알고리즘 #코딩테스트 #퀵정렬 #오름차순정렬
    android #java #kotlin #compose
    java #android #개발자
    IT #알고리즘 #그리디알고리즘 #탐욕알고리즘 #코테
    IT #BFS #코딩테스트 #구현 #큐 #재귀함수
    IT #알고리즘 #코딩테스트 #삽입정렬 #오름차순정렬
    IT #알고리즘 #코딩테스트 #DFS
    IT #코딩테스트 #알고리즘 #스택 #
    IT #백준 #코딩테스트 #알고리즘 #구간합 #반복문
    BufferedReader
    android #java #kotlin #상태관리
    IT #알고리즘 #코딩테스트 #우선순위큐 #힙 #절댓값 #오름차순 #정렬
    IT #코딩테스트 #알고리즘 #투포인터 #시간제한 #Scanner #BufferedReader #StringTokenizer
    IT #코딩테스트 #이진탐색 #알고리즘
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
미우딩
[Spring Boot] 조회와 수정의 분리(같이 했을 때 문제점과 리팩토링)
상단으로

티스토리툴바