한 프로젝트에 여러 클라우드 서비스를 사용해보자
1. 들어가며
- 사내 제품 중에서는 하나의 코드베이스로 여러 사업을 진행하는 제품이 있습니다.
- 아무래도 교육 플랫폼 서비스이기 때문에 활용도가 높은데요, 사기업의 임직원 강의를 위해서 사용될 수도 있으며 학교에서 초/중/고교생들의 학습을 위해서 사용되기도 합니다.
- 아무래도 클라우드 시장 점유율의 32%라는 큰 파이를 차지하고 있는 AWS를 자사 서비스에서도 사용하고 있습니다.
- 또한 각 클라우드 서비스마다의 장단점이 있어서, 이에 따라 상황에 맞게 AWS / GCP / NCP의 서비스를 사용하고 있습니다.
- 다만, 사업에 따라서 특정 클라우드를 사용해야 하는 상황이 있는데요, 특히 공공기관/정부와 함께하는 사업이라면 국내 클라우드를 사용해야 합니다.
2. 상황
- 프로젝트에서는 클라우드 서비스 기능 중 이미지 업로드 기능을 사용하고 있습니다.
- 기존에는 aws용 이미지 업로드 모듈이 있었습니다.
- 해당 모듈에서는 s3에 이미지 조회 / 업로드 / 삭제 / 리스트업 기능을 제공하고 있습니다.
- 다만, 이제는 사업에 따라서 이미지 업로드 기능을 다르게 가져가야합니다.
- NCP를 활용하는 사업일 경우 object storage를 사용해야합니다.
- kakao cloud를 활용하는 사업일 경우 metakage를 사용해야합니다.
- AWS를 활용하는 자사 사업일 경우 s3를 사용해야합니다.
- 배포 인스턴스마다(환경변수) 각기 다른 클라우드 서비스의 이미지 업로드 기능을 사용해야합니다.
3. 문제점
- 이제 각각 NCP / Kakao cloud / AWS용 버킷(object storage / metakage / s3) 모듈을 작성해야 합니다.
- 이러한 모듈을 사업(상황)에 맞게 호출하기 위해 하나의 추상화된 모듈을 작성해야 합니다.
- 추상화된 모듈을 호출하고, 사업에 맞게 알맞은 모듈을 호출하는 방식을 선택했습니다.
문제점
-
각각의 서비스의 sdk를 활용해서 로직을 작성하는 부분은 어렵지 않은데요, 다만 NCP에서는 object storage를 사용하는데 있어 AWS S3 API를 호환하여 제공합니다.
-
이로 인해서 기존의 AWS s3 모듈 코드를 똑같이 활용할 수 있는데요, 다만 생성해야하는 S3 객체가 다릅니다.
- NCP의 경우 S3 객체에 gov-ncloudstorage endPoint를 명시해줘야 하는 등의 차이점이 있습니다.
-
물론 아래와 같이 기존 AWS 코드에서 사업에 따라 분기처리하면 작동하기는 합니다.
// aws.js // AWS를 사용한다면 let s3 = null; if (global.STORAGE_TYPE === AWS) { s3 = new AWS.S3({ apiVersion: '2006-03-01', accessKeyId: global.ROOT_ACCESS_KEY, secretAccessKey: global.ROOT_SECRET_ACCESS_KEY, }); } // NCP를 사용한다면 else if (global.STORAGE_TYPE === NCP) { s3 = new AWS.S3({ apiVersion: '2006-03-01', accessKeyId: global.ROOT_ACCESS_KEY, secretAccessKey: global.ROOT_SECRET_ACCESS_KEY, signatureVersion: global.S3_SIGNATURE_VERSION, endpoint: global.S3_ENDPOINT, }); } module.exports = { s3, async getObject() { }, getObjectStream: () => { }, async copyObject() { }, async deleteObject() { }, async upload() { }, async getObjects() { }, async deleteObjects() { } }; -
하지만 관심 분리가 명확하게 되어있지 않아 모듈화의 이점을 살리기는 어렵다는 생각을 했습니다.
-
그렇다고 해서, 중복되는 코드를 두개 작성하면(AWS용 / NCP 용) 관리 포인트가 두개가 늘어버리는 단점이 있었습니다.
- AWS sdk에 변경점이 있다면 두개의 파일을 모두 수정해야 할 것입니다.
4. 해결방안
- 해결방안을 고민하던 와중에 Proxy 객체를 떠올리게 되었습니다.
- NCP용 모듈을 분리해서 ncp용 스토리지 객체를 만들되, 로직은 동일하기 때문에 기존 aws 모듈의 코드를 사용하는 방식을 택했습니다.
- aws 코드를 사용하는 방식은 Proxy를 통해 기존 AWS 모듈의 메서드를 사용하는 방식입니다.
- 해당 방식을 통해서 관심사가 분리된 모듈화의 이점도 살리면서, 불필요하게 관리포인트가 늘어나는 단점을 해결해나갈 수 있었습니다.
5. Proxy
- 코드를 작성하기 이전에, Proxy 객체에 대해서 연구하고 작성하려고 합니다.
5.1 Proxy란
- Proxy 객체를 사용하면 한 객체에 대한 기본 작업을 가로채고 재정의하는 프록시를 만들 수 있습니다.
- 우선 아래 두가지 객체가 사용됩니다.
- target: 프록시할 원본 객체
- handler: 가로채는 작업과 가로채는 작업을 재정의하는 방법을 정의하는 객체입니다.
예시
const target = {
message1: "hello",
message2: "everyone",
};
const handler2 = {
get(target, prop, receiver) {
return "world";
},
};
const proxy2 = new Proxy(target, handler2);
console.log(proxy2.message1); // world
console.log(proxy2.message2); // world
- get() 처리기는 target 객체의 속성 엑세스를 가로챕니다.
- proxy2.message1 / proxy2.message2 를 통해 속성 엑세스를 합니다.
- 이러한 속성 엑세스가 있을때, get() 처리기가 가로채서 handler에 정의한 기능을 하도록 합니다.
5.2 Proxy 객체를 생성하는 법
ProxyConstructor 인터페이스
- ProxyConstructor 인터페이스는 Proxy 객체를 생성하는 방법을 정의합니다.
- 해당 인터페이스를 기준으로 살펴보겠습니다.
interface ProxyConstructor {
/**
* Creates a revocable Proxy object.
* @param target A target object to wrap with Proxy.
* @param handler An object whose properties define the behavior of Proxy when an operation is attempted on it.
*/
revocable<T extends object>(target: T, handler: ProxyHandler<T>): { proxy: T; revoke: () => void; };
/**
* Creates a Proxy object. The Proxy object allows you to create an object that can be used in place of the
* original object, but which may redefine fundamental Object operations like getting, setting, and defining
* properties. Proxy objects are commonly used to log property accesses, validate, format, or sanitize inputs.
* @param target A target object to wrap with Proxy.
* @param handler An object whose properties define the behavior of Proxy when an operation is attempted on it.
*/
new <T extends object>(target: T, handler: ProxyHandler<T>): T;
}
declare var Proxy: ProxyConstructor;
5.2.1 revocable
-
취소 가능한 Proxy 객체를 생성합니다.
-
예시:
const handler = { get(target, property) { return target[property]; } }; const target = { message: "Hello" }; const { proxy, revoke } = Proxy.revocable(target, handler); console.log(proxy.message); // Hello revoke(); // console.log(proxy.message); // Error: revoke된 proxy에서는 'get'을 수행할 수 없다.
5.2.2 new
-
Proxy 객체를 생성합니다.
-
예시:
const handler = { get(target, property) { return target[property]; } }; const target = { message: "Hello" }; const proxy = new Proxy(target, handler); console.log(proxy.message); // Hello
5.3 다양한 트랩 메서드, 사용법
- proxyHandler 인터페이스를 기준으로 메서드들을 확인해보려고 합니다.
interface ProxyHandler<T extends object> {
/**
* A trap method for a function call.
* @param target The original callable object which is being proxied.
*/
apply?(target: T, thisArg: any, argArray: any[]): any;
/**
* A trap for the `new` operator.
* @param target The original object which is being proxied.
* @param newTarget The constructor that was originally called.
*/
construct?(target: T, argArray: any[], newTarget: Function): object;
/**
* A trap for `Object.defineProperty()`.
* @param target The original object which is being proxied.
* @returns A `Boolean` indicating whether or not the property has been defined.
*/
defineProperty?(target: T, property: string | symbol, attributes: PropertyDescriptor): boolean;
/**
* A trap for the `delete` operator.
* @param target The original object which is being proxied.
* @param p The name or `Symbol` of the property to delete.
* @returns A `Boolean` indicating whether or not the property was deleted.
*/
deleteProperty?(target: T, p: string | symbol): boolean;
/**
* A trap for getting a property value.
* @param target The original object which is being proxied.
* @param p The name or `Symbol` of the property to get.
* @param receiver The proxy or an object that inherits from the proxy.
*/
get?(target: T, p: string | symbol, receiver: any): any;
/**
* A trap for `Object.getOwnPropertyDescriptor()`.
* @param target The original object which is being proxied.
* @param p The name of the property whose description should be retrieved.
*/
getOwnPropertyDescriptor?(target: T, p: string | symbol): PropertyDescriptor | undefined;
/**
* A trap for the `[[GetPrototypeOf]]` internal method.
* @param target The original object which is being proxied.
*/
getPrototypeOf?(target: T): object | null;
/**
* A trap for the `in` operator.
* @param target The original object which is being proxied.
* @param p The name or `Symbol` of the property to check for existence.
*/
has?(target: T, p: string | symbol): boolean;
/**
* A trap for `Object.isExtensible()`.
* @param target The original object which is being proxied.
*/
isExtensible?(target: T): boolean;
/**
* A trap for `Reflect.ownKeys()`.
* @param target The original object which is being proxied.
*/
ownKeys?(target: T): ArrayLike<string | symbol>;
/**
* A trap for `Object.preventExtensions()`.
* @param target The original object which is being proxied.
*/
preventExtensions?(target: T): boolean;
/**
* A trap for setting a property value.
* @param target The original object which is being proxied.
* @param p The name or `Symbol` of the property to set.
* @param receiver The object to which the assignment was originally directed.
* @returns A `Boolean` indicating whether or not the property was set.
*/
set?(target: T, p: string | symbol, newValue: any, receiver: any): boolean;
/**
* A trap for `Object.setPrototypeOf()`.
* @param target The original object which is being proxied.
* @param newPrototype The object's new prototype or `null`.
*/
setPrototypeOf?(target: T, v: object | null): boolean;
}
5.2.1 apply
-
함수 호출을 가로챕니다.
-
예시
const handler = { apply(target, thisArg, argumentsList) { console.log(`Called with args: ${argumentsList}`); return target.apply(thisArg, argumentsList); } }; const proxy = new Proxy(function() {}, handler); proxy(1, 2); // Called with args: 1,2
5.2.2 construct
-
new 연산자를 가로챕니다.
-
예시
const handler = { construct(target, args) { console.log(`Construct called with args: ${args}`); return new target(...args); } }; const proxy = new Proxy(function() {}, handler); new proxy(1, 2); // Construct called with args: 1,2
5.2.3 defineProperty
-
Object.defineProperty()를 가로챕니다.
-
예시
const handler = { defineProperty(target, property, descriptor) { console.log(`Defining property ${property}`); return Reflect.defineProperty(target, property, descriptor); } }; const proxy = new Proxy({}, handler); Object.defineProperty(proxy, 'prop', { value: 42 });
5.2.4 deleteProperty
-
delete 연산자를 가로챕니다.
-
예시
const handler = { deleteProperty(target, property) { console.log(`Deleting property ${property}`); return Reflect.deleteProperty(target, property); } }; const proxy = new Proxy({ prop: 42 }, handler); delete proxy.prop; // Deleting property prop
5.2.5 get
-
프로퍼티 접근을 가로챕니다.
-
예시
const handler = { get(target, property, receiver) { console.log(`Getting property ${property}`); return Reflect.get(target, property, receiver); } }; const proxy = new Proxy({ prop: 42 }, handler); console.log(proxy.prop); // Getting property prop 42
5.2.6 getOwnPropertyDescriptor
-
Object.getOwnPropertyDescriptor()를 가로챕니다.
-
예시
const handler = { getOwnPropertyDescriptor(target, property) { console.log(`Getting descriptor for ${property}`); return Reflect.getOwnPropertyDescriptor(target, property); } }; const proxy = new Proxy({ prop: 42 }, handler); Object.getOwnPropertyDescriptor(proxy, 'prop');
5.2.7 getPrototypeOf
-
Object.getPrototypeOf()를 가로챕니다.
-
예시
const handler = { getPrototypeOf(target) { console.log('Getting prototype'); return Reflect.getPrototypeOf(target); } }; const proxy = new Proxy({}, handler); Object.getPrototypeOf(proxy);
5.2.8 has
-
in 연산자를 가로챕니다.
-
예시
const handler = { has(target, property) { console.log(`Checking existence of ${property}`); return Reflect.has(target, property); } }; const proxy = new Proxy({ prop: 42 }, handler); console.log('prop' in proxy); // Checking existence of prop
5.2.9 isExtensible
-
Object.isExtensible()를 가로챕니다.
-
예시
const handler = { isExtensible(target) { console.log('Checking if extensible'); return Reflect.isExtensible(target); } }; const proxy = new Proxy({}, handler); Object.isExtensible(proxy); // 'Checking if extensible'
5.2.10 ownKeys
-
Reflect.ownKeys()를 가로챕니다.
-
예시
const handler = { ownKeys(target) { console.log('Getting own keys'); return Reflect.ownKeys(target); } }; const proxy = new Proxy({ a: 1, b: 2 }, handler); console.log(Object.keys(proxy)); // Getting own keys
5.2.11 preventExtensions
-
Object.preventExtensions()를 가로챕니다.
-
예시
const handler = { preventExtensions(target) { console.log('Preventing extensions'); return Reflect.preventExtensions(target); } }; const proxy = new Proxy({}, handler); Object.preventExtensions(proxy);
5.2.12 set
-
프로퍼티 설정을 가로챕니다.
-
예시
const handler = { set(target, property, value, receiver) { console.log(`Setting property ${property} to ${value}`); return Reflect.set(target, property, value, receiver); } }; const proxy = new Proxy({}, handler); proxy.prop = 42; // Setting property prop to 42
5.2.13 setPrototypeOf
-
Object.setPrototypeOf()를 가로챕니다.
-
예시
const handler = { setPrototypeOf(target, prototype) { console.log('Setting prototype'); return Reflect.setPrototypeOf(target, prototype); } }; const proxy = new Proxy({}, handler); Object.setPrototypeOf(proxy, {});
6. Proxy로 모듈 작성하기
- 위 Proxy 객체에 대해 알아본 바로, 현재 상황에서 사용할만한 트랩 메소드는 get()입니다.
- 각 클라우드 서비스 객체 업로드 모듈을 추상화한 storage.js`모듈에서는, 각 메서드(e.g 업로드, 조회 등)가 프로퍼티로 존재합니다.
- libStroage.upload()의 형식으로 호출됩니다.
- 이러한 상황에서, get() 트랩 메소드를 통해 프로퍼티 접근을 가로채고, NCP에 대한 요청에 대해 코드 베이스가 같은 AWS 모듈을 호출해서 사용하려고 합니다.
- 각 클라우드 서비스 객체 업로드 모듈을 추상화한 storage.js`모듈에서는, 각 메서드(e.g 업로드, 조회 등)가 프로퍼티로 존재합니다.
6.1 구성 목표
flowchart TD
C{storage.js} -->|AWS s3 객체| D[aws.js]
C --> E[ncp.js]
C --> F[metakage.js]
E --> G{Proxy}
G --> |NCP s3 객체| D
- 사용단에서는 storage.js만을 호출합니다.
- 환경변수에 맞게 storage.js에서는 metakage.js / aws.js / ncp.js 모듈을 호출합니다.
- e.g)
- STORAGE_TYPE === aws → aws.js
- STORAGE_TYPE === ncp → ncp.js
- STORAGE_TYPE === metakage → metakage.js
- e.g)
- ncp.js의 경우 NCP를 바라보는 S3객체를 사용한다는 점 외에는 aws.js의 비즈니스로직이 동일하기 때문에, Proxy로 aws.js 모듈의 메서드를 S3객체를 매개변수로 넣어 호출합니다.
6.2 공용 모듈 작성
// 비즈니스에 맞게 필요한 클라우드 서비스의 모듈을 불러옴
const libStorages = {
aws: require('../../libs/aws'),
metakage: require('../../libs/metakage'),
ncp: require('../../libs/ncp'),
};
const libStorage = libStorages[STORAGE_TYPE];
// 비즈니스에 맞게 옵션명을 변경해줌
const OPTION_NAME_MAP = {
project: {
aws: 'bucket',
metakage: 'container',
ncp: 'bucket',
},
...
};
const convertOption = (option) =>
Object.keys(option || {}).reduce(
(acc, optionName) => ({
...acc,
[OPTION_NAME_MAP[optionName]?.[STORAGE_TYPE] || optionName]:
option[optionName],
}),
{}
);
// 각 모듈의 실제 구현부를 호출
module.exports = {
upload: async (option, callback) => {
const uploadResult = await libStorage.upload(convertOption(option));
callback?.(uploadResult?.data);
return uploadResult;
},
getObjectStream: async (option) =>
libStorage.getObjectStream(convertOption(option)),
...
};
- 다음과 같이 비즈니스에 따라 다른 모듈(AWS, NCP, metakage)이 호출되도록 공용 모듈을 하나 만들었습니다.
- 해당 모듈로 인해서, 비즈니스 로직단에서는 어떠한 모듈을 사용할지에 대해 신경쓰지 않습니다.
- e.g) storage.upload() 와 같이 호출하면, 이미 설정된 환경변수(STORAGE_TYPE)에 의해 의도된 모듈이 호출되고 사용됩니다.
- 이러한 공용 모듈이 있는 상황에서, Proxy 객체를 활용해서 간단하게 AWS 로직을 사용하는 NCP 모듈을 만들어보겠습니다.
6.3 AWS, NCP 모듈
기존 AWS 모듈
// aws.js
const s3 = new AWS.S3({
apiVersion: '2006-03-01',
accessKeyId: global.ROOT_ACCESS_KEY,
secretAccessKey: global.ROOT_SECRET_ACCESS_KEY,
});
module.exports = {
s3,
async getObject(options, s3Instance = s3) {
// code
},
getObjectStream: (options, s3Instance = s3) => {
// code
},
async copyObject(options, s3Instance = s3) {
// code
},
async deleteObject(options, s3Instance = s3) {
// code
},
async upload(options, s3Instance = s3) {
// code
},
async getObjects(options, s3Instance = s3) {
// code
},
async deleteObjects(options, s3Instance = s3) {
// code
}
};
- 기존에 사용하던 AWS s3용 메서드들이 모인 모듈입니다.
- NCP에 대한 요청일 경우 전달받은 프로퍼티를 Proxy 객체로 해당 AWS 모듈의 프로퍼티를 호출하도록 할 것 입니다.
- 호출할때, 옵션(key, bucket 등) 뿐만 아니라 NCP S3객체를 넘겨주도록 하였다.
- 기존 AWS에 대한 요청일 경우 디폴트로 최상단에 선언한 AWS용 S3객체를 사용하도록 하였다.
NCP 모듈
const AWS = require('aws-sdk');
const aws = require('#core/libs/aws');
const ncpS3Instance = new AWS.S3({
apiVersion: '2006-03-01',
accessKeyId: global.ROOT_ACCESS_KEY,
secretAccessKey: global.ROOT_SECRET_ACCESS_KEY,
signatureVersion: global.S3_SIGNATURE_VERSION,
endpoint: global.S3_ENDPOINT,
});
// ncp의 object storage는 aws-sdk를 같이 사용하지만 route53 기능을 지원하지 않음
const route53Methods = ['resizeCoverImage', 'getRecord', 'setRecord'];
const applyNcpS3Instance = (method) => (target, ...args) =>
target[method](...args, ncpS3Instance);
// 메서드 맵핑 객체 생성
const methodMapping = {
upload: applyNcpS3Instance('upload'),
getObjectStream: applyNcpS3Instance('getObjectStream'),
getObjects: applyNcpS3Instance('getObjects'),
deleteObject: applyNcpS3Instance('deleteObject'),
deleteObjects: applyNcpS3Instance('deleteObjects'),
copyObject: applyNcpS3Instance('copyObject'),
};
// 프록시를 사용하여 AWS SDK의 특정 메서드를 재정의
const awsProxy = new Proxy(aws, {
get: (target, property, receiver) => {
// Route53 메서드를 비활성화
if (route53Methods.includes(property)) {
return () => {};
}
// 메서드 맵핑을 사용하여 메서드를 재정의
if (property in methodMapping) {
return (...args) => methodMapping[property](target, ...args);
}
// 다른 모든 메서드는 원래의 메서드를 반환
return Reflect.get(target, property, receiver);
},
});
module.exports = awsProxy;
- 우선 aws.js에 대한 프록시 객체를 만들었습니다.
- get() 트랩 메서드를 통해 호출을 가로채고, 프록시로 aws.js 프로퍼티 메서드들을 사용하도록 하였습니다.
methodMapping, applyNcpS3Instance
- upload / getObjectStream / getObjects / deleteObject / deleteObjects / copyObject 해당 메소드들에 대해서는 기존 옵션(bucket, key, 등) 과 NCP용 S3객체를 전달해서 실행해야 합니다.
const applyNcpS3Instance = (method) => (target, ...args) =>
target[method](...args, ncpS3Instance);
- target은 aws 모듈입니다.
- …args로 전달받은 옵션을 모두 한번에 처리합니다.
- 옵션들과 함께 ncp S3 객체를 넘겨줍니다.
await libStorage.upload(convertOption(option));
- 위와 같이 사용하였을때, libStorage에서 NCP 모듈를 사용하게 되면 아래와 같이 해석됩니다.
await awsProxy.upload(options);
- ncp모듈에서는 위와 같이 변경됩니다.
const awsProxy = new Proxy(aws, {
get: (target, property, receiver) => {
// Route53 메서드를 비활성화
if (route53Methods.includes(property)) {
return () => {};
}
// 메서드 맵핑을 사용하여 메서드를 재정의
if (property in methodMapping) {
return (...args) => methodMapping[property](target, ...args);
}
// 다른 모든 메서드는 원래의 메서드를 반환
return Reflect.get(target, property, receiver);
},
});
- 이때 target은 aws 모듈, property는 upload가 됩니다.
- property가 정의되어있는 methodMapping에 따라서 새롭게 반환하는데요,
// 메서드 맵핑 객체 생성
const methodMapping = {
upload: applyNcpS3Instance('upload'),
...
};
const applyNcpS3Instance = (method) => (target, ...args) =>
target[method](...args, ncpS3Instance);
- upload의 경우 applyNcpS3Instance('upload') 즉 target[method](...args, ncpS3Instance) = aws.upload(options, ncpS3Instance)로 변환됩니다
- 이렇게해서 aws의 upload 메서드를 사용하면서 동적으로 매개변수로 ncpS3Instance를 전달하여 ncp에 업로드할 수 있게 만들었습니다.
최종 예시
- 환경변수로 STORAGE_TYPE을 ncp로 설정해두고, upload 메소드를 사용하면 다음과 같이 작동합니다.
sequenceDiagram
service.js->>+storage.js: libStorage.upload(options)
storage.js-->>+ncp.js: target - aws.js / property - upload / ...args - option
ncp.js-->>+aws.js: (proxy) upload(options, NCPS3Client)
aws.js-->>- ncp.js: result
ncp.js-->>- storage.js: result
storage.js-->>- service.js: result
- service 단에서 storage.js 메소드 호출
- storage.js에서 환경변수에 맞게 모듈 호출 (ncp.js)
- ncp.js에서 proxy로 ncp s3객체와 함께 aws.js 호출
- 결과값을 모듈을 거쳐서 service로 전달
7. 마무리
- 한 프로젝트에서 여러 클라우드 서비스를 사용하기 위해 모듈화를 진행해보았습니다.
- 또한 중복된 코드로 인한 관리포인트 증가를 막기 위해 Proxy 객체를 활용해 보았습니다.
- 모듈화를 통해 프로젝트 내에서 정적 스토리지 사용 방법이 통일되어 코드가 더 간단해지고 관리도 용이해졌습니다.
- Proxy 객체의 내부 구현에 대해서도 더 알아보아야 하겠습니다.
