삭제 기능을 구현할 때 처음에는 데이터를 지우는 방법만 정하면 된다고 생각했습니다. 하지만 게시글을 지우는 경우와 사용자가 탈퇴하는 경우를 비교하자, 무엇을 삭제하고 무엇을 남길지부터 서로 다른 기준이 필요했습니다.
게시글 삭제는 작성자가 콘텐츠 자체를 없애는 요청입니다. 반면 회원 탈퇴는 계정 사용을 중단하는 요청이지만, 그 사용자가 남긴 게시글과 댓글까지 모두 사라지면 커뮤니티의 대화 맥락도 함께 끊깁니다.
게시글을 지울 때 함께 사라져야 하는 데이터
게시글에는 댓글, 좋아요, 조회 기록이 연결되어 있습니다. 게시글만 삭제하고 이 데이터를 남기면 더는 존재하지 않는 게시글을 가리키는 데이터가 됩니다.
JPA의 cascade = REMOVE를 사용하면 엔티티 관계를 따라 자식 데이터를 삭제할 수 있습니다. 구현은
간단하지만 삭제 과정에서 자식 엔티티를 불러오거나 개별 DELETE가 반복될 수 있습니다. 삭제할 대상이
이미 postId로 명확했기 때문에, 연관 엔티티를 애플리케이션에 올리지 않고 테이블별 Bulk Delete를
실행하기로 했습니다.
@Modifying
@Query("""
DELETE FROM Comment c
WHERE c.post.id = :postId
""")
int deleteByPostId(@Param("postId") Long postId);
댓글, 좋아요, 조회 기록을 각각 postId 조건으로 삭제한 뒤 게시글을 삭제합니다. 이 전체 흐름은 하나의
트랜잭션 안에서 실행해, 연관 데이터 일부만 지워진 채 게시글 삭제가 실패하는 상황을 막았습니다.
Bulk Delete는 영속성 컨텍스트를 거치지 않고 DB에 바로 반영됩니다. 따라서 같은 트랜잭션에서 이미 불러온 연관 엔티티를 이후에도 사용할 흐름이 있는지 확인해야 합니다. 현재 삭제 흐름에서는 데이터를 다시 사용하지 않으므로, 엔티티를 로딩해 하나씩 삭제하는 것보다 직접 삭제하는 편이 목적에 맞았습니다.
회원 탈퇴에서는 콘텐츠를 남기기로 했다
회원 탈퇴는 게시글 삭제와 다르게 Soft Delete로 처리했습니다. 사용자가 탈퇴했다고 그 사람이 작성한 게시글과 댓글까지 모두 지우면, 다른 사용자의 댓글이나 답변도 맥락을 잃을 수 있기 때문입니다.
그래서 현재 정책은 다음과 같이 정했습니다.
- 회원 계정은 탈퇴 상태와
deletedAt을 기록합니다. - 탈퇴 사용자의 게시글과 댓글은 그대로 유지합니다.
- API 응답에서는 닉네임과 프로필 대신
알 수 없음으로 표시합니다. - 게시글과 댓글이 같은 기준을 사용하도록 작성자 응답 변환 로직을 공통화합니다.
public PostAuthorResponse toAuthorResponse(User user) {
if (user.isDeleted()) {
return PostAuthorResponse.unknown();
}
return new PostAuthorResponse(
user.getNickname(),
user.getProfileImageUrl()
);
}
이렇게 하면 콘텐츠와 대화의 구조는 유지하면서 탈퇴한 사용자의 식별 정보는 화면에 노출하지 않을 수 있습니다.
Soft Delete 이후 개인정보는 어떻게 할까
Soft Delete만으로 개인정보 처리까지 끝나는 것은 아닙니다. 일정 기간이 지난 뒤 사용자 정보를 어떻게 익명화하거나 파기할지는 별도의 정책이 필요합니다.
User 행을 남기고 개인정보만 익명화하기
게시글과 댓글의 외래 키는 유지하면서 이메일, 닉네임, 프로필처럼 개인을 식별할 수 있는 값을 대체합니다. 기존 관계를 바꿀 필요는 적지만, 어떤 값을 언제 익명화할지 정하고 배치 작업을 운영해야 합니다.
작성자를 변경한 뒤 User 행 삭제하기
게시글과 댓글의 user_id를 NULL로 만들거나 탈퇴 사용자 전용 계정으로 옮긴 뒤 기존 User를 삭제합니다.
실제 행을 제거할 수 있지만, 연관관계의 nullable 여부와 엔티티 설계를 함께 수정해야 합니다.
현재는 보관 기간과 개인정보 파기 기준이 정해지지 않았기 때문에 첫 단계인 Soft Delete와 응답 마스킹만 구현했습니다. 완료되지 않은 정책을 임의로 확정하기보다, 개인정보 처리방침과 서비스 운영 기준이 정해진 뒤 익명화 방식과 실행 주기를 결정할 문제로 남겼습니다.
권한 확인도 조회 조건에 포함하기
데이터 삭제 흐름을 정리하면서 댓글 수정과 삭제의 조회 방식도 함께 살펴봤습니다. 기존에는 댓글을 ID로 조회한 뒤 애플리케이션 코드에서 요청자가 작성자인지 비교했습니다.
수정 가능한 댓글을 찾는 목적이라면 댓글 ID와 사용자 ID를 처음부터 조회 조건에 넣을 수 있습니다.
@Query("""
SELECT c
FROM Comment c
WHERE c.id = :commentId
AND c.user.id = :userId
""")
Optional<Comment> findByIdAndUserId(
@Param("commentId") Long commentId,
@Param("userId") Long userId
);
댓글 삭제 시에는 게시글의 commentCount도 줄여야 하므로 Post까지 필요합니다. 이 경우에는 권한 조건과
함께 Post를 Fetch Join해 성공 경로에서 추가 조회가 발생하지 않게 했습니다.
@Query("""
SELECT c
FROM Comment c
JOIN FETCH c.post
WHERE c.id = :commentId
AND c.user.id = :userId
""")
Optional<Comment> findByIdAndUserIdWithPost(
@Param("commentId") Long commentId,
@Param("userId") Long userId
);
단건 API라서 목록 조회의 N+1 문제처럼 쿼리 수가 데이터 개수에 따라 늘어나는 상황은 아닙니다. 그래도 성공 경로에서 필요한 권한과 데이터를 한 번에 확인해 DB 왕복을 줄이고, Repository 메서드만 보아도 조회 목적이 드러나게 할 수 있었습니다.
댓글이 없어서 실패한 경우와 댓글은 있지만 작성자가 아닌 경우를 서로 다른 오류로 제공해야 한다면, 실패 경로에서는 존재 여부를 추가로 확인해야 합니다. 성공 경로 최적화와 오류 구분을 위한 조회 사이의 트레이드오프도 함께 고려해야 했습니다.
카운터 감소도 데이터 정합성의 일부였다
댓글과 좋아요를 삭제하면 게시글의 commentCount, likeCount도 함께 줄어듭니다. 이 값 역시 엔티티를
읽어 애플리케이션에서 계산하지 않고 DB 원자적 UPDATE로 변경했습니다.
UPDATE posts
SET comment_count = comment_count - 1
WHERE id = :postId
AND comment_count > 0;
동시 요청에서 증가분과 감소분이 유실되지 않도록 하고, 감소 조건에 count > 0을 포함해 카운터가
음수가 되는 상황도 방어했습니다. 원자적 갱신을 선택한 기준은
조회수 Lost Update 글과 같아 이 글에서는 반복하지 않았습니다.
삭제는 데이터의 수명과 관계를 정하는 일이었다
게시글 삭제에서는 콘텐츠와 수명을 함께하는 연관 데이터를 빠짐없이 제거해야 했습니다. 회원 탈퇴에서는 개인정보를 노출하지 않으면서도 다른 사용자가 참여한 콘텐츠의 맥락을 유지해야 했습니다.
같은 DELETE 기능처럼 보여도 대상 데이터의 의미와 관계가 다르면 처리 방식도 달라져야 합니다. 이번
작업을 통해 삭제 구현은 단순히 Repository의 delete()를 호출하는 일이 아니라, 어떤 데이터가 함께
사라지고 어떤 데이터가 남아야 하는지를 먼저 정의하는 일이라는 점을 확인했습니다.