인프런 영문 브랜드 로고
인프런 영문 브랜드 로고

인프런 커뮤니티 질문&답변

hib4888님의 프로필 이미지
hib4888

작성한 질문수

[리뉴얼] React로 NodeBird SNS 만들기

게시글 수정하기

게시글 수정에 관련해서 질문드리겠습니다.

작성

·

738

0

안녕하세요. 제로초님 강의 잘 듣고있습니다.

게시글 수정을 구현하다 궁금한 점이 있어서 질문드립니다.

저는 게시글의 수정버튼을 클릭하면 해당 post의 정보를 state에 저장한 뒤 posting페이지로 이동하려고 했습니다.

하지만 두페이지 모두 ssr이 적용되어 있어 수정페이지로 이동하면 state가 초기화되어서 수정 포스트정보가 없어지더라고요

혹시 링크이동 시 데이터를 포함하거나 ssr시 초기화되지 않을 state를 따로 지정할 수 있는 방법이 있을까요?

 

// 게시글 수정
const onClickEditBtn = useCallback((post) => () => {
    dispatch(moveToPostEditRequestAction(post)); // 수정할 게시글의 정보를 state로 저장한 뒤
    Router.push('/posting'); // 포스팅 페이지로 이동
  }, []);

<ContentBtn type='text' onClick={onClickEditBtn(post)}>수정</ContentBtn>
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { message } from 'antd';
import Head from 'next/head';
import Router from 'next/router';

import wrapper from '../store/configureStore';
import axios from 'axios';
import { END } from 'redux-saga';

import AppLayout from '../components/AppLayout/';
import PostingForm from '../components/PostingForm';
import { LOAD_MY_INFO_REQUEST } from '../reducers/user';
import { PostingText, PageMainText, PageSubText } from '../styles/pageStyles';

const Posting = () => {    
  const { me } = useSelector((state) => state.user);
  const { addPostDone } = useSelector((state) => state.post);

  useEffect(() => {
    if (addPostDone) {
      message.success('게시글이 정상적으로 포스팅되었습니다.', 1.5);
      Router.replace('/');
    }
  }, [addPostDone]);
  
  useEffect(() => {
    if (!me) {
      message.error('로그인이 필요한 서비스입니다.', 1.5);
      Router.push('/');
    }
  }, [me]);

  return (
    <>
      <Head>
        <title>게시글 작성 | Recipe.io</title>
      </Head>
      <AppLayout>
        <PostingText>
          <PageMainText className='bolder'>POSTING</PageMainText>
          <PageSubText>Sharing your recipes leads to the joyous happiness of others</PageSubText>
        </PostingText>

        <PostingForm />
      </AppLayout>
    </>
  )
};

// 하지만 포스팅페이지는 ssr을 사용해서 전달한 수정 포스트가 없다
export const getServerSideProps = wrapper.getServerSideProps(async (context) => {
  console.log(`context: ${context}`);
  const cookie = context.req ? context.req.headers.cookie : '';
  axios.defaults.headers.Cookie = '';
  if (context.req && cookie) {
    axios.defaults.headers.Cookie = cookie;
  }

  context.store.dispatch({
    type: LOAD_MY_INFO_REQUEST,    
  });

  context.store.dispatch(END);
  await context.store.sagaTask.toPromise();
});

export default Posting;

 

답변 1

0

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

강좌에서는 hydrate 액션을 매우 단순하게 만들어놨는데 여기서 바뀌는 내용만 반영하도록 수정하시면 됩니다.

hib4888님의 프로필 이미지
hib4888
질문자

제로초님 답변을 듣고 밤새 hydrate 액션을 조작해서 문제를 해결해보려고 했는데 제가 부족해서인지 방법을 찾기 힘들어서 재질문드릴께요 죄송합니다 ㅜㅜ

 

현재 게시글의 수정버튼을 누르면 게시글 정보를 가시고 posting페이지로 이동하려고합니다.

