즉시로딩과 지연로딩

이제 JPA에서 연관관계를 조회할때 가장 중요한 글로벌 패치 전략인 즉시로딩(EAGER)지연로딩(LAZY)에 대해서 살펴보겠습니다.

Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색
System.out.println(team.getName()); // 팀 엔티티 사용

위와 같이 회원 엔티티를 조회할때 조회 시점을 선택할 수 있도록 다음 두 가지 방법을 제공합니다.

  • 즉시 로딩: 엔티티를 조회할 때 연관된 엔티티도 함께 조회합니다.
    em.find(Member.class, "member1")를 호출할 때 회원 엔티티와 연관된 팀 엔티티도 함께 조회합니다.
    설정방법: @ManyToOne(fetch = FetchType.EAGER)
  • 지연 로딩: 연관된 엔티티를 실제 사용할 때 조회합니다.
    member.getTeam().getName(()처럼 조회한 팀 엔티티를 실제 사용하는 시점에 JPA가 SQL을 호출해서 팀 엔티티를 조회합니다.
    설정방법: @ManyToOne(fetch = FetchType.LAZY)

즉시로딩

즉시 로딩을 사용하려면 @ManyToOne의 fetch 속성을 FetchType.EAGER로 지정합니다.

@Entity
public class Member {

    @Id
    @Column(name = "member_id")
    private String id;

    private String name;

    private int age;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "team_id")
    private Team team;
}

//즉시 로딩 실행 코드
Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색

위의 예제코드에서는 회원과 팀을 즉시 로딩으로 설정했습니다. 따라서 em.find(Member.class, "member1")로 회원을 조회하는 순간 팀도 함께 조회합니다. 이때 회원과 팀 두 테이블을 조회해야 하므로 쿼리를 2번 수행할 것 같지만, 대부분의 JPA 구현체는 즉시 로딩을 최적화 하기 위해 가능하면 조인 쿼리를 사용합니다. 여기서는 회원과 팀을 조인해서 쿼리 한번으로 두 엔티티를 모두 조회합니다.

즉시로딩(fetch = FetchType.EAGER)

스크린샷 2019-11-21 오전 2 52 39

실제로 JPA가 left outer join 쿼리문을 날려서 회원과 팀을 한 번으로 조회한 것을 알 수 있습니다.
이후 member.getTeam()을 호출하면 이미 로딩된 팀1 엔티티를 반환합니다.

참고

NULL 제약조건과 JPA 전략
즉시 로딩 실행 SQL에서 JPA가 내부 조인이 아닌 외부 조인을 사용하고 있습니다. 그 이유는 현재 회원 테이블에서 TEAM_ID 외래 키는 NULL 값을 허용하고 있습니다. 따라서 팀에 소속되지 않는 회원이 존재할 가능성이 있기 때문에 팀에 소속되지 않는 회원과 팀을 내부조인하면 팀은 물론이고 회원 데이터도 조회할 수 없습니다. JPA는 이런 상황을 고려해서 외부 조인을 사용합니다. 하지만 성능상으로 외부조인보다 내부조인이 최적화에 더 유리합니다. 따라서 외래키에 NOT NULL 제약 조건을 설정하면 값이 있는 것을 보장하기 때문에 이런 경우에는 내부조인만 사용해도 됩니다.

ex)

@Entity
public class Member{

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "TEAM_ID", nullable = false)
    private Team team;
    //...
}

nullable 설정에 따른 조인 전략

  • @JoinColumn(nullable = true): NULL 허용(기본값), 외부 조인 사용
  • @JoinColumn(nullable = false): NULL 허용하지 않음, 내부 조인 사용

또는 아래 코드 처럼 내부 조인을 사용할 수 있습니다.

@Entity
public class Member{

    @ManyToOne(fetch = FetchType.EAGER, optional = false)
    @JoinColumn(name = "TEAM_ID", nullable = false)
    private Team team;
    //...
}

JPA는 선택적 관계이면 외부 조인을 사용하고 필수 관계면 내부 조인을 사용합니다.

내부 조인 결과

스크린샷 2019-11-22 오전 1 08 06

지연로딩

지연 로딩을 사용하면 @ManyToOne의 fetch 속성을 FetchType.LAZY로 지정합니다.

// 지연로딩 설정
@Entity
public class Member {

    @Id
    @Column(name = "member_id")
    private String id;

    private String name;

    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}


//지연 로딩 실행 코드
Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색
team.getName(); // 팀 객체 실제 사용

위의 코드에서는 회원과 팀을 지연 로딩으로 설정했습니다. 따라서 예제코드에서 em.find(Member.class, "member1")를 호출하면 회원만 조회하고 팀은 조회하지 않습니다. 대신에 team 맴버변수에 프록시 객체를 넣어 둡니다.

Team team = member.getTeam(); // 프록시 객체 

반환된 팀 객체는 프록시 객체입니다. 이 프록시 객체는 실제 사용될 때까지 데이터 로딩을 미룹니다. 그래서 지연로딩이라고 합니다.

team.getName() // 팀 객체 실제 사용

위의 코드처럼 실제 데이터가 필요한 순간이 되어서야 데이터베이스를 조회해서 프록시 객체를 초기화 합니다.

em.find(Member.class, "member1") 호출 시 실행되는 SQL은 다음과 같습니다.

SELECT * FROM MEMBER WHERE MEMBER_ID='member1';

team.getName() 호출로 프록시 객체가 초기화되면서 실행되는 SQL은 다음과 같습니다.

SELECT * FROM TEAM WHERE TEAM_ID='team1';

참고로 조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체를 사용할 이유가 없습니다. 따라서 프록시가 아닌 실제 객체를 사용합니다.

즉시로딩과 지연로딩 중에 어느것이 더 효율적인가에 대해서는 아무래도 프로젝트 상황에 따라 다를거 같습니다. 애플리케이션 로직에서 회원과 팀 엔티티를 같이 사용한다고 하면 SQL 조인을 사용해서 회원과 팀 엔티티를 한번에 조회하는 것이 더 효율적입니다. 페이스북에서 댓글 더보기 기능처럼 실제로 조회가 필요할때 게시물과 연관된 댓글을 조회하는것도 대표적인 지연로딩 방식입니다.

지연 로딩 활용

사내 주문 관리 시스템을 개발한다고 가정하겠습니다.

스크린샷 2019-11-22 오전 1 43 29

  • 회원은 팀 하나에만 소속할 수 있습니다. (N:1)
  • 회원은 여러 주문내역을 가집니다. (1:N)
  • 주문내역은 상품정보를 가집니다. (N:1)

애플리케이션 로직을 분석해보니 다음과 같았습니다.

  • Member와 연관된 Team은 자주 함꼐 사용되었습니다. 그래서 Member와 Team은 즉시 로딩으로 설정했습니다.

  • Member와 연관된 Order는 가끔 사용되었습니다. 그래서 Member와 Order는 지연 로딩으로 설정했습니다.

  • Order와 연관된 Product는 자주 함께 사용되었습니다. 그래서 Order와 Product는 즉시 로딩으로 설정했습니다.

회원 엔티티

@Entity
@Setter
@Getter
public class Member {

    @Id
    @Column(name = "member_id")
    private String id;

    private String name;

