1. Spring Boot 배포 방법

Spring Boot 공식문서를 보면 AWS에서 스프링부트를 배포할 수 있는 다양한 방법에 대해서 설명하고 있습니다. 그 중 Elastic Beanstalk을 가장 대표적으로 안내하고 있는데요, CLI를 통해서 배포하는 방법을 링크로 소개해주고 있지만 여기서는 GUI를 통해서 간단하게 배포해보고 또 그 과정에서 생긴 자잘한 버그에 대해서 공유해보고자 합니다.

 

2. Elastic Beanstalk을 이용한 배포 과정

2.1 AWS Management console에서 beanstalk을 검색합니다.

2.2 새 환경 생성을 클릭하고 환경 티어를 선택합니다.

우리는 Spring Boot를 이용한 웹 서버를 배포할 예정이므로 웹 서버 환경을 선택하도록 합니다.

2.3 웹 서버 환경 생성

  • 어플리케이션 이름 : 원하시는 어플리케이션 이름을 설정하시면됩니다.
  • 환경 정보 : 환경 이름과 도메인 정보를 입력합니다. 환경이름은 보통 어플리케이션 이름과 동일하게 사용합니다.
  • 플랫폼 : 관리형 플랫폼을 선택합니다.
    • 플랫폼은 Spring Boot를 위해 Java를 사용하도록합니다.
    • 플랫폼 브렌치는 자바 버전에 맞게 8 또는 11버전을 사용하도록 합니다. 스프링 부트와 호환되는 버전을 사용하시면 됩니다.
    • 플랫폼 버전은 추천되는 버전을 사용하도록 합니다.
  • 어플리케이션 코드
    • 여기서 스프링에서 배포할 JAR파일을 업로드 해도 되지만 우선 샘플 애플리케이션을 선택하도록 합니다. 만약 사용하는 Spring Boot가 데이터베이스를 사용하는 경우(대부분 그렇겠지만) 에러가 발생하기 때문에 데이터 베이스 구성을 완료한 이후 배포할 JAR 파일을 업로드 하는 것이 좋습니다.

2.4 Elastic Beanstalk 데이터베이스 설정

대부분의 Spring Boot 프로젝트는 데이터베이스를 사용합니다. 하지만 Elasic Beanstalk은 자동으로 데이터베이스까지 구성해주지 않기 때문에 데이터베이스를 먼저 설정해주어야합니다.

 

1. 좌측 Drawer에서 구성을 클릭합니다.

2. 구성 목록 중 가장 아래에 있는 데이터베이스에서 편집을 클릭합니다.

3. 편집에서 자신이 사용하고자 하는 데이터베이스 엔진과 옵션을 선택하도록 합니다.

4. 적용을 클릭합니다.

 

이렇게하면 Elastic Beanstalk을 위한 데이터베이스가 생성되며 아래와 같이 데이터베이스 엔드포인트가 생성됩니다.

3. Spring Boot Profile 수정

이제 배포를 위한 Spring Boot의 환경을 구성해보도록 합시다. 배포를 위한 prod 프로파일을 application.yml에 생성하고 여기에 데이터베이스 url과 server port를 입력하도록 합니다.

1. spring:datasource:url 입력

제가 사용한 url의 형식은 다음과 같습니다. {*} 대신에  이전과정에서 설정한 데이터베이스 옵션과 생성할 데이터베이스 이름을 설정해주도록합시다. 사실 데이터베이스 이름이나 사용자 이름 비밀 번호 등은 datasource에서 옵션으로 지정해주어도 되지만 저는 url을 사용하는게 습관이 되어서 아래처럼 형식을 생성했습니다. 

jdbc:mysql://{사용자 이름}:{비밀먼호}@{데이터베이스 엔드포인트 주소}/{데이터베이스 이름}?createDatabaseIfNotExist=true

2. server port

Elastic Beanstalk은 기본적으로 5000 포트로 요청을 받습니다. 따라서 디폴트인 8080을 사용하면 에러가 발생하기 때문에 서버 포트를 5000번으로 수정합니다.

3. Jar 스냅샷 생성

이제 JAR 스냅샷을 생성하여 Elastic Beanstalk에 업로드를 준비해 봅시다. 저는 gradle을 이용하여 JAR 파일을 생성하였습니다. maven을 사용하시는 분은 maven 빌드를 이용해서 똑같이 JAR파일을 생성할 수 있습니다.

./gradlew clean bootJar -Pprofile=prod

4. Spring Boot JAR 배포

이제 마지막입니다. 코드를 배포하기 전 Elastic Beanstalk에서도 프로파일 옵션을 활성화해주어야 합니다. 좌측 Drawer에서 구성을 클릭하고 소프트웨어 카테고리의 편집을 클릭합니다.

환경 속성에서 SPRING_PROFILES_ACTIVE을 prod로 설정합니다.

적용 버튼을 누르고 Elastic Beanstalk의 초기화면으로 돌아와 업로드 및 배포 버튼을 클릭하여 이전에 생성한 스프링 부트 JAR파일을 선택합니다!

배포가 완료되면 상태가 확인으로 변하게 됩니다. 만약 위험이나 경고 등 버그가 발생한다면 좌측 Drawer에서 로그를 클릭하고 전체 로그를 요청합니다. 전체 로그 파일을 다운로드 한 후 web.stdout.log 파일을 확인하면 Intellij 에서 처럼 Spring Boot 실행시 발생한 로그를 확인할 수 있습니다.

4. 접속

Elastic Beanstalk의 초기화면에 있는 도메인을 클릭해서 정상적으로 실행된다면 성공입니다. 저는 API Document인 Swagger에 접속하였습니다. 

 

혹시 글에 잘못된 점이나 궁금한 점이 있다면 댓글로 알려주시면 감사드리겠습니다!

들어가며

회사에서 웹 프로젝트를 개발하면서 협업 단계에서 서로 다른 개발환경과 로컬/테스트/배포 환경의 구성으로 인해 많은 문제점을 마주쳤었다. 이번 포스트에는 이와 관련된 문제들가 이를 어떻게 해결했었는지를 기록해보고자 합니다.

 

문제점

꽤 오래전의 일이지만 설명의 흐름을 위해서 처음부터 시작해보겠습니다.

1. 서로 다른 개발환경

팀 프로젝트를 진행하면서 다른 개발자가 커밋한 코드를 받아서 내 로컬에서 실행하면 한 번쯤은 오가는 말들이 있습니다.

A : "커밋 해주신거 설치해봤는데 안 돌아가는데요?"
B : "어 저는 돌아가는데요? 왜 그러지..."

확인해보면 .gitignore로인해 SSH/Oauth2 key, Database Profile 등 민감한 데이터가 공유되지 않은 것 부터 환경 변수 차이 등 다양한 문제가 있습니다. 하지만 대부분 버전이 조금씩 다르거나 같은 버전이어도 JVM 설정이 다른 경우 등 자잘한 문제를 찾고 해결하는데 진땀을 흘린적이 많았습니다.

 

2. 배포과정

1번 문제를 해결해서 이제 서로 공유된 코드를 실행할 수 있다고 해도 배포 과정에서 다시 문제가 생깁니다. 이전에 정리했던 설정을 일일이 업데이트하고 설정 변경사항이 생기면 이 또한 업데이트를 해야 원활히 배포코드를 실행할 수 있습니다.

 

1차 해결방법

짐작했겠지만, 컨테이너를 도입해서 개발환경을 일치시키도록 하였습니다. Chef를 이용하는 방법도 있지만, 단순한 Configuration 세팅 뿐만 아니라 OS의 차이 등 Chef만으로는 통일시키기 어려운 환경구성도 존재하기에 우리는 컨테이너를 도입했습니다. 

 

컨테이너 중에서도 생태계가 가장 발달된 Docker를 선택하였습니다. Docker는 clinet-server 구조로 인해서 보안과 관련된 이슈가 존재했기는 하지만 이익에 비해 큰 문제는 되지 않을 것이라 생각했습니다. Docker-compose를 이용해서 Proxy(Nginx with Client), Middleware(Spring), Database 3개의 컨테이너 개발환경을 한번에 구성할 수 있었습니다. 하지만 이것도 원활한 개발에는 문제가 있었죠.

새로운 문제

처음에는 Docker Network를 이용하여 3개의 컨테이너가 내부적으로 통신하도록 구현했습니다. 그러다 보니 local에서 개발할 때마다 디버그를 위해서는 빌드를 하고 빌드파일을 이용해 이미지를 생성한 다음 컨테이너를 실행해야 했었는데, 핫픽스 같은 경우에도 매번 이런 과정을 거쳐야 하기에 개발시간이 늦어지고 팀원들의 노트북이 점차 뜨거워지며 이륙을 하기 시작했습니다.

그리고 개발을 할 때 IDE환경에서 출력되는 로그를 보고 에러나 프로파일을 즉각적으로 확인하고 고쳐야 하는데, 컨테이너로 실행하면 일일이 컨테이너 터미널을 실행해서 로그를 찾아봐야한다는 점 또한 매우 번거로웠습니다.

 

2차 해결방법

여기서 local,dev,prod가 등장합니다. 저는 다른 분들의 환경 구성을 참고하여 우리 개발 환경에 필요사항을 추가하였습니다. 아래의 설명은 일반적으로 알려진 local,dev,prod의 역할보다 추가사항을 중점으로 서술하겠습니다.

 

1. local

이 환경의 목표는 서로 다른 로컬에서 동일하게 동작하도록 하는 것과 자신이 사용하고 있는 IDE와 원할히 호환하는 것이었습니다. 이전에 말한 문제를 해결하기 위해 local 환경에서는 컨테이너가 서로 Docker Network를 이용해서 내부적으로 통신하지 않고 호스트를 통해서 통신하도록 하였습니다. 

여기서 만약 자신이 벡엔드 개발자라 미들웨어 코드를 핫픽스하고 이 코드가 데이터베이스와 프록시 사이에서 잘 동작하는지 테스트 하고 싶다면, Middleware 컨테이너를 멈추고 Intellij에서 빌드를 실행하면 됩니다. 그러면 Intellij의 프로파일링 등 유용한 기능을 그대로 사용하면서 데이터베이스, 클라이언트와의 통신과정 등 통합 테스트를 빠르게 진행할 수 있습니다. 프론트도 마찬가지입니다, Webstorm에서 빌드를 실행하고 클라이언트 컨테이너는 멈추고 localhosat로 api를 전송하면, Middleware, Database와 통합테스트가 가능합니다. (Mocking 테스트 코드를 이용하면 이런과정이 필요없지 않을까 생각도 했지만, CORS같이 실제로 연결해봐야 알 수 있는 에러도 빠르게 파악하고자 local에 이 과정을 도입하였습니다.) 

미들웨어를 테스트할 때

2. Dev 

