Codebnb 프로젝트 리뷰

HELLO WORLD🙌·2020년 8월 16일
0

Mini-Project

목록 보기
7/8

Codebnb

Demo : https://youtu.be/IhYx9S43GYw

Description

  • 프론트엔드 2명, 백엔드 개발자 2명과 함께 에어비앤비 웹사이트의 디자인과 기능을 직접 구현해보는 프로젝트
  • 프로젝트 기간: 20.6.8 ~ 20.6.19

Tech Stack

  • React-hooks, React-Redux, Styled-Component

What did I do

메인 페이지

  • Media Query 반응형 적용
  • 유저가 입력한 검색값을 Redux를 사용하여 Global state로 관리하고,
  • query parameters로 전달하여 숙소 리스트 페이지로 라우팅
    로그인 페이지

모달 형식의 페이지 레이아웃 구현

  • 구글과 페이스북의 소셜 API 이용하여 로그인 기능 구현
  • local storage을 활용하여 사용자 정보(token) 관리

예약확인 페이지

  • Local state에 따른 조건부 컴포넌트 렌더링
  • 지난예약에 대해 리뷰와 평점을 백엔드 데이터에 POST하는 기능 구현

호스트등록 페이지

  • Formdata 객체를 활용하여 사진 업로드 기능구현
  • 다중 선택가능한 option의 state 관리

Code Review

소셜로그인

구글과 페이스북의 소셜 로그인 API의 사용하기 위한 과정은 다음과 같았다.

  • 페이스북,구글에서 개발자 계정 등록/ 앱 등록(클라이언트id값 생성)
  • SDK로드 (index.html에 developers docs에서 제공하는 소스코드를 붙여 넣는다)
    SDK(Software develop kit)란 제공받는 api를 편리하게 이용하기위한 핸들러이다.
  • SDK init (초기화) - 로드한 SDK에 클라이언트id값을 세팅하는 것을 통해 sdk를 이용하는 우리가 누군지 페이스북과 구글에 알려줌
const Login = (props) => {
  const googleLogin = () => {
    window.gauth.signIn().then(function () {
      let token = window.gauth.currentUser.get().wc.access_token;
      console.log(window.gauth.currentUser.get());
      localStorage.setItem("access_token", token);

      fetch(`${API}/user/google`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          access_token: token,
        }),
		 .then(window.location.reload()),
      });
    });
  };
  return (
    <Back>
      <AccountModal>
        <div>
          <button onClick={props.handleClose}>x</button>
        </div>
        <div>
          <button onClick={googleLogin}>구글 로그인</button>
          <button
            onClick={() =>
              window.gauth.signOut().then(function () {
                window.localStorage.clear();
                window.location.reload();
              })
            }
          >
            로그아웃
          </button>
        </div>
      </AccountModal>
    </Back>
  );
};

export default Login;

라이브러리 활용시

로그인 기능외에도 버튼 커스텀, 에러핸들링등등 여러 기능이 달린 라이브러리가 있길래 대체하였다.

구글과 페이스북으로 요청하여 응답받은 토큰을 body에 담아 백엔드로 보내고 회가입여부를 체크하고 ,
다시 우리 서버에서 보내준 토큰을 local storage에 담아서 로그인상태를 유지할 수 있도록 했다.

