본문 바로가기
개발/사이드 프로젝트

사이드 프로젝트 오늘 뭐 먹지? 소개

by Dahna 2022. 6. 16.

안녕하세요. 1인 개발 프로젝트인 오늘 뭐 먹지? 서비스를 소개합니다.

평소에 만들어보고 싶었던 서비스를 혼자서 만들어 보았는데, 기획과 개발 파트로 나누어 소개하겠습니다.

 

기획

직장인들의 점심메뉴 선정 시 발생하는 커뮤니케이션을 돕는 서비스

 

매일 찾아오는 점심메뉴 선정 시간.. 쉽고 빠르게 결정할 수 없을까?

한 명 한명 먹고 싶은 메뉴를 묻고, 투표하는 과정이 번거롭게 느껴졌어요. 그 과정을 간소화, 자동화하는 서비스를 만들어보고 싶었습니다.

문제 정의 

점심 메뉴 선정시 불편한(번거로운) 이유는 비슷한 질문과 답변이 매일 반복된다는 점에서 발생한다고 생각했습니다.

이에 대한 해결책으로 질문과 답변에 일정한 형식을 제공하여 사람이 직접 고민하고 행동할 부분들이 서비스 내에서 제공될 수 있도록 하는 방안을 떠올렸습니다.

 

여기서의 질문은 메뉴 제안입니다. 어떤 메뉴를 떠올리고 그 메뉴가 괜찮은지 질문을 통해 동의를 구해야 합니다.

답변은 동의 혹은 거절의 의사 표현입니다. 질문을 받은 메뉴가 먹고싶으면 동의를, 먹고 싶지 않으면 거절을 합니다.

이 질문과 답변의 주체는 조직 구성원 입니다. 점심 메뉴 질문의 대상은 조직 구성원 전체가 될 수도 있고, 내가 원하는 사람을 특정해서 물어볼 수도 있습니다. 질문의 주체는 조직 구성원 누구나 될 수 있으며, 답변의 주체는 질문을 받은 사람 즉 자신이 질문의 대상에 해당할 경우 답변이 가능합니다.

 

유저 페르소나

메뉴 제안 대상을 두 가지로 분리한 이유는 설정한 유저 페르소나 때문입니다. 먼저 유저는 직장인을 가정하고, 우리가 점심을 먹을 때 어떻게 행동하는지 생각해 보았습니다. 먹고 싶은 메뉴가 있어서 사내 누구와 먹어도 상관없이 같이 먹을 사람을 찾는 사람이 있고, 매일 점심을 먹는 멤버가 고정이 되어 있어서 그 안에서 메뉴를 결정해야 하는 사람이 있다고 생각했습니다. 이 두 필요를 기반으로 유저를 메뉴에 진심인 유저, 사람에 진심인 유저 두 성향으로 분류했습니다.

 

이와 같이 유저는 자신의 선호에 따라 메뉴 제안을 모든 구성원을 대상으로 할 수도 있고, 자신이 원하는 멤버를 별도의 소그룹으로 구성하여 그 안에서 제안할 수도 있습니다.

질문 형식

질문은 메뉴 제안이므로 제안의 컨텐츠는 맛집 정보와 게시글 관련 내용으로 구성됩니다. 맛집 정보는 맛집 이름과 이 맛집을 소개하는 정보로 구성되는데 이 서비스에서는 맛집 정보를 직접 관리하는 것은 번거롭다고 생각하여 맛집 정보는 카카오맵 api를 통해 불러오도록 했습니다. 맛집 이름으로 검색한 카카오맵 api 결과에는 맛집의 위치, 전화번호, 카카오맵에서 제공하는 다양한 리뷰를 살펴볼 수 있는 링크가 포함됩니다. 직접 입력을 받는 항목은 맛집 이름과 분류(한식, 양식, 중식 등의 카테고리)를 받습니다. 게시글 관련 내용은 게시글 제목, 구성원들에게 전달할 메시지를 포함합니다. 

 

답변 형식

답변은 동의 혹은 거절의 두 가지 경우가 존재하고, 이를 참여/불참으로 정의했습니다. 유저는 전체 공개된 제안과 자신이 속한 소그룹 내의 제안을 조회할 수 있고, 제안은 참여 기능을 제공하여 제안에 참여함으로서 동의 의사를 표현할 수 있습니다.  

 

