brunch

You can make anything
by writing

C.S.Lewis

by 지은 x NULL Mar 24. 2020

지역마다 공적마스크 재고소진에 걸리는 시간은?

서울은 71분, 전라북도는 2시간 45분, 울산은 38분 걸렸다.

크게 시도별로 나눠보면 가장 빨리 소진된 울산은 38분,
오래 남아있었던 전북은 2시간 하고도 45분간 재고가 충분*하다고 알려줬다.

*실제 현장의 상황은 알 수 없으나 데이터가 말해준 바로는 그러했다. 입고 이후 '충분(100개 이상)'상태를 유지하는 시간의 해당지역 평균이다.


숫자의 단위는 '분'이다.



개발자와 마스크의 상관관계

3월 초 커뮤니티에 마스크 재고 현황에 관한 API가 개발된다는 소식이 알음알음 알려졌고, 지난 3월 10일 공지 이후 3월 15일자로 오픈 API 형태로 제공되기 시작했으며 현재도 서비스가 되고 있다. (참고 : 개발자를 위한 공적마스크 데이터 개방 및 활용 안내)


그러자 재고 현황을 지도에 보기좋게 나타내는 각종 웹사이트가 경쟁적으로 나타났고 그 덕분에 서비스 초반에는 API가 먹통이 되기도 했다. - 캐시 하나 없이 매번 API를 호출한 모양이다. - 모두 동일한 API를 활용하기에 실제와 데이터의 괴리를 현장에서 직접 조사하며 바로잡을 게 아니라면 동일한 내용물에 껍데기(UI)만 다를 뿐. 어떤 서비스가 더 뛰어난가, 어떤 사용자경험이 더 편리한가는 크게 중요치 않다. 게다가 이미 포털사 지도앱에 내장되어 서비스 되고 있으니, 지도앱이라고 하면 접근성은 말할 것도 없고 그 어떤 사이트보다 안정적으로 서비스를 제공해주고 있다.


데이터 더미 속에서 미래(의 마스크)를 찾으려거든

더 나은 무언가를 만들 수 없는 조건이니 처음부터 바퀴의 재발명은 필요 없다고 생각했다. API 명세만 놓고 보면 데이터의 수집/분석이 불가능하여 서비스 시작일부터 매일같이 매 분마다 자료를 수집했다. 당장 줄을 설 필요는 없었지만 언젠가는 필요할 테니, 미래의 나를 위해 차곡차곡 곳간을 채웠고 어느덧 일주일치 데이터가 모여 공유해본다.



코드의 재구성

아래 코드처럼 프로토타이핑 후 파이썬으로 다시 작성했고, sleep으로는 API 호출 시간 때문에 수 초의 오차가 발생해서 분단위가 변하면 크롤링하도록 변경했다.


salesDF <- data.frame()

flag=0;repeat {

  cur_time <- Sys.time()

  message(flag,"]",cur_time)

  tryCatch({

    res <- RETRY("GET", url, times = 5) %>% 

      read_html() %>% html_text() %>% jsonlite::fromJSON()    

    salesdf <- res$sales


    for (i in 2:res$totalPages) {

      sales <- sprintf('/sales/json?perPage=5000&page=%d', i)      

      res <- RETRY("GET", paste0(base_url, sales), times = 5) %>% 

        read_html() %>% html_text() %>% jsonlite::fromJSON()

      salesdf <- bind_rows(salesdf, res$sales)

    }    

    salesDF <- bind_rows(salesDF, cbind(timestamp = as.numeric(cur_time), salesdf))

    flag <- flag + 1    

    if (flag %% 5 == 0) {

      filename <- paste0("mask_",as.integer(cur_time), ".tsv")

      write_tsv(salesDF, filename)

      system(sprintf('tar -zcf %s.tar.gz %s', filename, filename))

      Sys.sleep(1)      

      file.remove(filename)

      salesDF <- data.frame()

      print(as.integer(cur_time))

    }

    Sys.sleep(2*60)

  }, error = function(e) {

    print(nrow(salesDF))

    })

}


과제처럼 데이터가 주어지는 게 아니라면(업무든 현실문제든 알아서 tidy한 데이터가 생성된다거나 일의 시작부터 완벽하게 존재하는 경우는 없다고 보면 된다.) 추후 분석을 위해 어떻게 쌓을지부터가 굉장히 중요한데, 코드에 주석은 없지만 대략적인 흐름은 파악할 수 있을 것이다.

API에서 GET으로 받아온 JSON 텍스트를 data.frame으로 바꾸고 tsv 형태로 저장한 뒤 곳간을 아끼기 위해 tar로 압축해뒀다. 그렇게 일주일치를 채우니 약 1GB쯤 모였다.


처음 며칠은 2분~90초 단위로, 이후 1분마다 재고현황을 수집했는데, 결과는 당연히 대부분 중복되는 데이터가 가득해진다. 실시간으로 업데이트가 안될 뿐더러 수량이 아니라 대략적인 구간단위로 변화하기 때문이다.

