Skip to content

Latest commit

 

History

History
364 lines (210 loc) · 27.3 KB

230906.md

File metadata and controls

364 lines (210 loc) · 27.3 KB

서브타입의 레포지터리를 동적으로 결정 + 람다식을 활용한 지연 조회 (1)

tl;dr

배경

상속 관계 매핑과 조인 테이블 전략

한 조직에서 여러 개의 서비스를 운영하는 경우 다양한 유형의 유저가 존재할 수 있습니다. 우리 팀의 경우 현재 3개의 서비스를 운영하고 있었고, 추가로 4개까지 확장할 수 있는 가능성이 존재하고 있었습니다. 이를 편의상 서비스 A, B, C라고 합시다.

각 서비스를 사용하는 유저에 대해서 간단하게 설명하자면 다음과 같습니다. 먼저, A 서비스에는 교사 유저와 학부모 유저가 존재합니다. B 서비스에는 스태프 유저가 존재합니다. C 서비스에는 교사 유저와 관리자 유저가 존재합니다. 또한, 앞으로 추가될 D 서비스에는 현장요원 유저가 존재합니다.

배민에서 주문하는 유저 / 가게 사장님 / 라이더 / 콜센터 관리자에 대해서 각각의 서비스가 존재하고, 한 서비스에 둘 이상의 유저 타입의 존재한다고 생각하면 이해하기 쉬울 것입니다.

이를 구현하기 위해 기존 레거시에서는 JPA 상속 구조 + 조인 테이블 전략을 사용하고 있었습니다. 즉, 사용자의 기본적인 정보를 저장하는 User 테이블이 존재하고, 그 외 세부 유저 타입에 특화된 정보를 저장하는 Teacher 테이블, Parent 테이블이 존재합니다. 이때, 애플리케이션 상에서는 JPA의 @Inheritance(strategy = IngeritanceType.JOINED) + @DiscriminatorColumn 을 사용하여 상속 관계를 매핑하게 됩니다.

모놀리식 데이터베이스와 멀티 모듈, 그리고 통합 인증 모듈

현재 사내에서 운영하는 서비스는 모놀리식 데이터베이스를 기반으로 하고 있습니다 (모놀리식 아키텍쳐가 아닙니다!). 즉 여러 개의 프로젝트가 하나의 데이터베이스를 공유합니다. 하지만 이렇게 하는 경우 A, B, C, D 서비스에서 중복이 발생하는 도메인이 생길 수밖에 없습니다. 이때, 만약 Teacher 테이블에서 구조 변경이 발생하고, 해당 테이블을 A, B, D 서비스에서 사용한다면, 세 프로젝트의 도메인 코드를 '동시에' 변경해줘야 합니다. 즉 도메인 코드 중복으로 인한 동기화의 번거로움이 존재합니다. 물론 서로 다른 서비스 간의 트랜잭션 처리 역시 고려해야 하는 문제 중 하나입니다.

이를 위해서 차후 멀티 모듈 구조를 도입하여 중복되는 도메인 코드와 레포지터리를 공통 모듈로 분리하는 것을 제안했습니다. 당장은 일부 레거시에 대한 테스트 및 리팩토링 작업이 최우선적이었기 때문에, 기능 개발 시 모듈화가 가능하도록 설계하는 것이 중요했습니다. 특히 인증 기능의 경우 별도 모듈로 분리될 가능성이 컸던 상황이었습니다.

따라서, 인증 구현 시 최우선적으로 고려할 사항은 다양한 유저 타입을 처리할 수 있는가? 였습니다. 현재는 A, B, C, D 서비스의 인증이 독립적으로 구현되어 있지만, 멀티 모듈을 도입하게 된다면 A, B, C, D 서비스가 공통 인증 모듈을 사용하게 됩니다. 이때, Teacher, Parent, Staff, Agent 같이 각각의 서비스를 하나 이상 사용하는 유저 타입을 처리할 수 있어야 합니다.

문제 정의

절대 UserDetails에 엔티티 필드를 추가해서는 안돼

유연한 유저 타입 처리가 가능한 모듈을 어떻게 개발하는지 설명하기에 앞서서, 굉장히 중요한 포인트 하나를 짚고 넘어가겠습니다. 별도 글로 다룰 예정이긴 하지만 여기서도 언급할 필요가 있을 것 같아 함께 작성해봤습니다.