구성원 관리

전체 구성원에게 공개된 제안에 참여한 유저는 원하는 유저를 특정하여 참여 요청을 보낼 수 있습니다. 유저는 자신의 탭에서 받은 요청을 조회할 수 있으며, 수락과 거절이 가능합니다. 또한 소그룹에 속한 유저는 원하는 유저에게 소그룹 참여 요청을 보낼 수 있습니다. 마찬가지로 받은 요청은 유저 메뉴 창에서 조회할 수 있습니다.

 

맛집 정보 관리

제안을 등록하면 제안에 포함된 맛집 이름을 기준으로 맛집 정보를 등록하거나 업데이트합니다. 맛집이 등록되면 맛집 이름과 조직을 연결합니다. 이를 기반으로 맛집에 대한 조직 구성원들의 평가를 수집합니다. 조직 구성원들은 식사 시에 맛집에 리뷰를 작성할 수 있고, 이를 기반으로 맛집의 평가가 결정됩니다. 또한 맛집이 제안된 횟수가 저장되어 조직 내 맛집의 인기도로 사용됩니다. 이를 확장하여 추후에 조직 내애서 인기있는 맛집을 추천해주는 기능을 제공할 계획을 하고 있습니다.

 

기능 소개

오늘의 메뉴

오늘의 메뉴는 모든 구성원에게 공개된 제안으로, 메뉴에 진심인 유저를 위한 기능입니다. 같은 회사에 소속된 유저들끼리 그날그날 먹고 싶은 메뉴를 등록해 같이 먹을 사람을 구할 수 있습니다.

오늘의 메뉴 상세 페이지에는 연결된 맛집 정보와 참여 중인 멤버 목록을 보여줍니다. 맛집 정보로는 맛집 카테고리(한식, 양식, 중식..), 회사 내 오늘의 맛집에 등록된 횟수, 동료들이 식사한 횟수, 식사에서 받은 평점의 평균점수, 좋은 평가를 받은 비율, 카카오 맵 연동 지도 정보를 보여줍니다.

오늘의 메뉴에 같이 갈래요 버튼으로 직접 참여할 수도 있고, 참여한 유저는 회사 동료들에게 초대를 보낼 수 있습니다. 요청에 수락하면 오늘의 메뉴에 참여하게 됩니다.

받은 요청은 내 정보 메뉴에서 확인 가능합니다. 요청을 보낸 유저는 요청 취소를 할 수 있으며, 요청을 받은 유저는 수락 혹은 거절이 가능합니다. 모바일-웹 반응형으로 작성했습니다.

식사한 후에는 '먹었어요'를 통해 식사 종료를 알릴 수 있습니다. 원하면 간단한 리뷰 작성도 가능합니다. 맛집의 정보를 보여줄 때 이 리뷰를 기준으로 합니다. 맛집 이미지는 리뷰 중 좋은 평가를 받은 최신 리뷰의 이미지를 불러오며, 평점은 리뷰의 평균값을 사용합니다.

오늘의 메뉴 등록 시 맛집을 선택할 때는 카카오 맵 API를 이용합니다. 오늘의 메뉴를 등록할 때 키워드를 검색하면 회사 위치 근처의 맛집 목록을 지도에 보여주고, 맛집을 선택해 정보를 저장합니다. 

 

크루

크루는 조직 내에서 구성된 소그룹 입니다. 점심 메뉴를 먹는 멤버가 고정되어 있을때 해당 멤버들을 하나의 크루로 구성할 수 있습니다. 사람에 진심인 유저를 위한 기능입니다. 매일 점심을 먹는 멤버들과 크루에서 의사소통할 수 있습니다. 크루는 크루 멤버 관리 기능과 오늘의 메뉴 투표 기능을 제공합니다. 크루에 먹고 싶은 메뉴를 올리고 크루 멤버들끼리 투표를 할 수 있습니다. 

메인의 크루 탭에서는 내가 가입한 크루 목록을 보여줍니다. 크루는 공개되지 않고 초대를 통해서 가입한 멤버만 확인이 가능합니다. 유저는 모두 크루 생성이 가능합니다.

