스토리 홈

인터뷰

피드

뉴스

조회수 2484

A/B Testing 도구인 Optimizely 사용법

웹 서비스를 운영하다 보면 준비하는 과정에서 정말 많은 고민이 오갑니다. 컨텐츠의 배치, 헤드 카피, 인터랙티브.. 하지만 어떤 요소가 조금 더 사용자의 반응을 이끌어내는지 정확히 알 수 없습니다. 이런 부분들을 ‘직감’이나 ‘경험’으로 막연하게 자기 자신과 타인에게 주장하고 있지는 않나요?그렇다면 두 가지 혹은 그 이상의 시안들을 직접 시험대에 올려 각각 더 좋은 것을 선택하는 것은 어떨까요?A/B 테스팅에 관련한 유명한 일화가 하나 있습니다. 1497년, Vasco da gama는 최초로 유럽에서 아프리카 남부를 거쳐 인도까지 항해한 인물입니다. 그가 인도를 발견하고 귀항했을 때 160명의 원정대원 중 100명이 괴혈병으로 사망하는 사건이 있었습니다. 그만큼 괴혈병은 항해하는 선원들의 공포 대상이었죠. 그로부터 약 300년 뒤, 영국의 의사인 James Lind는 괴혈병의 치료법을 알기 위해 실험군을 나누어 각각 다른 음식으로 실험을 진행했습니다.실험은 다음과 같습니다. 괴혈병에 걸린 12명의 선원을 선정하여 그 중 10명에게는 보통 음식을 주고, 두 사람에게는 매일 라임 과즙을 마시게 하였습니다. 6일 후 라임 과즙을 마신 선원 두 명만이 괴혈병에 완벽히 치료된 모습을 보였습니다. James Lind가 실험하기 전에는 단순히 ‘감귤류 과일이 괴혈병에 좋다.’, ‘괴혈병으로 죽어가는 찰나에 잡초를 먹고 다시 살아났다.’ 라는 이야기만이 난무했었고 직접적인 치료법을 제시한 사람은 James Lind가 최초였습니다. 비타민C가 발견된 것이 1928년임을 고려하면, 이 당시에는 비타민C 이라는 개념이 없었기 때문에 James Lind의 실험은 후에 많은 선원의 목숨을 괴혈병으로부터 지켜주는 사례가 됩니다.괴혈병이 해적보다 더 무서웠던 대항해시대에 보통 음식(A)과 라임(B)을 이용해 선원들을 모두 구했던 영국 해군의 현명한 대처법에서 우리의 웹 서비스를 더욱 더 활성화 시키는 지혜를 얻어야 합니다.Optimizely?Optimizely는 웹서비스를 운영하면서 A/B Testing 수행을 원하시는 분들에게 적합한 서비스입니다. Optimizely를 사용하기 전에 A/B 테스팅에 대한 정보가 필요하다면 A/B 테스팅에 관련한 JC Kim님의 글( A/B Testing에 대한 기초적인 정보들 )을 먼저 읽어보시는 것을 추천합니다.Optimizely는 단순히 A/B 테스트의 진행과 그 통계 결과만 제공하는 것이 아니라, 테스트를 진행하는 동안의 모든 준비 과정에서 사용자들에게 도움을 주고 있습니다. 오늘은 그 Optimizely의 핵심 기능 및 활용법에 대하여 알아보겠습니다. Optimizely는 유료 서비스이지만 30일 동안의 Free Trial을 제공해주므로 그 기간 동안 충분히 이 서비스의 모든 것을 체험할 수 있습니다.Optimizely는 세계적인 대형 기업들이 이용하는 서비스로, 이들은 이미 Optimizely를 통해 각각 컨텐츠들에 대한 사이트 접속자들의 반응을 체크하고 있습니다. 대표적인 회사로 Starbucks, Salesforce, MTV, The Walt Disney Company, ABC 등이 있습니다.그렇다면 왜 많은 기업들이 A/B Testing에 집중하고 있고, Optimizely를 이용하는 걸까요?더 정확한 데이터를 추출하려는 노력.메일링 리스트를 수집하는 등의 폼 입력/전송을 하는 비율을 구하는 경우, 혹은 메인 페이지에서 다른 세부페이지로 이동하는 이용자 비율을 나타내기 위해 목표(Goal)을 나타냅니다. 목표한 골에 A 버전(기존안/Original) 이용자가 더 많이 들어갔는지, B 버전(새로 작성한 안/Variation)이 효과적이었는지를 테스트 할 수 있습니다.이처럼 Goal에 도달하는 행위를 ‘Conversion’이라 표현합니다. 방문자 수 대비 Conversions 수치를 비교한 Conversion rate를 비교하면 A/B 시안 중에 더 효과적인 결과를 수치와 그래프, 특히 “기준을 이길 수 있는 확률”(Chance to beat baseline)을 철저하게 계산해 결과를 명확하게 진단할 수 있습니다. 말 그대로 Goal과 Conversion Rate 수치로 사용자가 승자를 판단하는 것이 아니라, 수치공식을 통해 B 버전이 기존안(A버전)을 확실하게 이겼는지 아닌지를 파악해줍니다.더 자세히 알고싶은 부분은 해당 값을 구하는 통계공식이 있는 링크를 참고해주세요.정말 쉬운 실험요소 변경.Optimizely를 이용하면 여러분이 복잡한 CSS나 Javascript 기술이 없어도 쉽게 A/B 테스팅을 진행할 수 있습니다. Optimizely에서는 실험군의 요소를 마우스 클릭 몇 번으로 손쉽게 바꿀 수 있습니다. 가령 B 버전에 A 버전과 다른 문서 배치를 하거나 배경화면, 이미지, 폰트, 버튼 속의 문구 등도 별도의 코딩 절차 없이 Optimizely 실험페이지 내에서 변경할 수 있다는 말이죠. 또한 실시간으로 CSS를 변경하여 적용하거나 Javascript도 적용할 수 있습니다. 마치 ‘나모 웹 에디터’ 나 ‘드림위버’ 같은 인터페이스로 파워포인트 내의 요소를 다루듯 쉽게 바꿀 수 있습니다.위치와 크기를 Drag & Drop 으로 쉽게 움직이게 할 수 있습니다.웹사이트에 적용된 이미지 또한 로컬에 있는 파일 혹은 웹에 있는 이미지로 대체할 수 있습니다.텍스트도 곧바로 변경할 수 있고 HTML을 직접 대체해서 끼워 넣을 수 있습니다.참 쉽죠?간단한 설치위처럼 변경했던 시험요소들을 저장하려면 복잡하고 긴 코드를 다시 원래 파일에 붙여 넣어야 할까요? 그렇지 않습니다. Optimizely는 변경한 컨텐츠 정보를 간단한 자바스크립트 코드로 ‘Optimize’ 해 주기 때문에 단 몇줄만 추가해주면 원하는 결과가 나옵니다.확장성유명한 아티스트 두 명이 콜라보레이션 하는 상상을 해보죠. 각자의 개성을 살려 새로운 결과물들을 창조해내지요. 물론 그들의 궁합이 잘 맞아야 한다는 전제가 있습니다. 하지만 다행히도 Optimizely와 연동되는 서비스들은 궁합이 잘 맞는 편입니다. Optimizely는 A/B 테스팅에 관한 자료에 집중하고 있기 때문에, 조금 더 디테일한 자료(Analytics, Heatmap)는 욕심내지 않고 기타 많은 서비스와 연동합니다.Optimizely와 연동되는 서비스는 다음과 같습니다.AnalyticsGoogle AnalyticsKISSmetricsMixpanelOmniture SiteCatalystHeatmapClickTaleCrazyegg위 서비스 중 하나라도 이용 중이시라면, Optimizely와 어떤 부분이 연동이 되는 지 살펴보세요.마치며페이지 두 개를 접속자들에게 무작위로 나누어 배포해서 반응을 트래킹하는 기술은 흔할지도 모릅니다. 하지만 Optimizely를, 그리고 연동되는 다양한 서비스들을 이용하면 조금 더 세밀하고 확실한 데이터를 얻을 수 있습니다. 정말로 나의 웹 서비스에 필요한 것이 ‘잡초’인지 ‘레몬’인지 알고 싶다면 지금 당장 시작해보세요.#스포카 #기획 #A/B테스트 #A/BTest #꿀팁 #인사이트 #조언
조회수 878

P2P금융에서 고도의 엔지니어링이 필수적인 이유

지난 8월30일, 매일경제신문이 주최하고 과학기술정보통신부와 금융위원회, 금융감독원이 후원한 매경핀테크어워드2018에서 렌딧이 최우수상을 수상했다. 렌딧이 굳이 이런 경연대회에 참여를 한 이유는 ‘P2P금융산업에서 기술력과 고도의 엔지니어링 파워가 얼마나 중요한 지’를 널리 알리고 싶기 때문이었다.매경핀테크어워드 수상 소식을 들은 후, 엔지니어링팀 렌딧맨들과최근 렌딧은 개발자 채용에 그 어느때보다도 열심이다. 많은 개발자들과 만나 P2P금융산업의 미래와 우리 회사가 하는 일에 대해 설명하고 좋은 개발자를 영입하기 위해 노력하고 있다. 그런데 생각보다 훨씬 더 개발자들에게 P2P금융기업이 어떤 일을 하고 있고, 왜 개발자가 도전할 만한 분야인지 알려져 있지 않다는 사실을 알게 되었다. 이번 글에서는 렌딧이 하는 일을 바탕으로 P2P금융회사에서 왜 고도의 소프트웨어 엔지니어링이 필수적으로 필요하고, 개발자 여러분이 어떤 일에 도전해 볼 수 있는지에 대해 설명해 보려고 한다. 우선 대출과 투자 등 모든 서비스가 기존 금융회사와 달리 온라인 상에서 이루어진다. 특히 렌딧이 집중하고 있는 개인신용 P2P금융의 경우, 대출 심사와 집행, 투자 모집과 운용 등 서비스 전 과정을 100% 온라인, 비대면 서비스로 구축하고 있는 디지털 금융 플랫폼이다.대출 서비스에서는 머신러닝 기반의 대출자 심사평가모델 개발이 핵심적이다. 렌딧이 자체 개발한 렌딧 개인신용평가시스템(Lendit Credit Scoring System)을 예로 들어 보겠다. 신용평가사에서 제공하는 250여가지의 금융 데이터를 순식간에 분석해 모든 대출 신청자마다 개인화 된 적정금리를 산출해 내는 시스템이다. P2P금융기업인 렌딧이 개발한 심사평가모델을 기존 금융권의 심사평가모델과 비교할 때 가장 큰 차이점은, 머신러닝 기법을 사용해 각종 금융 데이터의 최근 12개월 간 트렌드를 분석한다는 점. 이를 통해 보다 정교하게 개인의 신용을 평가해 낸다. 여기에 추가적으로 신용평가사에서 제공하는 사기정보공유(Fraud Bureau)데이터, 직장 신용정보, 상환 정보 등을 종합적으로 반영하고 있다. 최근에는 대출자가 제출하는 신분증 확인 과정에 머신러닝을 적용해 자동화해 나가기 시작했다. 투자 서비스에서는 실시간으로 분산투자 포트폴리오를 추천해 주는 알고리듬이 돌고 있다. 투자자가 투자할 금액을 입력하면 눈깜짝할 사이에 현재 투자 가능한 채권을 조합해 분산투자 포트폴리오를 추천해 주는 시스템이다. 포트폴리오에 조합된 모든 채권에 투자금을 일정한 비율로 고르게 나누어 분산투자할 수 있도록 추천해 주는 것이 특징이다. 렌딧이 개발한 분산투자 시스템은 투자자 1인이 수백~수천개의 채권에 분산하는 것과 동시에, 채권 1개도 평균 1,303명, 최대 3,814명(기준 2018년 6월30일 현재)이 나누어 리스크를 분산하도록 개발되어 있다. 이렇게 분산투자를 시스템적으로 활성화 시키고 있는 덕분에, 현재까지 렌딧의 모든 투자자가 하고 있는 분산투자의 총 누적 건수는 거의 800만 건에 육박하는 수준이다. 점점 더 많은 데이터가 축적되고 있기 때문에, 이러한 데이터를 바탕으로 고객에게 제공할 수 있는 서비스 아이디어도 하루 하루 쌓여 가고 있는 중이다.P2P금융산업이 가장 발전한 시장인 미국의 경우, 최대 규모인 렌딩클럽 한 회사가 미국 개인신용대출 시장 전체의 약 1.5%이상을 차지할만큼 금융 시장을 혁신해 나가고 있다. 렌딧 역시 지난 3년간 빅데이터 분석에 기반한 정교한 신용평가를 통해, 대출 고객의 이자를 총 100억원이 넘게 절약해 드리는 성과를 만들어 냈다. 그간 기존 금융회사들이 만들어 내지 못한 중금리 대출 시장을 스타트업인 렌딧이 활짝 열어낸 것이다.렌딧에서 우리 렌딧맨들과 함께 한국의 금융을 혁신하는 금융 플랫폼을 만들어 가실 엔지니어 여러분을 기다립니다. 관심있는 분은 주저없이 [email protected] 로 연락 주세요. 많은 엔지니어 여러분과 만나뵙고 싶습니다. 
조회수 1652

로봇 공학의 새로운 패러다임! 한화정밀기계의 협동 로봇을 만드는 로봇사업부 인터뷰!

한화정밀기계의 협동로봇 HCR-5 / 출처 - 한화정밀기계 이제 번거로운 작업은똑똑하고 안전한 협동 로봇에게 맡기세요! 제조 산업의 다양한 과정들이 점차 기계화되어가고 있습니다. 기계화의 과정에서도 사람이 개입되어야 하는 번거로운 과정들이 남아있기 마련인데요. 사람이 꼭 필요한 섬세하고 동적인 역할까지 수행하면서 기계의 편리성을 살릴 수 있는 ‘협동 로봇(코봇)’의 탄생으로 그 고민이 해결되었습니다.머지않은 미래에 협동 로봇의 춘추전국시대가 예상되는 가운데, 2017년 시장에 진입한 한화정밀기계의 HCR 시리즈 협동 로봇은 뒤늦게 시장에 합류했지만, 유려한 디자인과 다양한 기능, 안전성을 고려한 특색 있는 제품 생산으로 전 세계 고객들의 사랑을 받으며 점유율을 확대해가고 있습니다. 협동 로봇의 발전으로 개발과 연구를 전문으로 하는 직업도 탄생했는데요. 한화정밀기계에는 협동 로봇 전문가집단인 로봇사업부가 존재합니다. 이 부서의 수장인 장우석 로봇사업부장에게 자세한 이야기를 들어보겠습니다. Q. 안녕하세요. 우선 협동 로봇에 대해 간단히 설명 부탁드립니다!한화정밀기계 장우석 로봇사업부장 / 출처 - 한화정밀기계안녕하세요. 한화정밀기계 로봇사업부의 장우석입니다. 산업 현장에서 사람들을 돕기 위해 만들어진 로봇이 바로 협동 로봇입니다. 이들은 정확성과 일관성이 요구되는 반복적인 업무들을 처리하는데요. 기존의 반복적인 업무를 대신하고, 작업자는 주관적인 판단이나 유연성이 요구되는 일을 할 수 있도록 돕는 것이죠. 현재의 협동 로봇 이전에 주로 사용했던, 기존의 산업용 로봇은 굉장히 한정적인 업무만을 수행할 수 있었습니다. 가령 물건을 하나 옮긴다고 가정하면, 그에 맞는 고난도의 컴퓨터 프로그램을 입력해야 그 일을 할 수 있습니다. 만약 다른 장소로 물건을 옮기려고 한다면 조립공정을 멈추고 중장비를 사용해 옮겨야 합니다. 따라서 시간과 비용이 굉장히 많이 듭니다. 반면 협동 로봇은 이러한 번거로운 과정들을 한 번에 해결해줍니다. 특히, 한화정밀기계의 HCR 협동 로봇은 사용자 친화적인 인터페이스를 갖추고 있어서 작업자가 작동법을 익히는데 하루도 채 걸리지 않습니다. 또한, HCR 협동 로봇의 워크플로를 세팅하거나 변경할 때는 단순히 필요한 항목들만 클릭해 바꾸면 됩니다.싱가포르 합자법인 공장에서 HCR-5를 생산하고 동남아시아 시장에 공급할 예정인 한화정밀기계 / 출처 - 한화정밀기계 Q. 한화가 로봇 산업에 진출하게 된 계기는 무엇인가요?한화그룹은 4차 산업혁명의 일환으로 로봇 산업을 시작했습니다. 다양한 분야 중 저희는 협동 로봇에 초점을 맞췄고, 작년에 국내 최초의 협동 로봇인 HCR 시리즈를 출시했습니다.한화그룹은 항공엔진, 에너지, 산업 장비, CCTV 카메라와 같이 다양한 산업 분야에 관심을 두고 있습니다. 이러한 시장을 키우고 선두가 되기 위해, 한화는 정밀기계, 동작 조종 기술, 사물 인식 소프트웨어, 자동 내비게이션과 같은 분야에서 전문성을 높이고 있습니다. 이러한 모든 것의 중심이 바로 로봇 산업입니다.이렇게 다양한 산업 지식, 경험 그리고 기술을 바탕으로 로봇사업부를 키울 수 있었고, 지금의 HCR 시리즈 같은 제품을 시장에 내놓을 수 있게 된 것입니다. 특히 로봇 공학 분야와 소프트웨어 개발에 매우 높은 전문성을 가진 인력을 보유하여 협동 로봇 기술 개발(R&D)을 빠르게 진전시킬 수 있었습니다. 한화정밀기계와 싱가포르 정밀 엔지니어링 전문 업체인 PBA 그룹의 합자법인 "PBA-Hanwha Robotics"의 개소식 모습 / 출처 - 한화정밀기계  Q. 한화 협동 로봇의 제품 현황과 고객 반응은 어떤가요?한화정밀기계의 협동로봇 HCR-5 / 출처 - 한화정밀기계한화정밀기계는 현재 세 종류의 협동 로봇(HCR)을 출시하였으며, 각각 3kg, 5kg, 12kg의 무게를 들 수 있습니다. 이 세 종류의 협동 로봇은 크기가 작고, 옮기기 쉬우면서 방대한 범위의 업무를 진행할 수 있기 때문에 다양한 업무 지원이 필요한 중소 제조 기업에 이상적인 모델이라 할 수 있습니다. HCR 시리즈의 시장 내 고객 반응은 매우 호의적입니다. HCR 시리즈만이 가진 가장 큰 장점은 사용자가 단일 제어 장치에서 두 개의 HCR 협동 로봇을 실행할 수 있다는 점입니다. 그렇기 때문에 운영비가 최대 10%까지 절감되는 효과가 있죠. 거기에 HCR 시리즈 조작이 쉽다는 점까지 장점으로 작용하면서 시간을 절약하고 생산성을 더욱 높일 수 있습니다.  기능과 안정성을 모두 잡은 HCR 시리즈만의 디자인 또한, 고객들은 HCR 협동 로봇의 수려한 디자인을 가장 크게 평가합니다. 보통 산업용 기계는 튀어나온 부분들이 있어서 긁히거나 부딪힐 위험이 있는데 HCR은 부드러운 곡선 모양으로 제작되어 안전하고 디자인이 뛰어납니다. 산업 디자인은 보이는 게 전부가 아닙니다. 사람들이 협동 로봇과 같이 일할 때 실제로 안정감을 느낄 수 있어야 합니다. 그런 이유로 더 안전하고 부드럽게 보이도록 곡면을 살려 디자인했습니다. 디자인과 기능 면에서도 HCR 시리즈는 매우 안전한 제품입니다. HCR 협동 로봇은 작업자의 옆에서 업무를 보조하는데, 자동 충돌 감지 기능이 있어서 부딪히면 즉각적으로 작동을 멈춥니다. 2017 iF 디자인 어워드, 제품 디자인 부분에서 본상을 수상한 HCR 협동 로봇 / 출처 - 한화정밀기계 Q. 협동 로봇의 미래에 대한 예측과 향후 개발하고자 하는 협동 로봇은?미래에는 AI와 딥러닝, IoT 등 4차 산업혁명을 대표하는 기술들이 접목된 협동 로봇이 시장을 주도할 것이라 예측합니다. 특히 AI와 딥 러닝 기술로 인해 조만간 로봇 산업에는 큰 지각 변동이 있을 것이라 예상합니다. 원래는 5년이나 10년 주기로 일어날 것으로 생각했는데, 이제는 그것보다 더 앞당겨질 것 같네요. 예전에는 몇 년 더 걸릴 것으로 생각했던 기술들이 AI와 딥러닝 기술이 접목된 지 2년 반 만에 이미 구현되고 있으니까요!그래서 한화정밀기계에서는 앞으로 생산될 제품에 AI나 빅데이터, IoT를 어떻게 접목하고, 실제로 어떻게 적용될 수 있을지에 대해 연구하고 있습니다. AI가 접목된 협동 로봇은 어떠한 상황이나 조건에서도 최대한 쉽게 일을 수행할 수 있습니다. 특히 기술 접목 분야에서 한화그룹은 다양한 산업군과 계열사가 있다는 것이 매우 큰 장점인데요. 다양한 계열사에 자문하면서 실제로 협동 로봇이 어떻게 업무에 적용이 되고, 앞으로 어떻게 발전시킬지 논의하고 있습니다. 협동로봇 합자법인 공장 투어 모습 / 출처 - 한화정밀기계 한화정밀기계의 장우석 부장은 피처폰에서 스마트폰 시대로 바뀌었듯이, 로봇 시장도 향후 몇 년 이내로 큰 패러다임 전환이 일어나리라 전망했습니다. 단순히 몇 개의 일을 수행하는 로봇에서 거의 모든 일을 처리할 수 있는 로봇으로 변화하는 것입니다. 협동 로봇 시장은 아직 초기 단계에 있으며 시장 규모도 매우 작지만, 앞으로의 사업 성장 가능성이 매우 큰 분야입니다.한화정밀기계는 현재 유럽과 동남아시아 시장의 큰 성장 가능성을 두고 사업에 박차를 가하고 있는데요. 단기적인 목표는 시장점유율을 매년 두 배로 늘리는 것이며, 장기적인 목표는 협동 로봇 분야에서 세계적인 선도 기업이 되는 것이라고 합니다.4차 산업혁명에 힘입어 자동차와 스마트 팩토리를 중심으로 기술 트렌드를 이끄는 기업을 목표로, 글로벌 로봇 시장을 선도하는 기업이 되기 위해 끝없는 노력을 거듭하고 있습니다. 점차 확대되는 협동 로봇 시장을 선도하는 한화정밀기계의 미래를 함께 응원 부탁드립니다!#한화 #한화그룹 #한화정밀기계 #구성원인터뷰 #직무정보 #기업정보 #기업문화 #비전 #목표 #채용정보 #공채정보
조회수 1426

