Space
article thumbnail
Published 2023. 9. 26. 20:47
[Team] MarbleUS(마블어스) (React) Project
반응형

프로젝트명

MarbleUS (= Marble + Us and Earth)


기간

23.08.24 ~ 23.09.22 (약 4주)


소개

세계일주 보드게임과 지구마블 세계여행 프로그램에서 모티브를 얻은 랜덤 여행 사이트입니다.

미션을 수행하여 여권처럼 각 지역의 스탬프를 얻고, 지역별 게시판을 통해 유저 간의 지역 정보를 공유할 수 있습니다.


배포 링크

http://marbleus-s3.s3-website.ap-northeast-2.amazonaws.com

 

MarbleUs

 

marbleus-s3.s3-website.ap-northeast-2.amazonaws.com


Github 링크

https://github.com/codestates-seb/seb45_main_023/tree/main

 

GitHub - codestates-seb/seb45_main_023

Contribute to codestates-seb/seb45_main_023 development by creating an account on GitHub.

github.com


기술 스택

FE

JS, React, Tailwind CSS, Recoil, React-Router, Axios, Figma, FontAwesome, Ckeditor5

 

BE

Java, Spring, Spring Boot, Spring Security, MySQL, AWS RDS, AWS EC2, AWS S3, REDIS, JWT

 

Environment

VScode, Git, Github, Prettier, Eslint


ERD

(Entity Relationship Diagram)


역할

저의 첫 프로젝트인 MarbleUS(마블어스)입니다.

마블어스에서 제 역할은 ‘FE 배포, 회원가입, 로그인, OAuth’이었습니다.


배포

AWS S3로 배포했습니다.

하지만, 배포할 당시 백엔드와 프론트엔드가 서로 각 분야의 배포를 따로 하고 있었고, 

서버를 킬 때마다 두 명이 있어야 한다는 BE 팀원분의 말씀에 비효율적이라 생각하여

이미 AWS S3로 배포했었던 것을 두고,  

백엔드 AWS 계정에 IAM 계정을 받아 FE 배포(S3)를 다시 구현하였습니다.

이로서 서버를 킬 때마다 한 명만 있으면 되므로, 효율적으로 할 수 있었습니다.

 

AWS S3 배포 방법을 다시 정리하여 기록하였습니다.

https://ghvhdh321.tistory.com/entry/AWS-S3-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0

 

[AWS] S3 배포하기

AWS S3 배포하기 1. AWS 회원가입을 한다. 2. AWS 콘솔에서 S3를 검색한다. 3. 버킷 만들기를 클릭한다. 4. 버킷 이름을 입력한 뒤, AWS 리전을 선택하고, 퍼블릭 액세스 차단 설정을 풀고 버킷 만들기를

ghvhdh321.tistory.com

 

그리고, CI/CD를 구현하고 싶었으나, 다른 기본기능들을 구현하는 것이 우선이라 생각하여 Advanced로 두었으며, 

기본 기능들을 완벽히 구현하지 못하였고, 그로 인해 Advanced에 도전할 시간이 없어서 아쉬웠습니다.


회원 가입

회원가입 창 / SignUp 버튼을 눌렸을 때 유효성 검사하는 사진

회원 가입의 주요 기능은 유효성 검사, 이미 가입된 이메일 유무 확인입니다. 

회원 가입 버튼을 누르면 유효성 검사와 가입된 이메일 유무를 확인합니다.

가입하려는 유저가 기입한 내용이 모든 유효성 검사를 통과해야지만 서버에 요청을 보내서 회원 가입이 되도록 구현하였으며, 유효성 검사 조건은 아래와 같이 구현했습니다.

