안녕하세요! 아이들나라 백엔드팀 최현구입니다.
오늘은 아이디어 선정부터 빠른 배포까지, 8명의 동료들과 함께 정신없는 한 달을 보내며 마이 쿠키 서비스를 탄생시킨 비화를 들려드리고자 합니다.
쿵짝쿵짝실험실TF 결성!
때는 바야흐로 2024년 1분기 초, 아이들나라 CTO 정호님께서 ‘쿵짝쿵짝실험실’ 이라는 TF 조직 결성을 알리시게 됩니다. 각 직군별 1~2명으로 구성된 구성원들은 모두 들뜬 마음에 어떤 걸 만들어볼까 상상의 나래를 펼치고 있었습니다.
행복한 상상의 나래를 펼친것도 잠시, 갑작스럽게 다음 달 서비스 출시를 목표로 달려가라는 지령이 떨어집니다.
기획 민경님 / QA 기원님 / 디자인 연지님, 예지님 / 모바일 우림님, 혜련님 / BE 다혜님 그리고 저까지 총 구성원은 겨우 8명, 남은 작업 기간은 단 29일…
분야별로 1~2명 씩 밖에 존재하지 않기 때문에 큰 서비스 개발이나 기능 개편은 어림도 없고, 최대한 작은 아이디어를 도출하게 빠르게 결과물을 만들어내야합니다.
분명 일정은 신경쓰지 말라고 하셨잖아요…
어떤 걸 만들어야할까?
갑작스러운 일정 픽스에 실험실TF 구성원들은 매일 같이 모여 머리를 맞대고 아이디어 선정을 위해 고군분투 했습니다.
평소에 아이들나라 서비스를 만들며 해보고 싶었던 것들, 아이들나라 서비스를 직접 이용하며 불편하다고 느꼈던 것들, 그리고 주변 지인들로부터 들었던 요청들을 하나 둘 씩 모으기 시작했습니다. 그렇게 아이디어를 한 판 모아두니 정말 현실적으로 해낼 수 있는, 그리고 꼭 지금 해내야만 하는, 보석 같이 빛나는 아이디어들이 하나 둘 눈에 보이기 시작했습니다.
저희가 선택한 보석은 ‘쿠키’ 였습니다.
아이들나라에는 컨텐츠를 재생하거나, 학습 퀴즈 등을 풀었을 때 획득할 수 있는 보상 재화 쿠키가 존재합니다. 아이들나라 고객들은 쿠키를 정말 쉽게 획득 가능하지만, 안타깝게도 쿠키를 소비할 컨텐츠가 마땅치 않아 소비가 거의 이루어지지 못하고 있습니다.
아이들나라 고객들도 나날이 쌓여가는 쿠키를 보며 “이건 도대체 어디에 쓰는거지?” 라는 물음을 가지셨던 모양입니다.
실험실TF 구성원들이 쿠키와 관련해서 접했던 충격적인 비하인드 스토리도 많았습니다.
•
대부분의 고객들은 쿠키 보유 개수가 많지 않지만, 최상위 고객은 210,000 개의 쿠키를 보유하고 있다.
•
유일한 쿠키 소비 기능은 5분 가량의 설문조사를 거쳐야만 사용 할 수 있다.
쿠키를 재화라고 부르기도 무색한 상황. 실험실TF 구성원들은 쿠키를 소비해서 비실물 보상을 제공하는 아이디어가 가장 작고, 빠르고, 아이들나라에 꼭 필요한 아이디어라고 결론 지었습니다. 그렇게 탄생한 서비스가 바로 ‘아이카드 랜덤 뽑기’ 입니다.
초기 기획안
아이디어가 잡히고 난 뒤는 간단합니다. 뒤도 돌아보지 않고 일정 내 배포를 향해 달려 나갑니다!
랜덤 뽑기 확률을 어떻게 만들까?
그럼 아이카드 랜덤 뽑기에 필요한 랜덤 뽑기 확률은 어떻게 만들고, 관리할까요?
우선 아이카드에 필요한 랜덤 뽑기 시스템의 요구사항은 다음 같습니다.
요구사항
•
각 카드는 1~5성 까지의 등급을 갖는다.
•
각 카드는 중복 등장이 가능하다.
•
투자한 쿠키 개수에 따라 등급별 등장 확률이 달라진다.
◦
쿠키를 3개 투자한다면 1~2성 카드가 잘 등장한다.
◦
쿠키를 5개 투자한다면 2~3성 카드가 잘 등장한다.
◦
쿠키를 10개 투자한다면 3~5성 카드가 잘 등장한다.
카드에 1~5성 까지의 등급이 존재하는 것은 여타 TCG 카드 게임, 모바일 게임에서 흔하게 보았던 시스템이라 쉽게 이해하고 정의할 수 있습니다.
고민이 많았던 부분은 '투자한 쿠키 개수별 등장 확률 변경' 인데, 초기에는 각각의 카드마다 등장 확률을 별도 관리해볼까 고민했습니다. 그러나 카드마다 확률을 관리하는 방법은 아래와 같은 이유로 불가능하다 판단했습니다.
1.
뽑기 방법(쿠키 20개, 이벤트 무료 뽑기 등)마다 확률을 다르게 하고 싶을 경우 관리가 불가능하다.
2.
새로운 카드가 추가될 때마다 전체 카드들의 확률을 재조정 해주어야 한다.
이렇게 만들면 운영 담당자가 너무 좋아해요.
매 번 전체 카드들의 확률을 재조정해야한다는 문제점을 차치하더라도, 뽑기 방법이 추가될 때마다 각 비즈니스 요구에 걸맞는 확률 관리가 불가능하다는 점이 가장 리스키합니다.
결국 현실적으로 선택할 수 있는 방법은 뽑기 방법 자체마다 확률을 별도로 관리하는 것. '특정 카드의 등장 확률이 높아진다.' 라는 요구사항이었다면 고민이 더 필요했겠지만, 다행스럽게도 '특정 등급에 해당되는 카드들의 등장 확률이 높아진다.' 라는 간단한 요구사항이므로, 어렵지 않게 로직을 구상해볼 수 있습니다.
뽑기 방법 별 확률 구성
위 표와 같이 총량을 100% 로 하고, 소모되는 재화별 뽑기의 확률을 달리하면 관리가 간편해집니다. 투자하는 쿠키의 개수가 늘어날수록, 상위 등급의 등장 확률을 늘리고, 하위 등급의 등장 확률을 줄이면 되죠.
만약 재화를 30개 소모해서 무조건 3~5성만 등장하도록 하는 뽑기를 만들고 싶다면 아래와 같은 확률표 관리도 가능해집니다.
카드 등급별로 등장 확률을 관리하는 방법을 알게 되었습니다. 그렇다면 어떻게 실제로 각 등급별 확률에 맞추어 카드가 등장하게 끔 만들 수 있을까요?
현재 표에 나타난 100% 확률에 맞추어 1~100 까지의 숫자를 각 확률 별로 고르게 분포시키고, 1~100 사이의 숫자를 랜덤하게 하나 뽑아내면 됩니다.
가령 쿠키 5개를 투자해 뽑기를 시도했는데, 숫자 72가 나온 경우는 어떨까요?
51~75 까지 분포된 3성 등급이 대상이 됩니다. 준비되어 있는 3성 카드들 중 하나를 랜덤하게 뽑아 사용자에게 지급하면 됩니다.
만약 등장 확률을 소수점 하위까지 관리하고 싶다면? 수의 범위를 100 이상으로 늘리면 됩니다. (소수점 첫째자리는 1~1,000, 둘째자리는 1~10,000) 당연히 랜덤하게 뽑는 숫자의 범위도 그에 맞춰서 늘려주면 됩니다.
카드 랜덤 뽑기 구현
Kotlin + Spring + JPA 환경에서 간단하게 랜덤 뽑기를 구현해봅니다.
// 카드
@Entity
class Card(
@Column(nullable = false)
var name: String,
@Column(nullable = false)
@Enumerated(EnumType.STRING)
var grade: CardGrade,
) {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
val id: Long = 0L
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Card
return id == other.id
}
override fun hashCode(): Int {
return id.hashCode()
}
}
Kotlin
복사
// 랜덤 뽑기
@Entity
class CardRandomDraw(
@Column(nullable = false)
val name: String,
@Column(name = "need_money", nullable = false)
val need_money: Int,
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "card_random_draw_range_by_grade",
joinColumns = [JoinColumn(name = "card_random_draw_id")]
)
@AttributeOverrides(
value = [
AttributeOverride(name = "grade", column = Column(name = "grade", nullable = false),),
AttributeOverride(name = "startRange", column = Column(name = "start_range", nullable = false)),
AttributeOverride(name = "endRange", column = Column(name = "end_range", nullable = false))
]
)
private val ranges: MutableList<CardRandomDrawRangeByGrade>
) {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
val id: Long = 0L
init {
validateDuplicateGrade()
validatePercentRange()
}
private fun validateDuplicateGrade() {
val gradeSet = ranges.map { it.grade.value }.toSet()
require(gradeSet.size == ranges.size) { "중복되는 등급(별)이 존재합니다. 등급(별): ${gradeSet.joinToString(", ")}" }
}
private fun validatePercentRange() {
val percentRange = ranges.map { it.startRange..it.endRange }.flatten()
require(percentRange.size == 100) { "확률의 범위는 1~100까지의 숫자로만 이루어질 수 있습니다." }
}
fun randomDrawGrade(): CardGrade {
val randomValue = (1..100).random()
return ranges.first { it.isInRange(randomValue) }.grade
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CardRandomDraw
return id == other.id
}
override fun hashCode(): Int {
return id.hashCode()
}
}
Kotlin
복사
// 랜덤 뽑기 등급별 범위
@Embeddable
data class CardRandomDrawRangeByGrade(
@Enumerated(EnumType.STRING)
val grade: CardGrade,
val startRange: Int,
val endRange: Int,
) {
init {
require(startRange <= endRange) { "확률의 시작 범위는 끝 범위보다 작거나 같아야 합니다." }
require(startRange >= 1) { "확률의 범위는 1~100까지의 숫자로만 이루어질 수 있습니다." }
require(endRange <= 100) { "확률의 범위는 1~100까지의 숫자로만 이루어질 수 있습니다." }
}
fun isInRange(value: Int): Boolean = value in startRange..endRange
}
Kotlin
복사
자세히 보면 1:N 구조를 나타내는 card_random_draw_range_by_grade 에는 별개 PK 가 존재하지 않습니다. card_random_draw_range_by_grade 1 row 로서는 비즈니스적으로 아무런 가치를 지니지 않고, card_random_draw 와 함께 다 같이 조회되었을 때 실질적으로 가치를 지니므로, 생명주기를 card_random_draw 와 함께한다는 의미로 @ElementCollection, @CollectionTable 을 활용했습니다.
@ElementCollection, @CollectionTable 에 대해서는 아래 글을 참고해주세요.
또 하나 살펴볼 점은 CardRandomDrawRangeByGrade 에서 @Embeddable 어노테이션을 사용중인데, 이 이유는 No-arg compiler-plugin 의 지원을 받기 위해서 입니다.
구체적으로 확률별 랜덤한 등급 뽑기 코드는 아래와 같이 동작합니다.
fun randomDrawGrade(): CardGrade {
val randomValue = (1..100).random() // 1
return ranges.first { it.isInRange(randomValue) }.grade // 2
}
Kotlin
복사
1.
1~100 사이의 난수를 뽑는다.
2.
카드 랜덤 뽑기에 존재하는 확률 범위 중에서 난수에 해당되는 등급을 뽑아낸다.
val cardGrade = cardRandomDraw.randomDrawGrade()
val drewCards = cardRepository.findByGrade(grade = cardGrade) // 3
return drewCards.random() // 4
Kotlin
복사
3.
뽑아낸 등급을 기준으로 카드 저장소에서 같은 등급의 카드들을 모두 찾아낸다.
4.
찾아낸 카드 리스트에서 랜덤한 한 장을 뽑아서 반환한다.
응용 및 고도화
만약 특정 카드에 대한 등장 확률을 높이고 싶다면, 아래와 같은 방식으로 추가 관리가 가능합니다.
모바일 게임 가챠에서 많이 보이는 사용자별 N 회 이상 뽑기시 특정 카드 무조건 지급과 같은 로직의 경우 사용자별로 뽑기 내역 테이블을 별개로 관리하면서 뽑기 직전 N 회 이상 조건에 도달했는지 체크하는 것으로 간단히 구현이 가능하겠습니다!
그 외에도 사용자별 N 회 이상 뽑기시 5성 카드 등장확률 20% 증가 와 같은 경우 다른 등급의 등장확률을 줄이고 5성 카드 등장 확률을 늘리거나, 1~100 을 넘어 1~120, 1~300 범위를 다루어 등장 확률을 늘릴 것인지 등 여러가지 고민이 추가로 필요할 것 같습니다.
마이쿠키 서비스 개시! 그 결과는…?
쿵짝쿵짝실험실TF 구성원 8명의 힘이 한 곳에 집중된 2024년 3월, 아이카드 랜덤 뽑기를 포함한 마이쿠키 서비스가 세상에 첫 발을 내딛었습니다!
실험실TF 구성원들이 마이쿠키 서비스를 개발하면서 가장 중요하게 생각한 것은 ‘어떤 기술’을 사용하여 ‘무엇을 만들었는가’ 보다, 그것을 통해 ‘누구’에게 ‘어떤 가치’가 전달되었고 ‘어떤 변화’를 일으켰는가 입니다.
변화를 인지한다는 것은, 당연하게도 ‘이전 상태’를 알고 현재와 비교가 가능하다는 뜻 입니다. 제품을 만들기 앞서 현재 현상이 어떠한지 인지하고, 그것을 문제로 정의 해내야 합니다. 문제가 명확하게 정의되면 자연스럽게 문제를 해결을 시도(제품 개발)할 수 있고, 문제가 잘 해결되었는지 관찰 할 수 있게 됩니다. 즉, 가치가 잘 전달되고 변화가 나타났는지 인지할 수 있습니다.
실험실TF 구성원들은 이러한 변화를 인지하기 위한 방법도 치열하게 고민했는데요, 특히 마이쿠키 서비스를 통해 궁극적으로 만들어내고 싶은 변화가 무엇인지부터 정의했습니다.
“우리가 목표로 하는 것은 프로필 전환율 상승과 보유 쿠키 개수 하락이라는 변화! 마이 쿠키 서비스는 변화를 만들기 위한 수단일 뿐!”
당찬 포부와 함께 준비한 여러가지 가설을 준비했고, 마이 쿠키 서비스 배포 이후 지표 변화를 눈으로 확인할 수 있었습니다.마이쿠키 서비스는 어떤 변화들을 만들어냈을까요? 수 많은 가설들 중 대표적인 3가지만 소개하겠습니다.
가설1. 접근성을 개선하면 기존 서비스 사용량이 늘거야!
기존 유일한 쿠키 소모 창구였던 ‘아이친구 키우기’ 메뉴에 접근하려면 거대한 산맥을 지나야 했는데요, ‘아이친구 키우기’ 메뉴가 지나치게 숨겨져 있기도 했고, 메뉴를 발견하더라도 5분 가량의 설문조사를 거쳐야만 실질적으로 이용이 가능했습니다.
쿠키 소모 메뉴 접근성을 개선하기 위해 메인 화면에 마이쿠키 서비스 메뉴를 추가하고, 과감하게 설문조사 절차를 생략했습니다. 그러자 아래와 같은 놀라운 변화가 생겨났습니다.
•
일평균 PV 803% 증가
•
일평균 UV 668% 증가
심지어 일평균 UV 의 경우 증가량이 갑작스럽게 치솟은 탓에 분석 서비스에서 이상 현상으로 여기기까지 했습니다.
가설2. 마이 쿠키 서비스에서 프로필 등록을 유도하면 등록 수가 늘어날거야!
마이 쿠키 서비스는 하나의 수단일 뿐! 결국 아이들나라 서비스에 가장 중요한 변화와 가치는 프로필 전환율 상승이었는데요, 실제로 마이 쿠키 서비스 출시 이후 아래와 같이 유의미한 변화가 생겨나고 있었습니다.
•
마이쿠키 서비스 출시 후 프로필 전환율 UV 12.6% 증가
•
전체 프로필 전환율 중 마이 쿠키 서비스를 이용한 프로필 전환율 14%
가설 3. 마이 쿠키 서비스를 통해 쿠키 소모량이 늘겠지?
가장 마지막으로, 아이들나라 고객들의 가장 큰 관심사이자 저희의 골칫거리였던 쿠키는 과연 얼마나 소비되었을까요?
초기 3주간 쿠키 지급 대비 사용률을 추적한 결과, 쿠키 몬스터와 같이 엄청나게 빠른 속도로 평균 47% 의 쿠키가 계속해서 소비하고 있었습니다!
쿵짝쿵짝실험실TF 로 배운 것들, 그 이후…
결국 실험실TF 구성원들은 아래와 같은 교훈을 얻을 수 있었습니다.
1.
고객들은 Geek 한, 엄청난 걸 기대하지 않는다.
2.
아이도 어른도 결국 재밌는 것에 반응한다.
3.
우리가 만들고 싶은 것이 아니라, 고객들이 원하는 걸 만들어야 한다.
교훈대로 고객들이 원하는 재밌는 서비스를 만들기 위해선 구성원들이 아래와 같은 서비스 고도화 사이클을 경험하는 것이 중요하다고 느껴졌습니다.
또한 8명의 작업자들이 함께 생각하고, 함께 제안하고, 함께 고민해서 우리 서비스를 더 멋지게 변화 시키기 위한 시간들을 가진 것이 너무 즐거웠습니다. 업무 외 시간에도 자발적으로 여러가지 아이디어를 제시하고, 테스트를 진행하고, 웃고 떠들며 정말 재밌게 참여했던거 같네요. 단순히 주어진 기획을 따르는게 아니라, 정말 주인의식을 갖고 행복하게 일하는 시간이었습니다.
이렇게 즐거웠던 실험실TF 는 3월 이후 어떻게 지내고 있을까요?
지속적으로 사용자들의 행동 지표를 관찰하며 새로운 카드 테마 추가 니즈를 파악하고, 신규 테마 ‘유삐의 여름방학’ 카드 뽑기가 공개되었습니다.
또한 쿵짝쿵짝실험실을 이어 두 번째로 출범한 우당탕탕실험실!
아이들나라는 오늘도 쿵짝쿵짝, 우당탕탕 여러가지 실험중이라 정신없는 하루를 보내고 있습니다.
이후에도 계속해서 고객들의 행동을 관찰하고, 그들의 니즈를 파악하고, 또 다른 실험실이 만들어지고, 서비스 고도화 사이클이 숨 가쁘게 돌아갈 때면, 아이들나라는 고객들에게 훨씬 더 가까이 다가선 서비스가 되어 있을 겁니다.