프로젝트

[프로젝트] save() 없이도 저장된다? JPA Cascade + Dirty Checking으로 참여자 생성 API를 3단계 리팩토링한 이야기 (feat. 퍼사드 패턴, 스프링부트)

투웨이코더 2026. 2. 8. 15:43

https://github.com/dnd-side-project/dnd-14th-8-backend

 

GitHub - dnd-side-project/dnd-14th-8-backend: [모여락] 모임 일정 및 장소 조율 통합 서비스

[모여락] 모임 일정 및 장소 조율 통합 서비스. Contribute to dnd-side-project/dnd-14th-8-backend development by creating an account on GitHub.

github.com

 

서비스가 커질수록 “로직이 어디에 있는지” 찾는 시간이 늘어납니다.

참여자 생성 API를 리팩토링하면서 아키텍처를 더 ‘깔끔하게’ 만들려는 시도에서 출발했지만

최종적으로는 JPA가 제공하는 영속성 컨텍스트의 힘(Dirty Checking + Cascade + Write-Behind)을 제대로 활용하는 방향으로 구조를 단순화했습니다.

 

이번 글은 그 과정을 3단계 리팩토링 정리한 글입니다. 

 

1. 문제의 시작: “참여자 생성”이 왜 이렇게 복잡해졌을까?

참여자 생성은 단순히 Participant만 저장하면 끝나는 문제가 아니었습니다.

 

  • 참여자가 들어오면 일정 투표(ScheduleVote) 도 같이 생성해야 하고
  • 경우에 따라 위치 투표(LocationVote) 도 같이 생성해야 했습니다.
  • 또한 localStorageKey 기반으로 “같은 브라우저 중복 참여 방지” 검증도 필요했습니다.

이런 요구사항이 쌓이면서 자연스럽게 “참여자 생성 API”는 여러 도메인(Participant/Schedule/Location/Meeting)을 동시에 건드리게 됐고 서비스 코드가 비대해지기 시작했습니다.

 

2. 1차 리팩토링: 단일 서비스의 비대화 → 책임 분리(Facade 패턴)

2.1. 1차 구조의 문제

초기에는 ParticipantService 하나가 아래를 다 했습니다.

  • Participant 생성
  • Meeting 조회
  • ScheduleVote 생성 및 저장
  • LocationVote 생성 및 저장
  • Repository 4개 의존

결과는 명확했습니다.

  • 도메인 경계가 무너지고
  • 변경 영향 범위를 예측하기 어려워지고
  • 테스트는 Repository 4개를 모두 Mock 해야 했습니다.

3. 그래서 Facade 패턴을 도입했습니다!

“도메인 서비스는 자기 도메인만 책임지고 유스케이스 조합은 Facade가 한다.”

  • ParticipantFacade가 트랜잭션 경계 + 유스케이스 조합 담당
  • ParticipantService, ScheduleVoteService, LocationVoteService를 분리
  • 각 서비스는 자기 Repository만 의존

이 단계에서 얻은 효과는 꽤 컸습니다.

 

  • SRP(단일 책임 원칙) 준수
  • 테스트가 “도메인 단위”로 분리됨
  • 변경 영향 범위가 서비스 단위로 좁아짐

 

 

Before. 단일 서비스

 

After. Facade 패턴 적용

 

 

하지만 새로운 문제가 발생했습니다.

 

3.1. 2차 리팩토링의 새로운 문제: “Facade가 진짜 필요한가?”

팀원 피드백이 결정적이었습니다.

“Meeting → Participant → Vote에 이미 CascadeType.ALL 걸려 있는데, 굳이 각 서비스에서 save를 할 필요가 있나요?”

 

Facade 구조를 다시 보면, 서비스들이 하는 일이 대부분 이거였습니다.

  • Repository 조회해서
  • 엔티티 생성만 하고
  • save는 결국 다른 곳에서 하거나, 의미가 애매함

즉, 이미 JPA가 제공하는 기능(Cascade/Dirty Checking)을 충분히 활용하지 못하고 불필요한 레이어를 추가하고 있었던 겁니다.

 

 

4. 3차 리팩토링(현재): Cascade Persist + Dirty Checking으로 “엔티티 그래프 기반” 단순화

이번 리팩토링의 목표는 명확했습니다.

  • ParticipantFacade 제거
  • ScheduleVoteService, LocationVoteService 제거
  • repository.save() 제거
  • 영속 상태의 Meeting 엔티티 그래프에 그냥 추가만 하고 끝내기
