카페24 커스텀 자사몰 개발기: ② 카페24 App + GCP로 관리자 데이터 가져오기

Jaeiklee.dev·2025년 9월 18일
0
post-thumbnail

이전 글: 카페24 커스텀 자사몰 개발기: ① 쇼핑몰 솔루션 선택하기

🎯 프로젝트 소개

카페24를 커스텀하여 자사몰을 구축하는 외주 프로젝트를 진행했습니다. 고객 요구사항 중 카페24에서 지원하지 않는 기능이 있어 별도로 개발을 진행했습니다. 프로젝트 기간은 2022년 11월 ~ 2023년 4월입니다. 본 글은 당시 노트를 기반으로 작성했습니다.

  • 현재 운영 중인 서비스라 서비스명은 밝히지 않았습니다.

요구사항

  • 카페24 쇼핑몰 메인 화면에 누적 판매량 노출
  • 관리자 데이터(Admin API) 기반으로 일관된 수치 제공
  • 운영자 개입 없이 안정적 갱신

제약

  • Admin API는 브라우저에서 직접 호출 불가
  • 카페24 APP을 통해 권한 위임 필요

아키텍처 개요

구성요소

  • 카페24 App(백엔드 권한 주체)
  • GCP 백엔드(API 프록시, 토큰 및 데이터 저장)
  • 카페24 프론트 스킨

데이터 흐름(요약)

  1. 사용자가 메인 페이지 접속
  2. 프론트 스크립트가 Cloud Functions 호출
  3. Cloud Functions는 저장된 액세스 토큰으로 카페24 Admin API 호출
  4. 카페24 API 서버에서 Token 검증 및 요청에 대한 데이터 반환
  5. Cloud Functions가 프론트에 데이터 전달 → 숫자 표시

개발 과정 요약

  1. 카페24 Developers 계정 생성
  2. 카페24 Developers에 App 생성, 등록
  3. App을 돌릴 백엔드 설계 → GCP로 구성
  4. GCP로 구성한 백엔드
    1. Cloud Functions: 판매량 가져오기
    2. Cloud Scheduler, Pub/Sub: 주기적으로 토큰 갱신 트리거
    3. Firestore: 분기별 판매량 데이터 및 토큰값 저장
  5. 카페24 프론트엔드 코드 수정하여 누적 판매량 수치 표시

1. 카페24 App 생성

카페24 관리자 데이터 필요로 하는 기능을 추가하려면 ‘카페24 APP’을 통해야 합니다. 카페24에 설치할 수 있는 일종의 플러그인입니다. 카페24 Admin API를 호출하는 백엔드는 직접 개발해야 합니다. 카페24 APP의 역할은 그 백엔드 API가 카페24 Admin API를 호출할 수 있도록 권한을 위임하는 것입니다.

제가 필요로 하는 상품 판매량은 관리자 데이터이기 때문에 카페24 developers에서 APP을 생성해 주었습니다.

실제 프론트에서 호출하는 것은 카페24 Admin API를 대리 호출할 백엔드입니다. Admin API는 프론트에서 직접 호출이 불가능하기 때문입니다. 저는 GCP의 Cloud Functions로 백엔드를 구축했습니다.

  • APP URL: https://<Cloud Functions 도메인> - 앱 최초 실행시 호출될 URL
  • Redirect URI: https://<Cloud Functions 도메인>/redirect

판매량 정보는 ‘주문’ API에서 찾을 수 있었습니다. 읽기만 하면 되기 때문에 ‘주문’ API에만 Read 권한을 부여했습니다.

2. GCP 백엔드 설계