레진 기술 블로그 - 자바 기반의 백엔드와의 세션 공유를 위한 레일즈 세션 처리 분석

레일즈 기반의 프론트엔드(브라우저에서 서버 사이드 렌더링 계층까지)와 자바 기반의 백엔드(내부 API와 그 이후 계층)이 세션을 공유하기 위해 먼저 레일즈의 세션 처리 과정을 분석하고, 레일즈 세션 쿠키를 다루기 위한 자바 소스 코드를 공유합니다.여기저기 자랑하고 다녔으니 아시는 분은 아시다시피 레진은 구글앱엔진을 사용하고 있습니다. 지금이야 Java, Python, Node.js, Go 언어와 Flexible Environment 같은 다양한 선택지가 있지만, 레진이 입주할 당시만 해도 Java 7(subset), Python(subset)을 지원하는 Standard Environment라는 선택지 밖에 없었죠.최근 Saemaeul Undong 기술 부채 탕감의 일환으로 자바7, 스프링3.x, JSP(!) 기반의 백엔드에 포함되어 있던 프론트엔드를 레일즈 기반의 프론트엔드 서버(서버 사이드 렌더링을 담당하는 서버는 프론트일까요? 백엔드일까요?)로 분리하고 있습니다.서로 다른 세계의 존재들 - 자바와 레일즈의 세션을 공유해야하는 상황이 문제의 발단입니다.자바와 레일즈의 세션을 공유하는 여러가지 방법이 있겠지만, 가장 단순하고 효과적인 방법은 쿠키(cookie)라고 판단하고, 세션 encrypt/decrypt와 marshal/unmarshal을 동일한 방식으로 맞추기로 했습니다. (백엔드 API를 완전히 stateless하게 새로 만들면 좋겠지만, 코인은 벌어야 소는 키워야죠)이를 위해 레일즈의 세션 처리 과정을 분석하고 정리했습니다.레일즈의 actionpack의 action_dispatch/middleware/cookie.rb를 보면 EncryptedCookieJar 클래스의 초기화 과정은 다음과 같습니다(digest의 경우 따로 지정안하면 SHA1이 사용되는 듯):class EncryptedCookieJar < AbstractCookieJar # :nodoc: include SerializedCookieJars def initialize(parent_jar) super if ActiveSupport::LegacyKeyGenerator === key_generator raise "You didn't set secrets.secret_key_base, which is required for this cookie jar. " + "Read the upgrade documentation to learn more about this new config option." end secret = key_generator.generate_key(request.encrypted_cookie_salt || '') sign_secret = key_generator.generate_key(request.encrypted_signed_cookie_salt || '') @encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret, digest: digest, serializer: ActiveSupport::MessageEncryptor::NullSerializer) end private def parse(name, encrypted_message) debugger deserialize name, @encryptor.decrypt_and_verify(encrypted_message) rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveSupport::MessageEncryptor::InvalidMessage nil end def commit(options) debugger options[:value] = @encryptor.encrypt_and_sign(serialize(options[:value])) raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE end end key_generator는 EncryptedCookieJar에 포함된 SerializedCookieJars 모듈에 정의되어 있습니다:module SerializedCookieJars # ... def key_generator request.key_generator end end 흠… 좀 더 파보죠. request.key_genrator는 다음과 같습니다:class Request # ... def key_generator get_header Cookies::GENERATOR_KEY end #... end 흠… 좀 더 파야할 듯 ㅠㅠ.Cookies::GENERATOR_KEY는 다음과 같습니다:class Cookies #... GENERATOR_KEY = "action_dispatch.key_generator".freeze end action_dispatch.key_generator는 레일즈의 엔진 모듈에 해당하는 railties의 application.rb에 정의되어 있습니다:def key_generator # number of iterations selected based on consultation with the google security # team. Details at https://github.com/rails/rails/pull/6952#issuecomment-7661220 @caching_key_generator ||= if secrets.secret_key_base unless secrets.secret_key_base.kind_of?(String) raise ArgumentError, "`secret_key_base` for #{Rails.env} environment must be a type of String, change this value in `config/secrets.yml`" end key_generator = ActiveSupport::KeyGenerator.new(secrets.secret_key_base, iterations: 1000) ActiveSupport::CachingKeyGenerator.new(key_generator) else ActiveSupport::LegacyKeyGenerator.new(secrets.secret_token) end end # ... def env_config @app_env_config ||= begin validate_secret_key_config! super.merge( # ... "action_dispatch.key_generator" => key_generator, "action_dispatch.signed_cookie_salt" => config.action_dispatch.signed_cookie_salt, "action_dispatch.encrypted_cookie_salt" => config.action_dispatch.encrypted_cookie_salt, "action_dispatch.encrypted_signed_cookie_salt" => config.action_dispatch.encrypted_signed_cookie_salt, "action_dispatch.cookies_serializer" => config.action_dispatch.cookies_serializer, "action_dispatch.cookies_digest" => config.action_dispatch.cookies_digest ) end end 너무 깊이 판 느낌적느낌(?)이 있지만, 여기까지 왔으니 좀 더 파보겠습니다.핵심 알고리즘은 activesupport의 key_generator.rb, message_encryptor.rb, message_verifier.rb에 정의되어 있습니다.먼저, key_generator.rb의 핵심은 다음과 같습니다:class KeyGenerator def initialize(secret, options = {}) @secret = secret # The default iterations are higher than required for our key derivation uses # on the off chance someone uses this for password storage @iterations = options[:iterations] || 2**16 end # Returns a derived key suitable for use. The default key_size is chosen # to be compatible with the default settings of ActiveSupport::MessageVerifier. # i.e. OpenSSL::Digest::SHA1#block_length def generate_key(salt, key_size=64) OpenSSL::PKCS5.pbkdf2_hmac_sha1(@secret, salt, @iterations, key_size) end end 계속해서, message_encryptor.rb의 핵심은 다음과 같습니다:def initialize(secret, *signature_key_or_options) options = signature_key_or_options.extract_options! sign_secret = signature_key_or_options.first @secret = secret @sign_secret = sign_secret @cipher = options[:cipher] || 'aes-256-cbc' @verifier = MessageVerifier.new(@sign_secret || @secret, digest: options[:digest] || 'SHA1', serializer: NullSerializer) @serializer = options[:serializer] || Marshal end def _encrypt(value) cipher = new_cipher cipher.encrypt cipher.key = @secret # Rely on OpenSSL for the initialization vector iv = cipher.random_iv encrypted_data = cipher.update(@serializer.dump(value)) encrypted_data << cipher.final "#{::Base64.strict_encode64 encrypted_data}--#{::Base64.strict_encode64 iv}" end def _decrypt(encrypted_message) cipher = new_cipher encrypted_data, iv = encrypted_message.split("--".freeze).map {|v| ::Base64.strict_decode64(v)} cipher.decrypt cipher.key = @secret cipher.iv = iv decrypted_data = cipher.update(encrypted_data) decrypted_data << cipher.final @serializer.load(decrypted_data) rescue OpenSSLCipherError, TypeError, ArgumentError raise InvalidMessage end def encrypt_and_sign(value) verifier.generate(_encrypt(value)) end def decrypt_and_verify(value) _decrypt(verifier.verify(value)) end (Hopefully)마지막으로, message_verifier.rb의 핵심은 다음과 같습니다:def initialize(secret, options = {}) raise ArgumentError, 'Secret should not be nil.' unless secret @secret = secret @digest = options[:digest] || 'SHA1' @serializer = options[:serializer] || Marshal end def valid_message?(signed_message) return if signed_message.nil? || !signed_message.valid_encoding? || signed_message.blank? data, digest = signed_message.split("--".freeze) data.present? && digest.present? && ActiveSupport::SecurityUtils.secure_compare(digest, generate_digest(data)) end def verified(signed_message) if valid_message?(signed_message) begin data = signed_message.split("--".freeze)[0] @serializer.load(decode(data)) rescue ArgumentError => argument_error return if argument_error.message =~ %r{invalid base64} raise end end end def generate(value) data = encode(@serializer.dump(value)) "#{data}--#{generate_digest(data)}" end private def encode(data) ::Base64.strict_encode64(data) end def decode(data) ::Base64.strict_decode64(data) end def generate_digest(data) require 'openssl' unless defined?(OpenSSL) OpenSSL::HMAC.hexdigest(OpenSSL::Digest.const_get(@digest).new, @secret, data) end # ... # encode, decode는 base64사용 이제 레일즈가 쿠키 기반의 세션을 어떻게 처리하는지 조금 눈에 들어옵니다. 그러나 우리의 최종 목표는 레일즈의 내부를 공부하는 것이 아니라, 자바에서 동일한 처리를 하는 것입니다. 모듈 의존성 따위는 가볍게 무시하고 무한복붙(?)을 시전해서, 레일즈의 세션 처리 과정을 눈으로 확인할 수 있도록 재구성했습니다:require 'openssl' require 'base64' require 'concurrent/map' class Object def blank? respond_to?(:empty?) ? !!empty? : !self end def present? !blank? end end class Hash # By default, only instances of Hash itself are extractable. # Subclasses of Hash may implement this method and return # true to declare themselves as extractable. If a Hash # is extractable, Array#extract_options! pops it from # the Array when it is the last element of the Array. def extractable_options? instance_of?(Hash) end end class Array def extract_options! if last.is_a?(Hash) && last.extractable_options? pop else {} end end end module SecurityUtils def secure_compare(a, b) return false unless a.bytesize == b.bytesize l = a.unpack "C#{a.bytesize}" res = 0 b.each_byte { |byte| res |= byte ^ l.shift } res == 0 end module_function :secure_compare end class KeyGenerator def initialize(secret, options = {}) @secret = secret # The default iterations are higher than required for our key derivation uses # on the off chance someone uses this for password storage @iterations = options[:iterations] || 2**16 end def generate_key(salt, key_size=64) OpenSSL::PKCS5.pbkdf2_hmac_sha1(@secret, salt, @iterations, key_size) end end class CachingKeyGenerator def initialize(key_generator) @key_generator = key_generator @cache_keys = Concurrent::Map.new end # Returns a derived key suitable for use. def generate_key(*args) @cache_keys[args.join] ||= @key_generator.generate_key(*args) end end class MessageVerifier class InvalidSignature < StandardError; end def initialize(secret, options = {}) raise ArgumentError, 'Secret should not be nil.' unless secret @secret = secret @digest = options[:digest] || 'SHA1' @serializer = options[:serializer] || Marshal end def valid_message?(signed_message) return if signed_message.nil? || !signed_message.valid_encoding? || signed_message.blank? data, digest = signed_message.split("--".freeze) data.present? && digest.present? && SecurityUtils.secure_compare(digest, generate_digest(data)) end def verified(signed_message) if valid_message?(signed_message) begin data = signed_message.split("--".freeze)[0] @serializer.load(decode(data)) rescue ArgumentError => argument_error return if argument_error.message =~ %r{invalid base64} raise end end end def verify(signed_message) verified(signed_message) || raise(InvalidSignature) end def generate(value) data = encode(@serializer.dump(value)) "#{data}--#{generate_digest(data)}" end private def encode(data) ::Base64.strict_encode64(data) end def decode(data) ::Base64.strict_decode64(data) end def generate_digest(data) require 'openssl' unless defined?(OpenSSL) OpenSSL::HMAC.hexdigest(OpenSSL::Digest.const_get(@digest).new, @secret, data) end end class MessageEncryptor module NullSerializer #:nodoc: def self.load(value) value end def self.dump(value) value end end class InvalidMessage < StandardError; end OpenSSLCipherError = OpenSSL::Cipher::CipherError def initialize(secret, *signature_key_or_options) options = signature_key_or_options.extract_options! sign_secret = signature_key_or_options.first @secret = secret @sign_secret = sign_secret @cipher = options[:cipher] || 'aes-256-cbc' @verifier = MessageVerifier.new(@sign_secret || @secret, digest: options[:digest] || 'SHA1', serializer: NullSerializer) @serializer = options[:serializer] || Marshal end def encrypt_and_sign(value) verifier.generate(_encrypt(value)) end def decrypt_and_verify(value) _decrypt(verifier.verify(value)) end def _encrypt(value) cipher = new_cipher cipher.encrypt cipher.key = @secret # Rely on OpenSSL for the initialization vector iv = cipher.random_iv encrypted_data = cipher.update(@serializer.dump(value)) encrypted_data << cipher.final "#{::Base64.strict_encode64 encrypted_data}--#{::Base64.strict_encode64 iv}" end def _decrypt(encrypted_message) cipher = new_cipher encrypted_data, iv = encrypted_message.split("--".freeze).map {|v| ::Base64.strict_decode64(v)} cipher.decrypt cipher.key = @secret cipher.iv = iv decrypted_data = cipher.update(encrypted_data) decrypted_data << cipher.final @serializer.load(decrypted_data) rescue OpenSSLCipherError, TypeError, ArgumentError raise InvalidMessage end def new_cipher OpenSSL::Cipher.new(@cipher) end def verifier @verifier end end #key generate encrypted_cookie_salt = 'encrypted cookie' encrypted_signed_cookie_salt = 'signed encrypted cookie' def key_generator secret_key_base = 'db1c366b854c235f98fc3dd356ad6be8dd388f82ad1ddf14dcad9397ddfdb759b4a9fb33385f695f2cc335041eed0fae74eb669c9fb0c40cafdb118d881215a9' key_generator = KeyGenerator.new(secret_key_base, iterations: 1000) CachingKeyGenerator.new(key_generator) end # encrypt secret = key_generator.generate_key(encrypted_cookie_salt || '') sign_secret = key_generator.generate_key(encrypted_signed_cookie_salt || '') encryptor = MessageEncryptor.new(secret, sign_secret, digest: 'SHA1', serializer: MessageEncryptor::NullSerializer) value = "{\"session_id\":\"6022d05887d2ab9c1bad8a87cf8fb949\",\"_csrf_token\":\"OPv/LxbiA5dUjVsbG4EllSS9cca630WOHQcMtPxSQUE=\"}" encrypted_message = encryptor.encrypt_and_sign(value) #encrypted_message = encryptor._encrypt(value) p '-----------encrypted value-------------' p encrypted_message # decrypt encrypted_message = 'bDhIQncxc2k0Rm9QS0VBT0hWc3M4b2xoSnJDdkZNc1B0bGQ2YUhhRXl6SU1oa2c5cTNENWhmR0ZUWC9zN05mamhEYkFJREJLaDQ3SnM3NVNEbFF3ZVdiaFd5YXdlblM5SmZja0R4TE9JbDNmOVlENHhOVFlnamNVS2g1a05LY0FYV3BmUmRPRWtVNUdxYTJVbG5VVUlRPT0tLXd1akRqOU1lTTVneU9LTWszY0I5bFE9PQ==--b0a57266c00e76e0c7d9d855b25d24b242154070' p '-----------decypted value-------------' puts encryptor.decrypt_and_verify encrypted_message p '---------------------------------------' 이 과정을 자바로 구현한 소스는 생략 깃헙에 올려두었습니다. 이 코드를 이용해서 서블릿 세션과 연동하는 방법은 추후 사측(?)과 협의되는 대로 공유할 예정입니다. 물론, 그 전에 쿠키를 공유할 필요가 없어지면(or 공유할 쿠키가 없어지면) 더 좋겠죠 :D
조회수 1633