팀내 결정에 따라 로그인 방식은 JWT를 사용하는 토큰 기반 인증 방식을 채택했습니다. 아래는 초기 인증 기능 구현에 대한 설명입니다.

  • UserDetails를 상속하는 PrincipalDetails를 구현합니다.
    • 해당 클래스는 User 타입 필드를 가집니다.
    • 이 필드에는 레포지터리로부터 조회해온 유저 엔티티가 저장됩니다. (i.e. userRepository.findByUsername)
  • 인증 정보가 생성되었으면 인증 컨텍스트에 인증 정보를 저장합니다. (i.e. SecurityContext.setAuthentication(principalDetails))
  • 추후 UserUtils.getCurrentUser() 에서는 SecurityContext.getAuthentication() 를 통해 인증 정보를 불러오고, 해당 인증정보에서 현재 로그인한 유저의 엔티티를 받아옵니다.

한 가지 유의할 점은 위 방식처럼 UserDetails를 커스터마이징할 때 유저 엔티티를 저장하는 프로퍼티를 추가해서는 안된다는 것입니다. 이는 아주 치명적인 문제를 초래할 수 있기 때문에 지양해야 합니다. 처음부터 정확한 내용을 알려드리지 않는 이유는 인증 기능을 개발하기까지의 과정을 최대한 자세히, 순차적으로 설명해드리기 위함입니다. 인증 컨텍스트에 유저 엔티티를 저장할 때 발생하는 문제점에 대해서는 뒤에서 추가로 설명하도록 하겠습니다. 일단 1차적으로는 이렇게 설계 및 구현이 완료되었다고 칩시다.

과연 UserRepository로 괜찮은가?

(앞서 말했던 치명적인 문제를 제외하면) 별다른 문제점이 없어보입니다. 하지만 다양한 유저 타입을 처리할 수 있어야 한다 라는 요구사항을 생각해보면, 이런저런 문제점이 보이기도 합니다. 이러면 절대 안돼! 는 아니지만 뭔가 불편하지는 '냄새'가 난단 말이죠. 같이 알아봅시다.

  1. UserRepository 를 만드는 것 자체가 객체지향적이지 않다.

    상속관계 매핑에서 조인 테이블 전략을 지정하면서 User의 경우 추상 클래스로 선언했습니다. 즉 User는 인스턴스화되면 안됩니다. 그 이유는 UserTeacher, Parent 같이 다양한 유저에서 공통되는 정보를 묶어서 추출해낸 개념이지, 하나의 객체라고 볼 수 없기 때문입니다. 객체가 될 수 없으니, 객체로 조회해오는 레포지터리도 존재하면 안됩니다.

  2. 쿼리가 두 번 발생한다.

    어찌저찌 User가 반환되었다 하더라도, 결국 각 서비스에 필요한 서브타입으로 적절히 캐스팅되어야 합니다. 만약 A 서비스에서 특정 교사의 소속 센터를 조회하려고 한다고 합시다. 이때 SecurityContext 에 저장된 현재 로그인된 유저를 가져온 다음, 이를 Teacher로 다뤄줘야 합니다. 결국 User 테이블에 대한 조회가 한번, Teacher 테이블에 대한 조회가 한번 하여 총 2회의 조회 쿼리가 발생합니다.

    상황에 따라 다르긴 하지만 현재 상황에서는 조회 쿼리를 두 번 날리는 것보다 유저의 서브타입에 맞게 조인해서 가져오는 것이 성능 상으로 더 낫습니다. 실제로 조인 테이블 전략에서 Teacher 와 같이 유저의 서브타입을 조회하는 경우 User 테이블과의 조인 쿼리가 발생하게 됩니다.

아하, 그렇다면 유저의 서브타입(e.g. Parent)에 해당하는 레포지터리에서 조회해오면 해결되겠네요? 그러니 기존에 추가했던 UserRepository 는 삭제하도록 합니다. 아니면, @NoRepositoryBean 어노테이션을 추가해주면 굳이 인터페이스를 삭제해주지 않더라도 빈 초기화 과정에서 제외됩니다.

switch 문으로 역할에 맞는 레포지터리에서 유저 조회해오기

