본문 바로가기

Study/개발일지

[백엔드온라인TIL] java 학습 36일차

안녕하세요. 오늘은 스프링 시큐리티를 활용하면서 궁금했던 부분을 공부해보았습니다. 

스프링 시큐리티는 SecurityContext에 인증된 Authentication 객체를 넣어두고 현재 스레드 내에서 공유되도록 관리하고 있는데요.

아래는 SecurityContext 인터페이스에 기재된 주석의 일부를 발췌했습니다.
(SecurirtContext : Interface defining the minimum security information associated with the current thread of execution.)

인증이후 편의적으로 현재 인증된 세션유저를 가져오기 위해 @AuthenticationPrincipal 어노테이션을 통해 UserDetails 인터페이스를 구현한 유저 객체를
주입할 때 사용하는 편입니다.

너무 편하게 사용했던지라 어떻게 동작하는 지에 대해서 직접 스프링 시큐리티 소스를 까보고 디버깅을 통해 공부해보고 싶었습니다.

이번 포스팅에서 이러한 내용을 공유하고자 합니다.




우선 해당 내용을 쉽게 보기 위해서는 Spring Security에 대한 선수 지식이 있다는 전제하에 기본적인 내용은 설명없이 넘어가고자 합니다.

스프링 시큐리티에 대한 선수지식은 해당 포스팅의 내용을 참조하시어 이번에 스프링 시큐리티에 대한 공부도 함께 해보시는 것을 추천드립니다.

 

sas-study.tistory.com/356

 

Restful API 구현을 위한 Spring Security 설정해보기 #0 (스펙정의, 사전 작업 등)

안녕하세요. 오늘은 스프링 프로젝트들 중에서 단연 최고 난이도로 알려져 있는 Spring Security 설정해보기 시리즈 중 첫번째 단계인 스펙정의 및 사전작업에 대해서 설명해드리고자 합니다. 제목

sas-study.tistory.com

 




우선 알고가야하는 클래스와 인터페이스가 존재합니다.

SecurityContextHolder : 클래스. 특정 전략에 따라 Security Context 영역을 관리하는 인터페이스입니다. 해당 클래스가 관리하는 SecurityContext 객체는 getter로 접근이 가능하다.(getContext() 메소드) 이는 현재 스레드 기준의SecurityContext를 반환한다.

SecurityContext : 인터페이스. Security Context 인터페이스. Authentication에 대한 접근 getter를 정의해놓음.

SecurityContextImpl : 클래스. SecurityContext 인터페이스를 구현한 객체. Authentication 객체에 대한 getter/setter를 정의해 놓은 객체. 해당 구현체를 통해 내부적으로 현재 스레드의(아마 1번의 request 요청일 것으로 생각되지만..) Security Context 를 생성하여 인증 후 Authentication 객체를 넣어놓는 역할을 한다.

Authentication : 인증 정보에 대한 부분을 정의해놓은 인터페이스. Principal과 Credentials, Authorities 에 대한 정의가 되어있다. 여러 구현체가 있지만 제가 썻던 스프링 시큐리티 내용에서는 UsernamePasswordAuthenticationToken 클래스를 활용했었습니다.
 - principal의 의미는 "인증되는 주체의 ID" 이고, 

 - Credentials 은 "주체가 정확한지 증명하는 것"

 - Authorities는 아시다시피 "권한"의 내용입니다.

UserDetails : 사용자 정보(username, password 등)을 가지는 인터페이스. 이를 구현하여 실제 로그인에 사용할 클래스를 만들면 된다. 스프링 시큐리티 내부적으로 직접 사용하지는 않고 Authentication 으로 캡슐화하여 저장된다. 따라서 UserDetails 구현체의 정보는 Spring Security Context에 저장된 Authentication 객체가 가져간다고 볼 수 있다.

HandlerMethodArgumentResolver : 인터페이스. 특정 전략에 따라 한 request에서 넘어온 인자들을 메소드 파라미터로 해석할 수 있도록 도와줌. 

AuthenticationPrincipalArgumentResolver : 스프링 시큐리티에서 HandlerMethodArgumentResolver 를 구현한 구현체로 @AuthenticationPrincipal 어노테이션이 실제로 사용되는 부분.




실제 스프링 시큐리티 내부 코드를 보자!!

사실 클래스 설명에서 거의 대부분의 내용을 말한 것 같은데. 대부분 주석에 기반한 내용이다. 따라서 직접 내부 코드를 열어보고 확인하는 시간을 가져보려고
한다.