    private int age;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "team_id")
    private Team team;

    @OneToMany(mappedBy = "member",fetch = FetchType.LAZY)
    List<Order> orders;


}

회원과 팀의 연관관계를 FetchType.EAGER로 설정했습니다. 따라서 회원 엔티티를 조회하면 연관된 팀 엔티티도 즉시 조회합니다.

회원과 주문내역의 연관관계를 FetchType.LAZY로 설정했습니다. 따라서 회원 엔티티를 조회하면 연관된 주문내역 엔티티는 프록시를 조회해서 실제 사용될 때까지 로딩을 지연합니다.

Member member = em.find(Member.class, "member1");

회원 엔티티를 조회하면 아래 그림처럼 엔티티를 로딩합니다.

스크린샷 2019-11-23 오전 12 22 15

회원과 팀은 즉시 로딩으로 설정했습니다. 따라서 회원을 조회할 때 하이버네이트는 조인 쿼리를 만들어 회원과 팀을 한 번에 조회합니다.
반면에 회원과 주문내역은 FetchType.LAZY로 설정했으므로 결과를 프록시로 조회합니다. 따라서 SQL문에 전혀 나타나지 않습니다.
회원을 조회한 후에 member.getTeam()을 호출하면 이미 로딩된 팀 엔티티를 반환합니다.

프록시와 컬렉션 래퍼

하이버네이트는 엔티티를 영속 상태로 만들 때 엔티티에 컬렉션이 있으면 컬렉션을 추적하고 관리할 목적으로 원본 컬렉션을 하이버네이트가 제공하는 내장 컬렉션으로 변경하는데 이것을 컬렉션 래퍼라고 합니다. 출력 결과를 보면 컬렉션 래퍼인 org.hibernate.collection.internal.PersistenceBag 이 반환된 것을 확인할 수 있습니다.

엔티티를 지연 로딩하면 프록시 객체를 사용해서 지연로딩을 수행하지만 주문 내역과 같은 컬렉션은 컬렉션 래퍼가 지연 로딩을 처리해줍니다. 컬랙션 래퍼도 컬렉션에 대한 프록시 역할을 하므로 따로 구분하지 않고 프록시로 부르겠습니다.
참고로 meber.getOrders()를 호출해도 컬렉션은 초기화되지 않습니다. 컬렉션은 member.getOrders().get(0)처럼 컬렉션에 실제 데이터를 조회할 때 데이터베이스를 조회해서 초기화 합니다.

스크린샷 2019-11-23 오전 1 22 06

JPA 기본 페치 전략

fetch 속성의 기본 설정값은 아래와 같습니다.

  • @ManyToOne, @OneToOne: 즉시 로딩(FetchType.EAGER)
  • @OneToMany, @ManyToMany: 지연 로딩(FetchType.LAZY)

가장 쉽게 외우는 방법은 xToOne으로 매핑된 엔티티는 글로벌 페치 전략이 즉시 로딩이라고 생각하면 됩니다. 그러면 나머지 xToMany로 끝나는 어노테이션은 자연스럽게 LAZY로 인식이 됩니다.

JPA 기본 페치 전략은 연관된 엔티티가 하나면 즉시 로딩을, 컬렉션이면 지연로딩을 사용합니다. 컬렉션을 로딩하는 것은 비용이 많이 들고 잘못하면 너무 많은 데이터를 로딩할 수 있기 때문입니다. 예를 들어 특정 회원이 연관된 컬렉션에 데이터를 수만 건 등록했는데, 설정한 페치 전략이 즉시 로딩이면 해당 회원을 로딩하는 순간 수만 건의 데이터도 함께 로딩됩니다. 반면에 연관된 엔티티가 하나면 즉시 로딩해도 큰 문제가 발생하지 않습니다.

이 책에서 가장 추천하는 방법은 모든 연관관계에 지연 로딩을 사용하는 것입니다.
그리고 애플리케이션 개발이 어느 정도 완료단계에 왔을 때 실제 사용하는 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하면 됩니다.

참고로 직접 SQL을 사용하면 이런 유연한 최적화가 어렵습니다. 예를 들어 SQL로 각각의 테이블을 조회해서 처리하다가 조인으로 한 번에 조회하도록 변경하려면 많은 SQL과 애플리케이션 코드를 수정해야 합니다.

컬렉션에 FetchType.EAGER 사용 시 주의점

  • 컬렉션을 하나 이상 즉시 로딩하는 것은 권장하지 않습니다.

컬렉션과 조인한다는 것은 데이터베이스 테이블로 보면 일대다 조인입니다. 일대다 조인은 결과 데이터가 다 쪽에 있는 수만큼 증가하게 됩니다. 문제는 서로 다란 컬렉션을 2개 이상 조인할 때 발생하는데 예를 들어 A 테이블을 N, M 두 테이블과 일대다 조인하면 실행 결과가 N 곱하기 M이 되면서 너무 많은 데이터를 반환할 수 있고 결과적으로 애플리케이션 성능이 저하될 수 있습니다. JPA는 이렇게 조회된 결과를 메모리에서 필터링해서 반환합니다. 따라서 2개 이상의 컬렉션을 즉시 로딩으로 설정하는 것은 권장되지 않습니다.

  • 컬렉션 즉시 로딩은 항상 외부조인을 사용합니다.

예를 들어 다대일 관계인 회원 테이블과 팀 테이블을 조인할 때 회원 테이블의 외래 키에 not null 제약조건을 걸어두면 모든 회원은 팀에 소속되므로 항상 내부 조인을 사용해도 됩니다. 반대로 팀 테이블에서 회원 테이블로 일대다 관계를 조인할 때 회원이 한 명도 없는 팀을 내부 조인하면 팀까지 조회되지 않는 문제가 발생합니다. 데이터베이스 제약조건으로 이런 상황을 막을 수는 없습니다. 따라서 JPA는 일대다 관계를 즉시 로딩할 때 항상 외부 조인을 사용합니다.

FetchType.EAGER 설정과 조인 전략을 정리하면 다음과 같습니다.

@ManyToOne, @OneToOne

- (optional = false): 내부 조인

- (optional = true): 외부 조인

@OneToMany, @ManyToMany

- (optional = false): 외부 조인

- (optional = true): 외부 조인

참고: ORM 표준 JPA 프로그래밍

'SpringFramework > JPA' 카테고리의 다른 글

값 타입  (0) 2019.11.26
영속성 전이 및 고아객체 제거  (0) 2019.11.23
프록시와 연관관계 관리  (0) 2019.11.22
고급매핑 - 조인테이블  (0) 2019.10.30
복합 키와 식별관계 매핑  (0) 2019.10.29

프록시

얼마전에 AOP 관련된 글들을 살펴보다가 프록시를 알게되어서 스프링 AOP에서 프록시가 어떻게 동작하는지에 대해서도 간단히 살펴보았습니다. 하지만 역시 이 프록시라는 녀석은 JPA에서 지연로딩을 할때도 사용하는 아주... 용도가 다양한 녀석이였습니다. 그래서 지연로딩을 공부하면서 다시한번 프록시에 대해서 리뷰해보겠습니다.