시작하기 전에...

먼저 전제조건 하나가 필요합니다. 바로 인증 요청 시 '어떤 타입의 유저인지'를 알 수 있어야 한다는 점입니다. 그래야 어떤 레포지터리에서 조회할지 사전에 결정할 수 있겠죠? 저희 팀의 경우 JWT를 사용하고 있으므로, 이 토큰의 페이로드로부터 해당 유저가 어떤 타입인지 식별할 수 있어야 합니다. 다행히, 기존 JWT 페이로드에서는 역할에 해당하는 클레임을 받고 있었습니다 (e.g. "role": "TEACHER"). 이를 DTYPE 칼럼에 대응되는 UserRole 열거형과 매핑해주어야 합니다.

즉 토큰에 있는 내용을 까서 유저 역할에 해당하는 열거형 원소로 변환해줘야 한다는 말입니다.

코드로 보여드리는 편이 더 쉽겠네요. 아래 코드를 봅시다.

// 토큰으로부터 UserRole 값의 문자열 리터럴을 가져옴
String userRole = getClaims(token).get(JwtProperties.TOKEN_ROLE, String.class);

// UserRole.valueOf(userRole)로 열거형 요소로 매핑해줌
return Optional.ofNullable(UserRole.valueOf(userRole))
        .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_ENUM_VALUE));

그렇다면 JWT에 들어있던 유저 역할 정보를 통해 어떤 레포지터리에서 조회 로직을 수행할 지 결정할 수 있습니다.

@Component
@RequiredArgsConstructor
public class PrincipalDetailsService {
    private final TeacherRepository teacherRepository;
    private final ParentRepository parentRepository;
    private final StaffRepository staffRepository;
    private final AgentRepository agentRepository;

    // ID 뿐만이 아니라 역할로도 조회해와야 하므로, 이 메서드 대신 아래 메서드를 사용한다
    @Deprecated
    public UserDetails loadUserByUsername(String username) {
        return null;
    }

    public UserDetails loadUserByUsernameAndUserRole(String username, UserRole userRole) {

        switch (userRole) {
            case TEACHER -> return new PrincipalDetails(teacherRepository.findByUsernameAndUserRole(username, userRole));
            case PARENT -> return new PrincipalDetails(parentRepository.findByUsernameAndUserRole(username, userRole));
            case STAFF -> return new PrincipalDetails(staffRepository.findByUsernameAndUserRole(username, userRole));
            case AGENT -> return new PrincipalDetails(agentRepository.findByUsernameAndUserRole(username, userRole));
            default -> throw new CustomException("this cannot be happen...");
        }
    }
}

이제 유저의 서브타입에 해당하는 레포지터리에서 join을 사용하여 한번에 조회해올 수 있으므로, 만사형통입니다.

...정말 그런가요? 뭔가 가슴이 답답해지고 심기가 불편해지지 않나요?

switch 문은 개방 폐쇄 원칙(OCP)을 위반한다

이 코드에서 풍기는 진한 코드 스멜을 느끼지 못했다면 정말로 큰 위기입니다. switch 문의 경우 주의해서 사용하지 않으면 여러 가지 문제를 발생시킵니다. 가장 많이 언급하는 예시가 '다른 케이스가 추가된다면 어떨까?' 인데요, 우리의 상황과 정확하게 일치하지 않습니까? 그러니, 위 코드의 경우 이런 질문을 던져볼 수 있겠습니다.

"다른 유저 타입이 추가된다면 어떨까?"

클린 코드에는 새(bird)를 예시로 들고 있습니다만, 저는 개인적으로 그런 현실과 동떨어진 예시를 좋아하지는 않습니다. 좀더 그럴법한 예시를 들어볼까요?

만약 서비스가 확장되어, 로그인 모듈에서 또다른 타입의 유저를 처리해야 하는 상황이 생긴다고 합시다. 가령 인프런 랠릿처럼 채용 플랫폼으로 확장하는 경우를 생각해볼 수 있겠습니다. 이 경우, 일반 교사가 아닌 원장님만 가입할 수 있어야 하며, 따라서 기존에 존재하던 Teacher를 일반교사와 원장님으로 분리하여 TeacherDirector 로 구분하는 방식으로 구현할 수 있습니다. 그렇다면 로그인 모듈은 다음과 같이 수정되어야 합니다.

