Skip to main content

AB테스트 도구 개발 과정

· 35 min read

AB테스트란?

이미지 출처) Deployment: Pengertian, Tujuan, dan Jenis-jenisnya

AB테스트는 전체 실사용자를 대상으로 대조군과 실험군을 나누어 UI 또는 알고리즘의 효과를 비교하는 방법론이다. 개발팀 내에서만 판단하기에는 어떤 안이 더 적절한지 객관적으로 판단하기 힘든 경우가 많은데, 이 때 AB테스트가 큰 효과를 보일 수 있다.

배경

개발 동기

팀 단위 개발을 진행하면서 근거있는 개발을 위해 AB테스트가 필요하다는 의견이 나왔다. 요즘 팀장을 맡아 기획 회의에 자주 참여하는 입장에서 데이터에 기반해 근거있는 개발을 진행할 수 있다는 점이 흥미로웠다.

출처) 구글 옵티마이즈 종료, AB Test 대안? - PLUS ZERO

시중에는 이미 AB테스트를 보조해주는 도구가 많이 나와있지만, 그 가격이 어마무시했다. GTM을 사용하면 무료로 AB테스트를 진행할 수 있지만 웹 중심의 구조이기에 모바일과 PC 환경을 모두 제공하는 우리 서비스와는 어울리지 않아 보인다. 결국 우리 서비스만을 위한 AB테스트 도구를 직접 개발하기로 했다.

동아리 DA님의 소중한 의견

"API를 호출해서 사람마다 A군, B군을 비율에 맞게 응답으로 내려주고, 클라이언트에서는 API 응답을 기반으로 여러 UI 중 하나를 출력하면 되지 않을까?" 라는 생각으로 AB테스트 개발을 시작했다.

설계

요구사항

기본적으로 어드민이 AB테스트를 CRUD할 수 있고 일반 사용자가 특정 실험군에 편입될 수 있어야 한다. 부가적으로 어드민은 AB테스트에서 특정 사용자를 특정 실험군에 직접 편입시킬 수 있는 기능이 필요하다(실험군 수동 편입).

구조 설계

AB테스트를 구현하기 위해 백엔드에서 어떤 구조를 가져가야 할 지 고민해보니 이전에 구상했던 구조로는 어림도 없었다. 모수가 그리 크지 않은 상황에서 확률에 기반하여 사용자를 편입시킨다면 정확한 값을 기대하기 힘들 수 있다. 또한 한 명의 사용자는 동일한 실험에 대해 다시 접근하더라도 이전과 같은 실험군 값을 받아올 수 있어야 하기 때문에 사용자별 실험군 데이터를 저장해야만 한다.

최종적으로 정리한 구조는 다음과 같다.

  1. 어드민은 AB테스트를 생성/조회/수정/삭제할 수 있다.
  2. 실험 진행 도중에 실험군 비율이 수정되면 기존에 편입되어 있던 사용자들의 실험군은 그대로 유지하고 이후로 새로 편입되는 사용자들에 대해 해당 비율이 적용된다.
    1. 실험군 비율이 0%로 수정되어도 이미 해당 실험군에 편입되어 있던 사용자들은 그대로 유지된다.
  3. 승자를 선택하고 실험을 종료하면 이후로 해당 실험에 대해 들어오는 모든 자신의 실험군 조회 요청에 승자를 반환한다.

DB 설계

처음에는 가볍게 개발할 수 있을 거라고 생각했는데 요구사항을 하나씩 반영하면서 완성도있게 가져가려다 보니 스케일이 점차 커졌다. 결국 테이블이 5개나 추가되었다.

