본문 바로가기

백엔드 개발일지

[JPA] 코멘트 신고 기능 도입 및 벌크 연산과 배치 작업을 통한 기간 정지 구현

현재 사이드 프로젝트로 진행중인 프로젝트 모카콩(https://github.com/mocacong/Mocacong-Backend)은 부적절한 코멘트에 대해 처리를 하는 신고 기능 로직이 없었습니다. 

이후, 커뮤니티 기능도 도입할 예정이기 때문에 아래의 조건에 해당하는 회원에 한해 60일동안 모카콩 서비스에 접근할 수 없도록 기간 정지 기능을 구현했습니다.

5번 이상 신고당한 코멘트 -> 코멘트 내용 마스킹, 코멘트 작성자에게 경고 1회 부여
11번 이상 경고를 받은 유저 -> 60일간 모카콩 서비스 접근 금지 (member의 status가 ACTIVE->INACTIVE)

 

 

 

1. 코멘트 신고 기능 도입

이후 커뮤니티 기능을 확장시킬 예정이기 때문에 신고 기능을 따로 Report 클래스로 두어 구현했습니다.

 

1) Entity

Report.class

@Entity
@Table(name = "report", uniqueConstraints = {
        @UniqueConstraint(columnNames = { "comment_id", "member_id" })
})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Report {

    private static final int MAXIMUM_COMMENT_LENGTH = 200;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "report_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "comment_id")
    private Comment comment;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member reporter;

    @Enumerated(EnumType.STRING)
    @Column(name = "report_reason")
    private ReportReason reportReason;

    @Column(name = "original_content", length = MAXIMUM_COMMENT_LENGTH)
    private String originalContent; // 원본 내용 유지

    public Report(Comment comment, Member reporter, ReportReason reportReason) {
        this.comment = comment;
        this.reporter = reporter;
        this.reportReason = reportReason;
        this.originalContent = comment.getContent();
    }

    public Member getReporter() {
        return reporter;
    }

    public void removeReporter() {
        this.reporter = null;
    }
}

Report 엔티티는 신고 관련 정보를 담는 클래스입니다.

해당 엔티티는 신고가 된 Comment 객체와 신고를 한 회원 객체를 참조합니다. 그리고 신고 사유를 나타내는 ReportReason Enum 필드 또한 데이터베이스에 저장합니다.

5번 이상 신고를 받은 코멘트는 마스킹 처리가 됩니다. 하지만 이후 신고 당한 코멘트 작성자가 문의를 통해 복원을 요청하는 경우, 댓글을 복원할 수 있어야 하므로 원본 댓글 내용을 담는 originalContent 필드를 추가했습니다.

 

 

 

ReportReason.enum

package mocacong.server.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import mocacong.server.exception.badrequest.InvalidReportReasonException;

import java.util.Arrays;
import java.util.Objects;

@NoArgsConstructor
@AllArgsConstructor
@Getter
public enum ReportReason {
	// 신고 사유
    FISHING_HARASSMENT_SPAM("fishing_harassment_spam"),
    LEAKING_FRAUD("leaking_fraud"),
    PORNOGRAPHY("pornography"),
    INAPPROPRIATE_CONTENT("inappropriate_content"),
    INSULT("insult"),
    COMMERCIAL_AD("commercial_ad"),
    POLITICAL_CONTENT("political_content");

    private String value;

    public static ReportReason from(String value) {
        return Arrays.stream(values())
                .filter(it -> Objects.equals(it.value, value))
                .findFirst()
                .orElseThrow(InvalidReportReasonException::new);
    }
}

ReportReason는 신고 사유를 나타내는 Enum입니다. 

차례대로 낚시/놀람/도배, 유출/사칭, 음란물/불건전한 대화, 게시판 성격에 부적절함, 욕설/비하, 상업적 광고, 정치 관련 내용 의 신고 사유를 의미합니다.

 

 

2) Service

ReportService.class

public CommentReportResponse reportComment(Long memberId, Long commentId, String reportReason) {
        Member reporter = memberRepository.findById(memberId)
                .orElseThrow(NotFoundMemberException::new);
        Comment comment = commentRepository.findById(commentId)
                .orElseThrow(NotFoundCommentException::new);

        try {
            createCommentReport(comment, reporter, reportReason);

            // 코멘트를 작성한 회원이 탈퇴한 경우
            if (comment.isDeletedCommenter() && comment.isReportThresholdExceeded()) {
                maskReportedComment(comment);
            } else {
                Member commenter = comment.getMember();
                if (comment.isWrittenByMember(reporter)) {
                    throw new InvalidCommentReportException();
                }
                if (comment.isReportThresholdExceeded()) {
                    commenter.incrementMemberReportCount();
                    maskReportedComment(comment);
                }
            }
        } catch (DataIntegrityViolationException e) {
            throw new DuplicateReportCommentException();
        }
        return new CommentReportResponse(comment.getReportsCount(), reporter.getReportCount());
    }

    private void createCommentReport(Comment comment, Member reporter, String reportReason) {
        if (comment.hasAlreadyReported(reporter)) {
            throw new DuplicateReportCommentException();
        }
        ReportReason reason = ReportReason.from(reportReason);
        comment.getReports().add(new Report(comment, reporter, reason));
    }

