Rental Application (React & Spring boot Microservice) - 32 : 게시글(5)

yellow_note·2021년 9월 19일
0
  • 이미지 로드 문제

게시글을 작성하면서 업로드한 이미지는 전부 post-service의 upload파일에 저장이 되게끔 진행을 했습니다. 하지만 진행하면서 게시글 이미지를 불러오려고 시도를 해봤지만 client-app에서 벗어난 파일들은 불러올 수 없다고 결론을 내렸고, post-service에서 이미지 파일을 base64로 인코딩하여 이미지 엔티티에 담아 반환을 하도록 하겠습니다.

FileUploader 클래스를 FileUtil로 이름을 변경하도록 하겠습니다.

  • 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에서 이미지 파일을 인코딩시킨 후 이미지 엔티티에 담도록 하겠습니다.

  • PostServiceImpl - getAllPosts
@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값에 저장한 후 이미지 배열에 담아주는 코드입니다.

  • JPA 에러
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경로를 다음과 같이 정해주도록 하겠습니다.

  • ./src/components/posts/PostCard.js
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
  • ./src/components/posts/common/PostLoading.js
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 컴포넌트에 적용하도록 하겠습니다.

  • ./src/components/posts/PostListTemplate.js
...

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 컴포넌트로 대체하도록 하였습니다.

위의 사진처럼 로딩이 되는 모습의 컴포넌트입니다.

이어서 상세페이지를 작업하도록 하겠습니다.

#1 상세페이지

이전의 코드들 중 postId를 id로 수정하도록 하겠습니다.

  • ./src/components/posts/PostCard.js
...

const PostCard = ({ item, i }) => {
    return(
        <Link to={ `/posts/post/${ item.id }` }>
            ...
        </Link>
    );
};

export default withRouter(PostCard);
  • ./src/App.js
...

const App = () => {
    return(
        <>
            ...
            <Route 
                component={ PostDetailPage }
                path="/posts/post/:id"
            />
        </>
    );
};

export default App;
  • ./src/components/posts/PostContainer.js
...

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데이터에 존재하는 이미지 데이터를 이미지 슬라이더에 넣어주도록 하겠습니다.

  • ./src/components/posts/PostViewer.js
...
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의 이미지 경로를 수정하도록 하겠습니다.

  • ./src/components/common/ImageCard.js
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코드입니다.

  • ./src/components/posts/PostViewer.js
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;

화면이 잘 나오는지 확인해보도록 하겠습니다.
!

#2 댓글구현

이어서 댓글을 구현하도록 하겠습니다. 댓글의 경우 이전에 작성해둔 코드에서 수정할 부분이 있어서 코드를 중점으로 수정을 진행하겠습니다.

댓글리스트의 경우 post 데이터를 불러오면 동시에 불러올 수 있도록 만들었습니다. 그러면 CommentList 컴포넌트에 넣어준 데이터를 사용자에게 보여주고, 댓글을 쓰는 작업을 할 수 있도록 구현하겠습니다.

  • ./src/components/comment/CommentContainer.js
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;
  • ./src/components/comment/CommentList.js
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;
  • ./src/components/comment/CommentItem.js
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의 값이 업데이트되면 페이지를 새로고침하여 댓글리스트를 최신화시키도록 하겠습니다.

  • ./src/components/comment/WriteButton.js
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;
  • 댓글 작성 REST API 엔드포인트를 POST /post-service/comments로 바꾸고 모든 값을 RequestBody로 받도록 하겠습니다.

댓글 작성을 위해 comment state값을 불러오는 코드입니다. writer는 현재 로그인한 user의 nickname값이고, postId는 현재 댓글을 작성하기 위한 포스트의 id값입니다. 댓글 작성이 잘 되는지 테스트를 해보겠습니다.


다음 포스트에서는 message-service를 연동해보도록 하겠습니다.

이미지 출처

참고

0개의 댓글