최종 프로젝트 GAIA 소개

작성: 2021.07.18

수정: 2021.07.18

읽는시간: 00 분

Development/Projects-DDIT

반응형

gaia by team seed   🌱
2024년 2월 29일부로, 약 30개월간의 운영을 끝내고 서버를 종료하였습니다.
발표영상테스트영상을 확인해보세요

GAIA는 기존의 Project Management System들의 어려운 사용법과 높은 진입장벽을 해결하기 위해 기획되었습니다.

아래의 모듈들을 통해 프로젝트 관리와 개발자간의 협업을 돕습니다.

  • 이슈 트래킹 ( Milestone, Issue )
  • 프로젝트 일정 관리 ( Calendar, Gantt )
  • 칸반 보드
  • 위키
  • 뉴스
  • 인스턴트 메신저
  • 프로젝트 통계
  • 통합 검색


📚 Technology Stack



👩‍👩‍👦‍👦 Team members

drawing

@Shane-Park Shane(PL)
@JeonghoonWon Josh(DA)
@KrGil Eisen(TA)



🏆 Award

대덕인재개발원 해당 기수 최우수 프로젝트로 선정되었습니다.👏👏




Gaia 소개

1.Gaia

  • Single Page Application
  • URL Structure
  • Elastic Search

2.Main

  • index
  • login
  • admin

3.User

  • overview
  • notification
  • alarm
  • profile
  • setting
  • log

4.Chatting

  • chat

5.Project

  • code
  • keyboard shortcut
  • search
  • multi languages
  • milestone
  • issue
  • gantt
  • calendar
  • kanban
  • news
  • wiki
  • analytics
  • setting - member
  • setting - management


1.Gaia

1) Single Page Application

gaia는 싱글 페이지 어플리케이션 입니다.
필요한 각종 함수들을 모듈화 시키고 기초부터 하나씩 설계하다보니 쉽지 않은 과정이었지만 해낼 수 있었습니다. gaia에서의 모든 요청은 비동기로 처리됩니다.

// 뒤로가기 이벤트 binding 하기
$(window).bind("popstate", function(event) {
    var data = event.originalEvent.state;
    if(data){ // 이전 페이지 데이터가 있으면 ajax로 다시 요청해 화면 렌더링.
        if(data.startsWith('member-')){
            memberMovePage(data.substring('member-'.length));
        }else{
            movePage(data);
        }
    }else{ // 히스토리에 정보가 없을경우 메인화면으로 이동시키기.
        var url = getContextPath();
        $(location).attr('href',url);
    }
})

// 뒤로가기 상황을 제외하고는 pushState를 통해 데이터를 쌓아야합니다.
const movePageHistory = function(pageParam){
    var url = getContextPath()
        +'/'+manager_id+'/'+project_title 
        + (pageParam!='code' ? '/'+pageParam : '');
    history.pushState(pageParam, null, url);
    movePage(pageParam);
}

pushState를 통해 데이터를 쌓고 popState를 binding 해서 뒤로가기와 앞으로 가기 상황을 해결했습니다.


2) URL Structure

또한 pathvariable을 적극적으로 활용해 URL 그 자체가 navigation 역할을 대체할 수 있도록 구현했습니다.

path

예를 들어 위의 url인 kkobuk/ddit302/issue/9 의 경우에는 kkobuk이 생성한 ddit302 라는 프로젝트의 9번째 이슈를 뜻 합니다.


@Controller
@RequestMapping("{manager_id:^(?:(?!admin$|view$|restapi$).)*$}/{project_title:^(?:(?!overview$|help$|chat$|setting$).)*$}")
public class ProjectUrlMapper {

    @Inject
    private ProjectService service;
    @Inject
    private ProjectDao dao;
    @Inject
    private WebApplicationContext container;
    private ServletContext application;

    @PostConstruct
    public void init() {
        application = container.getServletContext();
    }

    private static final Logger logger = LoggerFactory.getLogger(ProjectUrlMapper.class);