해당 서비스는 신고 로직을 담은 클래스이며 다음과 같은 로직으로 수행됩니다.

  • reportComment(): 회원이 코멘트를 신고하는 기능을 수행
    • 신고한 코멘트의 정보와 신고한 회원의 정보를 받아와 신고 기능 처리
    • 1) 코멘트를 작성한 회원이 탈퇴한 경우 && 코멘트가 5번 이상 신고를 받은 경우
      • 해당 코멘트 마스킹 처리
    • 2) 1)에 해당하지 않고 코멘트를 신고한 회원이 해당 코멘트를 작성한 회원인 경우
      • InvalidCommentReportException 발생
    • 3) 1)에 해당하지 않지만 코멘트가 5번 이상 신고를 받은 경우
      • 코멘트를 작성한 회원에게 경고 1회 부여
      • 해당 코멘트 마스킹 처리

 

  • createCommentReport(): 코멘트 신고 기능 수행
    • 이미 회원이 해당 코멘트를 신고한 경우
      • DuplicateReportCommentException 발생
    • 회원이 신고를 할 때 선택한 ReportReason을 해당 코멘트의 Reports 컬렉션에 신고 정보로 추가

 

 

 

 

 

 

2. 서비스 기간 정지 구현

현재 로직 상, 11번의 경고를 받은 회원은 60일 동안 서비스 접근 정지가 되도록 기획되어 있습니다. 하지만 매번 정지당한 회원이 정지당한지 60일이 지났는지 확인하는 일은 매우 번거롭습니다.

그렇기 때문에 모카콩 서비스에서는 매 자정마다 배치 작업을 통해 조건에 해당하는 회원의 상태(status)를 INACTIVE에서 ACTIVE로 바꾸어 서비스에 접근할 수 있도록 변경하도록 구현했습니다.

 


1) 배치 작업

// BatchConfig.class
@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") // 매일 자정에 실행
public void activateInactivateMembers() {
    memberService.setActiveAfter60days();
}

// MemberService.class
@Transactional
public void setActiveAfter60days() {
    LocalDate thresholdLocalDate = LocalDate.now().minusDays(60); // 기간 설정
    Instant instant = thresholdLocalDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
    Date thresholdDate = Date.from(instant);
    memberRepository.bulkUpdateStatus(Status.ACTIVE, Status.INACTIVE, thresholdDate);
}

배치 작업 관련 로직을 처리하는 BatchConfig에서 매 자정마다 `setActiveAfter60days()`를 호출하여 기간 정지 해제 대상 회원들에 한해 상태를 ACTIVE로 변경합니다. 이때 배치 작업 로직은 서비스에서 책임을 맡도록 분리하였으며 상태 변경은 벌크 연산을 통해 처리하였습니다.

 

 

 

2) 벌크 연산

벌크 연산은 데이터베이스나 시스템에서 여러 개의 데이터 레코드를 한 번에 처리하는 작업을 말합니다. 보통 개별적으로 데이터를 처리하는 것보다 대량의 데이터를 한 번에 처리하는 것이 성능과 효율성 측면에서 유리한 경우가 있기 때문에 모카콩에서도 회원들의 상태를 변경하는 `setActiveAfter60days()` 에서 벌크 연산을 수행했습니다.

 

벌크 연산의 장점은 다음과 같습니다.

  • 성능 향상: 개별적으로 INSERT/UPDATE/DELETE 쿼리를 수행하는 것이 아닌 벌크 연산을 통해 단일 작업으로 처리. 데이터 베이스에 접근하는 횟수가 줄어들어 성능이 향상
  • 트랜잭션 비용 절감: 벌크 연산을 통해 여러 개의 개별 트랜잭션 비용을 절감할 수 있으며 롤백할 가능성도 줄어듦
  • 네트워크 부하 감소: 네트워크 오버헤드를 감소시킴
  • 더 적은 라운드 트립: 개별 업데이트를 수행하면 각각의 요청과 응답으로 인해 라운드트립이 많아지지만 벌크 연산은 이러한 라운드트립을 한 번으로 줄여 성능을 개선
  • 데이터 일관성 유지: 벌크 연산은 한 번에 모든 업데이트를 처리하여 데이터 일관성을 유지

 

위와 같이 기본적으로 벌크 연산을 사용하면 데이터베이스에 대한 접근 횟수를 줄이고, 트랜잭션 비용 절감도 할 수 있다는 장점이 있습니다. 물론 벌크 연산을 사용할 때 모든 작업이 원자적으로 실행되도록 보장하여 데이터 일관성을 유지해야 한다는 주의점도 있습니다.

 

 

@Modifying
@Query("UPDATE Member m SET m.status = :newStatus WHERE m.status = :oldStatus AND " +
        "m.modifiedTime <= :thresholdDateTime")
void bulkUpdateStatus(@Param("newStatus") Status newStatus, @Param("oldStatus") Status oldStatus,
                      @Param("thresholdDateTime") Date thresholdDateTime);

해당 JPQL은 지정된 조건에 맞는 Member 엔티티의 status 필드가 지정된 newStatus로 변경되며, 이러한 업데이트는 한 번에 여러 개의 레코드를 처리하는 벌크 업데이트로 수행됩니다.