프로젝트를 진행하면서 일정 삭제 기능을 구현할 때 단순히 일정만 삭제하면 끝날 줄 알았다.
그런데 댓글 기능을 추가하고 나니, 일정에 댓글이 달린 상태에서는 삭제가 실패할 수 있다는 점을 알게 되었다.
이번 글에서는
왜 이런 문제가 생기는지,
왜 댓글을 먼저 삭제해야 하는지,
서비스 계층에서 어떤 방식으로 해결했는지를 정리해보려고 한다.
처음 일정 삭제 로직은 아래처럼 단순했다.
@Transactional
public void delete(Long scheduleId, Long loginUserId) {
Schedule schedule = findSchedule(scheduleId);
validateScheduleOwner(schedule, loginUserId);
scheduleRepository.delete(schedule);
}
처음에는 이 코드가 자연스럽다고 생각했다.
흐름도 단순했다.
일정이 존재하는지 확인
로그인한 유저가 작성자인지 확인
일정 삭제
하지만 댓글 기능이 추가된 후에는 상황이 달라졌다.
일정은 부모 데이터
댓글은 그 일정에 달린 자식 데이터
즉, 댓글이 남아 있는 상태에서 부모인 일정을 지우려고 하면
DB 입장에서는 참조 무결성(Foreign Key 제약) 문제가 생길 수 있다.
왜 이런 문제가 생길까?
쉽게 비유하면 이렇다.
일정은 원본 글
댓글은 원본 글에 붙은 메모
메모가 아직 붙어 있는데 원본 종이부터 찢어버리면,
메모는 “어디에 붙어 있었는지”를 잃어버리게 된다.
DB에서도 비슷하다.
댓글 테이블은 보통 schedule_id를 외래키로 가지고 있다.
즉 댓글은 “나는 어떤 일정에 달린 댓글이다”라는 정보를 가지고 있는데,
부모 일정이 먼저 지워지면 이 참조가 깨진다.
그래서 DB는 보통 이런 삭제를 막는다.
이 부분을 잘 몰랐다.
현재 댓글 엔티티는 일정과 연관관계를 가진다.
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "schedule_id")
private Schedule schedule;
즉 댓글은 반드시 일정에 속한다.
그래서 삭제 순서는 항상 이렇게 되어야 한다.
이미 CommentRepository에는 아래 메서드가 있었다.
void deleteAllByScheduleId(Long scheduleId);
이 메서드를 일정 삭제 로직에 연결해서 댓글을 먼저 지운 뒤 일정을 삭제하도록 수정했다.
수정 후 코드
@Transactional
public void delete(Long scheduleId, Long loginUserId) {
Schedule schedule = findSchedule(scheduleId);
validateScheduleOwner(schedule, loginUserId);
//일정에 달린 댓글 먼저 삭제
commentRepository.deleteAllByScheduleId(scheduleId);
// 이후 일정 삭제
scheduleRepository.delete(schedule);
그리고 ScheduleService에 아래 의존성도 추가했다.
private final CommentRepository commentRepository;
왜 이 방식이 좋은가?
이 방식의 장점은 명확하다.
삭제 순서가 분명하다
댓글 → 일정 순서로 가기 때문에 데이터 무결성을 지킬 수 있다.
서비스 계층에서 비즈니스 흐름이 잘 보인다
단순히 DB가 알아서 해주길 기대하는 게 아니라,
“일정을 지우려면 그에 달린 댓글도 같이 정리해야 한다”는 업무 규칙이 코드에 드러난다.
과제/학습 단계에서 이해하기 쉽다
cascade 같은 JPA 기능을 쓰는 방법도 있지만,
지금 단계에서는 “무엇을 왜 먼저 지우는지”를 눈으로 확인하는 방식이 더 이해하기 쉽다.
이번 트러블 슈팅을 통해
기능 하나를 구현할 때도 “연관된 다른 기능과 데이터”를 함께 봐야 한다는 점을 느꼈다.
앞으로는 삭제 기능을 만들 때,
단순히 repository.delete()만 생각하지 않고
이 데이터와 연결된 다른 데이터는 없는지 먼저 떠올리는 습관을 가져가고 싶다.
프로젝트를 진행하면서 ScheduleController, UserController, CommentController에 거의 같은 로그인 유저 조회 코드가 반복되고 있었다.
private Long getLoginUserId(HttpServletRequest httpServletRequest) {
HttpSession session = httpServletRequest.getSession(false);
if (session == null || session.getAttribute(SessionKey.LoginUserId) == null) {
throw ApiException.unauthorized("로그인이 필요한 요청입니다.");
}
return (Long) session.getAttribute(SessionKey.LoginUserId);
}
처음에는 “중복이니까 바로 공통화해야 하나?”라고 생각했다.
그런데 동시에 이런 고민도 들었다.
왜 굳이 HttpServletRequest로 세션을 꺼내고 있지?
그냥 HttpSession을 직접 받으면 안 되나?
지금 단계에서 바로 Interceptor로 가야 하나?
이 코드는 결국 같은 일을 반복하고 있었다.
세션 가져오기
로그인 여부 확인
세션에 저장된 loginUserId 꺼내기
즉 중복은 분명히 존재했다.
먼저 getSession(false)를 쓴 이유부터 정리해보면,
이 메서드는 기존 세션이 있으면 가져오고, 없으면 새로 만들지 않는다.
즉 로그인되지 않은 요청이 들어왔을 때 불필요한 세션을 만들지 않기 위한 방식이다.
반면 컨트롤러에서 HttpSession을 직접 주입받으면 코드는 더 간단해진다.
실제로 리팩토링 이후 컨트롤러는 아래처럼 바뀌었다.
@PostMapping
public ResponseEntity<ScheduleResponseDto> createSchedule(
@Valid @RequestBody ScheduleCreateRequestDto request,
HttpSession session) {
Long loginUserId = SessionUtil.getLoginUserId(session);
return ResponseEntity.status(HttpStatus.CREATED)
.body(scheduleService.create(loginUserId, request));
}
그리고 중복되던 로그인 체크 로직은 SessionUtil로 분리했다.
public final class SessionUtil {
private SessionUtil() {}
public static Long getLoginUserId(HttpSession session) {
if (session == null || session.getAttribute(SessionKey.LoginUserId) == null) {
throw ApiException.unauthorized("로그인이 필요한 요청입니다.");
}
return (Long) session.getAttribute(SessionKey.LoginUserId);
}
}
이 리팩토링의 장점은 분명했다.
컨트롤러 코드가 짧아진다
로그인 확인 로직을 한 곳에서 관리할 수 있다
중복이 줄어들어 가독성이 좋아진다
다만 여기서 중요한 점이 하나 있다.
HttpSession 직접 주입은 request.getSession(false)와 완전히 같은 의미는 아니다.
getSession(false)는 세션이 없을 때 새로 만들지 않는 방식이고,
HttpSession 직접 주입은 Spring MVC가 세션을 사용할 수 있도록 더 간단한 형태로 제공하는 방식이다.
즉 이번 리팩토링은 “중복 제거”와 “코드 간소화”에는 분명히 도움이 되었지만,
세션 처리 방식까지 완전히 동일하게 유지한 변경이라고 보기는 어렵다.
그래도 지금 단계에서는 충분히 의미 있는 리팩토링이었다고 생각한다.
왜냐하면 아직은 세션 인증 흐름 자체를 직접 이해하는 것이 중요하고,
바로 Interceptor까지 가기보다는 먼저 공통 로직을 분리하는 경험이 더 적절하다고 느꼈기 때문이다.
이번 작업을 통해 느낀 점은, 중복 제거가 항상 절대적인 정답은 아니라는 것이다.
학습 단계에서는 코드가 조금 반복되더라도 흐름이 잘 보이는 구조가 더 좋을 수 있다.
하지만 반복이 계속 보이고, 수정 포인트가 한곳으로 모일 수 있다면 그때는 공통화를 고려할 만하다.