테이블 설명

  • users
    • 기존에 존재하던 테이블이다.
    • 기기 정보와 연결하기 위해 사용된다.
  • device
    • 로그인 사용자의 기기 정보를 관리한다.
    • 실험군 수동 편입에 사용된다.
    • 한 명의 사용자는 여러 기기를 가질 수 있다.(1:N)
  • access_history
    • 모든 사용자의 접속 이력을 관리한다.
    • 로그인 사용자에 한해 device와 관계를 맺는다.
    • AB테스트 및 실험군 수동 편입에 사용된다.
    • 실험군 편입의 대상이 된다.
      • AB테스트에서의 실질적인 사용자라고 볼 수 있다.
    • 하나의 기기는 하나의 access_hsitory와 연결된다.(1:1)
  • access_history_abtest_variable
    • access_history와 abtest_variable 간의 연결 정보를 관리한다.(M:N)
  • abtest_variable
    • AB테스트의 실험군 정보를 관리한다.
    • AB테스트 전반에 사용된다.
    • 하나의 실험은 여러 실험군을 가질 수 있다.(1:N)
  • abtest
    • AB테스트 실험 정보를 관리한다.
    • AB테스트 전반에 사용된다.
    • 각 실험은 실험군을 승자(winner_id)로써 가질 수 있다.(1:1)

device 테이블의 필요성

access_history와 device를 분리한 이유가 궁금할 수 있다. 앞의 설명을 보면 둘을 구분하지 않아도 될 것 같고 분리하는 게 오히려 구조가 복잡하게 느껴지도록 하는 것 같다. 실제로 두 테이블을 합치더라도 동작하는 데는 아무런 문제가 없다. 둘을 분리한 이유는 데이터 저장을 최적화하기 위해서이다.

위 테이블은 access_history와 device 테이블을 합친 것이다. 2번 행에 user_id, model, type이 없는데, 비로그인 사용자의 데이터이기 때문에 불필요한 정보는 기록하지 않았다.

테이블을 분리해보면 테이블 내에 null값이 훨씬 적어진 것을 확인할 수 있다. 불필요한 값을 최소화하기 위해 테이블을 분리한 것이다.

내부 로직

사용자 수동 편입 과정

어드민은 AB테스트에서 특정 사용자를 특정 실험군에 직접 편입시킬 수 있는 기능이 필요한데, 어드민이 사용자를 수동으로 편입시켜준다고 하여 사용자 수동 편입이라고 지칭했다. 특정 사용자를 특정 실험군에 편입시키려면 클라이언트 입장에서 양 쪽(사용자, 실험군)을 유일한 대상으로 식별할 수 있어야 한다. 실험군의 경우는 AB테스트를 선택하고 편입시킬 대상 실험군을 선택하는 과정을 통해 특정할 수 있다. 하지만 사용자는 비교적 복잡한 과정이 필요하다.

사용자는 userId를 알면 특정할 수 있을 것 같다. 그런데 클라이언트가 어떻게 수동 편입시킬 대상의 userId를 알 수 있을까? 사용자 테이블에는 unique 컬럼이 id 외에 email밖에 없다. 하지만 대상 사용자의 email을 모르는 경우도 있을 수 있다. 또한 일부 사용자는 email이 null을 가질 수 있기 때문에 적절하지 못하다. 그럼 어떻게 userId를 가질 수 있을까?

사용자 이름으로 대상 사용자군을 특정할 수 있다. 어드민이 사용자 이름을 기반으로 검색하면 동일한 이름을 가진 사람들 정보가 목록으로 반환된다. 여기에 사용자 email 또는 전화번호를 함께 반환하여 클라이언트가 대상 사용자를 직접 특정할 수 있도록 한다. 이렇게 하면 userId를 구할 수 있는데, 여기서 정말 끝일까?

아니다. DB 설명에서 언급했는데 각 사용자는 여러 디바이스를 가질 수 있다. userId만으로는 해당 사용자가 가진 디바이스 중 어떤 디바이스의 실험군을 변경해야 할 지 알 수 없다. 따라서 클라이언트에서 사용자를 선택하면 해당 사용자가 사용중인 디바이스 목록과 함께 마지막으로 접속한 일시가 언제인지까지 함께 반환한다. 클라이언트는 이 목록에서 수동 편입의 대상이 될 디바이스를 직접 선택해야 한다.

일련의 과정이 사용자 수동 편입 기능을 사용하기 위해 필요하기 때문에 3개의 API로 분리했다.

  1. 사용자 이름으로 user 검색
  2. userId로 device 검색
  3. deviceId로 사용자 수동 편입 수행

사용자 식별 기준

