📎 배포 URL
이메일 로그인 테스트 계정
- ID :
[email protected]
- Password :
daengnyang2022
가져도댕냥은 반려동물을 사랑하는 팻펨족을 위한 SNS/커뮤니티 서비스입니다.
- 반려동물이 쓰던 물건을 중고로 판매/구매할 수 있습니다.
- 상품을 판매/구매하지 않아도 일상을 공유하며 즐거운 SNS 활동을 할 수 있습니다.
- 반려동물 커뮤니티 서비스를 이용할 수 있습니다.
FE 김민승 | FE 김의호 | FE 배승연 | FE 이광렬 |
---|---|---|---|
🔗 GitHub 디자인 리더 |
🔗 GitHub 기획 리더 |
🔗 GitHub 프로젝트 매니저 |
🔗 GitHub 개발 리더 |
- IDE : Visual Studio Code 1.74.2
- OS : macOS Monterey, Windows 10
- FE : React v18, Styled-components v5, Axios v1.2.1
- BE : 제공된 API 사용
- 버전 관리 : Git, GitHub
- 진행 상황 관리(칸반 보드) : GitHub Projects
- 이슈 관리 : GitHub Issues
- 문서 관리 : Notion
- 메신저 : Discord
- API 테스트 : Postman
public/favicon/
: 파비콘src/assets/
: 서비스에서 사용하는 에셋 파일 (폰트, 아이콘, 이미지)src/components/
: 서비스에서 사용하는 컴포넌트 (캐러셀, 공통 컴포넌트, 공통 레이아웃)src/context/
: 전역 데이터를 공유하기 위해 정의한 Context 파일src/hooks/
: 재사용을 위해 분리한 Custom Hooksrc/pages/
: 공통 컴포넌트를 사용해 만든 페이지src/routes/
: 페이지 라우팅을 위한 파일src/styles/
: 전역 스타일 파일src/utils/
: 재사용을 위해 분리한 유틸 파일
📦 가져도댕냥
├─ 📦 public
│ ├─ 📂 favicon
│ └─ 📜 index.html
└─ 📦 src
├─ 📂 assets
│ ├─ 📂 fonts
│ ├─ 📂 icons
│ └─ 📂 images
├─ 📂 components
│ ├─ 📂 carousel
│ ├─ 📂 common
│ └─ 📂 layout
├─ 📂 context
├─ 📂 hooks
├─ 📂 pages
│ ├─ 📂 ChatPage
│ ├─ 📂 CommunityPage
│ ├─ 📂 FeedPage
│ ├─ 📂 FollowListPage
│ ├─ 📂 JoinPage
│ ├─ 📂 LoginPage
│ ├─ 📂 NotFoundPage
│ ├─ 📂 PostPage
│ ├─ 📂 ProductPage
│ ├─ 📂 ProfileModificationPage
│ ├─ 📂 ProfilePage
│ ├─ 📂 SearchPage
│ └─ 📂 SplashScreen
├─ 📂 routes
├─ 📂 styles
├─ 📂 utils
├─ 📜 App.jsx
└─ 📜 index.jsx
- 소규모 프로젝트에 맞게 Main, Develop, Feature 세 Branch를 사용하는 전략 사용
- 요구사항 파악 및 프로젝트 규칙 설립 : 2022-11-29 ~ 2022-12-09
- 공통UI 컴포넌트 개발 : 2022-12-09 ~ 2022-12-13
- 페이지 퍼블리싱 : 2022-12-13 ~ 2022-12-17
- 기능 개발 : 2022-12-16 ~ 2022-12-27
- 버그 수정 및 유지보수 : 2022-12-26 ~ 2023-01-05
- 프로젝트 시작 전, 공통된 팀 목표를 세우고 시너지를 강화하기 위해 네이버폼 설문 진행
- 공동 작업툴인 Figzam을 이용한 회의
- 딱딱한 분위기의 회의가 아니라 모두가 참여할 수 있는 가벼운 분위기에서 회의를 진행
- 매일 9시 20분 데일리 스크럼을 통해 업무 공유 및 진행 상황 파악
- GitHub Wiki에 개발 규칙 등록
- GitHub Milestones을 이용한 단계별 목표 관리
- 프로젝트 단계별 목표를 명확하게 하기 위해 마일스톤 등록
- GitHub 이슈 등록 시 관련된 마일스톤 선택
- 작업 전 GitHub Issues 등록
- 이슈 해결 후 Pull Request 생성
- 컨벤션 통일을 위해 PR 템플릿 사용
- 팀원 2명 이상의 승인을 받아야 머지 가능
- GitHub Projects를 이용한 칸반 보드
- 이슈 진행 상황을 한 눈에 볼 수 있도록 칸반 보드 형태로 시각화
- 노션 기능별 작업 상황 관리 페이지를 작성해 작업 진행 상황 관리
- 전체적인 작업 진행 상황을 한 눈에 볼 수 있도록 기능별 진행 상황 표 제작
- 진행 전, 진행 중, 진행 완료, 수정 중 태그를 이용해 진행 상황 체크
- 노션 버그 리포트 페이지를 작성해 발견된 버그 관리
- 버그 발견자가 버그 유형, 버그, 버그 작성자, 개발 담당자 등록
- 버그 작성자가 개발 담당자인 경우 : 본인 등록
- 버그 작성자가 개발 담당자가 아닌 경우 : 협업 메신저로 내용 공유 후 개발 담당자 설정
- 개발 담당자는 버그 확인 후 확인 결과 등록
- 거절 : 버그가 아닌 경우
- 승인 : 버그
- 승인된 버그는 개발 담당자가 이슈 등록 후 버그 수정 진행
- 커밋 메시지 컨벤션, 코드 컨벤션, 네이밍 컨벤션, 디렉토리 구조 컨벤션 설립
- GitHub Wiki에 컨벤션 기록
- 이슈 관리
- 이슈 진행 상황 관리
- 작업 진행 상황 관리
- 버그 관리
- README 작성
- 기록을 위한 회의록 작성 담당
- 탑 내비게이션 5종, 탭 메뉴, 댓글 컴포넌트 제작
- Splash, 피드, 프로필, 404 페이지 퍼블리싱
- 홈 피드 페이지 기능 구현
- 피드 데이터를 받아 화면에 그려주는 기능 구현 (API 명세에 따름)
- 로그인 여부에 따른 페이지 로드 기능 구현
- 로그아웃 상태일때는 로그인 페이지 / 로그인 상태일때는 팔로잉 피드 데이터 받아오도록
- 피드 데이터 제한 없이 받아올 수 있도록 페이지 무한스크롤 구현
- 프로필 페이지 기능 구현 (사용자 정보, 등록된 상품, 작성글 조회 기능)
- 유저 정보 및 상품, 포스트 목록 데이터를 받아 화면에 그려주는 기능 구현 (API 명세에 따름)
- 포스트 목록 레이아웃 리스트 형식 / 앨범 형식으로 선택해서 볼 수 있도록 기능 추가
- 카카오톡 공유하기 라이브러리 추가하여 공유 기능 구현
- 상품 리스트를 깔끔하게 볼 수 있도록 Swiper 라이브러리 사용하여 캐러셀로 구현
- 게시물 목록을 제한 없이 볼 수 있도록 페이지 무한스크롤 구현
- 게시글 모달 관련 기능 구현
- 게시글 삭제 / 신고 기능
- 나의 게시물일때는 수정 및 삭제 기능 / 다른 사용자의 게시물일때는 신고 기능 보이도록 구현
- 게시글 삭제 / 신고 기능
- 게시물 좋아요 기능 구현
- 게시글, 상품 컴포넌트 제작
- 로그인 메인, 프로필 설정, 프로필 수정, 상품 등록, 상품 수정 페이지 퍼블리싱
- 회원가입 기능 구현
- 프로필 이미지 미리보기 기능
- 이미지리사이징을 통한 10MB 이상의 프로필 이미지도 업로드 가능 / 성능 향상
- 회원가입 정보 유효성 검사
- API 명세에 따른 계정 ID 중복 검사
- 정규표현식을 통한 계정 ID 유효성 검사
- 유효성 검사를 통한 시작하기 버튼 활성화
- 모든 유효성 검사 통과 후, Enter 입력 시 시작하기 버튼 클릭과 동일한 기능
- API 명세에 따라 프로필 설정 이전에 받아온 email, password와 함께 서버에 데이터 전송
- 프로필 수정 기능 구현
- 기존 프로필 정보 불러오기 (프로필 이미지 미리보기 포함)
- 유효성 검사
- API 명세에 따른 계정 ID 중복 검사 (기존 ID 유지 시 중복 검사 X)
- 정규표현식을 통한 계정 ID 유효성 검사
- 유효성 검사를 통한 저장 버튼 활성화
- 회원가입의 프로필 세팅과 동일한 기능 포함
- 상품 관련 기능 구현
- 상품 등록
- 이미지리사이징을 통한 10MB 이상의 상품 이미지도 업로드 가능 / 성능 향상
- 각 상품 정보의 유효성 검사
- 상품 가격의 천 단위 콤마 자동 생성 & 삭제
- 삭제
- 상품 삭제 클릭 시 상품이 삭제 되고, 판매 중인 상품만 리렌더링
- 수정
- 기존 상품 정보 불러오기 (상품 이미지 미리보기 포함)
- 상품 등록과 동일한 기능 포함
- 웹사이트에서 상품 보기
- 내 상품에 대해 웹사이트에서 상품 보기 클릭 시, 해당 페이지가 새 창으로 열림
- 상품 등록
- 커뮤니케이션
- 팀 목표를 세우고 시너지를 강화하기 위해 네이버폼 설문지 작성
- 자유로운 회의를 위해 Figzam 회의 공간 제작
- 프로젝트 관리
- 요구사항 정리 문서 작성
- 기능 리스트 및 기능별 작업 상황 관리 문서 작성
- 버그 리포트 작성 및 버그 관리 프로세스 설립
- Netlify를 이용해 프로젝트 배포
- 수월한 프로젝트 진행을 위한 작업
- 이슈 관리 프로세스 도입
- GitHub 이슈 템플릿, PR 템플릿 등록
- 소모적인 커뮤니케이션을 줄이기 위해 협업 메신저(Discord)와 GitHub 알림 연동
- 프로젝트 초기 세팅 작업
- 폴더 트리 구성 및 기본 파일 포함
- 팀 컨벤션에 맞춰 ESLint & Prettier 적용
- 수월한 퍼블리싱 작업을 위해 메인 레이아웃 적용
- 라우터 설계 및 구축
- 지식 공유
- 우리 팀의 Git Branch 전략에 맞는 협업 시나리오 설립 후 팀 내 전파
- Postman을 이용한 API 테스트 방법 및 Axios를 이용한 서버 통신 방법 팀 내 전파
- 가져도댕냥 로고, 마스코트 캐릭터 등 디자인 에셋 제작
- 집사생활 메인, 산책 난이도, 동물병원 페이지 Figma 시안 제작
- 애니메이션, 로딩, 검색, 팔로우, 모달, 메인 레이아웃 컴포넌트 제작
- 이메일 로그인, 팔로우 페이지, 집사생활 메인, 산책 난이도, 동물병원 페이지 퍼블리싱
- 여러 페이지에서 반복 사용 되는 레이아웃을 재사용 가능하도록 컨텐츠 레이아웃 컴포넌트로 분리
- 로그인 기능 구현 (API 명세에 따름)
- 이메일 및 비밀번호 유효성 검증
- 유효성 검증을 통과해야 로그인 버튼 활성화
- 로컬 스토리지에 토큰 저장
- 로그아웃 기능 구현
- 로컬 스토리지에 저장된 토큰 삭제
- 팔로우 기능 구현
- 팔로워 / 팔로잉 목록 데이터를 받아 화면에 표시하는 기능 구현 (API 명세에 따름)
- 팔로우 / 언팔로우 기능 구현 (API 명세에 따름)
- 집사생활 메인 페이지 기능 구현
- Swiper 라이브러리를 이용한 페이지네이션 캐러셀 구현
- 추천글 목록
- 추천글 API가 따로 주어지지 않음에 따라 게시글들이 추천글 목록처럼 보이도록 응답 데이터 가공
- 사용자가 많은 양의 게시글을 편리하게 볼 수 있도록 무한스크롤 적용
- 산책 난이도 페이지 기능 구현
- 행정구, 날씨, 미세먼지 조회 기능
- Geolocation API를 이용해 사용자의 현재 좌표를 가져옴
- Kakao API를 이용해 행정구역 정보를 받아옴
- OpenWeatherMap API를 이용해 날씨, 미세먼지 정보를 받아옴
- 산책 난이도 책정
- 행정구, 날씨, 미세먼지 조회 기능
- 동물병원 페이지 기능 구현
- Kakao API를 이용해 근처 동물병원 정보를 가까운 순으로 받아옴
- 사용자가 원하는 정보만 선별적으로 볼 수 있도록 더보기 버튼 적용
- 사용자 인증 여부에 따른 라우터 접근제한 기능 구현
- 재사용 가능한 코드는 Custom Hook으로 분리
- 전역에서 필요한 데이터는 Context 객체로 관리
- 버튼 컴포넌트(S, MS, M, L 사이즈) 제작
- 회원가입, 검색, 게시글 상세, 게시글 등록, 게시글 수정, 채팅방 목록, 채팅 페이지 퍼블리싱
- 아이콘, 이미지를 전역에서 사용할 수 있도록 공통 파일 제작
- 회원가입 기능 구현
- 유효성 검사를 통과한 이메일과 비밀번호를 프로필 설정페이지에 props로 내려줌
- 유효성 검사를 통과한 이메일과 비밀번호가 존재하면, 버튼이 활성화 되도록 구현
- 유저 검색 기능 구현
- 사용자 입력값이 바뀔 때 마다 입력값과 일치하는 유저 검색 결과 구현
- 검색된 사용자 클릭 시 해당 사용자의 프로필로 이동하는 링크 구현
- 사용자 ID가 아닌, 사용자 이름 과
input
창에 입력한 키워드가 동일한 부분이 있는 경우에는 입력한 키워드 부분만 다른 스타일 적용하여 강조
- 게시글 관련 기능 구현 ( 조회, 등록, 수정, 삭제, 신고 )
- 사용자 입력 텍스트와 이미지 파일 게시물 업로드 기능 구현
- 텍스트와 이미지가 없을경우, 버튼 비활성화
- 이미지 파일 3개 초과 시 사용자에게 보여지는 alert 구현
- 포스트 할 이미지 미리보기 및 미리보기에서 삭제 기능 구현
- 포스트 데이터를 받아 화면에 그려주는 기능 구현 (API 명세에 따름)
- 게시물 수정 기능을 구현
- 댓글 관련 기능 구현 ( 조회, 등록, 삭제, 신고 )
- 댓글 목록 데이터를 받아 화면에 그려주는 기능 구현 (API 명세에 따름)
- 댓글 업로드,삭제 및 신고 기능 구현 (API 명세에 따름)
- 채팅 관련 기능 구현
-
채팅 목록 조회 기능
채팅 기능
- 채팅 기능 방식
로그인된 사용자
↔채팅방으로 사용될 제 3자의 게시글
↔채팅할 상대 사용자
→ 채팅방으로 사용될 제 3자의 아이디는 유저 검색을 통하여 찾을 수 없도록 설정
< 채팅방 생성 >
- 채팅할 상대 사용자의 프로필에서 채팅 이미지 버튼을 클릭하면, 채팅방으로 사용될 제 3자의 게시글이 생성된다.
// 제 3자의 게시글을 다른 유저가 생성할 수 있도록 토큰값을 지정 const CHAT_TOKEN = process.env.REACT_APP_CHAT_SERVER_TOKEN;
- 제 3자의 게시글에 전송되는 컨텐츠인 채팅 데이터 →
‘로그인된 사용자의 accountname,채팅할 상대 사용자의 accountname’
const createChatroom = () => { axios .post( `https://mandarin.api.weniv.co.kr/post`, { post: { content: `${userAccountname},${profileUserAccountname}`, image: '', }, }, { headers: { Authorization: `Bearer ${CHAT_TOKEN}`, 'Content-type': 'application/json', }, }, ) .then((res) => { navigate(`/chat/${res.data.post.id}`); }); };
→ 전송된 데이터는 채팅리스트를 불러올때와 채팅방 이름을 나타낼때 사용한다.
- 채팅방 생성시, 제 3자의 게시글의 content 내용과 생성할 content 내용이 중복된다면 alert 창을 띄워서 이미 존재하는 채팅룸이라는 사실을 사용자에게 알린다.
< 채팅방 리스트 >
- 제 3자의 게시글의 정보를 불러와, 전송된 컨텐츠 데이터 에 사용자의
accountname
이 포함된 게시글만 보여준다.
< 채팅방 >
- useParams() 를 사용하여, 선택한 url의 파라미터를 가져온 후, 그 파라미터(postid) 에 해당하는 게시글을 불러온다. 여기에 작성된 댓글이 본인의 것이라면, 본인이 날린 채팅으로 보여지고, 아니라면 타 사용자가 보낸 채팅처럼 보여진다.
< 채팅 >
- 채팅방에 입장하면, 채팅방으로 사용된 게시물에 댓글 형식으로 데이터를 전송한다.
-
시작 화면 | 회원가입 페이지 | 로그인 페이지 |
---|---|---|
피드 페이지 | 검색 페이지 | 404 페이지 |
---|---|---|
채팅 목록 페이지 | 채팅방 페이지 | 채팅방 나가기 |
---|---|---|
게시글 상세 페이지 | 게시글 작성 페이지 | 게시글 수정 페이지 |
---|---|---|
게시글 삭제 | 게시글 신고 | 댓글 기능 |
---|---|---|
마이 프로필 페이지 | 유어 프로필 페이지 | 리스트형/앨범형 보기 |
---|---|---|
프로필 수정 페이지 | 팔로워/팔로잉 페이지 | 로그아웃 기능 |
---|---|---|
상품 등록 페이지 & 상품 링크 이동 | 상품 수정 페이지 | 상품 삭제 페이지 |
---|---|---|
집사생활 메인 페이지 | 산책 난이도 페이지 | 동물병원 페이지 |
---|---|---|
사용자의 프로필 페이지
useEffect(() => {
if (location.pathname === `/profile/${userAccountname}`) {
navigate('/profile');
}
}, [location, userAccountname, navigate]);
- 현재 url이
/profile/userAccountname
일 경우 나의 프로필 페이지이므로 url 주소를/profile
로 변경하도록 함
useEffect(() => {
const getUserProfileInfo = () => {
axios({
url: url + `/profile/${accountname ? accountname : userAccountname}`,
method: 'GET',
headers: {
Authorization: `Bearer ${userToken}`,
'Content-type': 'application/json',
},
})
.then((res) => {
setUserProfileInfo(res.data.profile);
})
.catch((err) => {
console.error(err);
});
};
getUserProfileInfo();
}, [url, accountname, userAccountname, userToken]);
유저 프로필 정보를 받아오는 getUserProfileInfo() 함수
-
accountname(현재 프로필 url에 표시된 아이디)와 userAccountname(현재 로그인되어있는 사용자의 아이디) 같을 경우 내 프로필 정보를 가져오고, 그렇지 않으면 현재 URL의 사용자 아이디에서 그 사용자의 프로필 데이터를 가져오게 함.
홈 피드 페이지, 프로필 포스트 영역 무한스크롤 기능
// 무한 스크롤 구현에 필요한 useState
const [numFeed, setNumFeed] = useState(0);
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [ref, inView] = useInView();
// 서버에서 피드를 가져오는 함수
const getUserFeed = useCallback(async () => {
const option = {
url: url + `/post/feed/?limit=10&skip=${numFeed}`,
...
},
};
setLoading(true);
await axios(option)
.then((res) => {
// 기존의 데이터와 새로운 데이터 배열 합치기
setIsFollowingPost(isFollowingPost.concat(res.data.posts));
setLoading(false);
setIsLoading(false);
})
.catch((err) => {
setIsLoading(false);
console.error(err);
});
}, [numFeed]);
useEffect(() => {
// 사용자가 마지막 요소를 보고있고(inview === true), 로딩중이 아니라면
if (inView && !loading) {
setNumFeed((current) => current + 10);
}
}, [inView, loading]);
return (
<div>
{isFollowingPost.map((post, i) =>
// isFollowingPost의 마지막 요소라면 ref추가
isFollowingPost.length - 1 === i ? (
<div key={post.id} ref={ref} />
) : (
<div key={post.id}>
<Post post={post} />
</div>
),
)}
</div>
)
- 게시물을 개수 제한없이 보기 위해
'react-intersection-observer'
라이브러리 사용하여 무한스크롤 구현함. - 서버에서 피드 데이터를 가져오는 함수를
axios
로 불러옴. 데이터를 누적해서 요청하는 불필요한 과정을 막기 위해skip
조건 추가한 후concat
메서드로 배열 합쳐서 브라우저에 보여줌. - 불러온 포스트들의 마지막 요소라면, 브라우저 하단부분을 의미하는
ref
값 추가함. 해당 요소가 보이면inView
값이true
로, 안 보이면false
로 자동으로 변경됨.inView
가true
일 경우 서버에 게시물 추가로 10개씩 요청함.
axios(option).then
...
if (res.data.posts.length < 10) {
setDone(true);
}
useEffect(() => {
// 새로 받아온 데이터 배열 개수가 10개 미만일때 스크롤 멈추기
if (!done) {
getUserFeed();
}
}, [numFeed]);
-
불러오는 데이터의 가장 마지막 요소를 보고있으면 계속 요청되는 현상을 막기 위해, 새로 받아오는 데이터 개수가 10개 미만일때
done
state를true
로 변경.done
이true
일 경우 계속해서 요청을 받아오고,false
일경우 요청을 멈추게 함.
게시물 이미지 / 상품 목록 캐러셀 기능
{imageFile[0] ? (
<SwiperWrapper>
<Swiper
style={swiperStyle}
spaceBetween={30}
pagination={{
clickable: true,
}}
modules={[Pagination]}
className='mySwiper'
>
{imageFile ? (
imageFile.map((img, i) => (
<SwiperSlide key={i}>
<ContentImg src={img} alt='' />
</SwiperSlide>
))
) : (
<></>
)}
</Swiper>
</SwiperWrapper>
- 게시물에 업로드된 이미지들을 Swiper 캐러셀 라이브러리 사용하여 구현함. 삼항연산자를 사용하여 이미지 파일이 있을 경우 Swiper로 이미지들을 표시해주고 있음.
{itemList.map((item) => (
<SwiperSlide key={item.id}>
<Product
productid={item.id}
productImg={item.itemImage}
productName={item.itemName}
productPrice={`${item.price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')} 냥`}
/>
</SwiperSlide>
- 상품 목록 데이터를 불러와서 캐러셀로 넘겨 확인할 수 있게 구현함.
회원가입 기능 (프로필 설정)
- 이미지 리사이징을 위한 browser-image-compression 라이브러리 사용
- Blob to Base64
-
아래와 같이 이미지 리사이징 과정에서 만들어진 Blob을 Web API를 통해 Base64 형태로 변환하여 어디에서든 사용 가능한 문자열 형태로 만들어 줌
// Blob to Base64 const readerBlob = new FileReader(); readerBlob.readAsDataURL(compressedFile); readerBlob.onloadend = () => { if (imageFunction) { imageFunction(readerBlob.result); // 서버로 전송될 이미지 } else { setThumbnailImg(readerBlob.result); // 이미지 미리보기 } };
- 하지만 Base64 특성상 문자열이 길어져 가독성이 떨어지며, 용량 이슈가 생길 수 있음
-
- 사용자 이름, 계정 ID, 소개 input 창 상태관리
- 각 input 창에 맞는 유효성 검사를 통해 상태관리를 하였음
- 사용자 이름 : 2~10자 입력 가능
- 계정 ID : 영문, 숫자, 특수문자(
.
,_
)만 사용 가능 - 소개 : 1~100자 입력 가능
- 각 input 창에 맞는 유효성 검사를 통해 상태관리를 하였음
- 시작하기 버튼 상태관리
-
아래와 같이 모든 input 창의 유효성 검사를 만족했을 때, 버튼이 활성화 되도록 함
useEffect(() => { if (userName && accountName && intro) { setDisabledButton(false); } else { setDisabledButton(true); } }, [userName, accountName, intro]);
-
Enter 입력 시 시작하기 버튼 클릭과 동일한 효과를 줌
const onCheckEnter = (e) => { disabledButton === false && e.key === 'Enter' && onClickStartButtonHandler(); };
-
- 회원 가입
-
API 명세에 따라 프로필 설정 이전에 받아온 email, password와 함께 서버에 데이터 전송
// JoinMembershipInput.jsx에서 전달받음 const location = useLocation(); const email = location.state.email; const password = location.state.password;
const onClickStartButtonHandler = () => { const option = { url: 'https://mandarin.api.weniv.co.kr/user', method: 'POST', headers: { 'Content-type': 'application/json' }, data: { user: { username: userName, email: email, password: password, accountname: accountName, intro: intro, image: image, }, }, }; };
-
프로필 수정 기능
-
기존 프로필 정보 불러오기 (프로필 이미지 미리보기 포함)
const getInfo = () => { const option = { url: 'https://mandarin.api.weniv.co.kr/user/myinfo', method: 'GET', headers: { Authorization: `Bearer ${userToken}` }, }; axios(option) .then((res) => { setUserName(res.data.user.username); setDefaultAccountName(res.data.user.accountname); setAccountName(res.data.user.accountname); setIntro(res.data.user.intro); setImage(res.data.user.image); }) .catch((err) => { console.error(err); }); };
-
이미지리사이징을 통한 10MB 이상의 프로필 이미지도 업로드 가능 (프로필 세팅과 동일)
-
유효성 검사
-
정규표현식을 통한 계정 ID 유효성 검사
// 영문, 숫자, 특수문자(.), (_)만 사용 가능 const regex = /*^*[a-z0-9A-Z_.]{0,}*$*/;
-
API 명세에 따른 계정 ID 중복 검사
- defaultAcconutName을 두어 기존 ID 유지 시 중복 검사 X
-
-
유효성 검사를 통한 저장 버튼 활성화 (프로필 세팅과 동일)
상품 관련 기능
-
상품 등록
-
이미지리사이징을 통한 10MB 이상의 상품 이미지도 업로드 가능 (프로필 설정과 동일)
-
각 상품 정보의 유효성 검사
-
상품 이미지 (필수)
-
상품명 (2~15자 이내)
-
가격 (숫자만 입력 가능)
-
판매링크
const linkFunction = (value) => { const urlRegex = /(http(s)?:\/\/)([a-z0-9\w]+\.*)+[a-z0-9]{2,4}/gi; if (urlRegex.test(value)) { setLink(value); } else { setLink(''); } };
-
-
상품 가격의 천 단위 콤마 자동 생성 & 삭제
// 콤마 찍기, 콤마 없애기 const commaFunction = (value) => { const comma = (value) => { value = String(value); return value.replace(/(\d)(?=(?:\d{3})+(?!\d))/g, '$1,'); }; const uncomma = (value) => { value = String(value); return value.replace(/[^\d]+/g, ''); }; return comma(uncomma(value)); };
-
-
삭제
-
상품 삭제 클릭 시 상품이 삭제 되고, 판매 중인 상품만 리렌더링
const deleteProduct = () => { const option = { url: `https://mandarin.api.weniv.co.kr/product/${productid}`, method: 'DELETE', headers: { Authorization: `Bearer ${userToken}`, 'Content-type': 'application/json', }, }; axios(option) .then(() => { updateProductList(); // 상품 리스트 리렌더링 }) .catch((err) => { console.error(err); }); closeModal(); };
// getProduct()로 상품 정보를 불러옴 const updateProductList = () => { getProduct(); };
-
-
수정
- 기존 상품 정보 불러오기 (상품 이미지 미리보기 포함)
- 상품 등록과 동일한 기능 포함
const params = useParams(); ... const onClickProductModificationHandler = () => { const option = { url: `https://mandarin.api.weniv.co.kr/product/${params.productid}`, method: 'PUT', headers: { Authorization: `Bearer ${userToken}`, 'Content-type': 'application/json', }, data: { product: { itemName: itemNameMod, price: priceMod, link: linkMod, itemImage: itemImageMod, }, }, }; axios(option) .then((res) => { console.log(res); }) .catch((err) => { console.error(err); }); }; ...
-
웹사이트에서 상품 보기
-
내 상품에 대해 웹사이트에서 상품 보기 클릭 시, 해당 페이지가 새 창으로 열림
<a rel='noopener noreferrer' target='_blank' href={productLink}>웹사이트에서 상품 보기</a>
-
React Router 6를 이용해 인증(로그인) 여부에 따른 접근 제한 구현
- 인증 여부에 상관 없이 접근 가능한 페이지
- 인증 안 된 경우만 접근 가능한 페이지
- 인증 된 경우만 접근 가능한 페이지
/* Router.jsx 일부 */
import { AuthContextStore } from '../context/AuthContext';
const Router = () => {
const { userToken } = useContext(AuthContextStore);
return (
<Routes>
<Route path='*' element={<Error404Page />} />
<Route path='/notfound' element={<Error404Page />} />
<Route element={<NonAuthRoute authenticated={userToken} />}>
<Route path='/' element={<SplashScreen />} />
<Route path='/login' element={<EmailLoginPage />} />
...
</Route>
<Route element={<AuthRoute authenticated={userToken} />}>
<Route path='/home' element={<FeedPage />} />
<Route path='/search' element={<SearchPage />} />
...
</Route>
</Routes>
);
};
/* AuthRoute.jsx */
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
const AuthRoute = ({ authenticated, redirectPath = '/' }) => {
if (!authenticated) {
return <Navigate to={redirectPath} />;
}
return <Outlet />;
};
export default AuthRoute;
- 전달 받는 데이터 설명
- authenticated : Context에 저장된 사용자 token 데이터
- redirectPath : 인증이 안 된 경우 리다이렉트 될 경로(
/
)를 기본값으로 갖고 있음
- 동작 설명
- 정상적으로 인증이 되지 않은 경우(사용자 token이 Falsy 값을 갖는 경우)
/
경로로 리다이렉트 됨 - 정상적으로 인증이 된 경우
Outlet
속성을 이용해 사용자가 접근하려는 페이지가 렌더링 됨
- 정상적으로 인증이 되지 않은 경우(사용자 token이 Falsy 값을 갖는 경우)
실시간 날씨를 바탕으로 강아지 산책 난이도 계산
-
산책 난이도(날씨) 페이지 구현 시 날씨 API와 미세먼지 API에 각각 요청을 보내고, 응답을 처리하는 과정이 필요했음
-
이때 Axios의 multiple request 기능을 이용해서 동시에 다중 요청을 보내도록 처리함
/* CommunityWeatherPage.jsx 일부 */ const CommunityWeatherPage = () => { const { longitude, latitude, error } = useContext(UserLocationContextStore); const OPEN_WEATHER_MAP_API = process.env.REACT_APP_OPEN_WEATHER_MAP_API; useEffect(() => { const getFetch = async () => { await axios .all([ axios.get( `https://api.openweathermap.org/data/2.5/weather ?lat=${latitude}&lon=${longitude}&appid=${OPEN_WEATHER_MAP_API}&units=metric`, ), axios.get( `https://api.openweathermap.org/data/2.5/air_pollution ?lat=${latitude}&lon=${longitude}&appid=${OPEN_WEATHER_MAP_API}`, ), ]) .then( axios.spread((weatherRes, dustRes) => { updateWeatherInfo(weatherRes); updateDustInfo(dustRes); }), ); }; getFetch(); }, [longitude, latitude]);
axios.all()
을 이용해서 여러 개의 요청을 묶어서 한 번에 보내도록 함. 위 예시에서는 2개의 요청만 한 번에 처리하고 있으나 3개 이상도 가능axios.spread()
를 이용해서 요청에 대한 응답을 각각 받아오도록 함
-
WeatherDescription 객체를 생성한 이유
- 사용한
날씨 API
(OpenWeatherMap)의 경우 해외에서 제공하는 API라 내부적으로 제공하고 있는 번역 기능의 성능이 미흡하였음 - 또한 산책 난이도 계산 시 날씨 점수를 책정할 필요가 있었음
/* WeatherDescription.jsx 일부 */ const WeatherDescription = { 202: { title: '폭우를 동반한 천둥구름', score: 10 }, 210: { title: '약한 천둥구름', score: 2 }, 211: { title: '천둥구름', score: 2 }, 212: { title: '강한 천둥구름', score: 10 }, 221: { title: '불규칙적 천둥구름', score: 2 }, 230: { title: '약한 스모그를 동반한 천둥구름', score: 2 }, ... };
- WeatherDescription 객체 설명
날씨 API
의 응답으로 오는날씨 id
를 객체의 key로 사용함- value로
title(한국어로 번역한 날씨)
와score(날씨 점수)
를 가짐
- 사용한
-
아래 예시는 실제 사용 예시
/* CommunityWeatherPage.jsx 일부 */ import WeatherDescription from '../../../utils/WeatherDescription'; const CommunityWeatherPage = () => { const { longitude, latitude, error } = useContext(UserLocationContextStore); const [weatherInfo, setWeatherInfo] = useState({}); useEffect(() => { const updateWeatherInfo = (res) => { const weatherData = res.data; setWeatherInfo({ weather: WeatherDescription[weatherData.weather[0].id].title, weatherScore: WeatherDescription[weatherData.weather[0].id].score, ... }); }; getFetch(); }, [longitude, latitude]); }
- weatherData.weather[0].id : 날씨 API의 응답으로 오는 날씨 id
* 산책 난이도 그룹 : 기온 / 날씨 / 공기 질
* 각 그룹의 상태는 최상 / 상 / 중 / 하로 평가한다.
* 최상 / 상은 산책 어려움, 중은 보통, 하는 산책 쉬움이다.
* 최상은 10점, 상은 2점, 중은 1점, 하는 0점의 점수를 갖는다.
* 최상이 하나라도 껴있으면 산책 어려움으로 책정된다.
* 기온
소형견을 기준으로 함
기온 상, 중, 하를 나눌때는 TACC 스케일을 참고
> 겨울 날씨 참고 : https://www.k-health.com/news/articleView.html?idxno=57536
> 여름 날씨 참고 : https://purplejam.kr/hot-summer-dog/
- 최상 : -9도 아래, 35도 위 (+10)
- 상 : -8도 ~ -2도, 27도 ~ 34도 (+2)
- 중 : -1도 ~ 6도, 23도 ~ 26도 (+1)
- 하 : 7도 ~ 22도 (0)
* 날씨
OpenWeatherMap API가 응답해주는 값 중 날씨 아이디 값인 weather.id로 판단
날씨에 따른 점수 부여
> utils/WeatherDescription.jsx에 scroe 추가
- 최상 : 폭설, 폭우, 태풍, 고온, 한랭, 돌풍, 우박, 스모그, 황사 등 (+10)
- 상 : 강한 비, 센 바람, 천둥 구름 등 (+2)
- 중 : 적은~중간 비, 적은~중간 눈, 약한~중간 세기 바람, 안개 등 (+1)
- 하 : 바람 거의 없음, 맑은 하늘, 얇게 낀 안개 등 (0)
* 대기 질
OpenWeatherMap API가 응답해주는 값 중 aqi 값으로 판단
aqi 값은 대기 질을 1~5로 평가한 값임
> API 링크 : https://openweathermap.org/api/air-pollution
- 최상 : 5 (+10)
- 상 : 4 (+2)
- 중 : 2~3 (+1)
- 하 : 1 (0)
* 산책 난이도를 구하기 위해 세 그룹의 점수를 더한다.
* 난이도 책정 기준
- 어려움 : 5 이상 (최상이 하나라도 껴있으면 산책 어려움으로 책정됨)
- 보통 : 2, 3, 4
- 쉬움 : 0, 1
서버 개발자와 소통이 어려운 오픈 API의 한계를 극복하기 위한 동물병원 상세보기 페이지 URL 구성
- 장소 id를 이용해 해당 장소에 대한 상세 데이터를 가져오려고 했으나, 확인 결과 카카오맵 API는 그러한 기능을 제공하지 않고 있었음
- 따라서 URL에 장소에 대한 상세 정보를 담아 보내기로 함
/* HospitalItem.jsx 일부 */
const HospitalItem = ({ hospitalInfo }) => {
const [detailUrl, setDetailUrl] = useState(null);
useEffect(() => {
if (Object.keys(hospitalInfo).length > 0) {
const url =
'?road_address=' +
hospitalInfo.road_address_name +
'&place_name=' +
hospitalInfo.place_name +
'&phone=' +
hospitalInfo.phone +
'&x=' +
hospitalInfo.x +
'&y=' +
hospitalInfo.y;
const encodeResult = encodeURI('/community/hospital' + url);
setDetailUrl(encodeResult);
}
}, [hospitalInfo]);
return (
<HospitalItemWrapper>
<HospitalLink to={detailUrl} aria-label={`${hospitalInfo.place_name} 상세 정보`}>
...
</HospitalLink>
</HospitalItemWrapper>
);
};
- 도로명 주소, 장소명, 전화번호, x좌표, y좌표를 URL에 포함함
- 이때 실제로는 단순히 URL에 있는 정보를 꺼내와서 상세보기 페이지를 구현할 예정이었으나, 보편적인 형태의 URL로 구성하기 위해 서버에 데이터를 전송하는 것과 같은 형태(
쿼리스트링
)로 URL을 구성 encodeURI()
를 이용해서 예약된 문자를 제외한 문자를 인코딩 처리-
리스트 아이템 UI
-
리스트 아이템 개발자도구 확인 시 인코딩된 URI 확인 가능
-
실제 주소창에는 디코딩된 형태로 표시되어 확인 결과 React Router 사용시 자동 디코딩됨
-
/* CommunityHospitalDetailPage.jsx 일부 */
const CommunityHospitalDetailPage = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [hospitalInfo, setHospitalInfo] = useState({});
useEffect(() => {
setHospitalInfo({
address: searchParams.get('road_address'),
name: searchParams.get('place_name'),
phone: searchParams.get('phone'),
x: searchParams.get('x'),
y: searchParams.get('y'),
});
}, []);
return (
<CommunityLayout padding='0' navType='titleNav' currentMenuId={2}
isViewTabMenu={false} fillHeight={true} title={hospitalInfo.name}>
<HospitalDetailWrapper>
...
<HospitalDetail hospitalInfo={hospitalInfo} />
</HospitalDetailWrapper>
</CommunityLayout>
);
}
useSearchParams()
훅을 이용해서 쿼리스트링을 다룸searchParams.get(key)
메서드를 이용해서 해당 key의 value를 가져옴
- 가져온 데이터를 hospitalInfo 객체에 저장한 후 화면에 표시
이미지 데이터 변경에 유연한 페이지네이션 캐러셀 컴포넌트
/* PaginationCarousle.jsx 일부 */
const PaginationCarousel = ({ itemList }) => {
return (
<>
<Swiper
modules={[Autoplay, Pagination]}
pagination={{
dynamicBullets: true,
}}
loop='true'
autoplay={{ delay: 2500, disableOnInteraction: false }}
>
{itemList.map(({ id, src, alt }) => (
<SwiperSlide key={id}>
<img src={src} alt={alt} />
</SwiperSlide>
))}
</Swiper>
</>
);
};
- 쉽게 확장할 수 있도록 객체 배열 형태인 itemList를 props로 전달받도록 함. 전달받은 itemList는 반복을 돌며 캐러셀 아이템(SwiperSlide)으로 추가됨
- 캐러셀 구현은 Swiper 라이브러리 이용
/* CommunityMainPage 일부 */
import { ADVERTISING1_IMAGE, ADVERTISING2_IMAGE,
ADVERTISING3_IMAGE, ADVERTISING4_IMAGE } from './../../../styles/CommonImages';
const CommunityMainPage = () => {
const advertisingImageList = [
{ id: 0, src: ADVERTISING1_IMAGE, alt: '산책 난이도 서비스 오픈! 실시간 날씨 확인하고 댕댕이랑 산책가자!' },
{ id: 1, src: ADVERTISING2_IMAGE, alt: '집사생활 페이지 런칭! 우리집 댕냥이를 위한 훌륭한 집사 되기' },
{ id: 2, src: ADVERTISING3_IMAGE, alt: '특별 이벤트! 친구 초대하고 애견호텔 무료로 가기' },
{ id: 3, src: ADVERTISING4_IMAGE, alt: '현재 위치에서 가장 가까운 동물병원 찾기 서비스 오픈!' },
];
return (
<CommunityLayout currentMenuId={0} fillHeight={isEmpty}>
<PaginationCarousel itemList={advertisingImageList} />
<PopularPosts isEmpty={isEmpty} changeEmptyState={changeEmptyState} />
</CommunityLayout>
);
};
id
,src
,alt
값을 갖는 있는 객체 배열을 만든 후 PaginationCrousel 컴포넌트에 전달하면 페이지네이션 캐러셀이 화면에 출력됨- 만약 이미지를 추가하고 싶다면 객체 배열 안 데이터를 추가하면 간단하게 캐러셀 이미지 추가 가능
회원가입 기능 구현 (email, password)
-
email 과 password 의
input
창에 text 데이터를 받아서, 각각의 유효성 검사 테스트하여 통과하면 클릭 이벤트를 통하여,useNavigate()
를 활용해 해당 페이지에state
정보를 넘긴다.... navigate('/join/setprofile', { state: { email: email, password: password, }, }); ...
검색 기능 구현
input
창에 검색한 키워드와 동일한 정보 ( 사용자 ID 와 사용자 이름 ) 를 GET 요청을 통하여 받아옴- 사용자 ID가 아닌, 사용자 이름 과
input
창에 입력한 키워드가 동일한 부분이 있는 경우에는 입력한 키워드 부분만 다른 스타일 적용하여 강조
// 키워드가 사용자 이름 과 부분적으로 겹치는지, 아니면 사용자 ID 와 부분적으로 겹치는지 판단하기 위해서 indexOf 사용
const usernameValidate = ~username.indexOf(keyword);
const accountnameValidate = ~accountname.indexOf(keyword);
// username 을 검색 키워드를 중심으로 잘라서, 3개의 문자열을 배열로 저장
const COMMA_APPEND_USERNAME = username.replace(keyword, `,${keyword},`);
const arrayKeyword = COMMA_APPEND_USERNAME.split(',');
<span>
// 키워드 부분만 따로 styled-component 를 생성하여, 강조
{arrayKeyword[0]}
<Keyword>{arrayKeyword[1]}</Keyword>
{arrayKeyword[2]}
</span>
게시글 관련 기능 구현
-
게시글 조회
useParams()
를 통하여, 해당 페이지의 파라미터를 가져와, 해당하는 게시물 데이터를 받아옴
-
게시글 등록
- 각 이벤트들로 하여금 브라우저의 기본 동작을 실행하지 않도록
preventDefault
로 막음 - 이미지 - 이미지를 업로드 하기위해, 양식에 맞도록 이미지 url 을 수정하는 작업을 거쳐서 서버에 전송 준비
- 게시글 텍스트 -
event.target.value
를 통하여, 텍스트를 실시간 저장하여 상태관리 후 서버에 전송 준비- 게시글 길이에 맞게,
input
창 크기 변경
// 텍스트의 길이에 맞추어 박스크기 조정 const textRef = useRef(); const handleResizeHeight = useCallback(() => { textRef.current.style.height = textRef.current.scrollHeight + 'px'; }, []);
- 게시글 길이에 맞게,
- 이미지와 게시글이 준비가 되면, 버튼 클릭을 통한 이벤트로 명세에 맞도록 서버로 데이터 전송
- 이미지 업로드 버튼의 스타일이 게시물 등록만 달라서, styled-component 오버라이드를 통하여 수정
/* ImageUploadButton.jsx */ ... const ImageUploadButton = ({ className, setUploadImg, uploadImg, inputRef }) => { ...
... return ( ... <ImgUploadButton uploadImg={postImages} setUploadImg={setUploadImg} // 오버라이드를 위하여 className 을 부여 ( 부여한 이유는 styled-component 가 스타일을 적용하는 방식을 이해한다면 알 수 있다. ) className={className} inputRef={inputRef} /> ... ... const ImgUploadButton = styled(ImageUploadButton)` position: fixed; margin-left: 26.6rem; bottom: 1.6rem; width: 5rem; height: 5rem; background-image: url(${UPLOAD_FILE_ICON}); background-position: center; background-size: cover; cursor: pointer; z-index: 100; `;
- 각 이벤트들로 하여금 브라우저의 기본 동작을 실행하지 않도록
-
게시글 수정
useParams()
를 통하여, 해당 페이지의 파라미터를 가져와, 해당하는 게시물 데이터를 받아옴- 받아온 데이터를 게시글 등록페이지에 뿌려줌
- 뿌려준 데이터를 가공하여, 다시 PUT 을 통하여 서버에 전송
댓글 기능 구현
-
조회 -
props
로 게시글 정보를 받아와, 그안에 저장된 데이터중comments
정보만을 정제하여 상태관리된 데이터를 뿌려준다. -
등록 -
props
로 받아온postid
를 활용하여, 준비된text
정보를 서버에 전송한다.const sendCommentData = () => { axios .post( `https://mandarin.api.weniv.co.kr/post/${postData.id}/comments`, { comment: { content: `${commentData}`, }, }, { headers: { Authorization: `Bearer ${userToken}`, 'Content-type': 'application/json', }, }, ) .then(() => { window.location.reload(false); }); };
-
삭제 및 신고
-
해당 댓글이 로그인된 사용자의 댓글인지를 판단하여, 모달을 띄운다
- 로그인 된 사용자일 경우 - 삭제
const deletePost = () => { axios({ url: url + `/post/${postID}`, method: 'DELETE', headers: { Authorization: `Bearer ${userToken}`, 'Content-type': 'application/json', }, }) .then((res) => { window.location.replace('/profile'); }) .catch((err) => console.error(err)); };
- 다른 사용자의 경우 - 신고
const reportPost = () => { axios({ url: url + `/post/${postID}/report`, method: 'POST', headers: { Authorization: `Bearer ${userToken}`, 'Content-type': 'application/json', }, }) .then((res) => { setIsReport(true); if (postID === res.data.report.post) { setIsReportSuccess(true); } else { setIsReportSuccess(false); } }) .catch((err) => console.error(err)); };
-
채팅 기능 구현
-
채팅 기능 방식
로그인된 사용자
↔채팅방으로 사용될 제 3자의 게시글
↔채팅할 상대 사용자
→ 채팅방으로 사용될 제 3자의 아이디는 유저 검색을 통하여 찾을 수 없도록 설정
< 채팅방 생성 >
-
채팅할 상대 사용자의 프로필에서 채팅 이미지 버튼을 클릭하면, 채팅방으로 사용될 제 3자의 게시글이 생성된다.
// 제 3자의 게시글을 다른 유저가 생성할 수 있도록 토큰값을 지정 const CHAT_TOKEN = process.env.REACT_APP_CHAT_SERVER_TOKEN;
-
제 3자의 게시글에 전송되는 컨텐츠인 채팅 데이터 →
‘로그인된 사용자의 accountname,채팅할 상대 사용자의 accountname’
const createChatroom = () => { axios .post( `https://mandarin.api.weniv.co.kr/post`, { post: { content: `${userAccountname},${profileUserAccountname}`, image: '', }, }, { headers: { Authorization: `Bearer ${CHAT_TOKEN}`, 'Content-type': 'application/json', }, }, ) .then((res) => { navigate(`/chat/${res.data.post.id}`); }); };
→ 전송된 데이터는 채팅리스트를 불러올때와 채팅방 이름을 나타낼때 사용한다.
-
채팅방 생성시, 제 3자의 게시글의 content 내용과 생성할 content 내용이 중복된다면 alert 창을 띄워서 이미 존재하는 채팅룸이라는 사실을 사용자에게 알린다.
< 채팅방 리스트 >
- 제 3자의 게시글의 정보를 불러와, 전송된 컨텐츠 데이터 에 사용자의
accountname
이 포함된 게시글만 보여준다.
< 채팅방 >
- useParams() 를 사용하여, 선택한 url의 파라미터를 가져온 후, 그 파라미터(postid) 에 해당하는 게시글을 불러온다. 여기에 작성된 댓글이 본인의 것이라면, 본인이 날린 채팅으로 보여지고, 아니라면 타 사용자가 보낸 채팅처럼 보여진다.
< 채팅 >
- 채팅방에 입장하면, 채팅방으로 사용된 게시물에 댓글 형식으로 데이터를 전송한다.
-
이번 팀프로젝트는 제게는 너무 행복하고 귀한 경험이었습니다. 경험 많은 팀장님이 팀원들을 위해 제가 생각치도 못하는 부분을 미리 준비해주시는 모습도 너무 인상깊었고 개발하면서 그것을 정말 편하게 활용했습니다. 미래에 프로젝트 팀장을 맡게 된다면 배우고 싶었던 부분이 많았습니다. 능력있는 팀원분들의 코드를 보며 동기부여도 많이 됐고 제가 잘 알지 못하는점을 캐치하고 센스있게 알려주는 모습에도 큰 용기를 얻었습니다. 팀원들과 부족한 부분을 채워주며 프로젝트를 잘 마무리한것 같아 기쁩니다. 긍정적인 팀의 분위기에 안좋은 영향을 끼치지 않으려고 평소보다 몇배로 더 열심히 할 수 있었으며 그로인해 개인적으로도 큰 성장을 이룬 것 같습니다. 이번 팀프로젝트를 통해 제가 어떤 방향으로 나아가는지, 개발과 프로젝트는 어떤식으로 해야하는지에 대한 큰 영감을 얻었습니다. 함께 고생한 팀원들께 너무너무 감사합니다!!
프로젝트 경험이 부족해서 설레기도 하고 걱정도 많이 되었는데, 열정 있는 팀원들 덕분에 협업하면서 많은 부분을 배우고 프로젝트를 잘 마칠 수 있었습니다. 기술적인 성장뿐만 아니라 서로의 문제 해결을 위해 끊임없이 몰두하는 팀원들의 모습을 보면서, 큰 동기부여가 됐습니다. 앞으로 수많은 프로젝트를 마주하게 될 때 이번 프로젝트 경험이 큰 자산이 될 것이라 확신합니다. 스스로 부족함이 많았지만 항상 격려와 응원을 보내준 팀원 분들이 계셔서 이겨낼 수 있었습니다. 너무 소중한 시간들 함께할 수 있어서 좋았습니다! 감사합니다!!
프론트엔드스쿨 기간 동안 이루고 싶었던 개인적인 목표가 3개 있었는데요. 첫 번째가 협업 가능할 만큼 GitHub 익히기, 두 번째가 팀 프로젝트 시 ESLint&Prettier 적용해보기, 세 번째가 팀 프로젝트 잘 마무리하기였습니다. 이번 가져도댕냥 프로젝트를 진행하면서 제가 이루고 싶었던 목표 3개를 모두 달성하게 되어 행복합니다.
이번 프로젝트를 통해 팀이기에 할 수 있는 경험을 많이 했습니다. 이슈 관리 프로세스와 버그 관리 프로세스를 세워 우리 팀의 문화로 만든 것, 다른 분들의 코드를 읽는 것에 두려움이 있었던 제가 다른 분들의 PR을 보며 리뷰를 드린 것, 모두 합심하여 코딩 컨벤션을 만들고 정해진 컨벤션대로 프로젝트 초기 세팅을 해본 것. 모두 혼자였으면 하지 못할 소중한 경험들이었습니다.
팀장 자리를 맡게 되어 부담이 많이 됐었는데, 오히려 이 부담이 저에게 좋은 방향으로 작용한 것 같습니다! 마지막으로 끊임없는 칭찬으로 저를 춤추게 해주셨던 팀원분들께 정말 감사드립니다. 저 혼자였다면 할 수 없었을텐데 오류가 나면 다 함께 해결하고, 안되는 게 있으면 밤을 새가며 어떻게든 해내는 팀원분들의 에너지와 열정 덕분에 완주할 수 있었습니다.
프로젝트를 진행하면서 팀원분들의 아낌없는 응원속에서 다함께 성장하는 기분을 느낄 수 있었습니다. 처음에는 단순한 퍼블리싱도 머뭇한 저였는데, 혼자 고민하는 과정을 거치고 팀원분들과 서로 소통을 통하여 문제를 해결해 나가다 보니 이제는 하나의 기능은 물론, 여러 기능을 구현하는것에 있어 자신감이 생긴 계기가 되었습니다. 서로 궁금한 부분이 있으면, 같이 고민해주고 해결하는 팀 분위기가 너무 따듯하고 행복했습니다. 작게는, styled-component 를 어느 단위로 쪼개야 하는 지 저희 팀원 모두 고민하여 저희에게 적합한 방법을 착안을 했었고, 크게는 무한렌더와 관련된 부분도 다함께 침착하게 화면공유를 하며 해결을 해 나갔습니다. 이러한 서로 협업하고 해결해 나가는 과정을 한달동안 지속하다보니 협업의 중요성을 느끼며, 성장해 나아가야할 방법을 알게 되었습니다. 가끔은 지치고 힘든 날도 있었지만, 팀원들의 열정에 저도 모르게 웃으면서 다시 한번 힘을 얻어 프로젝트를 진행하였습니다.
다른 복은 몰라도 항상 주변 인복은 많다고 생각하면서 살아왔는데, 이번 프로젝트 역시 타고난 인복 덕분에 좋은 팀원분들과 따듯한 분위기 속에 서로 성장하고 좋은 시간을 보냈습니다! 이 소중했던 시간을 꼭 기억하여, 혼자 성장하는 개발자가 아닌 함께 성장하는 개발자가 되도록 노력하겠습니다! 그동안 함께 고생해준 팀원들 너무 감사합니다~!!!🌸