    @GetMapping({"","{pageParam}", "{pageParam}/{paramNo}"})
    public String projectMenuOverview(
            @PathVariable String manager_id
            ,@PathVariable String project_title
            ,@PathVariable Optional<String> pageParam 
            ,@PathVariable Optional<String> paramNo 
            ,Authentication authentication
            ,HttpSession session
            ,Model model
            ,HttpServletResponse resp
            ) {

        // 접속중인 프로젝트에 대한 처리를 먼저 한다.
        loadProjectProcessor(manager_id, project_title, authentication, session, resp);

        // paramNo 가 존재할때는 pageParam에 붙여준다.
        if(paramNo.isPresent()) {
            pageParam = Optional.of(String.format("%s/%s", pageParam.get(),paramNo.get()));
        }

        model.addAttribute("pageParam", pageParam.isPresent() ? pageParam.get() : "code");
        model.addAttribute("manager_id", manager_id);
        model.addAttribute("project_title", project_title);

        return "view/template/project";
    }
}

PathVariable과 정규식을 활용해 구현 했습니다.

3) Elastic Search

@Component
public class ElasticUtil {
    private String hostname;
    private int port;

    public String getHostname() {
        return hostname;
    }

    public int getPort() {
        return port;
    }

    private RestClientBuilder restClientBuilder;

    private ElasticUtil() {
        Properties properties = new Properties();
        try {    // dbinfo.properties에서 접속 정보 받아옵니다.
            properties.load(Resources.getResourceAsReader("best/gaia/db/dbinfo.properties"));
        } catch (IOException e) {}
        hostname = properties.getProperty("el.url");
        port = Integer.parseInt(properties.getProperty("el.port"));
        HttpHost host = new HttpHost(hostname, port);
        restClientBuilder = RestClient.builder(host);
    };

    /**
     * @param index
     * @param query Map<String, Object> key는 프로퍼티명, object는 value 조건
     * @param sort Map<String, SortOrder>
     * @param size (null 넣을 수 있습니다. size null일 경우 모두 받아옴)
     * @return 
     */
    public List<Map<String,Object>> simpleSearch(
            String index
            , Map<String,Object> query
            , Map<String,SortOrder> sort
            , Integer size
            ){


        // search에 index 조건 걸기
        SearchRequest searchRequest = new SearchRequest(index);
        SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();

        // query에 있는 셋 쿼리 조건으로 걸기
        for(String key : query.keySet()) {
            searchSourceBuilder.query(QueryBuilders.matchQuery(key, query.get(key)));
        }

        // sort 에 있는 셋을 정렬 조건으로 걸기
        for(String key : sort.keySet()) {
            searchSourceBuilder.sort(new FieldSortBuilder(key).order(sort.get(key)));
        }

        if(size != null) {
            searchSourceBuilder.size(size);
        }else {
            searchSourceBuilder.size(200);
        }

        searchRequest.source(searchSourceBuilder);

        List<Map<String,Object>> list = new ArrayList<>();
        try(RestHighLevelClient client = new RestHighLevelClient(restClientBuilder)) {
            SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
            SearchHits searchHits = response.getHits();
            for(SearchHit hit : searchHits) {
                Map<String, Object> sourceMap = hit.getSourceAsMap();
                list.add(sourceMap);
            }
        } catch (IOException e) {}

        return list;

    }


    public int insert(String index, Map<String, Object> data ){
        IndexResponse response = null;
        try(RestHighLevelClient client = new RestHighLevelClient(restClientBuilder)) {
            data.put("date", LocalDateTime.now());
            XContentBuilder xContent = XContentFactory.jsonBuilder().map(data);
            String jsonBody = Strings.toString(xContent);

            // id 없이 삽입시 자동 UID가 생성됩니다.
            String id = null;
            IndexRequest indexRequest = new IndexRequest(index).id(id).source(jsonBody, XContentType.JSON);
            response = client.index(indexRequest, RequestOptions.DEFAULT);
        } catch (IOException e) {}

        return response.getShardInfo().getSuccessful();
    }
}

Elastic Search의 highlevel java client API를 활용한 모듈을 만들어서 쉽게 사용했습니다.



