안녕하세요. 저는 미소 SVC Product에서 일하고 있는 허훈입니다. 미소에서는 많은 서비스들이 DynamoDB를 사용하고 있습니다. 이 서비스들은 오래전부터 개발되었기 때문에 모든 코드들은 query
, scan
과 같은 DynamoDB API를 사용합니다. 이런 코드들은 기존 RDBMS에서 SQL을 사용하던 개발자들에겐 조금 이질적인 방법이라 DynamoDB를 처음 접하는 개발자들은 KeyConditionExpression
같은 표현식의 작성방법이나 API의 사용방법에 적응을 해야할 필요가 있었습니다. 그러다 비교적 최근에 DynamoDB에서 SQL과 비슷한 PartiQL을 지원하는 것을 보고 이것을 활용할 수 있는 방법에 대해 고민하게 됐습니다. 이 글에서는 PartiQL이 무엇이고 기존 코드들을 어떤식으로 PartiQL로 옮겨갈 수 있을지 DynamoDB에서 정보를 조회하는 부분에 중점을 두고 다루려고 합니다.
PartiQL이란
AWS 공식 문서를 보면 PartiQL을 아래와 같이 설명하고 있습니다.
- PartiQL은 구조화 데이터, 반구조화 데이터 및 중첩 데이터를 포함하는 여러 데이터 스토어에서 SQL 호환 쿼리 액세스를 제공합니다. Amazon 내에서 널리 사용되며 현재 DynamoDB를 비롯한 여러 AWS 서비스의 일부로 제공됩니다.
즉, PartiQL은 SQL 호환 쿼리 언어이고 실제로 비슷한 문법을 갖고 있습니다.
DyanmoDB에서 PartiQL이 지원되기 전에는 DynamoDB API를 사용해야만 데이터를 읽거나 쓸 수 있었지만 PartiQL이 지원되면서 RDBMS에서 SQL을 사용하는 것과 같이 데이터를 다룰 수 있게 됐습니다.
본격적으로 글을 읽기 전에…
- 아래 사용된 예제 코드는 편의를 위해 PartiQL의 statement는 치환자를 사용하지 않았습니다. 실제 코드에서는 SQL injection 같은 공격의 방지를 위해서라도 치환자를 사용하여 코드를 작성해야 합니다.
- 예제로 사용된 코드와 테이블은 현업에서 실제로 사용하고 있는 구조는 아니고 예시를 위해 구성된 구조입니다. 현업에서 사용하는 구조는 바로 공개하기가 어렵고 예시가 복잡해져서 좀 더 간단한 구조로 대체했습니다.
데이터의 조회
노드에서 DynamoDB의 내용을 읽으려면 aws-sdk에서 지원하는 DynamDB.DocumentClient
의 get
이나, query
, scan
, batchGet
같은 API들을 사용해야 합니다. 이중 scan
은 테이블 내의 모든 아이템들을 가져오기 때문에 filter
조건등으로 반환되는 아이템들을 선별하더라도 O(n)
의 시간 복잡도를 가진 연산을 하며 요금도 모든 아이템을 조회한 요금으로 청구되기 때문에 사용에 주의해야 합니다. 반면 query
는 테이블 생성 시 설정하는 파티션 키(partition key
)를 기준으로 아이템을 검색하고 선별하기 때문에 DynamoDB 사용자라면 이 query
를 사용하여 데이터를 가져오도록 작업을 할 것입니다.
한편 PartiQL의 경우 특정 쿼리 문을 실행하기 위해서는 aws-sdk
에 정의돼있는 DyanmoDB
클래스의 executeStatement
를 사용하기 때문에 insert
, select
, update
, delete
등의 동작을 하나의 메소드로 간편하게 실행할 수 있습니다.
다음의 코드를 비교해봅시다.
// get api를 사용할 때
import aws from "aws-sdk";
const docClient = new aws.DynamoDB.DocumentClient();
const params = {
TableName: "MisoTable",
Key: {
id: 1,
},
};
docClient.get(params, (err, data) => {
// handle data.Item and errors
});
// partiQL을 사용할 때
import aws from "aws-sdk";
const dynamoDb = new aws.DynamoDB();
const statement = `SELECT * FROM "MisoTable" where id = 1`;
const result = await dynamoDb
.executeStatement({
Statement: statement,
})
.promise();
// handle result.Items
위의 코드들은 id
가 파티션 키로 설정돼있는 MisoTable
에서 id
가 1인 아이템을 가져오는 코드입니다. 기존 API를 사용했을 때 params
에 설정했던 키가 PartiQL에서는 where절로 설정됐음을 볼 수 있습니다. 위의 두 코드는 아이템들을 잘 가져오지만 결과의 타입이 조금 다릅니다.
get
을 사용했을 때는 코드에서 바로 사용할 수 있는 오브젝트의 형태가 반환 되지만 PartiQL의 executeStatement
메소드를 사용해서 얻은 결과는DynamoDB.AttributeMap
의 리스트가 반환 되기 때문에 코드에서 바로 사용하기가 좀 불편합니다. 그래서 executeStatement
로 결과를 얻었으면 결과를 변환해 줄 필요가 있으며 aws-sdk
에서 지원해주는 Converter
의 unmarshall
메소드를 사용하여 타입을 변환해줘야 합니다.
아래는 변환의 예제 코드입니다.
import aws from "aws-sdk";
const dynamoDb = new aws.DynamoDB();
const statement = `SELECT * FROM "MisoTable" where id=1`;
const result = await dynamoDb
.executeStatement({
Statement: statement,
})
.promise();
for (const item of result.Items) {
const itemObject = aws.DynamoDB.Converter.unmarshall(item);
}
여러 조건을 갖는 query
를 PartiQL로 변환하기
이제 여러 조건을 갖고 있는 query
코드를 PartiQL로 어떻게 전환할 수 있는지 살펴보겠습니다.
// query api를 사용할 때
const params = {
TableName: "UserTable",
KeyConditionExpression: "id = :id",
FilterExpression: "last_visited_date > :last_visited_date",
ExpressionAttributeValues: {
":id": 1,
":last_visited_date": "2022-07-24",
} as DynamoDB.ExpressionAttributeValueMap,
ProjectionExpression: "id",
};
docClient.query(params, (err, data) => {
// handle data.Item and errors
});
// partiQL을 사용할 때
const statement = `SELECT id FROM "UserTable" where id = 1 and last_visited_date > '2022-07-24'`;
const result = await dynamoDb
.executeStatement({
Statement: statement,
})
.promise();
for (const item of result.Items) {
const itemObject = aws.DynamoDB.Converter.unmarshall(item);
}
query
API를 사용할 때 KeyConditionExpression
은 누락할 수 없는 조건이며 파티션 키의 연산 조건도 =
로 한정됩니다. 만약 KeyConditionExpression
에서 파티션 키에 다른 연산자를 입력했을 경우나 잘못된 표현식을 입력했을 경우 에러를 반환 받게 됩니다.
FilterExpression
의 경우는 다양한 연산자를 지원하는데 =
, <
, <=
, >
, >=
뿐만 아니라 BETWEEN
이나 IN
같은 연산도 지원합니다. 한편 PartiQL에서는 KeyCondition
과 Filter
의 구분이 없으며 모두 where
절에서 조건을 표현해줄 수 있습니다. 이런 사용법이 사용의 편의성은 있지만 주의를 기울이지 않으면 query
조건이 아니라 scan
조건이 돼버려서 의도하지 않게 테이블의 모든 아이템들을 검색하도록 동작할 수 있습니다.
이 점은 AWS에서도 우려가 됐는지 다음의 주의 사항이 표시돼 있습니다.
SELECT 문이 전체 테이블 스캔이 되지 않게 하려면 WHERE 절 조건에서 파티션 키를 지정해야 합니다. 등식 또는 IN 연산자를 사용합니다.
즉, where
조건에는 KeyCondition
과 마찬가지로 파티션 키의 연산 조건이 =
와 IN
으로 한정된다는 의미입니다.
그래서 다음과 같은 select
문은 테이블 전체를 scan
합니다.
const statement = `SELECT id FROM "UserTable" where id > 1`;
id
가 파티션 키라고 하더라도 >
연산은 KeyCondition
에서 지원하지 않기 때문에 만약 위의 조건을 KeyConditionExpression
에 사용하면 에러가 발생할 것입니다.
id
가 1보다 큰 아이템들을 반환 받기 위해서 이 조건은 filter
조건에 들어가야 합니다. 아래는 위의 조건을 각 조건에 따라 구성해본 코드들입니다. 첫번째 파라미터로 query
나 scan
을 실행시키면 에러를 반환할 것이고 두번째 파라미터는 scan
으로 실행시켰을 때만 정상적으로 실행 될 것입니다. // 에러를 반환할 것이다.
const wrongParams = {
TableName: "UserTable",
KeyConditionExpression: "id > :id",
ExpressionAttributeValues: {
":id": 1,
} as DynamoDB.ExpressionAttributeValueMap,
ProjectionExpression: "id",
};
// 아래와 같은 parameter들을 실제로 query method를 사용하여 실행시키면 역시 에러가 난다. KeyCondition이 없기 때문.
// 이 parameter들은 scan method를 사용해서 실행시켜야한다.
const rightParams = {
TableName: "UserTable",
FilterExpression: "id > :id",
ExpressionAttributeValues: {
":id": 1,
} as DynamoDB.ExpressionAttributeValueMap,
ProjectionExpression: "id",
};
파티션 키의 IN
위에서 where
조건에는 =
와 IN
을 사용하여 query
를 할 수 있다고 나와있습니다.=
는 KeyConditionExpression
에서 실행되는 것을 봤는데 그렇다면 IN
은 어떨까요?
const params = {
TableName: "UserTable",
KeyConditionExpression: "id IN (:id1, :id2)",
ExpressionAttributeValues: {
":id1": 1,
":id2": 2,
} as DynamoDB.ExpressionAttributeValueMap,
ProjectionExpression: "id",
};
// 아래의 partiQL처럼 동작하기를 기대한다.
// const statement = `SELECT id FROM "UserTable" where id in (1, 2)`;
위의 코드는 잘 실행될까요?
결론부터 말하자면 위의 코드는 동작하지 않습니다. 그 이유는 KeyConditionExpression
은 IN
연산자를 지원하지 않기 때문입니다. 그렇다면 partiQL에서 사용한 것처럼 여러 키들을 한꺼번에 요청해서 결과를 받는 방법은 무엇이 있을까요?
여러 조건을 넣어보고 알게 된 사실은 query
를 할 때 KeyConditionExpression
에서는 한번에 하나의 파티션 키 만을 허용한다는 것입니다. 그러므로 여러 파티션에 있는 아이템들을 조회하기 위해서는 batchGet
을 사용해야 합니다.
아래 코드는 파티션 키에서 IN
연산을 하는 것과 동일한 결과를 반환합니다.
const params = {
RequestItems: {
["UserTable"]: {
Keys: [{ id: 1 }, { id: 2 }],
ProjectionExpression: "booking_id, due_date_duration",
},
},
} as DynamoDB.DocumentClient.BatchGetItemInput;
docClient.batchGet(params, (err, data) => {
// handle data and errors
});
여기서 사용된 UserTable
은 예시를 간단하게 하기 위해id
를 파티션 키로 설정했고 정렬키(sort key
)는 설정하지 않았습니다. 만약 조회해야 하는 테이블이 파티션 키와 정렬키가 모두 설정돼 있다고 한다면 위의 코드들은 동일한 결과를 줄 수 없습니다.
만약 파티션 키와 정렬키가 UserTable
에 모두 설정이 돼 있다면 PartiQL로 파티션 키만 IN
조건을 주는 코드는 정상 동작 하겠지만 아래 batchGet
을 이용한 코드는 동작하지 않을 것입니다. 그 이유는 batchGet
의 키 설정은 스키마를 정확하게 기술해야 하기 때문에 키가 파티션 키만 설정돼있는 위의 예제에서는 sort key
가 없기 때문에 에러가 발생합니다. 그러므로 저 위의 두 코드는 완전히 동등한 형태의 변환은 아님을 유의해야합니다.
Limit 설정
얼마 안되는 데이터를 조회하는 경우가 아니라면 한번에 요청하여 조회할 수 있는 데이터 양에는 한계가 있습니다.
AWS의 API limit에 대한 내용을 보면
BatchGetItem
A single BatchGetItem operation can retrieve a maximum of 100 items. The total size of all the items retrieved cannot exceed 16 MB.
Query
The result set from a Query is limited to 1 MB per call. You can use the LastEvaluatedKey from the query response to retrieve more results.
Scan
The result set from a Scan is limited to 1 MB per call. You can use the LastEvaluatedKey from the scan response to retrieve more results.
위와 같이 정리돼있습니다.
즉, query
나 scan
은 한번 요청에 최대 1MB, batchGet의 경우 100개의 아이템까지 최대 16MB까지 조회할 수 있습니다. 하지만 보통 한번에 요청에 용량의 최대치를 요청하는 것이 아니라 아이템에 limit
을 설정해줘서 갖고 오는 갯수를 조절하는 방법을 많이 사용합니다.
다음의 코드를 봅시다.
// ExclusiveStartKey를 undefined로 설정해 처음부터 조회한다.
const params = {
TableName: "CustomerDeviceTable",
KeyConditionExpression: "os_type = :os_type and customer_id > :customer_id",
ExpressionAttributeValues: {
":os_type": "ios",
":customer_id": 1,
} as DynamoDB.ExpressionAttributeValueMap,
ProjectionExpression: "os_type, customer_id",
ExclusiveStartKey: undefined,
Limit: 5,
};
docClient.query(params, (err, data) => {
// handle data.Item and errors
});
이 코드는 os_type
이 ios
이고 customer_id
가 1 이상인 아이템들을 조회하는 코드입니다.
여기서 CustomerDeviceTable
은 os_type
을 파티션 키로 설정하고 customer_id
를 정렬키로 설정했습니다. 위에서 알아본 것처럼 파티션 키는 =
연산만을 허용하지만, 정렬키의 경우 다양한 연산을 지원하기 때문에 query
로 필요한 아이템들만 반환 받을 수 있습니다. 그리고 ExclusiveStartKey
와 Limit
을 설정하여 페이지 기능을 구현할 수 있었습니다. ExclusiveStartKey
는 query
를 실행했을 때 data.LastEvaluatedKey
로 반환 받으며 형태는 아래처럼 마지막 아이템의 파티션 키와 정렬키로 구성됩니다.
console.log(data.LastEvaluatedKey);
// {os_type: 'ios', customer_id: 5}
limit
이라는 키워드를 인식할까요? const statement = `SELECT id FROM "CustomerDeviceTable" where os_type = 'ios' and customer_id > 1 limit 5`;
위 코드는 기대와는 다르게 에러를 반환합니다.
PartiQL에서는 limit
라는 키워드를 지원하지 않으며 limit
을 설정하려면 DynamoDB.Types.ExecuteStatementInput
에서 Limit
을 설정해줘야 합니다.
const statement = `SELECT id FROM "CustomerDeviceTable" where os_type = 'ios' and customer_id > 1`;
const result = await dynamoDb
.executeStatement({
Statement: statement,
Limit: 5,
})
.promise();
위에서 결과를 보면 result.NextToken
에 뭔가 인코딩된 문자열이 같이 내려오는 것을 볼 수 있습니다. 해당 NextToken이 기존의 LastEvaluatedKey
처럼 다음 아이템의 시작점을 설정할 수 있는 값입니다.
const statement = `SELECT id FROM "CustomerDeviceTable" where os_type = 'ios' and customer_id > 1`;
const result = await dynamoDb
.executeStatement({
Statement: statement,
Limit: 5,
NextToken:
"pDJrNOVAUqaZl48pJa5Deez/jfe8bcwPQ2+0GP1oBIhw9VYFTEqbSazBe8zBFRkXkppPS9FnRgeqJDC/nsfDAyEaDeFtYUqkIvEn8J9f7/yCbSUca/DNTzHW8DdmbkA0EpolRRqzK5xcuAbjITbyL5G7mUHOzEdVQOOqwwKHVd7iHolRqhSn5DvE1cX6nnMji+t2Ok+mhd/3ONK22PijOQT8XeNVsgh+fL+fZIlhL3LsahGUAf9m2sn8R2jXs5wHDQmRUXLfhfQRr8B0wjOsgiZMHdb7Uy9S1Dt+jX9sao0O+1RvzFYzThCLfmvq2XQOLhaj8SSy+m4mqrv2h+kNWKyZYsuWuQHdT1xSjuEaFOlP5gZvKnD5Y8Etu4t9PZuyUh6FsjEiHkx8ngbf07z6PQ92gQ==",
})
.promise();
filter
와 함께 쓰이는 limit
의 동작
CustomerDeviceTable
이 아래와 같이 구성돼 있다고 가정해보겠습니다.
os_type (partition key) | customer_id (sort key) | created_at |
---|---|---|
ios | 1 | 2022-07-10T07:00:00.000Z |
ios | 2 | 2022-07-11T07:00:00.000Z |
ios | 3 | 2022-07-12T07:00:00.000Z |
ios | 4 | 2022-07-13T07:00:00.000Z |
ios | 5 | 2022-07-14T07:00:00.000Z |
android | 6 | 2022-07-15T07:00:00.000Z |
const params = {
TableName: "CustomerDeviceTable",
KeyConditionExpression: "os_type = :os_type",
FilterExpression: "created_at in (:created_at1, :created_at2)",
ExpressionAttributeValues: {
":os_type": "ios",
":created_at1": "2022-07-10T07:00:00.000Z",
":created_at2": "2022-07-13T07:00:00.000Z",
} as DynamoDB.ExpressionAttributeValueMap,
ProjectionExpression: "os_type, customer_id, created_at",
ScanIndexForward: true,
Limit: 3,
};
보통 위의 코드는 아래의 결과를 기대하고 만듭니다.
[
{
"os_type": "ios",
"customer_id": 1,
"created_at": "2022-07-10T07:00:00.000Z"
},
{
"os_type": "ios",
"customer_id": 4,
"created_at": "2022-07-13T07:00:00.000Z"
}
]
하지만 실제로 위의 코드를 실행시켜보면
{
"os_type": "ios",
"customer_id": 1,
"created_at": "2022-07-10T07:00:00.000Z"
}
이 아이템 하나만 반환 되는 것을 볼 수 있습니다.
왜 이런 일이 생기는 걸까요? 이것은 DynamoDB 내부에서 반환할 아이템을 선별하는 순서와 관계가 있습니다. 위의 코드에서 Limit
을 3이 아니라 5로 설정한다면 처음 예상한 것처럼 2개의 아이템이 모두 반환 될 것입니다. 여기서 filter
의 동작은 Limit
보다 나중에 처리되는 것을 알 수 있습니다. 결국 처음에 위 요청에서는 DynamoDB는 limit
설정에 의해 customer_id
1에서 3까지 가져온 후 그 안에서 filter
가 적용돼 customer_id
가 1인 아이템만 반환 된 것입니다. 이 때 aws에서는 반환 받은 값은 하나의 아이템이지만 3개의 아이템을 조회했으므로 3만큼의 요금이 부과됩니다. filter
는 DynamoDB 읽기(read)의 사용 요금을 줄여주지 못하기 때문에 위와 같은 쿼리를 만들 때는 주의가 필요합니다. filter
는 단순히 반환이 필요 없는 아이템을 미리 제거해줘서 네트워크 비용을 절감하는 효과 정도가 있을 뿐입니다.
그렇다면 PartiQL에서의 limit
동작은 어떻게 될지 아래의 코드를 살펴보겠습니다.
const statement = `SELECT id FROM "CustomerDeviceTable" where os_type = 'ios' and created_at in ('2022-07-10T07:00:00.000Z', '2022-07-13T07:00:00.000Z') order by customer_id asc`;
const result = await dynamoDb
.executeStatement({
Statement: statement,
Limit: 3,
})
.promise();
위 코드의 결과 역시 하나의 아이템만을 반환하며 위에서 API를 사용하여 데이터를 요청한 것과 동일한 결과 값을 보여줍니다. 결론적으로 API를 사용하나 partiQL을 사용하나 limit
과 filter
의 동작은 동일하다는 것을 알 수 있습니다.
그래서… 뭘 쓰죠?
지금까지 DynamoDB의 API를 사용해 데이터를 조회하는 코드를 PartiQL로 변경하면 어떻게 쓰여질 수 있는지 간략하게 알아봤습니다. aws 공식 문서에도 나와있듯 위 둘의 성능상의 차이는 없으니 자신이 좀 더 선호하는 방법으로 코드를 작성해도 상관은 없을 것 같습니다. 다만 PartiQL의 경우 where
절에서 키(key) 조건과 필터(filter) 조건을 섞어서 사용할 수 있기 때문에 명시적으로 KeyConditionExpression
같은 제약 조건을 설정하는 DynamoDB API보다 좀 더 주의를 기울여야 합니다. 그렇지 않으면 테이블 전체를 scan
하는 코드를 쉽게 만들게 될 것입니다.