kakasoo

HATEOAS을 이용한 유저 권한(Authorization) 설계 본문

프로그래밍/Backend

HATEOAS을 이용한 유저 권한(Authorization) 설계

카카수(kakasoo) 2022. 10. 2. 17:09
반응형

지금 내가 개발하고 있는 서비스는, B2B 특성 상 유저에게 주어질 수 있는 권한이 다양하다.

게임처럼 ( 나는 안해서 정확히 모르지만 ) 브론즈부터 플래티넘까지의 계급이 있다고 이해하기 보다, 세부적인 권한을 설정해야 한다.

가령 우리 고객들은, 자기의 후임에게 아래처럼 권한을 줄 수 있다.

 

"배송지를 새로 추가할 수는 있지만, 기존의 배송지를 수정할 수는 없게 하고 싶고, 직접 결제할 수는 없지만 주문 내역을 볼 수는 있다."

 

우리 서비스 내에서 유저에게 설정해줄 수 있는 권한은 카테고리만 해도 10가지 가량 되고, 세부 항목은 수십 가지가 넘는다.

심지어 이 항목들은, 아직도 기획이 추가되는 단계이고, 점차 확보할 고객 구성에 따라서 더 다양해질 수도 있다.

우리 서비스를 고객들이 어떻게 활용하느냐에 따라 기존의 권한은 추가되고, 수정되고, 또한 합쳐지기를 반복하면서 완성될 것이다.

그러나 이것을 개발하는 것은, 개발자에게 심각한 스트레스가 된다.

 

아직 완성되지 않은 어떤 시스템이, 현재도 동작하지만, 앞으로도 계속 수정이 가해지면서도 이전과 같이 동작하기를 바란다.

일단 나는 권한 카테고리에 따른, 다양한 권한 테이블들을 설계하고, 유저들이 자신의 권한을 조회할 수 있도록 만들었다.

이 과정에서 에러가 발생할 가능을 줄이기 위해 제너릭을 활용해, 내부 값을 추론할 수 있는 구성까지 구현하였다.

구성은 아래 예제 코드를 통해 살펴볼 수 있다.

 

Entity 설계

export class AuthGroupEntity extends CommonEntity {
    // NOTE : 권한을 가진 유저의 Id를 포함해, 권한들을 하나로 묶어 주기 위한 Entity

    @OneToOne(() => AAuthGroupEntity, (detailAuthGroup) => detailAuthGroup.authGroup)
    AAuthGroupName: AAuthGroupEntity;

    @OneToOne(() => BAuthGroupEntity, (detailAuthGroup) => detailAuthGroup.authGroup)
    BAuthGroupName: BAuthGroupEntity;

    @OneToOne(() => CAuthGroupEntity, (detailAuthGroup) => detailAuthGroup.authGroup)
    CAuthGroupName: CAuthGroupEntity;
}

 

 

export class AAuthGroupEntity extends CommonEntity {
    // NOTE : boolean 타입을 가지며, true/false가 on/off를 의미하는 세부 권한 칼럼들이 존재한다고 가정한다.

    @OneToOne(() => AuthGroupEntity, (authGroup) => authGroup.AAuthGroupName)
    @JoinColumn({ name: 'authGroupId', referencedColumnName: 'id' })
    authGroup: AuthGroupEntity;
}
export class BAuthGroupEntity extends CommonEntity {
    // NOTE : boolean 타입을 가지며, true/false가 on/off를 의미하는 세부 권한 칼럼들이 존재한다고 가정한다.

    @OneToOne(() => AuthGroupEntity, (authGroup) => authGroup.BAuthGroupName)
    @JoinColumn({ name: 'authGroupId', referencedColumnName: 'id' })
    authGroup: AuthGroupEntity;
}
export class CAuthGroupEntity extends CommonEntity {
    // NOTE : boolean 타입을 가지며, true/false가 on/off를 의미하는 세부 권한 칼럼들이 존재한다고 가정한다.

    @OneToOne(() => AuthGroupEntity, (authGroup) => authGroup.CAuthGroupName)
    @JoinColumn({ name: 'authGroupId', referencedColumnName: 'id' })
    authGroup: AuthGroupEntity;
}

 