Dev는 각 소규모 Feture들이 dev 브랜치에 합쳐저 한 번의 스프린트에서 구현할 기능들을 통합하여 테스트하기 위한 환경입니다. Local 과정에서 각 컨테이너별 기능 버그를 모두 해결했다는 가정하에 통합테스트를 진행합니다. 배포환경과 매우 유사하게 만들고 end-to-end 테스트를 진행하는데 이 과정에서는 종합적으로 오류가 발생하지 않는지, 컨테이너간 통신 병목현상 등이 발생하지 않는지 확인하고 개선 계획을 세운다. 이를 위해선 각 컨테이너에 대한 모니터링이 필요하기 때문에, Docker Network와 컨테이너 외부 포트를 모두 사용합니다. 외부 포트는 모니터링을 위해서만 사용하도록 합니다. (그라파나, 프로메테우스 같은 모니터링 툴을 사용하지 않는 이유는 제한적인 모니터링만 가능하기 때문입니다. 가령 미들웨어 파일시스템이 이미지가 잘 업로드 되는지 확인하려면 직접 터미널을 열어서 확인하는 경우가 더 빠르고 편리하기 때문)

3. Prod

Prod는 실제 서비스를 제공하는 환경입니다. Dev에서 테스트가 완료되면 CD를 통해서 자동으로 배포가 진행되며, 직접적인 코드수정을 금지합니다. Dev환경과 상당히 유사하지만 Middleware, Database 컨테이너가 외부에 포트를 공개하지 않는다는 점이 차이점입니다. Docker Network를 이용해서 오직 Proxy를 통해서만 접근이 가능합니다. 원천적으로 외부와의 직접적인 연결을 차단하기에 보안을 향상시킬 수 있습니다. 하지만 배포 환경도 잘 동작하는지 확인이 필요합니다. 외부 포트를 공개하지 않기에 간접적으로 개발자가 미들웨어나 데이터베이스에 접근하기 위한 중개자가 필요한데 이 때 그라파나, 프로메테우스를 사용합니다.

마치며

중간까지는 개발환경 구성을 다른 포스트나 문서를 보고 그대로 구현하는 경우가 많았는데, 우리 팀의 상황을 고려해서 개발환경을 개선하는 작업은 즐겁고 공부가 많이 되었습니다. 아직 미숙해서 문제점이 많겠지만, 문제점이 나오는데로 새로운 개선방법을 모색할 예정입니다.

들어가며

오늘은 나의 개발철학을 정리해보려고 한다. 너가 뭔데?라고 스스로도 생각하지만, 미숙한 내가 성장을 하려면 지금 내 생각을 정리해야 문제점이 무엇인지 파악하고 성장을 할 수 있다고 생각하기 때문이다. 

 

제목의 뜻

경영학과에서 수업을 들은 적이 있다면, 효과성과 효율성에 대해서 매번 듣게 된다. 복수전공을 하면서 교수님께서 "2학년 여러분은 다들 아시겠지만 한 번 더 설명할게요~"라고 해주셔 알게 되었다(물론 나는 복전이라 처음 들었지만... 배려 감사합니다 교수님ㅠㅠ).

 

효과성은 "목표를 어느정도 달성했는가?"를 뜻한다. 

효율성은 "목표를 달성하는데 어느정도 비용을 사용했는가?"를 뜻한다.

아래는 당시 교수님이 예시로 들어주신 말씀을 그대로 인용한 것이다.

벽에 붙어 있는 파리를 잡을 때, 해머로 때려서 잡았다면 파리를 잡는 다는 목표는 100% 달성한 것이다. 하지만 가볍게 파리채를 휘둘러서 똑같이 파리를 잡았다면, 효과성은 같지만 힘을 덜 사용했으므로 효율적이라고 할 수 있다.

개발이랑 무슨 상관?

설명을 들으면서 번뜩 깨닫는 것이 있었다. 이거 개발과정이랑 비슷하지 않나? 개발자는 사용자를 위해 기능을 개발한다. 그리고 구현해야 할 기능 안에서 효율적으로 컴퓨팅 자원을 사용하기 위해서 항상 고민하고 있다. 지금까지 진행해왔던 프로젝트 들이 주마등처럼 스쳐갔다. 

 

지난 프로젝트에서도 잘못된 개발 과정을 경험한 적이 많다. 스프린트를 하면서 요구사항 분석-> 해결방법 선택 -> 디자인 -> 개발 과정을 진행했는데, 개발 이전 과정을 진행할 때마다 고객에게 검수를 받아 개발 과정에서 오류를 최소화하는 장치로 삼았지만 결국 개발과정에 와서야 오류를 찾은 경우가 많다. 왜냐면 고객이 기존의 요구사항을 개발이 다 되고 나서 바꿀 수도 있고 완성 후 다른 기능 구현 과정에서 기존 개발 코드를 수정해야 하는 경우도 많기 때문이다.(아무리 SOLID를 잘 준수해도 명세가 바뀐다면 어쩔수 없다.) 

 

그런데 문제는 내가 개발 과정에서 효율성을 굉장히 중시했다는 점이었다. "작은 기능에 집중하며 이 기능의 성능이 좋아야 다른 개발도 수월하겠지?"라고 생각하며 효율성에 굉장히 집착했다. CSV Parsing 라이브러리가 생각보다 느려서 직접 만들기도 하며 기능의 첫 번째 버전에도 효율성을 신경써서 만들었다. 

 

처음에는 만족스러웠다. 하지만 시간이 지나서 기능이 추가 될 수록 빡빡하게 만들었던 코드를 변경해야하는 경우가 많아 고뇌가 깊어졌다. SOLID 원칙을 준수해서 기능간 결합도를 낮게 유지한다고 해도 명세가 바뀌면 결국 코드도 바꿔야 했다. 그러면 신경써서 만든 코드들을 날리고 다시 만드는 경우가 있었다.  

 

Clean Agile책에 따르면 애자일 개발과정은 잘못된 것을 빠르게 파악하는 능력이 중요하다고 서술되어 있다. 즉, 최적화나 세부적인 과정에 집중하는 대신 일단 동작가능하게 만들어보고 이것이 맞는지 빠르게 판단해야 한다고 한다. 이것은 효율성보다 효과성을 우선시 한다는 것을 의미한다. 아무리 가벼운 파리채를 만들어서 휘둘러도 파리가 없는 부분을 때리거나 이제 새를 잡아야 한다면 결국 무용지물인지 않은가? 지금까지의 나는 개발자로서 효율성에 집중했지만 이제는 달라져야 한다고 생각한다.

기술을 위한 기술에서 고객을 위한 기술로

내가 효율성에 집착한 이유는 무엇일까? 아마 최적화를 통해 빠르고 아름다운 코드를 작성하고 싶은 욕심이었을 것 같다. 코드를 완성하고 나서의 뿌듯함과 성취감이 욕심을 계속 부추겼던 것 같다. 배달의 민족 CEO 대표님이 말씀하신 엘리베이터 문제콰이 강의 다리 문제와 비슷하지 않을까 생각한다. 

 

돌이켜보고 나니 나는 효율성이란 아름다움을 위해서, 기술을 위한 기술에 집중하고 있었다. 결국 기술이란 그 자체보다 사람들을 위한 것인데도 말이다. 이것을 깨닫고 나니 그 동안 화려한 프레임워크나 기술을 사용하여 작품을 만드는데 매혹되어 왔다는 것을 알게 되었다. 가장 중요한 것은 그걸 사용하는 사용자 인데 말이다.

 

따라서 나는 앞으로 효과성을 우선시 하기로 했다. 최적화도 중요하지만 일단 빠르게 동작가능한 코드를 만들어야 다른 개발자와의 협업, 사용자의 검증 그리고 다른 기능의 개발을 빠르게 진행 할 수 있기 때문이다. 그렇게 목표로 했던 기능을 모두 완성해 고객을 위한 효과성을 달성하고 고객의 요구사항 변경 요청이 잦아들 때쯤, 효율성을 위한 개발을 진행할 것이다. 

 

마치며

지금까지 나는 기술에 매료되어 개발을 위한 효율성에 빠져있었지만,

앞으로는 고객을 위한 효과성과 개발을 위한 효율성을 잘 조율하는 개발자로 성장하고 싶다.

아직 많이 부족한 개발자의 글이지만 읽어주셔서 감사합니다! 

 

들어가며

꽤나 자극적인 재목이다. 하지만 내 심정을 표현하기에는 이것보다 적절한 제목은 없다고 생각했다. 최근에 개발과 관련된 잘못된 지식이 얼마나 잘 퍼지는 것인지 그리고 이런일이 왜 일어나는 것인지에 대한 경험과 생각을 적어보려 한다.

 

무슨 일인데?

2022년 3월, 면접 공부에 도움이 되는 깃헙 레포지토리를 추천받아서 읽어봤다. 바로 아래의 블로그인데 Star를 12.6k나 받고 레포지토리의 목적과 콘텐츠가 잘 정리되어있어서 면접 공부하기에 좋은 자료처럼 보였다. 초반을 읽어본 결과 너무 깊지도 않으면서 어떤 부분을 공부해야하는지 넓고 얇게 지식들이 정리되어 있었다. 

 

그런데 자료구조 부분을 읽다가 눈을 의심하게 만드는 한 문장이 있었다. 

Java Collection 에서 ArrayList 도 내부적으로 RBT 로 이루어져 있고, HashMap 에서의 `Separate Chaining`에서도 사용된다. 그만큼 효율이 좋고 중요한 자료구조이다.

순간 머리 속이 복잡해지기 시작했다. ArrayList가 RBT(Red - Black Tree)로 이루어져있다고? 아니 그럴리가 없는데? 그런데 12.6K나 star를 받은 레포지토리인데 이게 맞지 않을까?

 

그래서 내가 잘못 알고있는지 확인해보고자 아래와 같이 구글에 검색했다.

그런데 관련 내용을 찾을 수 없었다. 나는 더 혼란스러웠다. 혹시 최신 Java Collection의 ArrayList 코드 내부에 내가 모르는 RBT관련 로직이 있는 건가? 그럼 답은 하나다, 찾아보자. Github에서 어렵지 않게 OpenJDK의 코드를 찾을 수 있었고, 거기서 ArrayList 컬렉션 코드를 볼 수 있었다. 생각한 대로 RBT와 관련된 내용은 찾을 수 없었다.

 

https://github.com/openjdk/jdk17/blob/master/src/java.base/share/classes/java/util/ArrayList.java

 

GitHub - openjdk/jdk17: JDK 17 development

JDK 17 development. Contribute to openjdk/jdk17 development by creating an account on GitHub.

github.com

그럼 어디서부터 잘못되었을까? 출처가 궁금해서 해당 문장을 그대로 구글에 검색해보았다.

그랬더니 몇 개의 블로그에서 똑같은 문장이 그대로 적혀있었다. 여기서 나는 적잖은 충격을 받았다. 내가 생각하는 블로그는 자신이 경험한 일들을 공유하기 위한 활동이라고 생각했다. 요즘 개발자 취업시장에서 블로그가 중요시되는 만큼, 많은 사람들이 공부한 것을 블로그에 적는다는 것도 알고 있었다. 하지만 잘못된 내용이 이렇게 쉽게 전파되는 것을 보고 너무 충격을 받았다. 나도 지금까지 다른 분들의 블로그를 참고하여 개발을 진행하거나 공부를 한 적이 많았는데, 그 중에 잘못된 내용이 있을 것이라는 두려움 때문이었다. 

그래서?