이 프로젝트를 처음 맡았을 때, 웹 경험이 전무한 상태였습니다. 웹의 원리, 백엔드 개념 자체가 없었습니다. 처음에는 카페24 웹호스팅이라는 제품을 써서 PHP로 개발하려고 했습니다. 일주일을 삽질한 끝에, 웹애플리케이션을 돌릴 수 있는 제품이 아니라는 것을 깨달았습니다. 두 번째 시도한 것은 카페24 Node.js 서버입니다. 어떻게든 카페24 제품 안에서 모든 것을 해결하고자 했습니다. 이 제품도 문제가 있었습니다. SSL 적용이 불가능했습니다.

결국 찾아낸 것이 GCP, AWS였습니다. AWS를 더 많이 사용하지만, GCP가 압도적으로 문서화가 잘돼 있고 인터페이스도 직관적이라 접근성이 좋았습니다. 무료 구간도 두 배나 넉넉했습니다. 결국 GCP로 백엔드를 구성하기로 했습니다.

제약 사항

카페24 Admin API 액세스 토큰 유효 시간

카페24 Admin API를 호출하려면 액세스 토큰이 필요합니다. 액세스 토큰을 발급하려면 인증 코드가 필요합니다. 인증코드는 1분간 유효합니다. 액세스 토큰은 2시간 동안 유효합니다. 주기적으로 재발급이 필요합니다. 액세스 토큰이 발급될 때, 재발급에 사용하는 리프레시 토큰이 함께 제공됩니다. 리프레시 토큰은 14일간 유효하며, 한 번 사용하면 폐기됩니다.

order API 검색 기간

카페24 Admin의 order API는 파라미터로 start_date, end_date을 넣게 되어있는데, 최대 설정 가능 기간이 3개월이었습니다. 누적 판매량이 필요했기 때문에 분기가 넘어갈 때마다 분기별 판매량을 Firestore에 저장하도록 했습니다.

백엔드에 필요한 기능 구성

인증

  1. 인증 코드 발급: Cloud Functions
  2. 인증 코드 → 액세스 토큰 발급: Cloud Functions
  3. 발급받은 액세스 토큰, 리프레시 토큰 저장: Firestore
  4. 리프레시 토큰 사용하여 주기적으로 액세스 토큰 재발급: Cloud Scheduler, Pub/Sub, Cloud Functions

카페24 Admin API 호출

  1. 분기가 넘어갈 때마다 분기별 판매량 저장하기: Cloud Sheduler, Pub/Sub, Cloud Functions, Firestore
  2. 현재 분기의 판매량 가져오고 이전 분기의 판매량과 더해 누적 판매량 계산하기: Cloud Functions

Cloud Functions

  • get-sales-count: 액세스 토큰을 사용하여 카페24 Admin API를 호출하는 함수. http 트리거
  • update-prev-sales: 주기적으로 분기별 판매량을 Firestore에 저장하는 함수. Cloud Pub/Sub 트리거
  • update-token: 주기적으로 액세스 토큰을 재발급하여 Firestore에 저장하는 함수. Cloud Pub/Sub 트리거

Cloud Pub/Sub

Cloud Scheduler

  • update_prev_sales: 매월 1일에 Cloud Pub/Sub이 update-prev-sales 함수를 트리거하도록 하는 스케줄러
  • update_token: 매시 5분에 Cloud Pub/Sub이 update-token 함수를 트리거하도록 하는 스케줄러

Firestore

  • sales: 분기별 판매량을 저장
  • tokens: 액세스 토큰과 리프레시 토큰을 저장

3. 액세스 토큰 관리

액세스 토큰을 사용하여 카페24 API에 접근하는 과정은 다음과 같습니다. ‘Resource Owner’가 쇼핑몰 프론트엔드이고, ‘Client’가 카페24 APP(백엔드)입니다

인증 코드는 앱 최초 실행 시 발급됩니다. ‘앱 최초 실행’이란 쇼핑몰에 앱을 설치하는 것을 말합니다. 직접 제작한 앱을 내 쇼핑몰에 설치하는 것은 카페24 Developers > Apps > 해당 앱의 ‘개발 정보’에서 ‘테스트 실행’ 으로 가능합니다.