const Login = ({ openLogin, handleClose }) => {
  //구글 로그인 성공시
  const responseGoogle = (response) => {
    console.log(response);

    fetch(`${API}/user/google`, {
      method: "POST",
      headers: {
        "Content-type": "application/json",
      },
      body: JSON.stringify({
        access_token: response.wc.access_token,
      }),
    })
      .then((response) => response.json())
      // .then((response) => console.log(response));
      .then((response) => {
        localStorage.setItem("access_token", response.Authorization);
        localStorage.setItem("username", response.username);
        localStorage.setItem("avatar", response.avatar);
        window.location.reload();
      });
  };
  ... 
  
  return (
    <Portal elementId="modal-root">
      <Back onClick={openLogin && handleClose}>
        <AccountModal>
          <div>
            <button onClick={handleClose}>
              ...
            </button>
          </div>
          <div>
            ...

            <GoogleLogin
              clientId="515529931906-c9va4d88rfupqbr81mi9se4jjbmvkm6o.apps.googleusercontent.com"
              render={(renderProps) => (
                <GoogleLoginBtn
                  onClick={renderProps.onClick}
                  disabled={renderProps.disabled}
                >
                  ... 
                  구글 로그인
                </GoogleLoginBtn>
              )}
              buttonText="Login"
              onSuccess={responseGoogle}
              onFailure={responseFail}
              cookiePolicy={"single_host_origin"}
            />
const Nav = () => {
  const history = useHistory();
  const [loginBtn, setLoginBtn] = useState("로그인"); //로그인버튼의 text
  const [loginAvatar, setLoginAvatar] = useState(""); //프로필이미지url
  const [openLogin, setOpenLogin] = useState(false); //로그인을 위한 모달 띄우기
  const [openModal, setOpenModal] = useState(false); //로그인상태에만 나타나는 모달띄우기

  useEffect(() => {
    //로그아웃상태시 로그인버튼에는 초기상태인 "로그인"이 표시되고
    //로컬스토리지에 토큰이있다면(로그인시) 사용자 이름과 프로필 이미지를 nav바에 표시해준다.
    if ("access_token" in localStorage) {
      const username = localStorage.getItem("username");
      const avatar = localStorage.getItem("avatar");
      setLoginAvatar(<img src={`${avatar}`} className="avatar" alt=""/>)
      setLoginBtn(`${username}`);
    }
  }, [loginBtn]);

//loginBtn state를 통해 로그인여부를 확인할 수 있다.
//로그아웃상태라면 로그인 모달을 열고, 로그인상태라면 회원전용 기능이 있는 모달창이 열린다.
  const handleUserState = () => {
    if (loginBtn === "로그인") {
      setOpenLogin(!openLogin);
    } else {
      setOpenModal(!openModal);
    }
  };

//모달창 닫기 위한 함수정의
  const handleClose = () => {
    setOpenLogin(false);
  };

//로그아웃버튼을 누르면 로컬스토리지를 비우고, 버튼 표시를 다시 "로그인"으로 바꾼다.
  const handleLogout = () => {
    window.localStorage.clear();
    window.location.reload();
    setLoginBtn("로그인");
  };

  return (
    <Background onClick={openModal && handleClose}>
      <NavWrapper>
        ...

          <div className="LoginBtn nav" onClick={handleUserState}>
            {loginBtn}{loginAvatar}
            {openModal && (
              <UserModal
                openModal={openModal}
                handleUserState={handleUserState}
                handleLogout={handleLogout}
              />
            )}
          </div>
        </NavButton>
      </NavWrapper>
      {openLogin && <Login openLogin={openLogin} handleClose={handleClose} />}
    </Background>

로그인시에만 보여지는 UserModal 컴포넌트


로그인상태에서만 회원전용 페이지로 갈 수 있는 UserModal을 띄울 수 있고, useHistory hook을 이용하였다.

const UserModal = ({ handleLogout, openModal, handleUserState }) => {
  const history = useHistory();
  return (
    <Portal elementId="modal-root"> 
      <Background onClick={openModal && handleUserState}>
    //모달창처럼 뜨고, 따로 닫기버튼이 없어 모달 밖의 부분을 클릭하면 openModal 상태가 false로 바뀌어 닫힌다. 
        <User>
          <div
            onClick={() => {
              history.push("/trips");
            }}
          >
            여행
          </div>
          <div
            onClick={() => {
              history.push("host");
            }}
          >
            숙소 호스트되기
          </div>
          <Line></Line>
          <div className="bottom" onClick={handleLogout}>
            로그아웃
          </div>
        </User>
      </Background>
    </Portal>
  );
};

export default UserModal;

모달에 Portals이용하기

리액트에서 Portal은 컴포넌트를 렌더링하게 될 때, UI 를 어디에 렌더링 시킬지 DOM 을 사전에 선택하여 부모 컴포넌트의 바깥에 렌더링 할 수 있게 해주는 기능이다. 따라서 DOM 의 계층구조 시스템에 종속되지 않으면서 컴포넌트를 렌더링 할 수 있다.
그래서 부모 컴포넌트가 overflow: hidden 이나 z-index 스타일을 가지지만, 자식이 컨테이너에서 시각적으로 이탈해야하는 경우 유용하다.(다이얼로그나, 호버카드나, 툴팁, 모달창에 유용!)
내용출처: 공식문서/https://velog.io/@public_danuel/trendy-react-portals

const Portal = ({ children }) => {
  const el = useMemo(() => document.getElementById("modal-root"));
  return ReactDOM.createPortal(children, el);
};

export default Portal;

검색창 컴포넌트

유저가 검색하는 값은 리덕스 모듈을 통해 전역상태로 관리된다.
메인>리스트>상세페이지>예약페이지까지 검색결과가 반영되기때문에, 처음에는 props나 window.history를 이용해서 전달전달하는 방식이었지만, 비효율적인 것 같아서 프로젝트기간이 끝난 후에 다시 뜯어고치고, 다른 팀원이 완성한 페이지까지 손을 좀 대야했다.

const Search = ({
  history,
  searchActions,
  startDay,
  endDay,
  location,
  adults,
  children,
  infants,
}) => {
  //위치입력 input에 달린 핸들러
  const onChange = (e) => {
    searchActions.getLocation(e.target.value);
  };

  //메인에서 검색한 값이 리스트페이지에서 쿼리스트링형식으로 필터링되기때문에, 
  //검색버튼을 누르면 바로 그 주소로 보내도록 했다. 
  const goToList = () => {
    const checkinString = `&checkin=${startDay}`;
    const checkoutString = `&checkout=${endDay}`;
    const adultsString = `&adults=${adults}`;
    const childrenString = `&children=${children}`;
    const infantsString = `&infants=${infants}`;
	//위치입력만 필수이고, 날짜나 게스트숫자는 입력하지않아도 
  	  //보내는 주소에 반영되지않으면서 검색할 수 있도록 했다.
    if (!location) {
      alert("위치를 입력하세요");
    } else {
      history.push(
        `/list?location=${location}${startDay ? checkinString : ``}${
          endDay ? checkoutString : ``
        }${adults ? adultsString : ``}${children ? childrenString : ``}${
          infants ? infantsString : ``
        }`
      );
    }
  };

  return (
    <>
      <SearchWrapper>
        <SearchInputs>
          <SearchInput>
            <InputBtn>
              <div className="location">&nbsp;위치</div>
              <i className="fas fa-search min"></i>
              <form onSubmit={goToList}>
                <input
                  placeholder="어디로 여행가세요?"
                  onChange={onChange}
                  // value={location}
                ></input>
              </form>
            </InputBtn>
          </SearchInput>

          <Line></Line>
          <SearchInput className="media">
            <InputBtn>
              <div>&nbsp;체크인/체크아웃</div>
              <div>
              {/* 캘린더 컴포넌트 */}
                <Calendar />
              </div>
            </InputBtn>
          </SearchInput>
          <Line></Line>
          {/* 게스트 인원 컴포넌트 */}
          <SearchInput className="media">
            <Guest />
          </SearchInput>
        </SearchInputs>

        <SearchBtn onClick={goToList}>
          <i className="fas fa-search"></i>&nbsp;&nbsp;<span>검색</span>
        </SearchBtn>
      </SearchWrapper>
    </>
  );
};

캘린더 컴포넌트

에어비앤비에서 제공하는 react-dates라는 캘린더 라이브러리를 활용하였다.

class형으로 만들어진 컴포넌트라 함수형 컴포넌트로 직접 수정하고, 선택한 날짜가 moment객체로 담기기때문에, 백엔드에 보내줘야하는 날짜형식으로 format해주었다.

const Calendar = ({
  searchActions,
  startDay,
  endDay,
  displayHandler,
  totalPriceCalculator,
}) => {
  const [startDate, setStartDate] = useState(null);
  const [endDate, setEndDate] = useState(null);
  const [focusedInput, setFocusInput] = useState(null);

  const onDatesChange = ({ startDate, endDate }) => {
    setStartDate(startDate);
    setEndDate(endDate);
    
    //날짜형식 변환
	let checkin, checkout;
    if (startDate !== null && endDate !== null) {
      checkin = startDate.format("YYYY-MM-DD");
      checkout = endDate.format("YYYY-MM-DD");

      //상세페이지에서만 적용되는 핸들러를 실행하기 위한 로직
      //displayHandler,totalPriceCalculator라는 prop은 메인페이지에서는 전달되지 않고, 실행되면 안되기때문에 조건을 걸어두었다.
      displayHandler && displayHandler();
      totalPriceCalculator && totalPriceCalculator(startDate, endDate);
    }
    //리덕스 store에 저장
    searchActions.getStartDay(checkin);
    searchActions.getEndDay(checkout);
  };

  return (
    <>
      <DateRangePicker
        startDate={startDate}
        startDateId="your_unique_start_date_id" 
        endDate={endDate} 
        endDateId="your_unique_end_date_id" 
        onDatesChange={({ startDate, endDate }) =>
          onDatesChange({ startDate, endDate })
        focusedInput={focusedInput}
        onFocusChange={(focusedInput) => setFocusInput(focusedInput)}
        showClearDates={true}
        startDatePlaceholderText={!startDay ? "체크인" : startDay}
        endDatePlaceholderText={!endDay ? "체크아웃" : endDay}
      />
    </>
  );
};

인원수 카운터 컴포넌트

const Guest = ({ searchActions, adults, children, infants }) => {
  //인원 칸을 클릭하면 카운터가 나타남
  const [open, setOpen] = useState(false);
  const handleOpen = () => {
    setOpen(!open);
  };
	//인원선택을 안했을 시, "게스트추가" 라는 표시가 되고,
  //선택시 성인+어린이가 게스트인원으로 계산되고, 유아는 따로 표시된다.
  let guestNum;
  if (adults === 0 && children === 0 && infants === 0) {
    guestNum = `게스트 추가`;
  } else if (infants === 0) {
    guestNum = `게스트 ${adults + children}`;
  } else if (infants !== 0) {
    guestNum = `게스트 ${adults + children}명, 유아 ${infants}`;
  }

  return (
    <>
      <SearchInput>
        <InputBtn onClick={handleOpen}>
          <div>인원</div>
          <div>{guestNum}</div>
        </InputBtn>
        {open && <GuestModal /> }
      </SearchInput>
    </>
  );
};

게스트 모달 컴포넌트

단순히 +1, -1만 되는 카운터가 아니라, 실제 에어비앤비 사이트에서 적용되는 조건을 전부 반영하였다.

  • 성인, 어린이, 유아 각각 최대인원이 정해져있고, 그 이상 추가할 수 없음
  • 성인이 0명일때 어린이나 유아를 선택하면, 성인 1명이 자동으로 추가되야함
  • 0명에서는 (-) 버튼을 클릭할수 없고, 버튼이 다른 색깔로 표시된다.
const GuestModal = ({ searchActions, adults, children, infants }) => {
  return (
    <Modal>
      <Guests>
        <div>
          <span>성인</span>
          <span>13세 이상</span>
        </div>
        <div>
          <button
            className={adults === 0 && "disabled"}
            onClick={() => {
              if (adults === 1 && (children !== 0 || infants !== 0)) {
                searchActions.getAdults(1);
              } else if (adults !== 0) {
                searchActions.adultDecrement();
              }
            }}
          >
            -
          </button>
          <div>{adults}</div>
          <button
            onClick={() => {
              if (adults !== 16) {
                searchActions.adultIncrement();
              }
            }}
          >
            +
          </button>
        </div>
      </Guests>
      <Guests>
        <div>
          <span>어린이</span>
          <span>2~12</span>
        </div>
        <div>
          <button
            className={children === 0 && "disabled"}
            onClick={() => {
              if (children !== 0) {
                searchActions.childrenDecrement();
              }
            }}
          >
            -
          </button>
          <div>{children}</div>
          <button
            onClick={() => {
              if (adults === 0 && children === 0) {
                searchActions.getAdults(1);
                searchActions.getChildren(1);
              } else if (children !== 16) {
                searchActions.childrenIncrement();
              }
            }}
          >
            +
          </button>
        </div>
      </Guests>
      <Guests>
        <div>
          <span>유아</span>
          <span>2세 미만</span>
        </div>
        <div>
          <button
            className={infants === 0 && "disabled" }
            onClick={() => {
              if (infants !== 0) {
                searchActions.infantsDecrement();
              }
            }}
          >
            -
          </button>
          <div>{infants}</div>
          <button
            className={infants === 5 && "disabled" }
            onClick={() => {
              if (adults === 0 && infants === 0) {
                searchActions.getAdults(1);
                searchActions.getChildren(1);
              } else if (infants !== 5) {
                searchActions.infantsIncrement();
              }
            }}
          >
            +
          </button>
        </div>
      </Guests>
    </Modal>
  );
};

예약확인 페이지

로그인 시 유저의 예약 상황을 볼 수 있는 페이지이다.
예정된 예약, 이전예약 클릭시 페이지가 다시 로딩되지 않고 각각 다른 컴포넌트를 보여줘야한다.

const Trips = () => {
  const [activeTab, setActiveTab] = useState("upcoming"); //클릭한 Tab이 무엇인지
  const [upcoming, setUpcoming] = useState([]); //예정된예약데이터
  const [past, setPast] = useState([]); //이전예약데이터

  useEffect(() => {
    // 로컬스토리지에 담겨진 토큰을 헤더에 담아 인증하고 데이터를 응답받는다.
    const token = localStorage.getItem("access_token");
    fetch(`${API}/user/tripstate`, {
      method: "GET",
      headers: {
        "Content-Type": "application/json",
        Authorization: token,
      },
    })
      .then((res) => res.json()) 
      .then((res) => {
      //이전예약, 예정된예약 데이터를 각각 다른 state에 담아줌
        setUpcoming(res.data[0].up_coming);
        setPast(res.data[0].past_booking);
      })
      .catch((err) => alert(err));
  }, []);

  const tab = {
    upcoming: (
      <div>
      // 응답받은 데이터에 값이 없으면 메세지를 보여주고, 
      // 있으면 state에 저장한 데이터를 TripList컴포넌트로 prop로 전달하여 목록을 보여준다. 
        {upcoming.length === 0 ? (
          "다시 여행을 떠나실 준비가 되면 에어비앤비가 도와드리겠습니다."
        ) : (
          <TripList list={upcoming} activeTab={activeTab} />
        )}
      </div>
    ),
    past: (
      <div>
        {past.length === 0 ? (
          "과거 여행이 없습니다. 하지만 여행을 완료하면 여기에서 확인하실 수 있습니다."
        ) : (
          <TripList list={past} activeTab={activeTab} />
        )}
      </div>
    ),
  };

  return (
    <>
      <Wrap>
        <Nav />
        <TripsWrapper>
          <section>
            <h2>여행</h2>
            <Buttons>
              <button
                style={
                  activeTab === "upcoming"
                    ? {
                        color: "black",
                      }
                    : undefined
                }
                onClick={() => setActiveTab("upcoming")}
              >
                예정된 예약
              </button>
              <button
                style={
                  activeTab === "past"
                    ? {
                        color: "black",
                      }
                    : undefined
                }
                onClick={() => setActiveTab("past")}
              >
                이전 예약
              </button>
            </Buttons>
            <Tab>{tab[activeTab]}</Tab>
			...
            
const TripList = (props) => {
  const list = props.list.map((item) => {
    return (
      <Trip
        id={item.id}
        title={item.title}
        image_url={item.image_url}
        start_date={item.start_date}
        end_date={item.end_date}
        address={item.address}
        room_id={item.room_id}
        host_id={item.host_id}
        activeTab={props.activeTab}
      />
    );
  });
  return <TripLists>{list}</TripLists>;
};

리뷰 작성과 예약내역 확인을 위한 Thumbnail 컴포넌트

const Trip = (props) => {
  const [openComment, setOpenComment] = useState(false);

  const handleOpen = () => {
    const token = localStorage.getItem("access_token");
    fetch(`${API}/api/review/${props.room_id}`, {
      method: "GET",
      headers: {
        "Content-Type": "application/json",
        Authorization: token,
      },
    })
      .then((res) => {
        if (res.status === 200) {
          setOpenComment(!openComment);
        } else {
          alert("이미 작성한 후기가 있습니다.");
        }
      });
    .catch((err) => alert(err));
  };

  const handleClose = () => {
    setOpenComment(false);
  };

  return (
    <TripThumbnail>
    //클릭하면 해당 숙소의 상세페이지로 넘어가도록 한다.
      <Link to={`/detail/${props.room_id}`}>
        <img src={`${props.image_url[0]}`} alt="" />

        <div>
          <span>
            {props.start_date} - {props.end_date}
          </span>
          <span>{props.address}</span>
          <span>{props.title}</span>
        </div>
      </Link>
//예정된 예약에는 리뷰달기 버튼이 보여지면 안되기때문에 props값에 따라 스타일 적용.
      <div
        style={{ display: props.activeTab === "upcoming" ? "none" : "block" }}
      >
        <Btn onClick={handleOpen}>리뷰 쓰기</Btn>
      </div>
//리뷰쓰기 버튼을 누르면 openComment 상태가 true가 되고, Form 컴포넌트가 나타난다.
      {openComment &&
        <Form
          roomId={props.room_id}
          hostId={props.host_id}
          handleClose={handleClose}
        />
      ) }
    </TripThumbnail>
  );
};

호스트 등록 페이지

실제 에어비앤비에서 호스트 등록하려면 몇십개의 input이 필요하고, 10페이지 이상이 연결되어있다.
짧은 기간에 끝내야하기때문에 UI가 좀 못생겨져서 아쉽지만 20개정도의 input을 1페이지로 아주 간략하게 구성해보았다.

Option 핸들러

//각각의 input과 버튼마다 다른 이벤트핸들러를 전달하지않고, name과 value를 활용해서 한개의 함수로 통합했다
onChange = (e) => {
    const { value, name } = e.target;
    this.setState({ ...this.state, [name]: value }); //name 키를 가진 값을 value 로 설정
  };

  handlePlus = (e) => {
    e.preventDefault();
    const { name } = e.target;
    this.setState((current) => ({ ...this.state, [name]: current[name] + 1 }));
  };

  handleMinus = (e) => {
    e.preventDefault();
    const { name } = e.target;
    if (this.state[name] !== 0) {
      this.setState((current) => ({ ...this.state, [name]: current[name] - 1 }));
    }
  };
	
//input태그의 type이 checkbox이면 여러개 선택 가능하다.
//클릭하면 state에 담고, 다시 클릭하면 state에서 빼줘야하기때문에 filter메소드를 이용했다.
  handleMulti = (e) => {
    const { value } = e.target;
    let selectedTypes = [...this.state.features];
    if (selectedTypes.includes(value)) {
      selectedTypes = selectedTypes.filter((s) => s !== value);
    } else {
      selectedTypes = [...selectedTypes, value];
    }
    this.setState({ features: selectedTypes });
  };

이미지 업로드

콘솔에는 이미지가 배열로 잘 담긴걸 확인하고, 숙소데이터로 추가되서 리스트와 상세페이지에서 보여지는 걸 확인했지만.. 이미지가 빈값으로 들어온다고한다. 백엔드에서 수정을 해야하는 것 같다고는 하는데 프로젝트 기간이 끝나서 해결못한 부분이다

 onDrop = (images) => {
   //업로드한 사진을 state의 images 값으로 담는다. 
    this.setState({
      images: this.state.images.concat(images),
    });
  };

  onFetch = () => {
    const token = localStorage.getItem("access_token");
    fetch(`${API}/api/register/room`, {
      method: "POST",
      headers: {
        "Content-Type":
          "multipart/form-data; boundary=----WebKitFormBoundaryIn312MOjBWdkffIM",
        Authorization: token,
      },
      body: JSON.stringify({
        inputs: this.state,
      }),
    });
  };

  handleSubmit = (e) => {
    e.preventDefault();
    //빈 FormData객체를 생성
    const formData = new FormData();
    //state에 담아놓은 이미지파일들을 append 메소드로 키-값 형식으로 전부 추가한다
    for (let i = 0; i < this.state.images.length; i++) {
      formData.append("images", this.state.images[i]);
    }
    // 변환된 폼데이터로 대체하고, 전송한다
    this.setState({ images: formData }, this.onFetch);
  };

프로젝트 후 아쉬운점

리액트를 일주일 공부하고 연달아 짧은 기간의 프로젝트를 진행하였더니, 마음이 급해 기능 구현에만 급급했던 것 같다.
앞으로 코드를 짤 때 개선하고 싶은 점은

  • 성능 최적화 : useMemo, useCallback등 활용하여 불필요한 리렌더링을 줄이자.
  • 재사용가능한 컴포넌트 단위보다 페이지별로 생각하여 역할을 분배하여 개발을 시작한 것이다. 몇개 컴포넌트가 기능이 중복되어 나중에 통합하면서 수정하느라 시간을 소요했다.

0개의 댓글