이번 일을 겪고 다짐한 것이 있다. 앞으로 공부나 개발을 할 때는 공식문서 또는 그에 준하는 명성을 가진 개발자의 포스트나 책을 참고해서 개발해야겠다는 것을. 인터넷이 나날이 발전하는 만큼 세상엔 지식이 빠르게 퍼지고 있다. 하지만 그 지식들이 모두 정답이라고 할 수는 없다. 특히 기초 공부를 하는 과정에서 오개념을 학습하게되면 나중에 고치기 어렵고 러닝커브가 길어지며 "아 이게 맞는데 왜 이렇지?"를 개발과정에서 자주 외치게 되리라. 

 

정보의 홍수 속에서 찾기 쉬운 정보보단 접근하기 어렵더라도 순수하고 검증된 정보를 찾도록 노력하는 것 또한 개발자의 의무라고 생각한다.

Overview

Java Collection Implementations[1]

이 장에서는 여러 Data Structure의 개념과 자바에서는 실제로 어떻게 구현되어 있는지 알아보도록 한다.

Linear Data Structure

Array

논리적 저장순서와 물리적 저장순서가 일치한다. 즉 메모리에서 연속적으로 데이터가 기록되는 자료구조이다. 이 특성 덕에, 특정 아이템을 인덱스를 알면 O(1)안에 접근이 가능하지만(random access), 아이템 삭제나 추가를 진행할 때, 연속성을 유지해야 하기 때문에 최대 O(n)의 시간복잡도가 발생한다.

보통 자바에서는 []를 통해서 Array를 생성하며, 아래와 같이 스택 영역에 Array의 레퍼런스를 저장하고 힙 영역의 메모리에 연속적으로 데이터를 할당하고 있다. 이 때, 메모리의 크기는 고정적이다. 또한 Array에서는 같은 클래스의 인스턴스를 가지도록 하며, 관련이 없는 클래스가 할당될 경우 IllegalArgumentException이 발생한다.

ArrayList in Java

위와 같이 []를 이용한 일반적인 Array는 선언과 동시에 배열의 크기가 정해진다. 따라서 만약 초기 선언된 크기보다 더 많은 데이터를 넣고 싶다면 문제가 발생한다. 이를 해결하기 위해서 Java에서는 배열의 크기를 변경할 수 있는 ArrayList 클래스가 존재한다.

ArrayList는 Array를 사용하며 만약 사용하던 Array의 capacity(용량)가 부족해지면 적절한 크기로 늘린 새로운 Array를 만들고 기존의 데이터를 복사한다. 다른 블로그에서는 대부분 2배로 Array를 늘린다고 되어 있지만, 실제로는 아래와 같이 ArraysSupport.newLength을 사용하여 최적화된 크기로 늘린다.

openJDK17에 사용된 ArrayList 클래스의 grow 메서드

  • Tip : Vector vs ArrayList결론부터 말하자면 Vector는 synchronized되어 있으며, ArrayList는 asynchronized되어 있다. 즉, Vector는 하나의 스레드만 접근이 가능하며 ArrayList는 여러 스레드가 동시에 접근이 가능하다. synchronized되어 있다는 것은 이를 위한 코드가 추가적으로 작성되어 있다는 뜻이기에, 일반적으로 ArrayList가 더 빠른 성능을 가지고 있다. 따라서 Thread-safe한 환경에서는 ArrayList를 사용하고 그렇지 않은 환경에서는 Vector나 Collection.synchronizedList를 사용하는 것이 옳다.
  • 자바에서 Vector클래스를 들어본적이 있는가? 아마 대부분 들어본적이 없을 것이다. Vector는 자바의 초창기에 지금의 ArrayList기능을 위한 레거시 클래스이다. 그러나 현재는 List 인터페이스를 구현하기 위해서 Collection 프레임워크에 맞게 재구성되어있기 때문에 사용할 수 있다. 그렇다면 Vector 와 ArrayList의 차이점은 무엇일까?

Linked List

Linked List의 경우 Array와 달리 메모리에 연속적으로 저장되지 않으며, 각 아이템은 자기 다음의 아이템의 위치만을 가지고 있는 구조이다. 이를 통해서 메모리에 연속적으로 저장하지 않아도 리스트의 요구사항을 만족할 수 있어 삽입과 삭제에 O(1)의 시간복잡도가 요구된다. 그러나 원하는 부분에서 삽입과 삭제를 진행하기 위한 search 과정에서 최악의 경우 O(n)의 시간복잡도가 발생할 수 있기 때문에, 전체적인 시간복잡도는 O(n)이 될 수 있다.

Doubly-Linked List in Java

Doubly-linked list implementation of the Listand Dequeinterfaces. Implements all optional list operations, and permits all elements (including null). All of the operations perform as could be expected for a doubly-linked list. Operations that index into the list will traverse the list from the beginning or the end, whichever is closer to the specified index. [2]

자바에서 어떻게 구현되어 있는지 살펴보자. 자바에서 사용하는 LinkeList는 기본적으로 Doubly-LinkeList이다. 이는 하나의 원소가 자기 다음의 원소 주소만 저장하는 것이 아니라 반대로 자기 이전의 주소 또한 저장하는 것이다. 이렇게 사용하는 이유는 효율성을 위해서이다. 예를 들어, 찾고자 하는 원소의 인덱스가 리스트의 마지막에 가깝다면, 처음에 가까이 있는 원소보다 시간이 많이 소요될 것이다. 자바에서는 이런 불균형을 방지하기 위해서 마지막에서부터 원소를 탐색을 시작할 수 있도록 하기 위해서 Doubly-Linked List를 사용하고 있다.

Stack

선형 자료구조의 일종으로 마지막으로 삽입된 원소가 처음 나오는 구조(LIFO, Last In First Out)를 가지고 있다. 대표적으로 사용자가 어떤 액션을 취하고 되돌아가기 위해 ctrl+z를 누르는데 이 때, 사용자의 액션이 저장되는 자료구조가 스택이다. 또한, 프로그램에서 스택은 함수의 호출 구조를 저장하는데 사용된다. 예를 들어, 사용자의 정보를 다운로드 하는 함수가 아래와 같이 수행된다고 하자. 그럼 getUserInfo → parse → download 순으로 메서드가 호출되고 이 순서대로 스택에 호출 정보가 저장된다. 그리고 메서드 처리가 완료될때마다, download → parse → getUserInfo 순으로 값이 반환된다. 이렇듯, 스택은 마지막으로 처리한 과정부터 역으로 진행이 필요한 프로세스에 사용되는 기본적인 자료구조이다.

function getUserInfo(){
	return parse(donwload())
}

Stack in Java

자바에서는 Stack 클래스를 이용해서 stack 자료구조를 구현하고 있다. 그 특성은 위에서 말한 Stack의 특성과 동일하며 재미있는 점은 아래와 같이 Vector를 super 클래스로 삼고 있다. Vector는 synchronized ArrayList라고 생각하면 된다(자세한 내용은 위의 “Tip : Vector vs ArrayList”를 참고). 따라서 Stack 또한 하나의 스레드만 접근가능한 synchronized가 적용되어 있어 Thread-safe하다.

//Stack's super class
java.lang.Object
	java.util.AbstractCollection<E>
		java.util.AbstractList<E>
			java.util.Vector<E>
				java.util.Stack<E>

Queue

Stack은 LIFO 구조라고 하였다. 그렇다면 반대로 처음 삽입된 원소가 마지막으로 나오는 (FIFO, First In First Out)구조도 필요하지 않을까? 그럴 때 사용되는 것이 Queue이다. 큐는 CS에서 사용하는 세마포어, 버퍼링 등 다양한 프로세스를 구현하기 위해서 사용된다.

Queue in Java

Stack과 달리 자바에서는 Queue를 위한 클래스가 따로 존재하지 않는다. 대신, LinkedList, PrioirtyQueue 등 Queue 인터페이스가 구현된 다른 클래스를 적절히 선택하는 것을 권장하고 있다.

Queue<String> queue= new LinkedList<String>()
Queue<String> queue= new PrioirtyQueue<String>()

예를 들어, 일반적인 순서를 가지는 큐를 구현하고 싶으면 LinkedList를 구현하면 되고, 데이터를 삽입할 때마다 정렬되는 큐를 만들고 싶다면 PrioirtyQueue를 사용하면 된다. 이외 에도, AbstractQueue, ArrayBlockingQueue, ArrayDeque, ConcurrentLinkedDeque, ConcurrentLinkedQueue, DelayQueue, LinkedBlockingDeque, 등등, 다양한 클래스를 자신의 적절한 환경에 사용할 수 있다.

Tree

지금까지 다룬, Array,List, Stack, Queue는 데이터를 순서대로 표현할 수 있는 선형 자료구조다. 이와 달리 Tree는 나뭇가지가 뻗어 나가듯 데이터가 나뉘어지는 비선형 구조를 가지고 있다.

Binary Tree

이진 트리는 각각의 노드가 최대 2개의 자식노드를 가질 수 있는 재귀적인 자료구조이다. 이진트리는 깊이에 따라 레벨이 정해져 있다. 루트노드가 레벨 0이며 깊이가 내려갈 수록 레벨이 증가한다. 또한 최고레벨을 가르켜 해당 트리의 height라고 한다.

Perfect Binary Tree(포화 이진 트리)

모든 레벨이 채워져있는 이진 트리

Complete Binary Tree(완전 이진 트리)

트리의 노드가 위에서 아래로, 왼쪽에서 오른쪽으로 차곡차곡 채워져있는 트리이다. 여러 알고리즘에서 자주 사용되는 트리 구조

Full Binary Tree(정 이진 트리)

모든 노드가 0 또는 2개의 자식 노드만을 가지고 있는 트리이다.

Binary Tree in Java(Array vs Recursive Class)

자바에서는 이진 트리를 위한 특정한 클래스가 존재하지 않는다. 대신, 이진트리는 Array를 이용해서 간단하게 구현할 수 있다. 루트노드는 1번째 노드라고 규정하고 노드의 내용을 1번째 인덱스에 저장 했을 때, n번째 노드의 왼쪽 자식노드는 n2 인덱스에 저장하며 오른쪽 자신 노드는 n2+1 인덱스에 저장하도록 하면 Array에 이진트리의 정보를 저장할 수 있다.

아래와 같이 Array 이외에도 노드가 노드를 참조하는 재귀적인 클래스를 생성하여 이진트리를 구현할 수 있다.

class Node {
    int value;
    Node left;
    Node right;

    Node(int value) {
        this.value = value;
        right = null;
        left = null;
    }
}

이제 Array로 작성한 이진 트리와 클래스를 사용한 이진트리의 장단점을 구별해보자. 첫 번째로, 노드의 데이터를 가져올 때 Array는 인덱스를 통해서 상수시간내에 접근이 가능하지만, 클래스에서는 O(logn)의 검색시간이 발생한다. 따라서 삽입, 삭제, 업데이트 시에도 클래스로 구현한 이진트리에서 더 많은 시간이 소요된다. 두 번째로, 메모리 관점에서 바라볼때 클래스는 내용 이외에 자식노드의 인스턴스까지 참조해야 하기 때문에, Array에 비해서 많은 메모리가 필요하다. 그러나, 완전 이진트리가 아닌 이진트리의 경우 Array는 저장해야할 내용보다 많은 메모리가 필요하지만 클래스는 저장해야만 하는 노드를 구현하기 때문에 메모리를 절감할 수 있다.

