[Spring Data JPA] 다중 권한 프로젝트

👋 소개

나중에 구현할 프로젝트에서 DB, Redis는 AWS의 서비스를 사용할 예정이라 도커에서 MySQL과 Redis를 컨테이너로 설치해서 실행했다. 참고로 Redis를 컨테이너로 실행할것이라 기존에 OS에 설치된 Redis는 삭제했다.

전체 코드는 깃허브에서 볼 수 있으며, 전체 코드를 다 설명하지 않고 주요 부분만 설명했다.


📊 도메인 모델과 테이블

도메인 모델은 다음과 같다:

alt text

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

사용자권한(UserRole): 사용자(User)와 권한(Role)을 가지고 있다.

권한(Role): 권한명을 가지고 있다.


이에 따른 테이블은 다음과 같다:

alt text


🏗️ 엔티티

⏰ 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이기 때문에 지연로딩으로 설정해준다.

🏷️ 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-initializationtrue로 설정하면, Hibernate의 스키마 생성 이후에 데이터 소스 초기화(SQL 스크립트 실행)가 수행된다. 즉 spring.jpa.ddl-auto 설정에 대한 것이 수행된 후 실행된다는 것이다.
  • spring.sql.init.mode는 데이터 초기화 시점을 결정하는 옵션으로 다음 세가지 값을 가진다.
    • always: 모든 데이터베이스에 대해 항상 초기화 스크립트를 실행한다.
    • embedded - 기본값으로 내장 데이터베이스에 대해서만 초기화 스크립트를 실행한다.
    • never - 초기화 스크립트를 실행하지 않는다.

기본적으로 resources/data.sql의 스크립트를 실행시킨다.

실행순서는 다음과 같다:

  1. Hibernate가 엔티티를 기반으로 스키마를 생성/업데이트 한다.(ddl-auto 설정에 따라)
  2. schema.sql이 실행된다. (존재하는 경우)
  3. 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.ymlhibernate.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-autonone으로 설정해 스키마 자동 생성 비활성화
  • 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