[클린코드 적용기] 2. 관심사 분리 (Spring AOP)

전종원·2024년 8월 6일
0
post-custom-banner

이전 글에 이어서 클린코드 11장 시스템에서 다룬 관심사 분리를 적용합니다.

이전 글에서는 인증된 사용자 정보 추출에 대한 관심사를 분리했습니다.
이번 글에서는 비즈니스 로직 수행에 대한 로그 처리를 분리합니다.

개선 전

public void createFcmToken(String token, long userId) {
    log.info("start createFcmToken by token: [{}], userId: [{}]", token, userId);

    User user = userRepository.findById(userId)
            .orElseThrow(() -> new CustomException(CommonCode.NONEXISTENT_USER));

    // 중복 데이터 저장되지 않도록
    if (!fcmTokenRepository.existsByUserIdAndToken(userId, token)) {
        log.info("FCM 토큰 생성 by userId: [{}], token: [{}]", userId, token);
        fcmTokenRepository.save(new FcmToken(user, token));
    }
}

@Transactional
public void deleteFcmToken(long userId) {
    log.info("start deleteFcmToken by userId: [{}]", userId);

    fcmTokenRepository.deleteAllByUserId(userId);
}

public void updateUser(String nickname, long userId) {
    log.info("start updateUser by nickname: [{}], userId: [{}]", nickname, userId);

    User user = userRepository.findById(userId)
            .orElseThrow(() -> new CustomException(CommonCode.NONEXISTENT_USER));

    if (userRepository.existsByNickname(nickname)) {
        throw new CustomException(CommonCode.ALREADY_EXIST_NICKNAME);
    }

    user.updateNickname(nickname);

    userRepository.save(user);
}
  • 유저 서비스 코드의 일부입니다.
    • 비즈니스 로직 시작할 때 “start method name …” 로그를 기록하는 구조가 반복되는 것을 확인할 수 있습니다.

문제점

  1. 유지보수 용이성 ↓
    파라미터명이 변경되거나, 파라미터가 변경될 때마다 로그를 수정해야 합니다. 뿐만 아니라, 로그 메시지 형식을 변경하고 싶을 때에도 모든 비즈니스 로직을 수정해야 합니다.
  2. 개발 비용 ↑
    서비스 로직 작성 시에 항상 일관된 구조의 로그를 작성해야 하며, 오롯이 비즈니스 로직에 집중하는 데 방해가 됩니다.
  3. 실수 가능성 ↑
    깜빡하고 로그를 작성하지 않는 경우가 발생할 수 있습니다.

기대 효과

“서비스 로직 시작 로깅” 이라는 관심사를 분리한다면?
→ 사소한 변경에도 로그를 직접 수정하지 않아도 될 것입니다.
→ 로깅 동작이 자동화되어 개발자는 더이상 시작 로깅을 신경쓰지 않고, 비즈니스 로직에 집중할 수 있을 것입니다.

💡 리팩토링 계획

Spring AOP

  • Spring AOP를 활용하여 서비스 메서드가 시작될 때 로그를 남기도록 합니다.

관심사 분리

LogAspect

@Aspect
@Slf4j
@Component
public class LogAspect {

    @Pointcut("execution(* com.and20roid.backend.service.*.*(..))")
    public void service() {
    }

    @Around("service()")
    public Object loggingBefore(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().getName();
        String logMessage = buildLogMessage(methodName, joinPoint.getArgs(),
                (MethodSignature) joinPoint.getSignature());

        log.info(logMessage);

        return joinPoint.proceed();
    }

    private String buildLogMessage(String methodName, Object[] args, MethodSignature signature) {
        StringBuilder sb = new StringBuilder();
        appendMethodName(sb, methodName);
        appendParameters(sb, signature, args);
        return sb.toString();
    }

    private void appendMethodName(StringBuilder sb, String methodName) {
        sb.append("start ").append(methodName).append(" by ");
    }

    private void appendParameters(StringBuilder sb, MethodSignature signature, Object[] args) {
        String[] parameterNames = signature.getParameterNames();
        if (args.length > 0) {
            appendArguments(sb, parameterNames, args);
        } else {
            sb.append("no arguments");
        }
    }

    private void appendArguments(StringBuilder sb, String[] parameterNames, Object[] args) {
        for (int i = 0; i < args.length; i++) {
            sb.append(parameterNames[i]).append(": [");
            appendArgument(sb, args[i]);
            sb.append("], ");
        }

        // remove comma and space
        sb.setLength(sb.length() - 2);
    }

    private void appendArgument(StringBuilder sb, Object arg) {
        if (arg instanceof MultipartFile) {
            appendMultipartFile(sb, (MultipartFile) arg);
        } else {
            sb.append(arg);
        }
    }

    private void appendMultipartFile(StringBuilder sb, MultipartFile file) {
        sb.append(file.getName()).append(": [").append(file.getOriginalFilename()).append("]");
    }
}
  • service 하위 패키지에 위치한 서비스 클래스들을 대상으로 로깅을 자동화했습니다.
  • 파라미터 타입이 MultipartFile인 경우에는 파라미터명과 원본 파일명이 출력되도록 처리했습니다.

개선 후

public void createFcmToken(String token, long userId) {
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new CustomException(CommonCode.NONEXISTENT_USER));

    // 중복 데이터 저장되지 않도록
    if (!fcmTokenRepository.existsByUserIdAndToken(userId, token)) {
        log.info("FCM 토큰 생성 by userId: [{}], token: [{}]", userId, token);
        fcmTokenRepository.save(new FcmToken(user, token));
    }
}

@Transactional
public void deleteFcmToken(long userId) {
    fcmTokenRepository.deleteAllByUserId(userId);
}

public void updateUser(String nickname, long userId) {
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new CustomException(CommonCode.NONEXISTENT_USER));

    if (userRepository.existsByNickname(nickname)) {
        throw new CustomException(CommonCode.ALREADY_EXIST_NICKNAME);
    }

    user.updateNickname(nickname);

    userRepository.save(user);
}
  • 비즈니스 로직에 시작 로깅 관련 코드가 모두 제거되었습니다.
  • 실행결과입니다.
    • 이전의 형식은 그대로 유지하되, 잘 기록되는 모습을 볼 수 있습니다.
post-custom-banner

0개의 댓글