Binary Search Tree

이진 트리를 사용해서 데이터를 검색하기 효율적인 자료구조를 만들 수 있다. 아래와 같은 규칙을 정렬된 데이터를 트리 형식으로 구현해보자.

  • 규칙 1. 이진 탐색 트리의 노드에 저장된 키는 유일하다.
  • 규칙 2. 부모의 키가 왼쪽 자식 노드의 키보다 크다.
  • 규칙 3. 부모의 키가 오른쪽 자식 노드의 키보다 작다.
  • 규칙 4. 왼쪽과 오른쪽 서브트리도 이진 탐색 트리이다.

그리고 루트노드 부터 임의의 저장된 숫자를 검색한다고 했을 때, 마주치는 노드를 기준으로 왼쪽 자식노드로 검색할 지, 오른쪽 자식노드로 검색할 지 비교를 통해 결정할 수 있다. 트리가 완전 이진트리로 균형 잡혀 생성된 경우, 검색을 할 때마다, 비교해야 할 데이터가 절반씩 줄어들기 때문에 O(logN)=O(height)의 시간복잡도가 발생하여 큰 데이터 일 수록 매우 빠르게 검색할 수 있다.

그러나 만약 이진 탐색 트리가 왼쪽이나 오른쪽을 치우쳐 있을 경우, Worst Case 가 발생하며, 검색하는데 O(n)의 시간복잡도가 필요하다.

Red Black Tree

앞서 말했듯, 위와 같은 Binary Tree는 Worst Case의 경우 O(n)의 시간복잡도가 발생한다. 이런 경우를 대비하기 위해서 자료구조에는 Binary Tree가 균형잡인 트리로 유지하기 위한 Red Balck Tree가 존재한다. (구현하기 위한 알고리즘은 복잡하므로 생략한다.)

Red Balck Tree in Java

대표적으로 TreeMap이 Red Black Tree 구조를 사용하여 구현되어 있다. Map에서는 키의 유무 또는 위치를 검색하는게 중요한다. 이 때, 키를 저장하는 자료구조가 Red Black Tree이다.

Binary Heap

Complete Binary Tree 구조이며, 부모 노드의 값이 자식노드보다 항상 크다면 Max Heap, 부모 노드의 값이 자식노드보다 항상 작다면 Min Heap이라고 부른다. 이 자료구조는 저장된 데이터에서 최댓값이나 최솟값을 검색할 때 항상 O(1)의 시간복잡도를 보장하기에 최대,최소를 자주 찾는 프로세스에서 사용된다. 최대값을 pop할 경우 다시 저장된 데이터중 최댓값이나 최솟값이 루트노드에 존재해야 하기 때문에, heapify(맨 마지막 노드를 루트 노드에 대체시킨 후, 자식 노드와 비교하면서 데이터의 자리를 재위치 시키는 과정)를 진행하기에 O(logn)의 시간복잡도가 발생한다.

Binary Heap in Java

Priority queue represented as a balanced binary heap: the two children of queue[n] are queue[2n+1] and queue[2(n+1)]. The priority queue is ordered by comparator, or by the elements' natural ordering, if comparator is null: For each node n in the heap and each descendant d of n, n <= d. The element with the lowest value is in queue[0], assuming the queue is nonempty.

자바에서는 Heap을 구현하기 위해서 PriorityQueue 클래스를 사용한다. PriorityQueue는 Array를 이용해서 Complete Binary Tree를 구현하고 있으며, null을 포함한 non-Comparable한 데이터의 추가를 금지하고 있다. 또한, 동기성이 보장되고 있지 않아 여러 사용자가 동시에 접근하는 것은 안전하지 않으므로 이 경우, thread-safety가 보장된 PriorityBlockingQueue를 권장하고 있다.

Hash Table

Array에 데이터를 저장할 때 단순히 순서대로 저장하는 것이 아니라, 저장할 값을 기준으로 인덱스를 결정하여 저장하는 자료구조를 뜻한다. 이 때, 저장할 값을 기준으로 인덱스를 생성하는 방법을 hash method 또는 hash functiond이라고 하며 생성된 인덱스를 hashcode라고 한다. hash function이 좋지 못할 경우 서로 다른 값에서 동일한 인덱스가 생성될 수 있는데, 이 경우 동일한 인덱스에 여러개의 데이터가 존재할 수 있다. 이를 collision이라고 칭한다. hash function이 어떻게 작성되느냐에 따라, collision의 정도가 결정되는데, 항상 collision이 발생하지 않는것이 꼭 좋은 자료구조는 아니다. 오히려 적절한 collision이 발생하는 것이 좋은 성능의 Hash Table을 만들 수 있다.

HashTable in Java

자바에서는 HashTable을 사용할 때 기본적으로 키값과 저장할 내용, 두 가지를 입력한다. 키로 삽입되는 인스턴스는 Null이 아니며 hashCode()메서드가 구현되어 있어야 한다(HashMap은 Null값도 허용한다). 자바 개발자들은 Hashtable은 최근에 Java Collection에 포함된 대부분의 클래스와 달리 thread-safety가 보장되기 때문에 single programming에서 사용하는 경우 오버헤드가 발생하므로 thread-safety가 필요없는 경우 HashMap을 사용하는 것을 권장하고 있다. 또한 Hashtable은 레거시 클래스에 가깝기 때문에, thread-safety하고 고도의 동시성을 구현하기 위해서는 [ConcurrentHashMap](<https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ConcurrentHashMap.html>) 을 사용하는 것을 권장하고 있다.[5]

  1. Entry

이제 본격적인 구조에 대해서 알아보자. 해쉬 테이블의 데이터는 아래처럼 Entry 메서드에 저장된다. Entry는 Hashtable의 이너 클래스로, 이론적으론 하나의 버킷을 의미한다. [6]

private transient Entry<?,?>[] table;

내부 구조를 보면 알겠지만 LinkedList와 비슷한 구조를 가지고있다. 여기서 알 수 있는 점은, 같은 hashCode() 결과를 가져, 하나의 Entry에 포함된 원소들의 경우 검색하는데 선형적 시간복잡도가 걸린다는 것이다.

/**
 * Hashtable bucket collision list entry
 */
private static class Entry<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Entry<K,V> next;
....

2. Put

이번엔 새로운 데이터를 추가하는 코드를 살펴보자. 우선 윈소의 값이 null인지 확인하고, 기존의 테이블의 길이와 hashCode()를 통해서 인덱스를 결정한다. 그리고 인덱스에 해당하는 엔트리를 가져와 링크드리스트를 탐색하듯 반복문을 진행하고 같은 키를 가지는 경우 기존의 원소값을 업데이트 한다. 같은 키가 없다면 addEntry를 통해서 새로운 값을 추가한다.

public synchronized V put(K key, V value) {
        // Make sure the value is not null
        if (value == null) {
            throw new NullPointerException();
        }

        // Makes sure the key is not already in the hashtable.
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        @SuppressWarnings("unchecked")
        Entry<K,V> entry = (Entry<K,V>)tab[index];
        for(; entry != null ; entry = entry.next) {
            if ((entry.hash == hash) && entry.key.equals(key)) {
                V old = entry.value;
                entry.value = value;
                return old;
            }
        }

        addEntry(hash, key, value, index);
        return null;
    }

Hashtable에서는 loadFactor라는 값이 있는데 기존의 테이블을 얼마나 채우는 것을 허용하는지 결정하는 변수이다. 보통 메모리와 처리시간을 고려하여 0.75로 정해져 있다. threshold는 loadFactor값에 현재의 엔트리 용량을 곱한 결과로 지금 테이블 용량에서 최대로 채울수 있는 엔트리의 길이를 의미한다. 그래서 테이블에 새로운 값을 추가할 때, 테이블의 갯수가 threshold를 넘을 경우 rehash()메서드를 호출하여 테이블의 크기가 두 배인 변수를 만들어 기존의 값을 전부 복사한다. 여기서 배울 수 있는 점은, 만약 Hashtable의 크기를 처음에 결정할 수 있다면, rehash()메서드의 호출을 줄이기 위해서 처음부터 크기를 알맞게 할당해야 한다는 것이다. 그 다음 과정은 새로운 엔트리를 생성하여 값을 추가하는 것이다.

private void addEntry(int hash, K key, V value, int index) {
        Entry<?,?> tab[] = table;
        if (count >= threshold) {
            // Rehash the table if the threshold is exceeded
            rehash();

            tab = table;
            hash = key.hashCode();
            index = (hash & 0x7FFFFFFF) % tab.length;
        }

        // Creates the new entry.
        @SuppressWarnings("unchecked")
        Entry<K,V> e = (Entry<K,V>) tab[index];
        tab[index] = new Entry<>(hash, key, value, e);
        count++;
        modCount++;
    }

3. get

Hashtable에서 put을 이해했다면 get은 매우 간단하다. hashCode를 통해서 인덱스를 구한 후, 이에 해당하는 엔트리를 가져와 LinkedList를 검색하듯 get을 진행하고 만약 찾지 못했다면 null을 반환한다.

public synchronized V get(Object key) {
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
            if ((e.hash == hash) && e.key.equals(key)) {
                return (V)e.value;
            }
        }
        return null;
    }

HashMap vs Hashtable in Java

Java Docs를 포함한 대부분의 포스트에서는 HashMap과 Hashtable의 차이를 Thread-safety와 키, 원소의 Null 허용 여부에 두고 있다. 그러나 내부 구조에서는 매우 큰 차이가 존재하는데 바로 Resolve Conflict관점에서 Hashtable은 Seperate Chaining방식에서 Linked List만을 사용하지만 Tree와 Linked List를 공용으로 사용한다. Linked List는 탐색을 위해서 선형적 시간복잡도가 발생하는데 만약 하나의 버킷에 많은 양의 링크드 리스트가 존재한다면 탐색시간이 크게 늘어난다. 따라서 크기가 8이상의 버킷에 대해선 Red-Black-Tree방식으로 자료구조를 변경하여 탐색에 O(logN)의 시간복잡도를 제공하도록 한다.[7]

Graph

그래프는 정점(Vertex,Node)와 두 개의 정점을 잇는 간선(Edge)의 집합이다. Edge그래프는 Edge의 종류에 따라 Undirected Graph와 Directed graph로 나뉘게 되는데, Undirected의 경우 Edge에 방향이 없고, Directed의 경우 하나의 노드에서 다른 노드를 가르키는 방향성이 존재한다. 이 때, Undirected Graph에서 정점 간 연결된 Edge의 갯수를 Degree라고 하며 Directed Graph에서는 방향성에 따라 Outdegree, Indegree 두 가지로 나누어 갯수를 센다. 또한 만약 Edge에 가중치가 존재하는 경우 Weight Graph라고 칭한다.

그래프는 보통 이해관계를 표현할 때 주로 사용된다. 예를 들어 장소와 장소의 관계를 연결된 도로나 다리로 표현하거나 사람과 사람간의 채무 관계 등을 표시할 때 사용된다. 이런 이해관계를 그래프 자료구조로 저장함으로써 장소간 최단거리를 구하는 등의 알고리즘을 구현할 수 있다.