AuthGroup은 각각의 세부 AuthGroup들을 하나로 묶기 위한 Entity로, OneToOne 관계로 다른 권한들과 묶인다.

각각의 AAuth, BAuth, CAuth 등 실제로는 10가지 안팎이 되는 세부 권한 그룹들은, 추가, 삭제, 수정, 조회 등의 칼럼을 가진다.

유저는 이 세부 설정들을 통해서, 저마다 각기 다른 페이지를 보게 된다.

누군가는 자신의 권한에 따라 어떤 버튼을 보거나 보지 못하거나, 또는 비활성화된 버튼을 보게 될 것이다.

그런데 기획 요구사항에 따라 기존에 있던 AAuth의 권한 2~3가지를 하나로 통합할 필요성이 생겼다.

이유는, AAuth의 세 가지 권한 중, 첫번째 권한을 ON한 유저들은 2번째, 3번째 권한을 무조건 ON으로 한다는 통계가 나왔기 때문이다.

모든 유저가, 결국 첫번째 권한을 켜고 끄는 것을 가장 중요시 여긴다면 다른 권한들은 그 아래에 종속된 권한이 된다.

작지만, 불필요한 관리를 발생시킬 수 있기 때문에 우선 우리는 이걸 통합해서 없애버리기로 했다.

 

문제는, 각 API들은 실행에 있어, 그 실행에 필요한 권한 여부를 체크하게끔 되어 있다는 점이다.

 

지금까지는 이걸 가드(Guard)라고 하는, 일종의 미들웨어를 통해서 체크해주고 있었다.

내가 사용하고 있는 가드는, 권한의 종류 수만큼 존재했는데, 가드의 단점은 이미 정의된 상태에서 동적으로 결정될 수 없다는 것이었고,

가드는 지금과 같이 기획 변화에 치명적일 수 밖에 없었다.

즉, 권한 10개 중 1개가 바뀌면 가드도 1개가 바뀌어야 했고, 10개가 바뀌면 10개가 다 바뀌어야 했다.

바뀌는 거면 다행이지만, 바뀌었을 때 타입추론이 안 되고 있다보니, 10개 중 안 바꾼 게 있는지 체크를 하는 것도 곤혹스러운 일이다.

그래서 일단 Entity의 프로퍼티로부터 값을 추론해낼 수 있게, 제너릭한 권한을 작성할 필요를 느꼈다.

 

제너릭을 활용한 세부 권한 추론

export type DetailAuthGroupNames = keyof Pick<AuthGroupEntity, 'AAuthGroupName' | 'BAuthGroupName' | 'CAuthGroupName'>;

async function checkUserAuth<T extends DetailAuthGroupNames>(
    companyId: number,
    userId: number,
    targetAuthGroupName: T,
    authColumnName: keyof AuthGroupEntity[T],
) {}

 

타입스크립트로는 위와 같은 코드의 작성이 가능하다.

제너릭 T는 DetailAuthGroupNames를 확장한 것으로, AuthGroupEntity의 세부항목 그룹인 A,B,C AuthGroupName을 받는다.

따라서 checkUserAuth 라는 함수의 세번째 프로퍼티는 자동으로 셋 중 하나가 추론되어 나온다.

세번째 프로퍼티 T를 입력하는 순간, 네번째 프로퍼티는 AuthGroupEntity[T]에 존재하는 key 값으로 확정된다.

따라서, 유저에게서 ON/OFF를 검증하고자 하는 세부 권한 항목의 이름, 네번째 프로퍼티 역시 자동으로 추론된다.

이로 인해 실수로라도 오탈자를 낼 수 없게끔, 타입을 정확한 Column name으로 강제하는 패턴이 완성됐다.

 

이제 checkUserAuth의 내부 코드를 구현하기만 하면 된다.

 