냉정과 열정 사이

냉정과 열정 사이라는 소설이 있다. 대학교 때 읽었던 소설인데 두 사람의 여정을 각자의 시선에서 다룬 소설이다. 에잇퍼센트에 인턴으로 입사해 9개월간 일하고 훨훨 날아간 병훈님과 나도 이 소설처럼 각자의 시선에서 지난 9개월을 되돌아보려 한다. (경고한다. 로맨틱하지 않다.)병훈님이 떠나는 날. 아마 여러분이 보는것과 내가 이 사진을 보는 느낌이 많이 다를거다.1. 만나기까지- 소병훈 이야기2015년 대학교 3학년이 시작될 때부터 졸업 이후에 대한 고민이 생겨났다. 대학원 진학과 취직은 수많은 대학생들의 공통된 고민이기에 수많은 조언이 넘쳐나지만 결론은 '나에게 맞는 길'을 선택하는 것이다. 내 인생 내가 선택해야지 언제까지 남들 좋다는 길로만 가겠는가. 둘 다 겪어보고 내가 선택하겠다고 다짐했다.졸업을 위해서는 대학원에서 과제연구를 1년 해야 했기에 대학원은 겪어 볼 수 있었다. 그러면 취직도 경험해보고 싶은데 어떻게 하지? 대기업에서 1~2개월 인턴을 했던 친구들에게 물어보니 한결같이 '놀고먹다 보니 월급이 나온다'는 경험담을 늘어놓았다. 하지만 정말로 취직해서 놀고먹으면 잘리겠지. 대기업 인턴은 패스. 스타트업 관련 세미나에서 한 VC의 '스타트업은 망해도 스타트업 인턴은 망하지 않는다'는 말을 들었다. 창업에 생각이 있으면 스타트업에서 인턴을 해보라는 말이었다. 그래서 '스타트업에서 일해보자'라고 결정했다.수많은 스타트업 중에서 왜 에잇퍼센트를 선택했다고 물으신다면, 빠른 속도로 성장하면서 변하고 있는 스타트업 속에서 일해보기를 원했기 때문이다. 그리고 당시의 나는 CTO의 멋진 말 한마디에 눈을 반짝이며 '이 회사에서 이 사람과 일하고 싶다'라고 생각하면 앞뒤 안 가리고 지원하는 이상주의자였다. 그래서 페이스북에서 호성님의 글을 읽고 '이 회사가 내가 생각하던 회사구나'라는 생각이 들어서 먼저 지원했던 회사를 포기하고, 에잇퍼센트 입사를 간절히(?) 원하게 되었다.- 이호성 이야기2016년 1월의 첫 번째 근무 날. 대표님이 모두를 불러 모았다. 그리고는 회사의 투자 유치 소식을 알려 주었다. (무슨 투자 유치 소식을 "오늘 저녁에는 치킨을 시켜 먹기로 했어요." 수준으로 재미없게 이야기하는지 모르겠다.) 투자를 받는 것이 확정되었으니 대표님이 내게 전달해 주신 미션은 개발자를 채용해서 제품 개발의 속도를 높이라는 것이었다. 사실 에잇퍼센트에 오기 전에 한 회사에만 오래 있기도 했고 개발자들과의 네트워킹도 게을리했던 터라 당장 좋은 개발자를 뽑을 수 있는 방법이 없었다.그래서 블로그에 회사를 알리는 글을 쓰기 시작하면서 주위 분들께 추천을 부탁드렸다. 그중 JDLab의 양주동 대표님이 괜찮은 학교 후배를 추천해 주신다고 해서 속으로 쾌재를 불렀다. 하지만 추천해 주신 친구가 애매하게 9개월만 일할 수 있는 상황이라고 하니 고민이 되었다. 주니어가 실제로 일을 잘 하게 되려면 꽤 긴 시간이 필요한데, 실제로 일을 잘할 수 있게 되었을 때쯤 회사를 떠나는 게 아닐까 하는 생각이 들었기 때문이다. 하지만 인력(당시 4명)에 비해 해야할 일이 너무나도 많았기에 누군가의 손이라도 빌려야 할 판이었다. 그래서 일단 병훈님을 만나 보기로 했다.2. 면접- 소병훈 이야기에잇퍼센트에 들어가는 과정은 상당히 길다.처음에 간단한 티타임을 시작으로 실제 코딩 문제를 풀어보게 하고, 그 뒤에서 다시 1대 n으로 토론하는 과정, 그리고 대표님과의 이야기로 면접이 이어진다. 요즘은 논술 문제도 있다고 들었다. (역시 취직은 어려워)내 경우는 '면접 보려는 것은 아니니 그냥 커피 한잔 하자'는 부님의 간단한 속임수에 넘어가 티타임을 가졌다. 카페에서 커피 한잔 하면서 부님과 에잇퍼센트는 어떠냐고 물어보려고 왔는데, 어느새 내 앞에는 호성님이 앉아 있었고, 메일로 코딩 문제를 받는 것으로 커피 한잔이 끝났다. 이 티타임은 면접보다는 나에게 회사를 소개하고 회사가 나에게 적합한지 보는 과정이었다.코딩 문제는 성호님의 글로 유명해진 pingpong을 포함한 take-home 과제였다. 문제를 받은 다음날 다른 회사 면접을 보고 온 뒤 밤샘으로 문제를 풀었던 것과 제출할 때 pingpong 문제만큼은 자신 있어했던 기억이 난다. 그때의 기억을 떠올리며 당시에 제출했던 코드를 보니 'Assignment를 쓰지 말 것'이라는 조건이 깨져있었다. 자신감 넘치던 과거의 내가 부끄러워지는 순간이다.마지막 면접 과정도 조금은 숨 막히는 경험이었다. 가볍게 대화하는 분위기 속에서 대학에서 들었던 전공과목 별로 하나하나 물어가며 내 지식의 바닥을 확인했다. 대학에서 3년간 들었던 전공과목은 많지만, 질문 들어오는 족족 '모르겠습니다' 밖에 할 수 없었다. 내가 답할 수 있는 수준을 찾으시려는지 점점 질문의 난이도가 낮아졌고, 마지막으로 스택과 큐를 물어보는 질문에 답하면서 '이 회사는 못 들어가겠구나'라는 생각이 들었다.동시에 진행했던 다른 회사에서 합격 메일이 왔기에 에잇퍼센트에 '0월 0일까지 합격/불합격 결과를 알려주세요'라는 당당한 요구를 한 뒤 떨어졌다는 메일을 기다리고 있었다. 하지만 예상치 못한 합격 메일을 받았고, 그 메일에는실력에 대해서는 회사에 오셔서 보여주세요라는 잊지 못할 문구가 있었다.그리고 첫 출근, 4월 4일 9시 20분에 출근해서 잠긴 문을 보며 에잇퍼센트의 첫 날을 맞이했다.- 이호성 이야기병훈님이 왔다고 하셔서 학교 선배인 부님과 함께 회사 옆 '피어나' 카페로 갔다. (당시만 해도 사무실에 회의실이 없어서 모든 미팅을 회사 옆 카페에서 해야만 했다.) 병훈님의 첫인상은 “꺼벙이"였다.공대에서 흔히 볼 수 있는 스타일. 하지만 말하고 이야기하는 것은 번뜩이는 느낌은 크게 들지 않았다. 아마 나정도로 평범한 느낌이었던 것 같다. 이야기를 하다 보니 다른 곳에 면접을 이미 본 상태였다. 일단 우리 회사와 나에 대해서 좋은 감정을 갖게 하는 것이 좋겠다 싶어서 이래저래 약을 팔았다. 그리고 면접 문제를 메일로 전달하겠다고 하고 첫 번째 만남을 마쳤다.며칠 뒤 제출한 과제를 가지고 다시 한번 병훈님을 만났다. 전공에 관련된 기본적인 질문들을 던졌다.(정확히는 졸업한 지 10년이 지나서 그냥 내가 기억나는 것들을 물어보았다.) 그런데 10개의 질문을 던지면 8개의 질문 은 원하는 답을 듣지 못했다. 실망했다. 겸손하고 배움의 자세가 갖춰져 있는 친구라는 생각은 들었다. 하지만 이렇게 모르는 친구를 뽑아도 될까 하는 생각이 들었다.하지만 뽑았다. 솔직히 그냥 학벌을 보고 뽑았다. 좋은 학교에 다니고 있는 친구이니 지금까지 최소한 한 번쯤은 최선을 다해 본 적이 있겠지 라는 생각을 했다. 무엇보다 당시 나는 꽤 급했다.합격 메일에는 ‘실력에 대해서는 회사에 오셔서 보여주세요'라는 내용을 적어서 보냈다. 부족한 만큼 회사에 와서 최선을 다해주었으면 하는 마음이었다. 그리고 입사할 주에 있을 워크숍 준비에 대한 요청도 함께 드렸다.지금 와서 이야기하는 것이지만 최근에 병훈님을 면접 봤다면 떨어뜨렸을 거다. 생각해 보면 이게 면접, 특히 주니어 면접의 어려움이다. 그 사람이 입사해서의 2주 정도는 예상해 볼 수 있지만 그 뒤는 예상하는 것은 너무나 어려운 일이다.3. 들어와서 처음 했던 일- 소병훈 이야기들어와서 처음으로 했던 일은 나를 대학생에서 직장인으로 바꾸는 일이었다.회사에 들어온 지 1~2개월이 지났을 때 외부 업체와 전문 통신을 개발하는 작업을 맡았다. 대학교에서 두 PC 사이의 전문 통신 프로젝트를 만들었던 기억이 있어 충분히 혼자서(그리고 짧은 기간에) 만들 수 있으리라 생각하고 작업을 시작했다. 기존의 코드를 조금씩 수정하고 추가하던 이전의 작업들과는 다르게 처음부터 만들어내는 일이었다.이 일을 하면서 지금까지 '하나의 동작을 하는 무언가'를 100% 혼자서 만든 적이 없다는 것을 깨달았다. 항상 기본 틀을 받아서 코딩하고, 어려울 때는 모범 답안을 보면서 힌트를 얻었으며, 그러고도 힘이 부치면 7~80%만 완성하고 (시간이 없었다는 핑계를 대면서) 넘어갔었다. 회사에서는 이 일이 '소켓 통신의 이해를 확인하기 위한 프로그래밍'이라고 설명되어 있지 않았고, 어디서 버그가 발생했는지 힌트를 얻을 수 없었다.대학 강의로 들었던 내용들과 전혀 다른 지식들이 필요했지만, 필요한 기초적인 요소들은 구글에서 찾을 수 있었다. 하지만 어떤 키워드를 검색해야 하는지부터가 문제였다. 검색해야 하는 단어를 알아내려고 시니어 개발자님들께 돌아가면서 물어봤다. (너무 자주 물어야 해서 한 분에게만 묻기 죄송했다.) 처음에는 학교에서 배운 내용은 쓸모없다고 생각했었는데, 지금 생각해보니 그마저도 없었으면 구글과 위키의 내용도 이해 못했을 것 같다.웹 개발에 대한 기초도 없고, 어디가 끝인지 확신도 없어서 개발 시간이 길어졌다. 야근을 반복했다. 노력한다고 해서 없던 능력이 생기지는 않았고, 결과로 커다란 똥덩어리 같은 코드가 만들어졌다. 다행히도 (달리는 중간에 몇 개의 부품을 갈아 끼운 이후에) 최소한의 기능은 정상적으로 돌아갔다.그렇지만 이 코드가 12월까지 구린 냄새를 피우고 있었다. 에러를 만들지는 않지만 가독성이 떨어지고 창의적인 구조 때문에, 유지/보수를 할 때마다 과거의 내 실력을 확인하는 좋은 지표가 되었다.- 이호성 이야기시간이 많다면 병훈님을 옆에 앉혀 두고 차근차근 알려주고도 싶고, 같이 스터디도 하고 싶었지만 내게는 당장 해야 하는 일이 쌓여 있었다. 다행히 팀에 계신 시니어 개발자 분들이 병훈님일 이래 저래 잘 돌봐 주었다. (20살이 넘는 청년에게 "돌봐 주었다"라는 표현이 적당한 가에 대해서 곰곰 생각해 보았는데, 흠. 역시 적절하다.) 병훈님께 처음 한 달 동안은 조각을 고치는 일, 작지만 급한 일 들을 맡겼다. 덕분에 시니어 개발자들이 다른 일들에 집중을 할 수 있었다. 그리고는 한 달이 지나자 하나의 일을 떼어서 맡겨볼 수 있겠다는 생각이 들었다. 단순히 개발하는 일뿐만 아니라 다른 회사 개발자들과 커뮤니케이션을 직접 하면서 일을 할 수 있도록 했다. 병훈 님은 잊어버렸을지는 모르겠지만 외부 업체로 처음 전화를 걸었을 때 우리 팀의 시니어 개발자들은 모두들 키보드에서 손을 놓고 병훈님의 대화를 노심초사하면서 듣고 있었다.이 프로젝트는 곧 병훈님이 예상한 일정을 넘어섰고, 얼마 이후에는 내가 예상한 일정도 넘어섰다. 병훈님이 끙끙 앓고 있는 게 보였다. 그 일을 다른 사람에게 넘겨야 하는가의 고민도 여러 번 했다. 병훈님이 만들어 낸 창의적(이라고 말하고 싶겠지만 상식을 벗어난)인 코드들을 뜯어고치고 싶다는 생각도 했다. 하지만 시간은 지났고 테스트를 통과한 코드는 에잇퍼센트 프로덕트의 한 자리를 차지했다.4. 무엇을 배웠을까?- 소병훈 이야기첫 번째로 회사가 어떻게 돌아가는 지를 배웠다. 작은(?) 스타트업이었기에 개발팀 외 다른 팀원들과도 친하게 지낼 수 있었고, 회사 내에서 생기는 사건들을 전부 들을 수 있었다. 그렇게 아이디어가 나오는 순간부터 제품으로 완성되는 과정을 볼 수 있었다. 또한 회사의 크고 작은 의사 결정 과정을 지켜볼 수 있었는데, 모든 의사 결정들에 원인과 논리적인 과정이 따른다는 점이 재밌었다.내가 알지 못하는 원인들과 다른 사람들이 결정하게 된 이유가 궁금해서 여기저기 물어보았고, 모두들 숨기지 않고 말해 주었다. 대표님과의 티타임에 찾아가서 묻기도 하고(모두에게 열려있었는데 단 2명이 왔었다), 퍼포먼스 마케팅이 궁금하다며 점심시간에 옆에 앉아 이야기하고, 전화 응대를 어깨너머로 들어보기 했다. 글로 적어보니 처음 초등학교에 들어간 8살 아이 같기도 하지만, 에잇퍼센트에 있으면서 물어보는 만큼 알 수 있었고, 그만큼 이전과는 다른 세상을 알 수 있었다. (그리고 그만큼 야근을 했다.)두 번째는 개발자가 되는 과정을 배웠다. 당연히 개발 실력도 늘었지만, 조금 더 보태서 개발자가 되는 과정을 배웠다고 말하고 싶다. 누구라고 말할 것 없이 남는 시간을 조금씩 쪼개 공부하고 있는 사람들, 새벽 4시가 넘었음에도 꼼꼼히 기록을 남기며 마무리하는 야간작업, 그리고 혼자서는 만들 수 없는 거대한 코드를 점진적으로 만들어가는 개발팀을 보면서 개발자라는 직업을 만날 수 있었다. 내가 본 개발자는 (에잇퍼센트의 개발자만의 특징일 수도 있지만,) 모든 결과를 '우연'으로 넘기지 않고 원인을 찾았고, 원하는 분야를 찾아서 스스로 공부하고, 삶의 즐거움을 하나씩 가지고 있는 사람들이었다.마지막으로 나 되돌아보기. 나는 내 실력을 과대평가하고 있었다. 회사에 들어오면서 '열심히 하겠다'라고 말했지만 몇 개월 '열심히' 뛰어서 갈 수 있는 거리에는 한계가 있었다. 다른 사람들이 불가능하다고 말해도 혼자서 '노력하면 할 수 있다'라고 생각하면서 오기로 붙잡고 있다가 결국 기한을 넘긴 적이 많았다. 시간이 지나면서 '할 수 있을 것 같다'는 자신감이 아니라 완성된 결과물을 보면서 실력을 확인했다. 항상 자신감이 넘치다 보니 매번 내 생각보다 실력이 뒤에 있었고, 그런 생각이 들 때마다 기숙사에서 공부를 했다.그렇지만 나를 과대평가했던 것처럼 나의 목표도 과대평가 했었다. 내가 도달하려고 했던 목표도 꾸준히 달리면 도달할 수 있는 거리에 있었고, 생각보다 멀리 있지 않았다. 다만 '꾸준히'의 기준이 몇 주, 몇 개월이 아니라는 것을 배웠을 뿐이다.- 이호성 이야기내가 입사하기 전에 에잇퍼센트에 여러 명의 개발 인턴이 있었다고 했다. (commit log에서만 만날 수 있는 그대들이여. 왜 버그를 내게 주고 갔는가.) 그리고 한 명을 제외하고는 회사를 모두 떠났다. 처음에 대표님이 인턴 채용 제안을 몇 번 하셨을 때 개발팀에는 인턴을 채용하지 않겠노라고 말했었다. 사람이 전부인 개발팀에서 떠나는 것이 예정된 사람을 뽑고 싶지 않은 마음이었다. 하지만 병훈님은 이런 내 생각을 바꿔 놓았다.연말 평가에서 성장에 대한 상을 받을 만큼 병훈님의 성장은 눈부셨다. 이제 좋은 주니어는 무엇인가에 대해서 병훈님을 기준으로 생각을 할 수 있게 되었다. 주니어 채용에 대한 성공체험을 했다고도 할 수 있겠다.상은 병훈님이 받는데 주는 사람이 더 좋아하네?좋은 주니어는 당연하게도 일정 시간이 지났을 때 높은 곳에 올라갈 수 있는 사람이다. 높은 곳에 올라가기 위한 조건은 다음과 같은 것들이 있다.1) 상대적으로 이미 높은 곳에 있을 것 전설 속에만 존재하는 시니어 같은 주니어 되시겠다. 고등학교, 대학교 때 많은 지식과 경험을 쌓아서 이미 현업에서 잘할 수 있는 친구들이다.2) 인지능력, 학습능력문제를 이해하고 정의하는 능력이 뛰어나고 논리적인 사고를 한다. 속칭 똑똑한 친구들이다. 문제를 자세하게 설명해 주지 않아도 문제의 본질을 이해할 수 있고, 답으로 가는 길을 빠르게 찾아낼 수 있다. 새로운 것을 빠르게 익히고 배움에 대한 두려움이 없다.3) 지적겸손배움에 대해 열린 자세를 가지고 있어야 한다. 나는 개인적으로 주니어의 경우 이 능력을 "내갈굼력"이라고도 부른다. 다른 사람들에게 지적과 갈굼을 받으면서도 그것이 배움으로 이어진다면 감사한 마음으로 받아들인다. 감사한 마음은 다시 지식을 전해주는 사람에게 긍정적인 피드백이 되어 더 많은 것을 알려주게 되는 선순환 구조를 만들어 간다.4) 태도긍정적이고 도전적인 태도를 갖추어야 한다. 자신의 인생을 발전적으로 개척해 나갈 태도. 그리고 자신을 둘러싼 환경에 감사하는 태도. 이 태도는 팀에도 많은 영향을 미친다.병훈님을 면접 볼 때의 나는 1) 만을 중요하게 생각했기에 병훈님을 떨어뜨려야 하나 하고 생각했다. 하지만 이제 와서 뒤돌아 보니 병훈님은 2), 3), 4)을 모두 갖추고 있는 인재였다. 아마 몇 년 뒤에는 1)도 충분히 갖추게 되리라.5. 어떻게 일했나?- 소병훈 이야기 9개월 동안 에잇퍼센트를 다니면서 항상 내 능력으로 조금 힘들지만 불가능하지 않을 만큼 업무가 들어왔다. 스프린트(2주) 단위로 업무를 나눠가지는데, 일방적으로 업무를 할당받지 않고 팀 회의로 업무를 나눠갖는다. 호성님이 업무를 강요하지도 않고 업무 일정도 각자가 정하지만, 모두가 보고 있다는 느낌과 '스스로를 과대평가하는 나' 때문에 매번 촉박한 일정에 시달리게 되었다. 그렇게 나는 손을 들고 해당 업무의 책임자가 된다. 초반에는(전문 개발할 때까지)는 아예 질문하지 않아서 혼자 끙끙 댔는데, 너무 안쓰러워 보였는지 옆에 앉은 연태님이 먼저 도와주셨다. 시간이 지나면서 길이 명확하게 보이지 않을 때는 시니어 개발자(대부분 연태님)에게 물어보면서 일을 진행했다. 어느 날 호성님이 에잇퍼센트처럼 '실패하면서 성장할 수 있는 환경'이 다른 회사에서는 없다고 말했었다. 생각해보니 내가 자유롭게 개발해도 테스트와 코드 리뷰를 거치면서 문제를 잡아낸다. 그러고도 버그가 생기면 실서버에서 디버깅해서 문제를 해결한다. 심적으로 매우 죄송한 마음이 들지만 추가적으로 다른 벌은 받지 않았다. 일을 시작하기 전부터 지레 겁먹을 필요는 없었다. 그 뒤로 길이 희미하더라도 우선 걸어가 봤다. 그러다가 도저히 길이 보이지 않을 때, 조언을 받는 방식으로 일을 진행했다. 시간이 더 오래 걸리면서 최종 결과물의 수준이 떨어지는 상황이 발생했지만, 코드 리뷰를 받으며 최소한의 수준은 맞춰졌다. (그러면서 시간은 더 오래 걸린다.) 최대한으로 생각해서 만들어도 항상 놓치는 부분이나 더 간단한 해결 방법이 있었고, 그때 느끼는 아쉬움과 안타까움이 다음 개발할 때 잊지 않고 기억나서 내가 성장한다는 느낌이 들었다. 막바지에는 개발을 시작하기 전에 항상 의자를 들고 해당 업무를 요청한 사람 옆으로 갔다. 말로 이야기면 Slack이나 Trello로 이야기하는 것보다 빠르고, 해당 문제를 직접 보면서 자세한 설명도 들을 수 있었다. 요청사항을 받아 개발하는 느낌이 아니고 함께 문제를 해결한다는 느낌으로 이야기하면서 실시간으로 여러 해결방안을 제시하면서 생각을 주고받았다. 문제를 해결하면서 회사에 장기적으로 도움이 되는 방향을 고민하다 보니 '주인의식을 가지고 일한다'는 느낌이 들었다. (정확히는 이왕 만드는 거 아름답게 만든다는 생각이었다.)- 이호성 이야기회사에서 병훈님의 별명은 '아기새'였다. 업무를 하면서도 사람들의 보살핌을 필요로 했지만 그것 외에도 이런저런 허술한 면을 많이 보여줘서 누가 붙였는지 기억이 나진 않지만 모두의 입에 착 붙어 있는 별명이었다. (개발팀 내에서는 간혹 '아. 이런. 손이 많이 가는 친구'로 불리기도 했다.)에잇퍼센트에는 퇴사하면 털린다. 다들 떠나지 마라.병훈님을 연태님 옆자리에 앉게 했다. 회사 내에서 스위퍼(스프린트 내의 개발 잡일들을 처리하는 담당) 팀도 연태님과 같이할 수 있도록 했다. 경험과 인내심이 많고 상냥한 언니 같은 연태님(남자)은 병훈님의 좋은 파트너가 되어 주었다. 그리고 세바님은 어려운 문제를 함께 해결해 주고 코드의 퀄리티에 대한 감시자(갈굼자)가 되어 주었다. 언젠가 병훈님이 개발자의 길을 가게 되어 첫 월급을 받게 되면 이 두 분에게는 빨간 내복을 사드려야 할 거다.처음에는 아기새의 Pull Request(반영하고자 하는 코드 뭉치)에는 코멘트가 수십 개가 달렸다. 그것들을 꾸역꾸역 고치고 나면 다시 그 절반 정도의 코멘트가 달리곤 했다. 하지만 병훈님이 떠날 때쯤에는 내 코드에 "이렇게 저렇게 고치는 게 더 좋은 것 같은데요?"라고 코멘트를 달곤 했으니 발전하지 못한 나는 부끄러울 따름이다.그리고 병훈님은 다른 팀일에도 참 관심이 많았다. 그러고 보면 나도 처음에 스타트업에서 일하기 시작했을 때 다른팀 일들도 왜 그렇게 재미있었는지 모르겠다. 하지만 분명한 것은 작은 조직에서는 다른 팀에 대한 관심이 개발을 잘하는 데 많은 도움이 된다.6. 떠나기 이주일 전- 소병훈 이야기정해졌던 퇴사일이 가까워지면서 새로운 업무는 들어오지 않았다. To-do list는 사라지고, 대신 '인수인계'라는 일이 생겨났다. 지금까지 했던 일들을 문서로 남기면서 새로운 책임자에게 넘겨주는 일이었다. 큰 그림을 그렸던 것들이 있는데 완성을 하지 못한다는 아쉬움이 컸다.호성님께 1,2월 프리랜서 제안서를 받게 된 건 우연이였다. 다 같이 점심을 먹을 때 우연히 호성님과 같은 테이블에 있었고, 1,2월에 남은 일정을 이야기하다 농담처럼 나온 제안이었다.제안서를 받은 날, 기숙사에서 많은 계산을 했다. 개발하는데 어느 정도 시간이 걸릴지, 제안서의 업무 기한을 변경한다면 일정이 어떻게 될지, 그렇게 받은 돈으로 어느 정도 도움이 될지. 충분히 가능한 일정이었다. 못해서 아쉬워하던 일이기도 했다. 그렇기에 더 고민했다.긍정적으로 고민하던 제안을 거절한 이유는 '여행 도중에도 계속 개발을 생각할까 걱정되어서'였다. 이번 여행에서 아쉬움이 남으면 다음은 언제가 될지 모른다는 생각이 들자, 내 시간이 더 귀하다는 생각이 들었다.그러면서 내가 조금이라도 더 필요하다고 말해주는 점이 고마웠다. 내가 생각해보지 못한 선택지를 받아서, 나의 가치관을 되짚어 본 느낌이었다.- 이호성 이야기병훈님과 같이 식사를 했다. 병훈님은 복학하기 전 유럽으로 여행을 다녀온다고 했다. 밤에 돌아다니면 위험하니까 숙소에서 코딩이나 하라고 살살 꼬셨다. 밤에 코딩하고 그 아르바이트비로 낮에 럭셔리하게 맛있는 것 먹고 다니면 얼마나 즐거운 여행이 되겠냐고. 제안서를 하나 작성해서 해야 할 일과 보수를 적어서 병훈님께 주었다. 왠지 넘어올 것만 같은 느낌이었다.병훈님이 하루 정도 생각해 보더니 "어정쩡한 상태가 될 것 같아요. 생각해보니 이런 제안을 주신 것만으로도 정말 감사합니다."라고 했다. 실패했다.회사 입장에서 업무를 잘 알고 있는 병훈님이 조금이라도 일을 더 해주면 하는 마음이었다. 하지만 다시 생각해보니 인생의 후배에게는 좋지 않은 권유였던 것 같다. 돈이 중요할 때가 아니라 세상에 대한 경험과 자신을 뒤돌아 볼 시간이 필요했던 거니깐.7. 떠나는 날케익이나 먹고 떠나랏!- 소병훈 이야기떠나는 날도 크게 다르지 않았다. 코드도 살펴보고 pull request도 적으면서 이전과 같은 하루를 보냈다. 그리고 마지막 날, 혹시 작별 인사를 하면서 내가 울지 않을까 걱정했지만 괜한 걱정이었다. 2달 전부터 작별 인사(라 쓰고 갈굼이라 읽는다)를 받아서 그런지 마지막 인사가 특별한 느낌이 들지 않았다.그렇지만 그 뒤로 며칠간 회사를 나왔다는 묘한 홀가분함과 그동안 했던 일들이 내 손을 떠난 공허함이 있었다. 내가 없으면 회사가 바뀌지 않을까 하는 조그만 기대도 있었지만, 다들 나 없이 잘 지내나 보다. 나는 조금 아쉬웠는데.- 이호성 이야기9개월이라는 시간이 참 금방 지났다. 남은 기간 동안 여행을 떠나는 병훈님에게 사람들이 "에이 그거 여행 가면 뭐해. 그냥 회사에서 일해"와 같은 장난을 수도 없이 했던 것 같다. 하지만 떠날 시간은 정해져 있었고 병훈님은 사람들의 박수를 받으며 떠났다. 마치 80분을 열심히 뛴 축구선수가 교체를 위해 떠날 때 받는 박수처럼.8. 떠나고 난 후- 이호성 이야기며칠 간은 아침 데일리 미팅이 왠지 허전하고, 슬랙으로 말을 걸면 대답을 할 것만 같았다. 하지만 또 새로운 사람이 회사에 들어오고 바쁘게 회사가 돌아가면서 금방 잊혀지긴 하더라. 아 그러고 보니 병훈님이 만든 코드에서 버그가 나올 때마다 우리는 회사에 남은 아기새 인형을 괴롭히긴 했다.병훈님이 떠나고 나서 같은 학교의 후배인 선희님이 회사에 마케팅 인턴으로 들어왔다. 선희님이 자기소개 시간에병훈 선배와 같은 동아리에..라고 말하자마자 전 직원이 다 뒤집어졌다. 그렇다. 우리에게 "병훈"과 "선배"는 함께할 수 없는 단어였다.여행을 갔다가 돌아온 아기새 병훈님이 와인을 하나 물어왔다. 그리고는 파닥파닥.군대 문제가 있기에 당분간 병훈님과 함께 오래 일할 수 있는 기회는 찾아오지 않을 것 같다. 아쉬운 마음이 든다. 에잇퍼센트에서의 병훈님을 "막 알에서 깨어나 호기심을 가지고 세상을 바라보는 아기새"로 기억해야겠다. 그리고 그 모습을 기억하며 나 또한 초심을 되새겨야지.우리가 다시 만날 그날까지 병훈님이 더 큰 날갯짓으로 더 넓은 세상을 여행하길 바란다. 9개월간 함께 해준 병훈님께 감사한다. 안녕!덧, 그나저나 난 또 어디에서 찾아야 하나. 이 다음 아기새를.#8퍼센트 #에잇퍼센트 #인턴 #조직문화 #후기 #팀워크 #팀플레이
조회수 10026