// 유효성 검사 조건
이메일 : 일반적인 이메일 형식입니다.
비밀번호 : 영어, 숫자, 특수문자를 모두 포함하고, 최소 8자 이상이어야 합니다.
비밀번호 확인 : 비밀번호와 일치해야 합니다.
생년월일 : 생년월일을 선택해야 합니다.
이용약관 동의 : 이용약관 동의해야 합니다.
export default function SignUpPage() {
	const navigate = useNavigate();

	// 상태관리
	const [email, setEmail] = useRecoilState(emailState);
	const [password, setPassword] = useRecoilState(passwordState);
	const [confirmPassword, setConfirmPassword] =
	useRecoilState(confirmPasswordState);
	const [nationality, setNationality] = useRecoilState(nationalityState);
	const [birthDate, setBirthDate] = useRecoilState(birthDateState);
	const [agreement, setAgreement] = useRecoilState(agreementState);
	const [authorizationToken, setAuthorizationToken] = useRecoilState(authorizationTokenState);

	const [isLoading, setIsLoading] = useState(false);
	const [errors, setErrors] = useState({});

	// Email 유효성 검사 조건 : 일반적인 이메일 형식
	const validateEmail = (email) => {
		const emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
		return emailRegex.test(email);
	};

	// password 유효성 검사 조건 : 영어, 숫자, 특수문자(@$!%*?&)를 모두 포함하며, 최소 8자 이상
	const validatePassword = (password) => {
		const passwordRegex =
			/^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
		return passwordRegex.test(password);
	};

	// 회원가입 핸들러
	const validateForm = () => {
		// 에러 초기화
		const errors = {};

		// 이메일 유효성 검사
		if (!validateEmail(email)) {
			errors.email = "올바른 이메일 형식이 아닙니다.";
		}

		// 비밀번호 유효성 검사
		if (!validatePassword(password)) {
			errors.password =
				"비밀번호는 영어, 숫자, 특수문자를 모두 포함하고 최소 8자 이상이어야 합니다.";
		}

		// 비밀번호 확인 유효성 검사
		// 비밀번호 확인을 입력하지 않은 경우 
		if (!confirmPassword) {
			errors.confirmPassword = "비밀번호 확인을 입력해야 합니다.";
		} else {  
			// 비밀번호 확인을 입력하였으나, 비밀번호에 입력한 값과 다른 경우
			if (password !== confirmPassword) {
				errors.confirmPassword =
					"비밀번호와 비밀번호 확인이 일치하지 않습니다.";
			}
		}

		// 생년월일 유효성 검사
		// 생년월일을 입력하지 않은 경우
		if (!birthDate) {
			errors.birthDate = "생년월일을 선택해야 합니다.";
		} else {
			// 입력된 날짜가 현재 날짜보다 큰 경우 에러 표시
			const currentDate = new Date();
			const selectedDate = new Date(birthDate);
			if (selectedDate >= currentDate) {
				errors.birthDate = "올바른 생년월일을 입력해야 합니다.";
			}
		}

		// 이용약관 유효성 검사
		// 이용약관 동의를 하지 않은 경우
		if (agreement !== "true") {
			errors.agreement = "이용약관에 동의해야 합니다.";
		}

		setErrors(errors);

		// 모든 유효성 검사를 통과한 경우 (=== errors 객체의 길이가 0인 경우)
		return Object.keys(errors).length === 0;
	};

	const handleSignUp = async (event) => {
		event.preventDefault();
		if (!validateForm()) {
			return;
		}

		// 로딩 상태를 활성화
		setIsLoading(true);

		// API 요청을 보내기 위한 데이터 준비
		const requestData = {
			email,
			password,
			nationality,
			birthDate,
		};

		try {
			// 서버 API 호출
			const response = await axios.post(
				`${process.env.REACT_APP_SERVER_URL}/members/signup`,
				requestData,
				{
					headers: {
						"Content-Type": "application/json",
					},
				}
			);

			// authorization 토큰 갱신
			if(response.headers.get("newaccesstoken")) {
				setAuthorizationToken(response.headers.get("newaccesstoken"));
				localStorage.setItem('Authorization', authorizationToken ?? '');
			}

			// 회원가입 성공시 경고창이 나오고, 로그인 페이지로 이동한다.
			alert("Member Registered!");
			navigate("/login");
		} catch (error) {
        		// 가입하려는 이메일이 이미 가입된 상태인 경우
			if (error.response && error.response.status === 409) {
				setErrors({ email: "이미 가입된 이메일입니다." });
			} else {
				// 회원가입 실패 처리
				setErrors({ serverError: "회원가입에 실패했습니다." });
			}
		} finally {
			// 로딩 상태를 비활성화 (성공 또는 실패에 관계없이 항상 실행되도록 함)
			setIsLoading(false);
		}
	};
}

 