인증 코드 발급: Cloud Functions get-sales

‘개발 정보’의 ‘APP URL’에 입력한 URL입니다. 앱 최초 실행시 호출됩니다.

app.get('/', (req, res) => {
    const hmac = req.query.hmac;
    const url = req.url;
    var query = url.split('?')[1];
    if (query == null) {
        console.log("query is null");
        res.status(401).send('Abnormal access');
        return;
    }
    const queryWithoutHmac = query.substring(0, query.lastIndexOf('&'));
    
    // Create the hash value
    const hash = crypto.createHmac('sha256', appClientSecretKey).update(queryWithoutHmac).digest('base64');
    const pass = (hmac === hash);

    if (pass) {
        res.writeHead(301, {
            Location: `https://${mallId}.cafe24api.com/api/v2/oauth/authorize?\
            response_type=code&client_id=${appClientId}&state=${mallId}${appClientId}&\
            redirect_uri=${redirectUri}&scope=${scope}`
        });
    }
    else {
        res.status(401).send('Authentication Failed by HMAC verification');
    }
    res.end();
});

client ID, secret key는 카페24 Developers > 앱 > ‘개발 정보’에서 확인하실 수 있습니다. client ID, secret key, scope(권한 설정), 등이 일치하면 인증 코드가 발급되어 앱의 ‘개발 정보’에 입력한 ‘redirect URL’로 반환됩니다.

토큰 발급: Cloud Functions get-sales/redirect

인증 코드로 액세스 토큰을 발급합니다. 발급받은 액세스 토큰과 리프레시 토큰을 Firestore에 저장합니다.

app.get('/redirect', (req, res) => {
    if (req.query.state === `${mallId}${appClientId}`) {
        issueToken(req.query.code, res);
    }
    else {
        res.send('Error: State code not match. It may have been modified by other');
    }
});

async function issueToken(authCode, res) {
    await requestToken(`grant_type=authorization_code&code=${authCode}&redirect_uri=${redirectUri}`);
    res.send('Successfully completed installation!');
}

async function requestToken(postBody) {
    base64EncodedText = Buffer.from(`${appClientId}:${appClientSecretKey}`, "utf8").toString('base64');
    const postReqOptions = {
        hostname: `${mallId}.cafe24api.com`,
        path: '/api/v2/oauth/token',
        method: 'POST',
        headers: {
            'Authorization': `Basic ${base64EncodedText}`,
            'Content-Type': 'application/x-www-form-urlencoded'
        }
    };
    const postReq = https.request(postReqOptions, (response) => {
        let body = '';
        response.on('data', (chunk) => {
            body += chunk;
        });
        response.on('end', () => {
        		// 발급한 액세스 토큰, 리프레시 토큰 저장
            firestoreUpdateToken(body);
        });
    });
    postReq.on('error', (e) => {
        console.error(e);
    });
    postReq.write(postBody);
    postReq.end();
}

토큰 재발급: Cloud Functions update-token

주기적으로 Firestore에 저장해두었던 리프레시 토큰을 사용하여 액세스 토큰을 재발급합니다. 새로 발급된 액세스 토큰과 리프레시 토큰을 Firestore에 저장합니다. Cloud Scheduler, Pub/Sub에 의해 트리거되어 매 시 5분마다 실행됩니다.

functions.cloudEvent('updateToken', cloudEvent => {
  _updateToken();
});

async function _updateToken() {
    const refreshToken = await tokensRef.doc('refresh_token').get();
    requestToken(`grant_type=refresh_token&refresh_token=${refreshToken.data().token}`);
}

async function requestToken(postBody) {
    base64EncodedText = Buffer.from(`${appClientId}:${appClientSecretKey}`, "utf8").toString('base64');
    const postReqOptions = {
        hostname: `${mallId}.cafe24api.com`,
        path: '/api/v2/oauth/token',
        method: 'POST',
        headers: {
            'Authorization': `Basic ${base64EncodedText}`,
            'Content-Type': 'application/x-www-form-urlencoded'
        }
    };
    const postReq = https.request(postReqOptions, (response) => {
        let body = '';
        response.on('data', (chunk) => {
            body += chunk;
        });
        response.on('end', () => {
		        // 재발급한 액세스 토큰, 리프레시 토큰 저장
            firestoreUpdateToken(body);
        });
    });
    postReq.on('error', (e) => {
        console.error(e);
    });
    postReq.write(postBody);
    postReq.end();
}

4. 상품 누적 판매량 가져오기

분기별 판매량 업데이트하기: Cloud Functions update-prev-sales

주기적으로 Firestore에 분기별 상품 판매량을 업데이트합니다. Cloud Scheduler, Pub/Sub에 의해 트리거되어 매월 1일마다 실행됩니다.

functions.cloudEvent('updatePrevSales', cloudEvent => {
  updatePrevSales();
});

async function updatePrevSales() {
    const accessToken = await tokensRef.doc('access_token').get();
    const auth = "Bearer " + accessToken.data().token;

    // 지난 분기 구하기
    // ex) 오늘 2022년 1월 3일 -> 지난 분기: 2021-4
    var date = new Date();  // now (GMT+0000)
    date.setUTCHours(date.getUTCHours() + 9);  // The time after 9h from now (GMT+0000)
    date.setUTCMonth(date.getUTCMonth() - 3);  // previous quarter
    const year = date.getUTCFullYear(); // 지난 분기가 몇년도인지 구하기
    const quarter = getQuarter(date.getUTCMonth() + 1); // 지난 분기(1/2/3/4) 구하기
    var options = salesReqOptionsByQuarter(year, quarter, auth);
    
    // 카페24 Admin API 호출하여 지난 분기의 판매량 가져오기
    const prevQuarterCnt = await getSalesByQuarter(options);

    var newDocName = year + "-" + quarter;
    // 분기의 판매량을 업데이트
    firestoreUpdateSales(newDocName, prevQuarterCnt);
}

function getSalesByQuarter(options) {
    return new Promise((resolve, reject) => {
        const req = https.request(options, (res) => {
            let body = ''; 
            var count;
            res.on("data", (chunk) => { 
                body += chunk; 
            }); 
            res.on("error", (error) => { throw new Error(error); });
            res.on("end", () => { 
                try { count = JSON.parse(body).count; }
                catch (e) { reject(e); }
                resolve(count);
            });
        });
        req.on('error', function(err) {
            reject(err);
        });
        req.end();
    });
}

	// 지난 분기 판매량을 가져오기 위한 카페24 Admin API 쿼리문을 짜는 함수
function salesReqOptionsByQuarter(year, quarter, auth) {
    var startDate, endDate;
    switch(quarter) {
        case 1:
            startDate = year + "-01-01"
            endDate = year + "-03-31"
            break;
        case 2:
            startDate = year + "-04-01"
            endDate = year + "-06-30"
            break;
        case 3:
            startDate = year + "-07-01"
            endDate = year + "-09-30"
            break;
        case 4:
            startDate = year + "-10-01"
            endDate = year + "-12-31"
            break;
        default:
            break;
    }
    const path = "/api/v2/admin/orders/count?shop_no=1"
        + "&start_date=" + startDate + "&end_date=" + endDate
        + "&order_status=N00,N10,N20,N21,N22,N30,N40,N50"
        + "&date_type=order_date";
    console.log(path);

    return {
        hostname: `${mallId}.cafe24api.com`,
        path: path,
        method: 'GET',
        headers: {
            'Authorization': auth,
            'Content-Type': "application/json",
            'X-Cafe24-Api-Version': "2022-09-01"
        }
    };
}

누적 판매량 가져오기: Cloud Functions get-sales-count/getsales

