본문 바로가기
이슈와해결

일부러 정규화를 하지 않는 스키마는 어떨까? - 인가 프로세스에서 권한 관련 스키마 최적 설계 탐구 (feat. EAV, JsonB)

by Renechoi 2023. 12. 18.

개요

사내 시스템 인가 프로세스를 개발할 기회가 있었다. 당시 해당 프로젝트의 개발 초기 단계에서 권한 관련 데이터베이스 스키마 구성에 대해 고민했었다. 해당 프로젝트에서 다루는 역할이 두 가지 밖에 없었고, 추가적인 역할 생성이 제한될 것으로 예상되는 상황이었기 때문이다. 무엇보다 향후 사내에서 다른 분산 서비스들을 모두 아우르는 통합 인증 인가 시스템을 개발하려는 계획이 있었다. 따라서 해당 프로젝트에서 구현하는 인증 인가를 적은 리소스를 사용하도록 효율적으로 구성해도 괜찮을 것 같았다.

 

그의 일환으로 권한 스키마에 대해 URL 자원 관리를 별도의 테이블로 분리하는 전통적인 접근법의 적합성을 관리와 성능 측면에서 고민해보았다. 이 글은 당시 관련 주제에 대해 탐구하며 정리했던 글이다. 주요 용어와 코드는 그대로 사용하지 않고 컨셉용으로 대체하였다.

 


목차

  1. 개요
  2. 인가 시나리오
  3. 일반적인 정규화 기반의 스키마 설계
  4. 정규화를 배제한 스키마 설계
  5. 성능 향상 및 확장성 고려
  6. 직관성과 단순성
  7. 다른 대안 방식
  8. 결론

 

인가 시나리오

현재 설계된 인가 시나리오는 다음과 같습니다.

 

먼저 클라이언트로부터 인가된 HTTP 요청이 들어오면 인가 필터를 통해 요청 처리를 시작합니다. 이 필터는 요청의 URI와 HTTP 메서드, 사용자 ID를 통해 캐시된 사용자 정보를 조회하고, 이 정보를 바탕으로 사용자의 접근 권한을 판단합니다. 사용 판단 케이스는 두 가지로 나뉩니다.

 

첫 번째는 사용자의 특정 주차장 권한 판단으로, URI에 포함된 주차장 접근 권한이 없는 경우 접근을 거부합니다.

 

두 번째는 특정 기 정의된 URL 리소스 정보에 기반한 권한 판단으로, 사용자의 역할에 따라 특정 URL에 대한 접근 권한을 확인합니다. 이를 위해 사용자 권한에 대한 정보는 RDB 형태로 영속화되어 있어야 합니다. 이상의 두 가지 프로세스를 기반으로 최종적으로 클라이언트에게 접근 허용 또는 에러 응답을 반환합니다.

 

 

 

일반적인 정규화 기반의 스키마 설계

스키마 설계 부분은 위의 시나리오 중 두 번째 인가 프로세스에 관한 것입니다. persistence layer 부분에서 URL과 해당 URL에 접근 가능한 권한을 미리 설정해두어야 합니다. 따라서 이를 위한 스키마는 URL 자원에 대한 정보가 포함된 형태로 다음과 같습니다.

 

@Entity
public class GenericResource {
    private int resourceId;
    private String resourcePath;
    private String resourceDescription;
    private String resourceAction;
}

 

일반적인 정규화 기반의 설계를 생각해보면 다음과 같습니다. 위의 스키마에 대한 권한 매핑을 위한 다음 2가지 스키마가 추가로 필요합니다.

 

public class GenericRole {
    private Long id;
    private String genericId;
    private String roleName;
}

public class ResourceRoleMapping {
    private Long id;
    private GenericResource resource; 
    private GenericRole genericRole;
}

 

JPA 애노테이션을 사용하여 다대다 매핑을 구현하면 다음과 같습니다.

 

@Entity
public class GenericResource {
    private int resourceId; 
    private String resourcePath; 
    private String resourceDescription; 
    private String httpMethod; 
}

