Header는 로그인하거나 가입할 때, 숨겨지기 때문에 따로 Header와 함께 Route를 같이 렌더링해주는 컴포넌트가 필요하다.
// Layout.tsx
...
// 내용을 Header와 띄우고, 좌우 여백을 같은 constraint로 지정
const Content = styled.main`
margin: 0 auto;
margin-top: 45px;
max-width: 930px;
width: 100%;
`;
function Layout({ children }: Props) {
return (
<>
<Header />
<Content>{children}</Content>
</>
);
}
Layout 컴포넌트로 내려주는 children은 모든 내용이 들어가는 Home 컴포넌트다. 이런 식으로 Layout 컴포넌트의 자식으로 다른 컴포넌트를 내려주면 Header와 함께 내용이 깔끔하게 렌더링되는 것을 볼 수 있다.
// App.tsx
...
return(
...
{isLoggedIn ? (
<Layout>
<Home />
</Layout>
) : (
<Login />
)}
...
현재 로그인 방식은 token이 localStorage에 저장되어있으면, 유효성과 상관없이 정상적으로 로그인한 것으로 판단한다. 서버 요청을 통해, token이 서버가 발급한 것이 맞는지 확인할 필요가 있다. token을 확인하는 함수는 Header 이외에 다른 곳에서 사용할 수도 있으므로, hook으로 따로 만들어준다.
query 요청은 useQuery
를 사용한다. skip
안에 조건을 넣으면, 조건에 해당되면 요청을 보내지 않는다.
그럼, token이 있을 때만 useUser로 서버에 요청을 보내게된다. useEffect
로 useUser의 응답 데이터가 변할 때 응답이 null
이면 강제로 로그아웃 시킨다.
// useUser.ts
function useUser() {
const hasToken = useReactiveVar(isLoggedInVar);
const { data, error } = useQuery(ME_QUERY, {
skip: !hasToken,
});
useEffect(() => {
if (data?.me === null) {
logUserOut();
}
}, [data]);
return { data };
}
이제 token의 유효성을 확인할 수 있지만, 아직 요청에 token을 추가하지 않았기 때문에 제대로 작동하지 않는다. 클라이언트의 모든 요청에 어떤 항목을 추가하고 싶다면 setContext
를 사용해야한다.
그 전에, link
를 추가해야하는데, link
란 Apollo Client가 데이터 소스와 소통하는 방식을 의미한다. Http 요청은 httpLink
, 웹소켓 요청은 webSocketLink
, authorization 요청은 authLink
를 사용한다. 다음과 같이 세팅해주자.
// apollo.ts
const httpLink = createHttpLink({
uri: "http://localhost:4000/graphql",
});
// setContext
// 1번째 인자 : function (GQL req) - 필요 x
// 2번째 인자 : prevContext - 예전 headers 항목에 새로운 headers 추가
const authLink = setContext((_, { headers }) => {
return {
headers: {
...headers,
// localStorage의 token을 headers에 저장
token: localStorage.getItem(TOKEN),
},
};
});
export const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
});
백엔드에서 http 요청이 들어오면, 우선적으로 token을 확인하여 로그인 유저의 정보를 가져오는데 여기서 token이 유효하지 않으면 null
을 리턴한다. token이 유효하면, useUser의 query 요청을 정상적으로 수행한 후 유저 정보를 리턴해준다.
// server.ts
...
const apollo = new ApolloServer({
typeDefs,
resolvers,
// 로그인 유저의 정보에 접근 가능
context: async (ctx) => {
// http 요청인 경우
if (ctx.req) {
return {
loggedInUser: await getUser(ctx.req.headers.token),
client,
};
...
// users.utils.ts
export const getUser = async (token) => {
try {
if (!token) {
return null;
}
// find user with id inside token
const verifiedToken: any = await jwt.verify(token, process.env.SECRET_KEY);
if ("id" in verifiedToken) {
const user = await client.user.findUnique({
where: { id: verifiedToken["id"] },
});
if (user) {
return user;
}
}
// token이 유효하지 않으면 null 리턴
return null;
...
// me.resolvers.ts
...
Query: {
me: protectedResolver((_, __, { loggedInUser }) =>
// userUser 요청 성공 시, id와 일치하는 user 리턴
client.user.findUnique({ where: { id: loggedInUser.id } })
...
이제 token의 유효성 여부에 따라, 로그인 / 로그아웃 처리가 자동적으로 이루어지는 것을 확인할 수 있다.
로그아웃
로그인 성공
Header의 user 아이콘을 useUser로 가져온 avatar로 변경한다. Avatar 컴포넌트를 만든 후, props로 url을 내려주는데 없다면 빈 문자열을 할당해준다.
또, Avatar의 사이즈를 변경해주기 위해, lg라는 boolean 형태의 props를 내려주었다. lg의 유무에 따라서 Avatar의 크기가 변한다. lg가 없다면, false를 할당해준다.
// Avatar.tsx
interface Props {
url: string | undefined | null;
lg?: boolean;
}
interface SAvatar {
lg: boolean;
}
const SAvatar = styled.div<SAvatar>`
width: ${(props) => (props.lg ? "30px" : "25px")};
height: ${(props) => (props.lg ? "30px" : "25px")};
...
`;
function Avatar({ url = "", lg = false }) {
return <SAvatar lg={lg}>{url !== "" ? <Img src={url} /> : null}</SAvatar>;
}
Header에서 useUser로 가져온 유저 데이터 중 avatar을 Avatar 컴포넌트의 props로 내려준다.
// Header.tsx
...
const { data } = useUser();
return (
...
<Icon>
<Avatar url={data?.me?.avatar} />
</Icon>
...
seeFeed
query로 피드에 필요한 데이터들을 가져와서 Home 컴포넌트에 적절히 렌더시킨다.
// Home.tsx
...
const FEED_QUERY = gql`
query seeFeed {
seeFeed {
id
user {
username
avatar
}
file
caption
likes
comments
createdAt
isMine
}
}
`;
...
function Home() {
const { data } = useQuery<seeFeed>(FEED_QUERY);
return (
<div>
{data?.seeFeed?.map((photo) => (
<PhotoContainer key={photo?.id}>
<PhotoHeader>
<Avatar url={photo?.user?.avatar} />
<Username>{photo?.user?.username}</Username>
</PhotoHeader>
</PhotoContainer>
))}
</div>
...
Feed는 다음과 같이 UI를 구성하였다.
로그인 유저가 사진에 좋아요를 눌렀는지 확인할 수 있는 방법이 없다. 백엔드의 photos resolvers에 추가해야한다.
// photo.typeDefs.ts
...
type Photo {
...
isLiked : Boolean!
}
...
// photo.resolvers.ts
...
photo : {
...
// photo의 id와 로그인 유저의 id를 가져옴
isLiked: async ({ id }, _, { loggedInUser }) => {
// 비로그인이면
if (!loggedInUser) {
// 로그인이 되지 않았으므로 좋아요 X
return false;
}
// photoId = id 이고 userId = 로그인 유저 id인 like를 찾음
const ok = await client.like.findUnique({
where: { photoId_userId: { photoId: id, userId: loggedInUser.id } },
// 존재만 찾기 때문에 id만 뽑아옴
select: { id: true },
});
// 있으면
if (ok) {
// 좋아요 했음
return true;
}
// 없으면, 좋아요 안했음
return false;
},
...
이제 Photo의 typeDefs에 isLiked가 추가되었기 때문에, seeFeed
query에서 가져올 수 있게 되었다.
isLiked의 상태에 따라 다른 아이콘을 띄워준다. 참고로, icon은 svg 태그로 사이즈 변경이 가능하다.
// Home.tsx
...
<FontAwesomeIcon
style={{ color: photo?.isLiked ? "tomato" : "inherit" }}
icon={photo?.isLiked ? SolidHeart : faHeart}
/>
Like를 이미 눌렀으면 unlike, 안눌렀으면 like가 되는 toggleLike mutation을 적어준다.
// Photo.tsx
...
const TOGGLE_LIKE_MUTATION = gql`
mutation toggleLike($id: Int!) {
toggleLike(id: $id) {
ok
error
}
...
mutation을 useMutatoin hook으로 요청한다. 아이콘을 클릭했을 때, mutation function을 실행한다.
// Photo.tsx
function Photo({ id, user, likes, isLiked, file }: Props) {
const [toggleLikeMutation, { loading }] = useMutation(TOGGLE_LIKE_MUTATION,
{variables: { id },
});
return (
...
<PhotoAction onClick={() => toggleLikeMutation()}>
...
Mutation을 실행한 후, 백엔드에 에러가 없으면 query를 다시 호출한다. 이 방법으로 새로고침을 하지 않고 isLiked의 변경된 상태를 프론트엔드에 적용시킬 수 있다. 하지만, 이 방법은 Apollo Cache를 업데이트하긴 하지만 직접적으로 변경시키는 방법이 아니다.
이 방법은 Apollo가 data를 fetch한 후 바뀐 부분만 업데이트하지만, query 전체를 refetch하기 때문에 좋은 방법은 아니다. query가 크고 실시간 처리가 필요한 곳이면 적합하지 않다. 반대로, query가 작거나 prototype으로 금방 구현해야하는 상황에는 적합하다.
// Photo.tsx
...
function Photo({ id, user, likes, isLiked, file }: Props) {
const [toggleLikeMutation, { loading }] = useMutation(TOGGLE_LIKE_MUTATION, {
variables: { id },
// 원하는 만큼 어떤 query든지 refetch 가능 {query: query_name, variables: { ... }}
refetchQueries: [{ query: FEED_QUERY }], });
...
Refetch하지 않고 Apollo Cache에서 원하는 특정 object의 일부분을 수정할 수 있다. 다음과 같은 인자가 필요하다.
// photo.tsx
...
function Photo({ id, user, likes, isLiked, file }: Props) {
// data : 백엔드에서 준 데이터, cache : cache를 제어할 수 있는 link
const updateToggleLike: MutationUpdaterFn<toggleLike> = (cache, result) => {
const ok = result.data?.toggleLike.ok;
if (ok) {
// fragment AnyName on Photo{ target } : type이 Photo인 아무 이름의 fragment
cache.writeFragment({
id: `Photo:${id}`,
fragment: gql`
fragment AnyName on Photo {
isLiked
likes
}
`,
data: {
isLiked: !isLiked,
likes: cacheIsLiked ? cacheLikes - 1 : cacheLikes + 1,
}
...
Prop 없이도 cache에서 data를 만들어 사용할 수 있다. 만약 likes와 isLiked가 없더라도 아래와 같이 cache에서 data를 만든 후 writeFragment
로 작동하게 할 수 있다.
// photo.tsx
...
const ok = result.data?.toggleLike.ok;
if (ok) {
// fragmentId와 fragment를 생성
const fragmentId = `Photo:${id}`;
const fragment = gql`
fragment AnyName on Photo {
isLiked
likes
}
`;
// fragment를 읽어온 후(read) 쓰기(write)
const result: any = cache.readFragment({
id: fragmentId,
fragment,
});
// isLiked와 likes가 cache에 있으면
if ("isLiked" in result && "likes" in result) {
const { isLiked: cacheIsLiked, likes: cacheLikes } = result;
// cache를 이용하여, 값을 바꿔줌. props 없이 cache에서만 일어남
cache.writeFragment({
id: fragmentId,
fragment,
data: { ... }
...
댓글을 보여주기 위해, photo에 달린 총 comment의 개수와 comments가 필요하다. 이 두 가지는 백엔드에서 구현해서 가져오는 것이 좋다.
// photos.typeDefs.ts
...
type Photo {
...
commentNumber: Int!
comments: [Comment]
}
...
// photos.resolvers.ts
Photo : {
...
// photoId가 일치하는 모든 comment를 가져와서 개수를 구함
commentNumber: ({ id }) => client.comment.count({ where: { photoId: id } }),
// photoId가 일치하는 comment와 함께 user 정보까지 추가
comments: ({ id }) => client.comment.findMany({ where: { photoId: id }, include: { user: true } }), ...
이제 seeFeed에 다음 항목들을 추가한다.
// Home.tsx
...
const FEED_QUERY = gql`
...
comments { // comment
id // comment의 id
user { // comment 작성자
username // 작성자 이름
avatar // 작성자 사진
}
payload // 내용
isMine // 로그인 유저가 쓴 글인가?
createdAt // comment 만든 시간
}
commentNumber // 총 comment 개수
...
가져온 데이터들을 UI로 렌더시킨다. 컴포넌들을 최대한 작은 단위로 쪼개어 구성한다.
// Home.tsx
...
function Home() {
const { data } = useQuery<seeFeed>(FEED_QUERY);
return (
<div>
<PageTitle title="Home" />
{data?.seeFeed?.map((photo) => (
<Photo key={photo!.id} {...photo!} />
))}
...
// Photo.tsx
...
function Photo({ ... }: seeFeed_seeFeed) {
...
return (
...
<Likes>{likes === 1 ? "1 like" : `${likes} likes`}</Likes>
<Comments
author={user.username}
caption={caption}
commentNumber={commentNumber}
comments={comments}
/>
...
// comments.tsx
...
function Comments({ ... }: Props) {
return (
...
<Comment author={author} payload={caption} />
<CommentCount>
{commentNumber === 1 ? "1 comment" : `${commentNumber} comments`}
</CommentCount>
{comments?.map((comment) => (
<Comment
key={comment!.id}
author={comment!.user.username}
payload={comment!.payload}
/>
...
function Comment({ ... }: Props) {
...
return (
...
<FatText>{author}</FatText>
<CommentCaption>{payload}</CommentCaption>
...
Caption에 있는 Hashtag를 링크로 바꿔주기 위해서 정규표현식을 사용해야한다. 정규표현식 함수 중 Replace
를 사용한다. 문자열을 정규식과 일치하는 부분을 바꿔주는 함수인데, $&
패턴을 사용하면 변경된 문자열을 리턴해준다.
String.prototype.replace() - JavaScript | MDN
직접 HTML 태그를 렌더시키면 React가 텍스트로 변환하여 그대로 텍스트 그대로 띄워버린다. 유저가 직접적으로 코드를 삽입하지 못하도록 보호하는 것이다.
HTML 태그를 넣어주려면, dangerouslySetInnerHTML
이라는 props에 입력하면 된다. 하지만, 앞서 말했듯이 유저가 직접 코드를 삽입할 수도 있으므로 방지책이 필요하다.
sanitize-html이라는 라이브러리를 사용하여, HTML 태그 삽입을 제어할 수 있다. 먼저 내용을 한번 sanitizeHtml
로 검열한 후, 허용된 태그만 걸러서 렌더시킨다. 이 3가지 방법을 다음과 같이 사용할 수 있다.
// Comment.tsx
...
const cleanedPayload = sanitizeHtml(
payload!.replace(/#[ㄱ-ㅎ|ㅏ-ㅣ|가-힣|\w]+/g, "<mark>$&</mark>"),
{ allowedTags: ["mark"] }
);
return (
<CommentContainer>
<FatText>{author}</FatText>
<CommentCaption
dangerouslySetInnerHTML={{
__html: cleanedPayload,
}}
></CommentCaption>
</CommentContainer>
);
...
지금 필요한 것은 React 앱 안에서 Link
를 타고 다른 컴포넌트로 이동하는 것이기 때문에, 굳이 위와 같은 방법을 쓸 필요는 없다. JS Array 함수로 정규표현식을 만족하는 단어들만 Link
를 걸어, hashtag를 클릭 시 이동할 수 있게 만들자. 빈 태그 (Fragment)에 key를 부여하기 위해선, <>
가 아닌 <React.Fragment>
를 사용해야한다.
// Comment.tsx
...
return (
...
<CommentCaption>
{payload?.split(" ").map((word, index) =>
/#[ㄱ-ㅎ|ㅏ-ㅣ|가-힣|\w]+/g.test(word) ? (
<React.Fragment key={index}>
{" "}
<Link to={`/hashtags/${word}`}>{word}</Link>{" "}
</React.Fragment>
) : (
<React.Fragment key={index}>{word} </React.Fragment>
)
)}
</CommentCaption>
...
);
writeFragment
없이 훨씬 더 간결한 방법으로 Apollo cache의 데이터를 쓰는 것이 가능하다. cache.modify
는 다음과 같은 방법으로 사용 가능하다.
// Photo.tsx
...
function Photo({
...
likes,
isLiked,
}: seeFeed_seeFeed) {
const updateToggleLike: MutationUpdaterFn<toggleLike> = (cache, result) => {
const ok = result.data?.toggleLike.ok;
if (ok) {
const photoId = `Photo:${id}`;
// id와 수정하고 싶은 field
cache.modify({
id: photoId,
fields: {
// 수정할 값 대신, 이전 값을 쓸 수 있는 함수가 제공
isLiked(prev) {
// 새로운 값
return !prev;
},
likes(prev) {
if (isLiked) {
return prev - 1;
}
return prev + 1;
},
...
새로운 comment를 만드는 요청을 한다. comment를 DB에 저장시키고 다시 comment를 가져오는 방식이 아닌, Apollo cache를 이용하여 로컬에서 만든 comment를 바로 화면에 띄운다.
새로운 comment를 만들기 위해서 필요한 항목들은 모두 프론트에서 구할 수 있지만 유일하게 id는 백엔드에서 comment를 생성한 후 가져와야한다. 그래서, createComment의 응답으로 id를 가져올 수 있도록 한다.
// shared.ts
// 모든 Mutation에 대한 응답
type MutationResponse {
ok: Boolean!
error: String
id: Int // optional
}
// createComment.ts
...
createComment: protectedResolver(
async (_, { photoId, payload }, { loggedInUser }) => {
...
const newComment = await client.comment.create({
data: {
// comment의 내용
payload,
// photo의 id와 연결
photo: { connect: { id: photoId } },
// 로그인 유저의 id와 연결
user: { connect: { id: loggedInUser.id } },
},
});
// 요청 성공
// + id를 응답으로 보냄
return { ok: true, id: newComment.id };
}
...
이제 로컬에서 만든 comment가 바로 프론트로 반영될 수 있게 cache를 조작한다. 우선, cache에 올라간 comment와 똑같은 형태로 데이터를 만든다.
// comment.tsx
...
function Comments({ ... }: Props) {
// useUser hook으로 로그인 유저 데이터 가져오기
const { data: userData } = useUser();
const createCommentUpdate: MutationUpdaterFn<createComment> = (
cache,
result
) => {
// input 창에 있는 내용 저장
const { payload } = getValues();
// 저장 후 input 창 지우기
setValue("payload", "");
const { ok, id } = result!.data!.createComment;
// comment가 DB에 성공적으로 만들어지면 (ok), cache에 들어갈 데이터 조작
if (ok && userData.me) {
// comment와 같은 형태의 데이터
const newComment = {
__typename: "Comment",
createdAt: Date.now() + "", // 현재 시간
id, // mutation 응답으로 가져온 id
isMine: true, // 로그인 유저가 쓴 글
payload, // form에 적은 내용
user: { ...userData.me }, // 로그인 유저 정보
};
이 데이터로 writeFragment
를 사용하여 cache에 올릴 fragment를 만든다. 이 과정을 거치지 않고, 바로 위조된 comment 데이터를 Photo cache에 올려도 렌더링에 필요한 모든 데이터가 있기 때문에 프론트에선 제대로 작동하는 것처럼 보인다.
하지만, Apollo cache는 Photo와 Comment 간의 참조 관계(__refs)를 모르기 때문에 올라온 데이터를 삭제하는건 불가능하다. 진짜 백엔드와 통신하는 것처럼 cache를 똑같이 구현하기 위해선 아래와 같은 과정을 거쳐야한다.
// comment.tsx
...
const newCacheComment = cache.writeFragment({
// 새로운 fragment이기 때문에 id X
data: newComment,
// fragment를 GraphQL 형태로 나타내야함
fragment: gql`
fragment AnyName on Comment {
id
createdAt
isMine
payload
user {
username
avatar
}
}
`,
});
새로 만든 Comment fragment (newCacheComment)를 Photo에 추가한다. 백엔드에서 모든 comment 데이터를 가져오지 않아 리소스를 낭비하지 않고, 자체적으로 로컬에서 comment를 화면에 띄우기 때문에 훨씬 빠르다.
// comment.tsx
...
cache.modify({
id: `Photo:${photoId}`,
fields: {
// prev : 원래 cache에 있는 값
comments(prev) {
// 위조 comment로 덮어씀
return [...prev, newCacheComment];
},
// comment 개수 추가
commentNumber(prev) {
return prev + 1;
},
},
});
...
comment를 삭제하는 요청을 하기 위해서, comment의 id
가 필요하다. 또, 로그인 유저가 쓴 글만 삭제 버튼이 보이게 하기 위해서 isMine
이 필요하다. 마지막에, cache에서 comment가 달린 Photo가 무엇인지 알아야 comment를 삭제한 다음 comment의 개수에 1을 뺄 수 있기 때문에 photoId
도 필요하다. 이 세 가지를 Comment 컴포넌트로 내려준다.
// Comments.tsx
...
return(
...
{comments?.map((comment) => (
<Comment
...
id={comment.id}
photoId={photoId}
isMine={comment.isMine}
/>
))}
...
comment의 id
로 삭제 요청을 한다. Mutation 함수를 버튼에 이벤트로 걸어준다. 버튼을 클릭하면, 일단 백엔드에서는 정상적으로 해당 id
의 comment가 삭제된다.
// Comment.tsx
...
const DELETE_COMMENT_MUTATION = gql`
mutation deleteComment($id: Int!) {
deleteComment(id: $id) {
ok
}
}
`;
const [deleteCommentMutation] = useMutation(DELETE_COMMENT_MUTATION, {
variables: {
id,
},
});
const onDeleteClick = () => {
deleteCommentMutation();
};
return (
...
{isMine && <button onClick={onDeleteClick}>X</button>}
...
이제 삭제된 comment를 프론트에 반영한다. 이전과 마찬가지로, 백엔드에서 굳이 refetch를 하지 않고 cache에서 데이터를 지운 후 바로 프론트 화면에 띄워지도록 한다.
우선, update mutation 함수를 만든다. cache.evict
를 사용하면 매우 간단하게 cache에 저장된 데이터를 id
만으로 삭제 가능하다. comment를 삭제한 후, photoId
로 Photo cache의 commentNumber field 값을 1 줄인다. 이렇게 만든 update mutation을 useMutation
의 update
옵션에 넣어주면 프론트에서도 deleteComment가 바로 반영된다.
// Comment.tsx
...
function Comment({ id, photoId, isMine, ... }) {
const updateDeleteComment: MutationUpdaterFn<deleteComment> = (
cache,
result
) => {
const { ok } = result!.data!.deleteComment;
if (ok) {
cache.evict({ id: `Comment:${id}` });
cache.modify({
id: `Photo:${photoId}`,
fields: {
commentNumber(prev) {
return prev - 1;
},
},
});
...
const [deleteCommentMutation] = useMutation(DELETE_COMMENT_MUTATION, {
...
update: updateDeleteComment,
});