@Component
@RequiredArgsConstructor
public class PrincipalDetailsService {
    private final TeacherRepository;
    private final ParentRepository;
    private final StaffRepository;
    private final AgentRepository;
    private final DirectorRepository; // newly added repository

    // ID 뿐만이 아니라 역할로도 조회해와야 하므로, 이 메서드 대신 아래 메서드를 사용한다
    @Deprecated
    public UserDetails loadUserByUsername(String username) {
        return null;
    }

    public UserDetails loadUserByUsernameAndUserRole(String username, UserRole userRole) {

        switch (userRole) {
            case TEACHER -> return new PrincipalDetails(teacherRepository.findByUsernameAndUserRole(username, userRole));
            case PARENT -> return new PrincipalDetails(parentRepository.findByUsernameAndUserRole(username, userRole));
            case STAFF -> return new PrincipalDetails(staffRepository.findByUsernameAndUserRole(username, userRole));
            case AGENT -> return new PrincipalDetails(agentRepository.findByUsernameAndUserRole(username, userRole));
            case DIRECTOR -> return new PrincipalDetails(directorRepository.findByUsernameAndUserRole(username, userRole)); // newly added 'case' clause 
            default -> throw new CustomException("this cannot be happen...");
        }
    }
}

그렇습니다. 유저의 서브타입이 추가될 때마다 UserRole 열거형의 원소를 추가해줘야 하고, PrincipalDetailsService에 레포지터리 의존관계를 추가해줘야 합니다. 그리고, case 절도 추가해줘야 하고... 이만저만 번거로운게 아닙니다.

그리고 무엇보다 우리의 개발자 감수성은 XXXRepository.findByUsernameAndUserRole(username, userRole)이 반복되는 것을 참을 수 없습니다. 어떻게든 바꿔야 한다! 저도 그 감수성에 패배해버렸고 이 아티클을 작성하게 되어버렸죠.

아무튼, 이쯤에서 개방 폐쇄 원칙이 뭔지 짚고 갑시다.

코드는 확장에는 열려있어야 하지만, 수정에는 닫혀있어야 한다.

과연 저런 번거로운 과정을 해주는 것이 '확장에 열려있고, 수정에는 닫혀있다' 말할 수 있을까요? 아니겠죠.

물론 혹자는 이렇게 말할 수도 있을 겁니다.

"어차피 새로운 도메인 추가할 때 같이 수정해주면 되는 거 아닌가요? 코드 몇 줄 쓰는 거 가지고 그리 호들갑을?"

아니면 이렇게 말할 수도 있겠죠.

"switch 문이 그렇게 해악이면 대체 언제 써야 하는데요?"

흠... 그렇게 생각할 수도 있겠네요. 일단 이번 글은 이 두 가지 지적에 대해서 짚고 넘어가는 것으로 마무리하도록 합시다.

OCP... 그기 돈이 됩니까?

개발자가 실수할 수도 있으니까

시간이 흘러 저는 팀을 떠나게 되었고, 제가 만든 인증 모듈은 열심히 제 할 일을 하는 중이라고 합시다.

회사 역시 무럭무럭 성장해서 무려 30개가 넘는 서비스를 운영하게 되었습니다. 과장좀 해서 유저의 서브타입이 20개 넘게 존재하게 되었고 UserRole 역시 그 갯수만큼 존재하게 되겠네요. 물론 이쯤 되면 MSA를 생각할법도 하지만 일단 제가 혼신의 힘을 다해 만든 레거시 때문에 모놀리스에서 넘어갈 엄두도 못 내는 중이라고 하네요.

무튼 제가 쳐다보지 못할 정도로 커진 회사는 오늘도 서비스 하나를 런칭하기 위해 새로운 타입의 유저를 쓱삭하고 만들려고 합니다. 여기에 새로 입사한 모 주니어 개발자는 1) 유저를 상속하는 서브클래스를 만들고 2) UserRole에 값을 추가하는 것까지는 해냈지만 아뿔싸, 가장 중요한 PrincipalDetailsService 에 관련 로직을 추가하는 것을 까먹고 말았습니다.