// pages/home.js/postlist/postcard.js
const onClickEditPost = useCallback((post) => () => {
  dispatch({
    type: MOVE_TO_EDIT_POST,
    data: post,
  });
  Router.push('/posting');
}, []);

<ContentBtn type='text' onClick={onClickEditPost(post)}>수정</ContentBtn>
// reducers/post.js
export const initialState = {
  editPost: null,
}

case MOVE_TO_EDIT_POST:
  draft.editPost = action.data;
  break; 
// pages/posting.js
const Posting = () => {
  // state가 초기화되어 비어있습니다 ㅜㅜ
  const { editPost } = useSelector((state) => state.post);
};

 

이러한 상황에서 포스팅페이지로 이동할 때 post reducer의 editPost state만 초기화되지 않을 방법이 있을까요? ㅜㅜ

rootReducer는 다음과 같이 설정되어있습니다.

// reducers/index.js
import { HYDRATE } from 'next-redux-wrapper';
import { combineReducers } from 'redux';

import user from './user'
import post from './post'

const rootReducer = (state, action) => {
  switch (action.type) {
    case HYDRATE:
      console.log(('HYDRATE', action));
      return action.payload;
    default: {
      const combineReducer = combineReducers({
        user,
        post,
      });
      return combineReducer(state, action);
    }
  }
}

export default rootReducer;
제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

return action.payload 대신 return { ...state } 하면 덮어씌우는 것은 막을 수 있습니다.

hib4888님의 프로필 이미지
hib4888
질문자

항상 답변 감사합니다!!

답변해주신대로 return { ...state }를 적용했는데 어떤 이유인지 로그인도 안되고 ssr로 불러오던 게시글의 정보도 받아올 수 가 없게되네요 ㅜㅜ

import { HYDRATE } from 'next-redux-wrapper';
import { combineReducers } from 'redux';

import user from './user'
import post from './post'

const rootReducer = (state, action) => {
  switch (action.type) {
    case HYDRATE:
      console.log(('HYDRATE', action));
      // return action.payload;
      return { ...state }; 
    default: {
      const combineReducer = combineReducers({
        user,
        post,
      });
      return combineReducer(state, action);
    }
  }
}

export default rootReducer;

image

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

아 맞다, { ...state } 하면 덮어씌워지진 않는 대신 ssr도 없어져버립니다. 이 부분 좀 생각해볼게요.

hib4888님의 프로필 이미지
hib4888
질문자

아닙니다 곤란한 질문드려서 죄송합니다.

제가 자료를 찾은 뒤 redux-persist를 사용해서 localstorage 에 editPost state 를 저장한 뒤 hydrate 하는 걸 시도해봤는데

Server Side Render시에는 localstorage 접근이 불가능해서 제가 생각한대로 hydrate 가 제대로 동작하지 안더라고요

혹시 위와같은 방법으로는 해결이 불가능한가요?

 

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

네, redux-persist는 새로고침 시 데이터를 복구하는 용도라서 ssr용으로는 어려울 것 같습니다.

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

https://stackoverflow.com/questions/67011597/preserve-state-value-on-client-side-navigation-nextjs-next-redux-wrapper

이런 식으로 hydrate 액션을 복잡하게 짜는 방법도 있긴 합니다.

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

아니면 localStorage에 유지해야할 변경 사항을 저장해뒀다가 hydrate 후에 localStorage를 읽어 다시 복구할 수도 있을 것 같습니다.

hib4888님의 프로필 이미지
hib4888
질문자

조언해주신 방법을 시도해서 선택해봐야겠네요. 자세한 답변 정말감사합니다 ㅎㅎ

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

https://github.com/kirill-konshin/next-redux-wrapper#server-and-client-state-separation

공식문서에도 이런식으로 client용을 따로 구분하라고 되어있네요.

