[프로젝트] 어드민 서비스 구현하기 2탄 신고 조치 서비스

조찬영·2023년 9월 17일
0

들어가기 앞서

어드민 서비스 구현하기 1탄에서 어드민 계정을 생성하고 권한을 부여받는 기능을 만들어 주었습니다.
이제 생성된 권한을 이용한 신고 기능 및 신고 조치 서비스를 구현하려 합니다.

1. 전체적인 구현도

먼저 신고 조치 서비스의 전체적인 흐름은 다음과 같으며 세세한 예외 적인 부분은 코드 단계에서 살펴보도록 하겠습니다.

1. 신고 기능 (public)

  1. 유저는 다른 유저를 신고(report)할 수 있습니다.
  2. 신고 횟수가 특정 횟수를 넘기면 유저의 activity 값이 변경됩니다.(normal -> flagged)

2. 신고 조치 기능 (authorized)

  1. 어드민 권한을 가진 관리자만 해당 서비스를 이용할 수 있습니다.
  2. 관리자는 userActivity 값을 기준으로 유저를 조회할 수 있습니다.
  3. 특정 유저의 신고 내역(신고자, 신고 시간, 신고 내용등)을 확인할 수 있습니다.
  4. 신고 내역에 근거하여 유저의 activity값을 변경할 수 있습니다.

전체적인 흐름에서 userActivity 값이 자주 나오는데요,
이는 user 활동에 대한 지표이며 이후 코드에서 다시 살펴보도록 하겠습니다.


유저에 대해서 만들었지만 게시물과 같은 컨텐츠에도 충분히 적용할 수 있다고 판단하여 컨텐츠 신고 기능과 조치 서비스도 같이 만들기로 결정했습니다. 전체적인 흐름은 비슷합니다.

2. 신고 기능 만들기

먼저 UserReportEntity 를 만들어 신고 내역에 대한 테이블을 설계하였습니다.

UserReportEntity

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SQLDelete(sql = "UPDATE user_report SET deleted_at = NOW() WHERE id=?")
@Where(clause = "deleted_at is NULL")
@Table(name = "\"user_report\"")
public class UserReportEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long Id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "reporter_id")
    private UserEntity reporter;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "reported_user_id")
    private UserEntity reportedUser;

    @Column(columnDefinition = "text")
    private String content;

    @Column(name = "reported_at")
    private LocalDateTime reportedAt;

    @Column(name = "deleted_at")
    private LocalDateTime deletedAt;

    @PostPersist
    private void setCreatedAt() {
        reportedAt = LocalDateTime.now();
    }

    private UserReportEntity(UserEntity reporter, UserEntity reportedUser, String content) {
        this.reporter = reporter;
        this.reportedUser = reportedUser;
        this.content = content;
    }

    public static UserReportEntity of(UserEntity reporter, UserEntity reportedUser,
        String content) {
        return new UserReportEntity(reporter, reportedUser, content);
    }
}

그리고 게시물에 대한 PostReportEntity 테이블도 설계해 주었습니다.

PostReportEntity

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SQLDelete(sql = "UPDATE post_report SET deleted_at = NOW() WHERE id=?")
@Where(clause = "deleted_at is NULL")
@Table(name = "\"post_report\"")
public class PostReportEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long Id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "reporter_id")
    private UserEntity reporter;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "reported_post_id")
    private PostEntity reportedPost;

    @Column(columnDefinition = "text")
    private String content;

    @Column(name = "reported_at")
    private LocalDateTime reportedAt;

    @Column(name = "deleted_at")
    private LocalDateTime deletedAt;

    @PostPersist
    private void setCreatedAt() {
        reportedAt = LocalDateTime.now();
    }

    private PostReportEntity(UserEntity reporter, PostEntity reportedPost, String content) {
        this.reporter = reporter;
        this.reportedPost = reportedPost;
        this.content = content;
    }

    public static PostReportEntity of(UserEntity reporter, PostEntity reportedPost,
        String content) {
        return new PostReportEntity(reporter, reportedPost, content);
    }
}