크루 상세 화면에서는 크루 멤버 목록을 보여주며 같은 회사 소속 유저를 초대할 수 있으며, 크루 멤버들끼리 오늘의 메뉴를 등록하고, 먹고싶은 메뉴에 투표할 수 있습니다. 크루 내 오늘의 메뉴 목록은 투표 득표 순으로 정렬되어 보입니다.

식사한 후에는 '먹었어요'를 통해 식사 완료 표시를 할 수 있으며 선택적으로 리뷰를 작성할 수 있습니다.

 

맛집

맛집 이름을 클릭하면 맛집 정보를 확인할 수 있습니다. 맛집 리뷰와 지도 정보를 확인할 수 있습니다. 이미지를 클릭하면 모달 창을 통해 크게 볼 수 있습니다.

 

유저

유저는 이메일을 기준으로 한 계정을 가지며 이메일 직접 가입, 구글, 카카오 소셜 로그인을 지원합니다. 현재 회사는 하나만 등록이 가능하며, 유저는 회사에 가입 시 설정한 닉네임을 서비스 전반에 활용하게 됩니다. 이메일은 계정으로, 회사 내 내 정보는 프로필로 사용됩니다.

 

 

 

https://eatwhat.kr/

 

https://eatwhat.kr/

 

eatwhat.kr

서비스는 위 링크에서 사용할 수 있습니다.

앞으로 식사 리뷰 데이터를 활용한 회사 내 인기 맛집 추천 등 다양한 기능을 계획 중이니 많은 관심 가져주시면 감사하겠습니다:)

 

개발

백엔드 서버는 Django+DRF조합으로, 프론트엔드 서버는 React+Next+TS 조합으로 개발했습니다. 

먼저 데이터 스키마를 구상한 후에 백엔드 서버를 개발하고 프론트엔드 서버를 개발했습니다. 

사용한 기술 스택은 다음과 같습니다.

프론트 소스코드

백엔드 소스코드

 

기술 스택을 간단히 살펴보면 백엔드는 Django로 개발하고 Heroku에 DB와 서버를 배포했습니다. 이미지는 Cloudinary를 이용했습니다. DB는 PostgreSQL을 사용했습니다. 인증은 OAuth를 통해 Kakao와 Google 계정을 이용할 수 있도록 했습니다. 유저 인증 방식은 JWT 토큰을 기본으로 합니다.

 

프론트는 React와 Next환경에서 개발했으며 TS를 도입하였습니다. 부분적으로 TDD 방식을 적용해 보았으며 이를 위해 Jest와 react-testing-library가 적용되어 있습니다. 상태 관리는 서버 상태는 react query를, 클라이언트 상태는 redux를 통해 관리했으며 스타일은 tailwind CSS를 기반으로 하여 styled components와 twin.macro를 이용해 스타일을 분리했습니다.

추가적인 사용 라이브러리에는 폼을 위한 react-hook-form과 유효성 체크를 위한 Yup, 팝업 구현을 위한 poperjs가 있습니다. 별도의 UI kit는 사용하지 않았습니다.

배포는 netlify를 이용했으며 도메인은 gabia에서 구매한 후 네임서버를 netlify로 옮겼습니다.(도메인 비용 지원해주신 올리버 팀장님 감사합니다:))

 

백엔드

데이터 스키마

데이터 구조는 회사를 나타내는 팀과 유저, 오늘의 메뉴를 나타내는 파티, 크루 등으로 구성되어 있습니다.

유저는 이메일 필드를 id로 하며 계정의 역할을 하고, 팀에 가입하면 생성되는 팀 멤버에서 유저 프로필 등을 설정할 수 있게 하여 한 유저가 여러 팀에 속할 수 있습니다. 유저 인증 방식에는 이메일, 구글, 카카오가 있습니다.

 

회사를 나타내는 팀은 이름과 지역을 00동과 같은 형태로 받습니다. 또한 팀별로 6자리 고유 인증코드를 제공하는데 이를 통해서 유저는 팀을 검색하고, 가입할 수 있습니다. 팀에 가입하면 팀 멤버가 생성되며 팀 멤버를 통해 팀별 닉네임, 이미지 등을 설정할 수 있습니다.

 

파티와 크루는 팀을 기준으로 조회 및 가입을 할 수 있습니다. 유저가 팀에 가입할 때 생성된 팀 멤버 id를 이용하여 파티와 크루 서비스를 이용할 수 있습니다.

 

