올리브영 테크블로그 포스팅 유저의 소리를 듣는 법: 앱 리뷰 수신 시스템 개발기
Tech

유저의 소리를 듣는 법: 앱 리뷰 수신 시스템 개발기

올리브영 고객 문제 해결을 위한 앱 리뷰 수신 리드타임 50% 단축 과정

2025.05.23

안녕하세요! 올리브영에서 백엔드 개발을 담당하고 있는 silverbell입니다.
오늘은 "유저의 소리를 듣는 법: 앱 리뷰 수신 시스템 개발기"라는 주제로 올리브영의 경험을 공유해드리려 해요.

올리브영은 고객의 목소리를 빠르게 듣고 대응하기 위해 하루 평균 3~5개의 리뷰를 Android와 iOS 앱 리뷰를 별도 시스템으로 수신해요. 이번 포스트에서는 기존에 사용하던 앱 리뷰 수신 시스템의 문제점이 무엇이고, 이를 어떻게 개선했는지 이야기 해볼게요.

🚨 기존 시스템의 아쉬움


🐢 리뷰 배송이 느으려어요

기존 시스템의 가장 큰 문제는 리드타임이에요. 앱 리뷰 수신에 2~3일이 소요되다 보니 신속한 대응이 어려웠어요. 올리브영은 B2C 회사로, 고객의 불편함을 최소화하기 위해서는 이슈 트래킹 속도가 매우 중요해요. 그렇지만 리드 타임이 길어서 문제 발생과 동시에 처리하지 못했고, 해결한 이슈의 처리 완료 여부 또한 확인해야 하는 운영 피로도가 쌓이게 되었어요. 그래서 우리는 데이터 수신이 어느 지점에서 지연되는지 파악할 필요가 있었어요.

앱 리뷰를 수신하기 위해 App Follow라는 외부 플랫폼을 사용하고 있었으며, 이 과정은 다음과 같아요.

  1. 고객이 앱 리뷰 작성 후 Android와 iOS 스토어에서 내용 검증 진행
  2. 검증 완료된 앱 리뷰가 각 스토어에 공식 등록
  3. App Follow 서비스가 양쪽 스토어의 앱 리뷰 데이터 크롤링
  4. App Follow에서 Slack으로 앱 리뷰 알림 전송

크롤링 특성상 양쪽 스토어에서 주기적으로 정보를 수집하는데, 빈도에 따라 데이터 수신 시간에 차이가 발생해요. 앱 리뷰를 더 빠르게 수신하려면 크롤링 빈도를 높이는 방법이 있죠. 하지만 아쉽게도 우리는 App Follow라는 외부 플랫폼을 사용했기 때문에 크롤링 주기를 직접 설정하기 어려웠어요.

🕵🏻️ 빠른 방법을 찾아보자!

답답한 사람이 우물을 판다는 속담이 있죠. 앱 리뷰를 더 빠르게 수신하기 위해 우리는 시스템을 구축하기로 결정했어요. 백엔드 개발자로서 크롤링보다는 API 활용이 더 익숙했기에, 스토어 두 곳에서 제공하는 API를 통해 앱 리뷰 데이터를 수신하는 방식으로 시스템을 설계했어요.

  1. 고객이 앱 리뷰 작성 후 Android와 iOS 스토어에서 내용 검증 진행
  2. 검증 완료된 앱 리뷰가 각 스토어에 공식 등록
  3. 스토어에서 제공하는 API를 통해 앱 리뷰 데이터 수신
  4. Slack으로 앱 리뷰 알림 전송

설계는 끝났고, 이제 새로운 앱 리뷰 수신 시스템의 구성과 개발 과정을 살펴볼게요.

🧑🏻‍💻 앱 리뷰 수신 시스템 개발기


STEP 1. 데이터 조회에 필요한 인증 절차