현재 스레드가 세션을 물고있고 있다면 이미 SecurityContextHolder의 getContext() 메소드를 통해 SecurityContext 객체를 얻고 그 안의 getAuthentication() 메소드를 통해 Authentication (인증객체)를 얻게 될 것입니다. 

그렇다면 해당 컨트롤러 메소드의 @AuthenticationPrincipal 어노테이션은 실제 어떻게 동작하고 있을까??

@PostMapping(value = "/logout", 
             consumes = MediaTypes.HAL_JSON_VALUE, 
             produces = MediaTypes.HAL_JSON_VALUE)
public Object logout(@AuthenticationPrincipal SecurityUser securityUser, 
                     @RequestHeader("Authorization") String authorization) throws Exception {
    // 컨트롤러 로직
}

 

일단 메소드의 파라미터 영역에 Request 파라미터로 넘어온 인자를 바인딩하는 부분이 가장 의심스러워서 method argument resolve 역할을 하는 인터페이스를 구현한 부분이 Spring Security 클래스에 존재하는지를 확인해보았다.

 

결론적으로 말하면 우선 Spring Security를 활용하는 경우,

 

AuthenticationPrincipalArgumentResolver 클래스를 활용하여 resolveArgument 메소드를 구현하고 SecurityContext에 저장된 인증객체를 기반으로 Authentication 객체를 꺼내오게 된다. 아래는 실제 구현 메소드 부분이다.

 

컨트롤러의 파라미터마다 해당 resolveArgument 메소드가 실행되고 다음 1~ 3의 과정을 거쳐 파라미터에 주입해주게된다.

 

이는 현재 스레드에 Security Context에 저장된 Authentication 객체가 존재하는지에 따라 실행되므로 존재하지 않는다면 구현 메소드 내용과 같이 해당부분은 null로 바인딩 될것이다.

 

해당부분의 return 문이 도달하는 데에는 Security Context 에 인증정보가 저장되어있다는 전제하이고, 이는 곧 사용자는 로그인하였다는 전제하이다. 또한 개발자가 @AuthenticationPrincipal 어노테이션을 활용하여 인증주체의 ID를 주입한다는 개발내용이 있었다는 것이다.

 


 

@AuthenticationPrincipal 어노테이션을 통해 어떻게 인증정보(세션정보)를 가진 객체가 바인딩되는지를 살펴본 부분이었는데 사실 이 부분만 본다고해서 전체적인 그림을 그리기는 어렵다.

 

예를 들어, 세션을 사용하는 방식과 jwt 같은 토큰을 사용하는 방식에 따라서 시큐리티 설정과 처리방식이 매우 다양한 로직을 띄는데..

 

간단히 살펴보면 세션을 사용한다면 브라우저의 JSESSIONID를 활용하여 사용자 세션정보를 구분할 수 있으므로 특별히 요청 전에 세션정보만 잘 가져온다면 추가적인 처리가 덜하겠지만 JWT 같은 토큰을 활용한다면 요청된 요청마다의 토큰 정보(곧 세션정보가 될)를 읽어 매번 인증을 진행할 것이다. 따라서 매 요청마다 Filter를 활용하여 SecurityContext에 요청마다 인증되는 Authentication 객체를 set 시킬 것이고 이후에 Controller 에서 @AuthenticationPrincipal을 활용하여 가져올 수 있는 부분이다.

 

또한 principal 을 인증주체의 ID라고 정의하였으니 인증객체의 principal에 로그인 ID를 저장하고 @AuthenticationPrincipal로 Principal을 불러올 때, String loginId와 같이 유니크한 ID 값을 주입받아 해당 아이디를 조회하여 처리하는 부분이 있을 것이고,

 

비슷하게 principal의 인증주체의 id(혹은 데이터베이스 pk)를 hash 혹은 equals의 key로 설정하여 Unique 특성이 보장된다는 전제하에 principal에 UserDetails 구현 객체(사용자 인증정보)를 담아 직접 객체를 주입하여 처리할 수도 있는 부분이기 때문에 매우 다양하게 사용된다.

 


 

단편적으로 @AuthenticationPrincipal 어노테이션이 동작하는 부분만 보려고 하였으나 결과적으로 모든 부분을 뜯어보게 되었다.

 

