( 2020-01-23 기준 ) 접속 주소
http://assemble-client.s3-website-us-east-1.amazonaws.com/
초기 컨셉
Assemble은 모임을 잡을 때 여러 사람들의 의견을 모아 결정하는 과정을 도와주는 간편한 Web App입니다.
참여자마다 각각 다른 여유 시간과 일정 정보들을 모아서 최대한 많은 사람들이 모일 수 있도록 최선의 모임 시간을 찾아냅니다.
또한 모임의 활동 내용과 식사 정보에 대한 참여자들 개인의 선호, 불호 여부를 결정에 반영해서 모두에게 만족스러운 의사 결정을 효율적으로 할 수 있도록 돕습니다.
참여자가 위치 정보를 지도 위에 표시해서 공유할 수 있는 기능을 제공해서 모두가 직관적으로 만날 장소를 고를 수 있게 해주고, 모임 장소까지 걸리는 시간을 파악하거나, 모임 장소와 가까운 맛집을 찾는 일을 도와줍니다.
또한 약속 날짜에 볼 수 있는 영화를 확인하고, 당일 날씨나 미세먼지 정보를 쉽게 찾아볼 수 있는 디테일한 기능에 더해서 실시간 채팅과 투표 기능을 통해서 빠르고 효과적인 의사 결정을 지원합니다.
마지막으로 모임이 끝난 이후, 비용을 정산하는 문제까지도 한번에 해결할 수 있도록 더치페이 계산기를 지원하고 있습니다.
모임에서 다같이 찍은 사진을 모두에게 공유하는 일도 사진 게시판을 이용하면 아무 문제 없습니다.
모임의 시작부터 끝까지 책임지는 Assemble은 현재는 외부 API에만 의존하고 있지만, 자체 서비스로 추천, 광고 방식의 수익 모델을 도입한다면 경쟁력 있는 Win Win 솔루션이 될 것입니다.
High Level Object : 모임, 약속시 일정, 장소, 활동, 메뉴 결정에 참여자의 각각 다른 의견을 모두 반영한 직관적인 실시간 투표를 제공해서 효과적인 의사결정을 돕는 Web Application
Tech Stack :
[ Server Side ]에는 짧은 기한에 맞게 안정적이고 빠른 개발을 위해서 이미 익숙하고 디버깅, 이슈 해결에 자신이 있는 Node.js와 express를 이용해서 서버를 구성했고,
개발 초기에 자주 생길 수 있는 변경사항에 빠르게 대응하고 실시간 처리에 맞는 성능을 보장하기 위해서 자유로운 형식을 지원하며 대용량 데이터 처리에 적합한 NoSQL 타입의 데이터베이스인 Firebase - FireStore를 사용했습니다.
실시간 웹 서비스를 위해서 Socket.io를 사용했습니다.
[ Client Side ]에는 클라이언트의 복잡한 상태와 구조를 효율적이고 간단하게 처리하기 위해서 상태 처리를 한 장소에서 단방향으로 진행하게 도와주는 Redux와
안정적이고 빠른 개발을 위해 이미 익숙하고 디버깅, 이슈 해결에 자신이 있으며 효율적인 클라이언트 처리를 통해 반응성이 높은 React를 이용해서 클라이언트 구조를 잡았고,
빠르고 쉽게 사용자가 만족할만한 퀄리티의 UI를 제공하기 위해 Ant Design을 사용해서 디자인을 구성했습니다.
실시간 웹 서비스를 위해서 Socket.io를 사용했습니다.
상황: 각 참여자가 [일정, 장소, 활동, 메뉴] 에 대한 투표목록을 갖는다. 채팅은 따로 방에 속한 것으로 한다. (간단한 채팅 기능으로 충분하므로 참여자에 종속시키지 않고 방에 전체 채팅 목록을 종속시킨다.)
클라이언트는 실시간으로 모든 사람의 총 투표 결과를 확인할 수 있다. ex: "월요일 3시"에 몇명이 괜찮다고 했는지, "볼링 치기"에 몇 개의 좋아요와 싫어요가 있는지 실시간으로 확인
-
클라이언트는 맨 처음 들어올 때 자신의 방의 뷰를 서버에서 가져와서 렌더링한다.
서버는 실시간으로 모든 방의 상태를 방 별로 서버 안 모델에 저장해서 유지하고, 클라이언트와 실시간으로 상호작용하면서 직전 모델에서 변경사항을 해당 방의 클라이언트들에게 전달해준다.
<장점>: 클라이언트는 서버에 자신의 입력만을 전달하고, 서버에서 변경사항이 들어오면 그대로 변경해서 렌더링하면 되기 때문에 클라이언트 구현이 쉽다.
사용자가 적고, 방의 수가 적은 상황이 보장되고 활발한 방들만 있다는 전제 하에 클라이언트와 서버 모두 구현하기 쉽고, 통신 양이 적어서 가장 효율적이다.
<단점>: 방의 개수가 많고, 활발하지 않은 방의 비율이 높은 경우, 서버 안에 모든 방의 정보를 항상 저장해야 하기 때문에 비효율적이고 서버의 부담이 커져서 Scalable하지 않다.
따라서 많은 방을 지원해야 하고, 항상 활발한 방의 비율이 높지 않은 ASSEMBLE에는 적절하지 않은 구조이다.
-
클라이언트는 맨 처음 들어올 때 자신의 방의 모델을 서버에서 가져와서 렌더링한다.
모든 방의 상태는 방 별로 데이터베이스 안의 모델에 저장해놓고, 클라이언트가 요청을 보내면, 요청을 받았을 때만 데이터베이스 안에 있는 해당 방의 모델을 수정하고 변경사항을 해당 방의 클라이언트들에게 전달해준다.
<장점>: 클라이언트는 서버에 자신의 입력만을 전달하고, 서버에서 변경사항이 들어오면 그대로 변경해서 렌더링하면 되기 때문에 클라이언트 구현이 쉽다.
방의 개수가 많고, 활발하지 않은 방의 비율이 높은 경우, 방이 아무리 많더라도 모든 방의 상태를 서버가 유지할 필요가 없어서 부담이 적고 확장성이 높아진다.
<단점>: 일시적으로 트래픽이 높아지는 상황에서 매번 클라이언트가 입력을 요청할 때마다 변경사항을 찾기 위해서 테이블을 조인해서 부분적으로 모델을 완성 하는 과정을 반복해야 하므로 비효율적이다.
따라서 항상 요청이 균일하게 주기적으로 오는 것이 아니라, 참여자들이 모임을 갖는 순간에 폭발적으로 증가하는 ASSEMBLE에는 최적의 구조는 아니다.
-
클라이언트는 맨 처음 들어올 때 자신의 방의 모델을 서버에서 가져와서 렌더링한다.
자신이 속한 방의 상태를 클라이언트가 최신 모델로 유지하며, 입력이 생기면 클라이언트가 자신의 입력을 서버에 제출한다.
입력을 받은 서버는 해당 참여자의 입력(새 투표 목록)을 데이터베이스에 덮어쓰기만 하며 입력 내용을 그대로 해당 방의 다른 클라이언트에게 전달한다.
클라이언트는 변경사항을 받을 때마다 자신이 유지하는 모델을 업데이트하며 새 모델을 렌더링한다.
<장점>: 방이 많아지더라도, 방이 순간적으로 활발하게 운영되더라도 서버와 데이터베이스에 미치는 부담이 최소화된다. (또한 데이터베이스에 매 입력마다 쓰는 작업이 부담스러울 정도로 입력 수가 많고 주기가 짧을 경우 모델의 Reliability를 희생하면서 입력을 버퍼링 시켜서 데이터베이스에 제출하면 효율적으로 대규모 입력을 처리할 수 있다.)
<단점>: 클라이언트가 모델을 유지하고 관리해야 하는 책임이 생기기 때문에 클라이언트 구현이 어려워지고, 모든 클라이언트가 동일한 작업을 한다는 비효율성이 생긴다. (클라이언트가 다뤄야 할 모델이 크고 복잡하다는 상황에서는 이런 비효율성이 문제가 될 수 있다. 하지만 같은 그룹끼리 같은 처리를 한다는 점을 이용해서 같은 그룹끼리 묶어서 분산처리를 한다면 이 문제도 충분히 해결할 수 있다.)
따라서 클라이언트 구현이 어려워지긴 하지만, ASSEMBLE의 사용 환경에 최적의 구조인 구조 3을 사용하게 되었다.
COMMENT - 프로젝트 USE CASE에 최적인 3번 구조를 선택했고, 이 구조에는 클라이언트 구현 난이도가 높은 단점이 있다.
서버를 빠르게 완성하고 남은 시간을 클라이언트 구현에 사용하면 충분할 것이라고 설계 당시 예상했지만, 클라이언트에 새로 도입한 기술 이해와 함께 모델 관리를 10일이라는 시간 정도에 여유롭게 구현하기에는 구현 난이도가 높은 편이었다. 이 결정 때문에 모두 고생했고, 10일 제한의 프로젝트를 진행하는 프로젝트 관리 측면에서는 1번이나 2번 구조를 선택하는 것이 더 나은 선택이었을 것 같다.
req body
{
password: String,
name: String
}
res body
{
id: String
}
설명 : room_id에 해당하는 방에 새로 입장을 요청한다. 비밀번호가 맞다면 status code: 200을, 틀리다면 status code: 401을 응답으로 받는다. 방이 존재하지 않으면 status 404를 받는다. 정상 응답시 쿠키로 room에 JWT를 등록하고, body 안에도 JWT를 첨부한다.
req body
{
password: String,
}
res body
{
token: JWT
}
res body
{
room_id: String
name: String
}
res body
{
"room_id": String,
"password": String,
"roomname": String,
"payment_list": [],
"people": [
{
"menu_list": [],
"name": String,
"activity_list": [],
"avail_places_list": [],
"avail_schedules_list": []
}, ...
],
"chats": [
{
id: Number,
author_name: String,
content: String,
created_at: JSON Date
}, ...
]
}
설명 : room_id에 해당하는 방에서 연결을 끊는다. JWT안의 방 정보가 지워지므로 이후 동일 방에 들어가려면 비밀번호를 입력해야 한다. response로 status code: 200을 보낸다. 토큰의 기억하고 있는 방 정보가 지워진다.
설명 : room_id에 해당하는 방에서 유저 연결을 끊는다. 이후 해당 방에서 동일 이름으로 들어가려면 이름을 입력해야 한다.status code: 200을 보낸다. 토큰의 기억하고 있는 방-이름 정보가 지워진다. socket으로 disconnect 요청 message를 보낸다. io.to(socket_id).emit("drop", "")
설명 : room_id에 해당하는 방에 참여하는 사람 중에 name에 해당하는 사람이 있는지 조회한다. 이미 존재한다면 code: 200을 존재하지 않는다면 code: 404를 보낸다.
설명 :room_id에 해당하는 방에 새로운 이름 name을 추가한다. 이미 중복된 이름이 존재한다면 code: 409를, 새로운 이름을 추가하는데 성공했다면 (해당 방에 연결된 모두에게 socket.io로 해당 내용을 broadcast한 다음) code:201을 보낸다.
req body
{
name: String
}
설명 :room_id에 해당하는 방에 참여하는 사람 중에 name에 해당하는 사람의 정보를 삭제한다. 삭제 이후 (해당 방에 연결된 모두에게 socket.io로 해당 내용을 broadcast한 다음) response로 code: 200을 보낸다.
설명 :room_id에 해당하는 방에 채팅을 보낸다. (해당 방에 연결된 모두에게 socket.io로 해당 내용을 broadcast하고) 해당 채팅을 DB에 등록한 후 code: 201을 보낸다.
req body
{
content: String
}
설명 :room_id에 해당하는 방 내에 있는 name의 이름을 갖고 있는 사람의 가능 일정 목록을 변경한다. (맨 처음 제출이라도 PUT에 해당한다. 아이디를 생성할 때 빈 리스트가 생기기 때문이다.) (해당 방에 연결된 모두에게 socket.io로 해당 내용을 broadcast하고) 해당 리스트를 DB에 등록한 후 code: 201을 보낸다.
req body
{
avail_schedules_list: [
{
content: String
},
...
]
}
설명 :room_id에 해당하는 방 내에 있는 name의 이름을 갖고 있는 사람의 가능 장소 목록을 변경한다. (맨 처음 제출이라도 PUT에 해당한다. 아이디를 생성할 때 빈 리스트가 생기기 때문이다.) (해당 방에 연결된 모두에게 socket.io로 해당 내용을 broadcast하고) 해당 리스트를 DB에 등록한 후 code: 201을 보낸다.
req body
{
avail_places_list: [
{
content: String
},
...
]
}
설명 :room_id에 해당하는 방 내에 있는 name의 이름을 갖고 있는 사람의 활동 목록을 변경한다. (맨 처음 제출이라도 PUT에 해당한다. 아이디를 생성할 때 빈 리스트가 생기기 때문이다.) (해당 방에 연결된 모두에게 socket.io로 해당 내용을 broadcast하고) 해당 리스트를 DB에 등록한 후 code: 201을 보낸다.
req body
body(JSON):
{
activity_list: [
{
content: 내용,
isFavor: 호/불호 여부(boolean)
},
...
]
}
설명 :room_id에 해당하는 방 내에 있는 name의 이름을 갖고 있는 사람의 메뉴 목록을 변경한다. (맨 처음 제출이라도 PUT에 해당한다. 아이디를 생성할 때 빈 리스트가 생기기 때문이다.) (해당 방에 연결된 모두에게 socket.io로 해당 내용을 broadcast하고) 해당 리스트를 DB에 등록한 후 code: 201을 보낸다.
req body
body(JSON):
{
menu_list: [
{
content: 내용,
isFavor: 호/불호 여부(boolean)
},
...
]
}
io.to(room_id).emit("drop", "")
io.to(room_id).emit("chat message", body)
body
{
id: created_at,
author_name: author_name,
content: content,
created_at: created_at
}
io.to(room_id).emit("new person", body)
body
{
name: name,
avail_schedules_list: JSON.stringify([]),
avail_places_list: JSON.stringify([]),
activity_list: JSON.stringify([]),
menu_list: JSON.stringify([])
}
io.to(room_id).emit("delete person", name)
사용하는 상황 : 채팅방에 어떤 사람의 일정 목록이 update 되었음을 알릴 때 사용한다. 클라이언트가 같은 메시지를 두번 처리하는 문제를 방지하기 위해 name과 발송 시간에 의해 고유하게 만들어지는 id를 같이 전송한다.
io.to(room_id).emit("new schedule_list", body)
body
{
id: <double message를 방지하기 위한 id>,
name: name,
avail_schedules_list: schedule_list
}
사용하는 상황 : 채팅방에 어떤 사람의 장소 목록이 update 되었음을 알릴 때 사용한다. 클라이언트가 같은 메시지를 두번 처리하는 문제를 방지하기 위해 name과 발송 시간에 의해 고유하게 만들어지는 id를 같이 전송한다.
io.to(room_id).emit("new place_list", body)
body
{
id: <double message를 방지하기 위한 id>,
name: name,
avail_places_list: place_list
}
사용하는 상황 : 채팅방에 어떤 사람의 활동 목록이 update 되었음을 알릴 때 사용한다. 클라이언트가 같은 메시지를 두번 처리하는 문제를 방지하기 위해 name과 발송 시간에 의해 고유하게 만들어지는 id를 같이 전송한다.
io.to(room_id).emit("new activity_list", body)
body
{
id: <double message를 방지하기 위한 id>,
name: name,
activity_list: activity_list
}
사용하는 상황 : 채팅방에 어떤 사람의 메뉴 목록이 update 되었음을 알릴 때 사용한다. 클라이언트가 같은 메시지를 두번 처리하는 문제를 방지하기 위해 name과 발송 시간에 의해 고유하게 만들어지는 id를 같이 전송한다.
io.to(room_id).emit("new menu_list", body)
body
{
id: <double message를 방지하기 위한 id>,
name: name,
menu_list: menu_list
}
{
room_id: String,
room_name: String,
room_pwd: String,
people: <collection>,
chats: <collection>,
payment_list: Stringify(list_of_contents)
}
{
name : String,
avail_schedules_list: Stringify(list_of_contents),
avail_places_list: Stringify(list_of_contents),
activity_list: Stringify(list_of_contents),
menu_list: Stringify(list_of_contents)
}
{
id: String,
author_name: String,
content: String,
created_at: firebase.firestore.Timestamp
}