우리는 Google Play Store와 Apple App Store에서 제공하는 API를 통해 앱 리뷰 데이터를 수신 할 거에요. 이 데이터를 조회하려면 스토어마다 따라야 할 인증 절차가 있어요. 하나씩 살펴볼게요.

인증하기 I - Google Play (Android)

Google Play Store에서 제공하는 API에 접근하기 위해서는 OAuth2.0을 통한 사용자 인증이 필요해요.

❓OAuth2.0 이란
 - OAuth 2.0은 애플리케이션이 사용자를 대신하여 자원에 접근할 수 있도록 인증 및 권한 부여를 처리하는 오픈 표준 프로토콜입니다.
 - 주로 타사 애플리케이션이 사용자의 비밀번호를 요구하지 않고도 보안 리소스에 접근할 수 있도록 설계되었습니다.
 - 인증(누구인지 확인)보다는 권한 부여(무엇을 할 수 있는지)에 초점을 맞추고 있습니다.

OAuth 2.0을 사용하면 앱이 사용자 비밀번호에 직접 접근할 필요 없이 토큰을 통해 제한된 권한을 확보해 API를 호출할 수 있어요.

Google Play Developer에서 제공하는 공식 문서에는 인증 시나리오가 여럿 존재하는데요, 우리는 그중 서비스 계정 시나리오를 채택했어요. 서비스 계정을 활용하면 서버는 자체 ID로 Google OAuth 2.0 서버와 상호 작용할 수 있고, 이 과정에서 사용자 동의가 필요하지 않아요.

Google Play Store 인증 절차

  1. 먼저 애플리케이션내 서비스 계정을 생성합니다.
  2. 도메인 전체 권한을 서비스 계정에 위임합니다.
  3. 생성한 서비스 계정의 키 파일을 다운로드합니다. (키 파일을 활용해 인증 토큰을 획득하고, Google API를 조회할 수 있습니다.)

아래 Google Developer 문서에서 키 파일을 이용한 액세스 토큰 발급 방법과 토큰 만료 시 자동 재발급 절차를 확인할 수 있어요.

  • 서비스 계정의 자격 증명에는 고유한 생성된 이메일 주소, 클라이언트 ID, 최소한 하나의 공개/비공개 키 쌍이 포함됩니다.
  • 클라이언트 ID와 하나의 개인 키를 사용하여 서명된 JWT를 만들고 액세스 토큰 요청을 구성합니다.
  • 그런 다음 애플리케이션은 토큰 요청을 Google OAuth 2.0 권한 부여 서버로 보내고, 이 서버에서 액세스 토큰을 반환합니다.
  • 애플리케이션은 토큰을 사용하여 Google API에 액세스합니다.
  • 토큰이 만료되면 애플리케이션은 프로세스를 반복합니다.

여기까지 권한이 부여된 서비스 계정을 생성하고 해당 계정의 키 파일을 다운로드했어요. 이제 키 파일을 사용해 인증하고, Google API와 통신하기 위한 토큰 값을 발급 받을 수 있어요!

다음은 Google Play Console API를 사용하기 위한 인증 방식을 알아볼까요?

우리는 Google에서 제공하는 AndroidPublisher API로 인증 로직을 구성했어요. 또한 Google Developer에서 제공하는 다양한 API를 적재적소에 활용할 수 있게 구현했어요.


// Google Play Console API를 사용하기 위한 인증된 클라이언트를 생성합니다.
fun authenticate(appType: AppType): AndroidPublisher {
    val credential = runCatching { authorizeWithServiceAccount(appType) }
        .getOrElse { handleException("authentication", it) }

    return AndroidPublisher.Builder(HTTP_TRANSPORT, JSON_FACTORY, credential)
        .setApplicationName(appType.packageNameForGooglePlay)
        .build()
}