키워드는 팀 id를 기억합니다. 키워드는 맛집 이름을 나타내는데, 회사 별로 맛집 정보가 저장되고 점심 메뉴로 등록된 횟수, 식사한 횟수, 좋은 평가(맛있다)를 받은 횟수를 저장합니다. 이 정보들로 추후에 회사별 맛집 추천 등 통계를 제공하려고 합니다. 카테고리는 한식, 양식, 중식 등의 음식 종류를 의미합니다. 리뷰는 키워드별로 저장되어, 맛집 이미지, 평점 등을 불러올 때 사용됩니다.

 

파티는 키워드와 1:1 매칭 되며 멤버쉽과는 1:n 매칭됩니다. 멤버쉽을 통해 유저는 파티에 가입, 탈퇴, 초대(요청, 승인, 거절)가 가능합니다. 파티는 '식사했어요'를 통해 종료할수 있으며 종료 시에 원하면 연결된 키워드, 즉 식사한 맛집에 대해 리뷰를 작성할 수 있습니다.

 

크루는 런치로 표현된 추천 메뉴와 1:n 매칭 됩니다. 추천 메뉴는 투표와 1:n 매칭되며 투표는 유저와 1:1 매칭됩니다. 유저는 여러 메뉴에 중복 투표할 수 있습니다. 크루는 크루 멤버쉽과 1:n 매칭되어 크루 멤버를 관리합니다. 크루 멤버쉽을 통해 유저는 크루에 가입, 탈퇴, 초대(요청, 승인, 거절)할 수 있습니다. 크루 내 오늘 점심 메뉴 결정은 추천 메뉴와 투표를 통해 결정됩니다. 추천 메뉴는 키워드와 1:1 매칭됩니다. 각 추천 메뉴 당 '식사했어요'를 통해 키워드에 리뷰 작성이 가능합니다.

 

Django + DRF

api 작성은 drf의 viewset을 기반으로 작성했습니다. 응답 포매팅 미들웨어를 통해 응답 형태를 지정했습니다.

 response_format = {
	'success': is_success(response.status_code), # 성공 여부
 	'result': data, # 응답 결과
	'message': message # 성공 혹은 실패 메시지
}

요청 파라미터 형태도 지정할 수 있도록 했습니다.

def request_data_handler(data, required_fields=None, other_fields=None):
    formatted_data = data
    if isinstance(required_fields, list):
        for field in required_fields:
            try:
                value = formatted_data[field]
            except KeyError:
                raise Exception('필수 파라미터' + field + '가 누락되었습니다.')

    if isinstance(other_fields, list):
        for field in other_fields:
            try:
                value = formatted_data[field]
            except KeyError:
                formatted_data[field] = None

    return formatted_data

응답시 페이지네이션의 next와 previous 값을 커스텀하여 링크가 아닌 페이지(숫자)로 응답하도록 했습니다. 페이지네이션된 결과의 경우 응답 포맷의 result 안에 count, next, previous, results로 제공됩니다.

인증

인증은 크게 두 가지 방식을 지원하는데 이메일 + 비밀번호로 인증하는 방식과 OAuth에서 발급받은 토큰으로 인증하는 방식이 있습니다. OAuth 제공자로는 Google과 Kakao를 지원합니다. 

이메일로 서버에 직접 가입한 유저는 가입 시 생성한 비밀번호를 통해 인증합니다. 인증 결과로 access token과 refresh token이 jwt 포맷으로 발급됩니다. 

OAuth 제공자를 통해 가입한 유저는 OAuth에서 인증하고 발급받은 토큰으로 django 서버에 인증을 요청하고 django 서버는 토큰 유효성 체크 후 유효하면 로그인 처리한 후 jwt 포맷의 django 토큰 매니저가 발급한 access token과 refresh token을 발급합니다.

자세한 내용은 여기에 블로깅 해두었습니다.

 

전체 소스코드는 여기에서 확인 가능합니다.

 

프론트엔드

문제점, 원인, 개선 방향

프론트엔드 개발은 그동안의 개발 경험에서 아쉬웠던 점을 개선하고 발전할 수 있는 기회로 삼고 싶었습니다. 그래서 먼저 아쉬웠던 점들과 개선 방향을 정리하는 시간을 가졌습니다.

 