AB테스트를 개발하면서 가장 중요하게 생각한 부분 중 하나는 각 사용자를 어떻게 구분할 수 있는가?이다. 단편적으로 생각하면 userId를 사용할 수 있겠지만 한 명의 사용자가 여러 기기를 사용할 수도 있을 것이고, 비로그인 사용자의 기기도 식별할 수 있어야 한다. 테이블 구조를 보면 적절한 필드를 찾을 수 있다. 비로그인 사용자의 데이터도 저장하면서 여러 기기를 사용하더라도 고유한 식별자로 취급될 수 있는 필드, 바로 access_history.id이다.

일반 사용자가 AB테스트에 최초로 편입되면 실험군 편입 정보와 함께 자신의 accessHistoryId를 반환받는다. 이 값을 클라이언트 내부 저장소에 저장해두고 이후 "자신의 실험군 조회 요청"이나 또다른 "AB테스트 최초 편입 요청"이 필요한 경우 그 값을 다시 꺼내 사용한다.

여기에는 한 가지 문제가 있는데, 클라이언트에서 해당 id 값을 유실할 때 발생한다. 클라이언트에서 id가 사라지면 기존에 서버에서 유지하던 해당 기기 정보와의 연결이 끊어진다. 그럼 서버에는 무의미한 데이터가 계속 남아있는 문제가 생긴다. 이를 위해 마지막 접속 일시를 access_history에서 유지하도록 구성했다. 요구사항만 보면 device에서 관리하는 것이 무의미한 데이터의 최소화에 적절하지만 이렇게 구성함으로써 추후에 마지막으로 접속한 지 오래된 이력을 제거할 수 있는 여지를 남겨두었다.

캐싱

실험군에 편입된 인원 수

실험군을 자동으로 편입시킬 때는 각 실험군에 몇 명의 사용자가 편입되어 있는지 알아야 정의된 비율에 맞게 사용자를 편입시킬 수 있다. 이를 위해서는 각 사용자가 편입될 때마다 해당 실험군 개수를 증가시켜줘야 하는데(count++), 여기서 동시성 이슈가 발생할 수 있겠다고 생각했다. DB의 특정 정보를 동시에 제어하려고 하여 발생하는 문제를 어떻게 해결할 수 있을지 고민하다가 Redis 캐싱을 활용하기로 했다.

동시성 이슈를 걱정한 이유는 count를 증가시키는 로직이 매우 자주 호출될 것이기 때문이다. AB테스트가 적용된 페이지에 진입하는 모든 사용자는 해당 로직을 호출할 것이고, 그 때마다 count를 조회하고 증가시키는 로직을 수행해야 한다. 서비스 사용자가 많아질수록 동시성 이슈가 발생할 가능성이 점점 높아지는 것이다.

이를 해결하기 위해 DB 단의 count를 제어하는 시점을 시간당 1회로 제한했다. 사용자가 편입되는 상황에서 당장은 DB에 count를 적용하지 않고 그 정보를 캐시 상태로 유지한다. 이후 매 시간 정각마다 캐시로 유지중인 count를 DB에 적용하도록 스케줄링했다.

실험군 편입 정보

최초 편입 이후 AB테스트가 적용된 페이지에 접속하면 클라이언트는 자신의 실험군 조회 요청을 보낸다. 이 요청은 최초 편입보다도 훨씬 자주 일어날 것이고, 그 때마다 DB에 JOIN을 써가며 조회 쿼리를 날리기에는 효율적이지 못하다고 생각했다. 각 사용자마다 처음 편입되거나 한 번이라도 조회한 AB테스트에 대해서는 다시 조회 요청을 날릴 가능성이 매우 높다. 따라서 최초 편입 요청 혹은 자신의 실험군 조회 요청이 들어오면, 해당 사용자가 어느 실험군에 편입되어 있는지를 캐싱해둔다. 또한 3일의 TTL을 두고 자동으로 소멸되도록 하여 메모리 부하를 줄이고자 했다. 이 캐시는 해당 사용자의 편입 정보가 바뀌면 유효하지 않게 되므로 해당 상황에서는 자동으로 소멸시킨다.

문제 상황

여기서는 개발 과정에서 발생한 여러 문제를 다루고 있다. 위에서 설명한 구조가 여러 문제를 거쳐 만들어낸 최종안이기 때문에 그냥 넘어가도 문제없다. 하지만 사람은 시행착오를 거치며 성장하는 만큼 기록해두려고 한다.

사용자 식별 기준의 부적절함