async function checkUserAuth<T extends DetailAuthGroupNames>(
    companyId: number,
    userId: number,
    targetAuthGroupName: T,
    authColumnName: keyof AuthGroupEntity[T],
) {
    const isCreated = await this.userRepository.findOne({
        relations: {
            authGroup: {
                [targetAuthGroupName]: true,
            },
        },
        where: { id: userId, companyId },
    });

    if (!isCreated) {
        throw new BadRequestException(message.CANNOT_FIND_USER_FOR_AUTH_CHECK);
    }

    if (isCreated.authGroup && isCreated.authGroup[targetAuthGroupName]) {
        // NOTE : detailAuthGroup에서 권한 체크하고자 하는 authColumnName이 일치하고, value가 true인 것이 있으면 권한이 있는 것으로 판단한다.
        const detailAuthGroupKeyValues = Object.entries(isCreated.authGroup[targetAuthGroupName]);
        const isUserHasAuth = detailAuthGroupKeyValues.some(([key, value]) => authColumnName === key && value);
        if (isUserHasAuth) {
            return true;
        }
    }
}

 

일단은 위처럼, 객체에 동적으로 키를 할당할 수 있게 하여, user로부터 authGroup을, authGroup으로부터 세부 권한을 JOIN한다.

그리고 유저의 존재 여부를 검증한다.

유저가 존재한다면, 이제 그 유저의 권한을 체크해주는 로직을 추가하면 되는데, 이 로직이 마지막 if문에서 이루어진다.

이 if문은 유저가 가진 권한 중, 검증하고자 하는 칼럼 이름이 일치하는 것을 찾아, 그게 true/flase인지 확인하여 내보내기만 하면 된다.

이제 권한을 체크하기 위한 서비스 로직은 완성했다.

 

권한에 대한 Health Check 개선

서버가 현재 정상적으로 동작하는지 여부를 확인하기 위해 Health Check 로직이 들어가게 되는데, 이와 비슷한 구성은 상당히 많다.

장바구니에 현재 담긴 물건이 있으면 카트에 불빛이 들어온다던가, 아직 확인하지 않은 알림이 있는 등을 체크하는 경우가 그러하다.

장바구니는 괜찮지만, 알람의 경우에는 사람에 따라서 수백, 수천 개가 읽지 않은 채로 그대로 쌓여있는 경우도 있다.

이 경우에는 상품을 조회할 때, 상품을 조회하는 것보다 알람을 카운트하는 게 더 오래 걸리는 경우가 있다.

따라서 알람을 카운트하는 기간을 제한하거나, 또는 주기적으로 알람을 삭제하게끔 로직을 구성하기도 한다.

하지만 권한은 별개의 문제로, 기간을 가지고 제한하는 일이 있어서는 안 된다.

문제는 권한이 너무나도 거대해지고 있기 때문에, 이걸 빈번하게 호출하는 로직이 과연 괜찮은지에 대한 생각이다.

 

서버에서는 어차피 권한이 막혀 있는 경우 알아서 거부하겠지만, 브라우저에서도 페이지 렌더링을 위해 권한을 계속 호출한다.

이걸 최소한의 빈도로 줄이면서도, 권한을 완벽하게 체크하게 하는 방법은 없을까?

 

HATEOAS의 등장

HATEOAS는 RESTful 4단계 ( 0부터 세면 3번째 ) 에 등장하는 개념이다.

이는 리소스를 호출할 때, 그 응답 값으로 사용자가 호출할 수 있는 다음 동작(method)을 정의하는 방식에 대한 규약이다.

 

HTTP/1.1 200 OK

{
    "account": {
        "account_number": 12345,
        "balance": {
            "currency": "usd",
            "value": 100.00
        },
        "links": {
            "deposits": "/accounts/12345/deposits",
            "withdrawals": "/accounts/12345/withdrawals",
            "transfers": "/accounts/12345/transfers",
            "close-requests": "/accounts/12345/close-requests"
        }
    }
}

 

데이터가 전달될 때, 위의 link와 같이 key,value 쌍으로 다음 번에 호출할 수 있는 API 명세서가 주어진다면 어떨까?

다만, 이것을 정말로 지켜야 한다는 생각은 전혀 없는 상태지만, 이걸 우리 서비스 권한 체계에 응용하면 좋겠다는 생각이 들었다.