@Entity
public class GenericRole {
    private Long roleId; 
    private String genericId; 
    private String roleName; 
}

@Entity
public class ResourceRoleMapping {
    private Long mappingId; 

    @ManyToOne
    @JoinColumn(name = "resourceId")
    private GenericResource resource;

    @ManyToOne
    @JoinColumn(name = "roleId")
    private GenericRole genericRole; 
}

 

이와 같은 설계에서 인가 프로세스는 다음과 같이 진행됩니다.

  1. 요청된 URL과 HTTP 메소드를 기반으로 GenericResource 테이블에서 해당되는 자원을 조회합니다.
  2. 조회된 GenericResource의 ID를 사용하여 ResourceRoleMapping 테이블에서 해당 자원에 접근할 수 있는 GenericRole 목록을 가져옵니다.
  3. 사용자의 권한과 비교하여, 해당 GenericRole이 사용자에게 할당되었는지 확인합니다.
  4. 사용자가 해당 GenericRole에 해당하면 접근을 허용하고, 그렇지 않다면 접근을 거부합니다.

따라서 다음과 같은 join 쿼리를 작성할 것입니다.

 

SELECT
    gr.resourcePath,
    gr.httpMethod,
    gr.roleName
FROM
    GenericResource gr
JOIN
    ResourceRoleMapping rrm ON gr.genericResourceId = rrm.genericResourceId
JOIN
    GenericRole gr ON rrm.genericRoleId = gr.genericRoleId
WHERE
    gr.resourcePath = :requestedUrl AND
    gr.httpMethod = :httpMethod;

 

혹은 join을 사용하지 않고 각각의 테이블에서 개별적으로 내용을 조회 후 자바 코드 연산을 통해 조회하는 로직을 구성하면 다음과 같을 것입니다.

 

1. URL 리소스 조회: 먼저 GenericResource 테이블에서 요청된 URL과 HTTP 메소드를 기반으로 해당 자원을 조회.

 SELECT * FROM GenericResource WHERE urlPath = :urlPath AND httpMethod = :httpMethod;
Optional<GenericResource> findGenericResourceByUrlPathAndHttpMethod(String urlPath, String httpMethod);
  1. 권한 매핑 조회: 조회된 GenericResource의 ID를 사용하여 ResourceRoleMapping 테이블에서 해당 자원에 접근할 수 있는 GenericRole 목록을 조회.
 SELECT * FROM ResourceRoleMapping WHERE genericResourceId = :genericResourceId;
List<ResourceRoleMapping> findByGenericResourceId(int genericResourceId);
  1. 사용자 권한 확인: 이후, GenericRole 리포지토리를 사용하여 각 ResourceRoleMapping에서 참조하는 GenericRole 정보 조회.
 SELECT * FROM GenericRole WHERE genericRoleId = :genericRoleId;
Optional<GenericRole> findGenericRoleById(Long genericRoleId);

4. 권한 비교 및 접근 결정: 마지막으로 다음과 같은 자바 코드로 사용자의 권한과 조회된 GenericRole을 비교하여, 사용자가 해당 자원에 대한 접근 권한을 판단합니다.

public boolean authorize(String resourcePath, String httpMethod, String userId) {
    return genericResourceRepository.findGenericResourceByPathAndMethod(resourcePath, httpMethod)
            .map(genericResource -> hasAccessToResource(genericResource, userId))
            .orElse(false); // 자원이 존재하지 않는 경우, 접근 거부
}

private boolean hasAccessToResource(GenericResource genericResource, String userId) {
    List<ResourceRoleMapping> resourceRoles = resourceRoleMappingRepository.findByResourceId(genericResource.getResourceId());
    List<String> userRoles = getUserRoles(userId); // 사용자 권한 조회

    return resourceRoles.stream()
            .map(ResourceRoleMapping::getRoleId)
            .map(genericRoleRepository::findGenericRoleById)
            .filter(Optional::isPresent)
            .map(Optional::get)
            .anyMatch(genericRole -> userRoles.contains(genericRole.getRoleName())); // 권한 확인
}

