[Spring Data JPA] 다중 권한 프로젝트
👋 소개
나중에 구현할 프로젝트에서 DB, Redis는 AWS의 서비스를 사용할 예정이라 도커에서 MySQL과 Redis를 컨테이너로 설치해서 실행했다. 참고로 Redis를 컨테이너로 실행할것이라 기존에 OS에 설치된 Redis는 삭제했다.
전체 코드는 깃허브에서 볼 수 있으며, 전체 코드를 다 설명하지 않고 주요 부분만 설명했다.
📊 도메인 모델과 테이블
도메인 모델은 다음과 같다:

사용자(User): 사용자와 사용자권한(UserRole)은 일대다 관계이다. 사용자의 아이디인 이메일, 비밀번호, 닉네임, 사진, 프로바이더, 삭제 여부, 삭제 시간, 프로바이더(ProviderType)을 가지고 있다. 프로바이더는 로컬(Local), 구글(GOOGLE), 네이버(NAVER)가 있다.
사용자권한(UserRole): 사용자(User)와 권한(Role)을 가지고 있다.
권한(Role): 권한명을 가지고 있다.
이에 따른 테이블은 다음과 같다:

🏗️ 엔티티
⏰ BaseTimeEntity
1
2
3
4
5
6
7
8
9
10
11
12
@Getter
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public abstract class BaseTimeEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
@MappedSuperclass
- JPA 어노테이션으로, 이 클래스가 단독으로 사용되지 않고 다른 엔티티 클래스들의 공통 매핑 정보를 제공하는 상위 클래스임을 나타낸다.
- 이 클래스를 상속받는 엔티티들은 여기에 정의된 필드들을 자동으로 컬럼으로 갖게 된다.
@EntityListeners(AuditingEntityListener.class)
- JPA 엔티티 리스너를 지정하는 어노테이션이다.
AuditingEntityListener는 엔티티의 생성일시와 수정일시를 자동으로 관리해주는 리스너다. 이 리스너는 엔티티가 저장되거나 수정될 때 자동으로 호출되어@CreatedDate와@LastModifiedDate어노테이션이 붙은 필드를 현재 시간으로 설정한다.
@Column(updatable = false)
- JPA 어노테이션으로, 데이터베이스의 컬럼과 매핑됨을 나타낸다.
updatable = false는 이 필드가 한 번 저장된 후에는 갱신되지 않음을 의미한다.
리스너란?
리스너(Listener)는 특정 이벤트가 발생했을 때 자동으로 호출되는 메서드를 가진 객체를 말한다.
JPA 컨텍스트에서 리스너는 엔티티의 생명주기 동안 발생하는 다양한 이벤트를 감지하고 반응하는 역할을 한다.
👤 User
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@Entity
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class User extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
private String nickname;
private String email;
private String picture;
private String password;
@Enumerated(EnumType.STRING)
private ProviderType provider;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<UserRole> userRoles = new HashSet<>();
private boolean deleted;
private LocalDateTime deletedAt;
// == 연관관계 메서드 ==
public void addUserRole(UserRole userRole) {
this.userRoles.add(userRole);
userRole.updateUser(this);
}
// == 생성 메서드 ==
public static <T extends UserRequest> User createLocalUser(T request, PasswordEncoder passwordEncoder, Set<UserRole> userRoles) {
User user = new User();
user.email = request.getEmail();
user.nickname = request.getNickname();
user.password = passwordEncoder.encode(request.getPassword());
user.picture = request.getPicture();
user.provider = ProviderType.LOCAL;
user.deleted = false;
for (UserRole userRole : userRoles) {
user.addUserRole(userRole);
}
return user;
}
public static User createOAuthUser(OAuthAttributes attributes, Set<UserRole> userRoles) {
User user = new User();
user.email = attributes.getEmail();
user.nickname = attributes.getNickname();
user.picture = attributes.getPicture();
user.provider = ProviderType.getProviderType(attributes.getRegistrationId());
user.deleted = false;
for (UserRole userRole : userRoles) {
user.addUserRole(userRole);
}
return user;
}
// == 수정 메서드 ==
public <T extends UserRequest> User update(T request) {
this.nickname = request.getNickname();
this.picture = request.getPicture();
return this;
}
public void updatePassword(String rawPassword, PasswordEncoder passwordEncoder) {
if (rawPassword != null && !rawPassword.isEmpty()) {
this.password = passwordEncoder.encode(rawPassword);
}
}
// == 삭제 메서드 ==
public void delete() {
this.deleted = true;
this.deletedAt = LocalDateTime.now();
}
}
@Table(name = “users”)
- 데이터베이스의 “users” 테이블과 매핑된다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
- 인자 없는 생성자를 protected로 생성한다. 기본 생성자를 막아 값 변경 목적으로 접근하는 것을 차단한다.
@Enumerated(EnumType.STRING)
- JPA에서 Java enum 타입을 데이터베이스 칼럼에 매핑할 때 사용하는 어노테이션이다.
- 기본적으로는 int로 된 숫자가 저장된다. 숫자로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수가 없다.
- 따라서 문자열(
EnumType.STRING)로 저장될 수 있도록 선언한다.
@OneToMany(mappedBy = “user”, cascade = CascadeType.ALL, orphanRemoval = true)
mappedBy = "user"- 양방향 연관관계에서 UserRole 엔티티가 이 관계의 주인임을 나타낸다.
- 실제 외래 키는 UserRole 테이블에 존재함을 의미한다.
cascade = CascadeType.ALL- 캐스케이드 옵션은 부모 엔티티(User)의 특정 동작을 자식 엔티티(UserRole)에도 전파할지 결정한다.
CascadeType.ALL은 모든 종류의 데이터베이스 작업(저장, 수정, 삭제 등)이 User 엔티티에서 UserRole 엔티티로 전파됨을 의미한다.
orphanRemoval = true- 부모 엔티티(User)와의 관계가 끊어진 자식 엔티티(UserRole)를 자동으로 삭제하도록 지시한다.
- User 엔티티의 userRoles 컬렉션에서 특정 UserRole을 제거하면, 그 UserRole 엔티티는 데이터베이스에서도 삭제된다.
addUserRole()
- User와 UserRole은 양방향 연관관계이다.
- UserRole이 연관관계의 주인이다. User의 userRoles에 값을 추가할 때 순수한 객체 관계를 고려하면 양쪽에 다 값을 넣어주어야 한다.
- 따라서 연관간계의 주인인 userRole에도 User을 설정해준다. (
userRole.updateUser(this))
createLocalUser()
ProviderType.LOCAL인 User을 생성하는 메서드다.- 제너릭 메서드를 사용해
UserRequest의 하위 타입인 DTO들이 파라미터로 들어올 수 있게 해주었다.
createOAuthUser()
- 소셜 로그인을 통해 가입하는 User을 생성하는 메서드다.
❓ AccessLevel.PROTECTED로 설정한 이유:
JPA에서 엔티리를 프록시 객체로 생성하는 방식과 관련이 있다.
JPA 구현체는 지연 로딩(lazy loading)을 사용할 때 실제 엔티티를 조회하는 대신에 프록시 객체를 반환한다. 이 프록시 객체는 엔티티의 기본 생성자를 호출해서 만들어지는데, 기본 생성자가 private로 설정되어 있으면 JPA가 이생성자에 접근할 수 없기 때문에 프록시 객체를 생성할 수 없다. 따라서, 엔티티 클래스에서는 protected로 기본 생성자를 두는 것이 관례이다.
이는 외부에서 인스턴스를 직접 생성하는 것을 막으면서도 JPA와 같은 프레임워크가 기본 생성자에 접근할 수 있도록 하기 위함이다.
🎭 UserRole
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Entity
@Table(name = "user_role")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class UserRole extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_role_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "role_id")
private Role role;
public void updateUser(User user) {
this.user = user;
}
// 생성 메서드
public static UserRole createUserRole(Role role) {
UserRole userRole = new UserRole();
userRole.role = role;
return userRole;
}
}
@ManyToOne(fetch = FetchType.LAZY)
FetchType.LAZY- @ManyToOne 어노테이션은 기본이
FetchType.EAGER이기 때문에 지연로딩으로 설정해준다.
- @ManyToOne 어노테이션은 기본이
🏷️ Role
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Role extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "role_id")
private Long id;
@Column(name = "role_name")
private String name;
public Role(String name) {
this.name = name;
}
}
📜 권한 데이터 초기화
처음에는 CommandLineRunner를 이용해 권한 데이터를 초기화했는데, 복잡한 로직이 필요하지 않아서 spring.sql.init.mode 옵션을 활용하기로 했다.
1
2
3
4
5
6
7
spring:
jpa:
defer-datasource-initialization: true
sql:
init:
mode: always
spring.jpa.defer-datasource-initialization를true로 설정하면, Hibernate의 스키마 생성 이후에 데이터 소스 초기화(SQL 스크립트 실행)가 수행된다. 즉spring.jpa.ddl-auto설정에 대한 것이 수행된 후 실행된다는 것이다.spring.sql.init.mode는 데이터 초기화 시점을 결정하는 옵션으로 다음 세가지 값을 가진다.always: 모든 데이터베이스에 대해 항상 초기화 스크립트를 실행한다.embedded- 기본값으로 내장 데이터베이스에 대해서만 초기화 스크립트를 실행한다.never- 초기화 스크립트를 실행하지 않는다.
기본적으로 resources/data.sql의 스크립트를 실행시킨다.
실행순서는 다음과 같다:
- Hibernate가 엔티티를 기반으로 스키마를 생성/업데이트 한다.(ddl-auto 설정에 따라)
- schema.sql이 실행된다. (존재하는 경우)
- data.sql이 실행된다.
1
2
3
4
5
6
7
8
9
10
11
INSERT INTO role (role_name)
SELECT 'ROLE_USER' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM role WHERE role_name = 'ROLE_USER');
INSERT INTO role (role_name)
SELECT 'ROLE_ADMIN' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM role WHERE role_name = 'ROLE_ADMIN');
INSERT INTO role (role_name)
SELECT 'ROLE_MANAGER' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM role WHERE role_name = 'ROLE_MANAGER');
🔄 사용자 권한 수정 로직
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private User updateRoles(AdminUserRolesRequest request, User user) {
user.getUserRoles().clear();
request.getRoles().forEach(roleName -> {
Role role = roleRepository.findByName(roleName)
.orElseThrow(() -> new CustomException(ErrorCode.ROLE_NOT_FOUND));
UserRole userRole = UserRole.createUserRole(role);
user.addUserRole(userRole);
});
return user;
}
@Transactional
public AdminUserResponse updateUserRoles(Long id, AdminUserRolesRequest request) {
User findUser = userRepository.findById(id)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
User updateUser = updateRoles(request, findUser);
return AdminUserResponse.builder()
.email(updateUser.getEmail())
.nickname(updateUser.getNickname())
.picture(updateUser.getPicture())
.roles(updateUser.getUserRoles().stream()
.map(userRole -> userRole.getRole().getName())
.collect(Collectors.toSet()))
.provider(updateUser.getProvider().name())
.build();
}
AdminUserService에 있는 사용자의 권한을 수정하는 로직이다.
orphanRemoval = true
- 목적: User와의 관계가 끊긴 UserRole 자동으로 삭제
- 동작:
user.getUserRoles().clear()는 해당 User에 연관 모든 UserRole을 제거하는 역할을 한다. 따라서 연관된 모든 UserRole 엔티티가 데이터베이스에서 삭제된다.
cascade = CascadeType.ALL
- 목적: User 엔티티 변경 시 연관된 UserRole 자동 반영
- 동작:
user.addUserRole(userRole)로 User를 변경했지만 연관된 UserRole의 변경 사항도 자동 감지되어 추가되거나 제거된 역할이 데이터베이스에 반영된다.
🔍 사용자 전체 조회 페이징 최적화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Transactional(readOnly = true)
public AdminUserListResponse getAllUsers(int page, int size) {
Page<User> userPage = userRepository.findAllActiveUsers(PageRequest.of(page, size));
List<AdminUserResponse> adminUserResponse = userPage.getContent().stream()
.map(this::convertToAdminUserResponse)
.collect(Collectors.toList());
return AdminUserListResponse.builder()
.users(adminUserResponse)
.totalPages(userPage.getTotalPages())
.totalElements(userPage.getTotalElements())
.currentPage(userPage.getNumber())
.build();
}
private AdminUserResponse convertToAdminUserResponse(User user) {
Set<String> roles = user.getUserRoles().stream()
.map(userRole -> userRole.getRole().getName())
.collect(Collectors.toSet());
return AdminUserResponse.builder()
.email(user.getEmail())
.roles(roles)
.provider(user.getProvider().name())
.nickname(user.getNickname())
.build();
}
깃허브 코드
AdminUserService에 있는 사용자 전체 조회 페이징 로직이다.
문제 상황
- 페이징 + 컬렉션 엔티티 동시 조회 시 성능 이슈 발생
- N+1 문제: 연관된 엔티티를 조회할 때마다 추가 쿼리 발생
최적화 전략
application.yml에hibernate.default_batch_fetch_size적용
최적화 효과
- 쿼리 호출 수 감소:
1 + N->1 + 1 - DB 데이터 전송량 감소
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
select ur1_0.user_id, ur1_0.user_role_id, ur1_0.role_id from user_role ur1_0 where ur1_0.user_id in (?, ?, ?, ?, ?, ?, ?,...) select r1_0.role_id, r1_0.role_name from role r1_0 where r1_0.role_id in (?, ?, ?, ?, ?, ?, ?,...)
default_batch_fetch_size의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택하는 것을 권장한다. 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기 도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다. 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는 지로 결정하면 된다.
- 김영한님 JPA 강의 -
🧪 테스트 환경 설정
/test/resources/application.yml에 다음과 같은 코드를 작성했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:testdb
username: sa
password:
jpa:
hibernate:
ddl-auto: none
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 1000
defer-datasource-initialization: false
sql:
init:
mode: always
data:
redis:
host: localhost
port: 6379
# Test OAuth
security:
oauth2:
client:
registration:
google:
client-id: test
client-secret: test
scope: profile,email
naver:
client-id: test
client-secret: test
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
scope: email,name,profile_image
provider:
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: response
logging.level:
org.hibernate.SQL: debug
com.yoonji.adminproject: debug
- H2 인메모리 데이터베이스를 사용
- JPA
ddl-auto를none으로 설정해 스키마 자동 생성 비활성화 - OAuth 테스트를 위한 더미 설정
test/resources/schema.sql
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
CREATE TABLE IF NOT EXISTS role
(
role_id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP,
role_name VARCHAR(255),
CONSTRAINT pk_role PRIMARY KEY (role_id)
);
CREATE TABLE IF NOT EXISTS users
(
user_id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP,
nickname VARCHAR(255),
email VARCHAR(255),
picture VARCHAR(255),
password VARCHAR(255),
provider VARCHAR(255),
deleted BOOLEAN NOT NULL,
deleted_at TIMESTAMP,
CONSTRAINT pk_users PRIMARY KEY (user_id)
);
CREATE TABLE IF NOT EXISTS user_role
(
user_role_id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP,
user_id BIGINT,
role_id BIGINT,
CONSTRAINT pk_user_role PRIMARY KEY (user_role_id)
);
ALTER TABLE user_role
ADD CONSTRAINT FK_USER_ROLE_ON_ROLE FOREIGN KEY (role_id) REFERENCES role (role_id);
ALTER TABLE user_role
ADD CONSTRAINT FK_USER_ROLE_ON_USER FOREIGN KEY (user_id) REFERENCES users (user_id);
test/resources/data.sql
1
2
3
4
5
6
7
8
9
10
11
INSERT INTO role (role_name)
SELECT 'ROLE_USER'
WHERE NOT EXISTS (SELECT 1 FROM role WHERE role_name = 'ROLE_USER');
INSERT INTO role (role_name)
SELECT 'ROLE_ADMIN'
WHERE NOT EXISTS (SELECT 1 FROM role WHERE role_name = 'ROLE_ADMIN');
INSERT INTO role (role_name)
SELECT 'ROLE_MANAGER'
WHERE NOT EXISTS (SELECT 1 FROM role WHERE role_name = 'ROLE_MANAGER');
Leave a comment