트리 또한 그래프의 종류 중 하나이며, 트리는 그래프와 달리 사이클이 생성되지 않는 특징을 가지고 있다.

Implementation

그래프를 구현하는 방법에는 크게 두 가지가 존재한다.

Adjacent Matrix

인접 매트릭스의 경우 정점의 갯수만큼의 행과 열을 가진 배열을 만들고 row,col을 정점의 번호로 가정했을 때, A와 B를 잇는 간선이 존재하는 경우 matirx[A][B]에 간선의 여부 및 가중치의 값을 기록하여 그래프를 구현하는 방법이다.

Adjacent List

인접 리스트의 경우, 정점의 갯수만큼 Array를 생성한 다음 각 원소에는 LinkedList를 구현하여 정점 A와 B가 이어져 있는 경우 matrix[A].push(B)를 통해서 간선의 유무, 방향성을 표시한다. 이 때 가중치 그래프인 경우 가르키는 정점과 함께 가중치 값을 삽입하기도 한다(matrix[A].push([B,W])).

Search

그래프는 비정형 데이터이므로 인덱스를 통한 순차적 검색을 지원하지 않는다. 대신, Recursive based Class Tree처럼 정점과의 관계인 간선을 하나씩 이어나가면서 데이터를 탐색하는 방법이 있다. 여기에는 크게 깊이를 우선으로 탐색하냐, 너비를 우선으로 탐색하냐에 탐색법이 나뉘게 된다.

  1. DFS (Depth First Search)

DFS는 임의의 한 정점에서 다른 정점으로 나아가는 것을 반복하는 것이다. 예를 들자면, A 정점이 B,C와 연결되고 B 정점이 D,E와 연결된다면 A→B→D 순서로 연결된 간선 중 순서가 빠른 간선으로 계속 전진하는 것이다. 만약 더 이상 전진할 간선이 없다면 이전의 정점으로 돌아가 남아있는 간선으로 탐색을 다시 진행한다. 이 구조는 Stack을 이용한 미로찾기와 같은 구조인데, 역시 DFS에서는 Stack을 이용하여 이를 구현하다.

  1. BFS(Breath First Search)

BFS는 너비 우선 탐색으로, 주위에 있는 간선을 먼저 탐색하는 것이다. 위와 같은 예를 들때, BFS로 탐색하면 A→B→C→D→E 순서로 탐색하게 된다. 주위에 있는 정점을 모두 탐색했을 때, 이 후 탐색한 순서대로 또 다시 해당 정점에서 주위 탐색을 진행한다. “먼저 탐색한 정점이 또 먼저 탐색을 시작”하므로 FIFO 형태의 자료구조가 필요하므로 BFS에서는 Queue를 사용한다.

Reference

  1. https://docs.oracle.com/javase/7/docs/technotes/guides/collections/overview.html
  2. https://docs.oracle.com/javase/7/docs/api/java/util/LinkedList.html
  3. https://docs.oracle.com/javase/7/docs/api/java/util/Stack.html
  4. https://docs.oracle.com/javase/7/docs/api/java/util/PriorityQueue.html
  5. https://docs.oracle.com/javase/8/docs/api/java/util/Hashtable.html
  6. https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/java.base/share/classes/java/util/Hashtable.java
  7. https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/java.base/share/classes/java/util/HashMap.java

들어가며

Github은 Git Remote Repository로 사용하는데 아주 대중적인 웹사이트다. Github에는 프로젝트 관리를 위한 다양한 기능을 가지고 있어서 이번 포스트에는 이를 우리 프로젝트에서 활용한 방법에 대해서 간략하게 소개하겠다. 

1. 선택이유

지난 포스트에서도 말했듯, PM(Project Management)를 위한 툴은 여러가지가 있다. 그러나 용도에 따라 서로 분산된 툴을 사용해야 하고 이를 연동하는 과정이 가벼운 프로젝트를 시작하는 사람들에게는 오히려 짐이 된다. 그런데 Github Repository에는 Issue, Milestone, Actions, Project, Wiki 등 다양한 프로젝트 관리 도구가 한 곳에 존재하기 때문에 따로 설정할 필요가 없어 편의성이 좋다. 물론, 더 복잡하고 큰 프로젝트를 관리하기 위해서는 부족한 기능이 몇 가지 있다. 하지만, 5명 정도의 팀원이 운영하기에는 충분한 기능을 제공하였기에 Github의 PM 기능을 선택하게 되었다.

2. 새로운 프로젝트 생성하기

새로운 Repository를 만들고 Github Project 기능에 들어간 후, 새로운 프로젝트 생성 버튼을 클릭하면 아래와 같은 창이 나온다. 

여기서 프로젝트에 관한 설명을 기록할 수 있고 Project Template를 정할 수 있는데 이 부분이 중요하다.

프로젝트는 기본적으로 칸반 형식을 제공하는데, Automated kanban을 사용하게 되면 프로젝트에 포함된 이슈를 닫거나, PR을 merge하게 되는 경우 자동으로 칸반의 상태를 변경한다. 예를 들어서, "버그 수정하기" 이슈를 프로젝트에서 TODO 상태로 정한 후, 버그 수정이 완료되어 이슈를 Close하면 자동으로 Done 상태로 이동 된다. 이러한 기능덕에 우리 프로젝트에서는 Automated kanban을 선택하여 지금까지 관리하고 있다.

 

프로젝트를 생성할 때는 Description에 해야할 내용을 간략하게 작성하여, 이 프로젝트에서 해야할 일을 정의한다. 아래의 경우, 3주차 스프린트에서 프론트엔드와 벡엔드에서 진행해야할 내용을 간단하게 서술한 예시이다.

프로젝트를 생성하고 들어가면, 처음에는 아래와 같이 Todo, In progress, Done 세 가지 형식의 Colum이 자동으로 생성될 것이다. 여기서 팀원들이 어떤 이슈를 해결중인지 파악할 수 있어 프로젝트 진행과정을 공유하기에 용이하다. 간혹, 이슈 상태를 업데이트 하지 않은 경우가 있는데, 이러면 이슈가 해결되었는지 파악할 수 없어 개발과정이 지체되는 경우가 있었으니 주의해야 한다.

여기에서 바로 이슈를 생성할 수도 있고, 이슈에서 연관된 프로젝트를 설정할 수도 있다. 아래 사진에서 우측 하단을 보면 프로젝트를 설정할 수 있는 것을 볼 수 있다.

또한 Pull Request에서 프로젝트와 관련있는 이슈를 연결했을 때, 프로젝트 보드에서 이슈와 Pull Request를 자동으로 묶어 보여주기 때문에 PR의 상태까지 한번에 확인 할 수 있다.

이슈와 PR의 연결

마치며

이 글을 쓰면서 이슈와 PR 활용법에 대해서도 적어야겠다는 생각이 들어, 조만간 작성할 예정이다. 왜냐하면 커밋 코드를 리뷰하거나, 링크를 통해서 간단하게 참조하는 법 등, 유용한 기능들이 아직 많이 남아있기 때문이다. 만약 본인이 팀원들과 새로운 프로젝트를 시작하는데 Jira, Confluence같은 툴을 사용하는데 어려움을 느낀다면 Github Project를 사용해보는 것을 적극적으로 권장한다.

들어가며

지난 포스트에는 개발방법론과 이를 실현하기 위한 툴들에 대한 내용을 작성하였다. 이번에는 새로운 프로젝트에서 문제를 정의한 과정에 대해서 알아보도록 하겠다.

 

1. 시작

교수님께서 소개해주신 스타트업 대표님과 개발팀장님께 자문을 받았을 때 가장 인상깊었던 말씀은 클라이언트, 팀원들 간의 생각 차이를 줄이는게 가장 중요하다는 것이었다. 자세히 말하면 같은 내용을 얘기하고 있어도 미묘하게 비즈니스 로직이나 유저 스토리에 대한 이해에 차이가 발생할수 있으며 이를 바로잡지 않으면 나중에 개발을 진행할 때 문제 정의로 회귀하여 다시 개발을 진행할 수도 있는 나비효과가 발생한다는 것이었다. 따라서 이번 프로젝트에서는 생각 차이를 줄이는데 집중하여 아래와 같이 3가지 문제정의 방법을 사용하고 검증했다.

2. User Story

가장 처음에는 영상의학과 교수님들과 대학원생 분들과의 미팅에서 구현되었으면 하는 요구사항을 정리하고자 User Story를 아래와 같이 작성했다.

각자의 User Story에는 개발 시 구현 방안을 간단하게 노트로 작성했고 구체적인 내용을 테스크로 정의하고 내부 페이지에 작성하였다.

3. 디자인

이 후에는 User Story를 바탕으로 디자인 초안을 작성했다. 이를 작성할 때는 Figma를 선택했는데, 그 이유는 Kakao oven, Sketch 등과 달리 Google에서 사용하는 다양한 Material UI Component를 무료로 이용할 수 있었기 때문이다. 그리고 Material UI Component를 사용한 이유는 향후 프론트 프레임워크를 React를 사용할 예정이었는데 React MUI 라이브러리에 이미 Material UI를 준수하는 Component들이 만들어져 있어 디자인보다 로직에 집중 할 수 있었기 때문이다. (실제로 1.0.0 버전의 프론트 코드에는 CSS와 관련된 코드가 거의 작성하지 않아 로직에 집중된 코드를 작성할 수 있었다.)

또한 디자인을 통해서 다시 클라이언트와 소통할 때, 잘못 이해한 User Story를 바로잡을 수 있었고 개발 방향을 다시 점검 할 수 있었다.

4. 비즈니스 로직

본인은 이 부분이 문제정의에서 가장 중요한 부분이라고 생각한다. 왜냐하면 개발 코드를 작성하는데 직접적으로 연관이 있기 때문이다. 유즈케이스 다이어그램과 비슷하게 비즈니스 로직을 UML로 구성하였는데 이 부분에서 효율적인 개발을 위한 토론이 가능했고 또 역할 할당이나 협업 방식등을 정리하기에도 수월했다. 그리고 디자인에서도 점검한 유저 스토리에 대한 논리적인 오류를 발견하여 개발전 빠른 조치까지 가능했다. (나중에 개발을 할 때, 이 부분은 무슨일이 있어도 작성해야겠다고 다짐했다.) 또한 한눈에 프로그램의 동작과정을 파악 할 수 있었기 때문에 개발 팀원들간의 생각 차이를 줄이는데도 한몫했다.

Draw.io로 구현한 비즈니스 로직의 일부

UML형태로 작성한 비즈니스 로직의 또 다른 장점은 확장성이다. 애자일 형식으로 개발할 때는 스프린트 형태로 주마다 새로운 기능을 추가하는 형태로 개발했는데, 이 때 UML 컴포넌트를 기존의 비즈니스 로직에 추가하여 쉽게 확장 내용을 기술 할 수 있었으며 다시 논리적인 오류를 검증하여 기존 기능과의 병합을 안정적으로 진행할 수 있었다.

스프린트1의 비즈니스 로직 일부
스프린트1을 확장한 스프린트2 비즈니스 로직의 일부