hib4888님의 프로필 이미지
hib4888
질문자

감사합니다!! 강의들으면서 hydrate는 대충넘어갔는데

이 계기로 제대로 학습해야겠네요 ㅎㅎ

hib4888님의 프로필 이미지
hib4888
질문자

게시글 수정을 구현하다 궁금점이 있어서 추가로 질문드릴께요

이전 질문과 마찬가지로 저는 수정할 게시글을 클릭하면 게시글의 정보를 editPost state로 가져와 initialvalues로 설정해 수정 기능을 구현하고 있습니다.

근데 editPost.Images는 DB에서 가져온 자료여서 upload에 initialvalues로 설정해도 기존 이미지 제거, 새로운 이미지를 추가..등등 이미지와 관련된 수정작업시에 onChange함수가 동작을 안합니다.

혹시 위와 같은 문제를 해결할 수 있는 방법이 있을까요? 코드 함께 첨부하겠습니다.

 

postingForm

const PostingForm = () => {  
  const { editPost } = useSelector((state) => state.post);
  
  const normFile = useCallback((e) => {  
    if (Array.isArray(e)) {
      return e;
    }
  
    return e?.fileList;
  }, []);

  const onChangeImages = useCallback((e) => {
    const imageFormData = new FormData();
    
    e.fileList.forEach((f) => {
      imageFormData.append('image', f.originFileObj);
    });  
    
    dispatch({
      type: UPLOAD_IMAGES_REQUEST,
      data: imageFormData,
    });    
  }, []);    

  const onBeforeUpload = useCallback((file, fileList) => {    
    return false
  }, []);   

  useEffect(() => { 
    if (editPost) {
      const images = editPost.Images.map((v) => v.src);

      dispatch({
        type: EDIT_POST_UPLOAD_IMAGES,
        data: images,
      }); 
    }
  }, []);
  
  return (
    <section>
      <PostingFormWrapper
        initialValues={ 
          editPost && {
            title: editPost.title,
            desc: editPost?.desc, 
            images: editPost.Images,      
            ingredient: editPost.ingredient,
            recipes: editPost.recipes,
            tips: editPost?.tips,
            tags: editPost?.tags,            
        }}
        form={form}
        name="posting"
        onFinish={onSubmitForm}      
        scrollToFirstError
        encType='multipart/form-data'
      >
        <ImageUploaderWrapper
          name="images"
          rules={[          
            {
              required: true,
              message: '조리사진을 첨부하세요.',
            },
          ]}                    
          valuePropName="fileList"
          getValueFromEvent={normFile}
        >          
          <Upload.Dragger             
            name="image" 
            multiple            
            listType="picture"
            onRemove={onRemoveUpload}
            onChange={onChangeImages}
            beforeUpload={onBeforeUpload}
          >            
            <ImageUploaderText className='bold'>Drag files here OR</ImageUploaderText>            
            <Button type='primary' size='large' icon={<UploadOutlined />}>Upload</Button>
          </Upload.Dragger>
        </ImageUploaderWrapper> 
  )
};

export default PostingForm;

 

reducers/post.js

      case EDIT_POST_UPLOAD_IMAGES:
        draft.imagePaths = action.data;
        break;
      case UPLOAD_IMAGES_REQUEST:
        draft.uploadImagesLoading = true;
        draft.uploadImagesDone = false;
        draft.uploadImagesError = null;
        break;
      case UPLOAD_IMAGES_SUCCESS:        
        draft.editPost ? draft.imagePaths.push(...action.data) : draft.imagePaths = action.data;
        draft.uploadImagesLoading = false;
        draft.uploadImagesDone = true;        
        break;
      case UPLOAD_IMAGES_FAILURE:
        draft.uploadImagesLoading = false;
        draft.uploadImagesError = action.error;
        break;

 

saga/post.js

function uploadImagesAPI(data) {
  return axios.post('/post/images', data);
}