JPA에서 프록시는 연관된 객체들을 데이터베이스에서 조회하기 위해서 사용합니다.
프록시를 사용하면 연관된 객체들을 처음부터 데이터베이스에서 조회하는 것이 아니라 실제 사용하는 시점에 데이터베이스에서 조회할 수 있습니다.
하지만 자주 함께 사용되는 객체들은 조인을 사용해서 함께 조회하는 것이 더 효과적입니다. JPA는 즉시로딩과 지연로딩이라는 방법으로 둘을 모두 지원하고 있습니다.

    @Test
    @Transactional
    @Rollback(false)
    public void printUser() throws Exception {

        Team team = new Team();
        team.setId("team1");
        team.setName("팀1");

        teamRepository.save(team);

        Member member = new Member();
        member.setId("member1");
        member.setName("임준영");
        member.setAge(28);

        member.setTeam(team);
        memberRepository.save(member);

     }


     @Test
     @Transactional
     public void printUserAndTeam() throws Exception {

         Member member = memberRepository.findById("member1").get();
         Team team = member.getTeam();

         System.out.println("소속 팀: " + team.getName());
         System.out.println("회원이름: " + member.getName());
      }

즉시로딩(fetch = FetchType.EAGER)

스크린샷 2019-11-21 오전 2 52 39

위의 코드에서 printUserAndTeam() 메소드는 member1 로 회원 엔티티를 찾아서 회원은 물론이고 회원과 연관된 팀의 이름도 출력합니다.

만약 아래와 같은 코드에서 소속팀을 출력하지 않는다고 했을때 회원과 연관된 팀 엔티티까지 데이터베이스에서 함께 조화하는 것은 효율적이지 않아 보입니다.

   @Test
     @Transactional
     public void printUserAndTeam() throws Exception {

         Member member = memberRepository.findById("member1").get();
         System.out.println("회원이름: " + member.getName());
      }

JPA는 이런 문제를 해결하려고 엔티티가 실제 사용될 때까지 데이터베이스 조회를 지연하는 방법을 제공하는데 이것을 지연 로딩이라고 합니다. team.getName()처럼 팀 엔티티의 값을 실제 사용하는 시점에 데이터베이스에서 팀 엔티티에 필요한 데이터를 조회하는 것입니다.

프록시 기초

엔티티를 실제 사용하는 시점까지 데이터베이스 조회를 미루고 싶으면 EntityManager.getReference() 메소드를 사용하면 됩니다.

이 메소드를 호출할 때 JPA는 데이터베이스를 조회하지 않고 실제 엔티티 객체도 생성하지 않습니다. 대신에 데이터베이스 접근을 위임한 프록시 객체를 반환합니다.

스크린샷 2019-11-21 오후 11 13 56

프록시의 특징

아래 그림을 보면 프록시 클래스는 실제 클래스를 상속 받아서 만들어지므로 실제 클래스와 겉 모양이 같습니다. 따라서 사용자 입장에서는 이것이 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 됩니다.

스크린샷 2019-11-21 오후 10 47 53

프록시 객체는 실제 객체에 대한 참조(target)를 보관합니다. 그리고 프록시 객체의 메소드를 호출하면 프록시 객체는 실제 객체의 메소드를 호출합니다.

스크린샷 2019-11-21 오후 11 17 41

프록시 객체의 초기화

프록시 객체는 member.getNam()처럼 실제 사용될 때 데이터베이스를 조회해서 실제 엔티티 객체를 생성하는데 이것을 프록시 객체의 초기화라고 합니다.

//프록시 초기화 예제

Member member = em.getReference(Member.class, "id1");
member.getName(); //1. getName();
// 프록시 클래스 예상 코드
class MemberProxy extends Member{

    Object target = null;

    public String getName(){

        if(target == null){

            //2. 초기화 요청
            //3. DB 조회
            //4. 실제 엔티티 생성 및 참조 보관
            this.target = .....;
        }

        return target.getName();
    }
}

실제 프록시가 어떻게 실제 객체의 메소드를 호출하는지는 모르겠습니다...

프록시 초기화 과정

스크린샷 2019-11-21 오후 11 34 05

  1. 프록시 객체에 member.getName() 호출해서 실제 데이터를 조회합니다.
  2. 프록시 객체는 실제 엔티티가 생성되어 있지 않으면 영속성 컨텍스트에서 실제 엔티티 생성을 요청하는데 이것을 초기화라고 합니다.
  3. 영속성 컨텍스트는 데이터베이스를 조회해서 실제 엔티티 객체를 생성합니다.
  4. 프록시 객체는 생성된 실제 엔티티 객체의 참조를 Member target 맴버 변수에 보관합니다.
  5. 프록시 객체는 실제 엔티티 객체의 getName()을 호출해서 결과를 반환합니다.

프록시의 특징

  • 프록시 객체는 처음 사용할 때 한번만 초기화 됩니다.

  • 프록시 객체를 초기화한다고 프록시 객체가 실제 엔티티로 바뀌는 것은 아닙니다. 프록시 객체가 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근할 수 있습니다.

  • 프록시 객체는 원본 엔티티를 상속받은 객체이므로 타입 체크 시에 주의해서 사용해야 합니다.

  • 영속성 컨텍스트에서 찾는 엔티티가 이미 있으면 데이터베이스를 조회할 필요가 없으므로 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티를 반환합니다.

  • 초기화는 영속성 컨텍스트의 도움을 받아야 가능합니다. 따라서 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태의 프록시를 초기화하면 문제가 발생합니다. 하이버네이트는 org.hibernate.LazyInitializationException 예외를 발생시킵니다.

준영속 상태와 초기화에 관련된 코드는 아래같이 발생 할 수 있습니다.

Member member = em.getReference(Member.class, "id1");

transaction.comit();
// 준영속 상태 초기화 시도
//org.hibernate.LazyInitializationException 예외 발생!
member.getName(); 

em.close() 메소드로 영속성 컨텍스트를 종료해서 member는 준영속 상태입니다. member.getId()을 호출하면 프록시를 초기화해야 하는데 영속성 컨텍스트가 없으므로 실제 엔티티를 조회할 수 없습니다. 따라서 예외가 발생합니다.

프록시와 식별자

엔티티를 프록시로 조회 할 때 식별자(PK) 값을 파라미터로 전달하는데 프록시 객체는 이 식별자 값을 보관합니다.

Team team = em.getReference(Team.class, "team1"); //식별자 보관
team.getId(); // 초기화 되지 않습니다.

프록시 객체는 식별자 값을 가지고 있으므로 식별자 값을 조회하는 team.getId()를 호출해도 프록시를 초기화하지 않습니다. 단 엔티티의 접근 방식을 프로퍼티(@Access(AccessType.PROPERTY))로 설정한 경우에만 초기화되지 않습니다.

엔티티 접근 방식을 필드 (@Access(AccessType.FIELD))로 설정하면 JPA는 getId() 메소드가 id만 조회하는 메소드인지 다른 필드까지 활용해서 어떤일을 하는 메소드인지 알지 못하므로 프록시 객체를 초기화 합니다.

연관관계 설정시에 데이터베이스의 접근 횟수를 줄일 수 있습니다.

Member member = em.getReference(Member.class, "member1");
Team team = em.getReference(Team.class, "team1"); //식별자 보관
member.setTeam(team);

