프로젝트/Recipository

[Dev] 23.01.21. Service Layer에서 UserDetails 분리

규글 2023. 1. 21. 16:09

지난 게시글에서의 고찰 1

Spring Security를 활용한 로그인 방식에 의존성이 생긴다?

 사실 작업의 과정은 어렵지 않았고, 오래 걸리는 것도 아니었다. 하지만 바꾸고 보니 드는 생각이 있었다.

 이런 식으로 로그인에 성공한 Principal 객체를 service logic에서 활용한다면, 이와 같은 로그인 방식을 사용하는 사이트에 한정되는 개발 방식이라는 생각이다. 이 상황에 만약 로그인 방식이 바뀌기라도 한다면? 그럼 활용했던 UserDetails 인 SpUser 객체와 관련된 모든 항목에 대한 수정 작업이 동반될 것이다.

 

 그럼 로그인에 사용되는 UserDetails인 SpUser 외에, 따로 Member에 대한 Entity를 만들고 이를 table로 해야하는 것일까? 회원 가입 시에는 이 Member에 data를 담아 save 하여 table에 저장하고, 로그인 시에는 로그인 과정에 SpUserService의 loadUserByUsername method에서 member table로부터 data를 받아 UserDetails에 넣어 return 해주고, 게시글을 작성할 때는 SpUser가 아니라 Member data와 연관 관계를 맺도록 하여 service layer에 UserDetails 객체를 넘기지 않도록 하여 Spring Security의 로그인 방식에 의존하지 않을 수 있게 되는 것이 아닐까?


 위 내용은 지난 게시글의 마지막에 작성해둔 고찰이다. 현재 필자는 Spring Security를 활용하여 사이트에 로그인 하도록 작업해두었고, 그 과정에서 활용되는 UserDetails 객체를 implements 한 SpUser 객체를 만들어 작업했다. 물론 Spring Security의 다른 방식을 활용할 때도 이 UserDetails 객체가 활용되는 것이라고 알고는 있지만, 이때 만약 Spring Security가 아닌 다른 로그인 방식을 사용하게 된다면 어떨지 상상해본 것이다.

 

 참 여러 측면에서 '의존성' 이라는 말을 많이 보게 된다. 혼자 이리저리 사투하며서 우연히 본 게시글에서도 '특정 기술에 의존되도록 개발하는 것은 유지 및 보수 측면에서도 좋지 않은 방식'이라고 언급하고 있었는데, 필자의 게시글 중에서도 한 번 언급한 적이 있다.

 의존성 측면에서 보았을 때, 필자가 Spring Security를 활용해 로그인 기능을 구현하면서 사용되는 SpUser라는 친구가 로그인 과정에만 사용되는 것이라면 사실 큰 문제가 아니라고 생각한다. 로그인 과정에서 사용되는 UserDetailsService 에서만 활용되는 것이기에, 혹시라도 로그인을 Spring Security가 아닌 다른 것으로 구현하려고 했을 때는 이것만 바꿔주면 되는 상황이라고 할 수 있다.

 하지만 SpUser가 다른 service logic에서 활용된다면 어떨까? 그리고 상당히 여러 항목에서 활용되고 있다면 어떨까? 이 상황에서 극단적으로 Spring Security를 사용하지 않고 다른 방식을 활용하기로 결정했다면, 갑자기 로그인과 관련된 logic만 바꾸는 것이 아니라 관련된 service logic을 모두 수정해주어야 하는 상황에 직면하게 될 것이다.

 

 사실 이와 비슷하게 느껴지는 상황이 이전에도 여러 차례 있었다. Spring Security를 처음 공부하고 그것을 활용해서 로그인 과정을 구현하려고 했을 때, 필자는 SpUser 그대로를 활용해서 client로부터 data를 받으려고 했었다. 그러면서 SpUser에 그대로 Validation 의 의무를 더해주었고, 그 과정에서 encoding 된 data가 validation에 걸리며 Entity와 Dto로 구분하여 역할을 나누었던 것이 대표적인 사례라고 할 수 있다.

 더하여 SpUser는 UserDetails 객체로 로그인에 활용되는 친구인데, 이 친구를 그대로 service logic에 활용하려고 한다면 로그인 이외에 또다른 역할을 부여하는 것이라는 생각이 든다.

 

 