Spring Security를 뜯어보는 것은 거의 처음인데 많은 공부가 되었다. @AuthenticationPrincipal 어노테이션이 어떤식으로 동작하는지는 사실 어느정도 감이 잡히긴 했던 부분이라 정확히 확인하는 수준이었지만 좀더 안단에서 어떻게 인증정보가 저장되고 스레드와는 어떤 부분이 있는지 왜 이렇게 많은 인터페이스와 구현체들의 조합으로 개발된건지 느낌을 받을 수 있었다. 

 

실제 많이 사용되는 스프링 프레임워크의 어노테이션이 어떻게 동작하고 있는지 앞으로 많은 부분을 뜯어보게 될 것같다.

 

 

Section 1. 영속성 전이 (CASCADE)

1. 영속성 전이 (CASCADE)

" cascade : 폭포, 폭포처럼 흐르다 " 라는 사전적 의미를 바탕으로 엔티티의 상태를 변경할 때 해당 엔티티와 연관된 엔티티의 상태 변화를 전파시키는 옵션

  • 부모는 One 에 해당하는 Entity
  • 자식은 Many 에 해당하는 Entity
  • 부모 Entity 가 존재해야 자식 Entity 가 부모를 참조하며 생성될 수 있음




2. CASCADE 종류

CASCADE설명
PERSIST 부모가 영속화될 때 자식도 영속화
MERGE 부모가 병합될 때 자식도 병합
REMOVE 부모가 삭제될 때 자식도 삭제
REFRESH 부모가 refresh 되면 자식도 refresh
DETACH 부모가 detach 되면 자식도 detach
ALL 부모의 상태 변화를 자식에게 모두 전이




3. 주문 영속성 전이

  • 부모인 Order Entity 가 저장될 때, 자식인 OrderItem 또한 저장되는 영속성 전이

  • OrderRepository 생성

  • 부모 Order Entity CASCADE 지정




4. 주문 영속성 전이 테스트

  • 주문 영속성 전이 테스트 클래스 생성 및 DI

  • 주문 영속성 전이 테스트
  • Order 객체 저장 시, 참조되는 Order_Item 객체 저장

  • 3개의 Item 생성

  • orders 생성

  • order_item 생성

  • order 객체 조회



Section 2. 고아객체 (ORPHAN)

1. 고아객체 (ORPHAN)

부모 엔티티와 연관 관계가 끊어진 자식 엔티티

  • 자식의 엔티티를 다른 엔티티가 참조하고 있다면 제거 X




2. 고아객체 제거

  • 부모 Entity 에 orphanRemoval = true 설정




3. 고아객체 제거 테스트

  • 부모 Entity 인 Order 객체 생성

  • 고아 객체 제거
  • Order 객체가 관리하는 OrderItem 리스트에서 0번째 요소의 id를 추출
  • 0번째 요소를 제거한 뒤, OrderItem 조회 (id 이용)
  • 조회 결과 Optional.empty() 라면 제대로 제거된 것이므로 테스트 통과

  • order_item delete




4. CASCADE.REMOVE   vs   고아객체 제거

  • CASCADE.REMOVE - 부모 Entity 가 삭제될 때 같이 삭제되는 것
  • 고아객체 제거 - 부모 Entity 와의 연관관계가 끊어질 때 삭제되는 것

JPA를 공부하다 보면 바로 이해하기 쉽지 않은 개념들을 몇 개 마주친다. 필자는 연관관계 매핑, 영속성 전이, 고아 객체 등이 특히 어려웠다. 이때 CascadeType.REMOVE와 orphanRemoval = true가 유독 헷갈렸는데, 직접 학습 테스트를 작성하며 이해했다.

이번 글에서는 영속성 전이(REMOVE)와 고아 객체를 학습 테스트를 통해 비교하여 살펴본다. 최종적으로 독자들이 둘의 차이를 이해하는 것을 목표로 한다.


엔티티 기본 세팅

Team과 Member 엔티티를 바탕으로 두 개념의 공통점과 차이점을 알아본다. Team은 @OneToMany, Member는 @ManyToOne으로 양방향 매핑을 했다.

// Team.java
@Entity
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();

    public Team() {
    }
}

// Member.java
@Entity
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn
    private Team team;

    public Member() {
    }
}

학습 테스트를 조금 더 편하게 작성하기 위해 Team에 연관관계 편의 메소드 addMember()를 추가했다.

// Team.java
@Entity
public class Team {

    public void addMember(Member member) {
        members.add(member);
        member.setTeam(this);
    }
}

학습 테스트 기본 세팅

학습 테스트는 @DataJpaTest로 진행했다. 테스트에 필요한 TeamRepository와 MemberRepository를 각각 DI했다.