이 지점에서 고민이 되었던 부분은 PostReportEntity 부분이였습니다.

컨텐츠 관련 테이블에 대해서는 매번 해당하는 Report 테이블을 만들어 주어야 할까?


유저에 대한 정보는 엄밀하게 다뤄야 하기 때문에 독립적인 report 테이블을 만들어주는게 합당하다고 판단했지만, post 같은 컨텐츠(댓글, 채팅...)들은 이후 추가되면 될수록 하나의 컨텐츠당 하나의 report 테이블을 만들게 될 것 같았기 때문에 고민이 되었습니다.

report에 필요한 내용이 만약 신고자와 신고 당한 컨텐츠 그리고 신고 시각이라면
컨텐츠 타입 이라는 컬럼을 만들고 각 컨텐츠를 구분하기만 하면 하나의 report 테이블에 만들 수 있지 않을까하는 고민이 들었습니다.

하지만 그럼에도 위와 같이 PostReportEntity 처럼 하나의 컨텐츠에 대한 하나의 report 테이블을 만든 이유는 다음과 같았습니다.

  • 하나의 테이블이 비약적으로 거대해진다.
  • 고유 식별 넘버가 같아질 수 있기에 타입이라는 또 하나의 식별이 필요한데 이는 로직이 복잡해지며 탐색에 대한 추가적인 비용이 발생한다.
  • 컨텐츠마다의 속성이 크게 상이할 경우 유지 보수에 대한 유연성이 떨어질 수 있다.
  • 테이블을 효율의 관점에서만 바라보는 것은 좋지 않을 수 있다.

사실 하나의 테이블이 비약적으로 거대해진다. 라는 단점만으로 매 컨텐츠마다 독립적인 report 테이블을 설계하기로 결정했지만 혹시 저와 같은 고민을 하신 분들이 있지않을까 라는 생각에 글을 적어보았고 다른 좋은 방법이 있다면 공유되면 좋겠습니다!


3. Activity 값으로 활동 내역 나타내기

이제 user와 게시물(컨텐츠)에 대한 활동 내역을 나타낼 수 있는 Activity 클래스를 만들어 보겠습니다.

UserActivity

@Getter
@AllArgsConstructor
public enum UserActivity {

    NORMAL("일반 유저"),
    FLAGGED("신고받은 유저"),
    BAN("이용 제한 유저");

    private final String description;
}

ContentActivity

@Getter
@AllArgsConstructor
public enum ContentActivity {
    GENERAL("일반 컨텐츠"),
    FLAGGED("선정성이 있는 컨텐츠"),
    RESTRICTED("제한된 컨텐츠");

    private final String description;
}

다음과 같이 enum으로 상수 선언하여 유저와 게시물에 대한 activity 레벨을 알 수 있도록 만들어 주었습니다. (이후 user, post 테이블에 다음 컬럼을 추가합니다.)

UserEntity

Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "\"user\"")
public class UserEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long Id;

 	...(생략)
    @Enumerated(EnumType.STRING)
    private UserActivity userActivity = UserActivity.NORMAL;

(post 테이블은 생략하겠습니다.)

4. 신고 서비스 구현하기


ReportService

@Service
@RequiredArgsConstructor
public class ReportService {

    private static final int REPORT_LIMIT = 10;
    private final UserReportRepository userReportRepository;
    private final PostReportRepository postReportRepository;
    private final UserRepository userRepository;
    private final PostRepository postRepository;