OCP 원칙을 어긴다고 잘 돌아가는 코드가 갑자기 작동을 중지하고 그러는 것은 아닙니다. 사실 OCP를 무시하면서도 개발할 수 있습니다. 만약 UserRole을 한 1000군데 넘는 곳에서 switch 문의 조건으로 사용하고 있다고 합시다. 역할 하나 추가하려면 1000개 넘는 곳에 로직을 하나하나 추가해주면 되겠죠. 하지만 겁나 귀찮지 않겠습니까? 객체지향 5원칙이니 하는 것들을 너무 복잡하게 생각하지 맙시다. 그냥 개발 많이 하신 분들이 '이렇게 하니까 편하네?' 싶은 것들 정리해둔 느낌으로 보는게... 아무튼. 위에서 저 불쌍한 주니어 개발자가 실수하게 된 이유가 뭘까요?

뭐긴 뭡니까. 수정할 게 많으니까 그런 겁니다. 수정할 게 없어야 실수할 일도 없습니다. 무책임하게 Director 클래스만 만들더라도 인증이 되는 세상을 만들던가!

그래도 switch 문... 사랑하시죠?

엄연히 switch 문도 자바에서 제공하는 기능인데 왜 setter 처럼 여기에 대한 기준만 엄격한건데! 싶으실 수 있습니다. 그래서 클린 코드 책에서도 switch 문을 사용하되 코드 스멜을 풍기지 않는, 객체지향적으로 사용하는 방법을 제시하고 있습니다. 이거 보셔도 됩니다. 하지만 저희가 앞에서 언급한 케이스에는 적용하기 쉽지 않아보입니다.

하지만 개발을 하다보면 특정 열거형 클래스 값들에 대해서 switch 문을 적용하는 경우가 많습니다.
이 경우 꽤 유용하게 쓸 수 있는 방법이 있습니다. 바로바로...

switch 문을 열거형 클래스의 근방이나 내부에 배치하는 것입니다.

예시로 바로 봅시다.
가령 UserRole 값에 따라 서로 다른 엔티티를 생성해줘야 한다고 합시다.

public enum UserRole {
    DIRECTOR("DIRECTOR", "관리교사", DIRECTOR.class),
    TEACHER("TEACHER", "교사", Teacher.class),
    PARENT("PARENT", "학부모", Parent.class);

    private final String value;
    private final String description;
    private final Class<? extends User> domainClass;

    public createUserByUserRole(UserSaveRecord userSaveRecord) {
        switch (userSaveRecord.getUserType()) {
            case DIRECTOR -> return Director.createDirector(userSaveRecord);
            case TEACHER -> return Teacher.createTeacher(userSaveRecord);
            case PARENT -> return Parent.createParent(userSaveRecord);
        }
    }
} 

이렇게 하면 뭐가 좋을까요?

먼저 switch의 문제점 중 하나인 '수정에 닫혀있지 않아 실수할 가능성이 높아진다' 를 해결할 수 있습니다.

기존의 코드에서 PrincipalDetailsServiceswitch 문과 UserRole 열거형 클래스는 상당히 멀리 위치해있습니다. 따라서 UserRole에 변화가 발생하더라도, 이를 프로퍼티로 가지는 User 클래스와 다시 이 역할 프로퍼티를 switch 의 조건으로 사용하기 때문에, 이 변화를 switch 문에서 알기 어렵습니다.

결국 "응집도를 높히자!" 라는 말입니다. 굳이 열거형 클래스의 내부가 아니더라도, 최소한 같은 패키지 안에 배치한다면 실수의 여지를 줄일 수 있을 것입니다. 이렇듯 응집도가 높은 코드는 불가피하게 변경이 발생하더라도, 이를 처리하기 훨씬 쉬워집니다.

그래서 어떻게 하면 좋을까?

우리가 다루고 있는 문제를 다시 한번 정리해봅시다.

"PrincipalDetails 에서 UserRole 열거형 값에 따른 switch 문을 사용하고 있기 때문에,
UserRole 이 추가되는 경우 유연하게 대처하기 어려우며, 로직 추가를 빼먹을 수 있다."

그러면 어떻게 해야할까요?

  1. 서브타입에 따라 어떤 레포지터리의 조회 메서드를 호출할지를 switch 문이 아닌, 동적으로 결정할 수 있어야 한다.

  2. 이때, 동적으로 생성된 조회 메서드는 실제로 조회되는 시점에 호출되어야 한다.

이렇게 하면 PrincipalDetailsService 에서 모든 유저 서브타입 레포지터리에 대한 의존성을 없애고, 실수하기 쉬운 switch 문을 없애고, UserRole의 추가나 삭제에도 유연하게 대처할 수 있을 것입니다.

다음 아티클에서는 위의 두 가지 요구사항을 만족시킬 수 있도록, 레거시 코드를 리팩토링해보도록 하겠습니다.

뭔가 심기가 불편해지지 않나요? 네. 유저 타입이 추가될 때마다 PrincipalDetailsService에 레포지터리 의존관계를 추가해줘야 합니다. 이거야 뭐 괜찮습니다. 요구사항이 변하면 의존관계도 변하는 건 당연한 일이라고 생각할 수 있으니까요.

하지만 제일 꼴보기 싫은 건 뭘까요? 맞습니다.

XXXRepository.findByUsernameAndUserRole(username, userRole)이 반복된다는 점입니다.

그리고 개

어떻게 하면 유연하게 수정할 수 있을까요?

  1. JWT 클레임에 담긴 유저 타입을 RoleType의 열거형 값으로 변환한다.
  2. 해당 열거형을 DTYPE으로 가지는 도메인 클래스를 결정한다.
  3. 해당 도메인 클래스에 해당하는 레포지터리를 결정한다.
  4. 해당 레포지터리에서 조회 쿼리 메서드를 호출한다.
  5. 쿼리 결과가 존재한다면 인증정보 생성 성공, 아니라면 예외 발생

즉, '유저의 모든 서브타입에 해당하는 레포지터리 빈'에 의존하지 않고, '유저 서브타입에 해당하는 레포지터리 빈을 동적으로 결정한뒤 조회' 할 수 있다면 서브타입이 추가되거나 없어지는(...) 경우에도 유연하게 대처할 수 있을 것입니다. 수도 코드로 봅시다.

항상 그렇지만 말이야 쉽지 않습니까?

직접 코드로 구현해보면서 이게 진짜 되는건지 알아봅시다.

유저의 서브타입 목록 찾기

가장 큰 난관인 유저의 서브타입 목록 찾기입니다.

우리의 목표는 'User 클래스를 상속하는 클래스들의 목록' 을 얻는 것입니다.

하지만 특정 타입의 서브타입 목록을 찾기 위해서는 리플렉션을 사용해야 합니다. 더 편한 방법이 없을까요?

-> 대충 AnnotationScanner 써야 한다는 내용

-> 대충 @DiscriminatorValue로 가져올 수 있다는 내용

-> 조인 테이블 전략으로 다른 클래스도 스캔되는 걸 방지하기 위해서 해당 목록이 User.class의 서브타입인지 필터링해야한다는 내용

동적으로 레포지터리 빈 결정하기

-> 대충 Repositories 유틸리티 클래스를 사용하여 동적으로 레포지터리 빈 목록을 가져올 수 있다는 내용

-> 대충 여기서 QueryMethod로 해당 레포지터리 빈의 쿼리 메서드 목록을 가져올 수 있다는 내용

람다식으로 지연 조회

-> 하지만 이 로직을 수행하는 클래스에서 조회를 수행하면 안됨

-> 조회는 해당 조회 로직을 필요로 하는 클래스 내부에서 호출해야 함

-> 왜? 실제로 로그인되는 시점에 조회해야 함. 가령 PrincipalDetailsService 에서 필요한 시점에 조회해야...

-> 리플렉션을 사용하여 해당 쿼리 메서드에 대한 lambda function을 리턴해준다

-> 그리고 리턴받은 function에 apply하면 lazy evaluation 가능!

UserFactoryProvider 가 다양한 쿼리 메서드를 지원하도록 리팩토링

-> 대충 로그인 로직이 변경되었다는 이야기

-> 이제 PrincipalDetails에 엔티티가 아닌 id값을 저장 (프록시 때문에 문제 발생했기 때문)

현재 로그인한 유저를 반환받는 UserUtils 리팩토링하기

이렇게만 끝나면 좀 아쉬우니 SecurityContext 로부터 현재 로그인한 유저를 반환받는 유틸리티를 리팩토링 해보도록 합시다.