// 서비스 계정을 사용한 인증을 처리합니다.
private fun authorizeWithServiceAccount(appType: AppType): Credential {
    val classLoader = this.javaClass.classLoader
    val p12InputStream = classLoader.getResourceAsStream(appType.keyFileForGooglePlay)
        ?: throw IOException("P12 file not found")

    val privateKey = SecurityUtils.loadPrivateKeyFromKeyStore(
        SecurityUtils.getPkcs12KeyStore(),
        p12InputStream,
        { P12 파일의 비밀번호 },
        { 키 별칭 },
        { 키 비밀번호 }
    )

    return GoogleCredential.Builder()
        .setTransport(HTTP_TRANSPORT)
        .setJsonFactory(JSON_FACTORY)
        .setServiceAccountId(appType.serviceAccountForGooglePlay)
        .setServiceAccountScopes(Collections.singleton(AndroidPublisherScopes.ANDROIDPUBLISHER))
        .setServiceAccountPrivateKey(privateKey)
        .build()
}

여기까지 Google Play Store 인증을 위한 절차를 살펴보았습니다. 이어서 Apple App Store에서 진행할 절차를 설명할게요.

인증하기 II - Apple App Store (iOS)

Apple App Store에서 제공하는 API에 접근하기 위해서는 JWT(JSON Web Token)를 통한 인증이 필요해요.

❓JWT 란
 - JWT는 JSON 객체를 사용하여 정보를 안전하게 전송하기 위한 개방형 표준입니다. 
 - JWT는 세 부분으로 구성되어 있습니다: 헤더(header), 페이로드(payload), 서명(signature). 이 세 부분은 점(.)으로 구분되어 있으며, 각각 Base64Url로 인코딩됩니다. 
 - JWT는 주로 인증 및 정보 교환에 사용됩니다.

이번에도 공식 개발자 사이트에서 제공하는 App Store Connect API 가이드를 참고했어요.

Apple App Store 인증 절차

  1. App Store Connect API에 대한 API 키를 생성합니다.
  2. API를 사용하기 위해 JWT 토큰을 생성합니다.

App Store Connect API 인증은 Google Play Store에 비해 상대적으로 인증이 수월했어요. (JWT가 더 친근했던건 안비밀 입니다 🙈)

다음은 App Store Connect API를 사용하기 위한 인증 방식을 우리가 구현한 예시로 설명할게요.

fun signedJWT(): String {
    // JWT 헤더 생성 - 서명 알고리즘(ES256), 키 ID(keyId), JWT의 타입(JOSEObjectType.JWT)
    val header = JWSHeader.Builder(JWSAlgorithm.ES256)
        .keyID(keyId)
        .type(JOSEObjectType.JWT)
        .build()

    // JWT의 클레임 설정 - 발급자(issuerId), 발급 시간(issueTime), 만료 시간(expirationTime, 현재 시간에서 15분 후), 그리고 수신자(audience)
    val claimsSet = JWTClaimsSet.Builder()
        .issuer(issuerId)
        .issueTime(now)
        .expirationTime(expirationTime)
        .audience("appstoreconnect-v1")
        .build()

    // JWT 생성
    val jwt = SignedJWT(header, claimsSet)

    // 서명 처리
    try {
        val ecPrivateKey = privateKeyReader.readPrivateKey(keyPath)
        val jwsSigner = ECDSASigner(ecPrivateKey)
        jwt.sign(jwsSigner)
    } catch (e: Exception) {
        e.printStackTrace()
        logger.error("App store connect api error [jwt 발급 실패 !!]: $e")
    }

    // JWT 직렬화 및 반환
    return jwt.serialize()
}

STEP 2. 데이터 조회 후 가공하기

인증을 완료하면 각 스토어에서 제공하는 공식 API를 통해 앱 리뷰 데이터를 조회할 수 있어요 😆😆

올리브영에서는 업무 메신저로 Slack을 사용 중 입니다. Slack API로 제공하는 기능이 풍부해 업무에 적극 활용하고 있어요.
조회한 앱 리뷰 데이터를 수신하기 위해 우리는 Slack API 중 2가지를 사용했어요!

  • Block-kit: 깔끔하고 일관된 UI 프레임워크로, Slack 메시지를 예쁘게 구성할 수 있어요.
  • incomming webhook: 수신 웹훅을 사용하면 애플리케이션이 Slack으로 메시지를 남길 수 있어요.