5. 후기

유저 스토리, 디자인 그리고 비즈니스로직을 작성하면서 클라이언트의 요구를 점검하는 과정들을 통해 실제 프로젝트를 개발하는데 오류를 줄이고 개발방향을 확고히 함으로써 코드 재작성이나 수정 과정을 대폭 줄이는 효과를 얻을 수 있었다. 그러나 이런 과정도 완벽하지는 못하다. 중간까지의 개발과정에서 클라이언트의 요청이 아예 변경될 수도 있기 때문이다. 실제로 그런 경우가 있었고 이미 작성한 기능을 삭제하거나 수정해야 했다. 앞으로는 이런 상황에도 대비하기 위한 문제정의 방법을 계속 공부하거나 개발해야 할 것 같다. 

 

1. 들어가며

최근 들어서 연구실에서 새로운 프로젝트를 시작하여 블로그 글을 작성하는게 뜸해졌다.  생각한 기능을 구현하고자 바쁘게 움직이면서 무의식적으로 글을 작성하는 과정을 미뤄왔던 것 같다. 프로젝트가 어느정도 기능이 구현되어 할 일이 줄어들어 지금까지 있었던 여러 고난과 극복과정을 앓음다운 글로 작성해보면서 그 동안의 팀 리더로서의 경험을 정리하고자 한다. 아직 많이 미숙하지만 이 글이 새로운 프로젝트를 시작하려는 모든 이에게 도움이 되었으면 좋겠다. 

 

2. 새로운 프로젝트

새로운 프로젝트의 이름은 DSMP(Dicom Service Mangement Project)로 Dicom이라는 의료용 디지털 영상 데이터를 관리한다는 의미를 가지고 있다. 의료영상을 베이스로 머신러닝을 연구하는 연구실이다 보니 의료 영상을 관리하는 작업이 필요했다. 이 과정에서 환자 개인정보를 지우는 익명화 작업, 서로 다른 병원에서 얻은 Dicom의 PatientID가 중복되는 문제 식별 및 수정 등의 여러 작업과 문제가 있었다. 반복적인 작업과 문제를 해결하기 위해서 시스템이 필요하게 되었는데 이를 위해 개발하기로 한 것이 DSMP이다.

 

3. 개발 방법론

3.1 실패

지금까지 진행했던 프로젝트는 워터폴 방식으로 진행했었다. 이 방법은 공모전이나 단기 프로젝트 같은 방식에는 별로 문제가 발생하지 않았었지만, 실제로 고객 요구사항에 맞춰 개발할 때는 엄청난 독이 되었었다. 예를 들어, 스타트업 외주를 담당하게 된 적이 있었는데, 매 주 회의를 할 때마다 저번주에 정한 요구사항이 변경되는 경우가 다반사였다. 그러다 보니 개발을 시작하려고 하면 다시 요구사항 정의로 돌아가기를 반복하기 일수였기에 굉장히 스트레스를 많이 받은 적이 있었다. 

The Phases of Waterfall Methodology, 출처:&amp;amp;nbsp;https://www.smartsheet.com/content-center/best-practices/project-management/project-management-guide/waterfall-methodology

3.2 도전

따라서 이번 프로젝트 또한 그런 상황을 방지하고자 애자일 개발 방법론을 공부하고 선택했다. 국내, 국외의 여러 포스트들을 검색하고 읽으며 학교 도서관에서 PM에 도움이 될만한 책들을 여러 권 읽어가며 실패하지 않는 개발 방법론을 실천하겠다고 다짐했다. 애자일에 대해서 알고 싶은 사람들에겐 로버트.C.마틴의 클린 애자일을 추천한다. 장 수는 별로 없는 것에 반해 굉장히 인상 깊게 읽었었다. 또한 이 과정에서 교수님이 스타트업 대표님과 개발 팀장님을 소개시켜주셨는데 굉장히 현실적인 조언들을 많이 해주셔서 배운점이 많았다.

출처 :;http://www.yes24.com/Product/Goods/95728889

3.3 구현

공부를 통해서 스프린트, 에픽, 테스크 등 개념에 대해서 공부하고 PM과 Wiki를 구축하기 위한 툴을 선택했는데 맨 처음에는 Jira와 Confluence를 선택했다. 그러나 결국 Github Project와 Notion으로 대체 했는데 그 이유는 아래와 같다.

 

1. 편의성 vs 기능성

Jira의 경우 Confluence와 Github Repository와의 연결 기능을 제공하고 있고 로드맵이나 애자일 보드 등 유용한 기능들이 많아 잘 사용한다면 개발을 빠르고 유연하게 진행할 수 있을 것 같았다.

DSMP의 Jira 로드맵

그러나 처음 팀원들에게 이를 알려주었을 때, 기능을 사용하는게 굉장히 복잡하다는 피드백을 받았다. 나는 처음 사용하기에 그럴 수 있다고 생각했지만, 다시 곰곰히 생각해보니 5명 단위의 프로젝트였기 때문에 사실 복잡한 기능들을 모두 사용할 필요가 없다고 느꼇다. 따라서 Issue Tracking, Automated Kanban 등 기본적인 기능을 제공하며 편의성이 Jira에 비해서 높은 Github Project를 사용하게 되었다.

 

2. 불필요한 Description

개발을 진행하면서 결국 Github Issue를 사용했는데, 이슈에도 Description을 작성하고 이와 관련된 Jira 테스크에도 같은 Descriptiondn을 작성하는 경우가 다반사였다. 처음에는 Jira 테스크에 내용을 기술하고 링크를 Github Issue에 추가했으나 뭔가 잘못된것 같은 느낌이었다. 결국 과감히 Jira 사용을 포기함으로서 Desciption을 한 곳에만 집중하여 기술할 수 있어 오기입을 줄이고 편의성을 높일 수 있었다.

 

3. Commit Tracking

Github에서는 커밋을 작성할 때, 이슈 태그를 기술하면 이슈에 해당 커밋 내용이 자동으로 추가되고 만약 또 다른 커밋을 언급했다면 자동으로 링크가 형성되는데 이 기능이 팀원과 소통하는데 굉장히 도움이 많이 되었다. 물론 Jira에도 이와 비슷한 기능이 있지만, 이를 설정하고 이용하는데 Github에 비해서 번거롭기 때문에 Github에서 사용하는것 이 도움이 많이 되었다. 

&nbsp; 이슈와 커밋 태크 사용 예시

3.4 후기

내가 느낀 애자일을 한 단어로 말하자면 "분할 정복"이라고 생각한다. 작은 계획이 모여 결과를 만들어 내는 과정이 알고리즘의 분할 정복과 비슷한 느낌이었기 때문이다(엄밀히 말하면 다르지만). 사실 아직도 애자일을 제대로 알고 있는지는 모르겠다. 그러나 워터폴 방식에 비해서 확실히 좋았던 점이 많았던 것 같고 새로운 개발방법론을 적용하면서 많을 공부가 되었다. 지금 느끼는 거지만 모듈화와 함수형 프로그래밍 방식을 적용했다면 더욱 효율적이지 않았을까 생각이 자주 든다(이와 관련된 포스트는 따로 작성예정). 또한 이와 관련된 툴은 생각보다 엄청 많으며 자신의 프로젝트에 어떤 것이 적합한지 판단하는 능력이 중요하다고 생각했다. 무턱대고 기능이 더 많고 좋아보이는 툴이 자신의 프로젝트에 비해 오버 스펙인 경우가 있을 수 있기 때문이다. 그러나 프로젝트를 진행하면서 복잡성이 증가하고 이에 따라 새로운 기능이 필요하다면 열린 마음으로 새로운 툴을 선택할 수 있어야 한다고 생각한다.

 

 

문제 설명

데이터 처리 전문가가 되고 싶은 "어피치"는 문자열을 압축하는 방법에 대해 공부를 하고 있습니다. 최근에 대량의 데이터 처리를 위한 간단한 비손실 압축 방법에 대해 공부를 하고 있는데, 문자열에서 같은 값이 연속해서 나타나는 것을 그 문자의 개수와 반복되는 값으로 표현하여 더 짧은 문자열로 줄여서 표현하는 알고리즘을 공부하고 있습니다.간단한 예로 "aabbaccc"의 경우 "2a2ba3c"(문자가 반복되지 않아 한번만 나타난 경우 1은 생략함)와 같이 표현할 수 있는데, 이러한 방식은 반복되는 문자가 적은 경우 압축률이 낮다는 단점이 있습니다. 예를 들면, "abcabcdede"와 같은 문자열은 전혀 압축되지 않습니다. "어피치"는 이러한 단점을 해결하기 위해 문자열을 1개 이상의 단위로 잘라서 압축하여 더 짧은 문자열로 표현할 수 있는지 방법을 찾아보려고 합니다.

예를 들어, "ababcdcdababcdcd"의 경우 문자를 1개 단위로 자르면 전혀 압축되지 않지만, 2개 단위로 잘라서 압축한다면 "2ab2cd2ab2cd"로 표현할 수 있습니다. 다른 방법으로 8개 단위로 잘라서 압축한다면 "2ababcdcd"로 표현할 수 있으며, 이때가 가장 짧게 압축하여 표현할 수 있는 방법입니다.

다른 예로, "abcabcdede"와 같은 경우, 문자를 2개 단위로 잘라서 압축하면 "abcabc2de"가 되지만, 3개 단위로 자른다면 "2abcdede"가 되어 3개 단위가 가장 짧은 압축 방법이 됩니다. 이때 3개 단위로 자르고 마지막에 남는 문자열은 그대로 붙여주면 됩니다.

압축할 문자열 s가 매개변수로 주어질 때, 위에 설명한 방법으로 1개 이상 단위로 문자열을 잘라 압축하여 표현한 문자열 중 가장 짧은 것의 길이를 return 하도록 solution 함수를 완성해주세요.

제한사항

  • s의 길이는 1 이상 1,000 이하입니다.
  • s는 알파벳 소문자로만 이루어져 있습니다.

풀이

우선 제한 사항을 통해서 알고리즘의 최대 시간복잡도를 유추해보자. 보통 처리 라인이 1억 미만인 경우에 효율성 테스트가 통과가 된다. s의 길이는 최대 1000이므로 최대 시간 복잡도는 $s^2logs$ 정도로 추측할 수 있다.

우선 가장 쉬운 방법으로 문제를 해결해보자. 문자열을 압축한 결과를 리턴하는 함수를 추상화한다면 솔루션을 아래와 같이 모든 단위에 적용하여 최솟값을 찾도록 정의할 수 있다.

def solution(s):
    answer = 1002
    for i in range(1,len(s)+1):
        answer=min(answer,len(compress(s,i)))
    return answer

위에서 정의한 $compress(s,i)$ 함수의 정의는 다음과 같다. 이를 차근차근 풀어가면 재귀적 구조를 찾을 수 있다.

compress(s,i) = 문자열 s를 단위 i를 통해서 압축한다.

= 앞의 i개의 글자로 가능한 만큼 먼저 압축한다. + 나머지 글자를 단위 i를 통해서 압축한다

= frontCompress(s,i) + compress( 나머지 글자, i )

$$compress(s,i) = frontCompress(s,i) +compress(left, i) $$