// JpaLearningTest.java
@DataJpaTest
public class JpaLearningTest {

    @Autowired
    private TeamRepository teamRepository;

    @Autowired
    private MemberRepository memberRepository;
}

CascadeType.REMOVE

CascadeType.REMOVE는 부모 엔티티가 삭제되면 자식 엔티티도 삭제된다. 즉, 부모가 자식의 삭제 생명 주기를 관리한다. 만약 CascadeType.PERSIST도 함께 사용하면, 부모가 자식의 전체 생명 주기를 관리하게 된다.

한편, 이 옵션의 경우에는 부모 엔티티가 자식 엔티티와의 관계를 제거해도 자식 엔티티는 삭제되지 않고 그대로 남아있다.

학습 테스트를 위해 Team 엔티티에 영속성 전이 옵션을 추가한다.

// Team.java
@Entity
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(
        mappedBy = "team",
        fetch = FetchType.LAZY,
        cascade = CascadeType.ALL   // { CascadeType.PERSIST, CascadeType.REMOVE }와 동일하다.
    )
    private List<Member> members = new ArrayList<>();
}

먼저, 부모 엔티티를 삭제하는 경우를 살펴본다.

// JpaLearningTest.java
@DisplayName("CascadeType.REMOVE - 부모 엔티티(Team)을 삭제하는 경우")
@Test
void cascadeType_Remove_InCaseOfTeamRemoval() {
    // given
    Member member1 = new Member();
    Member member2 = new Member();

    Team team = new Team();

    team.addMember(member1);
    team.addMember(member2);

    teamRepository.save(team);

    // when
    teamRepository.delete(team);

    // then
    List<Team> teams = teamRepository.findAll();
    List<Member> members = memberRepository.findAll();

    assertThat(teams).hasSize(0);
    assertThat(members).hasSize(0);
}

delete 쿼리가 총 3번 나가는 걸 확인할 수 있다. 즉, Team(부모)가 삭제될 때 Member(자식)도 영속성 전이 옵션으로 인해 함께 삭제된다.

// DML
Hibernate: 
    insert 
    into
        team
        (id, name) 
    values
        (null, ?)
Hibernate: 
    insert 
    into
        member
        (id, name, team_id) 
    values
        (null, ?, ?)
Hibernate: 
    insert 
    into
        member
        (id, name, team_id) 
    values
        (null, ?, ?)

Hibernate: 
    delete 
    from
        member 
    where
        id=?
Hibernate: 
    delete 
    from
        member 
    where
        id=?
Hibernate: 
    delete 
    from
        team 
    where
        id=?

다음으로, 부모 엔티티에서 자식 엔티티를 제거하는 경우를 알아본다.

// JpaLearningTest.java
@DisplayName("CascadeType.REMOVE - 부모 엔티티(Team)에서 자식 엔티티(Member)를 제거하는 경우")
@Test
void cascadeType_Remove_InCaseOfMemberRemovalFromTeam() {
    // given
    Member member1 = new Member();
    Member member2 = new Member();

    Team team = new Team();

    team.addMember(member1);
    team.addMember(member2);

    teamRepository.save(team);

    // when
    team.getMembers().remove(0);

    // then
    List<Team> teams = teamRepository.findAll();
    List<Member> members = memberRepository.findAll();

    assertThat(teams).hasSize(1);
    assertThat(members).hasSize(2);
}

delete 쿼리가 전혀 나가지 않는다. 영속성 전이 삭제 옵션은 부모와 자식의 관계가 끊어졌다 해서 자식을 삭제하지 않기 때문이다.

// DML
Hibernate: 
    insert 
    into
        team
        (id, name) 
    values
        (null, ?)
Hibernate: 
    insert 
    into
        member
        (id, name, team_id) 
    values
        (null, ?, ?)
Hibernate: 
    insert 
    into
        member
        (id, name, team_id) 
    values
        (null, ?, ?)

orphanRemoval = true

orphanRemoval = true 또한 부모 엔티티가 삭제되면 자식 엔티티도 삭제된다. 따라서 CascadeType.PERSIST를 함께 사용하면, 이때도 부모가 자식의 전체 생명 주기를 관리하게 된다.

한편, 이 옵션의 경우에는 부모 엔티티가 자식 엔티티의 관계를 제거하면 자식은 고아로 취급되어 그대로 사라진다.

이번에는 학습 테스트를 위해 Team 엔티티에 고아 객체 옵션을 추가한다.

