올리브영이 데이터의 품질을 높이기 위한 여정
"고객이 100원 상품을 클릭했는데 결제하고 보니 10,000원 짜리 상품이였다면?" 이커머스에서 이런 일이 발생하면 고객 신뢰도는 급격히 떨어집니다. 문제의 근본 원인은 바로 '데이터 정합성'! 즉, 여러 시스템 간 데이터가 서로 일치하지 않는 것이죠. 올리브영도 예외는 아니었습니다. 기존엔 개발자들이 데이터 정합성을 책임졌지만, 실시간 메시지 기반 시스템으로 전환하면서 상황이 복잡해졌습니다. 하지만 데이터 정합성 또한 QA의 영역이라는 생각에 QA팀에서 데이터 정합성 확보를 위한 다양한 시도를 해왔습니다. 이번 편에서는 그중 하나인 API를 이용한 데이터 정합성 확보 전략에 관해 이야기할 예정입니다.
데이터 정합성이란?
쉽게 말해 "같은 상품 정보가 모든 시스템에서 동일하게 보이는 것"입니다. 립스틱 가격이 15,000원이라고 예를 들어 보겠습니다. 이 립스틱은 상품 관리 시스템, 주문 시스템, 고객용 앱에 동일한 제품으로 등록된 이상 서로 일치하는 정보를 표기해야 합니다. 이를 데이터 정합성이라 합니다.
데이터 정합성을 확보하기 위한 상품통합TF의 히스토리
현재 올리브영의 상품통합TF는 올리브영은 고객에게 더욱 신속하고 정확한 상품 정보를 제공하기 위해 기존 상품의 정보를 새로운 시스템으로 이전하는 과정을 거치는 중입니다.
기존의 배치 기반 데이터 처리 방식을 탈피해 실시간 메시지 기반의 데이터 처리 구조를 새롭게 도입했습니다.
시스템이 완전히 바뀌면서 새로운 과제에 직면했습니다. 데이터가 여러 시스템을 거치며 실시간으로 흐르게 되자, 데이터 정합성이 문제가 발생할 수 있다는 것이었습니다. 즉, 상품 정보가 최종적으로 고객에게 도달하는 과정에서 데이터가 서로 불일치하는 상황이 생길 수 있었습니다. 수많은 상품 정보가 오가는 올리브영에서는 치명적인 문제였습니다.
물론 이 테스트를 기존처럼 상품 정보에 대한 부분을 등록, 수정하면서 최종 테이블에 정상적으로 저장되는 것을 확인할 수는 있지만 모든 경우의 수를 테스트하기에는 한계가 있었습니다.
그래서 상품통합TF에서 낸 아이디어 중 하나가 구간마다 데이터를 조회하는 API를 만들어서 그 API를 통해 데이터 정합성을 확보하는 것이었습니다.
데이터 정합성 확보 전략
2가지 전략을 세웠습니다. 첫 번째는 UI 테스트를 통해 데이터 정합성을 확보하는 것이고, 두 번째는 API를 이용하여 데이터 정합성을 확보하는 것입니다.
전략 1. UI 테스트를 통한 데이터 정합성 확보
UI 테스트는 실제 사용자와 유사한 환경에서 애플리케이션을 테스트하는 방법입니다. 상품 정보에 영향을 미칠만한 항목들을 등록, 수정하여 최종적으로 상품 정보를 제공하는 테이블에 정상적으로 적재되었는지 확인하는 방식입니다. 이 방식으로 기본적인 등록, 수정을 통해서 정상적으로 데이터가 적재되는지를 확인할 수 있었습니다. 이 과정에서는 상품 정보에 영향을 줄 수 있는 항목별로 개발 과정에서 누락되거나, 반영 로직이 잘못되어있는 경우를 찾아낼 수 있었습니다.
전략 2. API를 이용한 데이터 정합성 확보
상품, 프로모션등의 데이터를 등록, 수정할 때 최종 테이블까지 적재되는 데까지는 여러 단계가 있습니다. UI 테스트로는 단계별로 확인하지 않고 최종 테이블에 정상적으로 적제되는지를 확인하였습니다. 최종 테이블은 1개의 테이블을 가지고서 가공되는 것이 아니라 여러 테이블에 저장된 데이터를 가지고서 최종적으로 가공하여 만든 테이블입니다. 그러므로 최종 테이블의 정합성을 정확하게 확인하기 위해서는 단계마다 데이터를 모두 확인하는 것이 좋습니다. 다만 리소스 문제로 인해 단계별 데이터를 모두 확인하는 것에 어려움이 있으니, 각 구간에서 제공하는 데이터 조회 API를 통해서 정합성을 자동으로 확인하면 좋을 것으로 생각했습니다. 그래서 일단 QA 서버에서 테스트하는 기간에 단계의 데이터를 API로 조회하여 자동으로 정합성을 확인하여 알림을 주는 시스템을 만들고 이렇게 만든 시스템을 현재는 STG, 운영에서 스케줄링하여 사용하고 있습니다.
시스템은 Postman과 Teamcity, Datadog을 이용하여 구축하였습니다.
여기서 잠깐! 왜 Postman, Teamcity, Datadog을 이용했는지 궁금하신 분들이 있을 수 있습니다.
- Postman: API 테스트 자동화 도구로 GUI 환경에서 스크립트를 작성하고 관리할 수 있어 각각의 API의 응답값을 비교할 수 있는 로직을 만들 수 있습니다.
- Teamcity: CI/CD 도구로, Postman에서 작성한 테스트 스크립트를 스케쥴링 하여 실행하고 결과를 관리 할 수 있습니다.
- Datadog: 모니터링 및 알림 도구로, 테스트 결과를 시각화하고 이상 징후 발생 시 알림을 받을 수 있습니다. 시스템 상태를 실시간으로 모니터링하는 데 유용합니다.
저희가 정합성을 맞춰야 하는 테이블은 A, B, C, D, 기존 최종 테이블, 신규 최종 테이블 크게 6개의 테이블이 있습니다. (그 외에도 더 있지만 이해를 돕기 위해서 구성도를 간략하게 하였고 테이블도 6개로 축약하였습니다.)

