개발 2일차...
오늘은 마지막 공통 컴포넌트인 모달을 만들고 메인 페이지 작업을 시작했다. 아직까지 개발은 어렵지 않은데, CSS... 알면 알수록 어렵고 신기하다.
먼저 개발 전에 디자인을 먼저 해 보고 개발을 진행하였다. 디자이너의 확인은 안 받긴 했지만... 이 정도면 괜찮잖아?

모바일용, PC용 디자인은 내부 요소들 사이 margin을 제외하고 거의 동일하다. 처음에 모달 내부 여백을 4rem(40px)로 설정하였는데 너무 넓다는 의견을 수렴하여 3rem(30px)으로 수정하였다.

이렇게 완성된 모달.
하지만 단 하나 문제는 모달이 열렸을 때 뒤에 있는 화면이 위 사진처럼 스크롤 된다는 것이다. 모달이 열리면 모달 뒤에 존재하는 화면의 스크롤은 방지하기 위해 시도를 해 보았다.
body의 overflow 막기
모달 뒤에서 우리가 스크롤을 방지해야 하는 요소는 <body> 요소이므로, DOM의 <body> 요소에 접근하여 overflow-y: hidden으로 설정해주면 어떨까? 대신 overflow를 막기 위해서 position을 지정해주어야 했고 다음과 같이 코드를 작성하였다.
if (isOpen) {
document.body.setAttribute(
"style",
"position: fixed; overflow-y: hidden; width: 100vw;"
);
} else {
document.body.setAttribute(
"style",
"position: unset; overflow-y: unset; width: unset;"
);
}
하지만 이 방법을 시도하면 뒤쪽 스크롤을 방지할 수는 있으나
position: fixed로 인해 body가 top: 0으로 이동해버려서 화면에서 확인하던 컨텐츠가 변경되는 문제가 발생했다.