- 100개 이상 : 충분 * 녹색
- 100개 미만(99개~30개) : 보통 * 노랑색  
- 30개 미만(29개~2개) : 부족 * 빨강색  
- 1개~0개 : 없음 또는 판매전 : * 회색

tar을 다시 헤쳐모여-해서 일별로 하나의 파일을 만들고......

중복되는(불필요한) 데이터를 지우면 이렇게 된다.


          code            stock_at remain_stat          created_at           datetime seq

1   1048230587 2020-03-22 13:56:00       empty 2020-03-22 23:55:00 2020-03-23_0000.gz   1 

2   1048230587 2020-03-23 13:57:00        some 2020-03-23 14:00:00 2020-03-23_1401.gz   1 

3   1048230587 2020-03-23 13:57:00         few 2020-03-23 17:45:00 2020-03-23_1746.gz   1 

4   1048230587 2020-03-23 13:57:00       empty 2020-03-23 21:40:00 2020-03-23_2142.gz   1 

5   1058222697 2020-03-21 14:02:00       empty 2020-03-22 23:55:00 2020-03-23_0000.gz   1 

6   1058222697 2020-03-23 14:02:00        some 2020-03-23 14:05:00 2020-03-23_1407.gz   1 

7   1058222697 2020-03-23 14:02:00         few 2020-03-23 17:35:00 2020-03-23_1737.gz   1 

8   1058222697 2020-03-23 14:02:00       break 2020-03-23 18:00:00 2020-03-23_1802.gz   1 

9   1058222703 2020-03-22 13:48:00       empty 2020-03-22 23:55:00 2020-03-23_0000.gz   1 

10  1058222703 2020-03-23 12:58:00      plenty 2020-03-23 13:00:00 2020-03-23_1302.gz   1


위 형태의 df를 가지고 상태(remain_stat) 변화가 있는 부분의 시간을 구하려면 어떻게 해야할까?

아주 정직하게 for문을 돌리면 이렇게 할 수 있겠다.


  for (i in 2:nrow(changed_time)) {

    if(changed_time$code[i] == changed_time$code[i-1]) {

      changed_time$duration[i] <- changed_time$created_at[i] - changed_time$created_at[i-1]

    }

  }


하지만 2만 개가 넘는 약국 수 * 새벽 시간을 제외한 매 분 약 1,000건이면 하루치가 수십만 건이 되고 단순한 산수도 굉장히 메모리를 많이 잡아먹는다. 그래서 아래와 같이 바꿔봤다.


  # 6시 이전 데이터 제외

  changed_time <- df %>% filter(data.table::hour(datetime) > 5)

  print(table(changed_time$remain_stat))  

  setDT(changed_time)

  changed_time[remain_stat=="plenty"]$seq <- 3

  changed_time[remain_stat=="some"]$seq <- 2

  changed_time[remain_stat=="few"]$seq <- 1

  changed_time[remain_stat=="empty"]$seq <- 0

  changed_time[remain_stat=="break"]$seq <- -1


  changed_time$duration <- NA

  changed_time$duration <- (changed_time$created_at[-1] - changed_time$created_at)/60

  changed_time$duration[changed_time$code != changed_time$code[-1]] <- NA

  changed_time$reverse <- NA

changed_time$reverse[!is.na(changed_time$duration) & (changed_time$seq[-1] - changed_time$seq) > 0] <- 1


위와 같은 방식으로 8일치(15~23일) 데이터를 정제해서 각 stat을 세어보면,

> table(changed_time$seq)  

    -1      0      1      2      3 

 70065 144132 147426 165015 164296 

이정도가 됐다.

데이터의 수집과 전처리 이야기는 이쯤에서 마무리하고 결과를 보자.



1200만 개의 데이터를 꿰어 보려면

꿰뚫어 보면 좋겠지만 690,934행 18열의 12,436,812칸짜리 데이터는 그냥 봐서는 아무것도 아닌 것 같다. 꿰뚫지는 못하더라도 열두 말의 구슬이라고 생각하고 꿰어보려고 했다.


우선 서울 시내로 한정하여 요일별로 구분하고 마스크 재고의 각 상태가 지속되는 시간을 흩뿌려 놓았다. 각 점이 하나의 약국 데이터고 약국의 위치를 구별로 나눠 색을 달리했다.

아무래도 수기 입력 데이터의 한계가 있다보니 이상치(outlier)는 제거하고 평균의 함정을 피하기 위해 boxplot을 그렸다.

Y축이 재고 상태가 유지된 시간인데, 월요일 '충분'은 2~30부터 100분 언저리까지 시간대에 대부분 몰려있고 평균선은 50분쯤 되는 그림이다.

재고 현황별 색상은 오픈 API 가이드를 충실히 따랐다.


글 작성일 기준으로 오늘(화요일)은 편차가 적고 굉장히 빠른 시간에 단계가 변화하는 걸 알 수 있다. 그럼 마스크 재고 앱을 보면서 재고 현황이 어제보다 훨씬 금방 변할 테니 더 서둘러야할까?