function* uploadImages(action) {   
  try {        
    const result = yield call(uploadImagesAPI, action.data); 
    yield put({
      type: UPLOAD_IMAGES_SUCCESS,
      data: result.data,
    })
  } catch(err) {
    console.error(err);
    yield put({ 
      type: UPLOAD_IMAGES_FAILURE,
      error: err.response.data,
    })
  }  
}

 

routes/post.js

const upload = multer({  
  storage: multer.diskStorage({
    destination(req, file, done) {      
      done(null, 'uploads');      
    },
    filename(req, file, done) {      
      const ext = path.extname(file.originalname);
      const basename = path.basename(file.originalname, ext);
      done(null, basename + '_' + new Date().getTime() + ext);      
    },
  }),
  limits: { fileSize: 20 * 1024 * 1024 },
});

router.post('/images', isLoggedIn, upload.array('image'), async (req, res, next) => { // uploadImagesAPI / POST /post/images
  try {        
    res.json(req.files.map((v) => v.filename));
  } catch (error) {
    console.error(error);
    next(error);
  }
});
제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

이미 업로드된 이미지들에 대해서는 input type file에 넣을 수 없으므로 이미 업로드된 이미지들에 대한 데이터와 새로 업로드할 이미지에 대한 데이터 이렇게 두 개로 분리하여 관리해야 할 것 같습니다. 그래야 새 이미지를 추가하는 것 뿐만 아니라 기존 이미지를 삭제하는 것에도 대응 가능합니다.

hib4888님의 프로필 이미지
hib4888
질문자

답변 감사합니다 ㅎㅎ

hib4888님의 프로필 이미지
hib4888
질문자

제로초님 정말 죄송한데 게시글 수정 관련해서 마지막으로 질문드릴께요.

게시글 수정기능을 구현한 뒤 실행했더니 EDIT_POST_FAILURE 액션이 실행되면서 다음과 같은 에러가 발생했습니다.

code: 'ER_TRUNCATED_WRONG_VALUE',
    errno: 1292,
    sqlState: '22007',
    sqlMessage: "Truncated incorrect DOUBLE value: '[object SequelizeInstance:Image],false'",
    sql: "UPDATE `images` SET `PostId`=?,`updatedAt`=? WHERE `id` IN ('[object SequelizeInstance:Image],false', '[object SequelizeInstance:Image],false', '[object SequelizeInstance:Image],true')",
    parameters: [ 119, '2022-10-22 11:50:30' ]
  },
  sql: "UPDATE `images` SET `PostId`=?,`updatedAt`=? WHERE `id` IN ('[object SequelizeInstance:Image],false', '[object SequelizeInstance:Image],false', '[object SequelizeInstance:Image],true')",
  parameters: [ 119, '2022-10-22 11:50:30' ]
}

이 후 해당 게시글을 확인해보니 이미지를 제외한 나머지부분은 전부 정상적으로 변경되었습니다.