    @Transactional
    public void reportUser(Long reporterId, Long reportedUserId, String text) {

        /*
           유저 신고 기능
              - 유저는 자기 자신을 신고할 수 없습니다.
              - 신고할 유저가 존재하는지 체크합니다.
              - 신고할 유저가 이미 ban 당했는지 체크합니다.
              - 이미 해당 유저를 신고했다면 신고가 접수되지 않습니다.
              - 신고당한 횟수가 REPORT_LIMIT 초과한다면 일반 유저에서 조치 필요 유저로 activity 값이 변경됩니다.
         */

        validateSelfReport(reporterId, reportedUserId);
        UserEntity reportedUserEntity = userRepository.findById(reportedUserId).orElseThrow(
            () -> new AppException(ErrorCode.USER_NOT_FOUND, "Reported user not found"));
        validateUserBanned(reportedUserEntity);

        UserEntity reporterEntity = getReporterEntityById(reporterId);
        if (userReportRepository.existsByReporterAndReportedUser(reporterEntity,
            reportedUserEntity)) {
            return;
        }
        userReportRepository.save(UserReportEntity.of(reporterEntity, reportedUserEntity, text));

        if (userReportRepository.countByReportedUser(reportedUserEntity) > REPORT_LIMIT) {
            reportedUserEntity.changeActivity(UserActivity.FLAGGED);
        }
    }

    @Transactional
    public void reportPost(Long reporterId, Long reportedPostId, String text) {

         /*
            게시물 신고 기능
               - 신고할 컨텐츠가 존재하는지 체크합니다.
               - 본인이 작성한 게시물을 신고할 수 없습니다.
               - 신고할 컨첸츠가 이미 제한되었는지 체크합니다.
               - 이미 해당 컨텐츠를 신고했다면 신고가 접수되지 않습니다.
               - 신고당한 횟수가 REPORT_LIMIT 초과한다면 일반 컨텐츠에서 선정적인 컨첸츠로 분류됩니다.
          */

        PostEntity reportedPostEntity = postRepository.findById(reportedPostId).orElseThrow(
            () -> new AppException(ErrorCode.POST_DOES_NOT_EXIST, "Reported post not found"));
        validateSelfReport(reporterId, reportedPostEntity.getUser().getId());
        validatedRestrictedContent(reportedPostEntity);

        UserEntity reporterEntity = getReporterEntityById(reporterId);
        if (postReportRepository.existsByReporterAndReportedPost(reporterEntity,
            reportedPostEntity)) {
            return;
        }
        postReportRepository.save(PostReportEntity.of(reporterEntity, reportedPostEntity, text));

        if (postReportRepository.countByReportedPost(reportedPostEntity) > REPORT_LIMIT) {
            reportedPostEntity.changeActivity(ContentActivity.FLAGGED);
        }
    }

    private UserEntity getReporterEntityById(Long reporterId) {
        return userRepository.findById(reporterId)
            .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND));
    }

    private void validateUserBanned(UserEntity reportedUserEntity) {
        if (reportedUserEntity.getUserActivity() == UserActivity.BAN) {
            throw new AppException(ErrorCode.USER_BANNED);
        }
    }

    private void validateSelfReport(Long reporterId, Long reportedUId) {
        if (reporterId == reportedUId) {
            throw new AppException(ErrorCode.CANNOT_REPORT_YOURSELF);
        }
    }

    private void validatedRestrictedContent(PostEntity reportedPostEntity) {
        if (reportedPostEntity.getContentActivity() == ContentActivity.RESTRICTED) {
            throw new AppException(ErrorCode.RESTRICTED_CONTENT);
        }
    }
}

단순한 조회와 기능들의 나열이므로 사실 "어떻게 구현할 것인가?" 보다는
"어떤 예외에 집중할 것인가?" 가 조금 더 고민이 되었던 부분이었고 해당 글에서도 예외에 초점을 두고 작성해 보겠습니다.

신고 기능에서 집중한 예외들

  • 유저는 자기 자신을 신고할 수 없다.

  • 신고 대상이 존재하는지 확인한다.

  • 신고 대상이 이미 ban 당했는지 체크한다.

  • 중복 신고는 신고 접수가 되지 않는다.

  • 신고당한 횟수가 REPORT_LIMIT 초과한다면 activity 값이 변경된다.
    (normal -> flagged)

저는 위와 같은 예외에 집중하며 신고 서비스를 구현하였고 유저와 게시물의 쿼리를 효율적으로 보내기 위해 순서가 조금 다르지만 모두 공통적인 예외 처리 로직을 가지고 있습니다.