Estimator: BLE를 사용한 Planning Poker 애플리케이션

1. Planning PokerStyleShare 개발팀에서는 스크럼을 활용하여 일을 진행하고 있습니다.1 스크럼에는 일감의 크기를 추정estimate하는 과정이 있는데요. 구성원들 모두가 일감에 대해 이해하고 일감의 크기가 어느정도인지 함께 논의하여 합의에 이르는 과정입니다. 스프린트 회의에서 일감을 등록한 사람(리포터)이 일감에 대해 설명하고 나서 전체 구성원들이 일감의 크기를 추정하는데, 이 때 사용하는 것이 바로 Planning Poker입니다.Planning Poker는 0.5부터 시작해서 1, 3, 5, 8, 13, 20, … 100과 같이 피보나치 수열로 증가하는 숫자를 가진 카드 덱입니다. 리포터의 설명이 끝난 뒤 스크럼 마스터가 하나, 둘, 셋을 외치면 각자 생각한 일감의 크기에 맞는 카드를 꺼내고, 스크럼 마스터는 구성원들의 추정치가 최대한 가까워지도록 부가설명이나 질문을 유도합니다.2▲ Planning Poker는 이렇게 생겼다. (출처: Control Group 블로그)하지만 개발팀이 커지면서 불편함이 생기기 시작했습니다. 회의에 참여하는 인원이 7-8명씩 되다 보니, 각자가 어떤 카드를 들고있는지 한눈에 보기가 어려워진 것입니다. StyleShare에서 자칭 아이디어 뱅크 역할을 담당하고 있는 저는 획기적인 방법이 필요하다고 생각했고, 굳이 카드를 꺼내들지 않아도 각자가 무슨 카드를 선택했는지를 쉽게 볼 수 있는 애플리케이션을 만들기로 결심했습니다.2. BLE (Bluetooth Low Energy)불편함을 덜기 위한 애플리케이션이므로, 사용자 경험이 굉장히 직관적이고 단순해야 했습니다. N:N 통신이 가능해야하고, 사용자를 귀찮게 하는 페어링Pairing이나 네트워크 접속 과정이 없어야 했습니다. 한마디로, 카드를 꺼내들고 눈으로 확인하는 것보다 더 편한 무언가를 만들어야 했습니다!처음에는 근거리 무선 통신을 위한 기술로 스타벅스에서 사이렌 오더 개발에 사용한 고주파 인식 기술을 생각했습니다.3 각자의 기기에서 선택한 카드에 맞는 소리를 내보내고, 다른 기기에서는 고주파를 읽겠다는 것이었는데요. Soundlly(구 aircast.me)와 같은 상업용 SDK를 쓰지 않는 이상, 사운드 프로그래밍을 한 번도 해본 적 없는 저에게는 데이터가 실린 고주파를 만드는 것부터 소리를 인식해서 데이터를 읽어내는 과정이 마치 화성에서 감자 키우는 이야기처럼 들렸습니다.그러다 문득 생각난 것이 바로 비콘Beacon입니다. 언젠가 소비자가 오프라인 매장에 방문하면 BLE를 이용해서 매장 위치를 파악하는 기술이 있다는 이야기를 들은 적이 있었습니다. 찾아보니 시중에 나와있는 대부분의 모바일 기기에서는 BLE를 위한 최소 조건인 블루투스 4.0을 지원했고, 페어링이나 네트워크 접속 과정도 불필요했습니다. 무엇보다, 화성에서 감자 키우는 것보다는 쉬워보였습니다.3. Swift로 BLE 개발하기그래서 BLE를 사용해서 개발하기로 했습니다. 컨셉은 간단했습니다. 내가 선택한 카드를 브로드캐스팅하고, 다른 사람들이 선택한 카드를 내 모바일 기기에 보여주면 되는 것이었습니다. BLE를 사용하면 정보를 브로드캐스팅할 수 있고, 다른 기기에서 브로드캐스팅하는 정보를 읽을 수 있습니다.BLE에서 데이터를 브로드캐스팅하는 것을 Advertising이라고 합니다. 정보를 advertising하는 주체는 Peripheral이고, advertising되는 정보를 스캔하여 데이터를 읽어들이는 주체는 Central이라고 합니다. Peripheral에서 정보를 advertising할 때에는 특정한 정보를 실어나를 수 있는데요. 이를 Advertising Data Payload라고 합니다. 이 정보에 카드 숫자와 이름을 실어서 전송하면 될 것 같습니다.BLE를 구현하기 위해서, iOS에서는 SDK에 기본적으로 포함돼있는 CoreBluetooth 프레임워크를 사용하면 손쉽게 개발이 가능합니다. CBPeripheralManager 클래스와 CBCentralManager 클래스를 쓰면 되는데요. BLE를 이용하여 제 이름 석자를 advertising하는 코드는 다음과 같습니다.Peripheralimport CoreBluetooth let serviceUUID = CBUUID(string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX") let service = CBMutableService(type: serviceUUID, primary: true) /// 1. `CBPeripheralManager`를 초기화하고, self.peripheral = CBPeripheralManager(delegate: self, queue: nil) /// 2. 사용가능한 상태가 되면 특정 UUID를 가진 서비스를 추가한 뒤에 func peripheralManagerDidUpdateState(peripheral: CBPeripheralManager) { if peripheral.state == .PoweredOn { self.peripheral.addService(service) } } /// 3. 원하는 정보를 advertising합니다. func peripheralManager(peripheral: CBPeripheralManager, didAddService service: CBService, error: NSError?) { self.peripheral.startAdvertising([ CBAdvertisementDataLocalNameKey: "전수열", CBAdvertisementDataServiceUUIDsKey: [serviceUUID], ]) } 참고로, UUID는 커맨드라인 명령어를 통해 쉽게 만들 수 있습니다.$ uuidgen XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX 마찬가지로, Peripheral에서 advertising하는 정보를 스캔하는 Central 코드는 다음과 같이 작성할 수 있습니다. UUID는 Peripheral에서 advertising에 사용한 UUID와 동일해야합니다.Centralimport CoreBluetooth let serviceUUID = CBUUID(string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX") let service = CBMutableService(type: serviceUUID, primary: true) /// 1. `CBCentralManager`를 초기화하고, self.central = CBCentralManager(delegate: self, queue: nil) /// 2. 사용가능한 상태가 되면 특정 UUID를 가진 서비스를 스캔합니다. func centralManagerDidUpdateState(central: CBCentralManager) { if central.state == .PoweredOn { // 이미 한 번 스캔된 정보라도 계속 스캔합니다. let options = [CBCentralManagerScanOptionAllowDuplicatesKey: true] self.central.scanForPeripheralsWithServices([serviceUUID], options: options) } } /// 3. Peripheral이 스캔되면 이 메서드가 호출됩니다. func centralManager(central: CBCentralManager, didDiscoverPeripheral peripheral: CBPeripheral, advertisementData: [String : AnyObject], RSSI: NSNumber) { print(advertisementData[CBAdvertisementDataLocalNameKey]) // "전수열" } 스캔을 시작할 때 CBCentralManagerScanOptionAllowDuplicatesKey 옵션을 true로 설정해서 한 번 스캔된 정보라도 중복으로 계속 스캔하도록 합니다.4. 원하는 정보를 실어나르기CBPeripheralManager을 사용하여 advertising을 할 때에는 Advertising Data Payload를 포함시킬 수 있는데, 이 정보 중 개발자가 원하는 값을 넣을 수 있는 곳은 CBAdvertisementDataLocalNameKey밖에 없습니다. 그마저도 길이가 제한돼있기 때문에, 패킷을 효율적으로 사용하기 위해서는 정보를 저장하는 프로토콜을 직접 정의해야 합니다.우선, 카드에 대한 정의는 enum을 사용해서 작성했습니다. 0부터 0xFF까지의 숫자를 가지도록 정의했습니다.public enum Card: Int { case Zero = 0 case Half = 127 case One = 1 case Two = 2 case Three = 3 case Five = 5 case Eight = 8 case Thirteen = 13 case Twenty = 20 case Fourty = 40 case Hundred = 100 case QuestionMark = 0xFD case Coffee = 0xFE case None = 0xFF } 그리고 제가 정의한 패킷의 프로토콜은 다음과 같습니다.영역길이예시설명Version200프로토콜 버전 (00~FF)Channel201BLE 커버리지 내에서 회의하는 팀이 여럿일 수 있으니, 채널로 구분합니다. (00~FF)Card2FE카드의 16진수 값 (00~FF)Name12전수열사용자 이름 (UTF-8 기준 한글 4글자)이렇게 하면 총 18바이트 내에서 필요한 정보를 모두 전송할 수 있습니다. 이제 이 "00", "01", "FE", "전수열" 값을 직렬화해서 CBAdvertisementDataLocalNameKey로 advertising하면 됩니다.Peripheralself.peripheral.startAdvertising([ CBAdvertisementDataLocalNameKey: "0001FE전수열", CBAdvertisementDataServiceUUIDsKey: [serviceUUID], ]) 그리고, Central에서 정보를 스캔할 때에는 이 값을 각 영역의 길이에 맞게 끊어서 읽을 수 있습니다.5. 마치며비록 적은 양의 정보지만, BLE를 사용해서 실시간으로 근거리 통신을 할 수 있게 되었습니다. 이제 남은 것은 카드를 선택할 수 있는 화면과, 다른 사용자가 선택한 카드를 화면에 보여주는 인터페이스입니다. UI 개발은 본 포스트에서 중점적으로 다루고자 하는 주제와는 조금 벗어난 이야기가 될 것 같아, 오픈소스로 공개된 코드로 대신하려고 합니다. 소스코드는 GitHub에서 볼 수 있으며, Estimator는 앱스토어에서 받아보실 수 있습니다.6. 참고 자료BLE(BLUETOOTH LOW ENERGY) 이해하기 - Hard Copy World스타일쉐어의 스크럼이 지나온 길 포스트에 보다 자세히 설명되어 있습니다. ↩구성원들의 추정치에 차이가 난다는 것은 해당 일감에 대해 서로가 이해하고 있는 정도가 다르기 때문입니다. 스크럼 마스터는 구성원들이 일감에 대해 모두 비슷한 생각을 가지도록 커뮤니케이션을 유도해야합니다. ↩http://www.bloter.net/archives/226643 ↩#스타일쉐어 #개발 #개발팀 #개발자 #인사이트
조회수 1916

Mac을 처음 쓰는 개발자에게

Overview애플(Apple) 제품을 한 번도 써본 적이 없습니다. 3주 전, 입사하고 받은 맥북(MacBook Pro)이 첫 애플 제품이었죠. 사실 개발 업무를 하면서 ‘한 번쯤은 애플 제품을 써 봐야겠다’는 생각을 하고 있었습니다. 단지 쉽사리 용기가 나지 않았을 뿐이었죠. 하지만 여러 개발 환경이 존재하는데도 개발자가 한 가지 환경만 고집하는 건 스스로의 잠재 능력을 좁히는 거라 생각했습니다. 그래서 이번 기회에 새로운 환경과 친해지려고 APM 웹서버 구성에 도전해봤습니다. (아자!) OS 설치 완료 후 환경Sierra 10.13apache 2.4php 5.6mysql 5.6 APM 설치 과정MAC 환경에서 APM 설치하려면 MAMP 방법도 있지만 기본적으로 apache, php가 설치되어 있으므로 패키지관리자 Homebrew를 이용하여 설치하겠습니다. 1.apache 설치 버전 확인$ httpd -v 명령어를 실행해서 아래와 같이 버전이 나오면 설치가 되어있는 상태입니다. $ httpd -v Server version: Apache/2.4.27 (Unix) Server built: Jul 15 2017 15:41:46 2.php 설치 버전 확인php -v 명령어를 실행해 아래와 같은 버전이 나오면 설치가 된 것입니다.$ php -v PHP 5.6.32 (cli) (built: Oct 27 2017 11:55:27)  Copyright (c) 1997-2016 The PHP Group  Zend Engine v2.6.0, Copyright (c) 1998-2016 Zend Technologies 참고: MAC Sierra 10.13 버전에는 php7 상위 버전으로 설치되어 있습니다. Homebrew로 php5.6 하위 버전을 추가적으로 설치해야 합니다.3.Homebrew 설치Homebrew 명령어1)패키지 검색하기 -> $ brew search 패키지명 2)패키지 설치하기 -> $ brew install 패키지명 3)패키지 삭제하기 -> $ brew uninstall 패키지명 4)설치된 패키지 목록확인 -> $ brew list 5)패키지 정보보기 -> $ brew info 패키지명 6)패키지 업그레이드 하기 -> $ brew upgrade 패키지명 7)패키지 저장소 추가하기 -> $ brew tap homebrew/패키지명 8)패키지 저장소 삭제하기 -> $ brew untap homebrew/패키지명 9)패키지 링크 삭제하기 -> $ brew unlink 패키지명 가.설치파일 다운$ /usr/bin/ruby -e “$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)” 나. Homebrew wget 설치 (Apple에서 제공하지 않는 패키지를 설치하기 위한 것이다.) $ brew install wget다. 심볼릭 링크 연결 $ ls -l /usr/local/bin/wget ../Cellar/wget/1.19.2_1/bin/wget bin/wget -> ../Cellar/wget/1.19.2_1/bin/wget 라. 패키지 저장소 추가 $ brew tap homebrew/dupes $ brew tap homebrew/php $ brew update 4.php56 설치가. Homebrew php56 설치 $ brew install php56 –with-apache 나. Apache에 PHP 설정 수정하기 아파치에 php7 모듈이 연결되어 있어 주석 처리 후 설치한 php5 경로로 연결한다. $ vi /etc/apache2/httpd.conf LoadModule php5_module /usr/local/php5-5.6.31-20170817-164511/libphp5.so #LoadModule php7_module libexec/apache2/libphp7.so 다. apache 재시작 apachectl restart라. phpinfo 확인 phpinfo 확인5.mysql56 설치가. Homebrew mysql56 설치$ brew install mysql56나. mysql 시작$ /usr/local/Cellar/[email protected]/5.6.38/bin/mysql.server start다. mysql 버전확인$ /usr/local/Cellar/[email protected]/5.6.38/bin/mysql –version명령어를 실행해서 아래와 같이 버전이 나오면 설치가 되어있는 상태입니다.$ sudo /usr/local/Cellar/mysql\@5.6/5.6.38/bin/mysql --version  /usr/local/Cellar/[email protected]/5.6.38/bin/mysql  Ver 14.14 Distrib 5.6.38, for osx10.13 (x86_64) using  EditLine wrapper 6.가상호스트 설정로컬에 다수의 프로젝트를 세팅하기 위한 것이다. 가. httpd.conf 파일 수정Include /private/etc/apache2/extra/httpd-vhosts.conf <- 주석제거 $ vi /etc/apache2/httpd.conf  # Virtual hosts Include /private/etc/apache2/extra/httpd-vhosts.conf 나. httpd-vhosts.conf 파일 수정NameVirtualHost : 아파치 2.4 이전 버전일 경우 80 포트에서 이름 기반 가상 호스트를 사용하겠다는 의미로 반드시 적어줘야 한다.DocumentRoot : 해당 프로젝트 소스 경로ServerName : 해당 프로젝트 접속 도메인주소 $ vi /etc/apache2/extra/httpd-vhosts.conf NameVirtualHost *:80       DocumentRoot "/Users/comkjs/Sites/ex1"     ServerName ex1.brandi.co.kr     ErrorLog "/private/var/log/apache2/error_log"     CustomLog "/private/var/log/apache2/access_log" common               Options FollowSymLinks         AllowOverride All         Order allow,deny         Allow from all         Require all granted         DocumentRoot "/Users/comkjs/Sites/ex2"     ServerName ex2.brandi.co.kr     ErrorLog "/private/var/log/apache2/error_log"     CustomLog "/private/var/log/apache2/access_log" common               Options FollowSymLinks         AllowOverride All         Order allow,deny         Allow from all         Require all granted     7. hosts 설정해당 도메인으로 접속시 DNS 서버를 사용하기 이전 로컬에 지정된 IP로 맵핑된다.$ vi /etc/hosts ## # Host Database # # localhost is used to configure the loopback interface # when the system is booting. Do not change this entry. ## 127.0.0.1 localhost 255.255.255.255 broadcasthost  ::1             localhost   127.0.0.1 ex1.brandi.co.kr 127.0.0.1 ex2.brandi.co.kr Conclusion물론 오랫동안 맥북을 사용했던 개발자에겐 쉬운 내용일 수 있지만 MS와 리눅스에 익숙했던 저에겐 ‘두려움’이었습니다. 리눅스 구조와 명령어가 비슷해서 리눅스를 이용했던 이용자에겐 어렵지 않을 것입니다. 한 번 세팅해두면 환경이 바뀌지 않는 이상 잘 건드리지 않기 때문에 나중에 세팅을 바꾸는 일이 있으면 또 다시 볼 수 있도록 기술 블로그에 남겨둡니다. 분명 언젠가는 도움이 되지 않을까요. 글곽정섭 과장 | R&D 개발1팀[email protected]브랜디, 오직 예쁜 옷만#브랜디 #기업문화 #조직문화 #업무환경 #인사이트 #경험공유 #Mac #개발자 #신입개발자 #조언
조회수 1914

MobX + React 10분 튜토리얼

* 이 글은 MobX의 MobX and React 튜토리얼을 번역한 글입니다.** 오역 및 오탈자가 있을 수 있습니다. 발견하시면 제보해주세요!개요MobX은 간단하고 확장 가능하며 테스트를 거친 상태 관리 솔루션입니다. 이 튜토리얼은 10분 안에 MobX의 중요한 컨셉들을 모두 소개합니다. MobX는 독립적인 라이브러리지만 대부분의 사람들은 React와 함께 사용합니다. 그래서 이 튜토리얼은 MobX와 React의 조합에 중점을 두고 설명합니다.The core idea상태는 각 애플리케이션의 핵심입니다. 버그를 만드는 관리가 되지 않는 애플리케이션을 만드는 가장 빠른 방법은 주변의 로컬 변수들과 동기화 되지 않는 상태나 일관성 없는 상태를 만드는 것입니다. 그래서 많은 상태 관리 솔루션들이 상태를 변할 수 없게 만드는 식으로 상태를 수정할 수 있는 방법들을 제한하려고 합니다. 하지만 이 방법은 새로운 문제들을 생성합니다. 데이터를 표준화 해야 하고 참조 무결성이 보장되지 않으며 프로토타입과 같은 유용한 컨셉들을 활용하지 못하게 됩니다.MobX는 일관성 없는 상태를 만들 수 없도록 주요 문제를 해결하여 상태 관리를 간단하게 만들었습니다. 이를 위한 전략은 간단합니다. 애플리케이션 상태로부터 파생될 수 있는 모든 것들을 자동으로 파생되도록 하는 것입니다.개념적으로 MobX는 애플리케이션을 스프레드시트로 간주합니다.1. 가장 먼저 애플리케이션 상태가 있습니다. 애플리케이션의 모델을 채우는 객체, 배열, 원시, 참조의 그래프입니다. 이 값들은 애플리케이션의 "데이터 셀"입니다.2. 둘째로 파생 값이 있습니다. 기본적으로 애플리케이션으로부터 자동으로 계산될 수 있는 모든 값들입니다. 이 파생 값이나 계산된 값들은 완료되지 않은 todo들의 수와 같이 간단한 값부터 todo의 시각적 HTML 표현과 같은 복잡한 내용까지 다양합니다. 스프레드시트 용어로는 애플리케이션의 공식이나 차트가 있습니다.3. 리액션은 파생 값과 매우 비슷합니다. 주된 차이점은 값을 생성하지 않는 함수라는 점입니다. 대신 자동으로 특정 작업들을 수행시킵니다. 대체로 I/O와 관련된 작업입니다. 리액션은 적당할 때에 자동으로 DOM이 업데이트되거나 네트워크 요청을 하도록 만듭니다.4. 마지막으로 액션이 있습니다. 액션은 상태를 변경하는 모든 것들을 말합니다. MobX는 모든 사용자의 액션으로 발생하는 상태 변화들이 전부 자동으로 파생 값과 리액션으로 처리되도록 합니다. 동기화되고 결함이 없습니다.간단한 todo store이론은 충분합니다. 위의 내용을 유심히 읽는 것보다 실제 예시를 보는 것이이해하기 아마도 더 쉽습니다. 아주 간단한 ToDo store을 가지고 시작해봅시다. 아래의 모든 코드 블록들은 수정이 가능하므로 run code  버튼을 클릭하여 실행시킬 수 있습니다. 아래의 코드는  todo 목록이 포함된 매우 직관적인 TodoStore입니다. MobX는 아직 포함되지 않았습니다.우리는 todos 목록이 있는 todoStore 인스턴스를 이제 막 만들었습니다. 어떤 객체들로 todoStore을 채울 시간입니다. 변경 사항들을 보기 위해 각 변화 이후에 todoStore.report를 호출하고 로그를 남깁니다. 레포트는 의도적으로 항상 첫 번째 할 일만 출력합니다. 이 때문에 예시가 좀 인위적이지만 아래에서 볼 수 있듯이 MobX의 의존성 추적이 동적임을 잘 보여줍니다.결과:반응형으로 만들기지금까지 이 코드에서 특별한 것은 아무것도 없었습니다. 그러나 report를 명시적으로 호출할 필요가 없다면 어떨까요? 각 상태가 변할 때마다 report가 호출되길 원한다고 선언할 수 있습니까? 그러면 report에 영향을 줄 수도 있는 모든 코드에서 report를 호출해야 합니다. 최신의 report가 출력되기를 원하지만 그것을 모두 작성하고 싶지는 않습니다.운이 좋게도 이것은 MobX가 여러분을 위해 동작하는 것입니다. 자동으로 상태에 연관되어 있는 코드를 실행합니다. 그래서 report 함수는 스프레드시트의 차트와 같이 자동으로 업데이트 됩니다. 이를 위해 TodoStore를 관찰할 수 있어야 MobX가 모든 변경 사항들을 추적할 수 있습니다. 이를 수행하도록 클래스를 변경해봅시다.또한 completedTodosCount 속성은 자동으로 todo 목록에서 파생될 수 있습니다. @observable과 @computed 데코레이터를 사용하여 객체에서 관찰할 수 있는 속성들을 생성할 수 있습니다.이게 끝입니다! 시간에 따라 변할 수 있는 값들을 MobX에게 알려주기 위해 @observable를 표시했습니다. 계산은 상태로부터 파생될 수 있는 것들을 확인하기 위해 @computed를 사용하여 표시됩니다.pendingRequrests와 assignee 속성들은 지금까지 사용되지 않았지만 앞으로 이 튜토리얼에서 사용됩니다. 이 페이지의 모든 예시들을 짧게 만들기 위해 ES6와 JSX 그리고 데코레이터를 사용합니다. MobX의 모든 데코레이터들은 ES5 부분들을 가지고 있으니 걱정하지 마세요.생성자에 report를 출력하는 작은 함수를 만들고 autorun으로 감쌌습니다. Autorun은 한 번 동작되는 리액션을 만들고 함수 안에서 사용되는 관찰 가능한 모든 데이터들이 변경될 때마다 자동으로 다시 실행합니다. report는 관찰 가능한 todos 속성을 사용하기 때문에 적절할 때 레포트를 출력합니다. 이것은 다음 리스트에서 설명됩니다. 실행 버튼을 눌러보세요:report은 자동으로 동시에 중간 값을 빼먹지 않고 출력하였습니다. 유심히 로그를 보면 새로운 로그에서는 4번째 줄이 없는 것을 발견할 수 있습니다. 뒤의 데이터가 변경되는 것으로 report가 실제로 변경되지 않기 때문입니다. 반면에 첫 번째 할일의 이름이 바뀐 것은 report에서 실제로 사용되는 이름이기 때문에 report를 업데이트 하였습니다. 이것은 todos 배열이 autorun에 의해 관찰되는 것이 아니라 todo 아이템들 안에 있는 개별적인 속성을 관찰하고 있다는 것을 잘 설명해줍니다.반응형 React 만들기지금까지 바보 같은 report를 반응형으로 만들었습니다. 이제 이 store에서 반응형 유저 인터페이스를 만들 시간입니다. React 컴포넌트들은 이름값을 못하고 반응형이 아닙니다. mobx-react 패키지의 @observer 데코레이터는 React 컴포넌트 render 함수를 autorun으로 감싸 자동으로 상태에 따라 컴포넌트가 동기되도록 만듭니다. 개념적으로 이전에 report를 가지고 했던 것과 다르지 않습니다.다음 코드는 몇 개의 React 컴포넌트를 정의합니다. 이 안의 MobX는 @observer 데코레이터 뿐입니다. 이것으로 충분히 데이터가 변경될 때 각 컴포넌트가 개별적으로 다시 렌더링하도록 만들 수 있습니다. 더이상 setState를 호출할 필요가 없으며 설정이 필요한 셀렉터나 상위 컴포넌트를 사용하는 상태의 적절한 부분을 찾을 필요도 없습니다. 기본적으로 모든 컴포넌트들은 더 똑똑해졌지만 아직 부족합니다.아래의 코드를 보기 위해 run code 버튼을 클릭하세요. 코드는 수정이 가능하므로 자유롭게 동작시킬 수 있습니다. 예를 들어 @observer 호출을 모두 지우거나 TodoView의 데코레이터만 지워보세요. 오른쪽의 미리보기에서 숫자들은 컴포넌트가 렌더링될 때마다 표시합니다. 다음 코드는 다른 작업을 수행하지 않고 데이터를 변경해야 한다는 것을 잘 보여줍니다. MobX는 자동으로 store의 상태에 따라 유저 인터페이스의 적절한 부분들을 다시 파생하고 업데이트합니다.참조 사용하기 지금까지 관찰가능한 객체(프로토타입과 일반 객체 둘 다)와 배열, 원시를 만들었습니다. MobX에서 참고를 다루는 방법에 대해 궁금하지 않나요? 상태가 그래프를 형성할 수 있나요? 이전 코드에서는 todos의 assignee 속성이 있는 것을 알았을 것입니다. 또 다른 "store"을 생성하여 assignee에 포함되는 사람들의 값을 전달하고 그들에게 할일이 할당해줍시다.두 개의 독립적인 store이 있습니다. 하나는 사람들이 있고 하나는 할 일들이 있습니다. 사람 store의 사람을 assignee에 할당하기 위해 참조를 할당했습니다. 변경사항들은 TodoView에 의해 자동으로 선택됩니다. MobX를 사용하면 데이터를 표준화할 필요가 없고 업데이트될 컴포넌트들을 지정하기 위해 셀렉터를 작성할 필요가 없습니다. 실제로 데이터가 어디에 저장되는지는 중요하지 않습니다. 오랫동안 객체들은 관찰가능하게 만들어졌고 MobX는 그것들을 추적할 수 있습니다. 실제 JavaScript 참조가 동작합니다. MobX는 파생과 관련이 있으면 자동으로 그것들을 추적합니다. 테스트 해보기위해 다음의 인풋 박스에 이름을 변경해보세요. (먼저 위의 Run code 버튼을 클릭했는지 확인해보세요)위의 인풋 박스의 HTML은 간단합니다:비동기 액션작은 Todo 애플리케이션에 있는 모든 것들은 상태로부터 파생되기 때문에 언제 상태가 변화하는지는 중요하지 않습니다. 비동기 액션을 만드는 것은 매우 수월합니다. 새로운 할일 아이템을 비동기적으로 로드하려면 아래의 버튼을 여러번 클릭하세요.코드는 매우 직관적입니다. UI가 현재 로딩되는 상태를 반영하도록 store의 pendingRequests 속성을 업데이트하는 것으로 시작합니다. 로딩이 끝날 때 store의 todos를 업데이트하고 pendingReqeust 카운터를 증가시킵니다. 이 스니펫을 이전 TodoList 정의와 비교하여 pendingRequests 속성이 어떻게 사용되는지 확인하세요.개발자 도구mobx-react-devtools 패키지는 화면의 오른쪽 최상단에서 찾을 수 있고 모든 Mobx+ReactJS 애플리케이션 내에서 사용할 수 있는 개발자 도구를 제공합니다. 첫 번째 버튼을 클릭하면 각 다시 렌더링되는 @observer 컴포넌트가 표시됩니다. 두 번째 버튼을 클릭하고 미리보기에서 해당 컴포넌트 중 하나를 클릭하면 해당 컴포넌트의 종속성 트리가 표시되므로 주어진 순간에 관찰중인 데이터 조각을 정확하게 검사할 수 있습니다.결론끝났습니다! 관용구는 없습니다. 완전한 UI를 형성하는 간단하고 선언적인 컴포넌트들입니다. 그리고 상태로부터 완전하고 반응형으로 파생됩니다. 여러분의 애플리케이션에서 mobx와 mobx-react를 사용하기 시작할 준비가 되었습니다. 지금까지 배운 것들을 짧게 요약하였습니다:1. MobX가 객체들을 관찰할 수 있도록 @observable 데코레이터 또는 observable(객체 혹은 배열)을 사용하세요.2. @computed 데코레이터는 상태로부터 자동으로 파생되는 함수를 만들기 위해 사용될 수 있습니다.3. 관찰 가능한 상태에 의존하는 함수들을 자동으로 실행하기 위해 autorun을 사용하세요. 로깅하거나 네트워크 요청하기에 유용합니다.4. React 컴포넌트를 진짜 반응형으로 만들기 위해 mobx-react 패키지의 @observer 데코레이터를 사용하세요. 자동으로 효율적으로 업데이트합니다. 심지어 많은 양의 데이터가 있는 아주 복잡한 애플리케이션에서도 사용됩니다.위의 수정 가능한 코드 블록을 사용하여 조금만 더 만져보면 MobX가 모든 변경 사항에 어떻게 반응하는지 기본적인 느낌을 얻을 수 있습니다. 예를 들어 언제 호출되는지 보기 위해 report 함수에 로그를 추가하거나 report를 출력하지 않고 이것이 TodoList 렌더링에 어떤 영향을 주는지 확인하세요. 아니면 특정 상황에서만 출력하세요...MobX는 상태 컨테이너가 아닙니다사람들은 종종 MobX를 Redux의 대안으로 사용합니다. MobX는 기술적인 문제를 해결하는 라이브러리일 뿐이며 아키텍처나 상태 컨테이너가 아닙니다. 그러한 의미에서 위의 예시들이 고안된 것으로 메서드에서 로직을 캡슐화하거나 store나 컨트롤러에서 구성하는 것과 같은 적절한 엔지니어링 기법을 사용하는 것이 좋습니다. 또는 HackerNesw의 누군가는 이렇게 말했습니다:"MobX는 많은 곳에서 언급되었지만 나는 마냥 좋다고 말할 수 없습니다. MobX로 작성하는 것은 컨트롤러/디스패처/액션/슈퍼바이저 또는 다른 형태의 데이터 흐름을 관리하여 애플리케이션의 요구 사항을 패턴화할 수 있습니다."#트레바리 #개발자 #안드로이드 #앱개발 #MobX #React #백엔드 #인사이트 #경험공유
조회수 7120