확실하지는 않지만 해당 오류가 `PostId`=?,`부분 때문에 발생한것같아 여러 시도를 해봤는데 문제를 해결할 수 없었습니다

바쁘시겠지만 해당 문제 관련해서 피드백 해주시면 감사하겠습니다.

관련 코드함께 첨부하겠습니다.

 

saga/post.js

// data: {
     fullEditImagePaths, // 수정된 이미지파일의 경로
     content: value, // 수정된 게시글의 컨텐츠
     postId: editPost.id, // 수정 게시글의 id
   }

function editPostAPI(data) {
  return axios.patch(`/post/${data.postId}/edit`, data);
}

function* editPost(action) {     
  try {
    const result = yield call(editPostAPI, action.data);    
    yield put({
      type: EDIT_POST_SUCCESS,
      data: result.data,
    })
  } catch(err) {
    console.error(err);
    yield put({ 
      type: EDIT_POST_FAILURE,
      error: err.response.data,
    })
  }  
}

 

route/post.js

router.patch('/:postId/edit', isLoggedIn, async (req, res, next) => {  // editPostAPI / PATCH /post/1/edit
  try {
    await Post.update({
      title: req.body.content.title,
      desc: req.body.content.desc,
      ingredient: req.body.content.ingredient,
      recipes: req.body.content.recipes,
      tips: req.body.content.tips,
      tags: req.body.content.tags, 
    }, {
      where: { 
        id: req.params.postId,        
      }
    });

    const post = await Post.findOne({
      where: { id: req.params.postId }
    })

    if (req.body.content.tags && req.body.content.tags.length !== 0) {            
      const hashtags = req.body.content.tags.split(/(#[^\s#]+)/g).map((v, i) => {
        if (v.match(/(#[^\s#]+)/)) {
          return v;
        }
        return null;
      });
      
      const hashtagArr = hashtags.filter((v, i) => v != null);        
      const result = await Promise.all(hashtagArr.map((tag) => Hashtag.findOrCreate({
        where: { name: tag.slice(1).toLowerCase() },
      })));
      await post.setHashtags(result.map((v) => v[0]));    
    };

    // 이미지를 수정하는 과정에서 해당 오류가 발생한 것 같습니다.
    if (req.body.fullEditImagePaths) {
      if (Array.isArray(req.body.fullEditImagePaths)) {
        const images = await Promise.all(req.body.fullEditImagePaths.map((image) => Image.findOrCreate({           
          where: { src: image },
        })));
        await post.setImages(images);
      } else {
        const image = await Image.findOrCreate({ 
          where: { src: req.body.fullEditImagePaths },
        });
        await post.addImages(image);
      }
    };

    const fullEditPost = await Post.findOne({
      where: { id: post.id },
      include: [{
        model: Image,
      }, {
        model: Comment,
        include: [{
          model: User,
          attributes: ['id', 'nickname'],
        }],
      }, {
        model: User,
        attributes: ['id', 'nickname'],
      }, {
        model: User,
        as: 'Likers',
        attributes: ['id'],
      }]
    });
        
    res.status(201).json(fullEditPost);    
  } catch (error) {
    console.error(error);
    next(error);
  }
})
제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

findOrCreate의 return값이 무엇인지 생각해보시면 왜 IN 부분이 저렇게 되었는지 아실 수 있습니다. 강좌에서도 map을 통해 별도로 추려냅니다.

hib4888님의 프로필 이미지
hib4888
질문자

설명해주셨는데 findOrCreate는 결과를 배열로 return하는걸 잊어버렸었네요 감사합니다.

근데 게시글 삭제 후에 DB를 확인해보니 comments, hashtags, images테이블에는 삭제된 게시글의 정보가 그대로 남아있던데 혹시 이유가 있을까요?

해당 내용의 강좌를 다시보고, 깃허브 코드도 확인해보니 게시글 삭제 라우터에서는 comments, hashtags에 대한 수정은 따로 안해주셔서요

image는 설명해주신대로 남겨서 관리되는 유지비용보다 머신러닝에 이용되어 얻을 수 있는 가치가 더 크기때문이라고 하셔서 이해는 되는데

나머지 comments, hashtags 테이블은 남겨두는 이유가 궁금해서 질문드립니다.

 

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

이것도 다 데이터라서 보관해두면 좋을 수 있습니다. 게시글도 소프트딜리트 해도 됩니다.

아니면 테이블에 on delete cascade를 설정해둬서 다 같이 지워지게 하면 됩니다.

hib4888님의 프로필 이미지
hib4888
질문자

이미지 뿐만이 아니라 텍스트와 같은 자료도 보관할 수 있는 가치가 있는지 몰랐네요.

답변 감사합니다 덕분에 많은 정보 얻었습니다!!

hib4888님의 프로필 이미지
hib4888

작성한 질문수

질문하기