설계 초기에는 사용자 식별 기준을 accessHistoryId가 아니라 public IP로 정했다. HTTP 요청 헤더(X-Forwarded-For 등)에서 public IP를 가져와 이를 access_history 테이블에 담았다. 모든 기기가 자신의 public IP를 유지하기 때문에 accessHistoryId와 같이 서버에서 별도로 내려주는 값도 필요없었다. 그래서 기존 사용자 정보와 연결지을 수 있었고, 이 설계 그대로 개발 후반까지 접어들었다.

동아리 부원들에게 코드리뷰를 받는 과정에서 public IP가 정말 불변한 기기 고유의 주소인가?라는 질문이 들어왔다. 생각해보니 public IP는 당연히 불변하다고 생각해왔고 초기부터 언제 한 번 찾아봐야지 라는 생각으로 이 때까지 미뤄왔다. 찾아보니 접속한 네트워크가 바뀌면 public IP여도 바뀐다고 한다. 공유기를 재부팅하거나 와이파이만 바꿔도 public IP가 바뀌는 것이다.

public IP는 더이상 사용자 식별의 기준이 될 수 없었다. 동일 기기여도 접속 장소에 따라 새로운 기기로 인식되는 건 전혀 의도치 않은 상황이다. 여러 고민 끝에 기기별로 고유한 토큰을 서버에서 만들어 제공해주는 방향으로 개선하기로 했고, 그 토큰의 출처가 accessHistoryId가 되었다.

하위 호환성을 고려하지 않은 설계

초기 설계 단계에서 fcm 토큰을 user 테이블에서 device 테이블로 이관하기로 했다. 이렇게 하면 인당 하나의 디바이스만 알림설정할 수 있던 불안정한 기존 구조를 인당 여러 디바이스를 알림설정 및 관리할 수 있도록 개선할 수 있다. 1:1 관계를 1:N 관계로 확장한 것이다.

위에서 사용자 식별 기준을 변경하고 나니 이 부분에서 문제가 보였다. 1:N 관계로 확장하는 건 좋은데, 기존에 알림 발송을 위해 유지중이던 fcm 토큰을 device로 이전하면 해당 사용자의 accessHistory와 device를 어떻게 연결한다는 말인가? AB테스트 최초 편입 요청 시 accessHistoryId 파라미터가 비어있다면 새로운 기기로 인식하도록 만들었다. 그리고 응답에서 해당 값을 반환한다. 이 id가 없으면 서버는 그 기기를 인식할 수 없다. 서버가 기기를 인식할 수 없는데 어떻게 device와 연결할 수 있단 말인가? 설령 복잡한 로직을 부가하여 연결하는 데 성공했다고 하더라도 요청받은 accessHistoryId가 알림을 허용해둔 device인지, 또다른 device인지 서버에서는 알 길이 없다. 1:1 관계를 1:N 관계로 확장하면서 문제가 발생한 것이다.

이 문제를 근본적으로 해결하기 위해서는 기존 사용자들의 알림 설정 정보(fcm 토큰)를 전부 지우고 다시 받아야 한다. 또한 알림 설정 관련 API의 요청 파라미터에 deviceId나 accessHistoryId를 추가해야 해서 하위호환성도 지키지 못한다. 정말 최악의 상황이다. AB테스트는 이렇게까지 큰 사이드이펙트를 일으킬 정도로 주요한 프로젝트가 아니었다. 어쩌다 이렇게까지 되버린건지 현타오고 자괴감들고 막 그래서 현재 상황을 정리해서 백엔드 부원들에게 공유했다.

이 상황은 근본적으로 두 가지 문제가 있다.

첫 번째, 최초에 알림 기능을 구현할 때 user:fcmToken을 1:N이 아니라 1:1로 설계했다. 이 부분은 이미 늦었고 AB테스트 개발자로서 관여할 수 있는 범위를 벗어났다.

두 번째, fcmToken을 device로 이관하여 user:device를 1:N으로 구성하고자 했다. 이렇게 구성하면 새로 진입한 기기가 기존 사용자의 기기와 동일함을 증명할 방법이 없었다.