회원 가입에서 이용약관을 넣었었는데 이용약관을 클라이언트에서 관리해야 되는지 아니면 서버에서 관리하여 클라이언트 쪽으로 불러와야 하는지에 대해 의문이 들었습니다. 프로젝트 때 멘토님께 여쭤보니 서비스 창업 초기처럼 이용약관이 자주 수정되는 상황이면, 클라이언트에서 관리하는 것이 좋고, 서비스를 지원한 기간이 조금 경과되어 이용약관이 자주 수정되지 않는 경우에는 서버에서 관리해서 불러오는 것이 더 좋다고 하였습니다. 그래서 개발이 진행 중이며 자주 변경될 수 있는 상황이기에 클라이언트에서 관리하는 것으로 하였습니다.


회원 탈퇴

회원 탈퇴 버튼 / 회원 탈퇴 재차 확인

회원 탈퇴의 경우,

비로그인 상태에서 회원 탈퇴 버튼을 누르면 '비로그인 상태입니다.'라는 경고창이 나오고,

로그인한 상태에서 버튼을 누른 경우, 한 번 더 회원 탈퇴를 정말로 할 것인지 재차 확인한 후 유저가 확인 버튼을 누르면 서버로 회원 탈퇴 요청을 보내고, 서버의 응답이 204로 오면 정상적으로 회원 탈퇴 되었으므로, 로컬스토리지의 토큰과 전역 상태 토큰을 초기화하고, 경고창으로 회원 탈퇴 되었다는 알림을 띄우며, 로그인페이지로 이동하게 구현하였습니다.

 