재귀 함수는 당연히 조건문이 필요하다. 글자는 점점 길이가 줄어들고 만약 남은 글자가 단위보다 작다면 그대로 전달하면 되므로 아래와 같은 조건문을 추가하자

$frontCompress(s,i)$의 경우에는 맨 앞 i개의 단어를 선택한 후 얼마나 반복되는지 확인만 하면 된다. 간단하게 구현할 수 있으니 내부에 같이 구현하도록 하면 아래와 같은 코드를 작성 할 수 있다.

def compress(s,size):
        #check condition
    if len(s)<size:
        return s
        #frontCompress
    count=1
    while s[0:size]==s[size*count:size*(count+1)]: count+=1
        #return, 갯수가 1인 경우에는 숫자는 리턴하지 않는다.
    if count==1:
        return s[0:size] + compress(s[size:],size)
    else:
        return str(count)+s[0:size] + compress(s[count*size:],size)

자 그럼 이 코드는 $s^2logs$ 안에 실행되는지 확인해보자. $compress(s,i)$ 는 솔루션에서 s번 호출되므노 $slogs$안에 실행되어야 한다. 함수를 자세히 보면 중간에 while문이 있지만, 이 결과가 s의 길이를 줄이고 있으므로 $compress(s,i)$ 의 $O(n)$은 $s$이다. 따라서 총 수행시간은 $s^2$이 되므로 제한사항을 충족한다.

들어가며

코틀린은 이미 정의된 클래스를 확장 시킬 수 있는 기능이 있다. 예를 들어서 아래와 같이 String 클래스에 원하는 메서드를 추가 할 수 있다.

fun String.getFirst() = this[0]
println("hello".getFirst())
//print 'h'

원리

원리는 생각보다 간단하다. 자바에서 static을 사용하여 기존 클래스를 입력받아 처리하는 메서드를 생성함으로써 사용자 입장에서는 기존의 클래스를 확장시킨 것처럼 보이게 하는 것이다.

public static String getFisrt(String string){ return string[0];}

Question?

그렇다면 만약 상속 관계에 있는 두 개의 클래스를 이용하여 아래와 같이 코드를 작성하면 어떤 문자열이 출력될까?

open class Parent 
class Child: Parent()

fun Parent.foo() = "parent" 
fun Child.foo() = "child"

fun main(args: Array<String>) { 
  val parent: Parent = Child()
  println(parent.foo()) 
// 여기서 출력되는건 무엇일까?
}

Answer & Why

원리를 알고있다면 추론하는 것은 간단하다. 위 코드가 자바로 변환 되었다고 생각해보자. static을 이용하므로 아래와 같이 변환 되었을 것이다.

//... 클래스 정의는 생략

public static String foo(Parent parent){return "parent";)
public static String foo(Child child){return "child";)

public static void main(String[] args){
	Parent parent=new Child();
	System.out.println(foo(parent));
}

자바 공부를 열심히 했다면 결과는 "parent"가 출력되었을 것이라고 바로 알았을 것이다. 자바는 오버로딩을 통해서 어규먼트와 파라미터가 일치하는 함수를 호출하기 때문이다. 위 코드를 보면 오버라이딩을 통해서 "child"가 출력될수 있다고 착각할 수있다. 그러나 확장함수는 인스턴스의 실제 값과는 관련없이 클래스에만 선언되기 때문에 함수를 호출할 때 인스턴스의 클래스 종류만을 따른다.

Question2 : Member vs Extension

자 그럼 만약 확장함수를 기존 클래스의 멤버 메소드를 오버라이딩 하는 것처럼 사용하면 어떻게 될까?

class A{
	fun foo()="member"
}
fun A.foo()="extension"

println(A().foo()) //??

Answer & Why

정답은 "member"를 출력한다. 왜냐하면 코틀린은 확장함수가 멤버 함수를 오버라이딩 하는 것을 허용하지 않는다. 왜 그럴까? 만약 반대로 허용한다고 생각해보자, 코드를 작성 할때 전역에서 확장함수로 Array.get()을 새로 정의 했다고 하자. 추후 다른 사람이 이 파일에 새로운 코드를 추가한다고 했을 때, get()을 본래의 의도로 사용하여 작성한다면 코드는 버그 투성이가 될 것이고 이를 인지하고 본래의 함수를 사용하고자 해도 확장함수로 정의된 이상 불가능하다.

하지만 코틀린은 확장함수로 오버로딩을 하는 것은 허용한다. 이는 기존의 멤버 메소드에 영향을 끼치지 않기 때문이다.

class A{
	fun foo()="member"
}
fun A.foo(i:Int)="extension $i"

println(A().foo(2)) //extension 2

 

Basics

Extension Function은 클래스를 확장한다. 클래스 밖에서 정의 되지만 regular 멤버로 클래스 내부에서 호출할 수 있다.

fun String.getLast(number: Int) = this.get(this.length-1)
class Test{
 fun main(){
        val c: Char ="abc".getLast()
    }
}

Receiver

확장 함수가 호출 될 때는 this가 reveiver로서 호출된다. 또한 기본적으로 this의 멤버 변수나 함수를 호출하기 위해서 일일이 this를 정의할 필요가 없다.

fun String.getLast(number : Int)=get(length()-1)

Import

extension을 이용하기 위해선 import를 이용해서 명시해주어야 한다. 왜냐하면 extension function은 기본적으로 전체프로젝트에서 사용할 수 없도록 되어있기 때문이다.

fun String.lastChar()=get(length-1)

import com.example.util.lastChar
val c: Char="abc".lastChar()

Calling Extension Functions from Java code

자바에서 코틀린의 확장함수를 호출할때는 static형태로 호출된다. 이 또한 import를 통해서 static한 함수만을 호출 할 수 있다.

//StringExtension.kt
fun String.lastChar()=get(length-1)

//JavaClass.java
import static StringExtensionKt.lastChar;
char c=lastChar("abc");
//or
char c=StringExtensionKt.lastChar("abc");

위에서 보듯 자바에서 확장함수를 호출할때는 첫 번째 파라미터로 리시버를 입력한다. 확장함수가 추가적인 파라미터가 필요하다면 자바에서는 두 번째 파라미터부터 입력하면 된다.

//StringExtension.kt
fun String.getChar(pos : Int)=get(pos)

//JavaClass.java
import static StringExtensionKt.getChar;
char c=getChar("abc",2);

Accessing private members

자바에서는 다른 클래스의 static 함수에서 private member를 호출할 수 없다.

코틀린의 확장함수는 분리된 보조 클래스에서 정의된 static 함수이다.

따라서 확장함수에서는 private member를 호출 할 수 없다.

Reference

Examples from the Standard Library - Starting up with Kotlin | Coursera

들어가며

지난 시간까지 데이터를 수집하고 Tensorflow Lite 모델을 만드는 과정까지 진행해보았다. 이번에는 이 모델 파일을 Android에 삽입해서 실제 구동까지 진행해보도록 하겠다.

TFlite 모델에 메타데이터 추가하기