아쉬웠던 점으로는 기능이 추가될 때마다 개별적으로 추가하는 방식으로 진행하다 보니 시간이 갈수록 서로 복잡하게 얽힌 코드가 되어 유지 보수하기 어려웠던 점을 꼽았습니다. 그 원인을 곰곰이 생각해 보니 초반에 프로젝트를 설계할 때 규칙을 세우지 않았기 때문이라고 생각했습니다. 이를 바탕으로 문제점과 원인과 개선 방향을 고민해 보았고 이 글을 참고하여 개선 방향을 설정해 보았습니다. 게시글 참고와 블로깅까지 허락해주신 Jbee님께 감사인사를 드립니다:)

문제점 원인 개선방향
새로운 기능이 추가될 때마다 몬스터 컴포넌트가 발생함 = 변경에 유연하지 않음 공통 컴포넌트에 도메인 맥락이 섞임 => 다른 데이터를 그려낼때 조건을 추가하게 됨 공통 컴포넌트에는 도메인 맥락을 제거하고 UI를 기반으로 추상화하고, 데이터는 도메인 컴포넌트를 조합하여 그려내기.
데이터를 그려내는 컴포넌트에 공통으로 사용 가능한 기능이 섞임 => 공통 기능을 재사용하기 위해 컴포넌트에 조건을 추가하게 됨 동일한 도메인 맥락 내에서 재사용되는 컴포넌트는 도메인별로 따로 묶어서 관리하기
코드 작성 과정과 작성된 코드 결과물의 일관성이 부족함 코드 작성시 적용할 규칙이 부재함 코드 작성시 적용할 액션 아이템을 갖기
코드를 수정할 때, 기존 코드가 제대로 작동하는지 보장할 수 없음 기존 코드의 작동을 보장하는 장치가 없음 먼저 테스트를 작성하고 테스트를 만족시키도록 개발하기

도출된 문제점들을 개선하기 위해서 액션 아이템을 고안했습니다.

 

1. 컴포넌트를 설계할 때 이 컴포넌트가 어떻게 사용되어야 하는지 먼저 정의한다.

2. UI요소데이터 기반 요소를 정리하고 분리하여 각각의 컴포넌트로 구성한다.

3. 컴포넌트의 의도를 정의하고 이를 검증할 수 있는 테스트 케이스를 작성한다.

4. 테스트 케이스를 통과할 수 있는 컴포넌트를 작성한다.

5. 컴포넌트를 리팩토링하며 정리한다.

 

개발 규칙을 정하고 액션 아이템을 도출하는 과정은 여기에 담겨 있습니다.

액션 아이템을 기반으로 컴포넌트를 개발한 과정을 기록했습니다. 계속해서 업데이트될 예정입니다.

 

상태 관리

애플리케이션의 상태 관리는 크게 서버에 요청과 응답을 통해 전달받은 데이터를 관리하는 것과 앱 자체적으로 UI 요소를 결정하기 위한 상태를 관리하는 것으로 볼 수 있습니다. 이렇게 두 가지 관점에서 상태 관리의 책임을 나누어 보았습니다.

 

서버 상태 - react-query

react-query는 서버 상태를 가져오고 캐싱하며 동기화하는 편리한 기능들을 제공합니다. 또한 비동기 데이터의 로딩, 성공, 에러 상태를 쉽게 관찰할 수 있습니다. 이 프로젝트에서 인증을 제외한 모든 서버 통신은 react-query를 이용했습니다.

 

클라이언트 상태 - redux

redux는 single source of truth라는 철학으로 전역 상태를 관리하는데 초점을 맞추고 있습니다. 앱에서 전역으로 참조되어야 할 인증과 UI표현을 위한 상태를 관리합니다.

 

api

api는 비동기 통신에 이용할 공통 기능과 Rest api를 통해 서버와 통신할 함수들을 관리합니다.

공통 기능은 인증 필요 여부에 따라 사용 가능한 Axios 인스턴스를 제공합니다. 인증이 필요한 api에서는 access token 확인 및 후속 조치가 설정된 인스턴스를 불러와 사용합니다. 그리고 서버와 통신할 함수들을 작성해서 서버 url 분기와 유사하게 분리해 두었습니다.