@Override
@Transactional
public CreateParticipantResponse createWithSchedule(String meetingId, CreateParticipantWithScheduleRequest request) {
    Meeting meeting = meetingService.get(meetingId);
    validateLocalStorageKeyUnique(meeting, request.localStorageKey());

    SchedulePoll schedulePoll = meeting.getSchedulePoll();
    if (schedulePoll == null) {
        throw new BusinessException(ErrorCode.SCHEDULE_POLL_NOT_FOUND);
    }

    Participant participant = Participant.of(meeting, request.localStorageKey(), request.name());

    List<ScheduleVote> votes = request.availableSchedules().stream()
            .map(schedule -> ScheduleVote.of(participant, schedulePoll, schedule))
            .toList();
    participant.getScheduleVotes().addAll(votes);

    meeting.addParticipant(participant);

    return CreateParticipantResponse.fromSchedule(participant, votes.size());
}

 

핵심 아이디어: JPA는 “그래프”를 저장한다

meetingService.get(meetingId)로 가져온 Meeting은 트랜잭션 안에서 영속(managed) 상태입니다.

그리고 Meeting은 다음처럼 연관관계에 Cascade가 걸려 있습니다.

  • Meeting -> participants : @OneToMany(cascade = ALL)
  • Participant -> scheduleVotes / locationVotes : @OneToMany(cascade = ALL)

그러면 할 일은 단순합니다.

  1. Participant 생성
  2. Vote 생성해서 Participant에 연결
  3. meeting.addParticipant(participant)

@Transactional이 끝나는 순간 JPA가 알아서 합니다.

  • Dirty Checking: 컬렉션 변경 감지 (participants에 추가됨)
  • Cascade Persist: Participant와 Vote까지 INSERT 전파
  • Write-Behind: 커밋 시점에 모아서 flush

영속성 전이(Cascade) 전파 경로

Meeting (영속 상태, MeetingService.get()으로 조회)
  │
  ├── @OneToMany(cascade = ALL)
  │   └── Participant (새로 생성)
  │         │
  │         ├── @OneToMany(cascade = ALL)
  │         │   └── ScheduleVote (새로 생성)
  │         │
  │         └── @OneToMany(cascade = ALL)
  │             └── LocationVote (새로 생성)
  │
  ├── @OneToOne(cascade = ALL)
  │   └── SchedulePoll (기존, meeting.getSchedulePoll()로 접근)
  │
  └── @OneToOne(cascade = ALL)
      └── LocationPoll (기존, meeting.getLocationPoll()로 접근)

4.1. 리팩토링 후 코드 흐름 (핵심)

  • meeting.getSchedulePoll() / meeting.getLocationPoll()로 접근하면서 불필요 Repository 조회도 제거
  • save()를 호출하지 않아도 커밋 시점에 INSERT가 나감

결과적으로 구조는 이렇게 줄었습니다.

Before

  • Controller → Facade → (MeetingService + ParticipantService + VoteService들) → Repository 여러 개

After

  • Controller → ParticipantService → MeetingService + ParticipantRepository

Before: Facade 패턴

ParticipantController
  └── ParticipantFacade
        ├── MeetingService
        ├── ParticipantService
        │     └── ParticipantRepository
        ├── ScheduleVoteService
        │     └── SchedulePollRepository
        └── LocationVoteService
              └── LocationPollRepository
 

After: Cascade

ParticipantController
  └── ParticipantService
        ├── MeetingService
        └── ParticipantRepository
 

 

 

Before: Facade 패턴

 
# 문제점
- Facade가 4개 서비스를 조율하는 추가 추상화 계층
- `ScheduleVoteService`, `LocationVoteService`가 Repository 조회 후 엔티티만 생성 (save 없음)
- Cascade가 이미 설정되어 있어 별도 서비스로 save할 필요 없음
- 과도한 레이어로 코드 추적이 복잡

 

After: Cascade + Dirty Checking

# 개선점
- Facade 레이어 제거 -> Controller가 ParticipantService를 직접 호출
- `ScheduleVoteService`, `LocationVoteService` 제거
- `MeetingService`와 `ParticipantRepository`만 의존
- Meeting 엔티티 그래프에 추가하면 Cascade로 자동 저장

 

5. 마무리: 리팩토링은 ‘패턴 선택’보다 ‘맥락 이해’가 먼저다

이번 경험에서 얻은 결론은 단순합니다.

  • 처음엔 서비스 분리가 정답처럼 보였고(Facade),
  • 다음엔 그 구조가 오히려 “불필요한 복잡도”가 되었습니다.
  • 최종적으로는 JPA의 영속성 컨텍스트를 제대로 활용하는 게 가장 단순하고 명확했습니다.

결국 리팩토링은 “더 많은 구조”를 만드는 게 아니라
필요한 구조만 남기는 과정이었습니다.