중복 신고같은 경우는 예외 레벨까지는 아니라고 판단하여 early - return 하였습니다.

(Controller 부분은 생략하였으나 하단의 깃 허브 링크에서 모든 코드를 보실 수 있습니다.)


5. 신고 조치 서비스 구현하기

신고 접수를 받았으니 이제 이를 처리하는 신고 조치 서비스를 구현해 보겠습니다.

ReportActionService

/**
* 신고 내역과 userActivity 값을 admin 권한으로 관리할 수 있는 서비스 클래스입니다.
*/
@Service
@RequiredArgsConstructor
public class ReportActionService {

   private final UserReportRepository userReportRepository;
   private final UserRepository userRepository;
   private final PostReportRepository postReportRepository;
   private final PostRepository postRepository;

   @Transactional(readOnly = true)
   public Page<User> getUserByActivity(UserActivity userActivity, Pageable pageable) {
       return userRepository.findByUserActivity(userActivity, pageable)
           .map(User::fromEntity);
   }

   @Transactional(readOnly = true)
   public Page<UserReport> getUserReportRecord(Long userId, Pageable pageable) {
       UserEntity userEntity = userRepository.findById(userId)
           .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND));
       return userReportRepository.findByReportedUser(userEntity, pageable)
           .map(UserReport::fromEntity);
   }

   @Transactional(readOnly = true)
   public Page<Post> getPostByActivity(ContentActivity contentActivity, Pageable pageable) {
       return postRepository.findByContentActivity(contentActivity, pageable)
           .map(Post::fromEntity);
   }

   @Transactional(readOnly = true)
   public Page<PostReport> getPostReportRecord(Long postId, Pageable pageable) {
       PostEntity postEntity = postRepository.findById(postId)
           .orElseThrow(() -> new AppException(ErrorCode.POST_DOES_NOT_EXIST));
       return postReportRepository.findByReportedPost(postEntity, pageable)
           .map(PostReport::fromEntity);
   }

   @Transactional
   public User changeUserActivity(UserActivity userActivity, Long userId) {
       UserEntity userEntity = userRepository.findById(userId)
           .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND));
       userEntity.changeActivity(userActivity);
       return User.fromEntity(userEntity);
   }

   @Transactional
   public Post changePostActivity(ContentActivity contentActivity, Long postId) {
       PostEntity postEntity = postRepository.findById(postId)
           .orElseThrow(() -> new AppException(ErrorCode.POST_DOES_NOT_EXIST));
       postEntity.changeActivity(contentActivity);
       return Post.fromEntity(postEntity);
   }
}

해당 기능들을 정리하면 3줄로 요약할 수 있습니다.

  • activity값을 기준으로 조회할 수 있다.
  • 특정 유저 및 게시물 검색시 관련된 신고 내역을 확인할 수 있다.
  • activity값을 변경할 수 있다.

해당 기능들로 기대해 볼 수 있는 서비스는 다음과 같습니다.

  1. 관리자는 신고 조치가 필요한 유저를 검색할 수 있다.(activity: flagged)
  2. 신고 조치가 필요한 유저의 신고 내역을 확인해본다.
  3. 1 문제가 없다고 판단되면 normal 유저로 activity값 수정
    2 문제가 있다고 판단하면 해당 유저를 ban 처리한다.

이외에도 ban 당한 유저를 조회하고 관리하거나 신고 내역을 관리하는등 다른 서비스의 활용을 기대해 볼 수 있고 게시물에 대해서도 똑같은 기능들을 수행할 수 있습니다.


6. 파라미터 값을 enum값으로 바인딩 받기

먼저 AdminApiController 클래스를 확인해 보겠습니다.

AdminApiController

@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
@Slf4j
public class AdminApiController {

    private final AdminCreationService adminCreationService;
    private final ReportActionService reportActionService;

