Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ 4주차 기본/심화/공유 과제 ] 로그인, 회원가입 구현해보기 #4

Open
wants to merge 56 commits into
base: main
Choose a base branch
from

Conversation

wrryu09
Copy link
Member

@wrryu09 wrryu09 commented May 9, 2024

✨ 구현 기능 명세

🧩 기본 과제

  1. 메인 페이지
    • 메인 이미지 or 비디오 넣기
  • 로그인페이지와 회원가입 페이지로 이동할 수 있는 버튼 구현
  1. 로그인 페이지

    • 아이디와 비밀번호를 입력할 수 있는 input구현
    • Login page 이미지 넣기
    • 로그인 버튼(기능)과 회원가입 페이지 이동 버튼 구현
    • 로그인 실패시 해당 에러메세지를 alert로 띄어주기
    • useParam 활용해서 id값 보유하고 있기.
  2. 회원가입 페이지

    • 아이디, 패스워드, 닉네임, 핸드폰 번호를 입력 받는 페이지 구현
    • 회원가입 버튼 클릭시 post api 통신을 진행하고 성공시 회원가입이 완료되었다는 메시지를 보여주는 alert 띄워준 후, 로그인 메인페이지로 이동
    • 아이디 중복, 비밀번호 형식 오류, 전화번호 형식 오류 등 모든 에러 alert로 메세지 보여주기
    • 비밀번호와 전화번호 형식은 input 아래에 보여주기
  3. 마이페이지

    • get 메소드를 사용해 사용자 정보를 가져오기
    • 서버에서 받아온 ID, 닉네임, 전화번호 데이터를 렌더링
    • 비밀번호 변경 토글을 사용해 비밀번호 변경 폼을 on/off할 수 있도록 구현
    • 기존 비밀번호 입력, 새로운 비밀번호 입력, 새로운 비밀번호 확인 input 구현
    • input이 비어있을 경우 api 작동되지 않도록 구현
    • 에러 발생시 api error객체 안 error message를 사용해 alert 띄우기
    • 홈 이동 버튼 구현

🔥 심화 과제

  1. 메인페이지

    • 비디오에 여러 기능을 적용
  2. 로그인 페이지

    • input이 비어있을 경우 api요청 보내지 않고 아래 error message를 띄워주기
  3. 회원가입 페이지
    input이 비어있는 상태로 api연결 시도했을시

    • 해당 input 테두리 색상 변경

    • input에 focus 맞추기

    • api요청 금지

    • 전화번호 양식 정규표현식으로 자동입력되도록 설정 (숫자만 입력해도 "-"가 붙도록)

    • 비밀번호 검증 유틸 함수 구현 (검증 통과되지 않을시 api요청 금지)

공유과제

  • prettier, eslint, styleLint에 대해
  • lighthouse에 대해

링크 첨부(팀 블로그 링크) :

https://citrine-tractor-afe.notion.site/LightHouse-5bf48dcfc60f427c9ec920111ea29ad5?pvs=4


📌 내가 새로 알게 된 부분

다음과 같이 같은 레이아웃을 적용할 때, 레이아웃을 재사용할 수 있도록

const ModalLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  return (
    <Container>
      <Modal>{children}</Modal>
    </Container>
  );
};

ModalLayout 을 만들고

const Login = () => {
  return (
    <ModalLayout>
      <LoginTitle>Login</LoginTitle>
      <LoginImage src={LoginImg} />
      <LoginInput />
    </ModalLayout>
  );
};

와 같이 Login, Mypage 페이지에서 사용해 같은 UI 를 제공했습니다.


  1. 스타일 코드 재사용
const buttonStyle = `
display: flex;
...
`;

const BtnContainer = styled.div`
  ${buttonStyle}
`;
const BtnLink = styled(Link)`
  ${buttonStyle}
  text-decoration: none;
`;

스타일 코드를 재사용하기 위해 buttonStyle 변수로 만들고 BtnContainerStyledLink 에서 재사용했습니다


  1. Link component styling을 다음과 같이 할 수 있음을 알게 됐습니다!
const BtnLink = styled(Link)`
	...
`

💎 구현과정에서의 고민과정(어려웠던 부분) 공유!

문제: CommonBtn 이라는 공통 버튼 컴포넌트가 있을 때, link 가 주어졌을 때는 라우팅을 해야 하고 onClick 이 주어졌을 때는 onClick 함수를 실행해야 했습니다.