Firestore에 저장된 분기별 상품 판매량과 카페24 Admin API 통해 가져온 이번 분기의 판매량을 모두 더합니다. 누적 판매량을 response로 전달합니다.

app.get('/getsales', (req, res) => {
    getSales(res);
});
async function getSales(res) {
		// 지난 분기까지의 판매량 가져오기
    const sales = await salesRef.get();
    var salesSum = 0;
    sales.forEach(quarter => {
        salesSum += quarter.data().count;
    });
    const accessToken = await tokensRef.doc('access_token').get();
    const auth = "Bearer " + accessToken.data().token;

    // 이번 분기의 판매량 가져오기
    var date = new Date();  // now (GMT+0000)
    date.setUTCHours(date.getUTCHours() + 9);  // 한국 시간
    var options = salesReqOptionsByQuarter(date.getUTCFullYear(), getQuarter(date.getUTCMonth() + 1), auth);
    const thisQuartCnt = await getSalesByQuarter(options);
		
		// 누적 판매량 response로 전달하기
    respBody = { count: thisQuartCnt + salesSum };
    res.setHeader('Access-Control-Allow-Origin', `https://${mallId}.cafe24.com`);
    res.setHeader('Access-Control-Allow-Methods', "GET, OPTIONS");
    res.json(respBody);
}
function getSalesByQuarter(options) {
    return new Promise((resolve, reject) => {
        const req = https.request(options, (res) => {
            let body = ''; 
            var count;
            res.on("data", (chunk) => { 
                body += chunk; 
            }); 
            res.on("error", (error) => { throw new Error(error); });
            res.on("end", () => { 
                try { count = JSON.parse(body).count; }
                catch (e) { reject(e); }
                resolve(count);
            });
        });
        req.on('error', function(err) {
            reject(err);
        });
        req.end();
    });
}

메인 페이지에서 누적 판매량 보여주기

카페24 프론트엔드 코드에 다음 JavaScript 코드를 삽입하여 누적판매량을 가져오도록 했습니다.

var order_count = 0;
fetch(<Cloud Functions:get-sales-count URL>/getsales, {
    mtehod: 'GET',
    mode: 'cors',
}).then(response => {
    if (!response.ok) {
    	throw new Error('Network response was not ok');
  	} else {
        return response.json();
    }
}).then(data => {
    order_count = data.count;
}).catch(error => {
    console.error('There was a problem with the fetch operation:', error);
});

후기

카페24에 종속된 구조라 개발 제약이 많았습니다. 권한과 조회 범위, 배포 방식까지 선택지가 좁았고, 국내 솔루션 특성상 참고 자료도 적어 문제를 찾고 검증하는 데 시간이 더 걸렸습니다. 프론트엔드 코드 수정은 카페24 HTML 디자인 편집기 즉, 웹상에서 카페24의 에디터를 사용해야하는데 이게 정말 불편합니다... 그럼에도 카페24 기술지원이 빠르고 적극적으로 응대해 주셔서 막힐 때마다 방향을 잡을 수 있었습니다.

웹이 처음이라 매일이 터널처럼 느껴졌습니다. 오늘 해결해도 내일 다시 막히곤 했지만, 끝까지 밀고 나가며 모르는 내용을 빠르게 파고들고 기록으로 정리하는 습관을 갖게 되었습니다. 이번 프로젝트에서 가장 큰 성장은 그 지점이었다고 생각합니다. 지금 하면 한 달이면 완성할 것 같은데, 그 때는 정말 힘들었네요. 덕분에 웹에 대한 전반적인 개념을 잡을 수 있었던 고마운 프로젝트였습니다.

참고 문서

카페24 Developers: https://developers.cafe24.com/admin/dashboard/main/front/app
카페24 APP 개발 가이드: https://developers.cafe24.com/app/front/app/develop
API Doc: https://developers.cafe24.com/docs/api/#introduction
API Index: https://developers.cafe24.com/docs/ko/api/admin/#api-index

0개의 댓글