export default function WithdrawButton() {
    const navigate = useNavigate();
    const [authorizationToken, setAuthorizationToken] = useRecoilState(authorizationTokenState);
    const userinfo = useRecoilValue(userInfo);
    const userId = userinfo.id;

    const [isLoading, setIsLoading] = useState(false);

    const handleWithdraw = async () => {
        // 로그인한 상태이면
        if(authorizationToken) {
            // 로딩 상태 시작
            setIsLoading(true);

            // 회원 탈퇴 재차 확인
            const withdrawConfirm = window.confirm('정말로 회원 탈퇴하시겠습니까?');
    		
            // 확인버튼 누른 경우
            if(withdrawConfirm) {    
                try {
                	// 서버에 회원 탈퇴 요청 보내기
                	const response = await axios.delete(`${process.env.REACT_APP_SERVER_URL}/members/withdraw/${userId}`, 
                    {
                        headers: {
                                Authorization : "Bearer " + localStorage.getItem("Authorization"),
                                "Content-Type": "application/json",
                            },
                    });
                    
                    // 성공적으로 회원 탈퇴된 경우
                    if (response.status === 204) {
                        // 로컬 스토리지의 토큰 삭제
                        localStorage.removeItem('Authorization');
                        
                        // 상태 토큰 삭제
                        setAuthorizationToken('');
                        
                        // 회원 탈퇴 성공 알림
                        alert('회원 탈퇴가 완료되었습니다.')

                        // 로딩 상태 종료
                        setIsLoading(false);

                        // 로그인 페이지로 이동
                        navigate('/login');
                    } else {
                        // 회원 탈퇴 실패 알림
                        alert('회원 탈퇴에 실패했습니다.');

                        // 로딩 상태 종료
                        setIsLoading(false);
                    }
                } catch (error) {
                    // 에러 처리 알림
                    alert('회원 탈퇴에 실패했습니다.');

                    // 로딩 상태 종료
                    setIsLoading(false);
                }
            } 
        } 
        // 로그인하지 않은 상태이면
        else {
            alert('비로그인 상태입니다.')
        }
    };

로그인

로그인 창 / 로그인 버튼을 눌렸을 때 유효성 검사하는 사진

로그인의 주요 기능은 토큰발급입니다. 

로그인 시 회원가입 때와 같이 유효성 검사를 합니다. 유효성 검사를 통과해야지만 서버에 요청을 보내서 로그인되도록 하였으며, 유효성 검사 조건 코드는 회원가입 코드와 유사합니다.

유저가 유효성 검사를 통과하여 로그인 버튼을 누른 경우, 로그인이 되는 동안 로딩 인디케이터를 통해 유저의 이탈을 방지합니다.

정상적으로 유저가 로그인이 되었으면 서버로부터 인증토큰을 받으며 로컬과 상태에 저장되며 웰컴페이지로 이동합니다.

 

로그인 후 로컬 스토리지에 정상적으로 토큰을 발급 받은 사진


로그아웃

(<- 비로그인 시 로그인 페이지로 이동하는 버튼)

로그아웃 버튼은 비로그인 시 로그인 페이지로 이동하는 버튼으로 나오고,

로그인이 된 경우에는 로그아웃 버튼으로 나타납니다.

로컬 스토리지의 토큰의 유무로 로그인과 비로그인 상태를 판단하였습니다.

 

(<- 로그인 시 로그아웃 버튼으로 나옴) 

로그인이 된 경우에는 로그아웃 버튼을 누르면 서버로 로그아웃 요청이 가며, 로컬 토큰과 , 상태 토큰 모두 초기화되며, 로그인 페이지로 이동합니다.

 

로그아웃 후 로컬 스토리지에 정상적으로 토큰이 초기화 된 사진

export const LogOutButton = () => {
    const navigate = useNavigate();
    const [authorizationToken, setAuthorizationToken] = useRecoilState(authorizationTokenState);
    const authToken = localStorage.getItem('Authorization');
  
    const logOutHandler = async () => {
      if(authToken) {
        // 로그인 상태일 때
        try {
          // 서버 API 호출
          const response = await axios.post(`${process.env.REACT_APP_SERVER_URL}/logout`, 
          null, {
              headers: {
                Authorization : "Bearer " + localStorage.getItem("Authorization"),
                "Content-Type": "application/json",
              },
            }
          )

          if (response.status === 200) {
            // 로컬 스토리지 토큰 삭제
            localStorage.removeItem('Authorization');

            // 상태 토큰 삭제
            setAuthorizationToken('');
            
            // 로그아웃 후 로그인페이지로 이동
            navigate('/login');
            alert('로그아웃 되었습니다.');
          } else {
            // 회원 탈퇴 실패
            alert('로그아웃에 실패했습니다.');
          }
        } catch (error) {
          // 에러 처리
          alert('로그아웃에 실패했습니다.');
        }
      }
    }
    return (
      <button type="button" onClick={logOutHandler} className='flex justify-center items-center'>
        <div className={`w-[40px] h-[40px] text-[20px] text-white bg-sky-400 hover:bg-[#0088F8] active:bg-gray-200 active:text-[#0088F8] rounded-full flex justify-center items-center transition duration-300 ease-in-out animate-pulse hover:animate-none shadow-md`}>
          {authToken ? <i class="fa-solid fa-right-from-bracket" /> :  <ToSmallButton linkName='loginpage' Size='sm' iconName='loginpage' colorName='blue' title='loginpage'/> }
        </div>
      </button>
    );
};

어렵고, 아쉬웠던 점

프로젝트를 진행하면서 기술적으로 어렵고, 아쉬웠던 부분은 너무나도 많았습니다.

 

새로운 기술스택 적응

새로운 기술스택 (Recoil, Tailwind CSS)을 처음 사용하다 보니 적응하는데 어려웠으며, 

조금 더 빠르게 적응했더라면 코드에 더 많은 신경을 쓸 수 있었을 텐데라는 아쉬움이 많이 남습니다.

그리고, 특히 버튼 컴포넌트를 만들어서 동적 스타일링으로 사용하고 싶었으나, Tailwind CSS로는 동적 스타일이 기존의 CSS처럼 적용되지 않았습니다. Tailwind CSS 공식 홈페이지에서 동적 스타일링이 적용되지 않는다는 것을 보고, 다른 방법을 찾아 적용해 보니 생각보다 어렵지 않았고, 오히려 기존에 사용했던 Redux, CSS보다 훨씬 쉽고 편하게 사용할 수 있었습니다.

 

// 버튼 컴포넌트
export const ToSmallButton = ({linkName, Size, iconName, colorName, title}) => {
  const buttonSize = {
    sm : 'w-[40px] h-[40px] text-[20px]',
    md : 'w-[60px] h-[60px] text-[30px]',
    lg : 'w-[80px] h-[80px] text-[40px]',
  }
  
  const icon = {
    mainpage : 'fa-solid fa-house',
    loginpage : 'fa-solid fa-right-to-bracket',
    mypage : 'fa-solid fa-user',
    blog : 'fa-solid fa-rectangle-list'
  }

  const link = {
    mainpage : '/',
    loginpage : '/login',
    mypage : '/mypage',
    blog : '/bloglist/1'
  }

  const color = {
    orange : 'text-white bg-orange-400 hover:bg-[#ff6200] active:bg-gray-200 active:text-[#ff6200]',
    blue : 'text-white bg-sky-400 hover:bg-[#0088F8] active:bg-gray-200 active:text-[#0088F8]',
    green : 'text-white bg-green-400 hover:bg-green-500 active:bg-gray-200 active:text-green-500',
    purple : 'text-white bg-purple-400 hover:bg-[#a100fd] active:bg-gray-200 active:text-[#a100fd]',
  }

  const specification = {
    mainpage : 'Go to MainPage',
    loginpage : 'Go to LoginPage',
    mypage : 'Go to MyPage',
  }

  return (
    <Link to={`${link[linkName]}`}>
      <div className={`${specification[title]} flex justify-center items-center`}>
        <div className={`${buttonSize[Size]} ${color[colorName]} rounded-full flex justify-center items-center transition duration-300 ease-in-out animate-pulse hover:animate-none shadow-md`}>
            <i className={`${icon[iconName]}`} />
        </div>
      </div>
    </Link>
  )
}

// ex) 적용 시
<ToSmallButton linkName="mainpage" Size="lg" iconName="mainpage" colorName="orange" title="mainpage" />
<ToSmallButton linkName="mypage" Size="sm" iconName="mypage" colorName="green" title="mypage" />

토큰 갱신

토큰 부분이 어려웠습니다. 

액세스 토큰이 만료되면 리프레시 토큰을 이용하여 액세스 토큰을 재발급받아야 하는데,

모든 요청마다 서버에 요청을 해서 구현하였습니다.

아쉽게도 프로젝트 끝날 무렵 액시오스(axios)의 인터셉터(interceptor), 인스턴스(instance)를 인지하게 되어 

액시오스, 인터셉터, 인스턴스를 활용하여 토큰 갱신을 구현하지 못한 부분이 많은 아쉬움이 남습니다.


스스로 칭찬할 점 

첫 프로젝트이기도 하고, Recoil과 Tailwind CSS를 한 번도 사용해 보지 않아

새로운 기술 스택을 사용하는 것이 겁이 났지만 도전하였다는 점과 잘 적용할 수 있었다는 점을 스스로 칭찬하고 싶습니다.

지금 프로젝트 기간이 끝났지만 팀원들과 계속 아쉬웠던 부분을 조금씩 추가 및 개선해 볼 예정입니다.


프로젝트 후 수정 및 추가한 내용

- 일반로그인, 소셜로그인의 메인페이지를 필수로 거쳐야 데이터가 나오던 부분 -> 完
- 로그아웃 오류 발생 -> 完
- 웰컴페이지 폼이 화면 전체의 가운데에 오지 않음 -> 完

- 도시이름과 날씨정보사이를 조금 띄우면 좋을 것 같아요 -> 完
- 온도 (ex: 22/24)를 (ex: 22 / 24℃ 아니면 22℃ / 24℃)로 되었으면 좋을 것 같아요 -> 完
- 강수확률(ex: 20%)이 무엇을 의미하는지 모를 것 같아요 앞에 물방울모양 넣으면 좋을 것 같아요 -> 完
- 날씨 아이콘의 크기를 약간 크게 했어요 -> 完

 

- 태그 필터버튼 안의 text와 글쓰기 text의 크기가 너무 작은 것 같아요 -> 完
- 게시글의 날짜를 변경하면 좋을 것 같아요 (toLocalString 사용하면 될 것 같습니다) -> 完

- 게시글의 댓글 붙이는 부분의 작성 버튼을 input box의 세로 크기와 맞추고, ‘작성’ text가 가로로 나오고, 작성 버튼의 가로길이가 더 커지면 좋을 것 같아요 -> 完

- 날씨 아이콘이 지금 두 가지(맑음, 비)밖에 없는데 강수확률에 따라 더 많은 날씨 아이콘을 보여주면 좋을 것 같아요 -> 完
- blogdetail에서 블로그리스트로 이동하는 버튼 있어야 할 것 같아요 -> 完
- blogwrite에서 글쓰기 버튼의 text를 예시로 ‘작성완료’, ’ submit’, ‘글쓰기완료’, ‘제출하기’.. 등으로 바꾸면 좋을 것 같아요 -> 完
- blogwirte에서 글쓰기 취소 버튼을 글쓰기 완료? 버튼 옆에 두고 글쓰기 취소버튼을 누르면 이전 페이지로 이동하면 좋을 것 같아요.(취소하기 누르면 bloglist로 이동) -> 完

반응형
profile

Space

@Space_zero

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!