이 부분 때문에 필자는 몇 시간동안 삽질을 했다. 이전 장에서 만든 .tflite 파일을 그대로 안드로이드에 사용하게 되면 에러가 발생한다:(. 구글링을 하고 stack overflow에 업로드하는 등 여러 노력을 통해서 솔루션을 찾아냈다. 에러의 내용은 tflite모델에 NormalizationOptions이 추가되어 있어야 한다는 뜻이었다. 이는 메타 데이터의 일종인데 이를 삽입하기 위해선 추가적인 작업이 필요했다.

addMetaData.ipynb
0.01MB

위 코드를 사용하면 필요한 메타 데이터를 추가 할 수 있다. 코드를 보면 아래의 부분을 볼 수 있는데 여기에 자신이 메타데이터를 추가할 tflite 파일과 label.txt의 경로를 지정한 후 그대로 실행시켜주면 된다.

Android Studio Demo APP에 삽입하기

자 이제 마지막 단계이다. github.com/tensorflow/examples/tree/master/lite/examples/object_detection/android이 링크에 들어가게 되면 Android에서 Object Detection 모델을 실행할 수 있는 코드를 다운로드할 수 있다. Readme의 지침을 잘 따라서 모델을 Asset 디렉터리에 삽입하게 되면 비로소 모델을 실행시킬 수 있을 것이다.

 

마무리

예전 코드를 참조해서 그런지 필자는 중간에 여러번 에러가 발생해서 굉장히 힘들었다... 이 글을 본 사람들은 부디 필자처럼 삽질을 하지 않았으면 좋겠다. ㅎㅎ 지금까지 안드로이드에서 동작하는 Object Detection 애플리케이션을 만드는 과정이었다.

Reference

1. www.tensorflow.org/lite/convert/metadata

 

TensorFlow Lite 모델에 메타 데이터 추가

TensorFlow Lite 메타 데이터는 모델 설명에 대한 표준을 제공합니다. 메타 데이터는 모델이 수행하는 작업과 입력 / 출력 정보에 대한 중요한 지식 소스입니다. 메타 데이터는 TensorFlow Lite 호스팅 모

www.tensorflow.org

2. github.com/tensorflow/examples/tree/master/lite/examples/object_detection/android

 

tensorflow/examples

TensorFlow examples. Contribute to tensorflow/examples development by creating an account on GitHub.

github.com

 

들어가며

지난 시간에는 roboflow를 사용해서 데이터를 업로드하여 처리하는 방법에 대해서 알아보았다. 이번에는 데이터와 Tensorflow 프레임워크를 사용해서 모델을 만들어보도록 하겠다. 추가적으로 컨버터를 사용해서 Tensorflow Lite 모델까지 변환하는 방법에 대해서 알아보도록 하겠다. (이 장에서는 필수적으로 수정해야 할 부분에 대해서만 언급하겠다. 코드에 대해서 자세히 알고 싶다면 Colab내부의 주석을 참고하도록 하자)

데이터 다운로드

colab.research.google.com/drive/1aIobwtqWggRbLbe606_WzVGtFiWZT9tr?usp=sharing

 

Roboflow-TFLite-Object-Detection_Bicycle_Helmet.ipynb

Colaboratory notebook

colab.research.google.com

위 링크는 모델을 학습시키고 Tensorflow Lite 모델로 변환하기 위한 모든 코드가 포함되어있다. 

Prepare Data부분에서 위와 같은 코드를 찾을 수 있을 것이다. 여기에 이전 장에서 진행했던 Roboflow 데이터셋의 링크를 삽입해야 한다. Roboflow 튜토리얼을 잘 따랐다면 대시보드에 자신이 만든 버전이 있는 것을 확인할 수 있을 것이다. 

여기에서 Export Your Dataset부분에서 포맷을 정하고 링크를 받을 수 있는 부분이 있을 것이다.

우선 COCO포맷을 정한다음 GetLink를 클릭하게 되면 Jupyter탭에서 아래와 같은 부분이 나올 것이다.

 !curl -L "링크" > roboflow.zip; unzip roboflow.zip; rm roboflow.zip

자신의 링크를 복사하여 colab에 [COCO Json Link Here!]라고 표시된 부분에 복사하도록 하자.

그리고 이번에는 Format을 Tensorflow TFRecord로 변경하여 위 과정을 반복한 후 [Tensorflow TFRecord Link Here!]라고 표시된 부분에 링크를 삽입하도록 한다. 이렇게 하면 데이터를 다운로드할 수 있다.

파일 이름 수정

Prepare tfrecord file 세션 아래의 코드를 보면 다음과 같은 코드가 있을 것이다.

각 roboflow에서 다운로드한 파일의 이름은 데이터셋에 따라서 다르기 때문에 여기에 자신이 설명한 데이터셋 이름을 업데이트해주어야 한다. colab 내부에 해당 경로를 따라가서 자신의 파일이 어떤 이름으로 정해져 있는지 확인 후 업데이트하도록 하자.

마무리

특별한 에로사항이 없다면 위에서 언급한 코드만 바꾸어 주어도 자동으로 모델을 학습하고 Tensorflow Lite로 모델을 변경한 다음 자신의 드라이브에 모델을 업데이트할 것이다. 만약 어떤 에러가 발생한다면 github.com/BEOKS/Bicycle-Helmet-Wearing-Detection 여기에 issue를 써준다면 수정하도록 하겠다.

Reference

1. How to Train a Custom TensorFlow Lite Object Detection Model, https://blog.roboflow.com/how-to-train-a-tensorflow-lite-object-detection-model/

 

How to Train a Custom TensorFlow Lite Object Detection Model

In this post, we walk through the steps to train and export a custom TensorFlow Lite object detection model with your own object detection dataset to detect your own custom objects. If you need a fast model on lower-end hardware, this post is for you. Whet

blog.roboflow.com

2. MobileNet SSD, https://github.com/tensorflow/models/tree/master/research/object_detection

 

tensorflow/models

Models and examples built with TensorFlow. Contribute to tensorflow/models development by creating an account on GitHub.

github.com

 

들어가며

최근에 어떤 프로젝트를 진행하게 되면서 안전모를 착용했는지 확인하기 위한 모바일용 머신러닝 모델이 필요하게 되었다. 3일이라는 짧은 시간 안에 프로토타입을 만들게 되었는데, 진행하면서 알게 된 에러 사항에 대해서 공유하기 위해서 이 글을 작성하니 모바일용 객체 탐지 모델을 만드는 사람들이 참고해서 쉽게 사용했으면 좋겠다.

혹시 결과물이 바로 보고 싶은 사람은 아래의 링크를 참고하란다.

github.com/BEOKS/Bicycle-Helmet-Wearing-Detection

 

BEOKS/Bicycle-Helmet-Wearing-Detection

Tensorflow lite model that detects bicycle helmet wearing and Android demo application - BEOKS/Bicycle-Helmet-Wearing-Detection

github.com

데이터 수집

모든 머신러닝 모델의 시작은 데이터를 확보하는 것이다. Kaggle에서 사용가능한 데이터들이 있는데 나는 이를 참고하였다. 참고로 라이선스가 전부 다르기 때문에 주의하기 바란다. 만약 이미 데이터가 확보되어있다면 이 과정을 넘겨도 된다.

www.kaggle.com/brendan45774/bike-helmets-detection

 

Bike Helmets Detection

764 images in 2 classes

www.kaggle.com

데이터 처리

전처리 프레임워크

머신러닝 모델이 잘 학습 할 수 있도록 데이터를 처리를 해야 한다. 데이터에 따라 몇 가지가 있지만 프로토타입 제작이므로 잘못된 정보가 있는지만 확인하도록 하겠다.

roboflow.com/

 

Roboflow: Everything you need to start building computer vision into your applications

Even if you're not a machine learning expert, you can use Roboflow train a custom, state-of-the-art computer vision model on your own data. Let us show you how.

roboflow.com

위 사이트는 확보한 데이터를 업로드하여 이미지를 육안으로 확인하거나 모델 향상을 위해서 Augmentation 시킬 수 있는 사이트이다. 만약 이미지 보안이 별로 중요하지 않다면 이를 활용하면 좋다. 업로드 및 활용과정은 처음 사이트를 이용할 경우 튜토리얼이 진행되니 순서만 따라간다면 어렵지 않게 사용할 수 있다.

데이터 확인

데이터를 입력하고 나면 위처럼 대시보드가 나오게된다. 여기서 이미지를 클릭하여 어노테이션이 잘 되어있는지 확인할 수 있다. 만약 어노테이션이 되어있지 않은 이미지가 있다면 쉽게 어노테이션을 추가할 수 있다.

데이터 전처리

대시보드 아래에 보면 Preprocessing Option을 볼 수 있다. 여기선 Crop, GrayScale, Tile 등의 전처리를 UI로 손쉽게 다룰 수 있다. (일부 기능은 유료버전에서만 사용 가능한데 돈내기는 싫으니 사이킷런의 전처리 라이브러리를 사용해서 코드에서 진행 후 다시 업로드하도록 하자)

Augmentation

Augmentation은 머신러닝 모델의 정확도 향상을 위해서 데이터를 증식시키는 것을 의미한다. 이 또한 대시보드에서 Augmentation Option을 선택하여 진행 할 수 있다. 여기도 몇 가지 기능은 유료버전에서만 사용 가능하다. 그러니 Albumentation(아래 링크 참고)을 사용해서 코드상에서 구현하도록 하자. 이 라이브러리가 roboflow보다 더욱 다양한 기능을 제공한다.

 github.com/albumentations-team/albumentations

 

albumentations-team/albumentations

Fast image augmentation library and easy to use wrapper around other libraries. Documentation: https://albumentations.ai/docs/ Paper about library: https://www.mdpi.com/2078-2489/11/2/125 - albume...

github.com

마무리

데이터와 모델의 목적에 따라서 위에서 언급한 것들 이외의 전처리 과정이 더 필요할 것이다. 기존의 솔루션을 잘 분석해서 어떻게 데이터를 처리할지 결정하고 이를 추가적으로 작업하여 데이터셋을 완성하자. 다음 장에서는 이 데이터셋을 가지고 tensorflow 프레임 워크를 사용하여 SSD MobileNetv2 모델을 만든 다음 이를 Tensorflwo Lite 모델로 컨버팅 하는 방법에 대해서 알아보자

 

Reference

  1. How to Train a Custom TensorFlow Lite Object Detection Model, https://blog.roboflow.com/how-to-train-a-tensorflow-lite-object-detection-model/
 

How to Train a Custom TensorFlow Lite Object Detection Model

In this post, we walk through the steps to train and export a custom TensorFlow Lite object detection model with your own object detection dataset to detect your own custom objects. If you need a fast model on lower-end hardware, this post is for you. Whet

blog.roboflow.com

들어가며

지난 글에서는 도커의 아키텍처에 대해서 알아보았다. 이번에는 도커의 샘플 애플리케이션을 실행시키면서 도커의 이미지 생성 방식을 알아보도록 하겠다.

애플리케이션 준비

https://docs.docker.com/get-started/02_our_app/, 데모 어플리케이션

도커에 동작시키기 위한 데모 애플리케이션을 확보해보자. nodejs로 동작하는 간단한 데모 애플리케이션을 docker getting-started github 저장소에서 다운로드할 수 있다. 

클론 된 getting-started에는 파일과 디렉터리가 있지만 우리가 원하는 nodejs 데모 애플리케이션은 getting-started/app 디렉토리 안에 있다.

https://docs.docker.com/get-started/02_our_app/

여기서 터미널을 열어 node src/index.js를 실행시킨다면 맨 처음 우리가 실행시킬 데모 애플리케이션을 실행시킬 수 있다. 하지만 지금 할 건 도커 이미지를 만든 다음 내부에 애플리케이션을 시작시키는 것이기 때문에 조금 참아보자.

도커 이미지 빌드

도커 이미지를 만들기 위해서는 이미지 빌드 명령어의 집합체인 Dockerfile이 필요하다. Dockerfile을 새로 만들어보자. 주의할 것은 해당 파일은 확장자명이 없다. 어떤 코드 에디터는 자동으로 .txt를 붙이거나 하는데 이럴 경우 에러가 발생하기 때문에 주의하도록 하자.

 

app 디렉토리 내부에 Dockerfile을 만든 다음 아래의 코드를 삽입한다. 주석은 생략해도 된다.

FROM node:12-alpine
# FROM은 베이스로 사용할 docker image를 의미한다.
# 이 이미지 위에 새로운 명령어나 패키지를 덧씌워 커스텀 이미지를 만들 수 있다.
# 지금은 node application을 위해서 node:12-alpine이미지를 베이스로 한다.
WORKDIR /app
# WORKDIR은 기본적인 작업공간을 의미한다. 
COPY . .
# COPY는 이미지에 파일을 추가하는것 을 의미한다.
# COPY <복사할 파일 경로> <이미지에서 파일이 위치할 경로>
# . . 으로 실행하면 dockerfile이 위치하는 컨텍스트의 파일들이 WORKDIR로 복사된다.

RUN yarn install --production
# RUN은 이미지를 빌드할 때 사용할 명령어를 설정할 수 있다.
CMD ["node","src/index.js"]
# CMD는 이미지를 빌드할 때가 아닌 컨테이너가 실행될 때 사용할 명령어를 정의할 수 있다.

추가적인 Dockerfile 명령어가 알고 싶다면 https://docs.docker.com/engine/reference/builder/#shell를 참고하자

이제 기본적인 준비가 끝났다. docker build를 사용해서 이미지를 만들어보자

 docker build -t getting-started .

-t는 따라오는 인자를 이미지의 태그로 정하겠다는 뜻이며, 마지막. 은 현재 디렉터리의 Dockerfile을 참조해서 빌드하겠다는 뜻이다.

빌드를 할 경우 Dockerfile에 명시해 둔 명령들이 차례로 실행하는 것 을 볼 수 있다.

이후 docker images를 실행하여 만든 이미지가 추가된 모습을 볼 수 있다.

그럼 바로 이미지를 컨테이너로 띄어보자.

컨테이너 실행

docker run -dp 3000:3000 getting-started

위 명령어를 해석해보자.

run은 말 그대로 컨테이너를 실행시킨다는 의미이다.

option으로 d와 p가 있다. d는 detach의 약자이며 실행한 컨테이너를 백그라운드에서 사용하도록 한다는 의미이다. p는 port의 약자로 3000:3000인자를 읽어 컨테이너의 3000번 포트와 Host OS의 3000번 포트를 매핑 하 준다는 의미이다. 만약 이를 해주지 않으면 컨테이너 애플리케이션에 접근할 수 없다.

 

위 명령어를 실행한뒤 로컬 컴퓨터에서 http://localhost:3000으로 접속하게 된다면 데모 애플리케이션에 접속할 수 있다. 도커 대시보드를 통해 해당 컨테이너에 접근해서 현재 프로세스를 실행시키면 Dockerfile에 정한 명령어가 잘 실행된 것을 볼 수 있다.

Reference

1. docs.docker.com/get-started/02_our_app/
2. docs.docker.com/engine/reference/builder/#shell

'Docker' 카테고리의 다른 글

[Docker] - Docker Architecture(도커의 구조)  (0) 2021.02.14
[Docker] - Docker란 무엇인가?  (0) 2021.02.14

+ Recent posts