응답 형식은 DRF에서 시리얼라이저의 형태와 동일하게 일반 응답과 페이지네이션 된 응답을 타입으로 만들어두고 지정해 주었습니다.

import { AxiosResponse, AxiosError } from 'axios'

export interface ApiResponse<ResultT> extends AxiosResponse {
  data: ApiResponseData<ResultT>
}

export interface ApiResponseData<ResultT> {
  result: ResultT
  message: null | string
  success: boolean
}

export interface ApiErrorResponse<ResultT> extends AxiosError {
  response: ApiResponse<ResultT>
}

export type PaginatedResult<ResultT> = {
  count: number
  next: null | number
  previous: null | number
  results: Array<ResultT>
}

뮤테이션 응답 타입을 커스텀 훅에 지정해 주었습니다.

import _ from 'lodash'
import { useEffect } from 'react'
import { useMutation } from 'react-query'
import { openToast } from '@/components'
import { ApiErrorResponse, ApiResponseData } from '@/type'

const useMutationHandleError = <ResultT>(
  func: (data: any) => Promise<ApiResponseData<ResultT>>,
  variables: Object,
  errorMessage: string = '에러가 발생했습니다.',
) => {
  const mutation = useMutation<
    ApiResponseData<ResultT>,
    ApiErrorResponse<ResultT>,
    any
  >(func, variables)

  useEffect(() => {
    if (mutation.error) {
      const { message } = mutation.error.response.data
      openToast(_.isString(message) ? message : errorMessage)
    }
  }, [mutation.error])
  return mutation
}

export { useMutationHandleError }

api 요청 함수에 응답 타입을 지정해 주었습니다.

import { authorizedInstance } from '@/api/setupAxios'
import { ApiResponse, isValidId } from '@/type'

export const listParty = async <ResultT>(
  page: number = 1,
  category?: number,
) => {
  const { data: response }: ApiResponse<ResultT> = await authorizedInstance.get(
    `group/party/`,
    {
      params: {
        category,
        page,
      },
    },
  )
  return response.result
}

쿼리 요청 커스텀 훅에 응답 타입을 지정해 주었습니다.

export const usePartyQuery = (category?: number) => {
  const { isLoading, user } = useAppSelector((state) => state.user)
  return useInfiniteQuery<
    PaginatedResult<PartyList>,
    ApiErrorResponse<PartyList>
  >(
    ['party', category],
    ({ pageParam = 1 }) => listParty(pageParam, category),
    {
      enabled: !!user?.team_profile,
      keepPreviousData: true,
      getNextPageParam: (lastPage, pages) => lastPage.next,
      cacheTime: 1000 * 60 * 60,
    },
  )
}

 

인증

유저 인증은 OAuth 제공자를 통한 인증과 백엔드 서버에 이메일과 비밀번호로 인증하는 두 가지 방법을 제공합니다.

서버는 일반적인 회원 가입 방식, 이메일과 비밀번호로 회원 가입을 지원합니다. 이메일로 직접 서버에 가입한 유저는 이메일과 비밀번호를 보내 인증할 수 있습니다.

 

소셜 로그인의 경우 google, kakao SDK를 감싼 라이브러리 react-google-loginreact-kakao-login을 이용했습니다. 로그인 방식은 자바스크립트 SDK를 통해 유저가 OAuth에 인증을 받으면 발급받은 토큰을 장고 서버에 보내고, 장고 서버에서는 이를 다시 OAuth 제공자에게 유효한지를 체크받은 후 유효하면 로그인 처리 후 장고 서버에서 유저를 조회하고 JWT 토큰을 발급하여 유저 정보와 함께 응답합니다. (응답은 일반 이메일 가입 유저와 동일한 형태입니다.) 이를 프론트에서는 redux에 저장합니다.

구글 로그인의 경우 자바스크립트 플랫폼 라이브러리 지원이 중단되어 Google ID로 이전이 필요하여 추후 다루어보려고 합니다.

 

인증 관련 자세한 내용은 여기에서 확인할 수 있습니다.

 

컴포넌트 개발 규칙 정하기

프론트엔드 개발 서두에 언급한 것처럼 컴포넌트 개발에 앞서 확장성을 늘리고 변경에 유연하게 대처할 수 있는 방안을 고민했습니다.