처음에는 Link 하는 역할만을 염두에 두고 BtnContainer 안에 Link 만을 넣었더니 Onclick을 실행할 수 없어서,

  • onClick 이 있는 경우에는 BtnContainer 에서 onClick을 수행하도록 하고
  • link 가 있는 경우에는 Link 컴포넌트로 링크하도록 만들었습니다.
const CommonBtn = (props: Props) => {
  return (
    <BtnContainer
      onClick={() => {
        props.onClick && props.onClick();
      }}
    >
      {props.link ? (
        <BtnLink to={props.link}>{props.text}</BtnLink>
      ) : (
        <p>{props.text}</p>
      )}
    </BtnContainer>
  );
};

하지만 어떤 역할을 하는지 알아보기 어렵다고 생각해 아래와 같이 구조를 변경했습니다.

const CommonBtn = (props: Props) => {
  const navigate = useNavigate();

  const handleClick = () => {
    if (props.onClick) {
      props.onClick();
    }

    if (props.link) {
      navigate(props.link);
    }
  };
  return (
    <BtnContainer type="button" onClick={handleClick}>
      <p>{props.text}</p>
    </BtnContainer>
  );
};

해결: props 를 검사해 각 경우에 따라 다른 동작을 하도록 handleClick를 만들고,

link 인 경우 Link 컴포넌트를 이용하는 대신 useNavigate() 훅을 이용한 네비게이션을 하도록 한 후 버튼에 onClick을 넘겨주는 방식으로 수정했습니다


api 에서 에러가 발생했을 때

  1. 요청은 이루어졌지만 성공하지 못한 경우
  2. 요청이 이루어지지 않은 경우 혹은 기타 에러

를 구분해 1번의 경우 메시지를 alert 로 출력해야 해서,

1번을 구분하는 방식으로 아래와 같이 isAxiosError(error) 를 사용했는데,

이 방법이 예외를 올바르게 처리할 수 있는 방법인지 궁금합니다.

...
catch (error) {
    if (isAxiosError(error)) alert(error.response?.data.message);
    else {
      console.log(error, "unknown error: memberJoin");
    }
  }

🥺 소요 시간

  • 10h

🌈 구현 결과물

https://citrine-tractor-afe.notion.site/week4-859a05e739d047de90648bf2a706fe6c?pvs=4

@wrryu09 wrryu09 self-assigned this May 9, 2024
@ijieun
Copy link

ijieun commented May 14, 2024

api 에서 에러가 발생했을 때

  • 요청은 이루어졌지만 성공하지 못한 경우
  • 요청이 이루어지지 않은 경우 혹은 기타 에러
    를 구분해 1번의 경우 메시지를 alert 로 출력하는 부분은
    저는 오류 코드로 구분해서 작성했습니다!! 간결하게 에러를 핸들링할 수 있어서 이렇게 작성했는데,
    승연님처럼 isAxiosError 함수를 사용하는 방법도 Axios 라이브러리에서 발생하는 에러를 처리하고 구분하는 데 효과적인것 같네요! 👍

Copy link

@ijieun ijieun left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

울 합세 리더님 ..💚 4주차 과제 너무 고생하셨습니다!!
컴포넌트 분리가 잘 되어있고 구조가 좋아서 읽기 쉽고 재밌었어요!!
타입 스크립트 prop 도 분리를 잘 해주셔서 감탄했어요

합세도 퐛팅해봅시다!! 🍀🍀