    @GetMapping("/report/users")
    public ResponseSuccess<List<UserActivityResponse>> getUserByActivity(
        @RequestParam UserActivity activity, @ValidatedPageRequest Pageable pageable) {
        log.info("[AdminApiController -> Called : getUserByActivity] get user by activity ");
        Page<UserActivityResponse> userActivityPage = reportActionService.getUserByActivity(
                activity, pageable)
            .map(UserActivityResponse::fromUser);

        return response(userActivityPage.getContent(),
            PageResponseWrapper.fromPage(userActivityPage));
    }

    @GetMapping("/report/users/{userId}")
    public ResponseSuccess<List<UserReportResponse>> getUserReportRecord(
        @PathVariable Long userId, @ValidatedPageRequest Pageable pageable) {
        log.info("[AdminApiController -> Called : getUserReportRecord] "
                + "get report record for user : {}", userId);
        Page<UserReportResponse> userReportPage = reportActionService.getUserReportRecord(userId,
                pageable)
            .map(UserReportResponse::fromUserReport);

        return response(userReportPage.getContent(),
            PageResponseWrapper.fromPage(userReportPage));
    }

컨트롤러 부분에서 이 부분을 살펴보겠습니다.

 @GetMapping("/report/users")
    public ResponseSuccess<List<UserActivityResponse>> getUserByActivity(
        @RequestParam UserActivity activity, @ValidatedPageRequest Pageable pageable)

@RequestParam 을 통해 받은 값을 enum 클래스인 UserActivity 에 바인딩하려고 합니다.
이때 파라미터에 값은 enum 값이 될 수 없으므로 문자열로 받은 뒤에 이를 enum 에 맞춰 직접 포맷팅 해야 하는데요,

매번 비슷한 작업에서 추가적인 포맷팅을 생략하기 위해 Spring Framework 에서 제공하는 Converter인터페이스를 사용하였습니다.


UserActivityConverter

public class UserActivityConverter implements Converter<String, UserActivity> {

    @Override
    public UserActivity convert(String source) {
        if (source == null) {
            return UserActivity.FLAGGED;
        }
        try {
            return UserActivity.valueOf(source.toUpperCase(Locale.ROOT));
        } catch (IllegalArgumentException e) {
            throw new AppException(ErrorCode.NOT_EXIST_ACTIVITY, e.getMessage());
        }
    }
}

  • 만약 입력된 문자열이 null이라면, 기본값인 UserActivity.FLAGGED를 반환합니다.

  • 입력된 문자열이 null이 아니라면, 해당 문자열을 모두 대문자로 변환하고, 그 결과를 바탕으로 해당하는 UserActivity 값을 찾아 반환합니다.

    • 예를 들어 "flagged"라는 문자열이 들어오면 이를 대문자로 바꿔 "FLAGGED"로 만든 후, 이 값에 해당하는 UserActivity.FLAGGED를 반환합니다.
  • 만약 입력된 문자열이 유효하지 않아서 적절한 UserActivity 값을 찾지 못한다면 IllegalArgumentException 발생, 애플리케이션 예외(AppException)가 발생하며 에러 코드로 NOT_EXIST_ACTIVITY가 사용됩니다.


WebMvcConfig

만들어진 Converter 클래스를 Spring MVC의 기본 동작에 추가 하기 위해서WebMvcConfig 에 등록하였습니다.

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    private final RateLimitingInterceptor rateLimitingInterceptor;

    @Override
    public void addFormatters(FormatterRegistry registry) {
        Stream.of(
            new UserActivityConverter(),
            new ContentActivityConverter()
        ).forEach(registry::addConverter);
    }

테스트 하기

실제로 값이 제대로 넘어오는지 확인해보기 위해 postman으로 api를 호출해 보겠습니다.

값이 잘 넘어온 것을 확인할 수 있었습니다.

7. 밴 당한 유저 접근 제한하기

이제 마지막으로 밴 당한 유저에 대해서 조치를 취해야 합니다.
저는 밴 당한 유저에 대해서 public 한 api에는 접근할 수 있지만 그 외에 api 에 대해서는 접근할 수 없도록 만들어 주고 싶었고 filter를 통해 이를 구현했습니다.

Filter class

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {

    private static final String BEARER = "Bearer ";
    private static final String PUBLIC_API_PREFIX = "/public-api/";
    private final PrincipalDetailsService principalDetailsService;
    private final JwtTokenGenerator jwtTokenGenerator;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        if (checkPublicApi(request, response, filterChain)) {
            return;
        }
        try {
            String accessToken = parseBearerToken(request);
            UserDetails userDetails = parseUserSpecification(accessToken);
            checkUserBan(userDetails);
            configureAuthenticatedUser(request, userDetails);
        } catch (Exception e) {
            //JwtAuthenticationEntryPoint 에서 jwt 에 대한 상세 예외 핸들링
            request.setAttribute("exception", e);
        }
        filterChain.doFilter(request, response);
    }

