AB테스트 도구 개발 과정
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테스트를 구현하기 위해 백엔드에서 어떤 구조를 가져가야 할 지 고민해보니 이전에 구상했던 구조로는 어림도 없었다. 모수가 그리 크지 않은 상황에서 확률에 기반하여 사용자를 편입시킨다면 정확한 값을 기대하기 힘들 수 있다. 또한 한 명의 사용자는 동일한 실험에 대해 다시 접근하더라도 이전과 같은 실험군 값을 받아올 수 있어야 하기 때문에 사용자별 실험군 데이터를 저장해야만 한다.
최종적으로 정리한 구조는 다음과 같다.
- 어드민은 AB테스트를 생성/조회/수정/삭제할 수 있다.
- 실험 진행 도중에 실험군 비율이 수정되면 기존에 편입되어 있던 사용자들의 실험군은 그대로 유지하고 이후로 새로 편입되는 사용자들에 대해 해당 비율이 적용된다.
- 실험군 비율이 0%로 수정되어도 이미 해당 실험군에 편입되어 있던 사용자들은 그대로 유지된다.
- 승자를 선택하고 실험을 종료하면 이후로 해당 실험에 대해 들어오는 모든
자신의 실험군 조회 요청
에 승자를 반환한다.
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로 분리했다.
- 사용자 이름으로 user 검색
- userId로 device 검색
- 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가 되었다.