트랙원들에게 상황을 설명하며 논의하다 보니 갑자기 한 가지 방법이 떠올랐다. device에서 device token(fcmToken)을 관리하는 건 당연해 보이지만, 꼭 그렇게 할 필요는 없었다. device는 fcmToken 등과 관련없이 AB테스트만을 위한 정보로 분리할 수 있다. user는 fcmToken을 가지지만 자신의 device와는 관련이 없다는 형태가 이상하게 느껴지기도 한다.

하지만 이 상황을 타개할 가장 효율적인 방법이라는 것은 틀림없었다. device를 AB테스트의 기능으로써만 작용하도록 기존의 DB와 분리하는 것이다. 이렇게 되면 user:fcmToken의 1:1 관계를 해치지 않으면서 device와 fcmToken의 의존성을 제거하여 기존 사용자의 알림설정된 기기와 새로 진입한 기기를 연결해야 한다는 문제가 사라진다.

요구사항 변경

이번 프로젝트에서 가장 구현하기 어려웠던 API는 누가 뭐래도 실험 수정 API이다. 실험 수정 API는 실험군 비율이 수정되면 이미 편입되어 있던 사용자들의 실험군을 실시간으로 변경하여 적용할 수 있어야 했다. 나는 API의 유연한 대응을 위해 실험 수정 API의 requestBody로 기존 실험군보다 개수가 적어지면 실험군 제거를, 많아지면 실험군 추가를 자동으로 진행하도록 구현했다. 이에 더해 실험군의 변수명(name)이 달라지면 새로운 변수로 인식하도록 하여 개수가 동일해도 변수명을 확인하고 추가/제거를 진행하도록 작성했다.

이렇게 보면 단일 API에 너무 많은 책임이 들어있는 것으로 보인다. 실제로 여기서 에러가 제일 많이 나왔고, 이 에러들을 잡는 데만 며칠씩 투자하고 있었다. 하지만 후반부에 뒤늦게 요구사항이 변경되었다.

실험 수정 API는 실험군의 추가/삭제 기능을 제공하지 않고, 비율이 수정되더라도 기존에 편입되어 있던 사용자들을 다른 실험군으로 옮기거나 하는 액션은 일어나지 않아야 한다. 에러에 머리를 싸매고 있던 나에게는 두 팔 벌려 환영할 소식이었고 바로 적용했더니 코드 4~500줄이 날아갔다. 😃

불안정한 PR의 머지 고민

개발 중반, public IP를 사용하던 초기 구조로 대부분의 기능을 구현하여 PR을 올렸다. 아직 에러가 몇몇 보이기는 하지만 다른 API까지 영향을 미치진 않았고, 알림 관련 테스트가 실패하지만 테스트 코드만 수정하면 금방 해결될 거라고 생각했다. 그래서 팀원들에게 리뷰를 요청했고, 당장 5일 뒤에 AB테스트 백엔드 QA 일정이 있었기에 에러 해결을 뒤로 미루고 머지를 요청하고 싶었다.

하지만 스테이지에 머지됐다가 잘못하면 스테이지 DB 롤백도 해야하고, 에러 해결 이전에 타 팀에서 프로덕션 배포를 해버리면 돌이킬 수 없게 되어 버린다. 코드리뷰 과정에서 publicIP 관련 이슈를 식별했지만 QA 이전까지 해결하기엔 시간이 너무 부족했다. 결국 동아리 부원과 논의 끝에 테스트용 배포 환경을 구축하기로 했다. 동아리에서 사용중인 CI/CD 도구와 실서비스에 영향을 주지 않는 서버 인스턴스를 활용하여 테스트 배포 환경을 구축한 뒤 순조롭게(?) QA를 진행할 수 있었다.

회고

완성된 모습

웹 페이지는 우리 동아리 프론트엔드 능력자 선배님께서 뚝딱 만들어주셨다! 😄

아쉬웠던 점

초기 설계 미흡 - 사용자 식별 정보

public IP가 불변 정보인지는 AB테스트 설계에 있어 매우 중요한 부분이었다. 하지만 나는 당연히 괜찮겠지~라는 안일한 생각으로 이에 대해 자세히 찾아보기를 미루고 미루다 결국 개발 후반부에 가서 구조를 뒤엎어야만 했다.

초기 설계 미흡 - 1:N 관계로의 확장