2.Main

main

GAIA의 메인 화면 입니다.

login

우측 상단 로그인 버튼을 눌러 로그인 할 수 있습니다.

provider

회원가입에서 연필 버튼을 클릭 하면 숨겨진 서버 관리자 모드로 진입 할 수 있습니다.

provider

해당 페이지에서는 운영중인 서버의 다양한 정보를 실시간으로 확인 할 수 있습니다. 지금은 Oracle cloud의 Ubuntu instance에서 서버를 구동중이라서 OS information에 LINUX로 나오는 것이 확인됩니다.



3.User

overview

처음 로그인 했을때 페이지 입니다. 좌측에는 내가 속해있는 프로젝트들이, 중앙부에는 나에게 할당된 이슈들이 보여집니다.

push

누군가가 접속을 하거나 내가 쓴 이슈에 댓글을 달면 push 알람을 받습니다. 우측 상단에 표시됩니다.

alarms

종 모양 아이콘을 클릭해서 알람들을 확인 할 수 있습니다.

profile

프로필 정보를 수정 할 수 있으며

setting

이름과 비밀번호를 변경 할 수 있습니다.

log

접속 이력은 elastic search의 비관계형 데이터베이스로 관리됩니다.



4.Chatting

chat

간단한 채팅 기능도 준비되어 있습니다.



5.Project

1) Code

code

프로젝트에 들어가면 첫 페이지인 Code 페이지 입니다. Github에 있는 repository와 연동해서 Code와 readme 파일을 가져옵니다. 우측에는 프로젝트에 대한 설명과 멤버들 목록이 나옵니다.

2) Keyboard Shortcuts

shortcut

생산성 향상을 위한 단축키 기능을 제공합니다. Ctrl + '/' 키로 단축키 목록을 확인 할 수 있습니다.

3) Search

search

통합 검색 기능을 제공합니다. Key를 입력할 때 마다 바로바로 검색 해 줍니다. Logstash로 Oracle 서버를 Elastic Search에 인덱싱 해서 구현 하였습니다.

4) Multi languages

languages

다국어 메뉴를 지원합니다. 메뉴는 하드코딩 되어 있지 않고 Database 에서 받아오기 때문에 간단하게 메뉴를 추가하거나 언어를 추가할 수 있습니다.

5) Milestone

milestone1

마일스톤 목록을 확인 할 수 있습니다. 각각 마일스톤의 진행도를 한눈에 확인 할 수 있습니다.

milestone2

개별 마일스톤을 조회 하면 해당 마일스톤에 속한 이슈들을 조회 할 수 있습니다.

6) Issue

issue

이슈 목록 페이지에서는 각 필터별로 이슈들을 필터링 해서 조회 할 수 있습니다.

assignee

담당자 목록에 마우스를 올리면 펼쳐서 보여줍니다. css로 구현 했습니다.

newissue

새로운 이슈를 작성 할 수 있습니다.

issueedit

간단하게 수정도 할 수 있고 댓글도 작성 합니다.

ro

상황에따라 로/으로 을/를과 같은 조사를 구분합니다. 아래의 코드로 구현했습니다.

// 받침이 있는 문자인지 테스트 해주는 함수 입니다.
const isSingleCharacter = function(text) {

 var strGa = 44032; // 가
 var strHih = 55203; // 힣

 var lastStrCode = text.charCodeAt(text.length-1);

 if(lastStrCode < strGa || lastStrCode > strHih) {
  return false; //한글이 아닐 경우 false 반환
 }
    return (( lastStrCode - strGa ) % 28 == 0)
}

// '로' 가 붙어야 하는지 '으로'가 붙어야 하는지 체크해주는 함수
const roChecker = function(text){
    return text + (isSingleCharacter(text)? '로' : '으로'); 
}
// '를' 이 붙어야 하는지 '을'이 붙어야 하는지를 체크해주는 함수
const rulChecker = function(text){
    return text + (isSingleCharacter(text)? '를' : '을'); 
}