작업 아이디어

 필자의 아이디어는 아니다. 필자가 지인에게 관련 자문을 구했을 때, 지인은 그래서 UserDetails 외에 따로 Member Entity를 만든다고 말했다. 필자는 이에 대해서 다음과 같이 생각했다.

  • 따로 Member 객체를 만든다면 로그인 과정의 UserDetailsService 의 loadUserByUsername method에서 Member Entity로 우선 DB의 data를 받아서 UserDetails인 SpUser에 data를 전달하여 return 한다.
  • 사용자의 정보가 Service logic에서 사용되어야 한다면, UserDetails가 아닌 Member를 활용하는 것으로 그 의존성을 덜어낼 수 있다.

 

 대신 이를 통해서 수정해야 하는 것들은 정말 많아진다. 가장 큰 것은 기존에 SpUser와 연관 관계를 맺고 있던 모든 항목에 대한 수정이 이루어져야 한다. 게시글에 해당하는 Recipe, 댓글에 해당하는 Comment에 대한 수정을 해야하는데, 필자의 작업 의도는 로그인 후 인증된 Authentication Principal을 활용해서 작성자의 id를 각 Entity에 넣어 save 하는 것이라 의도와는 조금 달라지게 된다.

 

작업

Entity 변경

Member.java / Recipe.java, Comment.java

package com.example.recipository.domain;

import com.example.recipository.dto.UserDto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Entity
@Table(name = "member")
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;
    private String email;
    private String name;
    private String password;
    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private Set<SpAuthority> authorities;

    private boolean enabled;

    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<Recipe> recipeList;
    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<Recipe> commentList;

    public UserDto toDto(){
        return UserDto.builder()
                .email(email)
                .name(name)
                .build();
    }

    public void addAuthority(){
        SpAuthority spAuthority = SpAuthority.builder()
                .member(this)
                .authority("ROLE_USER")
                .build();
        HashSet<SpAuthority> authorities = new HashSet<>();
        authorities.add(spAuthority);

        this.authorities = authorities;
    }

    public SpUser toUserDetails(){
        return SpUser.builder()
                .userId(userId)
                .email(email)
                .name(name)
                .password(password)
                .authorities(authorities)
                .enabled(enabled)
                .build();
    }

    public void updateName(UserDto userDto){
        name = userDto.getName();
    }

    public void updatePassword(String password){
        this.password = password;
    }
}

 첫 번째 이미지는 Member 이고, 두 번째 이미지는 붉은 선을 기준으로 위 아래로 각각 Recipe와 Comment 이다. 기존에 UserDetails 역할을 하던 SpUser의 내용들을 새로 만든 Member에 동일하게 가져오고, 연관 관계를 맺고 있던 @OneToMany의 mappedBy 속성을 member로 변경해주었다. 이어서 연관 관계를 맺고 있던 Recipe와 Comment에서 field의 type을 Member로 변경하고, 그와 관련된 DTO나 method에서 사용되는 인자의 type과 이름을 변경해주었다.

 

UserRepository 변경

UserRepository.java

package com.example.recipository.repository;

import com.example.recipository.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<Member, Long> {
    boolean existsByEmail(String email);
    boolean existsByName(String name);
    Optional<Member> findMemberByEmail(String email);
    Member getMemberByEmail(String email);
}

 기존에 UserDetails인 SpUser에 대한 Repository를 Member로 변경해주었다.

 

회원가입 변경