꼭 그렇지는 않다. 위는 테스트용으로 sample_n(10000) 즉 1만 개 데이터만 점찍어본 것이고, 전체 데이터는 조금 다르다.

열두 말의 구슬

모든 데이터로 모든 정보를 한꺼번에 주려는 욕심은 버리자.


전국적으로 수십~수백만 장이라고는 하지만 어차피 약국별 수량은 매우 한정적이다보니 사람이 조금만 몰려도 기존 데이터와는 확연히 달라질 수밖에 없다. 그래서 크게 시도 단위의 그룹을 먼저 알아보고 데이터도 평균값만 취해 그래프를 다시 그렸다.

각 요일별로 5부제가 실시되니 요일별 분석을 목적으로 했는데 아직 데이터가 부족한 듯싶다. 일요일이 두 번, 그리고 월요일을 두 번 거치면서 평균이 크게 변화하는 중이다. 데이터를 다시 펼치면,

이건 결국 다시 아래처럼 바꿀 수 있다.

요일보다는 일자별로 펼쳐 추세를 우선 확인


상위 1,2위를 차지한 전라북도와 대구광역시 그리고 가장 빨리 상태가 변한(조기 소진된) 울산광역시도 동일한 방법으로 그려봤다. (지역별 편차가 큰 이유는 판매 속도 차이뿐만 아니라 판매 방식에 따른 차이일 수도 있다. 가령 번호표를 나눠준다든지 시스템에 입력을 몰아서 한꺼번에 한다든지 등등. 그에 따른 지역간 차이는 없다고 가정하고 평균값을 참고해주기 바란다.)




지역을 좀더 세분화해보면 다음과 같다. 같은 서울시 내에서도 편차가 꽤 크다.(그림 모양이 매우 다르다.)

옅은 색상의 empty나 break 상태로 오래 유지되는 것은 시스템 오류일 가능성이 있고 오류가 아니더라도 현 시점에는 불필요한 정보로 간주한다.


다시 서울 시내 전체 평균 수치로  바꿔서 구체적인 시간을 알아보자. 대략 30~50분이면 상태가 변화했고, 주말에는 상대적으로 좀더 오래 지속됐음을 알 수 있다.

충분이 가장 안심할 수 있는 상태긴 하지만(이미 약국 앞에 수십 명이 줄을 서있다고 가정하면 다른 상태의 경우 앱을 보고 길을 나서기엔 이미 늦었을 수 있다.)

충분->보통->부족으로 이어지는 동안에도 구입 가능성은 있는 상태이므로 수치를 누적 합산해봤다.


규모는 한 눈에 안 들어오고 마치 더미 데이터를 집어넣은 일러스트 그림같은 느낌이다.

어렵게 그렸지만 선그래프는 그만 놓아줄 때가 된 것 같다. 지금 필요한 건 초록이 높으냐 빨강이 높으냐처럼 색상별 비교가 아니라 재고 상태를 유지하는 시간이기 때문에 누적 막대그래프가 가장 유용해 보인다.


100분, 200분 단위가 익숙지 않으니 시간(h), 분(m)으로 레이블까지 표시해줬다.

요컨대 어제까지의 데이터로 봤을 때 재고 상태 유지(=판매시간)이 길어지는 추세다.



그래서 데이터를 분석하면 마스크를 잘 살 수 있을까?


향후 과제 및 제언

코드 폭탄으로 시작해서 그래프 폭탄으로 마무리됐다.

이정도로 꿰기 작업을 마치고, 데이터가 좀 더 모이면 각 지역별, 일자별, 요일별 추세를 반영하여 며칠 뒤(본인이 구입 가능한 요일)의 상태 변화 시각을 예측해볼 수 있을 듯싶다.

데이터 분석의 관건은 결국 미래를 예측하는 데 있다고 본다.

입고시각은 앞으로도 들쭉날쭉하겠지만 실사용자 관점에서 재고 알림 앱을 열어 주변 판매정보를 확인했을 때 가령 '초록색'이라면 앞으로 몇 분가량은 '충분'한 상태로 유지되겠다는 예상이 가능할 테다.

판매 시점 서버 입력(데이터 업데이트)의 편차가 크지 않고 데이터의 정확성이 보증된다면 '공적'이라는 이름에 걸맞게 관 차원에서 각 지역별 수요에 입각한 수량 분배에 활용해볼 수도 있을 것이다.
*유독 빨리 소진되는 동네(시도처럼 큰 단위 수준이 아니더라도)와 요일이 있다면 공급물량을 늘린다거나 장시간 재고가 유지되는 주변 지역을 안내하는 등의 방법으로


-.NULL



예시로 들었던 지역별 그래프와 서울 시내지만 다른 양상을 나타내는 종로5가, 세종로(주소 내 문자열이 포함된 데이터로 필터링) 도표로 마무리한다.



브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari