SpringBoot2로 Rest api 만들기(16) – AOP와 Custom Annotation을 이용한 금칙어(Forbidden Word) 처리

0
2997
이 연재글은 SpringBoot2로 Rest api 만들기의 16번째 글입니다.

이번 장에서는 aop(aspect oriented programming)와 custom annotation을 이용하여 입력된 내용에 금칙어가 포함되어 있을 경우 예외 처리하는 방법에 대해 실습해보겠습니다.

aop란?

AOP란 관점 지향 프로그래밍이라고 하며 애플리케이션에서의 관심사의 분리(기능의 분리), 핵심적인 기능에서의 부가적인 기능을 분리하는 개념입니다. AOP는 부가기능을 Aspect로 정의하여, 핵심기능에서 부가기능을 분리함으로써 핵심기능을 설계하고 구현할 때 객체지향적인 가치를 지킬 수 있도록 도와주는 개념입니다.

AOP를 쉽게 이해하기 위해 예를 들면 애플리케이션의 기능 중 로그, 권한 체크, 트랜잭션과 같이 핵심 비즈니스 로직과는 별개로 동작하는 프로세스가 있습니다. 이러한 프로세스들은 비즈니스 로직에는 속하지 않지만 별도의 추가적인 동작을 하기 위해 비즈니스 로직 안에 포함됩니다. 비즈니스 로직 안에서 해당 로직은 객체로서 재 사용될 순 있겠지만 코드가 서로 섞여 버리게 되고 여러 프로세스에서 사용될 경우 코드 중복이 발생하여 유지보수를 어렵게 합니다.

AOP는 이러한 부분을 해결하는데 도움이 되며 이러한 별도 기능을 메인 프로세스에 포함시키지 않고서도 동일한 기능을 주입할 수 있도록 기능을 제공합니다.

이번장에서는 글 쓰기/수정 시 금칙어가 입력될 경우 저장되지 않도록하는 기능을 AOP와 Annotation으로 구현해 보겠습니다.

일단 기존 프로세스에 AOP를 사용하지 않고 금칙어 체크 기능을 추가해 보겠습니다.

CForbiddenWordException 생성

 public class CForbiddenWordException extends RuntimeException {

    public CForbiddenWordException(String msg, Throwable t) {
        super(msg, t);
    }

    public CForbiddenWordException(String msg) {
        super(msg);
    }

    public CForbiddenWordException() {
        super();
    }
}

ExceptionAdvice에 CForbiddenWordException 등록

@RequiredArgsConstructor
@RestControllerAdvice
public class ExceptionAdvice {

    // 생략...

    @ExceptionHandler(CForbiddenWordException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public CommonResult forbiddenWordException(HttpServletRequest request, CForbiddenWordException e) {
        return responseService.getFailResult(Integer.valueOf(getMessage("forbiddenWord.code")), getMessage("forbiddenWord.msg", new Object[]{e.getMessage()}));
    }

    // 생략...
}

국제화 message 추가

// exception_en.yml
forbiddenWord:
  code: "-1008"
  msg: "forbidden words ({0}) are included in the input."
// exception_ko.yml
forbiddenWord:
  code: "-1008"
  msg: "입력한 내용에 금칙어({0})가 포함되어 있습니다.

BoardService에 금칙어 로직 반영

checkForbiddenWord 메서드를 새로 추가하였고 해당 메서드를 writePost와 updatePost에서 사용하도록 처리하였습니다. checkForbiddenWord 메서드는 입력받은 문장에 금칙어가 포함되어 있으면 CForbiddenWordException을 발생시킵니다. 메서드 내에서 금칙어 목록은 하드 코딩하고 있는데 실서비스에서는 DB Table을 사용하여 추가, 삭제가 용이하도록 구현하면 됩니다.

코드를 보면 메인 프로세스에 부가 처리를 위한 프로세스가 끼어들게 되는 것을 확인할 수 있습니다. 더구나 여러 메서드에서 사용하는 바람에 벌써 중복 코드가 발생하였습니다.

public class BoardService {

    // 코드 생략 ...

    // 게시글을 등록합니다. 게시글의 회원UID가 조회되지 않으면 CUserNotFoundException 처리합니다.
    @CacheEvict(value = CacheKey.POSTS, key = "#boardName")
    public Post writePost(String uid, String boardName, ParamsPost paramsPost) {
        Board board = findBoard(boardName);
        // 금칙어 체크
        checkForbiddenWord(paramsPost.getContent());
        Post post = new Post(userJpaRepo.findByUid(uid).orElseThrow(CUserNotFoundException::new), board, paramsPost.getAuthor(), paramsPost.getTitle(), paramsPost.getContent());
        return postJpaRepo.save(post);
    }