private List<String> getUserRoles(String userId) {
    return new ArrayList<>();
}

 

정규화를 배제한 스키마 설계

정규화 설계에 대한 의문

현재의 시나리오에서는 두 가지 주요 역할(일반과 출입통제)만을 고려하고 있으며, 추가 역할 생성은 제한적일 것으로 예상됩니다.

 

이러한 상황에서 위에서 작성한 스키마 설계, 즉 URL 리소스와 권한을 별도의 테이블로 관리하는 전통적인 접근 방식에 대한 의문이 제기됩니다. 현재의 접근 방식은 다음과 같은 두 가지 주요 문제점을 가지고 있습니다:

 

  1. 복잡성과 유지 관리: 별도의 테이블로 나누어 관리함으로써, 스키마 관리가 복잡해지고, 데이터 무결성 유지가 어려워질 수 있습니다.
  2. 성능 문제: 매 요청마다 다수의 테이블에 대한 복잡한 JOIN 연산이 필요하게 되어, 시스템의 성능에 영향을 줄 수 있습니다. 특히 규모가 큰 시스템에서는 이러한 성능 문제가 더욱 부각될 수 있습니다.

 

이에 대한 해결책으로, JSON 형태의 데이터를 사용하는 NoSQL 형식의 데이터베이스 접근 방식을 고려해볼 수 있습니다. 유스케이스를 찾아 보니 다음과 같은 대안 설계 모델이 사용되고 있었습니다.

 

  • EAV (Entity-Attribute-Value) 모델: 이 모델은 각 엔티티에 대해 Key-Value 쌍을 저장합니다. 이때 메인 컨텐츠는 value 테이블에 저장하고 해당 value에 매핑되는 속성들을 Attribute 테이블에 저장하는 것이 특징입니다.
  • JSONB 컬럼: JSONB (JSON Binary)는 JSON 데이터를 이진 형식으로 저장하는 PostgreSQL 등의 RDBMS에서 지원하는 데이터 타입입니다. JSONB를 사용하면 JSON 형태의 데이터를 구조화하고, 인덱싱을 통해 빠른 검색과 쿼리 성능을 달성할 수 있습니다.

 

EAV 패턴 예시

  1. Entity 테이블:
customer_id customer_name
1 John Doe
2 Jane Smith
  1. Attribute 테이블:
attribute_id attribute_name attribute_type
1 email text
2 phone text
3 address text
  1. Value 테이블:
customer_id attribute_id value
1 1 mailto:john@email.com
1 2 123-456-7890
2 1 mailto:jane@email.com
2 3 123 Main St

 

EAV 데이터 조회 예시: John Doe의 이메일 주소 조회

   SELECT value
   FROM value
   WHERE customer_id = 1 AND attribute_id = 1;

 

 

JsonB 패턴 예시

고객(Customer) 정보를 저장하는 경우 JSONB 컬럼을 사용하면 다음과 같습니다.

 

CREATE TABLE customers (
    customer_id serial PRIMARY KEY,
    customer_info jsonb
);

-- 데이터 삽입 예시
INSERT INTO customers (customer_info)
VALUES ('{"name": "John Doe", "email": "john@example.com", "age": 30}');

-- JSONB 데이터 조회 예시
SELECT * FROM customers WHERE customer_info ->> 'name' = 'John Doe';

 

정규화를 배제한 스키마 설계 제안

같은 맥락에서 다음과 같은 더 간소화된 스키마 모델을 고려해보았습니다.

 

일반적인 정규화 대신 GenericResource 테이블에 직접 역할 정보를 추가하는 모델입니다.

 

@Entity
public class GenericResource {
    private int urlResourceId;
    private String urlPath;
    private String urlDescription;
    private String httpMethod;
    private String roles; // 역할 정보를 JSON 형태나 쉼표로 구분된 문자열로 저장
}

 

성능과 확장성에 대한 고려

성능 향상

 