초기에 사용했던 Block json 구조를 살짝 공유할게요. 현재는 이를 개선한 구조로 사용중입니다. :)

📦 Block json 보기/접기
{
  "attachments": [
    {
      "color": "<marketType.color.hex 값>",
      "blocks": [
        {
          "type": "header",
          "text": {
            "type": "plain_text",
            "text": "<header 텍스트>",
            "emoji": true
          }
        },
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "<rating> <countyFlag>"
          }
        },
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "*<title>*"
          }
        },
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "<content>"
          }
        },
        {
          "type": "context",
          "elements": [
            {
              "type": "plain_text",
              "text": "App : <appType.description>"
            },
            {
              "type": "plain_text",
              "text": "작성자 : <user>"
            },
            {
              "type": "plain_text",
              "text": "작성일 : <date>"
            },
            {
              "type": "plain_text",
              "text": "버전 : <version>"
            }
          ]
        },
        {
          "type": "divider"
        }
      ],
      "fallback": "<previewText>"
    }
  ],
  "text": ""
}

Block-kit으로 메시지 구조를 만들고 이제 남은 건 메시지 전송뿐! Slack에서 제공하는 incomming webhook을 활용해 원하는 Slack 채널로 메시지를 전송할 수 있어요. Slack API는 각 채널에 매핑되는 URL endpoint를 제공하는데, 이 때 WebClient를 이용해 endpoint로 메시지를 전송해요.

WebClient가 궁금하신 분은 올리브영의 이전 포스팅을 참고해주세요!

여기까지 잘 따라오셨다면, 그리고 당신이 백엔드 개발자라면! 디자이너나 프론트 개발자의 도움없이 앱리뷰 메시지를 Slack 채널로 수신하게 할 수 있습니다👏🏻👏🏻 그것도 아주 감각적인 디자인으로요. 🐵

STEP 3. 앱 리뷰 시스템 CI/CD 구축하기

끝난줄 아셨죠? 새로운 앱 리뷰 시스템을 지속적으로 운영하려면 CI/CD 파이프라인이 필요했어요. 올리브영은 주로 AWS에서 제공하는 서비스를 사용하고 있어요.

  • EC2: 서버를 띄울 공간
  • S3: 배포할 결과물을 저장하는 공간
  • codeDeploy: 배포를 도와줄 서비스

자주 배포되는 서비스가 아니기 때문에 간단한 CI/CD 구조를 채택했어요. 우리가 구축한 워크플로는 main 브랜치에 푸시하면 Github Actions으로 EC2까지 자동 배포되는 형태에요. 전체적 플로우는 좀 더 직관적인 이미지로 설명할게요.

codeDeploy에서 참고할 AppSpec 작성

AppSpec 파일은 배포 과정에서 어떤 작업을 수행할지 정의한 스펙 문서에요. YAML 또는 JSON 형식으로 작성해요. CodeDeploy가 애플리케이션 파일을 복사하거나 스크립트를 실행하는 등 배포를 자동으로 관리하는데, 이 배포 단계에서 AppSpec 파일을 참고해요.

AppSpec 파일에 대한 설명

AppSpec 파일은 배포 패키지의 루트 디렉토리에 위치해야 하며, appspec.yml 또는 appspec.json이라는 이름으로 저장되어야 해요.
AppSpec 파일의 기본 구조는 다음과 같아요.

version: 0.0
os: linux
# files 섹션
files:
  - source: build/libs/{프로젝트 명}-0.0.1-SNAPSHOT.jar 
    destination: {대상 인스턴스에 파일이 복사될 절대 경로입니다.}                  
    overwrite: {대상 위치에 동일한 이름의 파일이 이미 존재할 경우 덮어쓸지 여부를 결정합니다.}                            

