토픽의 요약
•
회원 가입 및 로그인 부분에서 스프링 프레임워크가 제공하는 기능을 충분히 활용하지 못한 케이스로 판단되었다.
•
유저 역할 및 권한을 구분하는 스프링의 기능을 활용 또는 학습해보고자는 토픽으로 개선방안을 찾아보고 프로젝트에 적용 할 수 있을지 가능성을 보고자 한다.
[문제 정의] 개선 필요성을 느낀 이유
회원가입과 로그인에서 사용자 권한에 대한 해결의 필요성을 느끼고 있다. 그 이유는 학습과 실습을 통해 일반 유저와 관리자 유저에 대해서 구분하는 것에서 ROLE을 통해서 구분하는 것을 실습해보긴 했다.
하지만, 현재 프로젝트에서는 일반 유저와 사업자 유저, 그리고 관리자 유저 처럼 최소 2개, 최대 3개의 회원의 유형이 존재한다.
이렇듯 유저가 2가지로 분기 되면서 총 3개의 유저 롤을 구성해야하는 응용의 혼란이 있었다. 따라서 한정적인 시간에 쫓기고 있었기 때문에 USER ROLE을 사용하지 않고 우선적으로 당장 생각나는 방식은 원시적으로 Java의 조건문을 통해서 사업자 번호 registNum을 입력받고, 이 필드에 어떠한 값이라도 보유한 유저를 사업자 유저로, null인 유저를 일반 유저로 구분하도록 하드 코딩하게 되었다.
큰 맥락에서는 사업자 번호 또한 유저의 정보기 때문에 객체 지향 프로그래밍 측면에서 억지로 맞다고 우길 수 있지만 아주 조금만 더 자세히 살펴보면 그 registNum 필드 자체의 역할은 말 그대로 사업자 번호의 정보일 뿐이지 유저의 유형을 구분하기 위한 용도는 아니기 때문에 결과적으로 잘못된 사용을 하고 있는 점이 가장 큰 문제점이다.
[원인 분석] 현재 상황 요약 및 왜 문제인가?
•
카페 프로젝트에서 회원은 일반유저, 사업자유저로 구분되고 있고 접근할 수 있는 기능이 구분되어 있다.
◦
회원의 구분은 다음과 같이 구분된다
▪
회원의 정보(필드/컬럼)중 registNum 이라는 필드의 값이 있는가? 없는가?라는 단순한 구분
•
사업자 : 숫자가 입력 되면(사업자 번호 등) 사업자로 인식된다.
•
일반회원 : null(또는 0) 인 경우 일반 유저로 인식된다.
요약하면 확장성과 유지보수성이 매우 떨어지는 코드이며 의도하지 않은 변형의 가능성과 이에 따라 또 다른 문제가 발생 할 수 있는 가능성 자체를 배제할 수 없다.
이 코드를 작업한 우리 팀만이 알고 있는 사실이 되버리며, 시간이 지나면 개발한 나 조차도 잊을 수 있는 위험한 개발 방식이다.
예를들어 아직 사업자 번호를 발급받지 못한 예비창업자의 경우 해당 메뉴 자체를 구경 할 수도 없을 수 없다.
사업자 번호를 잘못 입력한 유저의 경우 실제 그 사업자 번호를 가진 유저가 회원가입을 시도 할 때 등록된 유저인것 처럼 나타나는 등 서비스 차원에서 큰 혼란을 불러 올 수 있는 가능성 등을 배제할 수 없다.
더 나아가 세상의 많은 웹 서비스에서 이미 회원의 기능을 구분하는 기능은 기본적으로 구현되고 있는 상황인데, 그만큼 사례가 많은 상황에서 우리보다 더 복잡한 유저 관계를 가진 프로젝트들도 성공적으로 런칭되고 있을 것이다.
따라서, 이러한 문제는 이미 스프링에서 개발자가 쉽게 다룰 수 있도록 기술을 제공했을 것이라 판단했다.
[해결안 도출] 어떤 것을 참고해야 하는가?
우선 어떤 것들을 통해 해결 할 수 있는지 가이드를 찾아보는 것 자체가 어렵다. 따라서 ChatGPT를 통해서 그 핵심 키워드를 찾아내고 그걸 토대로 공식문서 또는 공식문서 동등한 신뢰도 높은 가이드를 찾아내는 것이 핵심이다.
아래는 ChatGPT와의 대화를 통해 획득한 KEYWORD이다. 단순하게 ChatGPT에 대한 무조건적인 신뢰보다는 2차적인 신뢰도를 확인 할 필요가 있다. 하지만 선생님이 없는 상황에서 스스로 공부하는데 있어서 질문을 구체적으로 명확하게 할 수록 ChatGPT는 좋은 키워드를 제공해주는 것을 활용 할 수 있다.
ChatGPT로부터 획득한 KEYWORD 대화 전문
각 KEYWORD에 따른 공식문서 참고자료 훑어보기와 어떤 기능들이 있는지 둘러보기
5.
다중 권한(Role) 지원
스프링 시큐리티는 기본적으로 다중 역할을 받을 수 있다. 사업자 유저가 일반 유저 역할과 사업자 역할을 동시에 가질 수 있다는 점은 참고할만하다.
6.
스프링 시큐리티의 Hierarchical Roles 사용
이는 Role을 계층적으로 구성 할 수 있다는 점으로 보여진다.
마치 ADMIN > STAFF > USER > TEMPUSER > GUEST 처럼 권한의 스코프를 점진적으로 구성 할 수도 있다. 예시코드는 다음과 같다.
@Bean
static RoleHierarchy roleHierarchy() {
RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
hierarchy.setHierarchy("ROLE_ADMIN > ROLE_STAFF\n" +
"ROLE_STAFF > ROLE_USER\n" +
"ROLE_USER > ROLE_GUEST");
return hierarchy;
}
// and, if using method security also add
@Bean
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setRoleHierarchy(roleHierarchy);
return expressionHandler;
}
Java
복사
7.
스프링 시큐리티에서의 커스텀 필터 체인 구성
로그인 문지기 역할을 하는 필터는 로그인 시 사업자 번호를 확인하고 권한을 동적으로 설정하는 커스텀 필터를 추가할 수도 있다. 필터를 통해서 권한의 부여를 조절 할 수 있다는 점을 알아냈다.
추가적인 유사 사례 확보 필요성에 대한 생각
사설을 포함하고 있습니다.(펼치기)
내 목표와 최대한 근접한 유사 사례를 찾는 과정과 판단
검색 과정에서 “Role과 권한(Privilege) 설계에 대한 주제로 작성한 블로그”를 찾게 되었다. 나는 생각보다 블로그를 보더라도 까다롭게 사례를 선택하는 편이기 때문에 글쓴이의 글의 전반적인 흐름과 깊이에 대해서 관찰하게 되었다.
1.
해당 개발자의 블로그에서는 나의 개선 상황과 비슷한 흐름으로 전개되고 있었으며 문제 인식 상황과 현재 상황이 가장 흡사하게 개선을 진행하는 모습을 찾을 수 있었다.
2.
사용자가 임시사용자, 일반 사용자 처럼 2개로 분리되어 있으며 추가적으로 관리자 admin 으로 총 3개의 User ROLE을 구현하는 것을 목표로 있다.
•
ROLE_USER (일반 사용자)
•
ROLE_TEMPORARY_USER (임시 사용자)
•
ROLE_ADMIN (관리자)
3.
블로그 자체는 단순한 코드의 나열이 아니라 문제 해결 단계에 따라 접근하는 방식이 높은 신뢰도를 갖게 했다.
4.
github을 통한 스터디 프로젝트 레포지토리를 내가 추구하는 방향으로 공유하고 있어서 그 코드를 전반적으로 살펴 볼 수 있었다. Fork를 통해 코드를 살펴보고자 클로닝을 진행했다.
[실행] 코드의 적용
1. 가장 먼저 살펴본 것은 프로젝트의 구조와 버전
우선 해당 프로젝트의 테스트를 해보고싶었다. 개발자님의 github Repo를 통해서 프로젝트 전체를 받았으며, 실행 과정과 각 부분의 차이를 나의 프로젝트와의 차이를 보고 싶었다.
해당 프로젝트 개요에서 내가 맞추어야 할 DB, 정보 등을 제공해주었다.
• Spec : Java 8, Spring Boot, H2, Spring Web, Spring Data JPA, Lombok
사실상 DB가 H2인점, JDK가 8인점 말고는 현재까지 내가 진행한 의존성과 동일한 상황이다. H2또한 관계형 데이터베이스로 내가 사용하고 있는 MySql 또는 Oracle로 간편한 체크 이후에 이전이 가능 할 것이다.
2. Entity와 연관 관계는 역할 기반 권한 관리에 적합한가?
User, Role, Privilege Entity 객체를 살펴보았다. 나의 프로젝트와 달리 Role과 Privilege 라는 Entity가 추가되어있다. 사용자 정보 외에 다른 표현들의 용도를 확인해야한다.
•
User.java
◦
사용자 정보를 포함하는 Entity 객체이면서 동시에 UserDetails의 구현체이다.
◦
User와 Role은 N:N 관계이다.
@Entity
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column
private String name;
@Column
private String email;
@JsonIgnore
@Column
private String password;
@Column
private String phoneNumber;
@Transient
private Collection<SimpleGrantedAuthority> authorities;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_role",
joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id")
)
private List<Role> roles;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getUsername() {
return this.name;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
Java
복사
◦
authorities 필드는 사용자의 권한을 나타내며, SimpleGrantedAuthority 객체의 컬렉션으로 표현되고 있다.
▪
@Transient 어노테이션은 영속 대상에서 제외시키기 위해 사용하는 어노테이션이다.
•
영속 대상에서 제외된다면, 더는 해당 필드나 메서드는 엔티티 매니저의 관리 대상에서 제외됨을 의미.
•
그럼 이 필드는 왜 영속성에서 제외시키고있는걸까?
◦
이 필드는 스프링 시큐리티가 사용자 인증 및 권한 부여를 처리할 때 사용되는 용도이다.
◦
데이터베이스에 저장되는 것이 아니라 스프링 시큐리티에서 사용자 인증과 권한 부여를 위해 임시적으로 사용되는 메모리 상의 필드라 볼 수 있기 때문에 제외시킨 것이다.
◦
roles 필드는 사용자와 관련된 역할(Role) 목록을 나타내며, ManyToMany 관계로 Role 엔티티와 연결됩니다.
▪
이 부분은 위 authorities 필드와 달리 실제로 DB에서 role이라는 컬럼으로 사용자의 역할을 저장되게 된다.
◦
하단 메소드 들의 의미
▪
~ implements UserDetails 를 구현하는 실체 메소드들이다. 저 부모가 추상화된 인터페이스 이기 때문,
▪
UserDetails 인터페이스는 스프링 시큐리티에서 사용자 정보를 표현하고 사용자 인증 및 권한 부여를 위한 핵심 인터페이스 중 하나이다.
•
getAuthorities():
◦
사용자의 권한(역할) 목록을 반환하는 메서드
◦
보통 사용자가 가지는 역할(Role)을 기반으로 권한 목록을 반환하며, 이를 통해 스프링 시큐리티는 사용자에 대한 권한을 확인한다.
•
getUsername():
◦
사용자의 식별자(일반적으로는 사용자 이름 또는 이메일)를 반환하는 메서드 ← 나의 프로젝트는 email을 식별자로 사용하고있음을 인지
◦
이 식별자는 사용자의 로그인 시 사용되며, 스프링 시큐리티는 이 식별자를 기반으로 사용자를 식별한다.
•
isAccountNonExpired():
◦
사용자 계정이 만료되었는지 여부를 나타내는 메서드
◦
이 메서드가 true를 반환하면 계정이 만료되지 않았음을 의미한다.
•
isAccountNonLocked():
◦
사용자 계정이 잠겼는지 여부를 나타내는 메서드
◦
이 메서드가 true를 반환하면 계정이 잠기지 않았음을 의미한다.
•
isCredentialsNonExpired():
◦
사용자 자격 증명(비밀번호)이 만료되었는지 여부를 나타내는 메서드
◦
이 메서드가 true를 반환하면 자격 증명이 만료되지 않았음을 의미한다.
•
isEnabled():
◦
사용자 계정이 활성화되었는지 여부를 나타내는 메서드
◦
이 메서드가 true를 반환하면 계정이 활성화되었음을 의미한다.
•
Role.java
◦
시스템에서 관리하는 Role 정보를 저장하는 entity 객체
◦
Role은 Privilege의 컨테이너로써 역할을 수행하기 때문에 하나의 Role은 여러 개의 권한을 포함한다.
◦
Role과 Privilege의 관계는 N:N 관계이다.
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column
private String name;
@JsonIgnore
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "role_privilege",
joinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "privilege_id", referencedColumnName = "id")
)
private List<Privilege> privileges;
}
Java
복사
◦
Role과 Privilege는 “role_privilege” 매핑 테이블을 통해서 관리된다.
▪
privileges: 해당 역할(Role)이 가지는 권한(Privilege) 목록을 나타내는 필드
•
@ManyToMany 어노테이션을 사용하여 다대다 관계를 정의하고, privileges와 Privilege 엔티티 간의 관계를 나타낸다.
◦
privileges 필드를 다대다 관계로 설정하는 것은 권한과 역할 간의 유연한 관계를 나타내기 위해 일반적으로 사용되는 방법이다.
◦
프로젝트의 요구 사항에 따라 다를 수 있지만,
◦
테이블간의 정보를 조회하는 방향 연관 관계를 맺을 때, 다대다 연관 관계를 피하고 중간 테이블을 사용하는 것이 일반적인 패턴으로 공부해왔다.
▪
N:M을 그대로 연결하는것과, N:1+1:N으로 중간테이블로 풀어내는 것은 코드와 데이터베이스 스키마를 간단하게 유지하고자 할 때 선택할 수 있는 옵션이며, 이 방식은 특히 다대다 관계가 간단하고 복잡한 중간 테이블을 만들지 않고도 충분히 관리 가능한 경우에 유용할 수도 있다. 특히 이런 모듈화된 샘플 프로젝트에서 그럴 가능성이 있지만 실제 프로젝트에서는 중간 테이블로 구성해야 할 것이다.
◦
현재 샘플에서는 예제를 간소화하고 초보자들에게 친숙한 개념으로 접근하도록 하기 위해 N:M을 그대로 선택한 것으로 보여진다.
▪
권한(Privilege)과 역할(Role) 간의 관계가 상대적으로 단순하며 샘플 프로젝트 개발자가 ROLE이라는 컬럼을 저장할 필요가 없다고 판단했기 때문으로 보여진다. 실제로 ERD에서도 ROLE이라는 컬럼을 찾을 수 없다.
◦
하지만 나의 프로젝트에서는 USER의 ROLE을 표현하도록 기획되었다.
◦
또는, 내 프로젝트에서도 ROLE을 표현할 필요가 있을까? 목적부터 다시 생각해볼 필요가 있다. → 나의 프로젝트에서는 ROLE 컬럼이 필요한 이유는 권한을 관리하기 위함이었나? 어떤 정보를 주어야 하기때문이었나?
▪
→ ROLE로써 유저 유형을 구분한다하면 API자체에 접근하지 못하게 하는것이 스프링의 의도 → @PRE… → API도 변경되야 하는데, 큰 의미가 없다.
▪
→ 지예, 테이블을 나눔 → 메소드를 1, 2인지 정해야되는것이고 → 만약 2로 간다면, spring security쪽에서
▪
→ 인용, @Pre메소드를 제한하기때문에 메소드 2가 필수적
•
→ 최초 문제 registNum = null 이걸로 문제라는것 → Validation을 생각 안했던것은
◦
판매자 구매자
▪
해당 프로젝트를 제공받은 서비스 관리자 입장에서 어떤 유저의 ROLE을 확인해야 할 가능성이 높다.(그는 개발자가 아니기 때문에 일일히 코드로 원하는 시점에 찾기 어려울 수 있다.) 따라서 그 정보를 DB에 제공해주는 것도 하나의 방안일 것이다.
▪
이렇듯 필요에 따라 User테이블에서 ROLE 컬럼이 확실히 보여져야 한다고 생각되고 있다.
▪
중간 테이블을 사용하면 복잡한 권한 부여 시나리오를 다루기 쉽고 데이터의 일관성을 유지할 수 있는 장점도 있다. → 나의 프로젝트는 중간 테이블로 추가 할 것을 인지
•
fetch = FetchType.EAGER로 설정되어 있으므로, 역할(Role)을 가져올 때 관련된 권한(Privilege) 정보도 즉시 로드하도록 설정.
◦
사용자(User) 테이블에 직접 role 컬럼을 추가하는 것보다 다대다 관계와 중간 테이블을 사용하는 것이 스프링 시큐리티 및 데이터베이스 설계의 권장 방식이다. 중간 테이블을 사용하면 복잡한 권한 부여 시나리오를 다루기 쉽고 데이터의 일관성을 유지할.
◦
@JsonIgnore 어노테이션은 JSON 직렬화에서 불필요한 관계를 제거하기 위함이다.
•
Privilege.java
◦
시스템에서 관리하는 권한 정보를 저장하는 entity 객체
◦
Role과 Privilege의 관계는 N:N 관계이다.
◦
Privilege 엔티티는 권한(Privilege)을 나타내는 클래스로, name 필드로 권한의 이름을 저장합니다.
◦
roles 필드는 권한을 가지는 역할(Role) 목록을 나타내며, ManyToMany 관계로 Role 엔티티와 연결됩니다.
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class Privilege {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column
private String name;
@ManyToMany(mappedBy = "privileges")
private List<Role> roles;
}
Java
복사
이러한 엔티티 구조를 기반으로 역할(Role)과 권한(Privilege)을 관리하며, 사용자(User) 엔티티는 역할(Role)을 가짐으로써 권한을 부여.
이 예제는 스프링 시큐리티를 활용하여 역할(Role) 기반의 접근 제어를 구현하는데 도움이 될 것
Entity이후 3계층과 인증 필터 커스텀과 Config 설정은 -2에서 이어서 다룰 예정
참고자료