이번 기회에 FCM 토큰을 device 테이블로 이전시켜 각 유저가 하나의 디바이스만을 제어할 수 있던 기존 구조에서 여러 디바이스를 가질 수 있도록 개선하고자 했다. 처음 설계하던 당시에는 완벽해 보이고 기존 문제점을 개선하는 훌륭한 구조라고 생각했다. 하지만 1:1 관계에서 1:N 관계로 확장하는 시점에 발생할 수 있는 사이드 이펙트를 충분히 고려하지 않았다. 뒤늦게 고민해보니 이 변경된 구조를 배포하기 위해서는 알림 관련 기존 데이터를 전부 파기해야 했고, 관련 API 명세도 바뀌어버려 하위호환성을 무시하는 결과를 낳았다. 결국 기존 데이터와 완전히 분리하는 방향으로 틀어서 해결했지만 초기에 설계가 미흡했던 것이 아쉽다.

나아가 우리 서비스에 처음 알림 기능을 추가할 때부터 user와 device(token)의 관계를 1:N으로 설계했다면 훨씬 완성도있는 구조를 가질 수 있지 않았을까 하는 아쉬움도 남는다.

PR 분리 미흡

보통 PR 하나가 1,000줄이 넘어가기 시작하면 읽기가 싫어지기 마련인데 이번 AB테스트 PR로 3,700줄짜리 PR을 올려버렸다. 나름 변명해보자면 AB테스트에 이해관계가 있는 백엔드 부원이 거의 없었기에 기능 단위로 PR을 분리해 올려도 리뷰가 늦어지거나 재차 수정하는 일이 빈번할까봐 한 번에 올렸다. 스테이지에 잘못된 코드가 담긴 PR을 올려버리면 실수로 프로덕션에 배포될 가능성도 무시할 수 없었다.

지금 생각해보면 그럼에도 PR은 기능 단위로 분리하여 올리는 것이 적절했다고 본다. 이해관계가 없는 백엔드 부원들이 기능단위로 하나씩 이해할 수 있는 기회를 줄 수도 있고, 스테이지로 올라갔다가 배포되는 게 걱정된다면 테스트용 배포 환경을 구축하여 활용할 수도 있었을 것이다.

QA를 위한 리스크 감수는 적절한가?

나는 QA 직전 당시 QA를 위해 스테이지 배포가 필요하니 리뷰해주세요! 라는 입장이었다. 이건 곧 에러를 인지하고 있음에도 QA를 위해 서둘러 머지하고 싶다는 말과 같다. 스테이지에 배포하는 것은 잘못하면 프로덕션까지 영향이 갈 수 있다는 것을 인지하고 있었다. 실제로는 백엔드 부원들과 논의 후 테스트 환경을 구축하여 스테이지 배포 없이 QA를 진행했지만 만약 내가 머지에만 집착하고 스테이지 배포를 강행했다면 어땠을까? 확실한 건 적절한 방향은 아니었을 것이라는 점이다.

좋았던 점

최근에 팀장을 맡다보니 백엔드 개발 자체에 대해 상대적으로 소홀히 하게 되었다. 그래서 개발에 몰두하고 싶다는 갈증을 느껴 이번 프로젝트에 참여한 것도 있다. 이번 프로젝트를 진행하면서 오랜만에 하나의 서비스에 대해 처음부터 끝까지 모든 것을 관여해서 너무 좋았다.

초기 설계가 미흡해서 개발 도중에 구조가 바뀌는 일이 많았고 개발 후반부에는 요구사항이 갑작스럽게 변경되기도 했다. 하지만 이런 상황에 나름 유연하게 대응했다고 생각하고, 그 덕분에 큰 문제없이 개발을 잘 마무리지을 수 있었다.

결론

회고를 보면 아쉬운 점이 굉장히 많다. 이렇게 보면 후회 가득한 개발 과정을 보낸 것처럼 보일 수 있지만 역설적이게도 나 자신에게 아쉬웠던 부분을 많이 인식할 수 있어서 너무 좋았다. 우리에게 필요한 서비스를 직접 만들어 사용한다는 경험도 흔하지 않다고 생각한다. 개발 과정도 재미있었고 다양한 고민을 해볼 수 있어서 좋았다. 유익한 경험을 제공해준 동아리에 감사하다.