[말모] 거의 모든 서비스 로직이 사용자를 검증한다 (AOP 적용기)

Choi Wontak·2025년 9월 1일
0

말모

목록 보기
1/9

난이도 ⭐️
작성 날짜 2025.07.06

고민 내용

말모는 커플 전용 앱이지만, 커플 연동이 아직인 사용자도 제한된 기능으로 접근 가능한 서비스이다.

그래서 크게 API 접근 권한을 세 가지 정도로 나눌 수 있는데,

  1. 사용자가 아니어도 접근 가능 (ex. 로그인, 약관 조회 등)
  2. 일반 사용자가 접근 가능 (ex. 내 정보 조회 등)
  3. 커플인 사용자만 접근 가능 (ex. 커플 상대 정보 조회 등)

1번은 오픈된 API이기 때문에 로직 상 크게 상관이 없다.
2번, 3번의 경우 JWT 토큰을 통해 DB 정보를 직접 조회하고, 비즈니스 로직에 맞춰 해당 유저가 권한이 있는지 검사해야 한다.

스프링 시큐리티가 있지만, 필터에서 토큰의 유효성 검사만 진행하지 실제 사용자의 정보를 참고하지는 않는다.
예를 들어 사용자가 탈퇴한 경우에도 서비스 입장에서는 토큰을 만료시킬 방법이 없기 때문에, 해당 토큰을 그대로 사용하더라도 필터를 통과하게 된다.

물론 UserDetailsService를 커스텀화해서, 조회하고 검증하는 방법도 있지만 요청 경로에 따라 2번 3번을 분리해야 하며, 비즈니스 관심사를 필터 단에서 처리해 유지보수에 어려움이 생길 수 있었다.

그래서 서비스 계층에서 JWT에 담긴 사용자 정보를 조회하고, 상황에 맞춰 2번과 3번의 형태를 검증하는 코드를 작성해야 했다.
그런데 거의 모든 서비스 로직이 사용자를 검증하다보니 반복의 문제도 생기고, 코드 레벨의 가독성도 떨어진다는 문제가 발생했다!

🤔 늘어나는 반복, 해결 방법은 없을까?


찾아보기

횡단 관심사

객체 지향 소프트웨어 개발에서 횡단 관심사 또는 크로스커팅 관심사(cross-cutting concerns)는 다른 관심사에 영향을 미치는 프로그램의 애스펙트이다. 이 관심사들은 디자인과 구현 면에서 시스템의 나머지 부분으로부터 깨끗이 분해되지 못하는 경우가 있을 수 있으며 분산(코드 중복)되거나 얽히는(시스템 간의 상당한 의존성 존재) 일이 일어날 수 있다.
(출처 : 위키백과)

말이 좀 복잡하게 적혀있는데, 현재 계층 또는 모듈에서 구현된 관심사가, 다른 부분에서도 요구하는 부분이기 때문에 코드가 중복되고, 쉽게 떼어내기 힘든 관심사를 의미한다.
이러한 상황을 '횡단 관심사'라고 한다.
하나의 요청의 플로우를 종단 관심사라고 볼 때, 이렇게 중복되는 부분은 공통적으로 관심을 갖는 부분이므로, 이 종단 관심사를 관통하는 횡단의 관심사라고 판단할 수 있다.

나의 경우에는, 멤버를 요청 상황에 맞추어 검증하는 부분이 그렇다.
모두 서비스 계층에서 검증이 이루어지지만, 다른 도메인(모듈)의 서비스 코드에서도 반복되고 있다.
이로 인해 코드가 중복되어 유지 보수도 어렵고, 하나의 서비스 메서드에서 이루어지더라도 서비스 계층 간의 의존이 우려되는 상황이다.

스프링이 횡단 관심사를 해결하는 방법

AOP (Aspect-Oriented Programming)
기존 도메인의 관점으로 진행되었던 코드 개발 방식과는 달리, 관심사(Aspect)를 기준으로 프로그래밍하여 중복되는 부분을 분리해낼 수 있는 개발방법론이다.

특히 스프링의 특징 중에 하나인 어노테이션을 이용한다면, 더 깔끔한 코드를 만들 수 있다.

스프링 AOP 적용해보기

우선 기본적으로 검증을 위한 코드를 작성한다.

@Override
public boolean isCoupleMember(Long memberId) {
    return queryFactory
            .selectOne()
            .from(coupleEntity)
            .where(coupleEntity.coupleMembers.any().memberEntityId.value.eq(memberId))
            .fetchFirst() != null;
    }
@Component
@RequiredArgsConstructor
public class MemberValidationQueryHelper {

    private final ValidateMemberPort validateMemberPort;

    public void isMemberCouple(MemberId memberId) {
        boolean coupleMember = validateMemberPort.isCoupleMember(memberId);

        if (!coupleMember) {
            throw new NotCoupleMemberException("커플 등록 전인 사용자입니다. 커플 등록 후 이용해주세요.");
        }
    }
}

이제 '커플인 사용자만 접근 가능해요'라는 것을 표시하면서, 해당 검증 기능을 실행시켜줄 수 있는 어노테이션을 만들어준다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckCoupleMember {
}

이제 aspectj의 어노테이션을 이용하여, 코드의 실행 시점을 정해주었다.
@Before("@annotation(CheckCoupleMember)")
해당 코드는
'어노테이션인 CheckCoupleMember를 실행하기 이전에 아래의 코드를 실행하라'
이런 의미를 담고 있다.

@Aspect
@Component
@RequiredArgsConstructor
public class CoupleMemberValidationAspect {

    private final MemberDomainValidationService mmemberValidationService;

    @Before("@annotation(CheckCoupleMember)")
    public void checkCoupleMember() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || !(authentication.getPrincipal() instanceof User user)) {
            throw new AuthenticationCredentialsNotFoundException("인증된 사용자를 찾을 수 없습니다.");
        }

        Long memberId = Long.valueOf(user.getUsername());
        mmemberValidationService.isMemberCouple(MemberId.of(memberId));
    }
}

사용자의 정보가 필요했기 때문에, 스프링 시큐리티가 ContextHolder에 저장해둔 User 객체를 통해 JWT 토큰에 저장되어 있었던 사용자의 ID를 가져올 수 있었다.

커플이 아닌 사용자에 대한 코드도 동일한 방법으로 작성하였다.

@Override
@CheckCoupleMember
public PartnerMemberResponseDto getPartnerInfo(PartnerInfoCommand command) {
      //... 코드 생략
}

이제 커플인 멤버만 접근할 수 있는 서비스 코드는 @CheckCoupleMember 어노테이션을 붙이면 서비스 코드의 실행 이전에 검증 코드를 먼저 실행하여 미리 예외를 던질 수 있다.


결론

결론
내가 하는 고민은 다 선배 개발자들이 한 고민...
OOP를 위한 선배님들의 해결책을 잘 흡수하자


profile
백엔드 주니어 주니어 개발자

0개의 댓글