이게 적용된다면 매번 호출할 필요없이, 유저가 현재 보고 있는 페이지의 권한만 체크하면 되기 때문에 빈도도 적고 데이터도 작아진다.

따라서 클라이언트의 렌더링 속도가 더 개선되는 데에 도움을 줄 수 있을 것이고,

더 나아가서 서버가 URL 주소를 바꾸게 되더라도 클라이언트가 문제없이 동작할 거라고 확신할 수 있게 된다.

빠르게 동료 개발자들에게 아이디어를 개진한 다음, 이걸 주말 중에 체계를 잡아, 하나의 권한만 우선 도입하기로 했다.

 

const apiUrlMap = {
    targets: [1, 2, 3, 4, 5, 6], // 이전에 1~6를 페이지네이션 했다고 가정한 경우, 또는 전체 폴더
    createFolder: {
        method: 'POST',
        url: 'v2/abcdef/folder',
        // body: {
        //     name: '@string / folder name to change', // client가 줄 값들에 대한 정의를 남길 수는 없을까?
        // },
        // auth: {}, // 이걸 위해 필요한 권한이 무엇인지에 대한 설명이 필요한가?
    },
    deleteFolder: {
        method: 'DELETE',
        url: 'v2/abcdef/folder/{id}',
    },
    updateFolder: {
        method: 'PUT',
        url: 'v2/abcdef/folder/{id}', // {id} 와 {name} 을 replace 해서 사용할 수 있게 한다.
    },
    moveFolder: {
        method: 'POST',
        url: 'v2/abcdef/folder?previous={previous}',
    },
    getProducts: {
        method: 'GET',
        url: 'v2/abcdef/folder/{id}?page={page}&limit={limit}&search={search}&orderName={orderName}&order={order}',
        query: {
            page: {
                type: typeof 1,
                required: false,
            },
            limit: {
                type: typeof 1,
                required: false,
            },
            search: {
                type: typeof '',
                required: false,
            },
            orderName: {
                type: 'enum',
                enum: SearchProductOrderName,
                required: false,
            },
            order: {
                type: 'enum',
                enum: OrderByType,
                required: false,
            },
        },
    },
    // excludeProduct: {} // 권한이 없는 경우에는 후속 동작에 대한 key 자체가 존재하지 않는다.
};

 

내가 정리한 apiUrlMap은 위와 같다.

첫째로, 유저가 권한이 존재하지 않으면 애초에 key,value 쌍 자체가 존재하지 않는다고 가정한다.

둘째로, 권한이 존재할 경우, 어떤 리소스에 대한 권한인지, target의 ids를 배열로 담아준다.

셋째로, 권한의 이름을 제공하며, 권한의 이름을 key로 하는 value 객체는 method와 url을 반드시 포함한다.

넷째로, 권한의 이름은, 클라이언트가 HTML tag의 id나 className으로 활용하여, 페이지를 제어하는 데 사용한다.

 

추가적으로, 추후에 이게 더 빌드업될 수 있다면, API를 보내는 데 필요한 값들을 클라이언트와 서버가 통합해나간다.

클라이언트는 서버가 제공한 것과 동일한 변수 명을 활용하며, 타입 추론되는 replace 함수를 구현, 활용하여 호출이 가능해진다.

클라이언트와 서버가 동일한 TypeScript 언어를 사용한다는 점을 활용해, 필요한 구성들을 npm에 올려 공유하는 것이다.

이로 하여금, 서버가 개발한 것을 클라이언트가 이중 개발하는 일을 방지한다.

 

발상 자체는 여기까지였고, 나머지는 실제 구현이다.

 

클라이언트 코드

list.map(item => 
    <div>
        <button onClick={ () => href(deleteFolder.mapApi({id: item.id, previous: list[index - 1].previous})) }>
        </button> 
    </div>
)

 

이런 식으로 구현하겠다고, 서로 합을 맞춰놓은 상태이다.

추후 이어서 작성한다.

코드 레벨에서 창의적인 아이디어를 제안하는 건 또 오랜만이라 몹시 즐겁다.

반응형