복잡한 JOIN 연산을 피하고 단일 테이블에서 필요한 모든 정보를 조회할 수 있습니다. 즉, GenericResource 테이블에서 직접 필요한 권한 정보를 조회하므로 조회 성능이 향상됩니다. 단, 검색 연산이 느려질 수 있지만, DB 자원 사용 관점을 고려한다면 정규화 방식 대비 효율적입니다.

 

확장성

향후 역할 추가 및 변경이 필요한 경우, GenericResource 테이블의 roles 필드만 수정하면 됩니다. 단, 해당 변경이 한 Row가 아닌 여러 개일 경우 같은 작업이 반복되므로 확장성 면에서는 정규화 방식보다 좋지 못합니다.

 

요약하자면 '특정 조건' 일 경우 성능과 확장성 면에서 더 나은 선택일 수 있습니다. 특정 조건이라 함은 다음과 같습니다.

  1. 역할의 수가 제한적이고, 변경 빈도가 낮은 경우: 역할이 적고 변경 빈도가 낮으면, 역할 정보를 직접 GenericResource 테이블에 저장하는 것이 효율적일 수 있습니다. 이 경우 데이터베이스 스키마의 복잡성이 감소하고, 데이터 관리가 용이해집니다.
  2. 데이터 무결성 유지가 우선이 아닌 경우: 데이터 무결성과 정규화가 크게 중요하지 않은 경우, 단순화된 스키마는 관리 및 개발 속도를 향상시킬 수 있습니다.
  3. 읽기 중심의 애플리케이션에서: 읽기 작업이 쓰기 작업보다 훨씬 많은 경우, 정규화된 스키마로 인한 복잡한 JOIN 연산을 피하고 빠른 조회가 가능합니다.

직관성과 단순성

무엇보다 이 방식의 장점은 직관성과 단순성이라고 생각합니다. GenericResource 테이블 하나에서 모든 필요한 정보를 얻을 수 있으므로 데이터 접근을 단순화시켜 인지 비용을 낮춥니다.

 

다른 대안 방식

추가로 고려해 본 다른 방식들입니다.

  1. 뷰(View)를 활용:
    • 방법: 데이터베이스에서 복잡한 쿼리와 조인 연산을 줄이기 위해 뷰를 사용합니다. GenericResource, GenericRole, 그리고 ResourceRoleMapping 테이블을 조합하여 뷰를 생성하고 인가 로직에 필요한 모든 정보를 하나의 결과 집합으로 제공합니다.
    • 장점: 복잡한 쿼리를 단순화하고 코드 수준에서 관리가 용이해집니다.
    • 단점: 별도의 뷰를 생성해야 하는 점에서 번거롭고, 동기화 등의 관리가 필요합니다.
  2. 캐싱 전략 강화:
    • 방법: 사용자의 권한 정보나 URL 리소스 데이터를 캐싱합니다.
    • 장점: 데이터베이스 IO를 줄이고 응답 시간을 단축효과가 있을 것입니다.
    • 단점: 마찬가지로 별도 로직이 필요하며 캐시 무효화 및 동기화 등을 위한 관리가 필요합니다.
  3. 역할 기반의 컬럼 추가:
    • 방법: 각 역할별 접근 가능 여부를 나타내는 불린 컬럼, 예를 들어 isAccessibleByGeneralRole, isAccessibleByGeneral2Role 등을 추가합니다.
    • 장점: 하나의 테이블에서 관리할 수 있어 직관적이고 단순합니다. 성능 면에서도 단일 필드 스키마 방식과 유사할 것입니다.
    • 단점: 역할이 추가될 때마다 스키마 변경이 필요합니다.

3번 대안의 경우 권한을 필드에 녹인 방식을 보다 더 하드하게 적용한 설계라고 생각합니다. 새로운 Role이 추가되지 않을 것이라는 전제가 강력하게 확고하다면 이 방식이 직관성, 단순성, 성능 효율면에서 가장 좋을 것 같습니다.

 

 

 

 


 

레퍼런스 

- https://vladmihalcea.com/how-to-store-schema-less-eav-entity-attribute-value-data-using-json-and-hibernate/

- https://www.mgt-commerce.com/tutorial/magento-eav-model/

 

 

 

반응형