실제로 회원 엔티티가 팀 엔티티와 연관관계를 설정할때 팀 엔티티를 데이터베이스에서 영속성 컨텍스트로 가져오지 않고 팀 엔티티의 식별자 값만 가지고 있는 프록시를 사용하면 데이터베이스 접근 횟수를 줄일 수 있습니다. 참고로 연관관계를 설정해도 프록시를 초기화하지 않습니다.

프록시 확인

JPA가 제공하는 PersistenceUtil.isLoaded(Object entity) 메소드를 사용하면 프록시 인스턴스의 초기화 여부를 확인 할 수 있습니다. 아직 초기화되지 않는 프록시 인스턴스는 false를 반환합니다. 이미 초기화되었거나 프록시 인스턴스가 아니면 true를 반환합니다.

// member1 식별자 값을 가지고 있는 프록시를 반환합니다.
Member member = entityManager.getReference(Member.class, "member1");

//현재 JPA에서 엔티티 데이터에 접근하는 방식은 필드이기 때문에 프록시가 초기화됩니다. 프로퍼티 방식이면 초기화가 되지 않습니다.
System.out.println("회원이름: " + member.getName());


boolean isLoad = entityManager.getEntityManagerFactory()
                 .getPersistenceUnitUtil().isLoaded(member);

System.out.println("isLoad: " + isLoad);
System.out.println("memberProxy = " + member.getClass().getName());

실행결과

스크린샷 2019-11-22 오전 12 34 47

이것으로 프록시로 조회한 것을 알 수 있었습니다.

참조: 자바 ORM 표준 JPA 프로그래밍

'SpringFramework > JPA' 카테고리의 다른 글

영속성 전이 및 고아객체 제거  (0) 2019.11.23
지연로딩과 즉시로딩  (0) 2019.11.23
고급매핑 - 조인테이블  (0) 2019.10.30
복합 키와 식별관계 매핑  (0) 2019.10.29
고급매핑 - 상속관계 매핑  (0) 2019.10.28

프록시 패턴

오늘은 스프링 AOP에 대해서 공부했던 과정들을 숙지하기 위해서 다시한번 프록시에 대해서 알아보았습니다. 프록시에 대해서 검색을 하는 도중에 프록시 패턴에 대한 포스팅을 접하게 되었고, 이 내용은 실제 AOP가 어떻게 동작하는지에 대한 메커니즘을 이해하는데 좋은 글이였다고 생각하여 다시 반복적인 코드 작성을 통하여 알아보는 시간이 였습니다.

프록시란?

프록시는 실제로 액션을 취하는 객체를 대신해서 대리자 역할을 합니다. 한마디로 실제 호출해야하는 메서드를 가지고 있는 실제 대상을 감싸고 있는 Wrapping 클래스라고 생각하시면 됩니다.

프록시 패턴을 사용하게 되면 프록시 단계에서 권한을 부여할 수 있는 이점이 생기고 필요에 따라 객체를 생성시키거나 사용하기 때문에 메모리를 절약할 수 있는 이점도 생깁니다. 프록시 패턴이 하는 일은 한마디로 자신이 보호하고 있는 객체에 대한 엑세스 권한을 제어하는 것입니다.

이렇게 함으로써 실제 인스턴스 사용 과정을 관여하고 메모리를 절약하는 방법을 코드로 살펴보겠습니다.

가장 먼저 실질적으로 사용되는 핵심 코드를 만들어보겠습니다. 하나는 인터페이스이고 하나는 인터페이스를 상속받는 클래스입니다.

인터페이스

public interface CommandExcutor {

    public void runCommand(String cmd) throws Exception;

}

인터페이스를 구현한 클래스

public class CommandExcutorImpl implements CommandExcutor {

    @Override
    public void runCommand(String cmd) throws Exception {
        Runtime.getRuntime().exec(cmd);
        System.out.println("cmd: " + cmd);
    }
}

위의 코드를 보면 인터페이스와 구현을 분리하여 작성하였고, 실질적으로 CommandExcutorImple를 호출하여 객체를 생성시키고 높은 cost의 runCommand() 메서드 작업으로 인해 메모리 낭비가 예상될 수 있습니다.

Runtime.getRuntime().exec() 메서드는 process를 실행시키거나 os를 제어할때 사용하는 클래스로만 알고 있습니다. 이 부분에 대해서는 저도 공부를 하지 않았기 때문에 따로 설명드리진 않겠습니다.

proxy class

위의 단점을 보완하기 위해 프록시 클래스를 작성하였습니다. 일단 똑같은 인터페이스를 상속받음으로 인터페이스의 일관성을 유지합니다.
그리고 생성자에서 CommandExcutorImpl 클래스를 인스턴스화 시키고, 인스턴스화 된 excutor 객체의 runCommand() 메서드를 프록시 클래스의 runCommand 메서드에서 엑세스를 결정합니다.

public class CommandExcutorProxy implements CommandExcutor {

    // isAdmin 값에 따라서 객체에 대한 엑세스 권한을 제어합니다.    
    private boolean isAdmin;
    private CommandExcutor excutor;


    public CommandExcutorProxy(String user, String pwd) {
        if ("sa1341".equals(user) && "1234".equals(pwd)) {
            isAdmin = true;
        }
        excutor = new CommandExcutorImpl();
    }


    @Override
    public void runCommand(String cmd) throws Exception {

        if (isAdmin) {
            excutor.runCommand(cmd);
        } else {
            if (cmd.trim().startsWith("rm")) {
                throw new Exception("rm is only admin");
            } else {
                excutor.runCommand(cmd);
            }
        }
    }
}

실행 클래스

public class ProxyPatternTest {

    public static void main(String[] args) {

        CommandExcutor excutor = new CommandExcutorProxy("sa1341","1111");

       try {
           excutor.runCommand("ls -al");
           excutor.runCommand("rm -rf *");
       }catch (Exception e){
           System.out.println(e.getMessage());
       }


    }
}

위에서는 일부로 패스워드를 다르게 주어서 특정 명렁어에 대해서 예외를 던지도록 작성하였습니다. rm -rf * 같이 터미널에서 디렉토리를 전부 강제 삭제하는 명령어이기 때문에 리스크가 존재하기 때문에 인증된 사용자만이 수행할 수 있도록 테스트 케이스에 넣어봤습니다.

실행 결과

스크린샷 2019-11-20 오후 7 33 15

위와 같이 프록시를 작성하면 인터페이스를 일관성있게 유지시키면서 엑세스 권한을 부여할 수 있고 더불어 메모리 절약을 할 수 있게 됩니다.

참조: https://blog.seotory.com/post/2017/09/java-proxy-pattern

'SpringFramework > JAVA' 카테고리의 다른 글

람다식을 통한 메소드 참조  (0) 2019.12.03
제네릭을 사용하는 이유?  (1) 2019.12.03
Object- 1장 객체, 설계  (0) 2019.11.04
Stream(스트림)  (0) 2019.10.05
@Annotation 이란?  (0) 2019.10.05

Remeber-Me 인증하기

웹에서의 로그인 처리는 크게 HttpSession을 이용하는 방식과, 쿠키(Cookie)를 이용하는 방식이 있습니다. HttpSession을 이용하는 방식을 흔히 세션방식이라고 합니다. 세션방식은 모든 데이터를 서버에서 보관하고, 브라우저는 단순히 키(key)에 해당하는 세션ID만을 이용하기 때문에 좀 더 안전하다는 장점이 있지만, 브라우저가 종료되면 사용할 수가 없기 때문에 모바일과 같은 환경에서 불편함이 있습니다.