UserServiceImpl.java

    @Transactional
    @Override
    public boolean signin(UserDto userDto) {
        try{
            // 비밀번호 encoding
            Member member = userDto.toEntity();

            member.addAuthority();
            userRepository.save(member);

            return true;
        } catch (Exception e){
            return false;
        }
    }

 회원가입에 대한 service logic이 상당히 간소화 된 것을 확인할 수 있지만, 이것은 보이기만 그렇고, 사실 비밀번호를 encoding 하여 Member Entity에 setting 하는 method를 UserDto에 새롭게 만들어주었기 때문에 겉으로 보이기에만 그런 것이다. 가장 크게 다른 점이라고 한다면 기존에 권한을 추가해주기 위해 가져왔던 addAuthority method를 사용하지 않도록 변경했다는 것이다.

 

 이것은 사실 원래도 가능했던 것으로 보이지만, 기존에 있었던 사용자(SpUser)와 그 권한(SpAuthority)에 대한 관계는 단방향 연관 관계에 foreign key를 SpUSer 객체에서 갖도록 설정한 부분이 table과는 달라서 올바르게 양방향 연관 관계를 먼저 맺어주었다.

 

 그리고 기존에 없었던 가입하려는 사용자의 정보에 권한 정보를 추가해주는 method를 Member에 추가해주었다. Logic 자체는 기존에 service method로 만든 것과 다르지 않지만, 이번에는 양방향 연관 관계를 형성한 만큼 자기 자신(Member)을 SpAuthority의 member에 setting 한다는 점이 다르다. 이 과정을 통해서 Member 만 save 하더라도 cascade 속성으로 인해 그와 연관 관계가 있는 SpAuthority 까지 save가 되므로 이전처럼 두 번의 save를 할 필요도 없어진다.

 

 

로그인 변경

SpUserService.java

    @Transactional
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> member = userRepository.findMemberByEmail(username);
        if(member.isEmpty()){
            throw new UsernameNotFoundException(username);
        }
        Set<SpAuthority> authorities = member.get().getAuthorities();
        System.out.println(authorities.size());

        SpUser spUser = member.get().toUserDetails();

        return spUser;
    }

 이전 게시글에서 SpUser와 SpAuthority 사이에 fetch type을 EAGER에서 LAZY로 바꾸면서 @Transactional을 추가해주는 작업을 했는데, UserDetails의 역할을 하는 SpUser와 사용자의 정보를 저장하는 Member로 역할을 나누었기 때문에 SpUserService의 loadUserByUsername 을 다시 한 번 수정해야 한다.

 우선 type은 다르지만 DB로부터 data를 가져오는 것은 동일하다. 그리고 이것을 바로 return 하는 것이 아니라 UserDetails의 역할을 해야하는 SpUser로 변환시켜서 return 해야 한다. 그래서 왼쪽과 같이 변환 method를 Member에 만들어주는 것으로 해결했다.

 

Service method에서 UserDetails 분리

 이것이 이번 게시글에서의 가장 중요한 부분이다.

 

 대표적으로 게시글을 작성하는 request에 대한 controller의 method이다. 가장 처음에는 @AuthenticationPrincipal annotation을 통해 얻어온 현재 인증된 사용자의 Principal로부터 사용자의 이름을 service method에 전달해서 사용했다. 그 이후에는 사용자인 SpUser와 게시글인 Recipe Entity 사이에 양방향 연관 관계를 형성하여 Principal 객체를 그대로 전달해서 사용했다.

 하지만 이제는 Principal인 SpUser 그대로가 아니라, SpUser 대신에 Entity로 활용될 Member Entity를 만들어서 SpUser를 Member로 변환하여 service method에 전달하는 방식으로 변경한 것이다. Service method에서도 기존에 SpUser를 활용하던 방식에서 Member를 활용하는 방식으로 변경해주었다.

 

 

 이런 작업이 되어있는 부분은 예시로 들었던 게시글을 작성하는 method와 댓글을 작성하는 method, 그리고 마이 페이지로 이동할 때 사용자가 작성한 게시글 List 를 불러오는 method의 세 군데이다. 지금은 당장 세 군데에 불과했음에도 꽤나 여러 class에서 SpUser를 Member로 변경해주는 작업이 많았다. 만약 이와 유사한 작업이 되었는 부분이 더 많았다면, 훨씬 더 많은 작업이 이루어졌어야 할 것이다.

 

 상황은 국비 과정 프로젝트를 리펙토링 하는 과정에서 Http 인자를 service method로부터 분리하는 것과 유사해보여서 작업한 것이지만, 이게 정말 최선의 방법이었을지는 명확하게 판단이 서지 않는다.