return (
<LoginModal>
<LoginTitle>Login</LoginTitle>
<LoginImage src={LoginImg} />
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

img 태그에 alt 속성도 넣어주면 좋을 것 같아요 :>

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영했습니다!! ㅎㅎ꼼꼼하게 봐주셔서 감사해요

week4/src/hooks/useForm.tsx Outdated Show resolved Hide resolved
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

공통된 컴포넌트 이름까지 세심한 수정 ..!! 너무 좋아용

Comment on lines 37 to 38
margin-top: 3rem;
margin-bottom: 3rem;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

margin : 3rem 0 으로 작성하면 상하 마진을 3rem 으로 쉽게 작성할 수 있을 것 같아요 !

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

더 간략하게 작성할 수 있네요~!! 반영했습니당!

Comment on lines 5 to 14
export interface JoinType {

export interface infoType {
authenticationId: string;
password: string;
nickname: string;
phone: string;
}

export interface JoinType extends infoType {
password: string;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

왜 password 만 따로 분리했는지가 궁금해요!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JoinTypeInfoType에서 중복되는 타입들이 많아서,,
JoinTypeInfoType를 상속받아 추가되는 항목만 적어줬습니다!

Comment on lines 32 to 50
/** 인풋 확인 */
const checkInput = () => {
if (id === "") {
alert("id를 입력하세요");
return false;
}
if (pwd === "") {
alert("비밀번호를 입력하세요");
return false;
}
if (nickName === "") {
alert("닉네임을 입력하세요");
return false;
}
if (phone === "") {
alert("전화번호를 입력하세요");
return false;
}
return true;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버 내부에서 응답되는 에러 메세지를 이용하지 않고, 클라이언트에서 오류 메세지를 다시 작성한 이유가 따로 있을까 궁금해요!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저 조건들에 걸리면 서버에 요청을 보내지 않아서 응답이 안 오기 때문에 따로 작성했습니다!

const ToggleIcon = styled.img<{ $active: boolean }>`
width: 1rem;
height: 1rem;
rotate: ${(props) => (props.$active ? "0deg" : "180deg")};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토글 버튼이 180도 돌아가도록 구현했네요! 신기해요 >_<

const [id, setId] = useForm("");
const [pwd, setPwd] = useForm("");
const [nickName, setNickName] = useForm("");
const [phone, setPhone] = useState("");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

phone 은 useForm hook 을 쓰지 않은 이유가 있을까요?
다른 이유가 없다면 일관성을 유지를 위해 통일하는게 좋을것 같다는 생각이 듭니다 !!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

훅 안에 input 에 입력될 때마다 setState 가 돌아가는 핸들러 함수가 있는데,
checkPhoneNo 함수로 정규표현식을 이용해 입력할 때마다 검사해야 했기 때문에 다르게 구현했습니다!
훅 내부 함수를 상황에 맞게 더 잘 사용할 수 있는 방법이 있을까요 ..?🥹

Comment on lines 5 to 15
/** 로그인 */
export const memberLogin = async (props: LoginType) => {
try {
const res = await serverAxios.post("/member/login", props);
return res;
} catch (error) {
if (isAxiosError(error)) alert(error.response?.data.message);
else {
console.log(error, "unknown error: memberLogin");
}
return false;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

성공 시 res를 그대로 반환하고 있고, 실패 시 false를 반환하네요!
일관성 있는 반환 타입을 유지하지 않아 보여서 항상 같은 형태의 객체를 반환하거나, 에러 상황에서 null을 반환하는 것은 어떨까요 ?? :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 그러네요!! 꼼꼼하게 봐주셔서 감사합니다!

Copy link

@chaeneey chaeneey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

합세 코드, 개인과제 코드를 보면서 계속 드는 생각은 너무너무 깔끔하단 점입니다 ..
분리를 왜이렇게 잘하시는거죠..? 저 다른 것보다 컴포넌트 분리, 파일 구조 이런 부분에 있어서 고민이 왕많았는데,
승연님 코드 보면서 정말 많이 배워가요!!!! 💝 플젝 하면서도 자주 보러 올게욥. 🤩
역시 합세 조장님 ~~~~~!!!!!

Comment on lines +1 to +5
import axios from "axios";

export const serverAxios = axios.create({
baseURL: import.meta.env.VITE_SERVER_URL,
});

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

axios 통신 부분을 이렇게 빼서 구현할 수가 있군요..!
완전 큰 깨달음 얻고갑니다 .. 우와 🤩..

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

같은 주소에 계속 요청을 보내야 하는 경우나 같은 조건을 계속 사용할 때
axios.create를 이용해 인스턴스를 만들어주면 편리하게 사용할 수 있답니다!!ㅎㅎ

Comment on lines +19 to +32
const Join = () => {
const navigate = useNavigate();
const [id, setId] = useForm("");
const [pwd, setPwd] = useForm("");
const [nickName, setNickName] = useForm("");
const [phone, setPhone] = useState("");

const idRef = useRef<HTMLInputElement>(null);
const pwdRef = useRef<HTMLInputElement>(null);
const nickNameRef = useRef<HTMLInputElement>(null);
const phoneRef = useRef<HTMLInputElement>(null);

const onPhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPhone(checkPhoneNo(e.target.value));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useForm과 비교해보니 onPhoneChange엔 checkPhoneNo() 과정이 포함되어 있더라고요!
이 차이 때문에 phone만 useState를 사용해서 이 파일에서 정리해준게 맞나요?! 제가 맞게 이해한건지 궁금합니다!
또, checkPhoneNo()는 심화과제에 있던 번호 형식 변경(000-0000-0000) 부분이 맞나요?!

컴포넌트 나누는 부분 진짜 완전 배워가요.. 이해 쏙쏙 정리 쏙쏙 .. 👍🏻👍🏻

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 정확히 보셨네요!!!
훅 안에 입력할 때마다 setState를 이용해서 state를 바꿔주는 부분이 있는데, 전화번호의 경우 입력할 때마다 정규표현식으로 체크해줘야 해서 따로 작성했습니다.!
훅을 최대한 이용하고 싶었는데 이건 따로 작성해주는 게 더 간단할 것 같아 일단 이렇게 작성했습니다,,ㅎㅎ

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오오..!! 너무 좋네요.. 이유가 또렷하게 있어서 더더더 좋은 것 같숨니다! 설명 감사해요! 💝

Comment on lines +8 to +13
import {
ALERTMSG,
BTNTXT,
INFORMATION,
INFORMMSG,
} from "../constants/messages";

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

상수 메시지..👍🏻 너무 꼼꼼해요 진촤
저도 합세 때 사용해서 꼭 습관 들이기를 .. 😊

Comment on lines +11 to +20
<MainContainer>
<ReactPlayer
url={mainVideo}
playing={true}
loop={true}
playbackRate={4}
/>
<BtnWrapper>
<CommonBtn text={BTNTXT.myInfo} link={`/mypage/${memberId}`} />
<CommonBtn text={BTNTXT.join} link="/join" />

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이번에 합세 하면서도 assets 링크 같은거 그대로 넣지않고, 정리 후에 넣어주는 모습이 정말 배울 점이라고 생각했는데
개인 과제에서도 이렇게 해주시니 너무 이해가 쏙쏙 가네요..😊 진짜 깔끔한 코드가 이런거구나 완전 배워갑니다!

Comment on lines +86 to +89
// 비밀번호 형식 검사
if (!verifyPwd(pwd)) {
alert(ALERTMSG.pwdFormat);
return false;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 비밀번호 형식이 맞지 않으면 400대 error가 뜨고, 서버에서 오는 message를 이용해서 alert 창을 띄웠는데
이렇게 구체적으로 형식 검사를 진행한 후 지정한 alert 창을 띄워주는 이 방식이 훨씬 구체적이고 더 꼼꼼한 방법 같습니다..
그치만 여기서 드는 초보의 궁금증 하나.. 이 두 방식의 큰 차이가 무엇인지 아시나요?!?
세심하게 오류를 다룰 수 있다는 점이 맞는건지 궁금합니다!!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

표시해주신 부분은 정규표현식으로 사용자가 작성한 비번이 조건에 맞지 않을 때 프론트에서 한번 미리 걸러주는 용도입니다!!
차이라고 하면...
형식 검사를 진행한 후 서버에 요청을 하면 사용자가 조건에 안 맞는 비번을 입력했다면 요청을 보내지 않으니까
요청을 덜 보내게 되어 네트워크 부담이 덜하고 서버에서 유효하지 않은 요청을 처리하는 횟수가 줄어드니까 서버에서 요청을 처리하는 횟수가 줄어들겠죠?!
저도 첫번째 부분을 코드의 다른 부분에 구현해두고 심화과제로 다시한번 체크하는 과정을 추가했답니다 ㅎㅎ

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오..!! 그러네요?! 요청을 덜 보내게 되어 네트워크 부담이 덜한다.. 생각도 못했는데 너무 좋은 장점이네요 ㅎㅎ!!
좋은 설명 너무 감사합니다! 💝💝

Comment on lines +68 to +75
const checkInput = () => {
const fields = [
{ field: id, ref: idRef, msg: ALERTMSG.id },
{ field: pwd, ref: pwdRef, msg: ALERTMSG.pwd },
{ field: nickName, ref: nickNameRef, msg: ALERTMSG.nickName },
{ field: phone, ref: phoneRef, msg: ALERTMSG.phone },
];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkInput 함수의 동작을 설명해주실 수 있나요 ..! (궁금합니닷 완전 확실하게 배워가고 싶어요😀)
field가 비어있을 경우, alert 창을 띄워주고,
비밀번호 유효 검사에 통과하지 않을 경우, 비밀번호 포맷 관련 alert 창을 띄워주는 방식이 맞나요?!
또, warnRef와 resetRefWarn 이 어떻게 작동하는 지도 궁금합니다!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

회원가입 시도시 사용자가 인풋을 잘 썼나 검사하는 용도입니다!!

  /** 인풋 확인 */
  const checkInput = () => {
    if (id === "") {
      alert("id를 입력하세요");
      return false;
    }
    if (pwd === "") {
      alert("비밀번호를 입력하세요");
      return false;
    }
    if (nickName === "") {
      alert("닉네임을 입력하세요");
      return false;
    }
    if (phone === "") {
      alert("전화번호를 입력하세요");
      return false;
    }
    return true;

사실 위의 코드와 비슷한 동작을 하는데, 구조가 너무 반복되는 것 같아 줄여보겠다고 작성한 코드입니다!!
읽기에는 더 안 좋은 것 같네요 😅

체크해야 하는 필드를 fields로 만들어
field는 검사 항목,
ref 에는 해당 항목을 입력하는 인풋,
msg 에는 해당 필드 미입력시 띄워줘야 하는 메시지를 담았습니다!

필드가 "" 와 같은 경우(비어있는 경우) 에러 메시지를 띄워주고, 해당 ref를 warn 합니다!
warnRefresetRefWarn는 인풋 비어있으면 인풋창 border 색 바꾸고 focus 처리 해주기 위해서 만든 건데,

  /** focus, border 색 바꾸기 */
  const warnRef = (ref: React.RefObject<HTMLInputElement>) => {
    if (ref.current) {
      ref.current.focus();
      ref.current.style.setProperty("border-color", "red");
    }
  };

warnRef 는 ref 를 props로 받아서 ref(해당 필드의 인풋창이겠죠???) 가 유효하면 focus() 처리를 해주고 테두리 색을 red로 바꿔줍니다!!

  /** focus 해제, border 색 복구 */
  const resetRefWarn = (ref: React.RefObject<HTMLInputElement>) => {
    if (ref.current !== null) {
      ref.current.blur();
      ref.current.style.setProperty("border-color", "black");
    }
  };

resetRefWarn는 반대로 focus() 를 해제하는 blur() 함수를 사용했고, 테두리 색을 다시 검정색으로 복구합니다.

그래서 field가 ""가 아니라면 (비지 않았다면) resetRefWarn(ref);를 실행해서 ref 상태를 복구시켜줍니다!~~

이해하시는 데에 도움이 되었으면 좋겠네요 !!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

와.. 승연님
당장 어디서든 강의 열어주시면 안되겠습니까 .. !!!
제가 아직 저렇게 요약된 코드를 풀어서 이해하는 부분이 살짝 부족해서 여쭤봤던 거고!! 설명을 너무 잘해주셔서 이해가 너무 잘 되네요.. 이 부분 저도 완전 연습해서 써먹고 싶어요 ㅎㅎ!

warnRef와 resetRefWarn 부분은 심화과제 내용이라 제가 모르고 있었나봐요! ㅎㅎ
승연님 자세한 설명 덕분에 요 부분까지 챙겨가서 너무 좋네요..
정성스러운 답변 너무너무 감사해요 💘 눈물 좔좔.. 나의 최고의 선생님..

week4/src/hooks/useForm.tsx Outdated Show resolved Hide resolved
Copy link
Contributor

@ljh0608 ljh0608 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typescript에서 catch 내부error 타입이 unknown이기 때문에 axios error인지 타입을 확인하고 그 타입이 맞을시 error객체에 접근해야합니다! 즉 타입가드를 사용한건데요.
이부분에 대해선 아래 링크 한번 보시면 도움이 될 것 같습니다
https://gxxrxn.github.io/axios-error-type-guard/

@wrryu09
Copy link
Member Author

wrryu09 commented May 16, 2024

typescript에서 catch 내부error 타입이 unknown이기 때문에 axios error인지 타입을 확인하고 그 타입이 맞을시 error객체에 접근해야합니다! 즉 타입가드를 사용한건데요. 이부분에 대해선 아래 링크 한번 보시면 도움이 될 것 같습니다 https://gxxrxn.github.io/axios-error-type-guard/

isAxiosError(error) 가 axios error 타입인지 확인하는 타입 가드였군요!!
그렇다면 세부적인 핸들링을 위해서는 에러코드 기반으로 처리하는 과정이 더 필요하겠네요!
자료 공유 감사합니다!! 꼼꼼하게 읽어보겠습니다
서버에서 받은 응답 타입 좁히기가 조금 까다롭네요😅 더 연습해 보겠습니다!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants