스토리 홈

인터뷰

피드

뉴스

조회수 1552

도도 파이터 제작기

안녕하세요. 도도 파이터의 개발과 시각 디자인을 각각 담당한 스포카 크리에이터 박준규, 박지선입니다.우선, 도도 파이터에 관심 가져주시고 참여해 주신 분들께 감사의 말씀을 드립니다. 도도 파이터는 저희의 당초 예상을 훨씬 뛰어넘는 71명의 제출로 마무리되었습니다. 많은 분의 참여 덕분에 이벤트를 무사히 마칠 수 있었다고 생각합니다.이 글에서는 도도 파이터의 기획 의도와 제작과정, 기술적인 디테일에 대해서 다루어 보려고 합니다.기획 의도저희는 파이콘 한국에 2015, 2016년에 이어 이번 2018년까지 총 세 차례 후원사로 참여하였습니다. 저희는 매번 코딩 컨테스트를 열고 있는데 2015년에는 코드 골프1, 2016년에 코드 난독화2이벤트를 개최했습니다. 저희는 지난 이벤트들을 통해 파이콘 참가자들에게 오락거리를 제공하면서 재능을 발굴할 수 있었습니다그동안 다른 후원사들도 여러 가지 훌륭한 코딩 컨테스트를 열었습니다. 저희들은 이에 고무되어 2018년 파이콘 한국 참가를 결정하면서 새로운 코딩 컨테스트 이벤트를 만들어 보기로 했습니다.저희는 이번 코딩 컨테스트의 목표를 아래 세 가지로 잡았습니다.바이럴 효과가 있을 것사람의 눈을 사로잡을 수 있어야 할 것접근성 있고 직관적인 규칙을 제공할 것위의 점들을 고려해 봤을 때 인공지능 대전 격투게임의 아이디어는 비교적 자연스럽게 도출되었다고 생각합니다.유저 대 유저가 직접 경쟁하는 방식은 코드 골프나 난독화처럼 주최 측이 취합해서 평가하는 방식보다 훨씬 버즈를 만들기 쉽습니다.대전 격투 게임이라는 틀은 30년 넘는 세월 동안 거의 그대로 유지되어 왔기 때문에 수많은 사람들에게 익숙합니다. 그리고 두 사람의 대결을 가장 직관적으로 표현할 수 있는 포맷입니다.게다가 저희는 귀여운 마스코트 캐릭터도 가지고 있습니다. 귀여운 마스코트 캐릭터들이 투닥투닥 싸우는 모습을 누가 그냥 지나칠 수 있을까요.익숙한 장르이기 때문에 게임의 규칙 역시 큰 틀을 잡는 데 어려움이 없습니다.이런저런 다른 후보들도 있었지만 이러한 이유로 격투 게임을 만들자는 합의에 다다랐습니다.게임 디자인하지만 격투 게임은 직관적으로 보이는 외양에 비해 파고들기 굉장히 복잡합니다. 현존하는 대전격투 게임들은 수많은 캐릭터가 등장하고 캐릭터별 성능 차이와 상성 관계가 존재하며 대응 전략도 전부 제각각이기 때문입니다. 저희는 이러한 요소를 전부 배제하기로 했습니다. 그런 것들이 대전격투 게임의 본질을 관통하는 특성은 아니기 때문입니다. 그것들을 전부 벗겨내면 남는 본질은 심리전입니다. 상대방의 플레이 전략을 파악한 뒤에 정보를 취합하여 액션을 취하는 것이 대전격투 게임의 알파이자 오메가입니다. 저희는 이 게임을 턴제로 설계했는데, 보통 실시간으로 이루어지는 대전격투 게임을 턴제로 설계해도 말이 되는 이유가 여기에 있다고 생각합니다. 턴제로 만들어도 대전격투 게임의 본질이 심리전이라는 대전제가 깨지지 않기 때문입니다. 저희는 인공지능 대전으로 심리전의 특징을 살릴 수 있을 거라 보았습니다.여러 가지 시스템을 고려했으나 게임 디자인은 최소화된 형태로 수렴했습니다.플레이어는 뒤 또는 앞으로 한 칸씩 움직일 수 있다.공격 방식은 펀치와 킥이 있는데, 펀치는 숙여서 피할 수 있고 킥은 점프해서 피할 수 있다.심리전이 성립하기 위해서는 최소한의 상성 관계가 만족되어야 합니다.상대방의 공격을 무조건 맞는 대신 받는 데미지를 절반으로 줄이는 방어 액션이 있다.때로는 리스크를 지지 않는 안전한 선택지도 제공하면 좋을 것입니다.그 외에 게임 디자인 과정에서 여러 가지 시행착오가 있었습니다.처음에는 캐릭터를 움직인다는 개념이 없었습니다. 두 캐릭터들이 같은 위치에 서서 싸운다기보다는 가위바위보를 하는 모양에 가까웠습니다. 그래서 캐릭터 이동 액션을 추가했습니다.그런데 스테이지 크기에 제한이 없었습니다. 플레이어가 무한히 뒤로 갈 수 있었는데 한 대 때린 뒤에 끝날 때까지 뒤로 도망가는 파훼가 불가능한 전략을 쓸 수 있었습니다. 스테이지 크기에 제한을 두는 방식으로 해결했습니다.원거리 공격, 대쉬, 필살기 등등 여러 가지 세부적인 시스템을 고려했으나 시스템이 지나치게 복잡해질 것 같았고 무엇보다 제때 밸런스를 조정할 자신이 없어서 포기했습니다.시스템을 이렇게 만들어 보니 상대가 근접하면 가만히 서서 공격만 하는 에이전트가 승리할 확률이 가장 높았습니다. 이를 방지하기 위해 최근 다섯 턴 간 취한 액션이 한 종류라면 데미지가 1/3, 두 종류라면 2/3만 들어가도록 페널티를 주었습니다.이 조치만으로는 방어/회피 없이 공격만 해도 이기는 문제를 해결하지는 못합니다. 따라서 방어/회피에 성공할수록 다음 번의 공격력이 강해지는 시스템을 추가하여 적극적으로 방어/회피를 하도록 유도하였습니다.저희는 데미지 계산 공식을 공개하는 것을 주저했는데, 구체적인 공식을 공개하면 제출물의 성향이 한쪽으로 쏠릴 것을 염려했기 때문입니다. 저희는 최대한 창의적인 솔루션이 많이 나오길 바랐습니다. 하지만 지금 돌이켜보면 구체적인 수치를 공개한다고 크게 바뀔 것이 있었나 싶기도 합니다.시각 디자인처음엔 격투 게임이라는 설정만 있었지만, 시각적으로 풍부하게 표현하기 위해 더 디테일한 기획이 필요했습니다. 그리하여 도도 파이터 만의 세계관을 만들어 풀어보기로 했습니다. 설정을 초반에 정하고 나니 캐릭터부터 모든 디자인이 술술 풀려갔습니다. 왜 게임을 만들 때 초반에 세계관과 시놉시스를 세세히 기획하는지 알겠더군요.원래 실제 도도새는 마다가스카르 동쪽에 있는 모리셔스 섬 해안가에 주로 서식한 것으로 추정된다고 합니다. 모리셔스 섬에 도도새가 모여 마을을 이루고 있는 모습을 상상했고, 그곳을 배경으로 도도 파이터가 펼쳐집니다.야자수, 뜨거운 햇빛, 맑은 바다. 그리고 자영업자가 많은 평화로운 도도 포인트 마을. 손님을 위해 더 좋은 매장을 운영하려면 체력은 필수. 각자의 방식으로 체력을 기르던 매장 사장님들이 최고의 체력왕을 고르기 위해 도도 파이터라는 대회를 개최하게 됩니다. 과연 체력왕 사장님은 누가 될까요?노을이 아름다운 모리셔스 섬에 숨겨진 도도 포인트 마을Lean하게 캐릭터 디자인하기짧은 시간 내 게임을 완성하기 위해서 그래픽 리소스 제작 비용을 줄여야 했습니다. (인력 서포트도 있었습니다3) 기존에 잘 정리되어 있는 디자인 리소스들은 이런 상황에서 특히나 빛을 발합니다. 파이터는 포포(도도새 캐릭터)로 한정하고 동작 디자인은 거의 통일하기로 했습니다. 또한, 게임 특성을 고려해 기존에 디자인되어 있던 반측면 조형만을 활용했습니다.다만 사용자간 구분이 필요하기에 각 캐릭터별 특색을 넣었습니다. 게임에 등장할 포포들은 매장 사장님이므로 격투게임에 등장하면 흥미로울 만한 업종에 계신(?) 포포만을 모셨습니다. 그리고 각 업종에 어울리는 패션 아이템과 구별되는 성격을 배합해서 총 3종의 캐릭터를 완성했습니다.도도 파이터 대회에 참가한 포포 사장님들스시 장인 포포: 철두철미한 성격으로 묵직하고 독특한 풍미의 시그니처 스시를 주 무기로 사용합니다.학원 원장 포포: 성실히 학생들을 지도하며 평소에 칠판 지우개로 팔근육을 단련해왔습니다.볼링장 사장 포포: 걱정이 많지만 볼링을 사랑하며 즐깁니다.도도 파이터에서 캐릭터는 총 9가지의 액션을 취할 수 있습니다. 기본 틀은 동일하지만 캐릭터별 특색을 넣는 것만으로도 단조로움을 없앨 수 있었습니다. 공격하는 무기는 잔인하기 보다는 귀엽고 웃긴 방향으로 해 산뜻한 분위기가 되도록 했습니다. 만약 스시 장인 포포가 칼을 들고 있었다면 게임 분위기가 살벌했을 것입니다.캐릭터들의 다양한 모습구현 상세서버서버는 아래의 소프트웨어 스택을 사용하여 구현하였습니다.파이썬 3.6Flask 웹 프레임워크PostgreSQL 데이터베이스SQLAlchemy 데이터베이스 라이브러리그 외에 설정 관리에는 settei, 데이터베이스 마이그레이션은 alembic 등 여러 오픈 소스 프로젝트를 사용하고 있습니다.이상은 스포카에서 사실상 표준으로 사용하고 있는 소프트웨어 스택이기 때문에 스포카 개발팀이 비교적 능숙하게 사용할 수 있습니다. 덕분에 3~4주 남짓한 짧은 기간 안에 완료할 수 있었습니다. 개발 당시의 급박한 상태가 그대로 드러나는 퀄리티긴 하지만, 소스 코드는 여기에서 받으실 수 있습니다. PR이나 버그 보고는 두손 두발 다 들고 환영합니다.프론트엔드게임의 프론트엔드는 Unity 엔진을 사용하여 개발하였습니다. Unity는 WebGL 타겟 빌드를 지원하는데, 이를 통해 웹 브라우저 위에서 실행가능한 WebAssembly 바이너리로 빌드할 수 있습니다.매칭 기록을 재생해주기만 하면 되는 간단한 부분이기 때문에 처음에는 런타임 바이너리 용량만 수 메가바이트에 달하는 거대한 게임 엔진을 쓰는 것이 내키지 않았습니다. HTML5 Canvas를 직접 써서 만들까 했지만, 생각보다 손이 많이 가고 제때 끝낼 자신이 없었습니다. 다행히 Unity로는 빠른 작업이 가능했고 절약한 시간만큼 애니메이션 효과와 시각적 완성도에 조금 더 시간을 투자할 수 있었습니다. 빌드 용량이 크긴 했지만, 결과적으로는 좋은 결정이었다고 생각합니다.배포 인프라도도 파이터는 Docker로 빌드되며, 스포카의 프로덕션 서비스에 사용되고 있는 AWS ECS 클러스터 위에 배포됩니다. 기존 인프라를 활용하여 추가적인 지출을 최소화할 수 있었습니다.지금에서야 말할 수 있는 사실이지만 도도 파이터는 파이콘 행사 중에도 미완성 상태였습니다. 여러분들이 도도 파이터에 참가하고 계신 와중에도 개발자는 부스 한구석에서 부리나케 작업을 하고 있었습니다. 급박한 과정에서 Docker와 ECS가 있었기에 빠른 배포가 가능했습니다.샌드박싱웹 앱 위에서 임의의 파이썬 코드를 실행을 허용하면 필연적으로 공격의 위협에 노출됩니다. 따라서 저희는 악의적인 코드가 실행되지 않도록 하는데 많은 노력을 했습니다.에이전트 스크립트는 메인 서버 프로세스와 격리되어 실행됩니다. 이때subprocess모듈을 사용합니다.스크립트는 바로 실행되지 않고 러너 안에서 실행됩니다.이때 러너에서는 스크립트가 다른 파일을 열지 못하도록__builtins__.open()함수를 지웁니다.러너 프로세스는 제한된 유저 권한으로 실행됩니다. 혹여나 다른 파일을 불러올 수 있는 가능성을 OS 레벨에서 차단합니다.보안상의 이유로 에이전트는 허용된 모듈만 불러올 수 있습니다. 러너에서는 스크립트의추상 구문 트리를 분석하여 허용되지 않은 모듈을 불러오는지를 검사합니다. 이때ast모듈을 사용합니다.러너가 참조하는 모듈을 에이전트 안에서 참조하지 못하도록sys.modules를 비웁니다.실수 또는 DoS로 스크립트가 무한 루프를 도는 상황을 방지하기 위하여 3초가 지나도 스크립트가 완료되지 않으면 프로세스를 강제로 종료하는 역할도 합니다.서버는 Docker 컨테이너 안에서 격리되어 실행됩니다. 만약 잘못된 코드로 인해서 서버가 죽는 상황이 생기면 ECS 클러스터가 자동으로 복원해 줍니다.가장 마지막으로, 모든 실행되는 코드는 기록을 남깁니다. 만에 하나 이 모든 보호 조치들을 우회한다고 하더라도 어떤 GitHub 아이디로 로그인해서 무슨 코드를 실행시켰는지 기록을 남겨서 사후에 추적할 수 있도록 하였습니다.느낀 점들무엇보다 대회 진행에 아쉬움이 진하게 남습니다. 참가자들을 여러 조로 나눈 것은 수시로 조를 배정하고 결승전 이전에 조별 우승자를 미리 선정하기 위함이었는데, 결과적으로 최종 제출 기한이 끝난 뒤에 조가 배정되고 결승 중계 현장에서 조별 우승자가 정해졌습니다. 이로 인해 결승 중계 진행이 많이 늘어졌던 것 같아서 아쉽습니다.참가자와의 소통을 위한 피드백 창구가 없었던 점 또한 아쉽습니다. 몇몇 참가자 분들께서는 직접 부스로 찾아오셔서 문의하시기도 했습니다. 생각하지 않은 것은 아니었는데 다른 시급한 작업이 우선이라 엄두를 내지 못했습니다.예상보다 참가자들이 많아서 결승전 중계 때는 시간이 많이 밀렸습니다. 플레이백 속도를 조절할 수 있는 기능을 넣었어야 했다는 아쉬움도 남네요.처음에 우려했던 밸런스가 붕괴하는 상황은 다행히 발견되지 않았습니다. 승리에 유리한 전략은 어느 정도 경향성이 있는 것으로 보이나 게임의 밸런스가 망가진 수준까진 아니라고 판단하고 있습니다.마치며여기까지가 장장 4주에 달하는 도도 파이터의 제작 후기였습니다. 후속 포스팅에서 이번 파이콘 한국 2018 세션에서 제출된 출품작들을 분석하고 어떤 참신한 코드가 있었는지를 알아보도록 하겠습니다. 읽어주셔서 감사합니다.특정 목적을 달성하는 프로그램을 가장 짧은 길이로 작성하여 겨루는 경쟁 게임입니다. ↩창의력을 동원하여 어떤 목적을 달성하는 코드를 가장 알아보기 어렵게 작성하는 경쟁 게임입니다. ↩디자인 서포트를 해주신 안정빈 디자이너에게도 감사를 표합니다. ↩#스포카 #기업문화 #조직문화 #개발자 #개발팀 #프로젝트 #후기 #일지
조회수 1618

Java의 json 라이브러리 google-gson

문제 상황안드로이드 어플리케이션을 개발하다 보면 주소록을 다루는 일이 종종 있습니다. 어플리케이션에서 주소록에 관련된 정보를 접근할 일이 있는 어플이라면 ContentResolver를 통해 단말의 주소록에 접근해서 필요한 정보를 가져오게 됩니다.그런데, 최근 개발하고 있는 스포카 어플을 통해 아주 많은 사람의 연락처가 저장된 주소록을 가지고 이런 저런 로직을 실행하는 상황을 테스트 하다보니, OutOfMemory(OOM)에러가 발생하는 현상을 볼 수 있었습니다. 모바일 디바이스들은 PC와 다르게 자원이 제한적이기 때문에 어떻게 하면 OOM을 일으키지 않을 수 있을까 라는 고민을 해야 하는 상황이었습니다.대강 문제가 되었던 클라이언트 사이드의 로직을 살펴보면 이렇습니다.단말의 주소록에 접근하여 필요한 정보를 추출 후 서버에 전송서버에서 정보를 가공하여 필요한 json 문자열을 생성 후 반환, 이 문자열은 주소록에서 보낸 정보의 양에 비례해서 늘어나게 됩니다.클라이언트 측에서 서버 측에서 보낸 json 문자열을 이용하여 JSONObject객체를 만든 후 이 JSONObject를 이용 리스트 완성eclipse의 MAT(Memory Analyzer)을 이용하여 어느 시점에서 OOM이 일어나는지를 추측해보았습니다. 서버에서 보내준 json형식의 문자열을 HttpURLConnection을 통해 전달받고 이를 StringBuilder를 이용하여 완전한 문자열으로 만들던 도중에 OOM이 일어나는 것으로 의심되었는데 이 때문에 JSONObject의 생성자에 json 문자열을 전달하기도 전에 메모리가 가득 차 버리니 매우 난감한 상황이었습니다.대게 주소록에 사람이 그렇게 많지 않으므로 (200~500명 정도) 아무런 문제가 없었지만 10000명 정도의 더미데이터를 주소록에 저장하고 테스트하다 보니 append 메서드를 호출하다 OOM에러를 뱉으면서 어플이 종료되었습니다. 문제는 append 메서드를 호출 시 StringBuilder의 capacity를 넘을 경우 내부적으로는 메모리 재할당과 copy과정이 일어난다는 것이었습니다. 그렇다고 초기 StringBuilder생성시 capacity를 무작정 높게 잡기도 애매한 상황이었습니다.gsongson은 Java객체를 json형식으로 변환하고 그 역으로도 변환할 수 있도록 도와주는 라이브러리입니다. gson의 사용법이 궁금하다면 gson user guide를 읽어보면 되고 api가 궁금하다면 gson api document를 참조하면 됩니다.gson 적용대략 이런 방식으로 프로젝트에 gson라이브러리를 적용하였고, HttpURLConnection을 통해 받아온 InputStream을 이용 바로 객체를 생성할 수 있었습니다. 이전에 StringBuilder를 이용할때 생기는 오버헤드가 사라진 셈이죠. 위와 같은 방식으로 OOM이 생기는 문제 상황을 해결 할 수 있었습니다.위의 예는 상황을 최대한 단순화하여 설명하려고 작성한 예제이고 이 사이트를 통해 더 상세하게 설명된 사용예를 보실 수 있습니다.#스포카 #개발 #개발자 #GSON #Java #인사이트 #google_gson
조회수 1575

VCNC가 Hadoop대신 Spark를 선택한 이유

요즘은 데이터 분석이 스타트업, 대기업 가릴 것 없이 유행입니다. VCNC도 비트윈 출시 때부터 지금까지 데이터 분석을 해오고 있고, 데이터 기반의 의사결정을 내리고 있습니다.데이터 분석을 하는데 처음부터 복잡한 기술이 필요한 것은 아닙니다. Flurry, Google Analytics 등의 훌륭한 무료 툴들이 있습니다. 하지만 이러한 범용 툴에서 제공하는 것 이상의 특수하고 자세한 분석을 하고 싶을 때 직접 많은 데이터를 다루는 빅데이터 분석을 하게 됩니다. VCNC에서도 비트윈의 복잡한 회원 가입 프로세스나, 채팅, 모멘츠 등 다양한 기능에 대해 심층적인 분석을 위해 직접 데이터를 분석하고 있습니다.빅데이터 분석 기술큰 데이터를 다룰 때 가장 많이 쓰는 기술은 Hadoop MapReduce와 연관 기술인 Hive입니다. 구글의 논문으로부터 영감을 받아 이를 구현한 오픈소스 프로젝트인 Hadoop은 클러스터 컴퓨팅 프레임웍으로 비싼 슈퍼컴퓨터를 사지 않아도, 컴퓨터를 여러 대 연결하면 대수에 따라서 데이터 처리 성능이 스케일되는 기술입니다. 세상에 나온지 10년이 넘었지만 아직도 잘 쓰이고 있으며 데이터가 많아지고 컴퓨터가 저렴해지면서 점점 더 많이 쓰이고 있습니다. VCNC도 작년까지는 데이터 분석을 하는데 MapReduce를 많이 사용했습니다.주스를 만드는 과정에 빗대어 MapReduce를 설명한 그림. 함수형 프로그래밍의 기본 개념인 Map, Reduce라는 프레임을 활용하여 여러 가지 문제를 병렬적으로 처리할 수 있다. MapReduce slideshare 참조MapReduce는 슈퍼컴퓨터 없이도 저렴한 서버를 여러 대 연결하여 빅데이터 분석을 가능하게 해 준 혁신적인 기술이지만 10년이 지나니 여러 가지 단점들이 보이게 되었습니다. 우선 과도하게 복잡한 코드를 짜야합니다. 아래는 간단한 Word Count 예제를 MapReduce로 구현한 것인데 매우 어렵고 복잡합니다.MapReduce로 단어 갯수를 카운트하는 간단한 예제 (Java). 많은 코드를 작성해야 한다.이의 대안으로 SQL을 MapReduce로 변환해주는 Hive 프로젝트가 있어 많은 사람이 잘 사용하고 있지만, 쿼리를 최적화하기가 어렵고 속도가 더 느려지는 경우가 많다는 어려움이 있습니다.MapReduce의 대안으로 최근 아주 뜨거운 기술이 있는데 바로 Apache Spark입니다. Spark는 Hadoop MapReduce와 비슷한 목적을 해결하기 위한 클러스터 컴퓨팅 프레임웍으로, 메모리를 활용한 아주 빠른 데이터 처리가 특징입니다. 또한, 함수형 프로그래밍이 가능한 언어인 Scala를 사용하여 코드가 매우 간단하며, interactive shell을 사용할 수 있습니다.Spark으로 단어 개수를 카운트하는 간단한 예제 (Scala). MapReduce에 비해 훨씬 간단하다.Spark과 MapReduce의 성능 비교. I/O intensive 한 작업은 성능이 극적으로 향상되며, CPU intensive 한 작업의 경우에도 효율이 더 높다. (자료: RDD 논문)Apache Spark는 미국이나 중국에서는 현재 Hadoop을 대체할만한 기술로 급부상하고 있으며, 국내에도 최신 기술에 발 빠른 사람들은 이미 사용하고 있거나, 관심을 갖고 있습니다. 성능이 좋고 사용하기 쉬울 뿐 아니라, 범용으로 사용할 수 있는 프레임웍이기에 앞으로 더 여러 분야에서 많이 사용하게 될 것입니다. 아직 Spark를 접해보지 못하신 분들은 한번 시간을 내어 살펴보시길 추천합니다.기존의 데이터 분석 시스템 아키텍처기존의 데이터 분석 시스템 아키텍처기존의 시스템은 비용을 줄이기 위해 머신들을 사무실 구석에 놓고 직접 관리했으며, AWS S3 Tokyo Region에 있는 로그를 다운받아 따로 저장한 뒤, MapReduce로 계산을 하고 dashboard를 위한 사이트를 따로 제작하여 운영하고 있었습니다.이러한 시스템은 빅데이터 분석을 할 수 있다는 것 외에는 불편한 점이 많았습니다. 자주 고장 나는 하드웨어를 수리하느라 바빴고, 충분히 많은 머신을 확보할 여유가 없었기 때문에 분석 시간도 아주 오래 걸렸습니다. 그리고 분석부터 시각화까지 과정이 복잡하였기 때문에 간단한 것이라도 구현하려면 시간과 노력이 많이 들었습니다.Spark과 Zeppelin을 만나다이때 저희의 관심을 끈 것이 바로 Apache Spark입니다. MapReduce에 비해 성능과 인터페이스가 월등히 좋은 데다가 0.x 버전과는 달리 1.0 버전에서 많은 문제가 해결되면서 안정적으로 운영할 수 있어 비트윈 데이터 분석팀에서는 Spark 도입을 결정했습니다.Apache Zeppelin은 국내에서 주도하고 있는 오픈소스 프로젝트로써, Spark를 훨씬 더 편하고 강력하게 사용할 수 있게 해주는 도구입니다. 주요한 역할은 노트북 툴, 즉 shell에서 사용할 코드를 기록하고 재실행할 수 있도록 관리해주는 역할과 코드나 쿼리의 실행 결과를 차트나 표 등으로 시각화해서 보여주는 역할입니다. VCNC에서는 Zeppelin의 초기 버전부터 관심을 가지고 살펴보다가, Apache Spark를 엔진으로 사용하도록 바뀐 이후에 활용성이 대폭 좋아졌다고 판단하여 데이터 분석에 Zeppelin을 도입하여 사용하고 있고, 개발에도 참여하고 있습니다.또한, 위에서 언급한 하드웨어 관리에 드는 노력을 줄이기 위해서 전적으로 클라우드를 사용하기로 함에 따라서1 아래와 같은 새로운 구조를 가지게 되었습니다.새로운 데이터 분석 시스템 아키텍처새로운 데이터 분석 시스템 아키텍처새로운 데이터 분석 시스템은 아키텍처라고 하기에 다소 부끄러울 정도로 간단합니다. 애초에 전체 시스템 구성을 간단하게 만드는 것에 중점을 두었기 때문입니다. 대략적인 구성과 활용법은 아래와 같습니다.모든 서버는 AWS 클라우드를 이용수 대의 Zeppelin 서버, 수 대의 Spark 서버운영Spark 서버는 메모리가 중요하므로 EC2 R3 instance 사용로그는 별도로 저장하지 않고 서비스 서버에서 S3로 업로드하는 로그를 곧바로 가져와서 분석함중간 결과 저장도 별도의 데이터베이스를 두지 않고 S3에 파일로 저장Zeppelin의 scheduler 기능을 이용하여 daily batch 작업 수행별도의 dashboard용 Zeppelin을 통해 중간 결과를 시각화하며 팀에 결과 공유이렇게 간단한 구조이긴 하지만 Apache Spark와 Apache Zeppelin을 활용한 이 시스템의 능력은 기존 시스템보다 더 강력하고, 더 다양한 일을 더 빠르게 해낼 수 있습니다.기존현재일일 배치 분석코드 작성 및 관리가 어려움Zeppelin의 Schedule 기능을 통해 수행 Interactive shell로 쉽게 데이터를 탐험 오류가 생긴 경우에 shell을 통해 손쉽게 원인 발견 및 수정 가능Ad-hoc(즉석) 분석복잡하고 많은 코드를 짜야 함분석 작업에 수 일 소요Interactive shell 환경에서 즉시 분석 수행 가능Dashboard별도의 사이트를 제작하여 운영 관리가 어렵고 오류 대응 힘듦Zeppelin report mode 사용해서 제작 코드가 바로 시각화되므로 제작 및 관리 수월성능일일 배치 분석에 약 8시간 소요메모리를 활용하여 동일 작업에 약 1시간 소요이렇게 시스템을 재구성하는 작업이 간단치는 않았습니다. 이전 시스템을 계속 부분적으로 운영하면서 점진적으로 재구성 작업을 하였는데 대부분 시스템을 옮기는데 약 1개월 정도가 걸렸습니다. 그리고 기존 시스템을 완전히 대체하는 작업은 약 6개월 후에 종료되었는데, 이는 분석 성능이 크게 중요하지 않은 부분들에 대해서는 시간을 두고 여유 있게 작업했기 때문이었습니다.Spark와 Spark SQL을 활용하여 원하는 데이터를 즉석에서 뽑아내고 공유하는 예제Zeppelin을 활용하여 인기 스티커를 조회하는 dashboard 만드는 예제결론비트윈 데이터 분석팀은 수개월에 걸쳐 데이터 분석 시스템을 전부 재구성하였습니다. 중점을 둔 부분은빠르고 효율적이며 범용성이 있는 Apache Spark, Apache Zeppelin을 활용하는 것최대한 시스템을 간단하게 구성하여 관리 포인트를 줄이는 것두 가지였고, 그 결과는 매우 성공적이었습니다.우선 데이터 분석가 입장에서도 관리해야 할 포인트가 적어져 부담이 덜하고, 이에 따라 Ad-hoc분석을 수행할 수 있는 시간도 늘어나 여러 가지 데이터 분석 결과를 필요로 하는 다른 팀들의 만족도가 높아졌습니다. 새로운 기술을 사용해 본 경험을 글로 써서 공유하고, 오픈소스 커뮤니티에 기여할 수 있는 시간과 기회도 생겼기 때문에 개발자로서 보람을 느끼고 있습니다.물론 새롭게 구성한 시스템이 장점만 있는 것은 아닙니다. 새로운 기술들로 시스템을 구성하다 보니 세세한 기능들이 아쉬울 때도 있고, 안정성도 더 좋아져야 한다고 느낍니다. 대부분 오픈소스 프로젝트이므로, 이러한 부분은 적극적으로 기여하여 개선하여 나갈 계획입니다.비트윈 팀에서는 더 좋은 개발환경, 분석환경을 위해 노력하고 있으며 이는 더 좋은 서비스를 만들기 위한 중요한 기반이 된다고 생각합니다. 저희는 항상 좋은 개발자를 모시고 있다는 광고와 함께 글을 마칩니다.연관 자료: AWS 한국 유저 그룹 - Spark + S3 + R3 을 이용한 데이터 분석 시스템 만들기↩저희는 언제나 타다 및 비트윈 서비스를 함께 만들며 기술적인 문제를 함께 풀어나갈 능력있는 개발자를 모시고 있습니다. 언제든 부담없이 [email protected]로 이메일을 주시기 바랍니다!
조회수 4842

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

들어가기1부에서는, KeyStore 를 사용해 Shared Preferences 를 암호화 하는 법에 대해 알아봤습니다. 그리고 이 글에서는 Room을 사용한 Database 를 암호화 하는 방법에 대해 설명합니다.2018년 현재, 안드로이드 자체에서 데이터베이스를 암호화하는 기능을 제공해 주진 않습니다. 따라서 오픈 소스 프로젝트인 SQLCipher, SafeRoom 의 사용법 위주로 설명할 예정입니다. 또한 KeyStore 에 대칭키를 생성하는 기능은 API Level 23 이후에서만 가능하며, SQLCipher 가 Android KeyStore 를 지원하지 않고 있습니다.이로 인해 1부에서 소개한 키 암호화 메커니즘으로 보호한 별도의 키를 디스크 어딘가에 저장해 두고, 필요할 때만 복호화 해서 쓴 다음 복호화된 내용을 지우는 방식으로 구현해야 합니다. 하지만 이런 방식으로 사용하는 키는 메모리에 순간적으로 남기 때문에 좋은 공격 표면(Attack surface) 이 됩니다. 그 이유도 함께 다뤄 보겠습니다.SqlCipher team 에서 하루라도 빨리 현재의 char[] 형식의 passphrase 를 입력받는 대신, JCA 를 사용해 암호화하는 데이터베이스를 구현하길 기대해 봅시다.SqlCipher1부에서 보여드렸다시피 internal storage 에 저장한 데이터는 결코 안전하지 않습니다. 파일 DB 인 Sqlite 데이터는 포맷을 모르면 어차피 볼 수 없을테니 조금 다르지 않을까요? 그렇지 않다는 것을 다음 예에서 보여드리겠습니다. 루팅한 디바이스에서 adb pull명령으로 sqlite3 데이터베이스를 추출 후 내용을 열어보면 다음과 같습니다.$ hexdump -vC secure_database.sqlite3 00000000  53 51 4c 69 74 65 20 66  6f 72 6d 61 74 20 33 00  |SQLite format 3.| 00000010  10 00 02 02 00 40 20 20  00 00 00 02 00 00 00 04  |.....@  ........| 00000020  00 00 00 00 00 00 00 00  00 00 00 04 00 00 00 04  |................| 00000030  00 00 00 00 00 00 00 04  00 00 00 01 00 00 00 00  |................| 00000040  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................| 00000050  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 02  |................| 00000060  00 2e 01 5a 0d 0f 95 00  02 0e a9 00 0e a9 0f c9  |...Z............| 00000070  0e 6f 0e 6f 00 00 00 00  00 00 00 00 00 00 00 00  |.o.o............| ... 00000d30  00 00 00 00 00 82 37 03  07 17 57 57 01 83 4d 74  |......7...WW..Mt| 00000d40  61 62 6c 65 73 71 6c 69  74 65 62 72 6f 77 73 65  |ablesqlitebrowse| 00000d50  72 5f 72 65 6e 61 6d 65  5f 63 6f 6c 75 6d 6e 5f  |r_rename_column_| 00000d60  6e 65 77 5f 74 61 62 6c  65 73 71 6c 69 74 65 62  |new_tablesqliteb| 00000d70  72 6f 77 73 65 72 5f 72  65 6e 61 6d 65 5f 63 6f  |rowser_rename_co| 00000d80  6c 75 6d 6e 5f 6e 65 77  5f 74 61 62 6c 65 05 43  |lumn_new_table.C| 00000d90  52 45 41 54 45 20 54 41  42 4c 45 20 60 73 71 6c  |REATE TABLE `sql| 00000da0  69 74 65 62 72 6f 77 73  65 72 5f 72 65 6e 61 6d  |itebrowser_renam| 00000db0  65 5f 63 6f 6c 75 6d 6e  5f 6e 65 77 5f 74 61 62  |e_column_new_tab| 00000dc0  6c 65 60 20 00 00 00 00  00 00 00 00 00 00 00 09  |le` ............| ... [리스트 1] Internal storage 에 저장된 SQLite3 database 를 dump 한 결과.역시 기대했던대로 데이터가 하나도 암호화되어 있지 않은 것을 확인할 수 있습니다. 그렇다면 가장 간단한 방법은 SQLiteDatabase클래스를 확장하는 일일 텐데요, 문제는 이 클래스가 final 로 상속 불가능하게 되어 있단 점입니다. 이 때문에 암호화된 SQLiteDatabase 구현체는 이 클래스 및 이 클래스에 강하게 결합되어 있는 SQLiteOpenHelper 를 온전히 쓸 수 없다는 문제가 있습니다. 즉, 바닥부터 새로 만들어야 하는 상황인데요, 다행히도 Zetetic 사에서 만든 SQLCipher for Android 는 이 문제를 모두 해결해 주는 고마운 오픈 소스 프로젝트입니다.SqlCipher 의 사용법은 기존의 SQLiteDatabase 에 의존하던 로직들의 import namespace 만 바꿔주면 되도록 구현되어 있어 마이그레이션 비용도 거의 들지 않습니다.// 안드로이드에서 제공해 주는 SQLiteDatabase 클래스명 import android.database.sqlite.SQLiteDatabase; // SqlCipher 에서 제공해 주는 SQLiteDatabase 클래스명 import net.sqlcipher.database.SQLiteDatabase; // 프로그램 시작시 native library 를 로드해줘야 한다. class MyApplication extends android.app.Application {    @Override public void onCreate() {        super.onCreate();        net.sqlcipher.database.SQLiteDatabase.loadLibs(this);    } } [리스트 2] android SQLiteDatabase 에서 SqlCipher SQLiteDatabase 로 마이그레이션 하기물론 두 클래스는 전혀 타입 호환되지 않지만, net.sqlcipher.database.SQLiteDatabase 의 모든 메소드 및 field의 signature 가 기본 android.database.sqlite.SQLiteDatabase 와 같기 때문에 이런 변경이 가능합니다. SqlCipher 개발팀의 수고에 박수를 보냅니다.RoomRoom 은 SQL 을 객체로 매핑해 주는 도구입니다. Room 을 이용해 데이터베이스를 열 때는 보통 아래와 같은 코드를 사용합니다.object Singletons {    val db: DataSource by lazy {        Room.databaseBuilder(appContext, DataSource::class.java, "secure_database")            .build()    } } abstract class DataSource: RoomDatabase() {    abstract fun userProfileDao(): UserProfileDao } // 클라이언트 코드에서 아래와 같이 호출 val userProfile: UserProfile = Singletons.db.userProfileDao().findUserByUid(userId) [리스트 3] Room database 의 정의 및 활용Sqlite 의 기본 동작은 파일 데이터베이스에 단순 Read 및 Write 만 합니다. 따라서 데이터베이스 접근시 암호화/복호화 동작을 하는 callback 을 주입해야 데이터베이스를 암호화 할 수 있습니다. 그리고 RoomDatabase.Builder 클래스는 데이터베이스를 열때 우리가 주입한 일을 할 수 있는 hook method(openHelperFactory) 를 제공해 주고 있습니다. 다음 코드를 살펴봅시다.class RoomDatabase.Builder {    class Builder {        /**        * Sets the database factory. If not set, it defaults to {@link FrameworkSQLiteOpenHelperFactory}.        */        @NonNull        public Builder openHelperFactory(@Nullable SupportSQLiteOpenHelper.Factory factory)    } } interface SupportSQLiteOpenHelper {    /**     * Create and/or open a database that will be used for reading and writing.     */    SupportSQLiteDatabase getWritableDatabase();    /**     * Create and/or open a database. This will be the same object returned by {@link #getWritableDatabase}.     */    SupportSQLiteDatabase getReadableDatabase();    /**     * Factory class to create instances of {@link SupportSQLiteOpenHelper} using {@link Configuration}.     */    interface Factory {        /**         * Creates an instance of {@link SupportSQLiteOpenHelper} using the given configuration.         */        SupportSQLiteOpenHelper create(Configuration configuration);    } } [리스트 4] Room builder 의 SupportSQLiteOpenHelper 주입 메소드 및 SupportSQLiteOpenHelper.Factory 인터페이스 정의설명을 최대한 간소하게 하기 위해 관심가질 필요 없는 코드 및 코멘트는 모두 제외했습니다. 아무튼 SupportSQLiteOpenHelper 구현체를 주입하면 뭔가 데이터베이스 작업 이전에 우리의 로직을 실행할 수 있을 것 같습니다.사실 이 인터페이스의 핵심은 바로 getWritableDatabase(), getReadableDatabase() 구현입니다. javadoc 에도 있지만 두 메소드로 반환하는 인스턴스는 같아야 하며 또한 암호화를 지원해야 한다는 것을 알 수 있습니다.결국 우리 목표는 Room 과 데이터베이스 암호화 로직을 연결해 주는 SupportSQLiteDatabase 구현체를 만드는 것임을 알 수 있습니다. 이 인터페이스는 규모가 제법 크기 때문에 이게 만만한 일이 아님을 직감하실 수 있을 겁니다.saferoom 도입으로 SupportSQLiteDatabase 인터페이스 구현체 사용하기앞서 살펴봤듯 SupportSQLiteDatabase 구현에는 상당한 노력이 필요하단 것을 알 수 있습니다. 그런데 고맙게도 saferoom 이라는 오픈 소스 프로젝트가 우리의 귀찮음을 잘 해결해 주고 있습니다. saferoom 의 SupportSQLiteOpenHelper 구현체를 간단히 살펴보면 아래와 같습니다./** * SupportSQLiteOpenHelper.Factory implementation, for use with Room  * and similar libraries, that supports SQLCipher for Android.  */ public class SafeHelperFactory implements SupportSQLiteOpenHelper.Factory {    private final char[] passphrase;    public SafeHelperFactory(final char[] passphrase) {        this.passphrase = passphrase;    }    @Override    public SupportSQLiteOpenHelper create(final SupportSQLiteOpenHelper.Configuration configuration) {        return(new com.commonsware.cwac.saferoom.Helper(configuration.context,            configuration.name, configuration.version, configuration.callback,            this.passphrase));    }    /**     * NOTE: this implementation zeros out the passphrase after opening the database     */    @Override    public SupportSQLiteDatabase getWritableDatabase() {        SupportSQLiteDatabase result = delegate.getWritableSupportDatabase(passphrase);        for (int i = 0; i < passphrase>            passphrase[i] = (char) 0;        }        return(result);    }    /**     * NOTE: this implementation delegates to getWritableDatabase(), to ensure that we only need the passphrase once     */    @Override    public SupportSQLiteDatabase getReadableDatabase() {        return getWritableDatabase();    } } /**  * SupportSQLiteOpenHelper implementation that works with SQLCipher for Android  */ class Helper implements SupportSQLiteOpenHelper {    final OpenHelper delegate;    Helper(Context context, String name, int version, SupportSQLiteOpenHelper.Callback callback, char[] passphrase) {        net.sqlcipher.database.SQLiteDatabase.loadLibs(context);        this.delegate = createDelegate(context, name, version, callback);        this.passphrase = passphrase;    }    abstract static class OpenHelper extends net.sqlcipher.database.SQLiteOpenHelper {        SupportSQLiteDatabase getWritableSupportDatabase(char[] passphrase) {            SQLiteDatabase db = super.getWritableDatabase(passphrase); return getWrappedDb(db);        }    } } [리스트 5] Saferoom 의 SupportSQLiteOpenHelper 구현체.소스 코드를 보면 SQLiteDatabase 의 원래 요구사항을 만족하지 못하는 구현 부분도 보입니다만, 그래도 이 정도면 수고를 꽤 크게 덜 수 있어 훌륭합니다.그리고 로직을 잘 보면 데이터베이스를 연 직후 암호로 넘겨준 char[] 배열을 초기화 하는 코드가 있다는 점입니다. 이것이 바로 이 문서의 서두에서 말했던 attack surface 를 최소화 하기 위한 구현입니다. 이 글의 주제에서 벗어난 내용이기에 여기서는 다루지 않습니다만, 궁금하신 분들은 부록 1: in-memory attack 맛보기에서 확인하실 수 있습니다.SqlCipher + SafeRoom + Room 구현 및 코드 설명이상으로 데이터베이스 암호화 전략에 대해 살펴봤습니다. 이 장에서는 실제로 연동하는 방법에 대해 다룹니다.불행히도 2018년 현재 SqlCipher 는 Android KeyStore 를 지원하지 않고 있습니다. 그리고 인스턴스 생성에 쓸 비밀번호로 CharArray 가 필요한데, 이 값은 한번 정해지면 불변해야 합니다. 여기 사용할 키를 KeyStore 에 저장하면 문제를 깔끔하게 해결할 수 있을 것 같습니다. 하지만 1부에서 살펴봤듯이 하드웨어로 구현된 Android KeyStore 밖으로는 키가 절대로 노출되지 않는다고 합니다. 이 문제를 어떻게 해결해야 할까요?먼저, SqlCipher 에 사용하기 위해 KeyStore 로 생성한 AES256 키의 내용을 한번 살펴봅시다.val secretKey = with(KeyGenerator.getInstance("AES", "AndroidKeyStore"), {    init(KeyGenParameterSpec.Builder(alias,             KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)        .setKeySize(256)        .setBlockModes(KeyProperties.BLOCK_MODE_CBC)        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)        .build())    generateKey() }) val keyInfo = with(KeyFactory.getInstance(privKey.getAlgorithm(), "AndroidKeyStore"), {    factory.getKeySpec(privKey, KeyInfo::class.java) }) println("Key algorithm : " + secretKey.algorithm) println("Key format : " + secretKey.format) println("Encoded key size: " + secretKey.encoded?.size) println("Hardware-backed : " + keyInfo.isInsideSecureHardware) // 실행 결과 Key algorithm : AES Key format : null Encoded key size: null Hardware-backed : true [리스트 6] AndroidKeyStore 에 저장한 Key 는 어플리케이션에서 직접 쓸 수 없다.저희가 보유중인 개발 시료 Nexus 5 에서 실행한 결과 위와 같이 나타났습니다. secretKey.encoded 의 값이 메모리에 있다면 이 값을 SqlCipher 생성자에 넘겨줄 수 있겠지만 값이 null 이네요. 보안 측면에서는 다행일 지 모르지만 우리 구현에서는 쓸 수 없으니 문제입니다. 그래서 별 수 없이 임의로 키를 만들고(AndroidAesHelper#generateRandomKey()), 1부에서 소개했던 AndroidRsaCipherHelper 를 이용해 암호화한 값을 Shared Preferences에 저장하는 식으로 구현해 봅시다.val settingsPrefs = appContext.getSharedPreferences("app_settings", Context.MODE_PRIVATE) val settings = SecureSharedPreferences.of(settingsPrefs) val dbPass = with(settings, {    /*     * String.toCharArray() 같은 함수를 쓰면 로직이 좀더 간단해지지만, JVM 에서의 String은     * Immutable 하기 때문에 GC 이전에는 지울 방법이 없으므로 attack surface 가 더 오랫동안     * 노출되는 부작용이 있다. 따라서 key의 plaintext 는 가급적 String 형태로 저장하면 안된다.     */    var savedDbPass = getString("DB_PASSPHRASE", "")    if (savedDbPass.isEmpty()) {        // KeyStore 에 저장해도 SqlCipher 가 써먹질 못하니 그냥 1회용 키 생성 용도로만 활용한다.        val secretKey = AndroidAesCipherHelper.generateRandomKey(256)        // String 생성자 사용: 이 문자열은 heap 에 저장된다.        savedDbPass = String(Base64.encode(secretKey, Base64.DEFAULT))        putString("DB_PASSPHRASE",  AndroidRsaCipherHelper.encrypt(savedDbPass))        // 메모리 내에 plaintext 형태로 존재하는 attack surface 를 소멸시켜 준다.        secretKey.fill(0, 0, secretKey.size - 1)    } else {        // decrypt 메소드 내부에서 String 생성자 사용하므로 base64 인코딩된 plaintext 키는 heap 에 저장된다.        savedDbPass = AndroidRsaCipherHelper.decrypt(savedDbPass)    }    val dbPassBytes = Base64.decode(savedDbPass, Base64.DEFAULT)    /*     * SqlCipher 내부에서는 이 char[] 배열이 UTF-8 인코딩이라고 가정하고 있다.     * 그리고 UTF-8 인코딩에서는 byte range 의 char 는 1 바이트니까,     * 아래 변환을 거치더라도 키 길이는 32 byte(256 bit)가 유지된다.     *     * UTF-8 인코딩에서는 32 글자 != 32 바이트가 아님에 항상 유의해야 한다!     */    CharArray(dbPassBytes.size, { i -> dbPassBytes[i].toChar() }) }) [리스트 7] 암호화한 SqlCipher 용 passphrase 를 사용하는 방법.위 코드를 사용해 char[] 타입의 값 dbPass 를 얻을 수 있습니다. 리스트 7을 이용해 얻은 dbPass를 아래 코드에 사용하면 SqlCipher - SafeRoom - Room 의 연동이 끝납니다.val dataSource = Room.databaseBuilder(_instance, DataSource::class.java, "secure_database") .openHelperFactory(SafeHelperFactory(dbPass))                .build() // 메모리 내에 plaintext 형태로 존재하는 attack surface 를 소멸시켜 준다. dbPass.fill('0', 0, dbPass.size - 1) [리스트 8] SqlCipher - SafeRoom - Room 연동하기위 코드에서 볼 수 있듯, 임의로 저장한 키를 Base64 인코딩으로 변환, 그리고 그것을 다시 CharArray 로 변환하는 과정에서 key 가 메모리에 존재해야 하는 순간이 있습니다. 이 구간을 바로 공격 표면(attack surface) 이라고 합니다.JVM 단에서 넘겨주는 Passphrase 를 SqlCipher 내부에서 native 로 어떻게 처리하고 있는지는 SqlCipher SQLiteDatabase 구현및 SqlCipher crypto 구현 에서 확인할 수 있습니다.결과 확인하기SafeHelperFactory 를 주입한 Room database 파일을 추출 후 hexdump 로 확인해 보겠습니다.hwan@ubuntu:~$ hexdump -vC secure_database.sqlite3 00000000  8c 0d 04 07 03 02 11 eb  a4 18 33 4f 93 e8 ed d2  |..........3O....| 00000010  e9 01 21 d7 49 df 25 9a  f4 1d c7 1e ff 2d b0 13  |..!.I.%......-..| 00000020  fc 17 9b 4b b2 1c a3 1d  7d 1d 69 76 b1 ea ec e8  |...K....}.iv....| 00000030  1f 50 e4 c4 6c 50 e6 82  58 27 b9 fe 85 21 27 99  |.P..lP..X'...!'.| 00000040  ec 54 53 ba 32 c6 59 09  b4 30 65 39 a0 75 3e c4  |.TS.2.Y..0e9.u>.| 00000050  b8 f7 ea 47 14 df c4 f0  7c be 9f 62 26 49 1c b2  |...G....|..b&I..| 00000060  0f 63 00 7a 09 7e 33 e0  43 2b eb ea 80 21 bb 5d  |.c.z.~3.C+...!.]| 00000070  5c 04 ff 57 a3 a3 7f c2  19 42 b9 67 6c e3 d5 c8  |\..W.....B.gl...| ... 00000d30  c1 f3 93 1f 4e 5b 6a 70  39 c2 e9 2c 3e 8f 7e ff  |....N[jp9..,>.~.| 00000d40  73 3a 9a 39 0d 8a 1a 3e  6b d4 5b de 1f 6d c4 b8  |s:.9...>k.[..m..| 00000d50  fb 62 3e 21 09 0a 31 20  37 5d 8d 0a 39 6d 35 31  |.b>!..1 7]..9m51| 00000d60  26 d6 b0 22 41 7e 6c 54  7d 77 22 ba 1b f3 cf 5a  |&.."A~lT}w"....Z| 00000d70  e5 47 97 76 f0 89 e5 98  b3 37 3c 8d 43 af 0e b9  |.G.v.....7<.C...| 00000d80  18 74 fd f5 2a 41 d8 b1  d9 70 32 0b 5c 93 4b 0d  |.t..*A...p2.\.K.| 00000d90  bc 60 4c 25 9a ec 53 23  90 60 b2 52 a8 a1 b1 87  |.`L%..S#.`.R....| 00000da0  f3 3e 03 3e ac 0a 75 a0  61 d8 bd 07 b8 5a 48 66  |.>.>..u.a....ZHf| 00000db0  57 85 13 ac 04 26 55 30  34 46 57 bf 8b 42 c6 2d  |W....&U04FW..B.-| 00000dc0  9e 82 a2 df 77 bb b3 2e  96 43 70 23 23 03 df 1d  |....w....Cp##...| ... [리스트 9] Internal storage 에 저장된 SQLite3 database 를 dump 한 결과. 리스트 1과 비교해 보자.이로서 오픈 소스의 힘을 빌려 우리 앱의 데이터베이스를 비교적 간편하게 암호화 할 수 있음을 알 수 있습니다.맺으며이로서 Persistent data 암호화에 대한 설명을 마칩니다. Android KeyStore 가 API Level 23 이상의 기기에서만 100% 동작한다는 점은 2018년 현재까지는 큰 단점입니다. 하지만 사소한 데이터라 하더라도 보안의 중요성은 날로 강조되고 있습니다. 따라서 빠르던 늦던 고객 데이터 암호화에 투자해야 할 순간이 다가온다는 점은 변하지 않습니다.언젠가는 적용해야 할 고객 데이터 보호의 순간에, 이 글이 여러분의 앱의 보안에 조금이나마 도움이 된다면 좋겠습니다.부록 1: in-memory attack 맛보기앞서 계속 반복해서 설명드렸던 메모리 내의 attack surface 를 찾아내는 방법을 간단히 설명해 보겠습니다. 잘 지키려면 잘 공격하는 법을 알아야 하므로 알아두면 좋지 않을까요? 그리고 일반적인 앱 개발과는 다소 동떨어진 이 장의 내용이 이해되지 않으신다면 한줄요약한 메모리 내부의 값도 때로는 안전하지 않을 수 있다 는 한마디만 기억해 두시면 됩니다. 모든 데모는 LG Nexus 5(Hammerhead), 시스템 버전 6.0.1(M) 에서 실행한 결과며 시스템마다 약간의 차이는 있을 수 있습니다.마켓에 출시한 앱들은 debuggable:false 가 설정된 상태이므로 힙 덤프를 바로 뜰 수는 없습니다. 그런데 어떻게 in-memory attack 이 가능할까요? 다음 리스트는 디버그 불가능한 앱의 힙 덤프를 시도할 때 보안 정책 위반 오류가 발생함을 보여줍니다.hwan@ubuntu:~$ adb shell ps | grep "com.securecompany.secureapp" USER PID PPID VSIZE RSS WCHAN PC NAME u0_a431   25755 208   1700384 100888 sys_epoll_ 00000000 S   com.securecompany.secureapp hwan@ubuntu:~$ adb shell am dumpheap 25755 "/data/local/tmp/com.securecompany.secureapp.heap" java.lang.SecurityException: Process not debuggable: ProcessRecord{b6f96fc 25755:com.securecompany.secureapp/u0_a431}     at android.os.Parcel.readException(Parcel.java:1620)     at android.os.Parcel.readException(Parcel.java:1573)     at android.app.ActivityManagerProxy.dumpHeap(ActivityManagerNative.java:4922)     at com.android.commands.am.Am.runDumpHeap(Am.java:1248)     at com.android.commands.am.Am.onRun(Am.java:377)     at com.android.internal.os.BaseCommand.run(BaseCommand.java:47)     at com.android.commands.am.Am.main(Am.java:100)     at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)     at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:251) [리스트 10] debuggable=false 설정된 앱의 힙 덤프 시도시 발생하는 예외(SecurityException)SuperUser 는 가능할까요? SuperUser 권한으로 앱을 강제로 디버그 가능한 상태로 시작해 보도록 하겠습니다.hwan@ubuntu:~$ adb shell 32|shell@hammerhead:/ $ su 1|root@hammerhead:/ \# am start -D -n "com.securecompany.secureapp/MainActivity" && exit Starting: Intent { cmp=com.securecompany.secureapp/MainActivity } hwan@ubuntu:~$ \# adb shell ps | grep "com.securecompany.secureapp" USER PID PPID VSIZE RSS WCHAN PC NAME u0_a431   27482 211   1700384 100888 sys_epoll_ 00000000 S   com.securecompany.secureapp hwan@ubuntu:~$ adb forward tcp:12345 jdwp:27482 hwan@ubuntu:~$ netstat -an | grep 12345                                                           tcp4       0      0  127.0.0.1.12345         *.*                    LISTEN     hwan@ubuntu:~$ jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=12345 java.net.SocketException: Connection reset     at java.net.SocketInputStream.read(SocketInputStream.java:210)     at java.net.SocketInputStream.read(SocketInputStream.java:141)     at com.sun.tools.jdi.SocketTransportService.handshake(SocketTransportService.java:130)     at com.sun.tools.jdi.SocketTransportService.attach(SocketTransportService.java:232)     at com.sun.tools.jdi.GenericAttachingConnector.attach(GenericAttachingConnector.java:116)     at com.sun.tools.jdi.SocketAttachingConnector.attach(SocketAttachingConnector.java:90)     at com.sun.tools.example.debug.tty.VMConnection.attachTarget(VMConnection.java:519)     at com.sun.tools.example.debug.tty.VMConnection.open(VMConnection.java:328)     at com.sun.tools.example.debug.tty.Env.init(Env.java:63)     at com.sun.tools.example.debug.tty.TTY.main(TTY.java:1082) Fatal error:  Unable to attach to target VM. [리스트 12] SuperUser 권한으로도도 Java 디버거를 붙일 수 없다.다행히도 debuggable=false 로 릴리즈한 앱은 자바 디버거(jdb)를 붙일 수 없으니 프로그램 실행을 매우 정밀하게 제어할 수는 없다는 것을 알 수 있습니다(debuggable=true 설정된 앱에 위 과정을 실행하면 어떤 일이 벌어지는지 직접 확인해 보세요!).하지만 안드로이드의 앱은 ‘linux process’ 에서 실행되므로 SuperUser 권한으로 process 메모리 전체 dump를 뜨는 것은 막을 수 없습니다. 정공법으로는 /proc/PID/maps 의 내용을 분석하면 됩니다만 제가 안드로이드를 깊게 알고 있는 것은 아니라, 어느 영역이 dalvik heap 인지를 알아낼 수 없었습니다. 이 때문에 프로세스 메모리를 통째로 떠서 내용을 헤집어보는 방식으로 공격해 보겠습니다. 여담입니다만, 데모를 위해 공격한 앱은 dumpsys 명령으로 확인해보니 약 6MiB 의 Java heap 을 쓰고 있는데요, 이 크기를 줄이면 줄일 수록 공격이 더욱 수월할 겁니다.아래 데모에서는 안드로이드 기기용(arm-linux-gnueabi)으로 컴파일한 gdb 를 미리 설치한 결과를 보여드리고 있습니다. 참고로 여기 보이는 [heap] 은 아쉽지만 native heap 이므로 우리 공격 목표는 아닙니다.1|root@hammerhead:/ \# cd /proc/27482 1|root@hammerhead:/proc/27482 \# cat maps 12c00000-12e07000 rw-p 00000000 00:04 8519       /dev/ashmem/dalvik-main space (deleted) ... b7712000-b771f000 rw-p 00000000 00:00 0 [heap] bee86000-beea7000 rw-p 00000000 00:00 0 [stack] ffff0000-ffff1000 r-xp 00000000 00:00 0 [vectors] 1|root@hammerhead:/proc/27482 \# ifconfig wlan0     Link encap:Ethernet          inet addr:192.168.12.117          inet6 addr: fe80::8e3a:e3ff:fe5f:64c9/64 1|root@hammerhead:/proc/27482 \# gdbserver –attach :12345 27482 Attached; pid = 27482 Listening on port 12345 [리스트 13] SuperUser 권한으로 gdbserver 실행.hwan@ubuntu:~$ adb forward tcp:23456 tcp:12345 hwan@ubuntu:~$ netstat -an | grep 23456 tcp4       0      0  127.0.0.1.23456         *.*                    LISTEN     [리스트 14] 로컬 포트 23456 으로 원격 포트 12345 를 연결하는 과정.이제 모든 준비가 끝났습니다. 개발 기기에서 gdb로 원격 프로세스에 접근한 뒤, 메모리를 덤프해 봅시다.hwan@ubuntu:~$ ./gdb (gdb) target remote 192.168.12.117:12345 Remote debugging using 192.168.12.117:12345 0xb6f92834 in ?? () (gdb) dump memory /tmp/com.securecompany.secureapp.heap 0x12c00000 0xb771f000 (gdb) [리스트 15] gdb 로 메모리를 덤프하는 과정.덤프한 힙 덤프 파일 속에 있을지도 모르는 문자열을 검색해 봅시다. 그 전에 잠시, 데이터베이스에 사용할 키를 어떻게 처리했었나 되새겨 볼까요? if (savedDbPass.isEmpty()) {        // ...        // String 생성자 사용: 이 문자열은 heap 에 저장된다.        savedDbPass = String(Base64.encode(secretKey, Base64.DEFAULT))    } else {        // decrypt 메소드 내부에서 String 생성자 사용하므로 base64 인코딩된 plaintext 키는 heap 에 저장된다.        savedDbPass = AndroidRsaCipherHelper.decrypt(savedDbPass)    } [리스트 16] Base64 인코딩을 처리하기 위한 임시 String 생성 과정.우리 로직은 256 비트의 키를 Base64 변환해서 디스크에 저장합니다. 그리고 256비트의 byte array 를 base64 변환한 결과는 (4 * (256 / 3)) / 8 = 42.66 바이트 -> 4의 배수여야 하므로 44바이트입니다. 약 1.34 바이트의 pad 를 맞추기 위해 문자열의 끝에 =가 최소 1글자 이상은 있을 겁니다. 한번 찾아봅시다.hwan@ubuntu:~$ strings /tmp/com.securecompany.secureapp.heap ... /masterkey ... user_0/.masterkey em_s 1337 ... [리스트 17] strings 명령을 사용한 힙 덤프 파일내의 문자열 검색의외로 = 나 == 로 끝나는 문자열이 발견되지 않습니다. 하지만 안심하기는 이릅니다. 이건 단순히 (공격자의 입장에서) 운이 나빠서 발견되지 않은 것일 뿐입니다. 우리가 원하는 어떤 ‘순간’ 에 힙 덤프 명령을 내리지 않았기 때문에 그렇습니다. 우리의 구현은 attack surface 를 매우 짧은 시간동안만 메모리에 노출하기 때문에 이 순간이 짧으면 짧을 수록, 디바이스의 성능이 좋으면 좋을 수록 순간을 잡아내기가 더욱 어려워집니다. 즉, 이 문서에서 보여드린 방식으로 CharArray 의 내용을 아주 짧은 시간 동안만 사용하고 지워버리면 내용을 탈취하기 굉장히 어렵습니다. 하지만 안심하기는 이릅니다. nano-time 단위로 앱을 실행할 수 있는 환경을 가진 국가급 공격자는 여전히 있기 때문입니다.그리고 이 방법은 루팅하지 않은 기기에서는 절대 재현이 불가능하므로 루팅되지 않은 환경일 경우에만 실행 가능하도록 한다던가 하는 방식까지 더한다면 공격자가 더욱 우리 앱을 뚫기 힘들 겁니다.여담입니다만 독자 여러분들 중 GameGuardian 처럼 다른 게임의 메모리값을 마구 바꾸는 앱이 어떻게 동작하나 궁금하신 분들도 있을 겁니다. 그런 류의 앱들도 바로, 이 장에서 설명했던 방식으로 동작합니다.장황했던 이 장의 내용을 한줄로 요약하면 Android KeyStore 로 보호하지 않은 키는 많은 수고를 들이면 뚫을 수 있다고 할 수 있습니다.부록 2: SQLite database 의 UPDATE / DELETE 구현 특성SQLite3 의 구현특성상, UPDATE / DELETE 시에 이전 레코드의 값이 남아있는 경우가 있습니다. 암호화 했으니 좀더 안전하다곤 하지만 찌거기 값을 굳이 남겨둬서 공격자에게 더 많은 힌트를 제공할 필요도 없습니다.이 문서는 암호화 구현에만 초점을 맞췄기 때문에 상세하게 다루진 않습니다만, LINE Tech blog에 소개된 True delete 는 이 문제를 해결하기 위한 방법을 제시하고 있으므로 그 문서도 한번 읽어보시길 권합니다.더 보기SQLCipherSafeRoomAndroid SQLite3 True delete - by LINE tech blogDifference between java.util.Random and java.security.SecureRandomAttack surface on security measuresAOSP: DebuggingRootbeer: Simple to use root checking Android library#하이퍼커넥트 #개발 #개발자 #안드로이드 #앱개발 #모바일 #PersistentData #인사이트 #개발후기
조회수 7115

클라우드 서비스 이해하기 IaaS, PaaS, SaaS

클라우드 컴퓨팅은 인터넷으로 가상화 된 IT 리소스를 서비스로 제공하는 것을 의미합니다. 그리고 클라우드 컴퓨팅에서 가상화 하여 서비스로 제공하는 대상은 인프라스트럭쳐, 플랫폼, 소프트웨어입니다. AWS와 Azure가 대중화되면서 클라우드를 인프라스트럭쳐의 가상화 개념으로만 이해하기도 하지만 클라우드는 인프라스트럭쳐 뿐만이 아니라 플랫폼과 소프트까지 포함하는 온라인의 모든 영역을 다루는 꽤 광범위한 개념입니다. 그렇기 때문에 클라우드는 분야별 특성별로 나누어서 이해하는 것이 좋습니다. 클라우드 서비스의 종류는 아래와 같이 크게 3가지로 나눌 수 있습니다. Infrastructure as a Service (IaaS, 아이아스, 이에스)서비스로 제공되는 인프라스트럭처입니다. 개발사에 제공되는 물리적 자원을 가상화합니다. Platform as a Service (PaaS, 파스)서비스로 제공되는 플랫폼입니다. 개발사에 제공되는 플랫폼을 가상화합니다.Software as a Service (SaaS, 사스)서비스로 제공되는 소프트웨어입니다. 고객에게 제공되는 소프트웨어를 가상화합니다.클라우드 구분하여 알아보자IaaS: 서비스로 제공하는 인프라스트럭쳐클라우드 인프라스트럭처 서비스는 확장성이 높고 자동화된 컴퓨팅 리소스를 가상화하여 제공하는 것입니다. IaaS는 컴퓨팅, 네트워킹, 스토리지 및 기타 인프라스트럭쳐를 사용하기 위한 서비스이며 사용자는 필요할 때 마다 서비스를 통해 리소스를 구입할 수 있습니다.(IaaS는 한국에서 이아스 또는 아이아스로 부르며 영미권에서는 이에:스 또는 아이아스로 발음합니다.)PaaS: 서비스로 제공하는 플랫폼클라우드 플랫폼 서비스는 주로 응용 프로그램을 개발 할 때 필요한 플렛폼을 제공하는 것입니다. PaaS는 사용자 정의 응용 프로그램을 개발하고 사용할 수있는 개발자를위한 프레임워크를 제공합니다. 개발사는 미들웨어를 설치하지 않고도 미들웨어에서 제공하는 API를 사용하여 소프트웨어를 개발할 수 있습니다. SaaS : 서비스로 제공하는 소프트웨어클라우드 애플리케이션(소프트웨어) 서비스는 사용자에게 제공되는 소프트웨어를 가상화하여 제공하는 것입니다. SaaS는 타사 공급 업체가 관리하는 사용자에게 응용 프로그램을 제공하기 위해 인터넷을 사용합니다. 대부분의 SaaS 애플리케이션은 웹 브라우저를 통해 직접 실행되므로 클라이언트 측에서 다운로드 나 설치가 필요하지 않습니다.무엇을 제공하는가클라우드는 온라인의 광범위한 영역을 모두 다루는 광범위한 영역입니다. 클라우드 서비스들은 제공하는 범위에 따라 IaaS, PaaS, SaaS로 나뉘고 있으므로 각각의 클라우드 서비스가 제공하는 내역을 살펴보는 것은 클라우드를 이해하는 데 많은 도움이 됩니다.  IaaS: 물리적 자원 제공IaaS는 고객에게 서버, 네트웍, OS, 스토리지를 가상화하여 제공하고 관리합니다. IaaS는 가상화 된 물리적인 자산을 UI형태의 대시보드 또는 API로 제공합니다. IaaS의 고객들은 서버와 스토리지를 접근할 수 있지만 사실상 클라우드에 있는 가상 데이터 센터를 통해 리소스를 전달받는 형태입니다. IaaS는 기존의 데이터센터에서 제공받던 물리적인 자산을 완벽하게 가상화하여 제공하기 때문에 서버 사양의 변경 등 물리적 자산의 수정이 필요한 경우 기존의 방식에 비해 훨씬 빠른 대응이 가능합니다.IaaS의 제공업체는 서버, 하드 드라이브, 네트워킹, 가상화 및 스토리지를 관리하며 고객은 OS, 미들웨어, 애플리케이션 및 데이터와 같은 자원들을 관리해야 합니다. PaaS: 소프트웨어 개발을 돕는 플랫폼 제공PaaS는 고객에게 OS, 미들웨어, 런타임과 같은 소프트웨어 작성을위한 플랫폼을 가상화하여 제공하고 관리합니다. 이 가상화 된 플랫폼은 웹을 통해 제공되며 개발자는 운영 체제, 소프트웨어 업데이트, 저장소 또는 인프라에 대한 관리 없이 소프트웨어 개발에 집중할 수 있습니다.PaaS를 사용하면 기업에서는 특수 소프트웨어 구성 요소를 사용하여 PaaS에 내장 된 응용 프로그램을 설계하고 만들 수 있습니다. 이러한 응용 프로그램 또는 미들웨어는 특정 클라우드 특성을 채택 할 때 확장 가능하고 가용성이 높습니다.SaaS: 고객이 사용하는 소프트웨어 제공SaaS는 고객을 대신하여 소프트웨어와 데이터를 제공하고 관리합니다. 패키지 또는 On-Prems 방식이라고 하는 기존의 소프트웨어 전달 방식과 다르게 SaaS는 개별 컴퓨터에 응용 프로그램을 다운로드하고 설치할 필요가 없습니다. SaaS를 통해 서비스를 공급하는 업체는 데이터, 미들웨어, 서버 및 스토리지와 같은 모든 잠재적 인 기술적 문제를 관리하기 때문에 고객은 유지 보수 및 지원을 간소화 하면서 비지니스에 집중 할 수 있습니다.클라우드의 장점과 단점클라우드 인프라 서비스를 사용할 때의 장점과 클라우드 소프트웨어 서비스를 사용할 때의 장점은 다를 수 밖에 없습니다. 이에 3가지 클라우드 서비스의 장점과 단점을 각각 설명합니다. IaaS: 장점비용물리적 자원을 소비 형태로 사용하기 때문에 고정비가 들지 않습니다.속도물리적 자원을 즉시 소비할 수 있습니다.관리물리적  자원에 대한 관리를 논리적인 영역으로 대체할 수 있습니다.물리적 자원에 대한 자동화 된 배포가 가능합니다.물리적 자원에 대한 안정적인 운영을 벤더에 맞길 수 있습니다.물리적 자원에 대한 규모의 확장 또는 축소가 자유롭습니다.  PaaS: 장점비용필요한 플랫폼만 소비 형태로 사용하기 때문에 비용 부담을 덜 수 있습니다. 속도개발 및 배포 프로세스를 빠르게 확보할 수 있습니다.관리소프트웨어 유지 관리가 쉬워집니다.가상화 기술을 기반으로 구축되어 비즈니스가 변함에 따라 리소스를 쉽게 확장 또는 축소 할 수 있습니다.응용 프로그램의 개발, 테스트 및 배포를 지원하는 다양한 서비스를 제공합니다.수많은 사용자가 동일한 개발 응용 프로그램에 액세스 할 수 있습니다.PaaS: 단점특정 플랫폼 서비스에 종속될 수 있습니다.SaaS: 장점SaaS는 소프트웨어 설치, 관리 및 업그레이드와 같은 지루한 작업에 소요되는 시간과 비용을 크게 줄임으로써 직원과 회사에 많은 이점을 제공합니다. 따라서 기술 직원이 조직 내에서 보다 긴급하고 중요한 문제에 집중할 수 있습니다. 비용소프트웨어를 소비 형태로 사용하기 때문에 비용 부담을 덜 수 있습니다.속도즉시 사용이 가능합니다. 관리소프트웨어를 설치할 물리적 자원이 필요하지 않습니다.언제 어디서든 접근가능합니다.SaaS: 단점커스터마이징이 어렵습니다. 클라우드 언제 적용해야 하는가IaaS: 빠른 변화를 원한다면스타트업이나 중소기업에게 IaaS는 훌륭한 옵션이므로 하드웨어나 소프트웨어를 설치하는데 시간과 돈을 낭비 할 필요가 없습니다. IaaS는 응용 프로그램과 인프라를 완벽하게 제어하고자하는 대규모 조직에 유용하지만 실제로 소비되거나 필요로하는 것을 구매하려는 경우에만 유용합니다. 빠르게 성장하는 기업의 경우, IaaS는 요구 사항이 변화하고 발전함에 따라 특정 하드웨어 나 소프트웨어에 전념 할 필요가 없으므로 좋은 선택이 될 수 있습니다. 또한 필요에 따라 확장 또는 축소 할 수있는 많은 유연성이 있으므로 새로운 응용 프로그램에 어떤 요구가 필요한지 확실하지 않은 경우 도움이됩니다.PaaS: 신속한 개발을 원한다면PaaS를 이용하는 것이 유익하거나 필요한 경우가 많이 있습니다. 동일한 개발 프로젝트를 수행하는 여러 개발자가 있거나 다른 공급 업체도 포함해야하는 경우 PaaS는 전체 프로세스에 뛰어난 속도와 유연성을 제공 할 수 있습니다. PaaS는 사용자 정의 된 응용 프로그램을 만들려는 경우에도 유용합니다. 또한이 클라우드 서비스는 비용을 크게 절감 할 수 있으며 앱을 신속하게 개발하거나 배포하는 경우 발생하는 몇 가지 문제를 단순화 할 수 있습니다.SaaS: 비지니스에 집중하고 싶다면보안상 민감한 사항이 아니라면 모든 기업에게 SaaS는 훌륭한 옵션입니다. 또한 협업이 필요한 단기 프로젝트라면 SaaS 를 도입하는 것이 훨씬 유리합니다. 일반적으로 On-Prems 솔루션은 모바일 액세스를 지원하지 않기 때문에 모바일 액세스가 필요한 경우에도 SaaS를 사용하면 비용가 시간을 절약할 수 있습니다.클라우드 서비스 예클라우드는 적용된 분야별로 이해해야 합니다. 아래는 분야별 서비스 예입니다. IaaSAmazon Web Services (AWS), Microsoft Azure, DigitalOcean, Google Compute Engine (GCE)PaaSAWS Elastic Beanstalk, Windows Azure, Heroku, Google App EngineSaaSGoogle Apps, Dropbox, Salesforce, WhaTap마무리지금도 많은 기업의 임원분들이 클라우드의 적용 여부에 대해 고민을 하고 있으며 많은 스타트업들이 클라우드 기반의 서비스를 만들어 가고 있습니다. 회사에 클라우드를 도입해야 한다면 IaaS를 도입할 지, PaaS를 도입할 지 아니면 SaaS를 도입해야 하는지 알고 있어야 합니다. 그리고 자사의 서비스가 클라우드 기반의 서비스라면 고객에게 왜 도입해야 하는지 쉽게 설명할 수 있어야 합니다. 제가 다니는 와탭랩스(whatap.io)는 국내에서 드물게 SaaS 모니터링 서비스를 제공하고 있습니다. 2015년 1월에 시작한 서비스는 이제 만 4년을 달려가고 있습니다. 앞으로 한국에서 더 많은 클라우드 서비스들이 나왔으면 합니다. #와탭랩스 #개발자 #개발팀 #클라우드서비스 #서비스소개
조회수 3655

포스트맨 200% 활용하기

편집자 주 MAC OS 기준으로 작성했으며, 본문 내용 중 Proxy(또는 프록시)는 영문으로 통일하여 표기함. OverviewPOSTMAN은 API 테스트에 큰 도움을 주는 도구입니다. 강력한데다가 무료입니다. 안 쓸 이유가 없군요. POSTMAN은 사용하는 방법도 쉽습니다. 그래서 이번 글에서는 최근에 나온 POSTMAN native 버전 패킷캡쳐 방법을 공유하겠습니다.native App은 기존 크롬 플러그인 버전보다 깔끔하고 버그도 많이 줄었습니다. 하지만 원래부터 강력했던 postman interceptor가 아직 지원하지 않습니다.1)공식 블로그 답변입니다.이미 interceptor를 사용하고 있어서 native App에 대한 니즈는 없었는데요. 한글 패킷 캡쳐를 시도하고 생각이 완전히 바뀌었습니다. intetceptor로 캡쳐된 패킷테스트 중이던 공지사항 제목이 이상하게 변경됐습니다.Postman Proxy를 써보자!어쩔 수 없이 native App 을 써야겠다고 생각했습니다. 가장 먼저 postman interceptor에 연결할 방법이 필요했는데 위의 공식 블로그 답변처럼 지금은 안 된다고 합니다. 구글링을 했더니 아래와 같은 글이 보였습니다.스마트폰이나, 기타 기기들의 패킷을 캡쳐할 수 있기 때문에 매력적인 방법입니다. 웹을 사용할 땐 브라우저를 Proxy 태우면 결과는 비슷하게 나올 겁니다.native Appnative App은 여기에서 다운로드 받을 수 있습니다. nativeApp을 켜면 오른쪽 위의 메뉴에 interceptor 아이콘은 없고 위성안테나 모양의 아이콘이 있습니다. 이것은 Proxy Server 기능입니다. Proxy Server를 postman native가 구동해주고 사용하는 방식이죠.Proxy 설정 화면이 뜨는 기본 포트는 5555번입니다. 따로 할 건 없고, 캡쳐 위치는 기본 값인 History로 지정합니다. 만약 다른 컬렉션에 내용을 모으고 싶다면 그곳으로 지정하세요. Connect 버튼을 클릭하면Proxy가 구동됩니다.요청 내용을 긁어 모을 때다!Proxy 세팅을 마쳤으니 브라우저를 연결해야겠죠? 일반적인 방법으로는 연결되지 않습니다. 여기선 크롬 확장 프로그램인 Proxy SwitchyOmega의 도움을 받았습니다. 다운로드는 여기를 클릭하세요.이것은 Proxy 스위칭 프로그램입니다. 도메인 단위로 설정이 가능하기 때문에 on 또는 off 따로 하지 않고도 사용이 가능할 겁니다. 플러그인 설치를 마쳤다면 설정을 유도합시다.Server에는 localhost, Port에는 5555를 적어주세요.캡쳐하고 싶은 사이트에 들어가 Direct 옵션을 켭니다.Proxy를 활성시킵니다.브랜디 주요 도메인인 brandi.co.kr을 클릭해 Proxy를 활성시키면 ***.brandi.co.kr 도메인은 Local Proxy를 타고 넘어가는데요. 이제 받기만 하면 됩니다. (빵끗)진짜 긁어 모아보자!캡쳐하려고 했던 사이트에 접속해 요청을 발생시킵니다.내부 테스트 서버postman native App 캡쳐 내용와우! 발생한 요청 내용이 캡쳐되어서 들어오기 시작합니다.속이 뻥!!!속을 썩이던 한글도 깔끔하게 캡쳐되었군요. 이제 행복한 테스트만 남았습니다. 즐거운 시간 되시길 바랍니다.소소하지만 알찬 팁1: 필터 기능proxy 설정도구에서 필터 기능을 사용하면 원하는 것만 캡쳐할 수 있습니다.소소하지만 알찬 팁2: 테스트 기능스마트폰의 native App은 위와 같이 설정하면 테스트할 수 있습니다. 이제 휴대폰 테스트 결과를 PC로 수집할 수 있을 겁니다. 앱 테스트에 대한 상세 설명은 여기를 클릭하세요.소소하지만 알찬 팁3: 안 쓸 때는..proxy를 안 쓸 때는 System Proxy를 클릭해 끄도록 합시다.1) interceptor는 브라우저 요청을 postman에서 패킷을 캡쳐해주는 도구다.참고Capturing HTTP requests글천보성 팀장 | R&D 개발2팀[email protected]브랜디, 오직 예쁜 옷만#브랜디 #개발자 #개발팀 #인사이트 #경험공유 #Postman
조회수 1089

자바스크립트 기초 문법 정리 Part 3

함수와 이벤트에 대한 내용이 이렇게 간략할지 몰라 따로 파트를 나누어 포스팅을 진행하였는데 불필요한 나눔이 되었네요. 하지만 곧 더 간략하고 직관적으로 볼 수 있도록 기초 문법 총 정리 포스팅을 하도록 하겠습니다. 혹여 참고 문서로 본 포스팅을 보시는 분들은 곧 올라오는 총정리 포스팅을 참고하시면 좋을 것 같습니다.함수function 함수명() {    실행문;    return 데이터;}참조 변수 = function() {    실행문;}function 함수명() {(매개 변수1, 매개 변수2)    실행문;}   이벤트<button id="btn" onclikc="alert('event!')">버튼></button>이벤트 종류onmouseover - 마우스가 지정한 요소에 올라갔을 때 발생.onmouseout - 마우스가 지정한 요소에 벗어났을 때 발생.onmousemove - 마우스가 지정한 요소를 클릭했을 때 발생.ondvlclick - 마우스가 지정한 요소를 연속 두 번 클릭했을 때 발생.onkeypress - 지정한 요소에서 키보드가 눌렸을 때 발생.onkeydown - 지정한 요소에서 키보드를 눌렀을 때 발생.onkeyup - 지정한 요소에서 키보드를 눌렀다 떼었을 때 발생.onfocus - 지정한 요소에 포커스가 갔을 때 발생.onblur - 지정한 요소에 포커스가 다른 요소로 이동되어 잃었을 때 발생.onchange - 지정한 요소의 하위 요소를 모두 로딩했을 때 발생.onunload - 문서를 닫거나 다른 문서로 이동했을 때 발생.onsubmit - 폼 요소에 전송 버튼을 눌렀을 때 발생.onreset - 폼 요소에 취소 버튼을 눌렀을 때 발생.onresize - 지정된 요소의 크기가 변경되었을 때 발생.onerror - 문서 객체가 로드되는 동안 문제가 발생되었을 때 발생.참고문헌:Do it! 자바스크립트+제이쿼리 입문 - 정인용JavaScript 튜토리얼 문서 (http://www.w3schools.com/js/default.asp)티스토리 블로그와 동시에 포스팅을 진행하고 있습니다.http://madeitwantit.tistory.com#트레바리 #개발자 #안드로이드 #앱개발 #Node.js #백엔드 #인사이트 #경험공유
조회수 2723

Next.js 튜토리얼 4편: 동적 페이지

* 이 글은 Next.js의 공식 튜토리얼을 번역한 글입니다.** 오역 및 오탈자가 있을 수 있습니다. 발견하시면 제보해주세요!목차1편: 시작하기 2편: 페이지 이동 3편: 공유 컴포넌트4편: 동적 페이지  - 현재 글5편: 라우트 마스킹6편: 서버 사이드7편: 데이터 가져오기8편: 컴포넌트 스타일링9편: 배포하기개요여러 페이지가 있는 Next.js 애플리케이션을 만드는 방법을 배웠습니다. 페이지를 만들기 위해 한 개의 실제 파일을 디스크에 만들어야 합니다.그러나 진짜 애플리케이션에서는 동적 컨텐츠를 표시하기 위해 동적으로 페이지를 생성해야 합니다. Next.js를 사용해 이를 수행하는 여러 방법들이 있습니다.쿼리 문자열을 사용하여 동적 페이지를 생성해봅시다.간단한 블로그 애플리케이션을 만들 예정입니다. 이 애플리케이션은 home (index) 페이지에 전체 포스트 목록을 가지고 있습니다.포스트 제목을 클릭하면 뷰에서 각 포스트를 볼 수 있어야 합니다.설치이번 장에서는 간단한 Next.js 애플리케이션이 필요합니다. 다음의 샘플 애플리케이션을 다운받아주세요:아래의 명령어로 실행시킬 수 있습니다:이제 http://localhost:3000로 이동하여 애플리케이션에 접근할 수 있습니다.포스트 목록 추가하기먼저 home 페이지 안에 포스트 제목 목록을 추가해봅시다.pages/index.js에 다음과 같은 내용을 추가해주세요.위의 내용을 추가하면 다음과 같은 페이지가 보입니다:첫 번째 링크를 클릭하면 404 페이지가 나지만 괜찮습니다.페이지의 URL은 무엇인가요?- /?id=Hello Next.js- /post?title=Hello Next.js- /post?title=Hello Next.js- /post쿼리 문자열을 통해 데이터 전달하기쿼리 문자열(쿼리 파라미터)를 통해 데이터를 전달했습니다. 우리의 경우에는 "title" 쿼리 파라미터입니다. 다음에서 보이는 것처럼 PostLink 컴포넌트를 이용해 구현해봅시다:(Link 컴포넌트의 href prop를 확인해주세요.)이처럼 쿼리 문자열을 이용하여 원하는 모든 종류의 데이터를 전달할 수 있습니다."post" 페이지 생성이제 블로그 포스트를 보여줄 post 페이지를 생성해야 합니다. 이를 구현하기 위해 쿼리 문자열로부터 제목을 가져와야 합니다. 어떻게 구현하는지 살펴봅시다:pages/post.js 파일을 추가하고 다음과 같이 내용을 작성해주세요:다음과 같이 보입니다:위의 코드에서 무슨 일이 일어났는지 살펴봅시다.- 모든 페이지에서 현재 URL과 관련된 내용들을 가진 "URL" prop를 가져옵니다.- 이 경우 쿼리 문자열을 가진 "query" 객체를 사용하고 있습니다.- props.url.query.title를 사용해 제목을 가져왔습니다.애플리케이션에서 몇 가지를 수정해봅시다. "pages/post.js"를 다음과 같이 변경해주세요: http://localhost:3000/post?title=Hello Next.js 페이지로 이동하면 무슨 일이 일어날까요?- 예상대로 동작할 것이다.- 아무 것도 랜더링하지 않을 것이다.- 해더만 랜더링할 것이다.- 에러를 발생시킬 것이다.특별한 prop "url"보다시피 위의 코드는 이와 같은 에러를 발생시킵니다:url prop는 페이지의 메인 컴포넌트에만 전달되기 때문입니다. 페이지에서 사용되는 다른 컴포넌트에는 전달되지 않습니다. 필요하다면 다음과 같이 전달할 수 있습니다:마치며쿼리 문자열을 사용하여 동적 페이지를 생성하는 방법을 배웠습니다. 이제 시작일 뿐입니다.동적 페이지를 렌더링하기 위해 더 많은 정보가 필요합니다. 그리고 쿼리 문자열을 통해 모든 것을 전달할 수는 없을 것입니다. 또는 http://localhost:3000/blog/hello-nextjs와 같은 깔끔한 URL을 원할 것입니다.다음 편에서 이것들에 대해 모두 배울 수 있습니다. 이번 편은 모든 것의 기초입니다.#트레바리 #개발자 #안드로이드 #앱개발 #Next.js #백엔드 #인사이트 #경험공유
조회수 3161

Apache Spark에서 컬럼 기반 저장 포맷 Parquet(파케이) 제대로 활용하기 - VCNC Engineering Blog

VCNC에서는 데이터 분석을 위해 다양한 로그를 수집, 처리하는데 대부분은 JSON 형식의 로그 파일을 그대로 압축하여 저장해두고 Apache Spark으로 처리하고 있었습니다. 이렇게 Raw data를 바로 처리하는 방식은 ETL을 통해 데이터를 전처리하여 두는 방식과 비교하면 데이터 관리비용에서 큰 장점이 있지만, 매번 불필요하게 많은 양의 데이터를 읽어들여 처리해야 하는 아쉬움도 있었습니다.이러한 아쉬움을 해결하기 위해 여러 논의 중 데이터 저장 포맷을 Parquet로 바꿔보면 여러가지 장점이 있겠다는 의견이 나왔고, 마침 Spark에서 Parquet를 잘 지원하기 때문에 저장 포맷 변경 작업을 하게 되었습니다. 결론부터 말하자면 74%의 저장 용량 이득, 10~30배의 처리 성능 이득을 얻었고 성공적인 작업이라고 평가하지만 그 과정은 간단하지만은 않았습니다. 그 과정과 이를 통해 깨달은 점을 이 글을 통해 공유해 봅니다.Parquet(파케이)에 대해Parquet(파케이)는 나무조각을 붙여넣은 마룻바닥이라는 뜻을 가지고 있습니다. 데이터를 나무조각처럼 차곡차곡 정리해서 저장한다는 의도로 지은 이름이 아닐까 생각합니다.Parquet을 구글에서 검색하면 이와 같은 마룻바닥 사진들이 많이 나옵니다.빅데이터 처리는 보통 많은 시간과 비용이 들어가므로 압축률을 높이거나, 데이터를 효율적으로 정리해서 처리하는 데이터의 크기를 1/2 혹은 1/3로 줄일 수 있다면 이는 매우 큰 이득입니다. 데이터를 이렇게 극적으로 줄일 수 있는 아이디어 중 하나가 컬럼 기반 포맷입니다. 컬럼 기반 포맷은 같은 종류의 데이터가 모여있으므로 압축률이 더 높고, 일부 컬럼만 읽어 들일 수 있어 처리량을 줄일 수 있습니다.https://www.slideshare.net/larsgeorge/parquet-data-io-philadelphia-2013Parquet(파케이)는 하둡 생태계의 어느 프로젝트에서나 사용할 수 있는 효율적인 컬럼 기반 스토리지를 표방하고 있습니다. Twitter의 “Julien Le Dem” 와 Impala 프로젝트 Lead였던 Cloudera의 “Nong Li”가 힘을 합쳐 개발한 프로젝트로 현재는 많은 프로젝트에서 Parquet를 지원하고 컬럼 기반 포맷의 업계 표준에 가깝습니다.Parquet를 적용해보니 Apache Spark에서는, 그리고 수많은 하둡 생태계의 프로젝트들에서는 Parquet를 잘 지원합니다.val data = spark.read.parquet("PATH") data.write.parquet("PATH") Spark에서는 이런 식으로 손쉽게 parquet 파일을 읽고, 쓸 수가 있습니다. 데이터를 분석하기 전에 원본이라고 할 수 있는 gzipped text json을 읽어서 Parquet 로 저장해두고 (gzipped json은 S3에서 glacier로 이동시켜버리고), 이후에는 Parquet에서 데이터를 읽어서 처리하는 것 만으로도 저장용량과 I/O 면에서 어느 정도의 이득을 얻을 수 있었습니다. 하지만 테스트 결과 저장용량에서의 이득이 gz 23 GB 에서 Parquet 18GB 로 1/3 정도의 저장용량을 기대했던 만큼의 개선이 이루어지지는 않았습니다.Parquet Deep Dive상황을 파악하기 위해 조금 더 조사를 해보기로 하였습니다. Parquet의 포맷 스팩은 Parquet 프로젝트에서 관리되고 있고, 이의 구체적인 구현체로 parquet-mr 이나 parquet-cpp 프로젝트 등에서 스펙을 구현하고 있습니다. 그리고 특별한 경우에는 Spark에서는 Spark 내부에 구현된 VectorizedParquetRecordReader 에서 Parquet 파일을 처리하기도 합니다.파일 포맷이 바뀌거나 기능이 추가되는 경우에는 쿼리엔진에서도 이를 잘 적용해주어야 합니다. 하지만 안타깝게도 Spark은 parquet-mr 1.10 버전이 나온 시점에도 1.8 버전의 오래된 버전의 parquet-mr 코드를 사용하고 있습니다. (아마 다음 릴리즈(2.4.0)에는 1.10 버전이 적용될 것으로 보이지만)Parquet 의 메인 개발자 중에는 Impala 프로젝트의 lead도 있기 때문에, Impala에는 비교적 빠르게 변경사항이 반영되는 것에 비하면 대조적입니다. 모든 프로젝트들이 실시간적으로 유기적으로 업데이트되는 것은 힘든 일이기 때문에 어느 정도는 받아들여야겠지만, 우리가 원하는 Parquet의 장점을 취하기 위해서는 여러 가지 옵션을 조정하거나 직접 수정을 해야 합니다.VCNC 데이터팀에서는 저장 용량과 I/O 성능을 최적화하기 위하여 Parquet의Dictionary encoding (String들을 압축할 때 dictionary를 만들어서 압축하는 방식, 길고 반복되는 String이 많다면 좋은 압축률을 기대할 수 있습니다)Column pruning (필요한 컬럼만을 읽어 들이는 기법)Predicate pushdown, row group skipping (predicate, 즉 필터를 데이터를 읽어 들인 후 적용하는 것이 아니라 저장소 레벨에서 적용하는 기법)과 같은 기능들을 사용하기를 원했고, 이를 위해 여러 조사를 진행하였습니다.저장용량 줄이기102GB의 JSON 포맷 로그를 text그대로 gzip으로 압축하면 23GB가 됩니다. dictionary encoding이 잘 적용되도록 적절한 옵션 설정을 통해 Parquet로 저장하면 6GB로, 기존 압축방식보다 저장 용량을 74%나 줄일 수 있었습니다.val ndjsonDF = spark.read.schema(_schema).json("s3a://ndjson-bucket/2018/04/05") ndjsonDF. sort("userId", "objectType", "action"). coalesce(20). write. options(Map( ("compression", "gzip"), ("parquet.enable.dictionary", "true"), ("parquet.block.size", s"${32 * 1024 * 1024}"), ("parquet.page.size", s"${2 * 1024 * 1024}"), ("parquet.dictionary.page.size", s"${8 * 1024 * 1024}"), )). parquet("s3a://parquet-bucket/2018/04/05") 비트윈의 로그 데이터는 ID가 노출되지 않도록 익명화하면서 8ptza2HqTs6ZSpvmcR7Jww 와 같이 길어지기에 이러한 항목들이 dictionary encoding을 통해 효과적으로 압축되리라 기대할 수 있었고, Parquet에서는 dictionary encoding이 기본이기에 압축률 개선에 상당히 기대하고 있었습니다.하지만 parquet-mr 의 구현에서는 dictionary의 크기가 어느 정도 커지면 그 순간부터 dictionary encoding을 쓰지 않고 plain encoding으로 fallback하게 되어 있었습니다. 비트윈에서 나온 로그들은 수많은 동시접속 사용자들의 ID 갯수가 많기 때문에 dictionary의 크기가 상당히 커지는 상태였고, 결국 dictionary encoding을 사용하지 못해 압축 효율이 좋지 못한 상태였습니다.이를 해결하기 위해, parquet.block.size를 default 값인 128MB에서 32MB로 줄이고 parquet.dictionary.page.size를 default 값 1MB에서 8MB 로 늘려서 ID가 dictionary encoding으로만 잘 저장될 수 있도록 만들었습니다.처리속도 올리기저장용량이 줄어든 것으로도 네트워크 I/O가 줄어들기 때문에 처리 속도가 상당히 올라갑니다. 하지만 컬럼 기반 저장소의 장점을 온전하게 활용하기 위해서 column pruning, predicate pushdown들이 제대로 작동하는지 점검하고 싶었습니다.소스코드를 확인하고 몇 가지 테스트를 해 본 결과, Spark에서는 Parquet의 top level field에서의 column pruning은 지원하지만 nested field들에 대해서는 column pruning을 지원하지 않았습니다. 비트윈 로그에서는 nested field들을 많이 사용하고 있었기에 약간 아쉬웠으나 top level field에서의 column pruning 만으로도 어느 정도 만족스러웠고 로그의 구조도 그대로 사용할 예정입니다.Predicate pushdown도 실행시간에 크게 영향을 줄 거라 예상했습니다. 그런데 Spark 2.2.1기준으로 column pruning의 경우와 비슷하게, top level field에 대해서만 predicate pushdown이 작동하는 것을 확인할 수 있었습니다. 이는 성능에 큰 영향을 미치기에, predicate 로 자주 사용하는 column들을 top level 로 끌어올려 저장하는 작업을 하게 되었습니다. 여기에 추가로 parquet.string.min-max-statistics 옵션을 손보고 나서야 드디어 10~30배 정도의 성능 향상을 볼 수 있었습니다.매일 15분 정도 걸리던 "의심스러운 로그인 사용자" 탐지 쿼리가 30여초만에 끝나고, cs처리를 위해 한 사람의 로그만 볼 때 5분 정도 걸리던 쿼리가 30여초만에 처리되게 되었습니다.못다 한 이야기parquet.string.min-max-statistics 옵션과 row group skipping에 관해.Parquet 같은 포맷 입장에서 string 혹은 binary 필드의 순서를 판단하기는 어렵습니다. 예를 들어 글자 á 와 e 가 있을 때 어느 쪽이 더 작다고 할까요? 사전 편찬자라면 á가 더 작다고 볼 것이고, byte 표현을 보면 á는 162이고 e는 101이라 e가 더 작습니다. Parquet 같은 저장 포맷 입장에서는 binary 필드가 있다는 사실만 알고 있고, 그 필드에 무엇이 저장될지, 예를 들어 á와 e가 저장되는지, 이미지의 blob가 저장되는지는 알 수 없습니다. 그러니 순서를 어떻게 정해야 할지는 더더구나 알 수 없습니다.그래서 Parquet 내부적으로 컬럼의 min-max 값을 저장해 둘 때, 1.x 버전에서는 임의로 byte sequence를 UNSINGED 숫자로 해석해 그 컬럼의 min-max 값을 정해 저장했습니다. 이후에 이를 개선하기 위해 Ryan Blue가 PARQUET-686에서 parquet-format에 SORT_ORDER를 저장할 수 있도록 했습니다.여기에서 문제는 이전 버전과의 호환성입니다. SORT_ORDER가 없던 시절의 Parquet 파일을 읽으려 할 때, min-max 값을 사용해 row group skipping이 일어나면 query 엔진에서 올바르지 않은 결과가 나올 수 있으니, binary 필드의 min-max 값을 parquet-mr 에서 아예 반환하지 않게 되어있습니다.하지만 이는 우리가 원하는 동작이 아닙니다. 여기에 parquet.string.min-max-statistics option을 true로 설정하면, 이전처럼 binary필드의 min-max값을 리턴하게 되고 rowgroup skipping이 작동하여 쿼리 성능을 크게 올릴 수 있습니다.마치며Spark과 Parquet 모두 많은 사람이 사랑하는 훌륭한 오픈소스 프로젝트입니다. 또한 별다른 설정이나 튜닝 없이 기본 설정만으로도 잘 돌아가는 편이기 때문에 더더욱 많은 사람이 애용하는 프로젝트이기도 합니다.하지만 오픈소스는 완전하지 않습니다. 좋은 엔지니어링 팀이라면 단지 남들이 많이 쓰는 오픈소스 프로젝트들을 조합해서 사용하는 것에서 그치지 않고 핵심 원리와 내부 구조를 연구해가며 올바르게 활용해야 한다고 생각합니다. 기술의 올바른 활용을 위해 비트윈 데이터팀은 오늘도 노력하고 있습니다.
조회수 4239

왜 SVG로 갈아탔는가?

이 글에서는 데일리호텔이 왜 png에서 svg로 갈아탔는지, 그리고 간단한 svg 실무 적용 팁에 대해 알려드리고자 합니다.01 SVG란 무엇인가?SVG는 “ Scalable Vector Graphics”의 약자입니다.JPEG, PNG 처럼 SVG도 그래픽 포맷(Graphic format) 중 하나입니다. SVG는 벡터 기반이기 때문에 리사이징이 되어도 전혀 깨지지 않습니다. 모든 해상도에서 자유자재로 활용할 수 있기 때문에 특정 해상도에 제한되어있지 않다는 게 핵심 포인트라고 할 수 있습니다.02 SVG가 왜 좋은가?다른 그래픽 포맷보다 SVG가 좋은 이유는 참으로도 다양합니다. 필자가 생각했을 때의 핵심 장점들은 이러합니다.1. 특정 사이즈에 구애를 받지 않습니다.즉 어느 해상도에서든 pixelate 되지 않습니다. 요새 디자이너들이 자주 사용하는 디자인 프로그램인 스케치로 따지면 아트보드와 비슷한 것 같습니다. 아트보드 안에 만든 레이어, 요소들은 다 벡터 기반입니다. 아트보드를 리사이징 해도 안에 요소들은 깨지지 않고 그 모습 그대로를 가지고 있습니다. 같은 원리로 SVG도 어떤 사이즈로든 그 모습 그대로가 유지됩니다. 그렇기 때문에 사이즈별로 아이콘을 일일이 생성해서 개발자에게 넘겨줄 필요가 없습니다. SVG 파일 하나면 모든 해상도를 대응할 수 있습니다.2. 작은 파일 사이즈비트맵 이미지들(PNG, JPEG) 같은 경우 파일 크기를 결정하는 주요 요소는 바로 ‘해상도’입니다. 예를 들어 5000x5000 픽셀 이미지는 항상 500x500보다 파일 사이즈가 큽니다.반면, SVG 그래픽 같은 경우 파일 크기를 결정하는 주요 요소는 바로 ‘복잡도’입니다. Path가 비교적 적은 간단한 이미지는 PNG, JPEG 보다 파일 사이즈가 적을 수도 있지만 이미지를 구성하는 요소의 복잡도(레이어가 많다든지 특정 효과가 많다든지)에 따라 파일 사이즈가 커집니다.하지만 이런 용량 문제는 SVG Optimizing을 하게 되면 나름 해결됩니다. 필자 같은 경우 업무적으로 스케치를 사용하고 있기 때문에 스케치에서 제공해주는 SVGO Compressor 플러그인을 활용하고 있습니다.https://github.com/BohemianCoding/svgo-compressorBohemianCoding/svgo-compressorsvgo-compressor - A Plugin that compresses SVG assets using SVGO, right when you export them. This Plugin requires Sketch 3.8.github.com 작은 파일 사이즈로 인해 로딩 시간도 훨씬 더 줄어든다는 장점 또한 있습니다.여기서 잠깐!혹시나 Bitmap과 SVG의 구성요소에 대해 잘 모르실 분들을 위하여 간단한 비교 해드리겠습니다.비트맵 그래픽: Raster Graphics (픽셀 기반)대표적인 포맷은 JPEG, PNG입니다. 이들은 픽셀로 구성되어 있습니다. 예를 들어 2x2 픽셀인 비트맵 이미지는 총 4px로 구성되어 있습니다. 개개인에 대한 픽셀들은 자유자재로 바꿀 수가 없고 움직일 수도 없습니다. 그렇기 때문에 100% 이상으로 이미지를 확대하면 Pixelate가 됩니다.SVG 그래픽: 벡터 기반픽셀로 구성되어 있지 않고 작업하고 있는 그래픽에 대한 정보로 구성되어 있습니다. 그렇기 때문에 어떤 사이즈로든 자유자재로 늘어나는 것이 가능합니다. 이러한 이유들로 인해 코드로 쉽게 적용된 스타일을 수정할 수 있습니다. 예를 들어 동그라미의 보더 값을 6에서 8로 바꾼다 / 색상을 그레이에서 블랙으로 바꾼다 / 사이즈를 40x40에서 80x80을 바꾼다 등스케치로 작업할 때도 쉽게 두 개의 차이점을 확인해볼 수 있습니다. 스케치에서 Export를 할 경우 비트맵 이미지는 하나의 압축된 레이어로 Export 됩니다. 반면 SVG는 레이어 그대로 눈에 보이지 않는 그래픽을 구성하는 정보들이 같이 저장된 채 Export가 됩니다.SVG를 구성하는 눈에 보이지 않는 정보들03 스케치가 SVG 이미지를 Export하는 방식다른 그래픽 포맷보다 SVG가 좋은 이유는 참으로도 다양합니다. 제가 생각했을 때의 핵심 장점들은 이러합니다.Sketch Export 기능스케치 하단 오른쪽 패널을 보면 Export 버튼이 있습니다. 여기서 Format을 SVG로 바꾸고 Export하면 금방 쉽게 끝나겠지 라고 생각할 수 있는데 여기서 조심해야 할 점은 본인이 어떻게 이미지를 작업했냐에 따라 옳지 않게 SVG가 내보내질 수 있습니다. 옳지 않게 SVG가 내보내 지게 되면 나중에 두 번 일을 작업하는 일이 발생할 수도 있습니다.쉽게 이해하실 수 있도록 이미지를 제작해 보았습니다. 아래 이미지는 같은 디자인인데 만들어진 방식이 각각 다릅니다.같은 아이콘이지만 구성하는 방식이 다름1. Two Shape2. One Shape3. Border and Shape Mix위 3가지 방법들은 옳고 그름이 없습니다. 다만 어떻게 이 아이콘을 나중에 활용할 것인가에 따라 만드는 방법이 달라지겠죠. 만약에 자동차 아이콘 안에 헤드라이트 색상을 바꾸고 싶다고 하면 위 방법 중 1번을 선택하면 될 것이고 선의 두께를 따로 조정하고 싶다 하면 3번 방식을 택하면 됩니다.SVG에 대해 잘 알지 못할 때는 프로그램 탓을 했었습니다. ‘왜 프로그램이 알아서 잘 못해주지?’라는 질문을 던졌지만… 슬프게도 이건 프로그램 잘못이 아닌 작업자 잘못입니다 �스케치 프로그램이든 아도비 일러스트레이터든 이 프로그램들은 디자이너가 만든 그래픽을 있는 그대로 svg 레이어로 번역하도록 프로그램이 되어 있습니다. 디자이너가 어떻게 작업했냐에 따라 그 정보 그대로 인식해서 svg로 만들어줍니다.04 SVG 아이콘이 제대로 적용 안될 경우다른 그래픽 포맷보다 SVG가 좋은 이유는 참으로도 다양합니다. 필자가 생각했을 때의 핵심 장점들은 이러합니다.헐 이건 도대체 왜….?!!!어느 날 SVG를 적용하기로 마음먹고 데일리호텔 앱 내 편의시설 아이콘 중 수영장 SVG 파일을 개발자에게 넘겼습니다. 근데 구멍이 뚫려야 할 곳이 채워져서 나오는데 원인을 모르고 헤매던 시절이 있었습니다. 미디엄에서 이 문제를 해결해줄 좋은 글을 발견하게 되었는데 난생처음 보는 단어가 2개 있었습니다.Even-Odd, Non-Zero…여기서 Even-Odd, Non-Zero의 차이점을 자세히 언급하기에는 너무 길어서 제가 참고한 미디엄 블로그 링크를 공유해드릴 테니 가서 보시면 이해하실 수 있을 것 같습니다. 작업하기에 앞서 꼭 읽어보시기를 권장합니다.https://medium.com/sketch-app-sources/preparing-and-exporting-svg-icons-in-sketch-1a3d65b239bbPreparing and Exporting SVG Icons in Sketch – Design + Sketch – MediumThis article is going to assume that you already understand the fundamentals of icon design. And focus on how to prepare and export them…medium.com 그래도 가볍게 필요한 내용만 공유드리자면 안드로이드에서는 fill-rule:evenodd를 제대로 지원하지 않고 fill-rule:nonzero만 지원한다고 보시면 됩니다. Even Odd는 특정 앱에서 호환이 안된다는 뜻입니다. (안드로이드 API 24 이상에서만 evenodd가 지원됨)근데 우리가 사용하고 있는 스케치 프로그램에서는 default값이 fill-rule:evenodd로 설정이 되어있고 여러 Path가 겹치는 아이콘 같은 경우 그대로 svg export를 하게 되면 위에서 제가 경험하였던 아이콘이 다 채워진 현상을 겪을 수 있게 되는 것입니다.1. Fills 섹션에서 Even-Odd를 Non-Zero로Fills 섹션에 가면 설정 아이콘이 있습니다. 클릭 시 Even-Odd가 디폴트 값인 것을 확인할 수 있습니다.스케치 Fill Default 값 = Even-OddNon-Zero로 설정값을 바꾸면 수영장 사다리 부분이 가득 채워진 채로 나오게 되는 것을 확인할 수 있습니다. 실제로 이 파일을 개발자에게 넘기게 되면 이렇게 채워진 채로 아이콘이 노출이 됩니다.Non-Zero 설정 / 모든 shape이 다 칠해짐이렇게 나가면 안 될 테니 수정하는 법을 알려드리겠습니다.2. Paths > Reverse Order 적용원래 뚫려 있어야 하는 Path를 Layer 패널에서 찾으면 됩니다. 빨간색으로 칠한 부분이 뚫려있어야 하는 부분들입니다.레이어 패널에서 path 확인하기Path가 선택된 채로 Layers > Paths > Reverse Order을 클릭합니다.Paths > Reverse OrderReverse Order을 클릭한 후 원래 뚫려있어야 하는 부분이 뚫리게 됩니다. 이 상태로 svg로 export하시고 개발자에게 전달을 하면 됩니다.마치며개인적으로 SVG에 대한 장점이 너무나도 크다고 생각하여 굳이 갈아타지 않을 이유가 없다고 생각합니다. 특히 Web 디자인을 할 때도 SVG를 저는 적극적으로 사용하시라고 권장하고 싶습니다. � 안드로이드 개발자에게 넘기기 전에 SVG 파일이 문제가 있는지 가볍게 확인하고 싶은 경우 아래와 같은 사이트를 추천해드립니다.http://inloop.github.io/svg2android/위에 문제가 되었던 수영장 아이콘을 이 사이트에 올려서 보게 되면 이런 화면이 뜹니다. Warning하고 노란색 경고 박스가 뜨게 되는데 fills-rule:evenodd에 대해서 언급을 하더라구요. 정말 유용한 사이트인 것 같습니다.아울러..많은 디자이너들이 SVG 적용을 해보시길 바라며 주변에 이 글도 많이 공유해주시면 감사하겠습니다. (ㅎㅎ)또한 데일리호텔 Tech, UI/UX 등의 정보를 얻어보고자 하시는 분은 https://dailyhotel.io/ 를 읽어 보시길 권장합니다.그럼 다음에도 좋은 정보로 찾아뵙겠습니다!원문 링크 : https://dailyhotel.io/디자인-안드로이드-앱-svg-아이콘-적용기-왜-svg로-갈아탔는가-99c57cd84240작성자 : Product팀 Rachel Kim#데일리 #데일리호텔 #개발자 #개발팀 #업무환경 #개발환경 #SVN
조회수 1155

테이블이냐, 컬렉션이냐, 그것이 문제로다!(KOR)

편집자 주 외래어 표기법에 따르면 ‘원어에서 띄어 쓴 말은 띄어 쓴 대로 한글 표기를 하되, 붙여 쓸 수도 있다.’고 규정하고 있다.(제3장 제1절 영어의 표기, 제10항과, 컴퓨터 전문어, 전기 전문어 등) 즉 ‘원칙’과 ‘허용’이 모두 가능하다는 의미다. 이를 바탕으로 여러 표기 용례를 참고한 결과, TableView는 ‘테이블뷰(원칙)’로 표기해야 하나, 본문에서는 독자의 가독성을 높이기 위해 ‘테이블 뷰(허용)’로 표기한다. 응용하여, CollectionView는 ‘컬렉션 뷰’로, TableViewCell은 ‘테이블 뷰 셀’ 등으로 띄어 쓴다. Overview앱에서 데이터를 사용자에게 보여줄 땐 여러 가지의 모습으로 나타납니다. 설정 앱처럼 목록으로 보여줄 때도 있고, 사진 앱처럼 그리드(grid) 형식으로 보여줄 때도 있습니다. 이처럼 데이터를 보여줄 때 많이 사용되는 뷰는 테이블 뷰(UITableView) 또는 컬렉션 뷰(UICollectionView)입니다. 각자 특징이 있기 때문에 앱의 성격에 따라 적절한 뷰를 사용해야 합니다. 왜냐하면 목록을 보여주는 디자인을 바꿀 때, 다시 개발해야 하는 수고를 덜 수 있기 때문입니다. 이번 글에선 각각의 뷰를 간략하게 알아보겠습니다. 목록 형식의 설정 앱과 그리드 형식의 사진 앱 스크린샷테이블 뷰(UITableView)단일 열에 배열된 행을 사용해 데이터를 표시하는 뷰입니다. 수직 스크롤만 가능하며, 테이블의 개별 항목을 구성하는 셀은 테이블 뷰 셀(UITableViewCell) 객체입니다. 테이블 뷰는 이 객체들을 이용해 테이블에 표시되는 행을 그립니다. 여러 행은 하나의 섹션 안에 구성될 수 있으며, 각 섹션은 헤더(header)와 푸터(footer)를 가질 수 있습니다. 섹션과 행은 인덱스 번호로 구별하는데, 번호는 0부터 시작합니다. 테이블 뷰는 plain과 grouped 스타일 중 한 가지의 스타일을 가질 수 있습니다. Plain 스타일은 보통 목록 스타일입니다. 섹션의 헤더와 푸터는 섹션 분리기(inline separators)로 표시되고 스크롤을 할 때 해당 섹션 안에 있는 콘텐츠 위에 나타납니다. Grouped 스타일은 시각적으로 뚜렷한 행 그룹을 표시하는 섹션이 있습니다. 섹션의 헤더와 푸터는 콘텐츠 위에 나타나지 않습니다. 아래와 같은 사진을 보시면 확연히 차이를 볼 수 있습니다. plain 스타일의 연락처 앱과 grouped 스타일의 설정 앱테이블 뷰의 많은 메소드들은 인덱스패스(NSIndexPath) 객체를 매개변수 또는 리턴 값으로 사용합니다. 테이블 뷰는 해당하는 행의 색인 인덱스와 섹션 인덱스 값을 가져올 수 있게 인덱스패스의 범주를 선언합니다. 또한 색인 인덱스와 섹션 인덱스 값을 가지고 인덱스패스를 만들 수 있습니다. 특히 여러 섹션이 있는 테이블 뷰는 섹션 인덱스 값이 반드시 있어야 행의 인덱스 번호로 구별할 수 있습니다.override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> AttractionTableViewCell {         // Table view cells are reused and should be dequeued using a cell identifier.         let cellIdentifier = "AttractionTableViewCell"              guard let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as? AttractionTableViewCell else {             fatalError("The dequeued cell is not an instance of AttractionTableViewCell.")         }                 let attraction = attractions[indexPath.row]                 cell.attractionLabel.text = "\(indexPath.row). \(attraction.nameWithDescription)"         cell.attractionImage.image = attraction.photo                 cell.attractionImage.tag = indexPath.row                 attraction.indexPath = indexPath                 ...                 return cell     } 위의 코드는 데이터 소스(data source) 메소드로, 테이블 뷰의 특정한 위치에 셀을 추가합니다. 다시 말해, 이 메소드는 테이블 뷰가 ‘표시할 새로운 셀이 필요할 때마다’ 특정 행에 노출할 정보가 있는 셀을 만들고 리턴하는 걸 말합니다. 매개변수로 필요한 셀 객체의 행을 가리키는 indexPath 값을 전달합니다. 그리고 indexPath의 row 값을 이용해서 attraction이라는 배열 인덱스로 활용하고, 셀에 표시할 정보들을 설정합니다. 여기서 attraction 배열은 관광 명소들의 정보들이 담고 있는 배열인데, 1행은 첫 번째로 저장한 관광 명소, 2행은 두 번째로 저장한 관광 명소 등 순서대로 설정하도록 indexPath.row 값을 이용하는 것입니다. indexPath의 row 값과 배열의 인덱스 값은 0부터 시작하기 때문입니다. 해당 예제는 섹션이 1인 경우이기 때문에 섹션 인덱스 값이 없지만, 섹션이 여러 개 있다면 반드시 섹션 인덱스 값을 이용해서 설정해야 합니다.테이블 뷰 객체는 데이터 소스(data source)와 델리게이트(delegate)가 필요합니다. 데이터 소스는 UITableViewDataSource 프로토콜을 구현해야 하고, 델리게이트는 UITableViewDelegate 프로토콜을 구현해야합니다. 데이터 소스는 테이블 뷰가 테이블을 만들 때 필요한 정보를 제공하고 테이블의 행이 추가, 삭제 또는 재정렬할 때 데이터 모델을 관리합니다. 델리게이트는 화면에 보이는 모습과 행동을 담당합니다. 예를 들어 표시할 행의 수, 사용자가 특정 행을 터치했을 때, 행의 재정렬 등과 같은 것입니다.override func numberOfSections(in tableView: UITableView) -> Int {         // #warning Incomplete implementation, return the number of sections         return 1     }      override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {         // #warning Incomplete implementation, return the number of rows         return attractions.count     } 위의 두 소스는 데이터 소스가 필수적으로 구현해야 하는 메소드입니다. 하나는 섹션의 개수를 리턴하고, 또 하나는 한 섹션 안에 있는 행의 개수를 리턴합니다.테이블 뷰는 수정 모드에서 행을 추가, 삭제, 재정렬할 수 있습니다. 각 행은 테이블 뷰 셀에 연관된 editingStyle에 따라서 추가, 삭제, 재정렬을 할 수 있는데, 예를 들어 editingStyle이 insert라면 추가하는 메소드를 실행하고, delete면 삭제하는 메소드를 실행합니다. 행의 showsReorderControl 속성이 true라면, 재정렬하는 메소드를 실행할 수 있습니다.// Override to support editing the table view.     override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {         if editingStyle == .delete {             // Delete the row from the data source             ...                 // delete rows and attractions and reload datas             attractions.remove(at: indexPath.row)             tableView.deleteRows(at: [indexPath], with: .middle)             tableView.reloadData()         } else if editingStyle == .insert {             // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view         }     } 위 소스는 editingStyle이 delete일 때 셀을 삭제하고 테이블 뷰를 다시 로드하는 기능을 구현한 것입니다.테이블 뷰를 만드는 가장 쉽고 권장하는 방법은 바로 스토리보드에서 테이블뷰컨트롤러(UITableViewController)를 이용해서 만드는 겁니다. 런타임에 테이블뷰컨트롤러는 테이블 뷰를 만들고 델리게이트와 데이터 소스를 자기 자신으로 할당합니다.컬렉션 뷰(UICollectionView)컬렉션 뷰는 테이블 뷰에서 할 수 있는 모든 것을 할 수 있습니다. 섹션을 가질 수 있고, 인덱스패스 값을 이용해서 셀을 구별합니다. 이 셀들은 컬렉션 뷰 셀(UICollectionViewCell)의 서브 클래스이며 데이터 소스(UICollectionViewDataSource)와 델리게이트(UICollectionViewDelegate)가 필요합니다. 셀을 추가, 삭제, 재정렬하는 기능도 구현할 수 있습니다. 그렇다면 컬렉션 뷰와 테이블 뷰를 구분하는 특징은 무엇일까요? 바로 레이아웃입니다. 컬렉션 뷰는 여러 개의 열과 행으로 셀을 표현할 수 있습니다. 예를 들어, 그리드(grid) 형태로 아이템의 목록을 보여줄 수 있습니다. 그래서 수직 스크롤뿐만 아니라 수평 스크롤도 할 수 있습니다.스토리보드에서 디자인한 테이블 뷰 셀과 컬렉션 뷰 셀위 스크린샷에서 테이블 뷰와 컬렉션 뷰의 가장 큰 차이는 바로 셀입니다. 테이블 뷰에서는 하나의 열에 여러 행을 표시하는 형식이기 때문에, 셀의 모습을 행에 맞춰서 디자인합니다. 하지만 컬렉션 뷰는 열과 행을 만들 수 있기 때문에, 꼭 행의 모습이 아니더라도 다양한 모습으로 셀을 디자인할 수 있습니다. 컬렉션 뷰 셀의 가장 큰 특징이기도 하죠. 위처럼 셀을 디자인하고 앱을 실행하면 아래의 화면이 나타납니다.테이블 뷰와 컬렉션 뷰의 앱 화면 차이또한 컬렉션 뷰는 레이아웃 객체가 있습니다. 기존에 제공하는 flow layout을 사용해도 괜찮지만, 본인이 원하는 레이아웃 모양을 custom layout을 만들어서 사용합니다. 이를 담당하는 프로토콜은 UICollectionViewDelegateFlowLayout 입니다.func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {         let fullWidth = collectionView.frame.size.width - (self.CGFLOAT_INSET_WIDTH * 3) - (self.CGFLOAT_ITEMSPACING * 3)         let width = fullWidth/3         return CGSize(width: width, height: width + self.CGFLOAT_HEIGHT_ATTRACTIONCELL_DEFAULT)     }         func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {         return UIEdgeInsetsMake(self.CGFLOAT_LINESPACING_VERTICAL, self.CGFLOAT_INSET_WIDTH, self.CGFLOAT_LINESPACING_VERTICAL, self.CGFLOAT_INSET_WIDTH)     } 위 소스에서 collectionView(:layout:sizeForItemAt:) 메소드는 해당하는 셀의 사이즈를 설정하고, collectionView(:layout:insetForSectionAt:) 메소드는 섹션 안에 margin을 설정합니다.여러 모양의 셀을 이루어 하나의 뷰 화면을 구현할 수도 있습니다. 섹션마다 셀을 만들어 각각 다른 모습의 셀을 설정하고, 한 화면에 다양한 모습의 셀을 가진 뷰를 만드는 것입니다. 예를 들어, 헤더, 메뉴, 본문, 푸터 각각 셀을 만들어서 원하는 모양으로 만들고, 하나의 뷰 컨트롤러에 셀을 조합해서 한 화면에 나타나게 할 수 있습니다. 이 방법을 사용하면 자주 사용하는 셀을 재활용할 수 있습니다. 똑같은 헤더와 푸터 셀을 여러 번 만들지 않고 기존의 셀을 재활용하면 시간도 절약하고, 훨씬 깔끔한 소스를 만들 수 있을 겁니다.브랜디 앱 스크린샷 일부위의 스크린샷처럼 여러 화면에서 보여줘야 할 똑같은 뷰가 있을 때, 셀 xib 파일을 만들고 컬렉션 뷰에서 셀을 섹션별로 설정 및 사용하면 재활용하기 좋습니다.Conclusion지금까지 테이블 뷰와 컬렉션 뷰의 특징들을 살펴봤습니다. 한마디로 정리하면 테이블 뷰는 가장 간단한 목록을 만들 수 있습니다. 컬렉션 뷰는 다양한 모습의 목록으로 커스터마이징(Customizing)할 수 있습니다.그렇다면 우리는 어떤 것을 선택해야 할까요? 구현할 목록이 얼마나 복잡한지에 따라 선택은 달라집니다. 테이블 뷰는 간단하고 보편적인 목록을 만듭니다. 반면에 컬렉션 뷰는 특정한 모습의 목록을 만들 수 있습니다. 그래서 테이블 뷰는 목록이 간단하고 디자인 변경이 없을 때만 사용하길 권장합니다. 하지만 나중에 디자인이 바뀔 수도 있다면 컬렉션 뷰를 사용하는게 더 좋겠죠.Simple is the best! 간단하게 구현할 수 있는 건 테이블 뷰를 사용합시다. 테이블 뷰에서 구현하기 힘들다면 컬렉션 뷰를 이용해 개성 있는 목록을 마음껏 만들어봅시다!참고UITableView - UIKit | Apple Developer DocumentationUICollectionView - UIKit | Apple Developer Documentation 글김주희 사원 | R&D 개발1팀[email protected]브랜디, 오직 예쁜 옷만#브랜디 #개발문화 #개발팀 #업무환경 #인사이트 #경험공유

기업문화 엿볼 때, 더팀스

로그인

/