쿠키를 이용하는 방식은 브라우저에 일정한 정보를 담은 쿠키를 전송하고, 브라우저는 서버에 접근할 때 주어진 쿠키를 같이 전송합니다. 이때 서버에서는 쿠키에 유효기간을 지정할 수 있기 때문에 브라우저가 종료되어도 다음 접근 시 유효기간이 충분하다면 정보를 유지할 수 있습니다.

Remember-Me는 최근 웹 사이트에서는 로그인 유지라는 이름으로 서비스되는 기능입니다. 이 방식은 쿠키를 이용해서 사용자가 로그인했던 정보를 보관하는 것입니다.

스프링 시큐리티의 Remember-Me 기능은 기본적으로 사용자가 로그인했을 때의 특정한 토큰 데이터를 2주간 유지되도록 쿠키를 생성합니다. 브라우저에 전송된 쿠키를 이용해서 로그인 정보가 필요하면 저장된 토큰을 이용해서 다시 정보를 사용합니다.

아래와 같이 로그인 폼에 remember-me 체크박스 처리하는 부분을 만들었습니다.

<form action="" method="post" class="form form--login">
    <div class="form__field">
        <label class="fontawesome-user" for="username"><span class="hidden">Username</span></label>
        <input type="text" id="username" name="username" class="form__input" placeholder="Username" required>
    </div>

    <div class="form__field">
        <label class="fontawesome-lock" for="password"><span class="hidden">Password</span></label>
         <input type="password" id="password" name="password"  class="form__input" placeholder="Password" required>
    </div>

    <p>
    <label for="remember-me">로그인 유지하기</label>
    <input type="checkbox" id="remember-me" name="remember-me"  class="form__input" />
    </p>

    <input type="hidden" th:name="${_csrf.parameterName}"
                   th:value="${_csrf.token}" />
    <div class="form__field">
                <input type="submit" value="Log In">
    </div>
</form>

스크린샷 2019-11-18 오전 12 31 41

remember-me를 데이터베이스에 보관하기

스프링 시큐리티는 기본적으로 remember-me 기능을 사용하기 위해서 Hash-based Token 저장 방식과 Persistent Token 저장 방식을 사용할 수 있습니다.

remember-me 쿠키의 생성은 기본적으로 username과 쿠키의 만료시간, 패스워드를 Base-64 방식으로 인코딩한 것입니다. 따라서.. 사용자가 패스워드를 변경하면 정상적인 값이라도 로그인이 될 수 없다는 단점을 가지고 있습니다.

이를 해결하기 위해서 가능하면 데이터베이스를 이용해서 처리하는 방식이 권장됩니다.
데이터베이스를 이용하는 설정은 org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl이라는 긴 이름의 클래스를 이용합니다.

원한다면 PersistentTokenRepository라는 인터페이스를 이용해서 자신이 원하는 대로 구현이 가능합니다만, 관련된 모든 SQL을 직접 개발해야 하므로 번거러운 점이 있습니다.

우선 토큰을 보관할 수 있는 테이블을 아래와 같이 생성합니다.(MySQL 기준)

create table persistent_logins(
    username varchar(64) not null,
    series varchar(64) primary key,
    token varchar(64) not null,
    last_used timestamp not null
);

SecurityConfig에서의 설정

SecurityConfig에서는 rememberMe()를 처리할 때 JdbcTokenRepositoryImpl을 지정해주어야 하는데 기본적으로 DataSource가 필요하므로 주입해 줄 필요가 있습니다.

@EnableWebSecurity
@Slf4j
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomerAuthenticationProvider customerAuthenticationProvider;

    private final JpaUserService jpaUserService;

    private final DataSource dataSource;

HttpSecurity에서는 JdbcTokenRepositoryImpl을 이용할 것이므로 간단한 메소드를 이용해서 처리합니다.

private PersistentTokenRepository getJDBCRepository(){

    JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
    repo.setDataSource(dataSource);
    return repo;
}

마지막으로 HttpSecurity가 이를 이용하도록 configure() 메서드의 내부를 수정합니다.

remember-me 기능을 설정하기 위해서는 UserDetailsService를 이용해야 한다는 조건이 있습니다. 만든 후에 HttpSecurity 인스턴스에 간단히 rememberMe()를 이용해 주면 처리가 가능합니다.

http.rememberMe()
.key("jpa")
.userDetailsService(jpaUserService)
.tokenRepository(getJDBCRepository())
.tokenValiditySeconds(60*60*24);

마지막에 추가한 tokenValiditySeconds()는 쿠키의 유효시간을 초단위로 설정하는데 사용합니다. 코드에서는 24시간을 유지하는 쿠키를 생성합니다.

브라우저에서 토큰정보

SecurityConfig의 설정을 추가한 후에 화면에서 remember-me를 선택한 로그인을 하면 persistent_logins테이블에 생성된 토큰의 값이 기록되는 것을 확인할 수 있습니다.

스크린샷 2019-11-18 오전 12 48 21

MySQL에서 토큰정보

스크린샷 2019-11-18 오전 12 48 41

이제 쿠키의 생성은 패스워드가 아니라 series에 있는 값을 기준으로 하게 됩니다. 사용자가 remember-me를 한 상태에서 로그아웃을 하면 스프링 시큐리티가 자동으로 데이터베이스에서 토큰정보를 삭제하는것을 확인할 수 있습니다.

스크린샷 2019-11-18 오전 12 56 36

'SpringFramework' 카테고리의 다른 글

Jenkins SSH를 이용한 GitHub 연동방법  (0) 2021.05.20
Jenkins에서 EC2로 배포하기  (0) 2021.05.20
Jenkins 설치 및 구동하기  (0) 2021.05.19

TDD, 리팩토링

오늘은 이화여대에서 구글 및 다양한 IT기업에서 주최하는 Dev Festoval이 개최를 하여서 같은 개발직종에 종사하고 있는 대학교 친구랑 참가비 1만원을 내고 컨퍼런스에 참여하였습니다.

그중에서 관심있는 세션을 3개정도 들었습니다.
첫번째 세션은 현업에서 종사중인 Lisa라는 분의 빠르고 지속적으로 성장하는 방법에 대한 세션이였습니다.

그중에 가장 인상 깊었던 말씀은 하루에 10분 정도 투자해서 5F 회고를 하시는 거였는데 5F는 아래와 같은 약자를 가지고 있습니다.

5F

  • Fact (사실은 무엇인가?)
  • Feeling (무엇을 느꼈는가?)
  • Find (무엇을 발견하였는가?)
  • Future Action (미래에 해야할 행동)
  • Feed Back (피드백)

나이도 젊으신 개발자였는데 항상 자신의 부족한점을 극복하려고 노력하는 모습이 정말 멋있고 인상 깊으셨습니다.
저도 하루하루 살아가면서 비슷하게나마.. 5F를 실천하려고 노력해야겠습니다.

두번째 세션은 어떤 오픈소스에 참여할까?로 마찬가지로 흥미진진한 세션이였습니다.

