게시글을 작성하면서 업로드한 이미지는 전부 post-service의 upload파일에 저장이 되게끔 진행을 했습니다. 하지만 진행하면서 게시글 이미지를 불러오려고 시도를 해봤지만 client-app에서 벗어난 파일들은 불러올 수 없다고 결론을 내렸고, post-service에서 이미지 파일을 base64로 인코딩하여 이미지 엔티티에 담아 반환을 하도록 하겠습니다.
FileUploader 클래스를 FileUtil로 이름을 변경하도록 하겠습니다.
...
@Component
@Slf4j
public class FileUtil {
...
public static byte[] convertFileContentToBlob(String filePath) {
byte[] result = null;
try {
result = FileUtils.readFileToByteArray(new File(filePath));
} catch(IOException io) {
log.error("File Conver Error");
}
return result;
}
public static String convertBlobToBase64(byte[] blob) {
return new String(Base64.getEncoder().encode(blob));
}
public static String getFileContent(String filePath) {
return convertBlobToBase64(convertFileContentToBlob(filePath));
}
...
}
FileUtil클래스를 수정하고 이를 이용하여 PostServiceImpl에서 이미지 파일을 인코딩시킨 후 이미지 엔티티에 담도록 하겠습니다.
@Transactional
@Override
public List<PostDto> getAllPosts() {
log.info("Post Service's Service Layer :: Call getAllPosts Method!");
List<String> exceptList = new ArrayList<>();
exceptList.add("COMPLETE_RENTAL");
exceptList.add("DELETE_POST");
Iterable<PostEntity> posts = postRepository.findAllByStatusNotIn(exceptList);
List<PostDto> postList = new ArrayList<>();
posts.forEach(v -> {
List<CommentEntity> comments = new ArrayList<>();
List<ImageEntity> images = new ArrayList<>();
v.getImages().forEach(i -> {
String filePath = i.getFilePath();
i.setFilePath(FileUtil.getFileContent(filePath));
images.add(i);
});
v.getComments().forEach(i -> {
comments.add(CommentEntity.builder()
.id(i.getId())
.comment(i.getComment())
.writer(i.getWriter())
.createdAt(i.getCreatedAt() .build());
});
postList.add(PostDto.builder()
.userId(v.getUserId())
.postType(v.getPostType())
.category(v.getCategory())
.rentalPrice(v.getRentalPrice())
.title(v.getTitle())
.content(v.getContent())
.startDate(v.getStartDate())
.endDate(v.getEndDate())
.createdAt(v.getCreatedAt())
.writer(v.getWriter())
.images(images)
.comments(comments)
.status(v.getStatus())
.build());
});
return postList;
}
v.getImages().forEach(i -> {
String filePath = i.getFilePath();
i.setFilePath(FileUtil.getFileContent(filePath));
images.add(i);
});
이미지를 인코딩하는 코드입니다. i는 이미지 배열 중 하나의 요소로 i에 저장되어있는 파일 경로값을 읽어와 FileUtil의 getFileContent메서드로 Base64형태로 인코딩한 후 스트링 형태로 filePath값에 저장한 후 이미지 배열에 담아주는 코드입니다.
2021-09-17 11:00:38.313 WARN [post-service,1bfef81c3075e003,1bfef81c3075e003] 35167 --- [nio-7100-exec-1] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 1406, SQLState: 22001
2021-09-17 11:00:38.313 ERROR [post-service,1bfef81c3075e003,1bfef81c3075e003] 35167 --- [nio-7100-exec-1] o.h.engine.jdbc.spi.SqlExceptionHelper : (conn=110) Data too long for column 'file_path' at row 1
위의 에러가 나타나면 컬럼의 길이가 작다는 의미이므로 테이블의 속성을 다음과 같이 변경할 수 있습니다. ALTER TABLE images MODIFY file_path LONGTEXT;
하지만 위와 같은 에러는 jpa에서 자동으로 데이터가 변경되면 업데이트해주는 기능 때문에 나타나는 에러입니다. 이미지 인코딩을 위해 Service레이어에서 setFilePath메서드를 사용했는데 이 과정에서 데이터가 변경되므로 자동으로 jpa에서 update sql을 이용하여 데이터베이스에 저장되어있는 이미지 데이터의 기본 경로 자체를 변경시켜버립니다. 따라서 이 자동업데이트 방지를 위해 ImageEntity의 filePath에 다음의 옵션을 주도록 하겠습니다. @Column(nullable = false, updatable = false) private String filePath;
자동업데이트 방지를 했으니 React 프로젝트의 PostCard의 src경로를 다음과 같이 정해주도록 하겠습니다.
import React from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import palette from '../../lib/styles/palettes';
import no_image from '../../static/no-image.png';
const PostCardBlock = styled.div`
background-color: white;
width: 400px;
height: 300px;
margin: 20px;
box-shadow: 0px 0px 2px 1px rgba(0, 0, 0, 0.1);
border-radius: 10px;
`;
const CardTitle = styled.div`
float: left;
width: 400px;
height: 50px;
overflow: hidden;
text-align: left;
padding-top: 10px;
padding-left: 10px;
`;
const CardImage = styled.img`
float: left;
width: 400px;
height: 200px;
`;
const CardNickname = styled.div`
float: left;
width: 300px;
text-align: left;
padding-left: 10px;
`;
const CardDate = styled.div`
float: left;
width: 90px;
color: ${ palette.gray[6] }
`;
const PostCard = ({ item, i }) => {
return(
<Link to={ `/posts/post/${item.postId}` }>
<PostCardBlock>
<CardImage src={
item.postType === '빌려줄게요' ?
"data:image/png;base64," + item.images[0].filePath :
no_image
} />
<CardTitle>
{ item.title }
</CardTitle>
<CardNickname>
{ item.writer }
</CardNickname>
<CardDate>
{ item.createdAt }
</CardDate>
</PostCardBlock>
</Link>
);
};
export default PostCard;
빌려줄게요의 경우 이미지 파일을 의무적으로 업로드해야하지만 빌려주세요는 이미지 파일을 업로드하지 않습니다. 따라서 빌려주세요의 경우 no-image파일을 가져와 대체를 해주었습니다.
코드가 완성되었으니 웹페이지에서 결과가 잘 나오는지 확인을 해보도록 하겠습니다.
정상적으로 출력이 되는 모습을 볼 수 있습니다. 그러면 게시판 페이지에서 데이터가 로딩되는 컴포넌트를 구현하도록 하겠습니다. useEffect를 통해 데이터를 불러오는 과정 중에 단시간동안 postList state의 값이 null인 상태인 경우 에러가 나타날 수 있으니 그 시간동안은 로딩 페이지로 대체하도록 하겠습니다.
다음의 패키지를 설치하도록 하겠습니다.
npm install @material-ui/core
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import CircularProgress from '@material-ui/core/CircularProgress';
const useStyles = makeStyles((theme) => ({
root: {
display: 'flex',
'& > * + *' : {
marginLeft: theme.spacing(2),
}
}
}));
const PostLoading = () => {
const classes = useStyles();
return(
<div className={ classes.root }>
<CircularProgress />
</div>
);
};
export default PostLoading;
이 컴포넌트를 PostListTemplate 컴포넌트에 적용하도록 하겠습니다.
...
const PostListTemplate = ({ history }) => {
...
return(
<>
<PostHeader />
<PostListTemplateBlock>
{
postList !== null ?
postList.map((item, i) => {
return <PostCard item={ item }
i={ i }
/>
}
) : <PostLoading />
}
</PostListTemplateBlock>
</>
);
};
export default withRouter(PostListTemplate);
postList state가 null인 경우 PostLoading 컴포넌트로 대체하도록 하였습니다.
위의 사진처럼 로딩이 되는 모습의 컴포넌트입니다.
이어서 상세페이지를 작업하도록 하겠습니다.
이전의 코드들 중 postId를 id로 수정하도록 하겠습니다.
...
const PostCard = ({ item, i }) => {
return(
<Link to={ `/posts/post/${ item.id }` }>
...
</Link>
);
};
export default withRouter(PostCard);
...
const App = () => {
return(
<>
...
<Route
component={ PostDetailPage }
path="/posts/post/:id"
/>
</>
);
};
export default App;
...
const PostViewerContainer = ({ match }) => {
const { id } = match.params;
...
useEffect(() => {
dispatch(readPost(id));
return() => {
dispatch(unloadPost());
dispatch(initialize());
};
}, [dispatch, id]);
...
};
export default withRouter(PostViewerContainer);
그리고 PostViewer에서 사용했던 더미 데이터를 지우고, post데이터를 받았으니 post데이터에 존재하는 이미지 데이터를 이미지 슬라이더에 넣어주도록 하겠습니다.
...
const PostViewer = ({
post,
error,
loading
}) => {
if(error) {
if(error.response && error.response.status === 404) {
return <PostViewerBlock>존재하지 않는 포스트입니다.</PostViewerBlock>
}
return <PostViewerBlock>오류 발생!</PostViewerBlock>
}
if(loading || !post) {
return null;
}
return(
<PostViewerBlock>
...
<PostArticle>
<ImageSlider Images={ post.images }/>
</PostArticle>
...
);
};
export default PostViewer;
마지막으로 ImageCard의 이미지 경로를 수정하도록 하겠습니다.
import React from 'react';
import styled from 'styled-components';
const ImageBlock = styled.div`
width: 100%;
`;
const Image = styled.img`
`;
const ImageCard = ({ image, i }) => {
return(
<ImageBlock>
<Image src={ "data:image/png;base64," + image.filePath }
alt="legend"
/>
</ImageBlock>
);
};
export default ImageCard;
댓글에 관한 컴포넌트를 제외한 PostViewer코드입니다.
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';
import FullButton from '../common/FullButton';
import Shortcut from '../common/Shortcut';
import paper_plane_outline from '../../static/img/paper-plane-outline.svg';
import CommentContainer from '../comment/CommentContainer';
import ImageSlider from '../common/ImageSlider';
const PostViewerBlock = styled.div`
padding-top: 200px;
margin-left: 10rem;
margin-right: 10rem;
`;
const PostHead = styled.div`
border-bottom: 1px solid ${ palette.gray[2] };
padding-bottom: 3rem;
margin-bottom: 3rem;
h1 {
font-size: 3rem;
line-height: 1.5;
margin:0;
}
`;
const SubInfo = styled.div`
width: 80%;
margin-top: 1rem;
color: ${ palette.gray[6] };
span + span:before {
color: ${ palette.gray[5] };
padding-left: 0.25rem;
padding-right: 0.25rem;
content: '\\B7';
}
`;
const MessageArea = styled.div`
float: left;
display: flex;
margin-right: 50px;
align-items: center;
`;
const RentalArea = styled.div`
float: left;
display: flex;
align-items: center;
`;
const PostArticle = styled.div`
display: flex;
justify-content: center;
align-item: center;
width: 100%;
border-bottom: 1px solid ${ palette.gray[2] };
`;
const PostContent = styled.div`
font-size: 1.3125rem;
padding: 15px;
color: ${ palette.gray[8] };
`;
const PostNav = styled.div`
display: flex;
justify-content: flex-end;
width: 100%;
border-bottom: 1px solid ${ palette.gray[2] };
`;
const RentalButton = styled(FullButton)`
width: 100px;
&:hover {
width: 100px;
}
`;
const StyledShorcut = styled(Shortcut)`
color: ${palette.blue[4]};
`;
const PostViewer = ({
post,
error,
loading
}) => {
if(error) {
if(error.response && error.response.status === 404) {
return <PostViewerBlock>존재하지 않는 포스트입니다.</PostViewerBlock>
}
return <PostViewerBlock>오류 발생!</PostViewerBlock>
}
if(loading || !post) {
return null;
}
return(
<PostViewerBlock>
<PostHead>
<h1>{ post.title }</h1>
<SubInfo>
<span>
<b>{ post.writer }</b>
</span>
<span>
{ post.createdAt }
</span>
</SubInfo>
</PostHead>
<PostArticle>
<ImageSlider Images={ post.images }/>
</PostArticle>
<PostContent
dangerouslySetInnerHTML={{ __html: post.content }}
/>
{
post.postType === '빌려줄게요' &&
<PostNav>
<MessageArea>
<StyledShorcut path='/messages'
src={ paper_plane_outline }
/>
</MessageArea>
<RentalArea>
<Link to={{
pathname: '/rentals',
state: { post: post }
}}
>
<RentalButton>
빌리기
</RentalButton>
</Link>
</RentalArea>
</PostNav>
}
<CommentContainer comments={ post.comments }/>
</PostViewerBlock>
);
};
export default PostViewer;
화면이 잘 나오는지 확인해보도록 하겠습니다.
!
이어서 댓글을 구현하도록 하겠습니다. 댓글의 경우 이전에 작성해둔 코드에서 수정할 부분이 있어서 코드를 중점으로 수정을 진행하겠습니다.
댓글리스트의 경우 post 데이터를 불러오면 동시에 불러올 수 있도록 만들었습니다. 그러면 CommentList 컴포넌트에 넣어준 데이터를 사용자에게 보여주고, 댓글을 쓰는 작업을 할 수 있도록 구현하겠습니다.
import React from 'react';
import styled from 'styled-components';
import CommentList from './CommentList';
import WriteContainer from './WriteContainer';
const CommentBlock = styled.div`
margin-top: 3rem;
min-height: 200px;
`;
const CommentContainer = ({ comments }) => {
return(
<CommentBlock>
<WriteContainer />
<CommentList comments={ comments }/>
</CommentBlock>
);
};
export default CommentContainer;
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import CommentItem from './CommentItem';
const ListBlock = styled.div`
width: 100%;
display: flex;
flex-direction: column;
float: left;
`;
const CommentList = ({ comments }) => {
const dispatch = useDispatch();
const { success } = useSelector(({ writeComment }) => ({ success: writeComment.success }));
useEffect(() => {
if(success) {
window.location.reload();
}
}, [dispatch, success]);
return(
<ListBlock>
{
comments !== null &&
comments.map((item, i) => {
return (
<CommentItem
item={ item }
i={ i }
/>
)
})
}
</ListBlock>
);
};
export default CommentList;
import React from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';
const ItemBlock = styled.div`
width: 100%;
border-bottom: 1px solid ${ palette.gray[2] };
margin-bottom: 1rem;
padding-bottom: 0.5rem;
`;
const Header = styled.div`
float: left;
width: 100%;
display: flex;
align-items: center;
`;
const WriterBlock = styled.div`
font-size: 1.3rem;
font-weight: bold;
`;
const CreatedAtBlock = styled.div`
font-size: 0.8rem;
margin-left: 15px;
color: ${ palette.gray[6] }
`;
const Content = styled.div`
float: left;
margin-top: 1.5rem;
margin-bottom: 0.8125rem;
`;
const CommentItem = ({item, i}) => {
return(
<ItemBlock>
<Header>
<WriterBlock>
{ item.writer }
</WriterBlock>
<CreatedAtBlock>
{ item.createdAt }
</CreatedAtBlock>
</Header>
<Content>
{ item.comment }
</Content>
</ItemBlock>
);
};
export default CommentItem;
댓글을 작성하면 success의 state값이 업데이트됩니다. 따라서 이 success의 값이 업데이트되면 페이지를 새로고침하여 댓글리스트를 최신화시키도록 하겠습니다.
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';
import { write, changeField } from '../../modules/writeComment';
const ButtonBlock = styled.div`
width: 60px;
float: left;
`;
const Button = styled.button`
width: 60px;
height: 40px;
border-radius: 4px;
background-color: ${ palette.blue[1] };
color: #ffffff;
outline: none;
border: none;
&: hover {
width: 60px;
height: 40px;
border-radius: 4px;
background-color: ${ palette.blue[2] };
color: #ffffff;
outline: none;
border: none;
}
`;
const WriteButton = () => {
const dispatch = useDispatch();
const {
comment,
postId,
writer,
} = useSelector(({
writeComment,
post,
user,
}) => ({
comment: writeComment.comment,
postId: post.post.id,
writer: user.user.nickname,
}));
useEffect(() => {
dispatch(changeField({
key: 'postId',
value: postId
}));
}, [dispatch, postId]);
useEffect(() => {
dispatch(changeField({
key: 'writer',
value: writer
}));
}, [dispatch, writer]);
const onSubmit = e => {
e.preventDefault();
dispatch(write({ postId, comment, writer }));
};
return (
<ButtonBlock>
<Button onClick={ onSubmit }>
댓글 달기
</Button>
</ButtonBlock>
);
};
export default WriteButton;
댓글 작성을 위해 comment state값을 불러오는 코드입니다. writer는 현재 로그인한 user의 nickname값이고, postId는 현재 댓글을 작성하기 위한 포스트의 id값입니다. 댓글 작성이 잘 되는지 테스트를 해보겠습니다.
다음 포스트에서는 message-service를 연동해보도록 하겠습니다.