이에 대한 방안으로 컴포넌트의 책임을 크게 UI 인터페이스 제공, 도메인 기반 데이터 출력이라는 두 가지로 구분했습니다. 화면을 그릴 때는 UI 인터페이스를 제공하는 컴포넌트에 도메인 종속 컴포넌트를 합성하여 사용합니다. 마치 그릇과 내용물 같은 관계로 구성했습니다. 그리고 이 책임 별로 컴포넌트 디렉토리를 구분했습니다.

components    
	/buttons	# 공통 UI기반 컴포넌트
    	/...
    /controls
    	/...
    /modal
    	/...
    /pagination
    	/...
    /popover
    	/...
    /toast
    	/...
    /modules	# 도메인 종속 컴포넌트들은 modules에 분리
    	/keyword
        	/...
        /party
        	/...
        ....

모달, 팝오버, 입력 폼, 무한 스크롤 등의 기본 UI 컴포넌트를 구현해서 재사용했고, 도메인 종속 컴포넌트들은 DB 모델 형식과 동일하게 타입을 작성하여 해당 데이터를 표현할 수 있도록 했습니다.

 

스타일

스타일은 tailwind Css를 기반으로 작성하였으며 별도의 UI kit는 사용하지 않았습니다. (사실 사용했다가 들어냈습니다) Styled-component와 twin.macro를 이용하여 스타일을 재사용했습니다. 기기에 상관없이 사용할 수 있도록 반응형으로 작성했습니다. (모바일과 데스크톱 환경에서 달라지는 스타일을 찾아보세요:))

 

테스트

테스트는 유닛 테스트를 기반으로 하는 testing-library와 jest 조합으로 작성했습니다. 몇 개의 재사용하는 컴포넌트에만 적용해보았습니다. 테스트 주도 개발이라 불리는 실패 - 성공 - 리팩토링의 과정을 앞서 언급한 액션 아이템에 포함시켜 적용했습니다. 컴포넌트를 인터페이스 관점에서 설계하고 이를 테스트로 작성해보니 보다 역할이 명확한 컴포넌트를 작성할 수 있었습니다. 또한 브라우저를 보지 않고도 개발이 가능한 점도 유용했습니다. (브라우저를 열지 않고 테스트를 만족하는 컴포넌트를 적용했을 때 바로 원하는 대로 동작되어 기뻤습니다)

시간 관계상 프로젝트 전반에 적용하지 못한 것이 아쉬움이 남습니다. 추후에는 '무엇을 테스트할 것인가'에 대한 고민을 좀 더 해서 보다 높은 테스트 커버리지를 가져가고 싶습니다.

 

전체 소스코드는 여기에서 확인할 수 있습니다.

 

배포

백엔드 서버와 DB는 heroku, 프론트엔드는 netlify 서버에 배포했습니다. 이미지는 cloudinary 서버에 업로드하고 주소를 DB에 저장했습니다. 도메인은 가비아에서 구입하였으며 DNS 서버는 netlify를 이용했습니다.(SSL 인증을 자동으로 해줍니다) 

heroku 서버는 무료 서버라 30분 동안 사용하지 않으면 잠자기 모드에 들어가 첫 api 호출에 몇 초간 지연이 생깁니다. 그리고 DB도 10000 row까지만 지원합니다. 한 달에 만원 안쪽의 hobby plan을 구매하면 대폭 개선되어 구매를 고려하고 있습니다. (처음에 geoDjango로 프로젝트를 시작해 DB를 5000줄 정도 차지해버렸는데 사용하지 않아 제거하고 싶습니다. eject 하는 방법을 몰라 헤매고 있습니다..)

 

 

후기

이렇게 A부터 Z까지 프로젝트를 진행해보니 생각보다 정말 오래 걸렸지만 재미있었습니다. 지난 개발 경험에서 아쉬웠던 점들을 개선하기 위해 고민하는 과정이 좋았습니다. 좋은 프로그램을 만들고 싶은 마음은 컸지만 첫술에 배부르긴 어렵다는 것을 인정하고 개발 목표를 결정했습니다. 그래도 고민한 흔적이 고스란히 묻은 나만의 프로젝트가 생긴 것이 값지다는 생각이 들었습니다. 고민할수록 부족한 점도 많이 보여서 앞으로 개선해가면서 프로젝트와 함께 성장해 나가야겠습니다:)

 

정리

댓글