결론적으로 window 객체의 scrollY 값을 만져야 하는데, 이 작업은 생각보다 시간이 소요되고 세밀한 작업을 필요로 하여서 여기에 시간을 투자하기 보다는 추후에 리팩토링 시에 해결해 보려고 한다.
어떤 컨텐츠가 들어와도 사용이 가능한 모달로 제작하였고 그에 따른 구현 과정과 사용법을 소개하려고 한다.
먼저 모달이 열렸는지 여부, 모달 내부에 들어있는 컨텐츠 등에 대한 정보를 전역에서 관리하기 위해 recoil의 atoms에 modalState라는 전역 상태를 만들어 관리하였다. 또한 다른 페이지에서 모달을 사용하는 동료들이 정확한 값을 입력해서 모달을 불러올 수 있도록 타입을 지정해주었다.
interface ModalProps {
isOpen: boolean; // 모달 open/close 여부
closeButton?: boolean; // 모달 우측 상단 닫기 버튼 유무
title?: string; // 모달 title
content?: string; // 모달 contents
buttonNum?: 0 | 1 | 2; // 모달 내부 버튼의 개수
handleConfirm?: () => void; // 확인 버튼 클릭 시 실행할 콜백함수
handleCancel?: () => void; // (버튼 2개일 때) 취소 버튼 클릭시 실행할 콜백함수
}
export const modalState = atom<ModalProps>({
key: "modalState",
default: {
isOpen: false,
closeButton: true,
title: "",
content: "",
buttonNum: 1,
handleConfirm: () => {},
handleCancel: () => {},
},
});
모달 컴포넌트의 코드는 다음과 같다. 자유로운 줄바꿈을 위해 컨텐트를 작성할 때는 <pre> 태그를 사용했다.
<pre className="modal-content">{content}</pre>
import { modalState } from "@/atoms/atoms";
import { useRecoilValue, useSetRecoilState } from "recoil";
import Button from "../Button/Button";
import "./Modal.scss";
const Modal = () => {
const {
isOpen,
closeButton = "true",
title,
content,
buttonNum,
handleConfirm,
handleCancel,
} = useRecoilValue(modalState);
const setModal = useSetRecoilState(modalState);
// 모달 닫기
const handleClose = () => {
setModal({
isOpen: false,
});
};
return (
<>
{isOpen && (
<div className="modal-layout">
<div className="modal-window">
{closeButton && (
<button className="modal-close" onClick={handleClose}>
<img src="img/icon-close-black.svg" alt="닫기" />
<i className="hidden">모달 닫기</i>
</button>
)}
<h3 className="modal-title">{title}</h3>
<pre className="modal-content">{content}</pre>
<>
{buttonNum && (
<div className="modal-button-container">
{buttonNum > 1 && (
<Button
size="full"
bgColor="light"
onClick={() => {
if (handleCancel) {
handleCancel();
}
handleClose();
}}
>
취소
</Button>
)}
<Button
size="full"
onClick={() => {
if (handleConfirm) {
handleConfirm();
}
handleClose();
}}
>
확인
</Button>
</div>
)}
</>
</div>
</div>
)}
</>
);
};
export default Modal;
모달 사용법
모달이 필요한 곳에서 atoms에 저장된 modalState를 불러오고, useSetRecoilState 함수를 호출한다. recoil을 사용해야 하므로 클라이언트 컴포넌트에서 호출해야 할 것이다.
const setModal = useSetRecoilState(modalState);
modalState를 섧정하는 setModal 함수를 호출한다.
setModal({
isOpen: true,
title: "로그인 필요",
content:
"포토티켓을 꾸미려면 로그인이 필요합니다.\n로그인 페이지로 이동하시겠습니까?",
buttonNum: 2,
handleConfirm: () => {
router.push("/login");
},
handleCancel: () => {},
});
이렇게 공통 컴포넌트 작업이 마무리되었고, 메인 화면 작업을 시작하였다.
우리가 사용할 예정인 아마데우스 api는 모든 데이터가 영어로 넘어오고, 우리가 필요한 정보들이 몇 개 빠져있어서, 우리는 사이트 운영을 위해 필요한 필수 데이터들을 "코드" 형태로 만들어 미리 DB에 저장해두고, 사이트에 처음 접속할 시 api와 통신하여 전역에 코드를 저장, 필요한 곳에서 불러 사용하려고 했었다.
우리가 필요한 코드들은 다음과 같다.
"ICN": {
code: "ICN",
value: "서울/인천",
nameKor: "인천국제공항",
nameEng: "Incheon International Airport",
cityCode: "SEL",
countryCode: "KR",
areaCode: "대한민국",
img: `/files/${clientId}/seoul.webp`,
},"KE": {
code: "KE",
value: "대한항공",
nameKor: "대한항공",
nameEng: "Korean Air",
allianceKor: "스카이팀",
allianceEng: "Skyteam",
carrierType: "FSC",
},"333": {
code: "333",
value: "A330-300",
nameKor: "에어버스 A330-300",
nameEng: "Airbus A330-300",
manufacturerKor: "에어버스",
manufacturerEng: "Airbus",
specKor: "광동체",
specEng: "Wide Body Aircraft",
},그런데 문제는 api 통신 후 받아온 이 코드들을 전역 상태로 관리하기 위해서는 recoil을 이용해야 하는데, 그러면 첫 페이지 또는 루트 레이아웃부터 "use client" 키워드를 이용해 클라이언트 컴포넌트로 만들어야하고 클라이언트 컴포넌트 하위의 모든 요소는 클라이언트 컴포넌트가 되므로 Next.js를 사용하는 의미가 없어진다는 것이었다.
그래서 생각한 방법이 루트 레이아웃 내부에 전역 상태에 코드를 저장하는 용도의 클라이언트 컴포넌트를 하나 만들고 여기서 코드를 저장하는 것이었다. 그런데 이 방법을 사용하다보니 클라이언트 컴포넌트가 렌더링되고 코드 데이터가 저장하는 데까지 시간이 너무 소요된다는 문제가 발생했다. 서버 컴포넌트에서 바로 api 통신 후 코드를 받아오면 시간이 훨씬 단축되는 데 말이다.
결론적으로 코드가 필요한 곳에서 api 통신을 통해 코드를 받아오는 것이 훨씬 효율적이라고 판단하여 api 통신 후 코드를 받아오는 함수를 따로 만들어 여러 페이지에서 불러와 사용하기로 하였다. 그렇게 탄생한 fetchCodes.ts 함수이다.
import { ApiRes, CodeData, CodeState, SingleItem } from "@/types";
const SERVER = process.env.NEXT_PUBLIC_MARKET_API_SERVER;
const CLIENT_ID = process.env.NEXT_PUBLIC_MARKET_API_CLIENT_ID;
export const fetchCodes = async (): Promise<CodeState> => {
const url = `${SERVER}/codes`;
const res = await fetch(url, {
headers: {
"client-id": CLIENT_ID,
},
});
const resJson: ApiRes<SingleItem<CodeData>> = await res.json();
if (!resJson.ok) {
throw new Error("코드 조회 실패!");
}
return resJson.item.flatten;
};
이후 이어진 본격적인 메인 페이지 개발.

이렇게 만드는게 목표인데, 검색창 뒤에 위치한 배경 이미지와 배너를 화면 양쪽에 꽉 차게 만들어야하는데 개발 전에 전체적인 레이아웃을 잡아 둔 덕분에 아무리 width를 조절해도 레이이아웃에 갇히는 문제가 생겼다.
이를 CSS로 해결해야하는데, 생각보다 잘 안 되어서 CSS 전문가님께 SOS를 요청해 다음과 같은 스타일 지정을 통해 해결할 수 있게 되었다. CSS는 어려워,,,
.full-width {
min-width: 320px;
width: 100vw;
position: relative;
left: 50%;
transform: translateX(-50%);
}
이렇게 스타일을 잡아두고 현재 배너 작업 진행 중에 있다. 이로써 개발 2일차 마무리. 다음 주까지 검색 기능 및 메인 화면을 마무리하려먼 갈 길이 바쁘다. 화이팅,,,