이번에는 대학생 신분인데 벌써부터 오픈소스에 기여하여 발표를 하는 것을 보고 나는...이 나이때까지 무엇을 했나... 반 강제적으로 5F를 하게 되었습니다.
주로 깃허브의 중요성을 강조하셨던게 기억이 남았습니다. 깃허브는 개인 프로젝트를 할때 백업용으로도 쓰지만 현업에서는 협업 및 버전관리용으로 쓰는것을 강조하셨고 기본적으로 GIT을 다룰줄 알아야 된다고 강조하셨습니다.

그리고 오픈소스를 참여할때 어떤 오픈소스를 건드려야 될지 좋은 팁을 주셨는데 주로 오픈소스는 최근까지 논의가 활발하게 이루어진 오픈소스에 참여하는게 좋다고 하셨습니다. 최근까지 논의한 오픈소스를 어떻게 판별하는지는 최근까지 오픈소스 관리자의 피드백이 있으면 그 오픈소스는 지금도 활발하게 많은 Contributer가 참여하는 오픈소스이기 때문에 저 자신이 성장할 확률도 높습니다.

그리고 커밋 메시지를 남길때 대충 남기지 말고 그것 자체만으로도 협업하는 개발자들이 이해시킬 수 있도록 작성해야 하는걸 강조하셨습니다.

TDD, 리팩토리의 중요성

마지막으로 제가 들은 세션은.. 사실 이것을 듣기 위해 왔다고 해도 과언이 아닌 우아한테스크코스 교육을 담당하고 계시고 TDD에 관한 책도 저술하신 박재성님의 의식적인 연습으로 TDD, 리팩토링 연습하기 세션을 들었습니다.

기대했던만큼 돈이 아깝지 않을정도로 훌륭한 퀄리티의 세션이였습니다.
제가 앉은 자리는 맨 뒤쪽이여서... 리팩토링을 적용할 코드가 보이지 않았지만 다행히 구글링을 통해서 오늘 세션에서 봤던 리팩토링 예제코드를 보고 분석할 수 있었습니다.

간단하게 리팩토링 예제를 리뷰해보겠습니다.

아래 코드는 어떤 특정 구분자를 가진 문자열 숫자들을 전달하는 경우 구분자를 기준으로 분리한 각 숫자의 합을 return 하는 코드입니다.

박재성님은 웹, 모바일 UI나 DB에 의존관계를 가지지 않는 요구사항으로 연습을 해야하고, 회사 프로젝트에 연습하지 말고 장난감 프로젝트를 활용하라고 하셨습니다.

public class StringCalculator {

    public static void main(String[] args) {

        String text = "1,3,5,7,9";


    }

    public static int splitAndSum(String text){
        int result = 0;
        if(text == null || text.isEmpty()){
            return 0;
        }else{
            String[] values = text.split(","); 
            for (String value : values) {
                result += Integer.parseInt(value);
            }
        }
        return result;
    }
}

가장 먼저 위의 코드는 if문과 else문으로 구성이 되어있습니다. 리팩토링에서는 else 예약어를 쓰지 않는것을 지향하고 있습니다.
그리고 splitAndSum()에는 문자열이 비어있는지 검사하고 문자열을 구분자를 기준으로 분리하여 각 숫자의 합을 구하는 등 많은 일들을 하고 있는게 보이고 있습니다.

이 코드를 메소드가 한 가지 일만 하도록 아래와 같이 구현해보겠습니다.

메소드 분리로 리팩토링 적용 후 코드

public class StringCalculator {

    public static void main(String[] args) {

        String text = "1,3,5,7,9";

        System.out.println(splitAndSum(text));
    }

    // 아래 메소드가 한 가지 일만 하도록 구현하고 있습니다.
    public static int splitAndSum(String text){

        if(isBlank(text)){
            return 0;
        }

        return sum(toInt(text.split(",")));
    }

    public static int[] toInt(String[] values){

        int[] numbers = new int[values.length];


        for (int i = 0; i < values.length; i++) {
            numbers[i] = Integer.parseInt(values[i]);
        }
        return numbers;
    }

    public static int sum(int[] numbers){

        int sum = 0;

        for (int number : numbers){
            sum += number;
        }

        return sum;
    }

    public static boolean isBlank(String text){
        return text == null || text.isEmpty();
    }
}

메소드 분리를 통해 리팩토링된 코드를 보면 각 메소드가 하나의 일만 수행하는 것을 알 수가 있습니다.

이것은 compose method 패턴을 적용하여 메소드(함수)의 의도가 잘 드러나도록 동등한 수준의 작업을 하는 여러 단계로 나누었습니다.

확실히 리팩토링을 통해서 이전 코드랑 비교했을때 가독성이 높아진 것을 알 수가 있습니다.

개발자들이 개발을 잘하는것도 중요하지만 돌아가는 프로그램에 초점을 맞추기 보다는 유지보수성과 기능 확장을 위해서 유연한 코드를 작성하는게 중요하다고 다시 한번 이번 세션을 통해 느꼈습니다. 유연한 코드를 작성하가 위해서는 협업하는 사람들이 코드를 읽기 쉽게 작성하는 거랑 일맥상통하는데 이러한 능력을 갖추기 위해서는 한 번에 한 가지 명확하고 구체적인 목표를 가지고 리팩토링을 연습하는 습관을 기르는게 중요하다고 생각합니다.

'잡다한 것' 카테고리의 다른 글

Git- 실전 가이드  (0) 2019.11.29
IntelliJ 자주 사용하는 단축키 정리  (0) 2019.11.24
자기소개  (0) 2019.10.03

기존에 간단하게 만들었던 프로젝트에 로그인 기능을 추가해달라는 요구사항이 있으면 여러가지 방법이 있습니다. 대표적으로 Filter를 사용하여 DispatcherServlet이 요청 URL를 인터셉트 하기전에 세션을 관리하는 방식으로 인증을 처리하는 방법입니다. 하지만 이미 스프링에서는 쉬운지는 모르겠지만 더 간편하게 보안을 적용 할 수 있는 스프링 시큐리티라는 기능을 제공하고 있습니다. 오늘은 웹 환경에서 스프링 시큐리티가 어떻게 동작하고 인증 및 권한처리 방법에 대해서 포스팅하였습니다.

스프링 시큐리티란?

Spring Security는 스프링 기반의 어플레케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크 입니다.

스프링 시큐리티 개념

스프링 시큐리티를 개념을 잡기 위해선은 아래 기본적인 용어는 알아두어야 한다고 생각합니다. 평상시에 많이 접하던 용어라 거부감은 딱히 없었습니다...ㅎㅎ

용어 정리

보안: 위험, 손실 , 범죄가 발생하지 않도록 방지하는 상태를 가리킵니다. 일반적으로 보안의 피해발생의 원인이 인간의 행위라는 점에서 안전이라는 개념과 구분됩니다.

접근주체(Principal): 보호된 리소스에 접근하는 대상

인증(Authentication) : 애플리케이션의 작업을 수행할 수 있는 주체(사용자)라고 주장할 수 있는것을 말합니다.

인증의 방식

  • 크리덴셜 기반 인증 : 사용자명과 비밀번호를 이용한 방식
  • 이중인증 : ATM을 이용할 때처럼 물리적인 카드와 사용자가 입력한 개인정보를 조합하는 방식
  • 하드웨어 인증 : 자동차 키와 같은 방식