    // 게시글을 수정합니다. 게시글 등록자와 로그인 회원정보가 틀리면 CNotOwnerException 처리합니다.
    //@CachePut(value = CacheKey.POST, key = "#postId") 갱신된 정보만 캐시할경우에만 사용!
    public Post updatePost(long postId, String uid, ParamsPost paramsPost) {
        Post post = getPost(postId);
        User user = post.getUser();
        if (!uid.equals(user.getUid()))
            throw new CNotOwnerException();

        // 금칙어 체크
        checkForbiddenWord(paramsPost.getContent());

        // 영속성 컨텍스트의 변경감지(dirty checking) 기능에 의해 조회한 Post내용을 변경만 해도 Update쿼리가 실행됩니다.
        post.setUpdate(paramsPost.getAuthor(), paramsPost.getTitle(), paramsPost.getContent());
        cacheSevice.deleteBoardCache(post.getPostId(), post.getBoard().getName());
        return post;
    }

    public void checkForbiddenWord(String word) {
        List<String> forbiddenWords = Arrays.asList("개새끼", "쌍년", "씨발");
        Optional<String> forbiddenWord = forbiddenWords.stream().filter(word::contains).findFirst();
        if(forbiddenWord.isPresent())
            throw new CForbiddenWordException(forbiddenWord.get());
    }
}

금칙어 기능 추가가 완료되었습니다. 서버를 구동하고 swagger에서 금칙어를 등록하면 다음과 같은 오류가 발생하는 것을 볼 수 있습니다.

AOP 방식으로 변경

이제 본론으로 돌아와서 AOP를 이용한 금칙어 처리를 구현해 보겠습니다. 현재 개발된 내용은 BoardService내의 writePost, updatePost에서 금칙어를 처리하는 메서드를 호출하도록 구현되어있습니다. 즉 메인 비즈니스 로직에 금칙어 처리 프로세스가 섞여 있습니다. 이제 이 프로세스를 메인 프로세스 밖으로 빼내어 동일하게 동작하도록 처리해봅시다.

실습에서는 금칙어 처리를 위한 Custom Annotation을 만들고 BoardService의 메서드에 적용함으로써 기능이 작동되도록 구현하겠습니다. 

아래와 같이 annotation package를 생성하고 ForbiddenWordCheckAspect, ForbiddenWordCheck를 생성합니다. ForbiddenWordCheck는 Annotation이고 ForbiddenWordCheckAspect는 Annotation이 적용된 메서드에 적용될 프로세스가 담긴 class입니다.

ForbiddenWordCheck 작성

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ForbiddenWordCheck {
    String param() default "paramsPost.content";
    Class<?> checkClazz() default ParamsPost.class;
}

@Target({ElementType.METHOD})
Annotation이 메서드 레벨에서 사용될 수 있다는 의미입니다.
@Retention(RetentionPolicy.RUNTIME)
Annotation의 LifeTime입니다. 즉 이 Annotation은 애플리케이션이 실행 중일때 정보를 얻을 수 있다는 의미입니다.
@interface
interface앞에 @를 붙여 annotation 선언임을 알립니다.
String param() default “paramsPost.content”;
메서드에서 금칙어 체크할 파라미터의 정보입니다. paramPost라는 이름의 파라미터 객체로부터 content field의 값을 읽어 금칙어 체크를 하겠다는 의미입니다. 만약 금칙어 체크할 파라미터가 객체가 아닌 String일 경우 파라미터 명만 넣으면 됩니다.(@ForbiddenWordCheck(param=”파라미터명”)
Class checkClazz() default ParamsPost.class;
금칙어 체크할 파라미터가 객체인 경우 해당 객체의 Class정보를 세팅합니다. 금칙어 체크할 파라미터가 String인 경우 사용되지 않습니다.

ForbiddenWordCheckAspect 작성

이번 클래스는 설명할 내용이 많아 코드내에 주석을 달았습니다. 자세한 내용은 코드내 주석을 확인해 주십시오.

package com.rest.api.annotation.aspect;

import com.rest.api.advice.exception.CForbiddenWordException;
import com.rest.api.annotation.ForbiddenWordCheck;
import io.micrometer.core.instrument.util.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;

@Aspect
@Component
public class ForbiddenWordCheckAspect {

    // 어노테이션이 설정된 메서드가 실행되기 직전(Before)에 금칙어 체크를 적용.
    @Before(value = "@annotation(forbiddenWordCheck)")
    public void forbiddenWordCheck(JoinPoint pjp, ForbiddenWordCheck forbiddenWordCheck) throws Throwable {
        // 금칙어를 체크할 메서드의 파라미터가 객체인지(객체.필드명) 일반 String인지에 따라 구분하여 처리.
        String[] param = forbiddenWordCheck.param().split("\\.");
        String paramName;
        String fieldName = "";
        if (param.length == 2) {
            paramName = param[0];
            fieldName = param[1];
        } else {
            paramName = forbiddenWordCheck.param();
        }
        // 파라미터 이름으로 메서드의 몇번째 파라미터인지 구한다.
        Integer parameterIdx = getParameterIdx(pjp, paramName);
        if (parameterIdx == -1)
            throw new IllegalArgumentException();

        String checkWord;
        // 금칙어 체크할 문장을 객체내의 필드값에서 알아내야 할 경우(리플렉션 이용)
        if (StringUtils.isNotEmpty(fieldName)) {
            Class<?> clazz = forbiddenWordCheck.checkClazz();
            Field field = clazz.getDeclaredField(fieldName);
            field.setAccessible(true);
            checkWord = (String) field.get(pjp.getArgs()[parameterIdx]);
        // 금칙어 체크할 문장이 String형태의 파라미터로 넘어오는 경우
        } else {
            checkWord = (String) pjp.getArgs()[parameterIdx];
        }
        // 체크할 문장에 금칙어가 포함되어 있는지 확인.
        checkForbiddenWord(checkWord);
    }

    // 메서드의 파라미터 이름으로 몇번째 파라미터인지 구한다.
    private Integer getParameterIdx(JoinPoint joinPoint, String paramName) {
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        String[] parameterNames = methodSignature.getParameterNames();
        for (int i = 0; i < parameterNames.length; i++) {
            String parameterName = parameterNames[i];
            if (paramName.equals(parameterName)) {
                return i;
            }
        }
        return -1;
    }

    // 입력된 문장에 금칙어가 포함되어 있으면 Exception을 발생시킨다.
    private void checkForbiddenWord(String word) {
        List<String> forbiddenWords = Arrays.asList("개새끼", "쌍년", "씨발");
        Optional<String> forbiddenWord = forbiddenWords.stream().filter(word::contains).findFirst();
        if (forbiddenWord.isPresent())
            throw new CForbiddenWordException(forbiddenWord.get());
    }
}

BoardService 메서드에 annotation 설정

기존에 메서드 내에 포함된 금칙어 체크 로직은 제거합니다. 그리고 writePost, updatePost 메서드 상단에 @ForbiddenWordCheck를 달아줍니다.

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class BoardService {

    // 생략...

    // 게시글을 등록합니다. 게시글의 회원UID가 조회되지 않으면 CUserNotFoundException 처리합니다.
    @CacheEvict(value = CacheKey.POSTS, key = "#boardName")
    @ForbiddenWordCheck
    public Post writePost(String uid, String boardName, ParamsPost paramsPost) {
        Board board = findBoard(boardName);
        Post post = new Post(userJpaRepo.findByUid(uid).orElseThrow(CUserNotFoundException::new), board, paramsPost.getAuthor(), paramsPost.getTitle(), paramsPost.getContent());
        return postJpaRepo.save(post);
    }

    // 게시글을 수정합니다. 게시글 등록자와 로그인 회원정보가 틀리면 CNotOwnerException 처리합니다.
    //@CachePut(value = CacheKey.POST, key = "#postId") 갱신된 정보만 캐시할경우에만 사용!
    @ForbiddenWordCheck
    public Post updatePost(long postId, String uid, ParamsPost paramsPost) {
        Post post = getPost(postId);
        User user = post.getUser();
        if (!uid.equals(user.getUid()))
            throw new CNotOwnerException();

        // 영속성 컨텍스트의 변경감지(dirty checking) 기능에 의해 조회한 Post내용을 변경만 해도 Update쿼리가 실행됩니다.
        post.setUpdate(paramsPost.getAuthor(), paramsPost.getTitle(), paramsPost.getContent());
        cacheSevice.deleteBoardCache(post.getPostId(), post.getBoard().getName());
        return post;
    }

    // 생략...
}

작업이 모두 완료되었습니다. 다시 서버를 구동하고 Swagger를 통해 금칙어를 체크해보면 이전과 동일하게 기능이 작동하는 것을 확인할 수 있습니다.

이번 실습을 통해 메인 프로세스 내에 부가 프로세스가 포함되지 않더라도 해당 로직을 적용시킬 수 있는 방법에 대해 살펴보았습니다. 언뜻 보면 이러한 분리를 위한 추가적인 작업이 더 번거롭다고 생각할 수 있습니다. 하지만 더 복잡하고 중복이 많이 발생하는 프로세스를 이와 같은 방법으로 분리하면 프로세스 간의 밀접도를 낮출 수 있어 코드의 복잡도를 줄일 수 있으며, 중복 발생하는 코드를 하나의 클래스에 집중시킬 수 있으므로 유지보수도 용이해지게 됩니다.

실습에 사용한 코드는 아래 GitHub에서 확인할 수 있습니다.

https://github.com/codej99/SpringRestApi/tree/feature/block_fobidden_word

GitHub Repository를 import하여 Intellij 프로젝트를 구성하는 방법은 다음 포스팅을 참고해주세요.

Docker로 개발 환경을 빠르게 구축하는 것도 가능합니다. 다음 블로그 내용을 읽어보세요!

스프링 api 서버를 이용하여 웹사이트를 만들어보고 싶으시면 아래 포스팅을 참고해 주세요.

연재글 이동[이전글] SpringBoot2로 Rest api 만들기(15) – Redis로 api 결과 캐싱(Caching)처리