    private void checkUserBan(UserDetails userDetails) {
        if (!userDetails.isEnabled()) {
            throw new AppException(ErrorCode.USER_BANNED);
        }
    }

    private boolean checkPublicApi(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws IOException, ServletException {
        if (request.getRequestURI().startsWith(PUBLIC_API_PREFIX)) {
            filterChain.doFilter(request, response);
            return true;
        }
        return false;
    }
    ...(생략)
}

현재 프로젝트에서 로그인 관련된 부분은 jwt 토큰을 통해 유저를 인증하고 있었기에
JwtTokenFilter 에서 유저 접근을 제한하도록 만들었습니다.

현재는 토큰 관련 인증에 관한 글이 아니기에 다른 부분들은 생략하고 핵심적인 부분만 다루겠습니다.

   private void checkUserBan(UserDetails userDetails) {
       if (!userDetails.isEnabled()) {
           throw new AppException(ErrorCode.USER_BANNED);
       }
   }

   private boolean checkPublicApi(HttpServletRequest request, HttpServletResponse response,
       FilterChain filterChain) throws IOException, ServletException {
       if (request.getRequestURI().startsWith(PUBLIC_API_PREFIX)) {
           filterChain.doFilter(request, response);
           return true;
       }
       return false;
   }
  • checkPublicApi 메서드를 통하여 public한 api는 필터링을 하지 않도록 만들었습니다.

  • checkUserBan 메서드는 UserDetailsisEnabled 을 통하여 userAcitivity 값을 검증할 수 있습니다.

    UserDetails

@Getter
@AllArgsConstructor
public class UserPrincipal implements UserDetails, OAuth2User {

 private UserActivity activity;
 
 ...(생략)

    @Override
    public boolean isEnabled() {
        if (activity == UserActivity.BAN) {
            return false;
        }
        return true;
    }

물론 서비스 레이어에 있는 LoginService 클래스 또는 Interceptor 레벨에서도 이를 구현할 수 있지만,
로그인에서 OAuth2 인증 방식도 사용하고 있었기에 서비스 레이어에서 이를 해결하는데 복잡한 부분들이 있었고 Interceptor 에서 구현하기에는 중복되는 로직들이 꽤 발생하여 우선 JwtTokenFilter에서 구현을 했습니다. (이후 수정 될 여지가 있습니다.)

이후 밴 당한 유저가 public 하지 않은 api를 호출했을 때 filter 에서 이를 캐치하여 의도대로 예외를 발생시킨 것을 확인할 수 있었습니다.

끝 마무리

이렇게 유저와 게시물에 대한 신고 접수기능과 신고 조치 기능을 구현해 보았습니다.
구현에는 어려움이 없었지만 구현하기 위한 설계 단계에서는 적절한 어려움이 있었던 것 같습니다.

또한 인가(authorization)에 대한 부분을 이렇게 중점으로 두고 만들어 보는 것은 처음인지라, 구현전에 걱정되는 부분도 많았지만 그럼에도 큰 문제없이 잘 해낸 것 같아서 스스로에게도 만족하고 칭찬해 주고 싶은 기능이였습니다.

전체의 코드는 [깃 허브 링크]에서 보실수 있습니다.

profile
보안/응용 소프트웨어 개발자

0개의 댓글