인증과 인가
일반 사원인 김사원과 재무팀의 박부장은 사내 모든 직원들의 급여명세서를 열람하고 싶어한다. 먼저 시스템에 로그인을 하면 인증관리자(인증을 처리함)는 사용자가 제시한 정보를 통해 사용자가 누구인지를 확인한다. 다음으로, 접근결정 관리자는 사용자가 가지고 있는 역할을 가지고 급여명세서에 접근할 수 있는지 아닌지를 판별하게 된다.

인가(Authorize): 해당 리소스에 대해 접근 가능한 권한을 가지고 있는지 확인하는 과정(After Authentication)

권한: 권한은 인증된 주체가 어플리케이션의 동작을 수행할 수 있도록 허락되있는지를 결정하는것을 말합니다. 따라서 권한 승인이 필요한 부분으로 접근하려면 인증 과정을 통해 주체가 증명 되어야만 한다는 것을 의미합니다.

역할부여란?

인증을 통해서 인증된 주체를 하나 이상의 권한(역할)에 매핑하고 보호된 리소스에 대한 권한을 체크하는 것을 말한다.

필터

스프링 시큐리티에서 웹 애플리케이션에 주로 영향을 주는 방식은 일련의 ServletRequest 필터를 사용한 방식이다. 필터들이 애플리케이션에 대한 모든 요청을 감싸서 처리한다. 스프링 시큐리티에서 여러개의 필터들은 아래 그림과 같이 체인형태를 이루면서 동작한다. 자동 설정 옵션을 사용하면 10개의 스프링 시큐리티 필터가 자동으로 설정된다.

DelegatingFilterProxy

DelegatingFilterProxy는 스프링 시큐리티가 모든 애플리케이션 요청을 감싸게 해서 모든 요청에 보안이 적용되게 하는 서블릿필터이다.(스프링 프레임워크에서 제공) 스프링 프레임워크 기반의 웹 애플리케이션에서 서블릿 필터 라이프 사이클과 연계해 스프링 빈 의존성을 서블릿 필터에 바인딩하는데 사용합니다.
web.xml에 다음과 같은 설정을 해주면 애플리케이션의 모든 요청을 스프링 시큐리티가 감싸서 처리할 수 있게 됩니다.

<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
<filter-name>springSecurityFilterChain</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

추가로 설명하자면
DelegatinFilterProxy는 filter-name으로 명시된 스프링 컨텍스트에서 빈으로 등록된 springSecurityFilterChain에게 실제 인증처리를 위임하기만 합니다. springSecurityFilterChain은 Filter 인터페이스를 구현한 여러 종류의 필터들을 List 객체에 가지고 있습니다.

스프링 시큐리티를 커스터마이징 하기 위해서는 SpringSecurity에서 제공하는 필터들에 대해 이해하는게 좋습니다.
SpringSecurityFilterChain은 아래 표와 같이 여러 종류의 필터들을 가지고 있습니다.

SpringSecurityFilterChain 종류

12

필터 체인의 제일 마지막에 위치한 FilterSecurityInterceptor는 앞에 지나온 모든 필터들의 정보를 토대로 최종 결정을 내립니다.

SpringSecurityFilterChain

다운로드

스프링 시큐리티 구조

스크린샷 2019-11-12 오후 8 27 23

Spring Security Process

아래 사진은 웹 기반 인증 요청을 처리하는 기본 프로세스를 그림으로 도식화 하였습니다.

스크린샷 2019-11-16 오전 12 01 52

가장 많이 사용하는 폼 기반의 인증방식에 대해서 살펴보겠습니다.
저희 웹 서비스를 가입한 사용자 A가 있다고 가정하겠습니다. 저희 웹 서비스는 회원가입을 통해 가입하여 리소스에 대한 해당 권한을 가진 사용자만이 웹 서버의 리소스에 접근할 수 있습니다.

  1. 먼저, A가 처음에 브라우저를 켜서 저희 웹 서버에서 제공하는 서비스를 이용하기 위해서 폼 기반의 인증요청을 보냅니다. 그러면 위의 표에 나와있듯이 맨 처음에 AbstractAuthenticationProcesFilter를 구현한 클래스(UsernamePasswordAuthenticationFilter)가 인증요청에 해당하는 URL을 감지하여 요청을 가로챕니다.

    public class UsernamePasswordAuthenticationFilter extends
         AbstractAuthenticationProcessingFilter {
  2. Request 객체에 사용자명과 패스워드를 획득 후에 UsernamePasswordAuthenticationTocken 객체에 해당 정보를 바인딩을 하게 됩니다.

UsernamePasswordAuthenticationTocken는 단순 사용자명과 패스워드를 담는 단순 도메인 객체입니다.

  1. UsernamePasswordAuthenticationFilter가 주입 받은 AuthenticationManager의 authenticate 메소드를 호출합니다. 이때 인자로 UsernamePasswordAuthenticationTocken을 반환합니다.
    AuthenticationManager는 스프링 시큐리티의 중요한 요소 중 하나이기 때문에 나중에 자세히 설명하겠습니다.

UsernamePasswordAuthenticationTocken은 Authentication을 상속했기 때문에 자바의 다형성으로 인해 반환이 가능합니다.

  1. AuthenticationManager는 사용자명/패스워드를 실질적으로 인증하는 역할을 맡고 있습니다. AuthenticationManager는 인터페이스이기 때문에 구현체인 ProviderManager를 사용합니다. 이 ProviderManager는 이 권한을 AuthenticationProvider 인터페이스에게 넘깁니다.

뭔가 복잡하지만.. 결국 인증 처리는 AuthenticationProvider를 통해서 처리한다고 생각하면 됩니다. 저희 개발자는 AuthenticationProvider의 구현체를 이용하여 인증 메커니즘을 커스터마이징하여 구현하면 되는 것입니다.

  1. 데이터베이스에서 사용자 정보를 가져올때는 AuthenticationManager는 UserDetailService에게 폼 기반의 인증을 요청한 주체(principal)로 부터 입력받은 사용자명을 파라미터로 받아서 실제 DB에서 정보를 가져와 UserDetails에 정보를 넣어서 반환하는 역할을 합니다.

AuthenticationProvider: 로그인 인증처리를 구현 (비밀 번호 대조)
UserDetailsService: 유저정보를 DB에서 조회하는 역할

위의 순서과 대략적인 스프링 시큐리티 처리 과정입니다. 일반적으로는 인증 관리자는 데이터베이스에 저장된 사용자 정보로 인증을 처리 합니다.

Spring Security 코드 리뷰

이제 간단하게 스프링 시큐리티를 코드를 작성하여 구현해보겠습니다.
가장 먼저 웹 환경에서 스프링 시큐리티를 적용하기 위해서 아래와 같은 클래스 파일을 작성하였습니다.

SpringSecurity config

//웹 보안 활성화 시키는 어노테이션
@EnableWebSecurity
@Slf4j
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // AuthenticationProvider 커스터마이징한 구현체
    private final CustomerAuthenticationProvider customerAuthenticationProvider;

    // DB에서 사용자 정보를 가져오는 UserDetailService 구현체
    private final JpaUserService jpaUserService;

    @Override
    public void configure(HttpSecurity http) throws Exception {
      log.info("security config.......");


      // 웹과 관련된 다양한 보안 설정을 걸어주는 역할을 합니다.
      http.authorizeRequests().antMatchers("/guest/**").permitAll()
              .antMatchers("/board/list").permitAll()
              .antMatchers("/board").hasRole("BASIC");


      // authorizeRequests()는 시큐리티 처리에 HttpServletRequest를 이용합니다.
      http.authorizeRequests().antMatchers("/manager/**").hasRole("MANAGER");

      http.authorizeRequests().antMatchers("/admin/**").hasRole("ADMIN");

      // customize 한 로그인 페이지 설정
      http.formLogin().loginPage("/login");

      //특정 리소스에 대한 접근 권한이 존재하지 않을때 이동시킬 페이지 설정
      http.exceptionHandling().accessDeniedPage("/accessDenied");

      // 로그아웃시에 세션 무효화, 스프링 시큐리티는 사용자 정보를 HttpSession 방식으로 관리하기 때문에 브라우저가 완전히 종료되면, 로그인한 정보를 잃게 됩니다. 브라우저를 종료하지않을시에.. 로그아웃으로 무효화시킵니다.
      http.logout().logoutUrl("/logout").invalidateHttpSession(true);

    }

    // 패스워드 암호화를 위해 PasswordEncoder Bean으로 등록
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception{
        // AuthenticationManagerBuilder는 다양한 인증 관리자를 생성해주는 객체입니다. 
        auth.authenticationProvider(customerAuthenticationProvider).userDetailsService(jpaUserService).passwordEncoder(passwordEncoder());
    }
}