// Team.java
@Entity
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(
        mappedBy = "team",
        fetch = FetchType.LAZY,
        cascade = CascadeType.PERSIST,
        orphanRemoval = true
    )
    private List<Member> members = new ArrayList<>();
}

이전과 동일하게 부모 엔티티를 삭제하는 경우를 살펴본다.

// JpaLearningTest.java
@DisplayName("orphanRemoval = true - 부모 엔티티(Team)을 삭제하는 경우")
@Test
void orphanRemoval_True_InCaseOfTeamRemoval() {
    // given
    Member member1 = new Member();
    Member member2 = new Member();

    Team team = new Team();

    team.addMember(member1);
    team.addMember(member2);

    teamRepository.save(team);

    // when
    teamRepository.delete(team);

    // then
    List<Team> teams = teamRepository.findAll();
    List<Member> members = memberRepository.findAll();

    assertThat(teams).hasSize(0);
    assertThat(members).hasSize(0);
}

이때도 delete 쿼리가 총 3번 나가는 걸 확인할 수 있다. 즉, Team(부모)가 삭제될 때 Member(자식)도 고아 객체 옵션으로 인해 같이 삭제된다.

// DML
Hibernate: 
    insert 
    into
        team
        (id, name) 
    values
        (null, ?)
Hibernate: 
    insert 
    into
        member
        (id, name, team_id) 
    values
        (null, ?, ?)
Hibernate: 
    insert 
    into
        member
        (id, name, team_id) 
    values
        (null, ?, ?)

Hibernate: 
    delete 
    from
        member 
    where
        id=?
Hibernate: 
    delete 
    from
        member 
    where
        id=?
Hibernate: 
    delete 
    from
        team 
    where
        id=?

학습 테스트로 부모 엔티티를 삭제할 때는 CascadeType.REMOVE와 orphanRemoval = true가 동일하게 동작하는 것을 이해했다.

그렇다면, 부모 엔티티에서 자식 엔티티를 제거할 때는 어떤 결과를 나타낼까?

// JpaLearningTest.java
@DisplayName("orphanRemoval = true - 부모 엔티티(Team)에서 자식 엔티티(Member)를 제거하는 경우")
@Test
void orphanRemoval_True_InCaseOfMemberRemovalFromTeam() {
    // given
    Member member1 = new Member();
    Member member2 = new Member();

    Team team = new Team();

    team.addMember(member1);
    team.addMember(member2);

    teamRepository.save(team);

    // when
    team.getMembers().remove(0);

    // then
    List<Team> teams = teamRepository.findAll();
    List<Member> members = memberRepository.findAll();

    assertThat(teams).hasSize(1);
    assertThat(members).hasSize(1);
}

이전과는 다르게 delete 쿼리가 1번 나간다. 고아 객체 옵션은 부모와 자식의 관계가 끊어지면 자식을 고아로 취급하고 자식을 삭제하기 때문이다.

// DML
Hibernate: 
    insert 
    into
        team
        (id, name) 
    values
        (null, ?)
Hibernate: 
    insert 
    into
        member
        (id, name, team_id) 
    values
        (null, ?, ?)
Hibernate: 
    insert 
    into
        member
        (id, name, team_id) 
    values
        (null, ?, ?)

Hibernate: 
    select
        team0_.id as id1_1_,
        team0_.name as name2_1_ 
    from
        team team0_

Hibernate: 
    delete 
    from
        member 
    where
        id=?

비교 결과

  • 부모 엔티티 삭제
    • CascadeType.REMOVE와 orphanRemoval = true는 부모 엔티티를 삭제하면 자식 엔티티도 삭제한다.
  • 부모 엔티티에서 자식 엔티티 제거
    • CascadeType.REMOVE는 자식 엔티티가 그대로 남아있는 반면, orphanRemoval = true는 자식 엔티티를 제거한다.

주의점

두 케이스 모두 자식 엔티티에 딱 하나의 부모 엔티티가 연관되어 있는 경우에만 사용해야 한다.

예를 들어 Member(자식)을 Team(부모)도 알고 Parent(부모)도 알고 있다면, CascadeType.REMOVE 또는 orphanRemoval = true를 조심할 필요가 있다. 자식 엔티티를 삭제할 상황이 아닌데도 어느 한쪽의 부모 엔티티를 삭제했거나 부모 엔티티로부터 제거됐다고 자식이 삭제되는 불상사가 일어날 수 있기 때문이다.

그러므로 @OneToMany에서 활용할 때 주의를 기울이고, @ManyToMany에서는 활용을 지양하자.

 
728x90