# permissons 섹션
permissions:
  - object: {권한을 적용할 대상 파일 또는 디렉토리의 경로입니다.}     
    pattern: {'object'로 지정된 경로 내에서 권한을 적용할 파일 또는 디렉토리의 패턴입니다.}       
    owner: {해당 파일 또는 디렉토리의 소유자를 지정합니다.}
    group: {해당 파일 또는 디렉토리의 그룹을 지정합니다.}                     

# hooks 섹션
hooks:
  # ApplicationStop: 애플리케이션을 중지해야 하는 배포 라이프사이클 이벤트입니다.
  # 'files' 섹션이 실행되기 전에, 현재 실행 중인 애플리케이션을 안전하게 중지시키기 위해 사용됩니다.
  ApplicationStop:
    - location: {실행할 스크립트의 위치입니다. 배포 패키지 내의 상대 경로로 지정합니다.}
      timeout: {스크립트 실행을 위해 허용되는 최대 시간(초)입니다. 이 시간을 초과하면 배포가 실패합니다.}
      runas: {스크립트를 실행할 사용자 계정입니다. 지정하지 않으면 root 권한으로 실행됩니다.}
  # BeforeInstall: 'files' 섹션에서 파일을 복사하기 전에 실행되는 이벤트입니다.
  # 주로 이전 버전의 애플리케이션 파일 삭제, 필요한 디렉토리 생성, 사전 작업 등을 수행합니다.
  BeforeInstall:
    - location: 
      timeout: 
      runas:
  # ApplicationStart: 모든 파일이 복사되고 권한 설정이 완료된 후, 새 버전의 애플리케이션을 시작하는 이벤트입니다.
  # 일반적으로 웹 서버 재시작, 애플리케이션 프로세스 실행 등의 작업을 수행합니다.
  ApplicationStart:
    - location: 
      timeout: 
      runas: 

AppSpec 파일의 각 세션 설명은 공식 문서를 참고하면 자세히 알 수 있어요.

Github Actions Workflow 작성

이제 마지막 단계인 Github Actions Workflow를 작성하면 됩니다. Workflow 파일은 .github/workflows 디렉토리에 위치 해야 하며, YAML 형식으로 작성해요.
Workflow 파일의 이름은 자유롭게 지정할 수 있지만 일반적으로 deploy.yml 또는 ci-cd.yml형태로 명명해요. 저는 deploy.yml로 작성했고, 구조는 아래와 같아요.

name: Deploy to Amazon EC2

on:
  push:
    branches:
      - main

env:
  AWS_REGION: {리전이름}
  S3_BUCKET_NAME: {S3 버킷이름}
  CODE_DEPLOY_APPLICATION_NAME: {codedeploy 애플리케이션 이름}
  CODE_DEPLOY_DEPLOYMENT_GROUP_NAME: {codedeploy 배포 그룹 이름}

permissions:
  contents: read

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    environment: production

    steps:
    # (1) 기본 체크아웃
    - name: Checkout
      uses: actions/checkout@v3

    # (2) JDK 17 세팅
    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        distribution: 'temurin'
        java-version: '17'

    # (3) Gradle build (Test 제외)
    - name: Build with Gradle
      uses: gradle/gradle-build-action@v2
      with:
        arguments: clean build -x test -Pprofile=prod

    # (4) AWS 인증 (IAM 사용자 Access Key, Secret Key 활용)
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ env.AWS_REGION }}

    # (5) 빌드 결과물을 S3 버킷에 업로드
    - name: Upload to AWS S3
      run: |
        aws deploy push \
          --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \
          --ignore-hidden-files \
          --s3-location s3://${{ env.S3_BUCKET_NAME }}/${{ github.sha }}.zip \
          --source .

    # (6) S3 버킷에 있는 파일을 대상으로 CodeDeploy 실행
    - name: Deploy to AWS EC2 from S3
      run: |
        aws deploy create-deployment \
          --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \
          --deployment-config-name CodeDeployDefault.AllAtOnce \
          --deployment-group-name ${{ env.CODE_DEPLOY_DEPLOYMENT_GROUP_NAME }} \
          --s3-location bucket=${{ env.S3_BUCKET_NAME }},key=${{ github.sha }}.zip,bundleType=zip