기존 UserUtilsSecurityContext 로부터 로그인한 유저를 User 타입으로 반환합니다. 하지만 실제로 담겨있는 인스턴스는 User의 서브타입 인스턴스이므로, 만약 UserUtils.getCurrentUser()와 같이 반환받은 후 Teacher의 동작인 getCenter() 를 호출하기 위해서는

문제 정의

절대 UserDetails에 엔티티 필드를 추가해서는 안돼

유연한 유저 타입 처리가 가능한 모듈을 어떻게 개발하는지 설명하기에 앞서서, 굉장히 중요한 포인트 하나를 짚고 넘어가겠습니다. 별도 글로 다룰 예정이긴 하지만 여기서도 언급할 필요가 있을 것 같아 함께 작성해봤습니다.

팀내 결정에 따라 로그인 방식은 JWT를 사용하는 토큰 기반 인증 방식을 채택했습니다. 아래는 초기 인증 기능 구현에 대한 설명입니다.

  • UserDetails를 상속하는 PrincipalDetails를 구현합니다.
    • 해당 클래스는 User 타입 필드를 가집니다.
    • 이 필드에는 레포지터리로부터 조회해온 유저 엔티티가 저장됩니다.
    • 인증 정보가 생성되었으면 SecurityContext.setAuthentication(principalDetails) 를 통해 인증 컨텍스트에 인증 정보를 저장합니다.
  • 추후 UserUtils.getCurrentUser() 에서는 SecurityContext.getAuthentication() 를 통해 인증 정보를 불러오고, 해당 인증정보에서 현재 로그인한 유저의 엔티티를 받아옵니다.

이 방식과 비슷하게, 구글에 검색해보면 UserDetails 를 커스터마이징할 때 유저 엔티티를 저장하는 프로퍼티를 추가하는 글들이 정말 많습니다. 결론부터 말하자면 절! 대! 해서는 안될 짓입니다.

그 이유는 '인증 과정에서 조회해온 유저 엔티티'와 '서비스 로직에서 조회해온 유저 엔티티'는 서로 다른 트랜잭션에 존재하기 때문에, 인증 과정에서 조회한 유저 엔티티는 이후 서비스 로직에서 호출될 때 이미 준영속 상태가 되기 때문입니다.

일반적으로 PinricpalDetailsServiceloadUserByUsername() 메서드는 레포지터리에서findUserByUsername을 사용하여 ID에 해당하는 유저가 존재하는지 확인합니다.

그렇지 않으면 시큐리티에서 조회한 엔티티의 경우 실제 서비스 로직 트랜잭션에서는 영속성 컨텍스트에 의해 관리되지 않게 됩니다.

엔티티가 영속성 컨텍스트에 의해 관리되지 않는다는 것은 쓰기 지연 변경 감지, 지연 로딩 등등 영속성 컨텍스트가 제공하는 아주아주 편리한 기능을 쓰지 못한다는 것을 의미합니다. 아니 애초에 DB 엔티티를 객체로 다룰 수 없으면 JPA를 쓰는 이유가 없잖아요? 불편한 수준이 아니라 영속성 컨텍스트 없이는 객체지향적 백엔드 개발도 없습니다.

구글에서 찾아볼 수 있는 많은 글들이 UserDetails를 커스텀할 때 저희 팀의 경우 이 이슈에 대해서 알고 있지 못했고, 다른 토큰 기반 인증을 사용하는 레거시에서 유저 엔티티를 인증정보에 저장하고 있었기에 잘못된 방식을 선택하게 되었습니다. 실제로 다른 많은 스프링 시큐리티 관련 글에서도 해당 내용을 찾기 어렵기도 했고요.

https://velog.io/@jakeseo_me/JPA-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%A3%BC%EC%9D%98%EC%82%AC%ED%95%AD-%EC%A0%95%EB%A6%AC

여기서 저희 팀과 동일한 이유로 문제를 겪었던 글이 있어 들고 왔습니다. 궁금하시면 읽어보세요.

시큐리티 단에서는 엔티티를 식별할 수 있는 데이터만 UserDetails로 들고다니고, 유저 엔티티 자체는 트랜잭션 안에서 조회해오는 것이 맞습니다. 왜일까요?