1. 정합성을 자동으로 확인하기 위한 플로우 정의
가장 먼저 정합성을 테스트 해야하는 구간과 어떤 값을 비교해야 할 것인지를 정의 하였습니다. 예시 플로우를 하나 설명드리겠습니다.
Step1) 오늘 날짜에 변경된 상품 리스트를 조회하는 API를 호출하여 조회된 상품 리스트의 상품 코드값을 가져옵니다.
Step2) A테이블에 상품 코드값을 넘겨서 상품 상세 정보를 조회하는 API를 호출합니다.
Step3) B테이블에 상품 코드 값을 넘겨서 상품 상세 정보를 조회하는 API를 호출하여 A테이블과 B테이블에서 조회된 상품 상세 정보를 비교합니다.
2. API 정의서 바탕으로 API 마다 같은 값이 반환되어야 하는 값들 정의
API마다 응닶값의 필드명이 다른 경우도 있고 같은 경우도 있어서 이 부분을 정리하여 정의하였습니다.
3. Postman을 이용하여 스크립트 작성
Postman을 이용하여 각 API를 호출하고 응답 값을 비교하는 스크립트를 작성하였습니다. 각 API의 응답 값에서 비교해야 하는 필드명을 추출하여 비교하는 로직을 작성하였습니다.
각 Step에서 작성된 스크립트와 플로우를 간단하게 설명하겠습니다.
Step1) 오늘 날짜에 변경된 상품 리스트를 조회하는 API를 호출하여 조회된 상품 리스트의 상품 코드값을 추출
{
"modifiedDateStart": "{{todayDate}}",
"modifiedDateEnd": "{{todayDate}}"
}
body에 오늘 날짜를 넣어서 변경된 상품 리스트를 조회하는 API를 호출합니다.
이 경우 todayDate를 넘겨주어야 해서 pre-request script에 오늘 날짜를 구하는 코드를 작성하였습니다.
- pre-request script
// 오늘 날짜를 YYYY-MM-DD 형식으로 생성
let today = new Date();
let yyyy = today.getFullYear();
let mm = String(today.getMonth() + 1).padStart(2, '0');
let dd = String(today.getDate()).padStart(2, '0');
let formattedDate = `${yyyy}-${mm}-${dd}`;
// 환경 또는 글로벌 변수에 저장
pm.environment.set("todayDate", formattedDate);
오늘 날짜에 변경된 API를 조회하게되면 아래와 같은 형태로 넘어오게 됩니다. 이때 받은 number값은 그 다음 A테이블에 상품 상세 정보를 조회하는 API를 호출할 때 사용하게 됩니다.
{
"status": "SUCCESS",
"message": "Requested Successful",
"code": 200,
"data": {
"List": [
{
"number": "A0000000122"
},
{
"number": "A0000000123"
}
]
}
}
받은 응답 값의 상품 번호를 20개씩 자르는 과정을 진행됩니다.
여기서 잠깐! 왜 상품 번호를 20개씩 자르는 과정을 거쳤는지 궁금하신 분들이 있을 수 있습니다.
아래에 알림을 주는 과정을 설명할 예정인데 그 과정에서 한 번에 많은 오류가 발생 시 알림을 줄 수 있는 글자 수를 초과하거나 Postman 또는 Newman테스트 결과서가 잘려서 만들어지는 경우들이 발생하였습니다.
또한 API 자체에도 너무 많은 상품 번호를 넘겨주게 되면 API에서 처리할 수 있는 양을 초과하여 Timeout이 발생하는 때도 있었습니다.
그래서 상품 번호를 20개씩 잘라서 API를 호출하도록 하였습니다.
- post-request script
let res = pm.response.json();
let rawList = res.data.List || [];
// 테스트 제외 상품 등록
let excludeList = [];
// 필터링
let goodsNumbers = rawList
.map(item => item.number)
.filter(num => !excludeList.includes(num));
// 20개씩 잘라서 저장했을 때 현재 테스트 할 index를 환경 변수에 저장
let index = parseInt(pm.environment.get("currentChunkIndex") || "0");
pm.environment.set("currentChunkIndex", index);
// 상품 번호를 20개씩 잘라서 저장
let chunks = [];
let pageSize = 20;
for (let i = 0; i < goodsNumbers.length; i += pageSize) {
chunks.push(goodsNumbers.slice(i, i + pageSize));
}
pm.environment.set("goodsChunks", JSON.stringify(chunks));
// 현재 테스트한 페이지 수의 상품 번호가 null로 넘어오면 테스트 종료
if (chunks[index] == null)
{
pm.execution.setNextRequest(null);
}
Step2) A테이블에 상품 코드값을 넘겨서 상품 상세 정보를 조회하는 API를 호출
A 테이블에서 상품 상세 정보를 조회하기 위해서는 어떤 body로 넘겨주어야 하는지 살펴보면 아래와 같습니다.
{
"goodsNumbers": [
"A0000000122",
"A0000000123"
]
}
Step2에서 받은 number값을 body로 쓰기 위해서는 2번의 post-request, 3번의 pre-request script에 아래와 같이 작성하여 호출하였습니다.
- pre-request script
// Step1에서 넘겨준 상품 정보 (20개씩 잘려서 넘어오는 상품 정보
let chunks = JSON.parse(pm.environment.get("goodsChunks") || "[]");
let currentChunk = chunks[index];
pm.environment.set("currentGoodsNumbers", JSON.stringify(currentChunk));
// 요청해야할 JSON 형식으로 변환
let requestBody = {
goodsNumberList: currentChunk
};
pm.environment.set("finalRequestBody", JSON.stringify(requestBody));
Step2에서 받은 상품 상세정보에 대한 응답 값을 상품 번호 기준으로 빠르게 찾기 위해서 hashmap 방식으로 저장하였습니다.
- post-request script
let res2 = pm.response.json();
let step2Data = res2.data || [];
// 현재 시간에서 10분(600,000ms) 전의 시간 계산 (정합성 정확성을 위해서 Message가 반영되지 않았거나 배치 타이밍에 대한 부분때문에 정합성에 부정확함이 생길 수 있습니다.
// 그래서 10분 이상 지난 데이터만 필터링하여 정합성을 테스트 하는 대상에서 제외합니다
let tenMinutesAgo = Date.now() - 30 * 60 * 1000;
let oneAndHalfHoursAgo = Date.now() - 4 * 60 * 60 * 1000;
// status가 10이 아니고, modifiedDate가 10분 이상 지난 애들만 필터링
let filteredStep2 = step2Data.filter(item => {
let modifiedTime = new Date(item.modifiedDate).getTime();
return modifiedTime < tenMinutesAgo && modifiedTime >= oneAndHalfHoursAgo
});
let goodsNumberList = filteredStep2.map(item => item.goodsNumber);
// goodsNumber로 빠르게 찾기 위해 Hash map 방식으로 저장
let step2Map = {};
filteredStep2.forEach(item => {
step2Map[item.goodsNumber] = item;
});
pm.environment.set("step2Map", JSON.stringify(step2Map));
Step3) B테이블에 상품 코드 값을 넘겨서 상품 상세 정보를 조회하는 API를 호출하여 A테이블과 B테이블에서 조회된 상품 상세 정보를 비교
Pre-request script에서 통해서 B테이블의 데이터를 호출하는 과정은 Step2와 동일하여 설명은 생략하겠습니다. Step3에서는 Post-request script에서 B테이블의 응답 값을 A테이블과 비교하는 과정이 중요합니다. (전체 발췌는 어려워 일부 코드 발췌하였습니다.)
- Step3 post-request script
- 필드명이 같을 경우는 다음 스크립트를 사용하였습니다.
let res3 = pm.response.json();
let step3Data = res3.data || [];
// Step2에서 저장한 Hash map 가져오기
let step2Map = JSON.parse(pm.environment.get("step2Map") || "{}");
// 비교 대상 필드 premiumBrandFlag 제외, statusName
fieldsToCompare = ["goodsName", "status"]; // 원하는 항목 여기에 추가
// 결과 로그
step3Data.forEach(item3 => {
let goodsNo = item3.goodsNumber;
let item2 = step2Map[goodsNo];
fieldsToCompare.forEach(field => {
let val2 = item2[field];
let val3 = item3[field];
pm.test(`${goodsNo} - 필드 테스트: '${field}' A vs B`, function () {
if (val2 !== val3) {
console.error(`❌ ${goodsNo} - '${field}' mismatch | A: ${val2} | B: ${val3}`);
pm.expect.fail(`❌ ${goodsNo} - '${field}' mismatch | A: ${val2} | B: ${val3}`);
}
});
});
});
- 필드명이 다를 경우 다음 스크립트를 사용하였습니다.
// 필드 매핑 정의
const fieldMapping = {
goodsName: "goodsName", goodsSectionCode: "goodsSectionCode", goodsType: "goodsTypeCode", underAgeSalePossibleFlag: "underageSalesFlag", duplicateItemFlag: "multipleOptionFlag", freeDeliveryFlag: "deliveryFreeFlag", progressStateCode: "status", mediaType: "mediaCode", soldOutFlag: "soldOutFlag", searchDisplayFlag: "searchExposureFlag", displayStartDateTime: "displayStartDateTime", displayEndDatetime: "displayEndDatetime"
};
step3Data.forEach(item => {
const goodsNo = item.goodsNumber;
const match = Object.values(step2Map).find(m => m.goodsNumber === goodsNo);
for (const [sourceField, targetField] of Object.entries(fieldMapping)) {
let val2, val3;
val2 = item[sourceField];
val3 = match[targetField];
pm.test(`${goodsNo} - ${sourceField} vs ${targetField} 비교`, function () {
if (val2 !== val3) {
console.error(`❌ ${goodsNo} - 필드 불일치: '${targetField}' (A: ${val3}) vs '${sourceField}'(B: ${val2})`);
pm.expect.fail(`❌ ${goodsNo} - 필드 불일치: '${targetField}'(A: ${val3}) vs '${sourceField}'(B: ${val2})`);
} else {
console.log(`✅ ${goodsNo} - '${sourceField}' vs '${targetField}' 일치`);
}
});
}
});
이렇게 작성된 스크립트를 Postman에서 Postman에서 제공하는 CLI 기능을 통해서 Teamcity, cronjob에 연결하여 테스트를 자동으로 하게 하였습니다.
4. Teamcity OR Cornjob을 이용하여 정기적으로 테스트 실행 및 리포트 생성
for i in {0..999}
do
echo "Running with currentChunkIndex=$i"
# 20개씩 끊어서 테스트를 진행함으로 currentChunkIndex를 이용하여 page수를 증가시켜서 테스트 하도록 함
postman collection run {id} -e {env} --env-var currentChunkIndex=$i --reporters cli,json --reporter-json-export result.json
# 실패 수 추출
PRE_COUNT=$(jq '.run.summary.prerequestScripts.executed' result.json)
# 테스트 한 것이 없으면 break
if [ "$PRE_COUNT" -eq 1 ]; then
break
fi
SUMMARY=$(jq -r '
.run.summary.tests.executed as $executed |
.run.summary.tests.failed as $failed |
"📦 *A <-> B <-> C 테스트 요약[prd] *\n✅ Total: \($executed), ❌ Failed: \($failed)" +
(
[.run.executions[]
| select(.tests != null)
| .tests[]
| select(.error != null)
| " • \(.error.message)"
] | if length > 0 then "\n" + join("\n") else "" end
)' result.json)
# 실패 수 추출
FAILED_COUNT=$(jq '.run.summary.tests.failed' result.json)
# 실패 시에만 메시지 전송
if [ "$FAILED_COUNT" -gt 0 ]; then
curl -X POST -H "Content-Type: application/json" --data "{\"text\": \"$SUMMARY\"}" https://hooks.slack.com/services/...
echo "Slack 메시지 전송 완료"
fi
done
이렇게 작성된 스크립트는 Teamcity 또는 Cronjob에서 정기적으로 실행되며, 테스트 결과를 JSON 형식으로 저장합니다.
그러나 여전히 타이밍적인 이슈가 발생하는 경우가 있어 2번 연속 문제가 발생했을 때 알림이 오길 바랬고 문제가 있는 상품의 번호의 히스토리를 한번에 볼 수 있는 대시보드가 필요했습니다.
5. Datadog을 이용하여 대시보드 구축 및 알림
기존 실행 방식에 datadog에 적재할 저장할 log를 만드는 부분을 추가하였습니다.
# 총 테스트 결과
jq -r '
"Type=API_Monitoring," +
"TotalCase=\(.run.summary.tests.executed)," +
"Passed=\(.run.summary.tests.passed), " +
"Failed=\(.run.summary.tests.failed)"
' result.json | tee -a /Users/app/oliveyoung/Logs/result.log
# 실패 수 추출
FAILED_COUNT=$(jq '.run.summary.tests.failed' result.json)
# 실패 시에만 메시지 전송
if [ "$FAILED_COUNT" -gt 0 ]; then
# 실패 발생 시 오류 메시지를 result.log 파일에 저장
echo "$Datadog\n" >> /Users/app/oliveyoung/Logs/result.log
fi
Datadog Agent를 통해서 Logs폴더 하위에 생성되는 *.log 파일을 읽어가도록 만들었고 Datadog에서 읽어간 로그를 parsing하여 대시보드에 표시하도록 하였습니다.
이렇게 구축된 대시보드는 정합성 테스트 결과를 실시간으로 모니터링할 수 있게 해주며, 문제가 발생했을 때 빠르게 대응할 수 있는 환경을 제공합니다.
일시적인 타이밍으로 인한 오류는 제외하기 위해서 알림은 이 중에 1시간에 2번 이상 같은 오류를 발생시킨 경우에만 알림이 오도록 설정하였습니다.
Datadog에 대해서 조금 더 자세히 알고 싶다면 올리브영 QA는 Datadog을 어떻게 활용하고 있을까?를 참고해 주세요.
현재 운영되고 있는 데이터 정합성 확보를 위한 자동화 테스트 리스트
위에서 기본적인 플로우를 설명해 드렸는데 위의 방식을 변형, 응용하여 현재 운영되고 있는 데이터 정합성 확보를 위한 자동화 테스트 리스트는 다양합니다
API를 이용한 데이터 정합성 테스트를 통해서 얻은 효과
효과는 3가지 측면으로 나눌 수 있습니다.
1. 리소스 절감 측면
테스트 기간의 리소스 절감 측면에서 확인한 수치는 다음과 같습니다.
- 범위 부분 : 75% 절감 - 전체 확인해야하는 구간 중에 75%를 API를 이용한 정합성 자동화 테스트로 진행하였습니다.
- 인적 리소스 부분 : 45% 절감 - 리소스 측면에서 절감 효과가 범위 절감보다 낮은 이유는 상품, 프로모션등을 케이스별로 등록, 수정하는 작업들은 리소스를 많이 소비하기 때문입니다.
2. 수동 테스트로 발견할 수 있는 케이스의 한계 극복
전체 발견 이슈 중 약 51% 이슈 발견 하였습니다.
정합성 자동화 테스트로 발견한 대표적인 이슈 예시를 보면 다음과 같습니다.
- 메시지 발행 타이밍
- 메시지 중복 발행
- 다수 메세지 발행 시 성능 문제
- DB 특정상으로 인한 문제
3. 실 운영 서버에서 타이밍적인 부분, 예외 케이스에 대한 검증 측면
QA서버에서 발견할 수 있는 이슈들의 케이스는 한정적이기 때문에 운영 서버에 API를 통한 자동화 정합성 시스템을 구축해놓음으로써 운영 서버에서 발생할 수 있는 타이밍적인 부분이나 예외 케이스에 대한 검증을 할 수 있었습니다.
이 부분에서 좀 더 다양한 사례들을 확인하여 정합성을 확보하는 데 가장 중요한 역할을 하였습니다.
데이터 정합성을 확보하려는 자동화 테스트의 핵심 요소
데이터 정합성에 대한 품질을 높이는 것은 QA팀이 혼자서 의지를 가진다고 할 수 있는 부분이 아닙니다.
개발팀에서도 필요성이 있다고 인식이 되어야 서로 협업을 통해서 구축할 수 있습니다. 왜냐하면 어떻게 데이터 정합성 테스트를 진행할 것인지 결정하고, 결정된 방향성에 대해 개발팀의 도움이 필요합니다.
상품통합TF에서 API를 이용하여 데이터 정합성을 테스트 하기로 결정된 후 API를 통해 데이터를 조회할 수 있는 API가 있어야 하며, API의 응답 값에 대한 정의서가 있어야 했습니다.
필요성이 없다고 인식이 되었다면 이러한 부분에 대한 협의가 원활하게 이루어질 수 없습니다. 이것이 얼마나 필요한 것인지에 설득하는 것은 QA의 역할이라고 생각이 듭니다.
이번에 데이터 정합성을 높이기 위해 API 자동화 테스트 시스템을 구축하는 과정은 기존에 해본 API 테스트와는 조금 다른 방식이었습니다. 그러므로 중간에 많은 변화 과정을 거치게 되었는데 개발팀에서도 이런 방식은 어떨지 이런 것은 가능할지 등의 문의와 제안을 주셨습니다. 이 과정에서 저는 품질을 위해서 다 같이 고민하고 있다는 생각이 들었습니다.
그러므로 데이터 정합성 확보를 위한 자동화 테스트를 구축하기 데 필요한 것은 품질을 위해서 다같이 고민 문화라고 생각합니다.
마무리
이번 글은 올리브영에서 API 테스트를 활용해 데이터 정합성을 확보한 첫 번째 사례입니다. 이 글을 통해 저희가 리소스 절감과 운영 안정성을 어떻게 동시에 확보했는지 알려드리고 싶었습니다. 앞으로도 API를 활용해 품질을 높이는 다양한 방식을 꾸준히 소개해 드릴 예정이니 많은 관심 부탁드립니다.
이번 데이터 정합성 자동화 테스트 시스템을 성공적으로 구축할 수 있었던 건, 저희 QA팀의 노력뿐만 아니라 품질 향상을 위해 함께 고민하고 협력해 주신 상품통합TF💚 덕분이었습니다. 함께 한 모든 분들께 다시 한번 진심으로 감사드립니다.