@EnableWebSecurity

WebSecurityConfigurerAdapter는 세가지 configure() 메소드를 오버라이딩하고 동작을 설정하는 것으로 전반적인 웹 보안정책을 설정할 수 있습니다.

스크린샷 2019-11-12 오후 7 10 31

이 중에서 가장 많이 사용하는 객체가 HttpSecurity 입니다. 보안에 관련된 역할과 많은 책임을 가지고 있습니다.

AuthenticationManagerBuilder는 다양한 인증관리자를 생성해주는 빌더로 메모리 기반의 인증과 데이터베이스 인증 관리자 등 다양한 인증 관리자를 생성해주는 역할을 맡고 있습니다.

http.formLogin()을 이용하면 기본적으로 AbstractAuthenticationProcessingFilter를 구현한 UsernamePasswordAuthenticationFilter 구현체를 이용하게 됩니다.

http.formLogin().loginPage("/login");

참고로 UsernamePasswordAuthenticationFilter에서는 인증(로그인)요청에 대해서 기본적으로 GET 방식으로 인증 정보가 들어오는 것을 허용하지 않고 Http Method를 POST 방식으로만 지원하고 있습니다.

저 같은 경우에는 아래 인증관리자 빌더를 통해서 AuthenticationProvider와 UserDetailsService를 등록하였습니다. 위에서 언급한것 처럼 DB를 통한 인증처리 방식을 많이 사용하기 때문에 authenticationProvider(customerAuthenticationProvider)를 작성하지 않고 UserDetailsService()만 작성하여도 AuthenticationManager에 DaoAuthenticationProvider가 자동으로 연결됩니다.

auth.authenticationProvider(customerAuthenticationProvider).userDetailsService(jpaUserService).passwordEncoder(passwordEncoder());

AuthenticationProvider 구현

@Component
@RequiredArgsConstructor
public class CustomerAuthenticationProvider implements AuthenticationProvider {
    // UserDetailsService 구현체 
    private final JpaUserService jpaUserService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = authentication.getName();
        UserDetails userDetails = null;

        try {
              userDetails = jpaUserService.loadUserByUsername(username);
        }catch (UserNotFoundException e){
            System.out.println(e.getMessage());
        }catch (BadCredentialsException e){
            System.out.println(e.getMessage());
        }catch (Exception e){
            System.out.println(e.getMessage());
        }

        return new UsernamePasswordAuthenticationToken(userDetails.getUsername(),userDetails.getPassword(),userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return false;
    }
}

위의 코드가 실제로 인증을 처리해주는 AuthenticationProvider 구현체 입니다.
UserDetailsService 인터페이스를 구현한 JpaUserService 객체에게 사용자로 받은 사용자명으로 데이터베이스에 해당 사용자를 조회하여 존재하면 UserDetails에 담아서 AuthenticationProvider에게 제공해주는 역할을 하고 있습니다. 존재하지 않으면 예외를 처리하겠죠

UserDetails

public class JpaSecurityUser extends User {

    // 스프링 시큐리티에서는 권한정보를 ROLE + 권한명으로 리소스 접근승인을 판단합니다. 
    // 따라서 접두사인 PREFIX 변수에 필수로 적어줘야 권한을 확인하고 판별할 수 있습니다.
    private static final String ROLE_PREFIX = "ROLE_";

    private Member member;

    public JpaSecurityUser(Member member){
        // User 클래스를 상속하기 때문에 부모생성자는 필수로 호출해줘야 합니다.
        super(member.getUid(), member.getUpw(),makeGrantedAuthority(member.getRoles()));

        this.member = member;
    }

    public JpaSecurityUser(String username, String password, Collection<? extends GrantedAuthority> authorities){

        super(username, password, authorities);

    }

    // 접근제한자 private로 캡슐화를 통해서 해당 User 클래스에서 생성자로 필요한 사용자의 권한정보를 생성 합니다.  
    private static List<GrantedAuthority> makeGrantedAuthority(List<MemberRole> roles){

        List<GrantedAuthority> list = new ArrayList<>();

        roles.forEach(role -> list.add(new SimpleGrantedAuthority(ROLE_PREFIX + role.getRoleName())));

        return list;
    }

    public Member getMember() {
        return member;
    }
}

UserDetails는 인터페이스이기 때문에 실제로 DB에서 조회한 정보를 담기 위해서는 해당 인터페이스의 구현체를 만들어야 합니다. 하지만 우리의 넘사벽 프레임워크인 스프링은 이미 다 준비가 되어있죠.. User라는 클래스가 UserDetails를 구현해놨기 때문에 아래에 UserDetailsService 구현부에서 User를 반환해도 됩니다. 하지만 저는 위의 코드 처럼 나중에 추가적인 맴버변수(이메일, 생년월일)를 사용할 수도 있기 때문에 User 클래스를 확장한 JpaSecurity를 구현하여 Member 도메인을 필드로 가지도록 하였습니다.

public class JpaSecurityUser extends User{

}

사용자명을 파라미터로 받아서 DB에서 해당 유저의 정보를 조회하는 역할을 담당하는 UserDetailsService를 구현한 부분입니다. JPA를 사용하기 때문에 Repository를 이용하여 DB에 접근하였습니다.

UserDetailsService

@Service
@RequiredArgsConstructor
public class JpaUserService implements UserDetailsService {

    @Autowired
    private  final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        JpaSecurityUser user = memberRepository.findById(username).filter(m -> m != null).map(m -> new JpaSecurityUser(m)).get();

        if(user == null){
            throw new UserNotFoundException("해당 계정이 존재하지 않습니다.");
        }

        return user;
    }
}

아래 개발자분들이 올려주신 블로그가 저에게 스프링 시큐리티 개념을 정립하는데 많은 도움을 주셨습니다.

출처: https://blog.naver.com/sipzirala/220979656224,https://javaiyagi.tistory.com/431

+ Recent posts