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)
그러면 할 일은 단순합니다.
- Participant 생성
- Vote 생성해서 Participant에 연결
- 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의 영속성 컨텍스트를 제대로 활용하는 게 가장 단순하고 명확했습니다.
결국 리팩토링은 “더 많은 구조”를 만드는 게 아니라
필요한 구조만 남기는 과정이었습니다.