안드로이드 앱의 Persistent data를 제대로 암호화해 보자! (1/2)

들어가기오늘 소개해드릴 글은 안드로이드에서 좀 더 안전하게 파일 시스템에 데이터를 저장하는 방식에 관한 내용입니다. 이 글은 중급 이상, 상급 이하 안드로이드 개발자를 대상으로 작성했으며 완독하는데 약 20분 정도가 필요합니다. 최대한 쉽게 쓰려고 노력했습니다만 이 글이 잘 이해되지 않는 독자분들은 이 문서 말미의 더 보기 섹션에 링크된 외부 문서들을 읽어보시는 편이 좋습니다.1부에서는 Shared preferences 에 저장하는 데이터를 암호화 하는 방식에 대해 다루고 있으며, 2부에서는 데이터베이스를 암호화 하는 방식에 대해 다루겠습니다.내 앱의 데이터, 과연 유출로부터 안전할까?안드로이드 공식 사이트의 저장소 개발 가이드 문서는 데이터를 저장하는 여러 가지 방법을 소개하고 있습니다. 그 중 ‘내부 저장소’ 의 다음 특징은 눈여겨볼 만 합니다.기기의 내부 저장소에 파일을 직접 저장할 수 있습니다. 기본적으로, 내부 저장소에 저장된 파일은 해당 애플리케이션의 전용 파일이며 다른 애플리케이션(및 사용자)은 해당 파일에 액세스할 수 없습니다. 사용자가 애플리케이션을 제거하면 해당 캐시 파일은 제거됩니다.즉, 다른 애플리케이션에 노출하면 곤란한 중요한 정보들은 내부 저장소에 담아두면 안전하다고 할 수 있습니다. 하지만, 정말일까요? 다음 예제를 이용해 내부 저장소에 저장한 사용자의 중요한 정보를 어떻게 탈취하는지 알아보겠습니다. 예제 앱은 충성 사용자에게 보상하기 위해 사용자가 앱을 몇 번 실행시켰는지를 기록합니다.class AppRanTimesRecordingActivity : AppCompatActivity() {    privateval sharedPrefs by lazy {        // Shared preferences 는 Internal storage 에 저장된다.        getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)    }    private var appRanCount = 0    override fun onCreate(savedInstanceState: Bundle?) {        super.onCreate(savedInstanceState)        accessToSharedPrefs()        appRanCount++        Toast.makeText(applicationContext, "App has ran $appRanCount times!!", Toast.LENGTH_LONG).show()        finish()    }    override fun onDestroy() {        saveSharedPrefs()        super.onDestroy()    }    private fun accessToSharedPrefs() {         sharedPrefs.run { appRanCount = getInt(KEY_APP_RAN_COUNT, 0) }    }    private fun saveSharedPrefs() {        sharedPrefs.edit().run({            putInt(KEY_APP_RAN_COUNT, appRanCount)            apply()        })    }    companion object {        private const val SHARED_PREF_NAME = "MySecureSettings"        private const val KEY_APP_RAN_COUNT = "appRanCount"    } } [리스트 1] MODE_PRIVATE 로 보호하는 SharedPreferences 사용앱의 데이터는 /data/data/com.securecompany.secureapp 에 저장되어 있습니다만, 앱을 release 모드로 빌드하면 adb 명령으로도 볼 수 없으니 안전하다고 할 수 있을 겁니다. 실제로 adb 명령을 이용해 저장한 파일을 보려고 시도하면 아래와 같은 오류가 발생합니다.$ adb shell "run-as com.securecompany.secureapp ls -al /data/data/com.securecompany.secureapp" run-as: Package 'com.securecompany.secureapp' is not debuggable 그렇다면 디버거로도 볼 수 없으니 내부 저장소에 저장한 데이터가 안전하다고 말 할 수 있을까요?그렇지 않습니다! 안드로이드는 루팅이 매우 손쉬운 운영체제기 때문에 설령 release 모드로 빌드한 앱이라 하더라도 adb 명령을 이용해 모두 접근할 수 있습니다. 루팅한 기기에서 우리가 제작한 SecureApp의 내부 저장소 구조를 아래와 같이 확인할 수 있습니다.$ adb shell "sudo ls -al /data/data/com.securecompany.secureapp" drwxrwx--x u0_a431 u0_a431 2018-06-04 14:15 cache drwxrwx--x u0_a431  u0_a431           2018-06-04 14:15 code_cache drwxrwx--x u0_a431  u0_a431           2018-06-04 14:15 shared_prefs $ adb shell "sudo ls -al /data/data/com.securecompany.secureapp/shared_prefs" -rw-rw---- u0_a431 u0_a431 111 2018-06-04 14:15 MySecureSettings.xml $ adb shell "sudo cat /data/data/com.securecompany.secureapp/shared_prefs/MySecureSettings.xml" <?xml version='1.0' encoding='utf-8' standalone='yes' ?>     별다른 테크닉이 없더라도 인터넷에 널린 수많은 루팅 방법으로 기기를 루팅하면 제아무리 내부 저장소에 저장한 데이터라도 이렇게 손 쉽게 유출이 가능하다는 것을 확인할 수 있습니다. 이런 방식의 보안 기법은 불투명성에 의지한 보안이라고 하여, 방법을 전혀 모르는 공격자에게는 유효한 방식입니다만 이 글을 읽는 독자 수준의 개발자라면 취약점을 금세 파악할 수 있다는 단점이 있습니다.그렇다면 암호화를 적용하면 되지 않을까?맞습니다. 어차피 유출을 피할 수 없다면, 데이터를 암호화하면 됩니다. 그래서 암호화 로직으로 데이터를 암호화해 보도록 하겠습니다. 이 코드는 AES / CBC / PKCS5Padding 방식을 사용해 주어진 데이터를 암호화합니다. 각 용어를 간략하게 설명하자면 다음과 같습니다.AES: 미국에서 개발된 블럭 암호화 방식으로 좀 더 나은 보안성을 가진다. 데이터를 일정 크기(블럭)로 나눠 암호화하며 보통 128비트, 192비트, 256비트 단위로 암호화한다. 키의 길이는 암호화 방식에서 사용할 블럭 크기와 완전히 같아야 하는 특징이 있다.CBC: 블럭을 회전시키는 방식을 말한다. 최초로 소개된 블럭 회전 알고리즘인 ECB(Electronic Code Book) 의 보안 취약점을 해결하기 위한 방식으로 같은 데이터 입력에 대해 완전히 다른 결과를 내므로 보안성이 좀 더 높다. 하지만 CBC 방식을 위해서는 초기화 벡터(Initialisation Vector, IV)를 반드시 사용해야 한다.IV : CBC 블럭 회전방식에 사용하는 초기화 값. 암호화할 데이터와 키가 변하지 않더라도 이 값만 바뀌면 결과가 크게 달라진다. 암호화 key 와는 전혀 무관한 값이기 때문에 외부에 노출되더라도 보안 위협은 적은 편이며 암호화 요청마다 다른 IV 를 사용해 보안성을 높일 수 있다. 다만, 키 길이와 일치하는 길이의 IV 가 필요하다.PKCS5Padding: 블럭 암호화 방식은 입력 데이터의 길이가 블럭의 길이 혹은 그 배수와 일치해야 하는 문제점이 있다. 입력 데이터가 블럭 길이보다 짧을 경우 원칙적으로 암호화가 불가능하다. 이런 어이없는 단점을 보완하기 위한 방식으로, 입력 데이터를 강제로 블럭 크기만큼 맞춰주는 알고리즘의 일종이다.object AESHelper {    /** 키를 외부에 저장할 경우 유출 위험이 있으니까 소스 코드 내에 숨겨둔다. 길이는 16자여야 한다. */    private const val SECRET_KEY = "HelloWorld!!@#$%"    private const val CIPHER_TRANSFORMATION = "AES/CBC/PKCS5PADDING"    fun encrypt(plainText: String, initVector: String): String {        val cipherText = try {            with(Cipher.getInstance(CIPHER_TRANSFORMATION), {                init(Cipher.ENCRYPT_MODE,                         SecretKeySpec(SECRET_KEY.toByteArray(), "AES"),                         IvParameterSpec(initVector.toByteArray()))                return@with doFinal(plainText.toByteArray())            })        } catch (e: GeneralSecurityException) {            // 특정 국가 혹은 저사양 기기에서는 알고리즘 지원하지 않을 수 있음. 특히 중국/인도 대상 기기            e.printStackTrace()            ""        }        return Base64.encodeToString(cipherText, Base64.DEFAULT)    }    fun decrypt(base64CipherText: String, initVector: String): String {        val plainTextBytes = try {            with(Cipher.getInstance(CIPHER_TRANSFORMATION), {                init(Cipher.DECRYPT_MODE,                        SecretKeySpec(SECRET_KEY.toByteArray(), "AES"),                        IvParameterSpec(initVector.toByteArray()))                val cipherText = Base64.decode(base64CipherText, Base64.DEFAULT)                return@with doFinal(cipherText)            })        } catch (e: GeneralSecurityException) {            // 특정 국가 혹은 저사양 기기에서는 알고리즘 지원하지 않을 수 있음. 특히 중국/인도 대상 기기            e.printStackTrace()            ByteArray(0, { i -> 0 })        }        return String(plainTextBytes)    } } [리스트 2] 간단히 구현한 AES128 암호 및 해독 로직그리고 위의 AESHelper 를 이용해 SharedPreference 에 들어갈 자료를 암호화해 봅시다.class MainActivity : AppCompatActivity() {    privateval iv by lazy { lazyInitIv() }    privateval sharedPrefs by lazy {        getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)    }    private var appRanCount = 0    override fun onCreate(savedInstanceState: Bundle?) {         super.onCreate(savedInstanceState)        setContentView(R.layout.activity_main) // Shared preferences 는 Internal storage 에 저장된다. accessToSharedPrefs()        appRanCount++ Toast.makeText(applicationContext, "App has ran $appRanCount times!!", Toast.LENGTH_LONG).show()    }    override fun onDestroy() {        saveSharedPrefs()        super.onDestroy()    }    private fun accessToSharedPrefs() {        sharedPrefs.run({            val appRanCntEncrypted = getString(KEY_APP_RAN_COUNT, "")            if (appRanCntEncrypted.isEmpty()) {                return@run            }            appRanCount = AESHelper.decrypt(appRanCntEncrypted, iv).toInt()        })    }    private fun saveSharedPrefs() {        sharedPrefs.edit().run({            putString(KEY_APP_RAN_COUNT, AESHelper.encrypt(appRanCount.toString(), iv))             apply()        })    }    private fun lazyInitIv(): String {        return sharedPrefs.run({            var iv = getString(KEY_SESSION_IV, "")            if (iv.isEmpty()) {                // 2001년 - 2286년 동안에는 항상 13자리로 나타난다. 그러므로 16자리 IV가 보장된다.                iv = "${System.currentTimeMillis()}000"                edit()                    .putString(KEY_SESSION_IV, iv)                    .apply()            }            return@run iv        })    }    companion object {        private const val SHARED_PREF_NAME = "MySecureSettings"        private const val KEY_APP_RAN_COUNT = "appRanCount"        private const val KEY_SESSION_IV    = "ivForSession"    } } [리스트 3] 리스트 2를 활용해 데이터를 암호화해 저장.저장한 SharedPreferences 를 확인해 보면 다음과 같은 결과를 얻을 수 있습니다.$ adb shell "sudo cat /data/data/com.securecompany.secureapp/shared_prefs/MySecureSettings.xml" <?xml version='1.0' encoding='utf-8' standalone='yes' ?>    1528095873216000    F9dq8ezypMPeUsHpPIUcnQ==     역시 기대대로 암호화되었네요. IV 는 노출돼도 상관없는 정보라고 했으니 괜찮겠죠. 이제 우리 앱의 사용자는 설령 기기를 잃어버리더라도 소중한 정보가 암호화되어 있으니 문제없을 겁니다.라고 생각한다면 오산입니다! 불행히도 안드로이드는 디컴파일이 매우 쉬운 플랫폼이기 때문에 이런 식의 암호화는 사실 그다지 효과가 있지 않습니다. 심지어 IV 가 그대로 노출되어 있기 때문에 공격자에게 큰 힌트가 되었습니다. IV 는 키와는 다른 값이므로 유출되어도 상관없다곤 하지만, 어쨌든 암호화 과정에서 중요하게 다뤄지는 정보임에는 매한가지이므로 사용자에게 굳이 노출할 필요는 없습니다.다소 극단적인 예를 들어 설명했습니다만 요지는 이렇습니다. 어떤 식으로든 우리의 로직 내에서 키를 관리하는 방식으로는 완벽하게 암호화했다고 말할 수 없습니다. AESHelper 소스의 첫 줄에 있는 내용을 다시 한번 살펴봅시다. /** 키를 외부에 저장할 경우 유출 위험이 있으니까 소스 코드내에 숨겨둔다. 길이는 16자여야 한다. */    private const val SECRET_KEY = "HelloWorld!!@#$%" 불행히도 이 소스에 적힌 코멘트는 틀렸습니다. jadx 나 bytecode-viewer 로 획득한 우리 앱의 APK 파일을 디컴파일 해 봅시다.@Metadata(   mv = {1, 1, 10},   bv = {1, 0, 2},   k = 1,   d1 = {"\u0000\u0014\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0002\n\u0002\u0010\u000e\n\u0002\b\u0007\bÆ\u0002\u0018\u00002\u00020\u0001B\u0007\b\u0002¢\u0006\u0002\u0010\u0002J\u0016\u0010\u0006\u001a\u00020\u00042\u0006\u0010\u0007\u001a\u00020\u00042\u0006\u0010\b\u001a\u00020\u0004J\u0016\u0010\t\u001a\u00020\u00042\u0006\u0010\n\u001a\u00020\u00042\u0006\u0010\b\u001a\u00020\u0004R\u000e\u0010\u0003\u001a\u00020\u0004X\u0082T¢\u0006\u0002\n\u0000R\u000e\u0010\u0005\u001a\u00020\u0004X\u0082T¢\u0006\u0002\n\u0000¨\u0006\u000b"},   d2 = {"Lcom/securecompany/secureapp/AESHelper;", "", "()V", "CIPHER_TRANSFORMATION", "", "SECRET_KEY", "decrypt", "base64CipherText", "initVector", "encrypt", "plainText", "production sources for module app"} ) public final class zzw {   private static final String A = "HelloWorld!!@#$%";   private static final String B = "AES/CBC/PKCS5PADDING";   public static final zzw INSTANCE; // ... } [리스트 4] 리스트 2를 디컴파일한 결과. 키가 그대로 노출됨을 확인할 수 있다.이름은 난독화했지만 문자열이 그대로 노출된 상태이므로 공격자가 단서를 찾기란 매우 쉬울 겁니다. 더군다나 원본 소스의 내용이 짧으니 아무리 난독화 했더라도 내용을 파악하기란 그리 어렵지도 않을 것이고요.여기서 일부 독자분들은 ‘그럼 이 로직을 JNI 로 만들면 되지 않냐?’ 라고 반문하실 수도 있습니다. 하지만 JNI 로 컴파일한 .so 파일조차 objdump 같은 명령으로 내용을 다 들춰볼 수 있습니다. 특히 Kotlin 구현처럼 static const 형태로 소스코드에 적어두면 공격자 입장에서는 .data 세그먼트 만 확인하면 되죠. 그렇다면 .data 세그먼트를 회피하기 위해 로직으로 키를 생성하도록 작성했다고 해 봅시다. 좀 더 난이도가 올라가긴 하겠지만 숙련된 공격자라면 .text 세그먼트를 이 잡듯이 뒤져 실마리를 찾을 수 있을 겁니다. 물론 이 정도 수준의 역공학을 할 수 있는 사람의 수는 적지만, 아예 없지는 않으니 문제는 여전히 남아 있습니다. 한번 확인해 볼까요?static const char* SECRET_KEY = "HelloWorld!!@#$%" void encrypt(char* plainText, char* initVector, char[] result) {    char* now = malloc(sizeof(char) * 13);    itoa(&time(NULL), now, 10);    char* iv = malloc(sizeof(char) * 16);    strcpy(iv, *now);    strncpy(iv, "000", 3);   const struct AES_ctx aesCtx = { .RoundKey = 16, .Iv = *iv }    AES_init_ctx(aesCtx, SECRET_KEY);    // ... } [리스트 5] C 로 작성한 AESHelper 로직(일부).$ objdump "mySecureApp/build/obj/local/armeabi-v7a/libAESHelper.so" section .data    # 의미 불명의 문자열 발견! 혹시 key 는 아닐까???    00000200 db "HelloWorld!!@#$%", 16    00000210 equ $ - 00000200 section .text    global _start    _start:    # ...        mov rsi, 00000200  # 이 명령 앞뒤로 조사해보면 저 문자열의 용도를 파악할 수 있다.        mov rdx, 00000210        syscall    # ... [리스트 6] ARM EABI V7용으로 컴파일한 바이너리를 디스어셈블 한 결과.즉, 어떤 방식으로 구현하건 암호화에 쓸 키를 소스 코드에 박아두는 것은 그다지 현명한 선택이 아니란 것입니다. 더군다나 안드로이드에서 앱을 만든다는 것은 내 로직이 공격자에게 낱낱이 까발려져 있다는 것을 의미합니다. 중요한 데이터를 .text 에 들어가도록 숨기는 것도 가능하긴 하지만, 그런 방식은 나중에 유지보수하는 사람에게도 골치 아플 겁니다. 소스 코드가 그만큼 어려워질 테니까요. 그리고 그런 방식으로 정보를 숨긴다 하더라도 최정예 크래커 집단, 예를 들어 국정원 같은 수준이라면 그 정도는 큰 어려움 없이 파훼 가능합니다.꿈도 희망도 없는 상황처럼 보입니다만 다행히도 안드로이드는 이런 문제를 해결해 주는 KeyStore API 를 제공하고 있습니다.KeyStore 를 도입하자Android KeyStore 시스템 문서의 첫 머리에 적혀있는 글은 다음과 같습니다.The Android Keystore system lets you store cryptographic keys in a container to make it more difficult to extract from the device. Once keys are in the keystore, they can be used for cryptographic operations with the key material remaining non-exportable. Moreover, it offers facilities to restrict when and how keys can be used, such as requiring user authentication for key use or restricting keys to be used only in certain cryptographic modes.Android Keystore 시스템은 암호화 키를 ‘컨테이너’ 에 저장하도록 해 기기에서 키를 추출하기 더욱 어렵게 해 줍니다. 일단 키를 Keystore 에 저장하면 키를 추출 불가능한 상태로 암호화에 사용할 수 있습니다. 또한 Keystore 는 키 사용 시기와 방법(예: 사용자 인증 등의 상황)을 통제하고, 특정 암호화에서만 키를 사용하도록 허용하는 기능도 제공합니다.좀더 쉽게 다시 설명하자면, 암호화에 쓸 키를 소스코드 내부 어딘가가 아니라, 시스템만이 접근 가능한 어딘가(컨테이너)에 저장해 문제를 해결해 준다는 뜻입니다. 여기서 키가 저장되는 ‘컨테이너’ 는 기기별로 구현이 다를 수 있습니다만 핵심은 사용자 어플리케이션이 그 영역에 접근할 수 없다는 점입니다. 이 때문에 KeyStore 를 사용해서 키를 안전하게 저장할 수 있습니다.또한 앱에서 등록한 KeyStore 는 앱 삭제 시 함께 제거되므로, 똑같은 package name 으로 앱을 덮어씌우는 등의 공격으로 키를 유출할 수도 없습니다. 이는 여러 앱에서 공유하는 KeyChain 과는 다른 특성이며 기능 활성화를 위한 별도의 입력이 필요 없다는 장점이 있습니다.[그림 1] KeyChain API 사용시 나타나는 시스템 다이얼로그. 어려운 용어가 난무하는 등 사용자 경험이 그다지 좋다고 말할 수 없다.반면 Android M 이상에서는, KeyGenParameterSpec.Builder#setUserAuthenticationRequired(boolean) API 로 시스템 다이얼로그의 표시 유무를 제어할 수 있습니다.Secure SharedPreferences 구현하기앞서 설명드렸던 KeyStore 를 사용해 SharedPreferences 의 내용을 암호화하는 로직입니다. 소스 코드의 길이가 꽤 길기에, github gist 링크로 대신합니다. 독자 여러분들을 위해 최대한 쉽고 간단한 형태로 구현했으므로 필요에 맞춰 커스터마이징 하는게 좋습니다.AndroidCipherHelper.kt - KeyStore 에서 생성한 랜덤 패스워드를 이용해 입력받은 문자열을 암호화 하는 로직. IV 설정 등 귀찮은 작업을 피하기 위해 비대칭 암호화 알고리즘을 사용했다. 또한 암호화 및 복호화 과정에서 비대칭키의 Public key 로 암호화하고, Private key 로 해독하도록 구현했다. TEE 를 올바르게 구현한 기기(안드로이드 23 이상 + 메이저 하드웨어 제조사)에서 동작하는 한, 이 데이터의 내용이 유출되더라도 복호화는 오직 이 로직 내부에서만 할 수 있다.SecureSharedPreferences.kt - AndroidCipherHelper 가 문자열 위주로 암호화하므로, 모든 입력값을 문자 형태로 변환 후 입출력한다.결과 확인Secure SharedPreferences 를 실제로 구현한 뒤, 앱의 shared preferences 를 열어보면 아래와 같은 결과가 나타납니다.$ adb shell "run-as com.securecompany.secureapp cat /data/data/com.securecompany.secureapp/shared_prefs/MySecureSettings.xml" <?xml version='1.0' encoding='utf-8' standalone='yes' ?>     oh+XL/vQqAdxNzFEkKVOfcZAkP7jh92tcKpxzM6bbv9iGUk2lR7ayJsR6FZXt3rAKC+4sLVTP1cy e+NpgZ67wjoeBM4maMjXjSkovc8cO8rVVsQLqedJtW3gGOItTTCkjIQGh+TsBDjz8C3IdmNSKqGE GmBwQBoV0QuO+uO6cdPI/Gx816P0kcLmr5xsAy9XUwJeTE9947sYydiztJsgkKxuiGFLJK435pAb UhatjSFse4MpBCugHcLUVg5UXGwQcfbJuuQ/CBcmQmYb3MldNzLfOWtsQiwQJpz0J12fsYlQOBnO UnLVcND+DU17cP+Q4Cjah8VwmiY1a0shMn09Rw==         ozh8dKH+yCRSWoiW0HQtF/bWD7Aw6rfjzklT302AlTOpYmVdEiIfVoTK97bsyK1mXbwN5Qpas82Q dYgnnZl9sfY8pzyXHM0dtm88euB5vgmzljb04LClF3oRZ7Qi5ZRyK90kQ/HN/6EgYvf6zEwR7Ydg 08kJ/bde4Z5lSz+kJ79dHEpE+QAV48U0F0/yp12+xKFRNbaBLBaaWclUNF10jONPKjC3HS/aQozT 1ngQWSKzPq87B0OFExraSPDoLT8zx8ElhTgEtpBRcUwtzmSnhGvgtIUhziFpZBbdvuqAGZ+L5El1 T7H9ipEosN3Aivh/5rz9dntJe3mJvfCFdFITlA==     (Android L 이상이라고 가정할 경우)앱의 개발자조차 키를 알 수 없기 때문에, 파일을 유출하더라도 이를 깨는것은 현재로선 매우 어렵습니다. 즉, 우리 앱은 사용자 데이터를 안전하게 보호하고 있다고 자신 있게 말할 수 있습니다.AndroidKeyStore 파헤쳐보기그렇다면 어떤 방식으로 AndroidKeyStore 가 동작하고, 왜 안전한지 좀더 상세히 살펴보겠습니다.“AndroidKeyStore” 문자열의 중요성Android Keystore system 문서에 따르면 Android Keystore Service 에 접근하기 위해서는 아래와 같이 코드를 작성해야 한다고 합니다. val keyStore = java.security.KeyStore.getInstance("AndroidKeyStore") [리스트 7] Android Keystore 인스턴스 획득 방법여기서 주의할 점은 AndroidKeyStore 라는 문자열입니다. 반드시 정확한 문자열로 입력해야 합니다. 왜냐면 이는 Google 이 안드로이드의 보안 시스템을 Java Cryptography Architecture(JCA) 표준에 맞춰 구현했기 때문에 그렇습니다. 그리고 JCA 표준을 구현하면 JVM 인스턴스(안드로이드도 변형 JVM 의 일종입니다) 내에서 동작하는 모든 로직이 Security 클래스에 등록된 암호화 구현체를 사용할 수 있게 됩니다. 즉, Google 이 컨트롤 할 수 없는 서드파티 로직(우리의 앱 혹은 각종 안드로이드 오픈 소스들)에서도 Android Keystore 를 표준 Java 방식으로 사용할 수 있도록 구현했기에 이런 방식으로 호출해야 하는 겁니다.물론 안드로이드에서는 AIDL 파일을 제공받는 방식 혹은 Context#getSystemService(String) 메소드로 서비스 인스턴스를 획득할 수도 있습니다. 하지만 첫 번째 방식은 바인드된 서비스가 언제든 Kill 될 수 있다는 문제가 있습니다. 그리고 두 방식의 공통적인 문제점은 보안을 사용하는 모든 로직에 if (currentEnvironment == "Android") then... 같은 예외 처리 로직을 넣어줘야 한다는 점입니다. 전 세계 모든 오픈소스 개발자들이 안드로이드로의 포팅을 위해 그런 귀찮은 작업을 해 줘야 하는 일인데.. 그게 가능할까요?“AndroidKeyStore” JCA Provider 등록 과정앞서 AndroidKeyStore 라는 문자열의 중요성을 알아봤습니다. 그렇다면 왜 중요한지도 알아두면 좋겠죠?안드로이드는 linux 기반의 운영체제입니다. 시스템 부팅 직후 실행되는 init.rc 스크립트에서는 /system/bin/app_process 명령을 실행하는데 이 명령은 Android Runtime 위에서 실행되는 Zygote process를 초기화 합니다.Zygote 는 간단하게 설명하자면 안드로이드 앱 실행속도를 향상시키기 위한 일종의 공용 런타임 같은 것입니다. 그리고 앱이 실행되면 Zygote 에 설정된 내용이 사전에 로드되는데, 아까 언급한 초기화 과정 중에 아래와 같은 내용이 있습니다.package com.android.internal.os; /**  * Startup class for the zygote process.  *  * Pre-initializes some classes, and then waits for commands on a UNIX domain  * socket. Based on these commands, forks off child processes that inherit  * the initial state of the VM.  *  * Please see {@link ZygoteConnection.Arguments} for documentation on the  * client protocol.  *  * @hide  */ public class ZygoteInit {    private static final String TAG = "Zygote"; /**     * Register AndroidKeyStoreProvider and warm up the providers that are already registered.      *     * By doing it here we avoid that each app does it when requesting a service from the      * provider for the first time.      */     private static void warmUpJcaProviders() {        // ...        // AndroidKeyStoreProvider.install() manipulates the list of JCA providers to insert        // preferred providers. Note this is not done via security.properties as the JCA providers        // are not on the classpath in the case of, for example, raw dalvikvm runtimes.        AndroidKeyStoreProvider.install();        Log.i(TAG, "Installed AndroidKeyStoreProvider in "                 + (SystemClock.uptimeMillis() - startTime) + "ms.");        // ...    } // ... } [리스트 8] ZygoteInit.java 의 JCA provider 설치 및 속도향상 과정package android.security.keystore; /**  * A provider focused on providing JCA interfaces for the Android KeyStore. *  * @hide  */ public class AndroidKeyStoreProvider extends Provider {    public static final String PROVIDER_NAME = "AndroidKeyStore";    public AndroidKeyStoreProvider() {        super(PROVIDER_NAME, 1.0, "Android KeyStore security provider");        // ...    } /**     * Installs a new instance of this provider.     */    public static void install() {        // ....        Security.addProvider(new AndroidKeyStoreProvider());        // ...    } } [리스트 9] AndroidKeyStoreProvider.java - “AndroidKeyStore” 라는 이름의 JCA provider 등록 과정이런 일련의 과정을 거쳐 시스템에서 등록한 AndroidKeyStore 라는 이름으로 Android KeyStore 서비스에 접근할 수 있게 됩니다. 그리고 안드로이드에서 사용 가능한 KeyStore provider 들의 종류를 뽑아보면, 아래와 같은 결과가 나타납니다.// List all security providers for (Provider p : java.security.Security.getProviders()) {    System.out.println(String.format("== %s ==", p.getName()));    for (Provider.Service s : p.getServices()) {        System.out.println(String.format("- %s", s.getAlgorithm()));    } } output: == AndroidKeyStoreBCWorkaround == == AndroidOpenSSL == ... == AndroidKeyStore ==    - AndroidKeyStore     - HmacSHA256    - AES    ... [리스트 10] 안드로이드 M(6.0.1)에서 지원하는 KeyStore provider 목록(중요) AndroidKeyStore 의 Hardware 레벨 지원 여부 확인다시 Android KeyStore 시스템의 설명으로 돌아가 봅시다.Key material of Android Keystore keys is protected from extraction using two security measures:…Key material may be bound to the secure hardware (e.g., Trusted Execution Environment (TEE), Secure Element (SE)) of the Android device. When this feature is enabled for a key, its key material is never exposed outside of secure hardware.Android KeyStore 는 키의 추출을 방지하기 위해 두 가지 보안 조치를 사용합니다:…키는 안드로이드 기기의 보안 하드웨어(e.g., Trusted Execution Environment (TEE), Secure Element (SE)) 에서만 동작할 수 있습니다. 이 기능이 활성화되면 키는 절대로 보안 하드웨어 밖으로 노출되지 않습니다.그런가보다 싶지만 유심히 읽어봐야 할 대목이 있습니다. 바로 Key material may be bound to … 부분입니다. is 가 아니라 may be 랍니다. 즉, 키가 하드웨어에 저장되지 않을 수도 있다는 사실입니다. 물론 문서에는 언급되어 있지 않지만 안드로이드 시스템 특징상 제조원가 절감을 위해 디바이스 제조사들이 KeyStore 를 소프트웨어로 구현할 수도 있다는 뜻입니다. AOSP 의 Keymaster 구현을 살펴보면 sw_enforced 라는 키워드가 있습니다. 이 keymaster API 를 하드웨어 제조사에서 Keymaster HAL 을 통해 호출하는데 만약 sw_enforced 인스턴스를 넘기는 형태로 구현할 경우 그 하드웨어는 KeyStore 를 지원하지만 (API Level 18), 그것이 반드시 별도의 보안 하드웨어 위에서 동작한다고 말할 수는 없습니다.그리고 “Inside Android Security” 의 저자 Nicolay Elenkov 에 의하면 Android M 이전의 Software-backed KeyStore 는 root 된 기기에서 유출 가능하다고 합니다. 링크의 내용이 다소 길기 때문에 요약하자면 software 기반의 KeyStore 구현은 키를 /data/misc/keystore/user_X(여기서 X 는 uid - 시스템이 앱마다 부여하는 id)에 저장하는데 이 파일의 내용은 keystore-decryptor 로 풀어볼 수 있다고 합니다. 그리고 하드웨어 보안을 지원하지 않는 기기를 확보하지 못해 실 기기에서는 확인할 수 없었습니다만, 에뮬레이터에서 실제로 확인해 본 결과 사실이었습니다.즉, (Android KeyStore)를 쓰더라도 Android M 이전의 기기에서는 우리 앱의 데이터가 100% 안전하다는 장담을 할 수는 없습니다. 아직까지 이 문제를 해결할 방법은 찾지 못했습니다만 아래와 같은 로직으로 ‘이 기기에서의 앱 실행은 안전하지 않을 수 있다’ 같은 안내를 띄우는 정도의 가이드는 개발 가능합니다.val privKey = (keyEntry as KeyStore.PrivateKeyEntry).privateKey val factory = KeyFactory.getInstance(privKey.getAlgorithm(), "AndroidKeyStore") val keyInfo: KeyInfo try {    keyInfo = factory.getKeySpec(privKey, KeyInfo::class.java)    println("HARDWARE-BACKED KEY???? " + keyInfo.isInsideSecureHardware) } catch (e: InvalidKeySpecException) {    // Not an Android KeyStore key. e.printStackTrace() } [리스트 11] KeyInfo API 로 키가 하드웨어로 안전하게 보호되고 있는지를 확인하는 방법다행히도 저희가 보유 중인 개발 시료에서 모두 확인해본 결과 모두 true 로 확인되는 것으로 보아 전 세계의 대중적인 API Level 18 이상인 Android 기기에서는 KeyStore 를 안심하고 사용할 수 있다는 결론을 얻었습니다.다만 API Level L 이전의 Android KeyStore 에는 사용자가 Lock screen 을 설정하지 않을 경우 초기화 된다거나, 직접 확인하진 못했지만 앱을 삭제하더라도 KeyStore 가 완전히 초기화되지 않는 등의 문제도 있다고 하니 유의하는 것이 좋겠습니다.맺으며이상으로 KeyStore 를 사용해 데이터를 암호화하는 방법에 대해 알아봤습니다. 저희 하이퍼커넥트에서도 현재 제작 중인 안드로이드 앱 일부에서 이 기능을 탑재해 고객 여러분들의 데이터를 안전하게 보호하려 노력하고 있습니다. 또한 iOS 도 Secure enclave라 하여 비슷한 기능을 제공하고 있으며 역시 저희 개발진은 이 기술의 적극 도입을 위한 노력을 진행 중입니다.물론 여기 적혀있는 내용들은 Android M(API Level 23) 이후에서만 100% 안전하기 때문에 저희는 그 이전의 안드로이드 버전에서도 데이터를 안전하게 저장할 방법에 대해 지금도 계속 고민 중입니다.또한 눈치 빠른 독자분들은 이 기법을 잘 응용하면 외부 저장소에 저장하는 파일도 암호화 할 수 있다는 사실을 깨달으셨을 겁니다. 이 기법은 요즘 데이터 불법 유출로 몸살을 앓고 있는 웹툰 앱들에도 유용합니다. 임시로 다운로드 한 이미지 파일을 KeyStore 가 생성해주는 키로 암호화해 버리고, WindowManager.LayoutParams#FLAG_SECURE 를 사용해 화면 캡쳐까지도 막아버린다면 대부분의 어설픈 유출 시도는 손쉽게 막으실 수 있으리라 생각합니다.꽤 길었던 1부가 끝났습니다. 2부에서는, 2017년 5월에 소개된 Room을 사용한 안드로이드 데이터베이스를 암호화하는 법에 대해 소개하겠습니다.더 보기Android KeyStore 시스템블록 암호 운용 방식초기화 벡터문자 인코딩AES 암호화RSA 암호화Padding(Cryptography)AOSP KeyStore implementation requirementsHow the Android keystore system can be secureJCA reference guideUnderstanding Android zygote and DalvikVMAndroid InternalsKeystore redesign in Android M - by Nicolay ElenkovAnalysis of Secure Key Storage Solutions on Android#하이퍼커넥트 #개발 #개발자 #안드로이드 #모바일 #앱개발 #PersistentData #개발후기 #인사이트
조회수 1968

Golang 체험기

AWS EC2 태그를 Kubernetes Label로 뽑아주는 Vungle/Labelgun에 문제가 많아서 이번에 대대적인 수술을 하였다. 하루에 수백번씩 Pod가 죽는 통에 도저히 참을 수가 없었다. 아무튼 이와 관련한 이야기는 다른 글에서 썰을 풀고 여기서는 Go에 초점을 맞추고 경험담을 늘어놓아볼까 한다.장점기술 탐색 — golang이란 글에서는 주로 부정적인 견해를 보였지만 최근에는 생각이 바뀌었다. 무엇보다 Docker와 같은 컨테이너 기반 서비스에는 Golang과 같은 언어가 Java 또는 Python 같은 언어보다 분명 장점이 있다. 미리 빌드한 바이너리 파일만 컨테이너에 넣으면 되기 때문에 가볍다. Java Runtime을 컨테이너에 넣을 때보다 월등히 가볍다. 여기서 가볍다 함은 컴퓨팅 리소스 측면, 컨테이너 빌드 구성의 용이함 모두를 뜻한다. 물론 전통적인 C/C++ 환경도 비슷하지 않냐라고 의문을 품는 사람도 있겠지만 Golang은 goroutine등으로 동시성 제어를 런타임 시스템이 알아서 제어해주기 때문에 언제든 머신을 갈아치울 수 있는 클라우드 환경에 훨씬 적합하다. 그 외에도 현대적인 언어의 여러 장점을 누릴 수 있는데 이는 다른 글이 훨씬 잘 설명해놓았기에 자세한 언급은 하지 않으려 한다.GOPATH 를 처음 여행하는 GOPHER 들을 위한 GOLANG 안내서단점Application Performance Monitoring을 구축하기가 생각보다 어렵다. New Relic과 DataDog Trace 모두 개발자가 코드를 상당량 추가해줘야 한다. 보통 에이전트만 붙이면 알아서 잘 작동하는 Java APM에 비해 상당히 과거의 방식이다.func saveFile(ctx context.Context, path string, r io.Reader) error { // Start a new span that is the child of the span stored in the context. If the span // has no context, it will return an empty one. span := tracer.NewChildSpanFromContext("filestore.saveFile", ctx) defer span.Finish() // save the file contents. file, err := os.Create(path) if err != nil { span.SetError(err) return err } defer file.Close() _, err = io.Copy(file, r) span.SetError(err) return err }소스코드를 바이너리 코드로 컴파일하기 때문에 빌드 및 테스트 피드백 주기가 길다. C++을 한참 다루던 시절로 돌아간 느낌이다. 한마디로 답답하다.게다가 npm과 같은 패키지 관리 시스템이 없고 Git과 같은 소스버전관리시스템을 바로 접근해 사용하기 때문에 초기 빌드가 엄청나게 느리다. Git clone 보다는 이미 잘 패키징된 파일 몇 개를 다운로드 받는 쪽이 월등히 빠를 수밖에 없지 않나?패키지 관리 시스템과 더불어 빌드와 관련해 그 존재가 매우 의심쩍은 게 하나 있으니 바로 GOPATH이다. Python의 virtualenv처럼 프로젝트별로 완전히 고립된 개발환경을 갖추면 여러 모로 장점이 많은데 왜 이런 환경변수가 존재해야 하는가? 왜? 대체 왜?마지막으로 한가지 더. Go는 goroutine 등으로 병렬작업을 지원하여 분명 편하다. 하지만 순수한 함수형 언어가 아니고 Immutable한 데이터를 메시지 패싱하는 방식이 아니기 때문에 애먹는 부분이 많다. goroutine과 channel을 장점으로 내세우는만큼 최소한 표준 라이브러리는 동시성을 최대한 고려해서 설계했을 법한데 그렇지 않은 부분이 많아서 당혹스러웠다. 물론 이러한 설계는 그만한 장점이 있지만 한동안 유행하던 다수의 언어와는 방향이 달라서 다소 적응하기 힘들었다.#데일리 #데일리호텔 #개발 #개발자 #개발팀 #스킬스택 #기술스택 #스택도입기 #후기 #golang
조회수 1863

Genius? Jininus!

나는 인생을 살면서 많은 "천재"들을 만났다. 스타트업에 있다보면 더더욱 "영재""천재"로 불리는 수 많은 사람들을 보게 된다. 그들은 학문적으로 놀라운 성과와 스펙을 보유하고 있었다. 아마 당신이 한 회사를 운영하는 사람이거나 인사 담당자라면 분명 혹할 것이다. 하지만 정작 나는 같이 일하고 싶었던 사람이 단 한 명도 없었다. 주변에서는 천재들과 같이 일하면 성공할 것이라고 생각하지만, 사업적 결과물과 두뇌는 별개의 문제라고 나는 생각한다. 대단한 능력을 가지고도 빛 없이 사라진 사람들을 얼마나 많이 보았는가. 물론 나도 대단한 사람과 일하고 싶다. 그러나 그 기준을 "영특함"에 국한시키고 싶지는 않다. 사업적으로 혹은 사회적으로 더 나은 미래를 후손에 물려주기 위해서는 그 이상의 "무언가"가 필요하다. 지금부터 나에게 그 "무언가"를 가르쳐 준 "진짜 천재"에 대한 이야기를 하고자 한다. 그에 대한 이야기를 하기 전에 나에 대한 이야기를 가볍게 하고자 한다. 5년 전만 해도 나는 비전과 목표가 없었다. 어려서 부터 돈 욕심만 많았다. 대학교를 다니면서도 돈을 벌 수 있는 방법이면 수단과 방법을 가리지 않았다. 한 일화로 당시에 학원 강사 아르바이트를 하고 있었는데 도매시장에서 트렌디한 문구류를 사와 수업을 가르쳤던 중/고등학생에게 팔았다. 시간과 행동에 제약이 있는 학생들은 수업 시간에 벌어지는 소소한 쇼핑에 돈을 지불했다. 그러나 끝이 좋지 않았다. 학생의 부모님에게 알려져 결국 학원에서 해고 조치 되었다. 지금의 내가 돌이켜보면 엄청나게 창피한 일이다. 학생들에게 단순한 편리와 재미를 줄 순 있었지만, 돈 말고는 남는게 없었다.20대의 대부분은 가치 없는 돈벌이의 연속이었다. 혹자는 말한다. 우선 돈 벌고 가치 있는 곳에 쓰면 된다고. 그러나 이런 식의 무의미한 접근은 내가 가야할 길이 아니라고 느꼈다. 인생에서 가치 있는 일을 찾아야 했다. 그때 발견했다. 혁신, 도전, 열정이 정말 실천되고 있는 세계가 있다는 것을. 스타트업이라는 단어조차 생소했던 시기였다. 심지어 IT라는 분야를 그 전까지 제대로 공부해 본 적도 없었다. 스타트업의 "ㅅ"도 모르던 내가 이 세계에 적응할 수 있는 방법은 뛰어난 사람들과 함께 시작하는 것 뿐 이었다. 온갖 미사여구로 괜찮은 연봉과 복지를 내세우는 기업도 꽤 있었다. 그러나 나에게 가장 중요한 건 "내가 성장할 수 있는지"와 “구성원”이였다. 꽤나 당연한 조건으로 기업을 찾았음에도 불구하고 찾을 수가 없었다. 그러다가 첫 스타트업으로 선택한 게 라우드소싱 이라는 작은 팀이었다. (찾게 된 과정에 대해서는 다른 글을 통해 소개하겠다) 안정적인 연봉도 없고, 확실한 미래도 없었지만 내가 이 팀과 같이 해야겠다 결정한 건 "권진" 이라는 단 한 사람 때문이었다. 모든 기업이 그렇지만 누구나 회사에 합류하면 3개월간의 수습기간을 거친다. 스타트업이라고 예외는 아니다. 오히려 더 냉정하게 자신을 되돌아 보는 시간을 가져야 한다. 나는 내 스스로를 입증하고 싶었다. “제가 3달 안에 이 회사가 성장할 수 있는 계약들을 가져오겠습니다. 그 정도 능력도 발휘 못한다면 제 발로 나가겠습니다” 3달 동안 권진은 일에 대해서 전혀 간섭하지 않았다 . 팀워크에 있어서 가장 중요한 부분은 신뢰라고 생각한다. 하지만 신뢰라는 부분이 친하다고 해서 혹은 비전과 목표가 같다고 해서 생기는 것이 아니다. 각자의 위치에서 최고의 성과를 목표로 내고, 한계를 뛰어넘어 성장하는 모습을 보여줄 때 강력한 신뢰가 생긴다. 서로가 같이 일하고 싶은 마음을 만들어 주는 것.이게 팀워크의 핵심이다. 나는 나대로 권진은 권진대로 각자가 맡은 일들을 완벽하게 수행했고, 우리는 그 일들을 하나의 사업으로 만들어 갔다. 그는 나에게 따로 주저리 주저리 피드백을 하지 않았다. 하지만 행동으로 결과물의 중요성을 보여주었고, 나는 3달동안 7건의 B2B 계약을 성사시켰다.애초에 같이 할 사람을 정할 때는 모든 부분을 면밀히 살피고 고민해야 하지만, 내가 같이 하기로 결정 했다면 상대가 최고의 결과물을 낼 수 있도록 믿어주는 것. 내가 배운 첫번째 교훈이었다.실력을 보여주었다고 환상적인 Fit일까? 누구든 본인이 만들어 내는 결과물을 혼자만의 능력이라고 오판하기 쉽다. 내가 영업처를 설득하고, 계약서를 체결해 왔기 때문에 내가 없었으면 이 계약도 없었을 것이다. 감각적이고 환상적인 디자인을 뽑아냈는데 이건 순전히 나의 재능에 의한 것이다. 팀원들이 이런 생각들을 하기 시작한다면 그 팀은 단시간 내에 모래성처럼 무너질 것이다. 권진은 개인이 만들어 내는 결과물도 팀원들이 각자의 분야에서 해 온 노력들의 최종산출물이라고 생각한다.영업처를 설득할 수 있었던 건, 우리 팀이 환상적인 서비스를 만들어 주었기 때문이다.나의 디자인은 기획팀과 마케팅팀의 노력을 하나로 담은 것 뿐이다.톱니바퀴처럼 팀원들이 맞물려 돌아가며 서로의 존재에 대해 감사함을 느낄 때 놀라운 일이 벌어진다. 내가 배운 두번째 교훈이다.권진이 지켜온 2가지 요건이 계속 좋은 사람을 팀으로 영입할 수 있었던 강력한 요소였다고 생각한다. 나의 실력을 우리 팀에 입증하는 것. 나의 결과물은 우리 팀 노력의 산물 이라는 것.권진과 함께 일하며 느낀 그의 주요한 능력은 개발도 디자인도 아니었다. (물론 이 2가지도 잘한다)팀 내의 균형을 맞추고 팀원들이 끊임없이 성장하게 도와주는데 있다. 개성 넘치는 팀원들을 하나의 비전으로 묶어서 성장할 수 있게 하는 사람을 나는 살면서 권진 이외에는 아직 본 적이 없다. 장담컨데, 만약 현재 더팀스 대표가 권진이 아니라 다른 사람으로 바뀐다면 팀원들은 전부 팀을 나갈 것이다. (연봉이 대폭 인상된다 할지라도)그래서 나는 이걸 Jin in Us 라고 명칭했다. 권진이라는 확실한 구심점 안에 개성넘치는 팀원들이 한 몸처럼 목표로 향해가는. 나는 앞으로 대표라는 역할을 할 생각이 없다. 권진 이라는 사람보다 대표의 역할을 충실히 수행할 자신이 없어졌기 때문이다.리더십이라는 분야가 있다면 그는 천재가 아닐까?내가 우리 팀에 합류시키고 싶은 사람이 있을 때면 하는 단골멘트로 이 글의 마무리를 짓는다.“우리 팀의 권진을 만나보세요. 분명히 함께 하고 싶을 겁니다”#더팀스 #THETEAMS #천재디자이너 #풀스택개발자 #CEO #리더십 #경험공유 #팀원자랑 #팀원소개 #회사의자랑
조회수 1079

Jeykll에서 플러그인 없이 sitemape 생성하기

오늘은 구글에서 블로그를 검색할 수 있도록 설정하는데에서 크게 삽질했다.. 구글 웹마스터에 사이트맵을 등록해야 했는데 그 사이트맵이 자꾸 테스트를 통과못해서 3시간이나 삽질했다.. ㅠㅠ계속 삽질하다가 찾은 이유는.. _config.yml 파일에 url 속성이 없어서 url을 가져오지 못해 생긴 문제였다. ㅠㅠ 정말 허무하고 신나고.. 아무튼 모든 문제를 해결하여 성공적으로 완료했으니 그 방법에 대해 정리하도록 하겠음.참고한 블로그: 스우의 게임서버와 클라이언트! 미친듯이 영어 검색어들로 오류를 찾으며 삽질했었는데 의외로 한글 블로그에서 이 부분에 대해 언급되어 있어 해결할 수 있었다. 감사합니다 ㅠㅠsitemap 생성하기1. sitemap.xml 파일 생성블로그의 root 디렉토리에 sitemap.xml 파일 생성.2. sitemap.xml 파일 작성하단의 코드를 복사하여 만들어준 sitemap.xml 파일에 붙여넣기.            3. url 설정추가_config.yml 파일에 url 설정이 없는 경우 url 설정을 추가하여 sitemap.xml에서 site.url 변수값을 사용할 수 있도록 해줌. (이 부분 때문에 무한 삽질 ㅠㅠ)4. 구글 웹마스터 툴에서 테스트 혹은 제출구글 웹마스터 툴에서 테스트 혹은 제출을 통해 만들어준 sitemap이 제대로 동작하는지 확인.여태 GA나 기타 여러가지를 설정하느라 공개하지 않았는데 이제서야 공개합니다.제 블로그는 https://heelog.github.io/about/ 입니다!#트레바리 #개발자 #안드로이드 #앱개발 #Jeykll #백엔드 #인사이트 #경험공유

기업문화 엿볼 때, 더팀스

로그인

/