(5) 빌드 결과물을 S3 버킷에 업로드에 설명을 덧붙이자면, $github.sha 변수는 GitHub에서 각 커밋마다 생성하는 고유한 값입니다. 이 값을 활용하여 파일 업로드 시 이름 중복으로 인한 충돌을 방지했어요.

GitHub 변수는 GitHub Context에서, deploy.yml은 AWS CodeDeploy에서 자세히 알아보세요.

STEP 4. 배포 후 모니터링

서비스를 배포 했다면 내가 만든 시스템이 잘 작동하는지 확인 해야겠죠? 앱 리뷰 시스템은 리뷰가 잘 수신되는지 확인 하는 것이 중요했어요.
리뷰 발송이 끝난 시점에 시스템이 전송한 리뷰의 갯수를 Slack 알림으로 받고, 리뷰 전송에 실패하면 에러로그와 함께 Slack으로 알람을 받아 즉시 대응할 수 있게 구성을 했어요!

가장 최근에 발생했던 오류 메세지를 공유해볼게요. google play에서 제공해주는 API서버가 다운 된 케이스로 일시적인 오류였어요!

다행히 시스템이 안정되어 오류 메시지를 자주 받지는 않아요.🥹

모니터링을 통해 개선이 필요한 점이 있다면, 지속적으로 디벨롭해 나갈 예정입니다!

🧑🏻‍🔧 개선 된 점

이전에 사용한 App Follow는 이미 해소된 이슈를 수신하거나 고객의 불편함을 늦게 수신하는 경우가 있었는데요, 새로운 앱 리뷰 수신 시스템의 등장으로 가장 큰 문제였던 리드 타임을 단축할 수 있었어요. 덕분에 고객의 목소리에 더욱 신속하고 유연하게 대응할 수 있게 되었어요 👏🏻👏🏻👏🏻

올리브영은 온라인몰 외에도 올리브영 글로벌/디플롯 - 라이프스타일 플랫폼도 서비스하고 있어요. 새로운 앱 리뷰 수신 시스템은 올리브영 온라인몰뿐만 아니라 글로벌몰과 디플롯에도 적용되어 고객의 목소리를 신속하게 구성원들에게 전달하는 중요한 역할을 담당하고 있어요.

마무리하며

불편에 익숙해지지 않고 개선해가는 올리브영의 개발기, 어떠셨나요? 어떻게 보면 귀찮고 번거로울 수 있지만 저와 팀은 새로운 시스템을 만들어가는 과정이 정말 즐거웠습니다! 수신한 앱 리뷰를 활용하여 제품 기획, 마케팅, 운영 개선, 경쟁 분석등을 진행하고 있어요. 이렇게 서비스가 활발하게 활용되는 모습을 보며 제품 메이커로서 큰 성취감을 느낄 수 있었어요.

올리브영 개발 조직은 고객의 불편사항과 개선 요청에 귀 기울여 더 나은 경험을 창출하는 조직이에요. 앞으로도 고객의 소중한 피드백을 경청하고 신속하게 대응하며, 여러분의 쇼핑 여정이 더 즐겁고 놀라운 경험이 될 수 있도록 열정을 다해 달려가겠습니다!

긴 글 읽어주셔서 감사합니다! 😊

Github Actions인증앱리뷰
올리브영 테크 블로그 작성 유저의 소리를 듣는 법: 앱 리뷰 수신 시스템 개발기
🙈
silverbell |
Back-end Engineer
아이디어를 현실로, 상상을 코드로 만드는 일을 하고 있습니다.