한글 종성이 총 28개 인 것을 활용해 코드를 작성 했습니다.

7) Gantt

gantt

8) Calendar

calendar

9) Kanban

kanban

칸반 기능도 구현했습니다. 각각의 칸반 Column과 Card들은 Singly linked list로 연결 되어 있습니다. 해당 비즈니스 로직은 아래와 같습니다.

    @Override
    @Transactional
    public ServiceResult moveCard(Integer droppedCardNo, Integer newColumnNo, Integer nextCardNo) {
        // 드랍된 카드 정보 받아오기
        KanbanCardVO droppedCard = kanbanDao.selectCard(droppedCardNo);
        KanbanCardVO previousNextCard = null;
        KanbanCardVO currentNextCard = null;
        // 드랍된 카드의 previousNextCard 정보
        if (droppedCard.getKb_card_next_no() != null) {
            previousNextCard = kanbanDao.selectCard(droppedCard.getKb_card_next_no());
        }
        // 드랍된 카드의 현재 다음 카드 정보
        if (nextCardNo != null) {
            currentNextCard = kanbanDao.selectCard(nextCardNo);
        }

        if (previousNextCard != null) {
            // previousNextCard가 droppedCard의 priv_no를 priv_no 로 가진다.
            previousNextCard.setKb_card_priv_no(droppedCard.getKb_card_priv_no());
        }

        // droppedCard의 이전 카드 정보를 현재 다음 카드의 이전 카드 넘버에서 가져와 수정한다.
        if (currentNextCard == null) {
            // 새로운 자리에 다음 카드가 없다면, 이사 온 컬럼의 마지막 카드를 priv_no 로 갖는다.
            Integer lastCardNo = kanbanDao.getLastCardNo(newColumnNo);
            droppedCard.setKb_card_priv_no(lastCardNo);
        } else {
            // 새로운 자리에 다음 카드가 있으면 해당 카드의 이전 카드 번호를 뺐어온다.
            droppedCard.setKb_card_priv_no(currentNextCard.getKb_card_priv_no());
            // 현재 다음 카드의 이전카드 정보를 dropped card no 로 수정한다.
            currentNextCard.setKb_card_priv_no(droppedCardNo);
        }
        // droppedCard의 column 값을 현재 다음 카드의 column 값으로 수정한다.
        droppedCard.setKb_col_no(newColumnNo);

        // 변경이 있었던 세 개의 칸반 카드 정보를 모두 업데이트 한다.
        // 쿼리를 세번 쏘지만, 하나의 트랜잭션으로 관리

        int validChecker = 1;
        if (previousNextCard != null) {
            validChecker *= kanbanDao.updateCard(previousNextCard);
        }
        validChecker *= kanbanDao.updateCard(droppedCard);
        if (currentNextCard != null) {
            validChecker *= kanbanDao.updateCard(currentNextCard);
        }

        if (validChecker == 1) {
            return ServiceResult.OK;
        } else {
            return ServiceResult.FAIL;
        }
    }

제가 워낙에 칸반을 좋아해서 팀에서도 포스트잇을 활용한 칸반을 적극적으로 활용 했습니다.

kanban2

19)News

news

뉴스 페이지 입니다. 무한 스크롤로 페이징 처리 하였습니다.

11)Wiki

wiki

위키 페이지에서는 각 위키별 수정 내역또한 조회 할 수 있습니다.

12)Analytics

analytics

프로젝트의 각종 통계를 확인 할 수 있는 페이지 입니다.

13)Member

member

멤버 관리 페이지에서는 소속된 멤버들을 조회하고, 멤버의 권한을 부여 할 수 있으며 초대 혹은 탈퇴를 시킬 수 있습니다.

14)Management

management

프로젝트를 관리하는 페이지 입니다.

label

새로운 라벨을 생성할 때는 커스터마이징 할 수 있도록 했습니다.

binary

사용모듈, 이슈중요도, 그리고 권한의 경우에는 컬럼을 여러개 만들 필요 없도록 이진수 형태로 값을 저장하도록 구현했습니다.

반응형