dangerouslySetInnerHTML

dangerouslySetInnerHTML은 브라우저 DOM에서 innerHTML을 사용하기 위한 React의 대체 방법입니다.

일반적으로 코드에서 HTML을 설정하는 것은 사이트 간 스크립팅 공격에 쉽게 노출될 수 있기 때문에 위험합니다.

따라서 React에서 직접 HTML을 설정할 수는 있지만, 위험하다는 것을 상기시키기 위해 dangerouslySetInnerHTML을 작성하고 __html 키로 객체를 전달해야 합니다.

function createMarkup() {
  return {__html: 'First · Second'};
}

function MyComponent() {
  return <div dangerouslySetInnerHTML={createMarkup()} />;
}

 

위의 글에서 주의 깊게 읽어야하는 부분은 2가지입니다.

코드에서 HTML을 설정하는 것은 사이트 간 스크립팅 공격에 쉽게 노출됩니다.

=> 이것은 innerHTML이나 dangerouslySetInnerHTML이나 동일합니다.

 

위험하다는 것을 상기 시키기 위해 dangerouslySetInnerHTML을 작성하고, __html 키로 객체를 전달해야합니다.

dangerously...^^..

 

동작은 동일하게 HTML을 삽입합니다.

하지만 innerHTML을 사용하면 DOM노드가 수정되었을때 수정된 것을 알 수 있는 방법이 없다고 합니다.

따라서 dangerouslySetInnerHTML을 사용하여 가상 DOM과 실제 DOM을 비교하여 변경된 것이 있다면 리렌더링이 될 수 있도록 해야합니다.

 

 

잘못된 예시

const App = () => {
	const str = 'Hello!';
    
    return(
    	<div>
        	{str}
        </div>
    )
};

ReactDOM.render(
	<App />, document.getElementById('root');
)

 

 

사용 예시1

const App = () => {
	const str = 'Hello!';
    
    return(
    	<div dangerouslySetInnerHTML={{__html: str}}></div>
    )
};

ReactDOM.render(
	<App />, document.getElementById('root');
)

 

 

사용 예시2

const App = () => {
    const markup = () => {
    	return {__html : 'Hello'}
    };
    
    return(
    	<div dangerouslySetInnerHTML={markup()}></div>
    )
};

ReactDOM.render(
	<App />, document.getElementById('root');
)

 

 

간단히 알아보는 XXS

사이트 간 스크립팅 공격 or 크로스 사이트 스크립팅 (XSS, cross-site scripting)

  • 웹 애플리케이션의 취약점 중 하나
  • 관리자가 아닌 이가 페이지에 악성 스크립트를 삽입할 수 있는 취약점
    • 예를 들면.. 게시판에 악성 스크립트가 담긴 글을 올려 사이트를 공격 할 수 있음
  • 악성 스크립트를 통해 해커가 사용자의 정보(쿠키, 세션등)를 탈취하거나 비정상적인 기능을 수행
  • 주로 다른 웹 사이트와 정보를 교환하는 식으로 작동하므로 사이트 간 스크립팅이라고 함
  • 취약점을 방지하기 위해서는 사용자의 입력 값을 검사하고 사용해야함

 


출처 & 참고

위키백과

ko.reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml

ko.wikipedia.org/wiki/%EC%82%AC%EC%9D%B4%ED%8A%B8_%EA%B0%84_%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8C%85

 

스토리지 중에 무엇을 써볼까하다가 AWS S3를 이용하기로 했습니다.

그래서 조사하고 기억하기 위해 글을 작성합니다!

AWS S3가 무엇인지 알아보겠습니다.

 

 

aws 마크 (출처: 위키백과)

 

 

 

AWS(Amazon Web Services) S3(Simple Storage Serviced)

아마존에서 제공하는 온라인 스토리지 웹 서비스입니다.

높은 내구성, 가용성, 저렴한 가격이 장점입니다.

 

이분의 블로그 글을 참고하시면 자세합니다.

저는 이 블로그 글을 읽고 제가 기억해야하는 부분을 요약한 것입니다.

 https://acstory.tistory.com/33

 

[AWS] S3란? 무제한으로 저장할 수 있는 스토리지!

한마디로 요약하면 S3는 "높은 내구성"과 "높은 가용성"을 "저렴한 가격"으로 제공하는 "인터넷 스토리지 서비스" 이다. 하나의 저장 공간을 구성하고 그 공간에 데이터를 업로드하면 인터넷을 통

acstory.tistory.com

 

내구성과 가용성이 높다라는 것은 외부의 자극에도 변형되지 않고 유지되며 시스템이 장애없이 정상적으로 운영되는 성질이 높다는 것입니다.

 

 

 

일반 스토리지와 객체스토리지(S3)의 차이

일반 스토리지

일반 스토리지 이미지 (출처: https://acstory.tistory.com/33)

1) 업로더가 업로드하면 스토리지에 저장됨

2) 저장된 스토리지에 있는 파일을 사용자가 다운로드 요청

3)

정상: 파일을 정상적으로 응답

손상이 있을 경우: 다운로드 실패 혹은 손상된 파일을 응답

 

 

객체스토리지 (S3)

객체 스토리지 설명 (출처: https://acstory.tistory.com/33)

1) 업로더가 업로드하면 스토리지가 여러 위치에 복제본을 생성

2) 저장된 스토리지에 있는 파일을 사용자가 다운로드 요청

3)

정상: 파일을 정상적으로 응답 (이때 복제본도 응답 가능)

손상이 있을 경우: 손상본을 제외 시키고 정상적인 복제본을 통해 응답 

 

 

단점은 스토리지가 객체를 내부 복제하는데에 시간이 걸린다.

물론 매우 짧다.

객체 스토리지 단점 설명 (출처: https://acstory.tistory.com/33)

따라서 복제 도중 다양한 요청에 대해 일관적인 응답이 아닐 경우도 존재합니다.

덮여쓰기 중 이전 버전을 응답한다거나 삭제했음에도 응답이 되는 경우를 예로 들 수 있습니다.

 

 

 

S3 요금

최초요금은 없으며 사용한 만큼 비용을 지불해야합니다.

스토리지 클래스를 결정할 때엔 네가지 비용 요소를 고려해야한다고 하네요.

- 스토리지 요금

- 요청 및 데이터 검색 요금

- 데이터 전송 및 전송 가속화 요금 

- 데이터 관리 기능 요금

https://aws.amazon.com/ko/s3/pricing/

 

 

리전을 눌러보면 GB당 얼마인지 나옵니다.

리전마다 다르니 확인하시고 사용하세요.

 

 

 


https://acstory.tistory.com/33

aws.amazon.com/ko/s3/

 

 

게임을 저장하고 불러오는 작업을 진행하고 있습니다.

db 작업도 하고있는 중인데, 여태 해본적이 없어서 꽤나 찾아보고 해야하네요..

sequelize를 통해 작업을 하고 있는데,

데이터 갯수를 몇개 정해서 가져오고 이것을 랜덤으로 섞어서 프론트로 내려줘야하는 경우가 생겼습니다.

 

원래 작업했던 순서는..

프론트에서 모든 배열을 받은 후 배열의 갯수를 특정하고 그 다음 랜덤하게 섞는 작업을 했었습니다만...

모든 데이터를 받는 것부터가 너무 낭비였습니다.

그래서 이 부분을 수정하면서 랜덤하게 섞는것도 가능하단것을 알게되었습니다. 

 

limit을 통해 몇개 가져올지,

order를 통해 어떤형식으로 가져올지 정할 수 있습니다.

다만 어떤것이 성능상 좋은지는 잘 모르겠군요... (order부분)

router.get('/nonsensequiz', async (req, res, next) => { // POST /game/nonsensequiz
    try {
        const quiz = await NonsenseQuiz.findAll({
            limit: 20,
            order: sequelize.random()
        });

        res.status(200).json(quiz);
    } catch (error) {
        console.error(error);
        next(error);
    }
});

 

프론트에서 작업했던 코드도 한 번 보겠습니다.

원래는 sort로 간단하게 끝내려다가 피셔-에이츠 셔플 알고리즘으로 작업하기로 결정했습니다.

sort는 랜덤정렬이라고 하기에는 빈도 수에 차이가 꽤 발생한다고 합니다.

아래 블로그글을 참고해보세요 (블로그 글에서 해답보기 클릭하셔야 글 확인이 가능합니다.)

ko.javascript.info/task/shuffle

 

배열 요소 무작위로 섞기

배열의 요소를 무작위로 섞어주는 함수 shuffle(array)을 작성해 보세요. shuffle을 여러 번 실행하면 요소의 정렬 순서가 달라야 합니다. 예시를 살펴봅시다. let arr = [1, 2, 3]; shuffle(arr); // arr = [3, 2, 1]

ko.javascript.info

 

피셔-에이츠 셔플 알고리즘은..

배열의 첫번째부터 시작해 배열의 크기만큼 반복하면서 임의의 요소와 해당 요소를 바꿔치기하는 알고리즘입니다.

일단 셔플하기 전에 원본 데이터를 deep copy해주었습니다.

function shuffleArray (arr){
    let temp = arr.map((v) => {
        return cloneObject(v);
    });

    for(let i = temp.length - 1; i > 0; i--){
        let j = Math.floor(Math.random() * (i + 1));
        [temp[i], temp[j]] = [temp[j], temp[i]];
    }

    return temp;
};

function cloneObject(obj) {
    let clone = {};

    for (var key in obj) {
        if (typeof obj[key] == 'object' && obj[key] != null) {
            clone[key] = cloneObject(obj[key]);
        } else {
            clone[key] = obj[key];
        }
    }

    return clone;
}

 

현재 db에서 배열을 섞어서 내려주는 작업을 진행해서 프론트쪽 코드들은 사용안하게됐지만,

성능상 비교를 하여 어느쪽이 좋은지 판단해서 작업하는 작업자가 되어야겠습니다.

 

 

 

 

심심이

 

 

심심이 API는 유료지만 개발자를 위해 데모프로젝트 100회 무료 이용을 제공하고있습니다.

100회 통신만 무료기 때문에 몇번만에 성공할지는 잘 모르겠지만 도전했습니다.

https://workshop.simsimi.com/

 

SimSimi Workshop

Make a chatbot that allows for small talks.

workshop.simsimi.com

 

 

계정을 먼저 생성해주세요.

본인의 메일주소를 입력해서 가입하면 해당 메일로 인증메일이 전송되고 수락하면 로그인이 됩니다.

 

 

그 후 [내 계정 > 대쉬보드] 페이지로 이동합니다.

대쉬보드 페이지

 

 

대쉬보드에서는 나의 API나 남은 요청 횟수 확인이 가능합니다.

 

API 요청횟수 확인

 

 

API 사용방법보기 클릭하면 페이지 이동이 되는데, 문서를 읽다보면 API 요청에 대한 예시가 curl로 되어있습니다.

저는 curl이 낯설어서 당황했습니다.;

curl 요청 예시

 


CURL 이란?

서버와 통신할 수 있는 커맨드 명령어 툴이다. 웹개발에 매우 많이 사용되고 있는 무료 오픈소스이다 curl의 특징으로는 다음과 같은 수 많은 프로토콜을 지원한다는 장점이 있다. 또한 SSL 인증 방식 역시 가능하다

 


 

뭐 그렇다네요.

저는 axios로 요청하기 위해 포스트맨으로 테스트했습니다.

응답 데이터 받은 후로는 어렵지 않으니깐요. 

(성공한 요청만 횟수 차감)

 

 

포스트맨

메서드를 POST로 변경하고 호출 주소 입력칸에 주소를 입력합니다.

주소의 190410 버전 부분은 예시에 적혀있어서 그대로 사용했습니다. 

포스트맨 요청주소 부분

 

 

해당 탭들로 이동해서 데이터를 입력합니다.

 

Header 탭

Content-Type과 x-api-key를 입력합니다. x-api-key는 내 API키입니다.

포스트맨 Header 탭 부분

 

 

Body 탭

utext와 lang은 필수 파라미터입니다, 필수 파라미터를 추가해줍니다.

포스트맨 Body 탭 부분

 

 

이후 Send 버튼을 누르면 아래와 같이 응답이 잘 오는것을 확인할 수 있습니다.

그리고 응답이 정상적으로 왔다면 대쉬보드에 요청 횟수가 99로 줄어있을것입니다.

포스트맨 응답 부분

 

 

 

교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS) 문제

 

자 그리고 또 저에게 닥친 문제는 CORS문제였습니다.

심심이와 채팅을 하기 위해서는 브라우저 -> 심심이서버로 요청을 보내게 되는데,

서로 다른 주소를 가지고 있기 때문에 보안상 차단이 됩니다.

CORS 에러

 

이때 강의에서 들었던 말이 생각났습니다.

 

차단은 브라우저가 하지만 

허용은 서버가 해줘야합니다.

서버와 서버간의 요청은 CORS가 문제가 되지 않는다.

 

제가 가장 간단히 할 수 있는 해결법은 저의 백엔드 서버를 이용하는 것이었습니다.

백엔드 서버가 없다면 proxy 서버를 따로 띄워서 요청을 보내야 합니다.

 

브라우저에서 -> 백엔드 서버 요청은 이전에 등록한 저의 게시글들에 적혀있습니다.

동일한 방식으로 처리하면 됩니다.

 

간단하게 정리하면 절차는 아래와 같습니다.

브라우저에서 dispatch -> sagas에서 axios를 통해 내 백엔드 서버로 요청 -> 백엔드에서 심심이 API로 요청

만약 본인의 백엔드 서버가 없다면 proxy를 띄워서 프록시 서버로 요청을 보내고 -> 프록시 서버에서 심심이로 API 요청을 하면 됩니다.

 


 

저의 결과코드입니다.

 

 

ChatRoom.js

(길어서 끊어 보겠습니다. 이어서 사용하면됩니다.)

import

import React, { useState, useCallback, useEffect, useRef, } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';

// 1. 그냥 useState로 대처해서 작업하면 됩니다.
import useInput from '../../../hooks/useInput';
import { DELETE_MESSAGE, SEND_MESSAGE_REQUEST } from '../../../reducers/simsimi';

import styled, { keyframes } from 'styled-components';
import { Avatar } from 'antd';
import { LeftOutlined, ArrowUpOutlined } from '@ant-design/icons';

// 2. WindowDialog 관련 부분은 제가 따로 사용하는 컴포넌트라 제거해도됩니다. 
import WindowDialog from '../../WindowDialog/index';
import Chat from './Chat';

 

 

styled-component

const Wrap = styled.div`
    width: 100%;
    height: 100%;
`;

const Header = styled.div`
    position: relative;
    display: flex;
    height: 10%;
    align-items: center;
    justify-content: center;
    border-bottom: 1px solid #ccc;
    box-sizing: border-box;
`;

const BackButton = styled.button`
    position: absolute;
    top: 50%;
    left: 0;
    transform: translateY(-50%);
    padding: 0;
    background: none;
    border: none;
    outline: none;
    cursor: pointer;
`;

const BackIcon = styled(LeftOutlined)`
    font-size: 16px;
    color: #666;
`;

const Content = styled.div`
    padding: 5%;
    height: 85%;
    box-sizing: border-box;


    div + div {
        margin-top: 10px;
    }
`;

const Footer = styled.div`
    height: 5%;
`;

const Input = styled.input`
    padding: 5px 10px;
    width: 80%;
    height: 30px;
    border: 1px solid #ddd;
    outline: none;
    box-sizing: border-box;
`;

const SendButton = styled.button`
    width: 20%;
    height: 30px;
    border: 1px solid #ddd;
    border-left: none;
    outline: none;
    background: none;
    cursor: pointer;
`;

// 1. api 호출할때 시간이 소요되므로 (물론 내가 글을 작성할때도) 로딩중.. 과 같이 사용합니다.
const loadingAni = keyframes`
    0%, 100% {
        transform: translateY(-1px);
    }
    50% {
        transform: translateY(1px);
    }
`;

const LoadingText = styled.div`
    span {
        display: inline-block;
        animation: ${loadingAni} .7s infinite;
    }

    span:nth-child(2) { animation-delay: 0.1s; }
    span:nth-child(3) { animation-delay: 0.2s; }
    span:nth-child(4) { animation-delay: 0.3s; }
    span:nth-child(5) { animation-delay: 0.4s; }
    span:nth-child(6) { animation-delay: 0.5s; }
`;

 

 

jsx와 propTypes

// 1. onPrevStep은 이전페이지를 보여주기 위함으로 관련된 코드들은 없어도 무방합니다.const ChatRoom = ({ onPrevStep }) => {
const dispatch = useDispatch();
const inputRef = useRef(null);
const { me } = useSelector((state) => state.user);

// 2. chatList 배열의 데이터를 채팅창에 렌더할 것입니다.
const { chatList, sendMessageLoading } = useSelector((state) => state.simsimi);
    const [openedDialog, setOpenedDialog] = useState(false);
    const [message, onChangeMessage, setMessage] = useInput('');

    useEffect(() => {
        inputRef.current.focus();
    }, []);

    useEffect(() => {
        if (sendMessageLoading) {
            setMessage('');
        }
    }, [sendMessageLoading]);


    const onCloseDialog = useCallback((res) => {
        setOpenedDialog(false);

        // 3. 채팅 리셋 요청
        if (res.state) {
            onPrevStep();
            dispatch({
                type: DELETE_MESSAGE
            });
        }
    }, []);

    const onCloseRoom = useCallback(() => setOpenedDialog(true), []);

    const onSendMessage = useCallback(() => {
        if (!message || !message.trim()) {
            return;
        }

        // 4. 채팅 보내기 요청
        dispatch({
            type: SEND_MESSAGE_REQUEST,
            data: {
                nickname: me.nickname,
                text: message
            }
        });
    }, [message]);

    // 5. 엔터키에도 메세지가 보내져야합니다.
    const onKeyPress = useCallback((e) => {
        if (e.code === 'Enter') {
            onSendMessage();
        }
    }, [message]);

    // 6. 입력중, 응답 중일때 로딩으로 사용할 것입니다.
    const renderLoading = useCallback(() => {
        return (
            <LoadingText>
                <span>입</span>
                <span>력</span>
                <span>중</span>
                <span>.</span>
                <span>.</span>
                <span>.</span>
            </LoadingText>
        )
    }, []);

    return (
        <>   
            <Wrap>
                <Header>
                    <BackButton
                        onClick={onCloseRoom}
                    >
                        <BackIcon />
                    </BackButton>

                    {/* 7. 심심이 홈페이지에서 이미지 주소를 긁어온것입니다.. */}
                    <Avatar
                        src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNi4wLjUsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iNDQxLjA4NHB4IiBoZWlnaHQ9IjM5MS43NjNweCIgdmlld0JveD0iMCAwIDQ0MS4wODQgMzkxLjc2MyIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgNDQxLjA4NCAzOTEuNzYzIg0KCSB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGw9IiNGRkUzNEYiIHN0cm9rZT0iIzMyMzUzRiIgc3Ryb2tlLXdpZHRoPSI1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iDQoJTTQyOC41NjUsMjE5LjA5OGMtOS41NzIsMC45ODQtMTcuMjE1LTIuOTI2LTIyLjc4OS03LjY0NmMxLjMwNS04Ljc5NSwxLjk4OS0xNy43OTMsMS45ODktMjYuOTUxYzAtMTAwLjUxNi04MS40ODMtMTgyLTE4Mi0xODINCgljLTEwMC41MTYsMC0xODIsODEuNDg0LTE4MiwxODJjMCwxMS42MjIsMS4xMDMsMjIuOTg0LDMuMTg1LDM0Yy03LjA5Miw3LjIxMS0xNy44MzgsMTQuMzI4LTMxLjk3NywxMi44NzUNCgljMCwwLTE1LjMzMi0wLjA4Ny0xMiwxNS41NTJjMCwwLDEuMzkzLDExLjYzOSwxOS4wMjksMTAuOTcyYzAsMCwxNi4xNjQtMS41ODIsMzEuMjkyLTE1LjEyOA0KCWMxNi44MDgsNDkuNzY0LDU0LjU1Miw4OS44NzgsMTAyLjc0OSwxMDkuODg1Yy0xLjU2Nyw3LjY0Ni0yLjM4NCwxOS4zNTQsMi42NSwzMS43NDhjMCwwLDMuMzMsNS45OTUsMTAuNjYxLDQuNjY4DQoJYzYuNDMzLTEuMTY1LDUuODk0LTguODI5LDUuNDExLTEzLjc5NmMtMC41NDUtNS41OTUtMC4zMTItMTAuNDYxLDEuMjQyLTE1LjY2MWMxNS44MTQsNC40ODQsMzIuNTA2LDYuODg2LDQ5Ljc1Nyw2Ljg4Ng0KCWMyNi44MzcsMCw1Mi4zMTUtNS44MTIsNzUuMjUtMTYuMjRjMS40MTEsNC40OTIsMi4xMzcsOS42LDIuMTE2LDExLjA2OWMtMC4wNjgsNS4wMTEtMy4yMTUsMTUuNjQ2LDQuMjQ4LDE3LjAwMg0KCWM2LjMzMiwxLjE0OSwxMC40MzItMy44MTcsMTEuOTM0LTkuNDMzYzEuOTIyLTcuMTksMi4yNi0xNS44MTYsMC44MzMtMjMuMTMyYy0wLjA0OS0wLjI1MS0wLjYwNi0yLjU3OC0xLjIxNy00Ljg5OQ0KCWM0MC4yMjUtMjQuMDE5LDcwLjM5OS02My4xMTEsODIuNzI4LTEwOS40ODRjMTAuODgzLDcuOTkyLDIxLjI2NCw5LjAxNiwyMS4yNjQsOS4wMTZjMTQuMTY1LDAuNTM1LDE1LjI4Mi04LjgxMSwxNS4yODItOC44MTENCglDNDQwLjg4LDIxOS4wMjgsNDI4LjU2NSwyMTkuMDk4LDQyOC41NjUsMjE5LjA5OHoiLz4NCjxwYXRoIGQ9Ik0yNTYuMzc5LDM4LjYwOGMtMjAuODY2LDMuODEtNy4yNjMsMjUuOTMzLDguMTkyLDIyLjU3NGMxMC4yNjQtMi4yMzUsMTguMTUyLTExLjQxMSwxNS42NjgtMjAuOTg5DQoJYy0yLjM5OC05LjIyNi0xNi45OS0xMy42NjItMjUuOTY3LTE0LjAxNWMtMTEuNTQ4LTAuNDY3LTIzLjE1LDQuMDY4LTI3LjI5MSwxNC41ODFjLTMuNjcsOS4zMTQsMS41MDIsMTkuMDg3LDguMzc4LDI1LjcxNA0KCWM4LjczOCw4LjQyNiwyMS4zMSw5LjcyOCwzMy4yMTEsNy4zNDNjOS44ODYtMS45ODIsMjMuMDA3LTguNDgsMjQuNTItMTguNTg1YzAuNDI1LTIuODc0LTMuODgzLTMuNTkyLTQuNzgyLTAuODUNCgljLTQuMzYzLDEzLjMzOS0yNC4zOTcsMTcuMzc4LTM3LjQ1OCwxNC41NTFjLTE0Ljc3MS0zLjE5OS0yNy4yMi0yMi42NjgtMTMuMzk4LTMzLjQxNGM4LjkzNy02Ljk1LDM5LjY1My01LjM1NSwzNy41MDgsOS42NTcNCgljLTEuMjM3LDguNjk5LTEyLjU3NCwxMi4wNjctMjAuMTY3LDkuMTIyYy00LjcwMy0xLjgyOS00LjUyMy02LjgwNiwzLjI4NS05LjYwM0MyNjAuODk5LDQzLjY4MSwyNTkuNTg5LDM4LjAyNywyNTYuMzc5LDM4LjYwOA0KCUwyNTYuMzc5LDM4LjYwOHoiLz4NCjxlbGxpcHNlIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgY3g9IjE2NS4zMDEiIGN5PSIxNDkuNjgxIiByeD0iMTEuNzA0IiByeT0iMTMuNTQzIi8+DQo8ZWxsaXBzZSBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGN4PSIzNzAuMTUzIiBjeT0iMTQzLjAwMSIgcng9IjkuNzA0IiByeT0iMTEuMjI5Ii8+DQo8cGF0aCBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ig0KCU0xNTAuMDk4LDExMy4xMzdjMCwwLDEyLjY2Ny0xNiw0MC44MzMsNiIvPg0KPHBhdGggZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSINCglNMzYwLjQ0OSwxMTkuMTM3YzAsMCwxMS4xNjctMTMuMTE5LDE5LjQwNy01LjgwOSIvPg0KPHBhdGggZmlsbD0iI0YxOEQ4QiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTE1NC44NDIsMTkyLjU2NQ0KCWMwLDAsMTAyLjUyNiwxMS44NjksMjIxLjg2LTguMDY1YzAsMCwxMi42NjYsNjYuNzMyLTEwNCw3Ni43MzJDMjcyLjcwMiwyNjEuMjMyLDE1Mi43NTEsMjc2LjQzMSwxNTQuODQyLDE5Mi41NjV6Ii8+DQo8cGF0aCBmaWxsPSIjRkZGRkZGIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ig0KCU0yNzIuNzAyLDI2MS4yMzIiLz4NCjxwYXRoIGZpbGw9IiNGRkZGRkYiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iDQoJTTE1NS42MywyMDUuOTYzYy0wLjYyOS00LjE1OC0wLjkwNy04LjYxNy0wLjc4OC0xMy4zOTdjMCwwLDEwMi41MjYsMTEuODY5LDIyMS44Ni04LjA2NWMwLDAsMC41NjUsMi45ODEsMC4zNTYsNy43NzkiLz4NCjxwYXRoIGZpbGw9IiNGRkZGRkYiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iDQoJTTE1NC44NDMsMTkyLjU2NWMtMC4xMTksNC43ODYsMC4xNzcsOS4yMzMsMC44MDksMTMuMzk2YzI1LjMwMSw0LjI4NywxMzIuMzgsMTkuNDE4LDIyMS40MDUtMTMuNjgzDQoJYzAuMjA4LTQuNzkzLTAuMzU0LTcuNzc4LTAuMzU0LTcuNzc4QzI1Ny4zNjksMjA0LjQzNSwxNTQuODQzLDE5Mi41NjUsMTU0Ljg0MywxOTIuNTY1eiIvPg0KPHBhdGggZmlsbD0iI0ZGNjQ2NCIgc3Ryb2tlPSIjMzIzNTNGIiBzdHJva2Utd2lkdGg9IjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSINCglNMjU3LjY1NywyNjEuNmMwLDAtMi40NjEtMTMuNjc4LDYuNjYxLTE0LjY5MWMwLDAsNi41MTUtMC4xNDUsNy4wOTUsNC45MjNjLTAuMzU3LTMuMTMsMi40MjItNC43MjksNS4xMTMtNC44ODgNCgljMy4xNDYtMC4xODYsNS45MTgsMS43Myw2LjYxNCw0LjkyOGMwLjUxNSwyLjM2OSwwLjc3Miw1LjEzMiwwLjY5NSw3LjU1MkMyODMuODM2LDI1OS40MjMsMjczLjEyOSwyNjIsMjU3LjY1NywyNjEuNnoiLz4NCjwvc3ZnPg0K"
                    />
                </Header>

                <Content>
					{/* 8. chatList 배열을 채팅창에 그려줍니다. */}
                    {chatList.map(({ nickname, text }, i) => {
                        return (
                            <Chat
                                key={`${nickname}_${i}`}
                                nickname={nickname}
                            >
                                {text}
                            </Chat>
                        )
                    })}

                    {/* 9. 로딩 중일때 상황에 따라 렌더합니다. nickname과 children을 Chat 컴포넌트에 전달합니다. */}
                    {message && <Chat>{renderLoading()}</Chat>}  
                    {sendMessageLoading && (
                        <Chat nickname="simsimi">
                            {renderLoading()}
                        </Chat>
                    )}
                </Content>

                <Footer>
                    <div>
                        <Input
                            ref={inputRef}
                            value={message}
                            onChange={onChangeMessage}
                            onKeyPress={onKeyPress}
                            placeholder="채팅을 시작해보세요"
                        />

                        <SendButton onClick={onSendMessage}>
                            <ArrowUpOutlined />
                            <span className="hidden">전송</span>
                        </SendButton>
                    </div>
                </Footer>
            </Wrap>

            {openedDialog && (
                <WindowDialog
                    type="confirm"
                    text="대화내용이 지워집니다. 진행하시겠습니까?"
                    callback={onCloseDialog}
                />
            )}
        </>
    );
};

ChatRoom.propTypes = {
    onPrevStep: PropTypes.func.isRequired,    
};

export default ChatRoom;

 

 

Chat.js

import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';

const Wrap = styled.div`
    text-align: ${props => props.align};
`;

const SpeechBubble = styled.div`
    padding: 5px 10px;
    display: inline-block;
    border-radius: 5px;
    background: ${props => props.bgcolor};
`;

// 1. props 받은 것들을 렌더합니다. (이건 껍데기만 있는 컴포넌트입니다.
const Chat = ({ children, nickname }) => {
    const SIMSIMI = 'simsimi';
    const SIMSIMI_ALIGN = 'left';
    const SIMSIMI_COLOR = '#ffe34f';
    const USER_ALIGN = 'right';
    const USER_COLOR = '#f18d8b';

    return (
        <Wrap
            align={
                nickname === SIMSIMI
                ? SIMSIMI_ALIGN
                : USER_ALIGN
            }
        >
            <SpeechBubble
                bgcolor={
                    nickname === SIMSIMI
                    ? SIMSIMI_COLOR
                    : USER_COLOR
                }
            >
                {children}
            </SpeechBubble>
        </Wrap>
    );
};

Chat.propTypes = {
    children: PropTypes.node.isRequired,
    nickname: PropTypes.string,
};

Chat.defaultProps = {
    nickname: 'user',
};

export default Chat;

 

 

그 다음 흐름에 따라

sagas, reducer.. 그리고 백엔드 서버의 라우터 코드들을 보겠습니다.

 

 

 

ChatRoom.js

대화창에 대화를 입력하고 전송하게되면 ChatRoom에서 dispatch 요청을 합니다.

요청 데이터에는 유저닉네임, 대화를 보내긴하지만, ui를 위해 닉네임을 포함했을뿐 다른 값이어도 상관없습니다.

여기서 필수 파라미터는 채팅 메세지입니다.

chatroom.js에서 dispatch

 

 

 

sagas/simsimi.js

sagas폴더에 simsimi.js 파일을 생성합니다.

다른 sagas와 분리하기 위해, 따로 보여드리진 않지만 이 파일도 sagas/index.js에 등록해주세요.

import axios from 'axios';
import { all, fork, put, takeLatest, delay, call } from 'redux-saga/effects';
import {
    SEND_MESSAGE_REQUEST, SEND_MESSAGE_SUCCESS, SEND_MESSAGE_FAILURE,
} from '../reducers/simsimi';

function sendMessageAPI(data){

// 1. 내 백엔드 서버로 먼저 요청을 보냅니다.
    return axios.post('/simsimi', data);
};

function* sendMessage(action){
    try{
        const result = yield call(sendMessageAPI, action.data);

        // 2. 데이터 응답을 받으면 응답 값을 보냅니다.
        yield put({
            type: SEND_MESSAGE_SUCCESS,
            data: result.data
        });

    }catch(err){
        console.error(err);
        yield put({
            type: SEND_MESSAGE_FAILURE,
            error: err.response.data
        })
    }
}

function* watchSendMessage(){
    yield takeLatest(SEND_MESSAGE_REQUEST, sendMessage);
}

export default function* guestbookSaga(){
    yield all([
        fork(watchSendMessage),
    ]);
}

 

 

 

reducer/simsimi.js

reducer에도 simsimi.js 파일을 생성합니다.

이것도 이전에 쓴 글에 있기에 따로 설명안합니다. reducer/index.js에 등록해주세요.

import produce from '../util/produce';

export const initialState = {
    chatList : [],
    sendMessageLoading: false,
    sendMessageDone: false,
    sendMessageError: false,
};

export const SEND_MESSAGE_REQUEST = 'SEND_MESSAGE_REQUEST';
export const SEND_MESSAGE_SUCCESS = 'SEND_MESSAGE_SUCCESS';
export const SEND_MESSAGE_FAILURE = 'SEND_MESSAGE_FAILURE';
export const DELETE_MESSAGE = 'DELETE_MESSAGE';

const reducer = (state = initialState, action) => produce(state,(draft) => {
    switch(action.type){
        case SEND_MESSAGE_REQUEST:
            draft.sendMessageLoading = true;
            draft.sendMessageDone = false;
            draft.sendMessageError = false;

            // 1. 요청 시에는 내 데이터를 저장합니다.
            draft.chatList.push(action.data);
            break;

        case SEND_MESSAGE_SUCCESS:
            draft.sendMessageLoading = false;
            draft.sendMessageDone = true;
            draft.sendMessageError = false;

            // 2. 응답 시에는 심심이 데이터를 저장합니다.
            draft.chatList.push(action.data);
            break;

        case SEND_MESSAGE_FAILURE:
            draft.sendMessageLoading = false;
            draft.sendMessageDone = false;
            draft.sendMessageError = true;
            break;

        // 4. 대화창을 비웁니다.
        case DELETE_MESSAGE:
            draft.chatList = [];
            break;

        default:
            break;
    }
});

export default reducer;

 

 

sagas에서 POST로 /simsimi요청을하게되면,

이게 어디로 요청이 가는거냐? 바로 제 백엔드 서버로 요청이 갑니다.

그 이유는 sagas/index.js에 baseURL로 제가 등록했기 때문입니다.

(만약 등록하지 않았다면 본인의 백엔드 서버 주소를 axios 호출 시 입력하면됩니다.)

sagas/index.js

 

 

 

back/app.js

라우터로 simsimi.js 파일을 생성할 것이므로 라우터에 app.js에 등록해줍니다.

라우터 파일을 require를 해서 app.use를 통해 등록합니다.

app.js 라우터 require
app.js 라우터 등록

 

 

 

back/routes/simsimi.js

라우터를 만들어줍니다. routes/simsimi.js 파일을 생성합니다.

const express = require('express');
const router = express.Router();
const axios = require('axios');

router.post('/', async (req, res, body) => { // POST /simsimi
    try {
        const result = await axios({
            url: 'https://wsapi.simsimi.com/190410/talk',
            method: 'post',
            headers: {
                'Content-Type' : 'application/json;charset=utf-8',
                'x-api-key' : '본인의 프로젝트 키를 넣으세요.',
                'Access-Control-Allow-Origin' : '*',
                'Access-Control-Allow-Credentials': 'true'
            },
            data: {
                utext : req.body.text,
                lang : "ko",
            },
        });

        res.status(result.data.status).send({
            nickname: 'simsimi',
            text: result.data.atext
        });

    } catch (error) {
        console.error(error);
        next(error);
    }
});

module.exports = router;

 

필수 파라미터들을 data로 전달합니다.

포스트맨으로 테스트할때 처럼 Header를 입력합니다.

Content-Type, x-api-key !! 

 

 

 

front/reducer/simsimi.js

그리고 테스트를 해보면 응답이 올텐데, 

응답값은 프론트 서버에서 받습니다.

reducer/simsimi.js의 SUCCESS부분에서 확인할 수 있습니다.

심심이 응답에 대한 코드

 

응답에서 chatList(채팅 배열)에 넣어줍니다.

ChatRoom.js에서 코드를 확인해보면 chatList를 통해 채팅을 렌더링합니다.

 

 


 

 

이렇게 작업하고나서 완성된 모습입니다.

완성

흐름을 다시 짚어 볼까요?

 

1.

ChatRoom.js에서 채팅입력(dispatch)

ChatRoom.js에서 채팅입력(dispatch)

 

2.

front/sagas/simsimi.js를 통해 내 백엔드 서버로 요청을 보낸다!

백엔드 서버로 요청

 

3.

백엔드에서 외부 API로 요청, 응답을 프론트로 전달

백엔드에서 외부 API로 요청, 응답을 프론트로 전달

 

4.

sagas에서 reducers로 응답 값 전달

sagas에서 reducers로 응답 값 전달

 

5.

reducer에서 응답 데이터 처리

reducer에서 응답 데이터 처리

 

 

 

완성된 모습과 동작 모습입니다.

(로컬이라 좀 느립니다.)

심심이 gif

 

 

 

지난 포스팅에서 만들었던 슬라이드를 수정하게되었습니다.

포스팅에서도 말했었는데,

기존 방식은 지정된 갯수의 이미지만으로 작업을 한 부분이었기에 문제가 없는데,

이미지 갯수가 동적으로 들어오게되면 스타일이 깨지게됩니다.

그래서 실제로.. 작업하다가 이미지 갯수를 늘리려하니 깨지게되고 이런게 너무 거슬려서 수정하게되었습니다.

 

다만 지난 포스팅과는 방식이 다릅니다.

지난 포스팅은 customPaging 옵션 방식으로 제작한 것이고

이번엔 AsNavFor 방식으로 제작하게되었습니다.

 

 

AsNavFor 옵션 example 확인해보기

react-slick.neostack.com/docs/example/as-nav-for

 

Neostack

The last react carousel you will ever need

react-slick.neostack.com

 

 

끊어서 코드를 확인해보겠습니다.

import 

import React, { useCallback, useEffect, useRef, useState } from 'react';
import Slick from 'react-slick';
import styled, { css } from 'styled-components';

import { LeftOutlined, RightOutlined } from '@ant-design/icons';

Slick이 필수나머지는 꾸미기 위해 import해온 부분들입니다.

 

 

Style

const Wrap = styled.div`
    overflow: hidden;

    & > div + div {
        margin-top: 20px;
    }
`;

const Inner = styled.div`
    position: relative;

    .paging_items {
        filter: grayscale(1);

        &:hover {
            filter: none;
        }
    }

    .slick-current .paging_items {
        filter: none;
    }
`;

const defaultItemStyle = css`
    width: 100%;    
    text-align: center;

    img {
        height: 100%;
        vertical-align: top;
    }
`;

const MainSlickItems = styled.div`
    ${defaultItemStyle}    
    height: 350px;

    img {
        max-width: 100%;
    }
`;

const PagingItems = styled.div`
    ${defaultItemStyle}    
    height: 80px;
    cursor: pointer;
    
    img {
        width: 100%;
    }
`;

const defaultButtonStyle = css`
    position: absolute;
    top: 50%;
    padding: 0;
    width: 30px;
    height: 30px;
    line-height: 1;
    border: none;
    border-radius: 50%;
    background: none;
    outline: none;
    transform:translateY(-50%);
    cursor: pointer;
`;

const PrevButton = styled.button`
    ${defaultButtonStyle}
    left: 0;
`;

const NextButton = styled.button`
    ${defaultButtonStyle}
    right: 0;
`;

const defaultIconStyle = css`
    font-size: 22px;
    color: #dedede;

    &:focus,
    &:hover { 
        color: #666;
    }
`;

const PrevIcon = styled(LeftOutlined)`
    ${defaultIconStyle}
`;

const NextIcon = styled(RightOutlined)`
    ${defaultIconStyle}
`;

스타일은 꾸미기 나름이고 여기서 설명할 부분은 슬릭이 부여하는 클래스들인데요. 

선택된 아이템에는 slick-current가 노출되고 있는 아이템들에는 slick-active가..

그리고 centerMode가 true로 되어있어서 선택 후 가운데에 위치하게되는 객체에 .slick-current 클래스가 붙습니다.

저는 선택된 객체에 스타일을 주기위해 해당 .slick-current 클래스를 사용했고, 

hover를 추가해주기 위해서 Slick을 감싸고 있는 엘리먼트에 스타일을 추가하는 방식으로 진행했습니다.

 

 

Component_1

return 영역을 제외하고 먼저 보겠습니다.

const Slide = ({ images }) => {

	// 1.
    const [mainSlick, setMainSlick] = useState(null);
    const [pagingSlick, setPagingSlick] = useState(null);
    const mainSlickRef = useRef(null);
    const pagingSlickRef = useRef(null);

    useEffect(() => {
        setMainSlick(mainSlickRef.current);
        setPagingSlick(pagingSlickRef.current);
    }, []);
	
    // 2.
    const mainSettings = {
        dots: false,
        arrows: false,
        infinite: true,
        slidesToShow: 1,
        slidesToScroll: 1,
    };
    
	// 2.
    const pagingSettings = {
        dots: false,
        arrows: false,
        centerMode: true,
        slidesToShow: 8,
        swipeToSlide: true,
        focusOnSelect: true,
    };
	
    // 3.
    const onClickPrev = useCallback((ref) => () => ref.current.slickPrev(), []);
    const onClickNext = useCallback((ref) => () => ref.current.slickNext(), []);

    return (
    	//...
    );
};

export default Slide;

1.

ref만 저장해서 작업을 끝내고 싶었지만.. 

state를 사용하지않으면 동작하지않아서 state를 추가했습니다. 

아마 비동기때문인 것 같은데, 좋은 방법이있다면 댓글로 짚어주시면 감사합니다.

컴포넌트 마운트 시에 state 부분에 ref.current를 넣어줍니다.

 

2.

메인이 될 슬라이드와 페이지 번호를 담당할 슬라이드의 셋팅입니다.

 

3.

클릭 함수 하나로 해결하기 위해 대상이 될 ref를 인자로 받아 처리합니다.

 

 

Component_2

return (
  <Wrap>
    <Inner>
      <Slick 
      
      	{/* 1 */}
        ref={mainSlickRef} 
        asNavFor={pagingSlick}
        {...mainSettings}
      >
      
      	{/* 2 */}
        {images.map((v, i) => {
          return (
            <MainSlickItems key={`${v.title}_${i}`}>
            	<img src={v.src} />
            </MainSlickItems>
          )
        })}
      </Slick>

	{/* 3 */}
    <>
      <PrevButton onClick={onClickPrev(mainSlickRef)}>
        <PrevIcon />
      </PrevButton>

      <NextButton onClick={onClickNext(mainSlickRef)}>
        <NextIcon />
      </NextButton>
    </>
    </Inner>

    <Inner>
      <Slick
        ref={pagingSlickRef}
        asNavFor={mainSlick}
        {...pagingSettings}
      >
        {images.map((v, i) => {
          return (
            <PagingItems 
              key={`${v.title}_${i}`}
              className="paging_items"
            >
            	<img src={v.src} />
            </PagingItems>
          )
        })}
      </Slick>

      <>
        <PrevButton onClick={onClickPrev(pagingSlickRef)}>
          <PrevIcon />
        </PrevButton>

        <NextButton onClick={onClickNext(pagingSlickRef)}>
          <NextIcon />
        </NextButton>
      </>
    </Inner>
  </Wrap>
);

1.

ref에 해당 객체를 담아줍니다.

asNavFor에 같이 동작할 객체를 저장한 state를 넣어줍니다.

세팅도 넣어줍니다.

 

2.

슬라이드에 표현할 이미지를 반복시킵니다. (당연히 메인이랑 페이지번호부분이 동일한 이미지여야겠죠)

 

3. 

슬라이드 이전, 다음 버튼입니다.

이 부분에서 ref를 넣어줘야 prev, next 함수 실행 시에 어떤 객체를 대상으로 할 지 설정할 수 있습니다.

 

 

메인 슬라이드와 페이지번호 슬라이드는 매우 비슷합니다.

하지만 스타일이나 조금씩 다른 부분이 있어서 하나로 사용하지 못했습니다.

(따로 컴포넌트로 분리하면 가능할 법도 합니다만...)

 

 

완성코드입니다.

import React, { useCallback, useEffect, useRef, useState } from 'react';
import Slick from 'react-slick';
import styled, { css } from 'styled-components';

import { LeftOutlined, RightOutlined } from '@ant-design/icons';

const Wrap = styled.div`
    overflow: hidden;

    & > div + div {
        margin-top: 20px;
    }
`;

const Inner = styled.div`
    position: relative;

    .paging_items {
        filter: grayscale(1);

        &:hover {
            filter: none;
        }
    }

    .slick-current .paging_items {
        filter: none;
    }
`;

const defaultItemStyle = css`
    width: 100%;    
    text-align: center;

    img {
        height: 100%;
        vertical-align: top;
    }
`;

const MainSlickItems = styled.div`
    ${defaultItemStyle}    
    height: 350px;

    img {
        max-width: 100%;
    }
`;

const PagingItems = styled.div`
    ${defaultItemStyle}    
    height: 80px;
    cursor: pointer;
    
    img {
        width: 100%;
    }
`;

const defaultButtonStyle = css`
    position: absolute;
    top: 50%;
    padding: 0;
    width: 30px;
    height: 30px;
    line-height: 1;
    border: none;
    border-radius: 50%;
    background: none;
    outline: none;
    transform:translateY(-50%);
    cursor: pointer;
`;

const PrevButton = styled.button`
    ${defaultButtonStyle}
    left: 0;
`;

const NextButton = styled.button`
    ${defaultButtonStyle}
    right: 0;
`;

const defaultIconStyle = css`
    font-size: 22px;
    color: #dedede;

    &:focus,
    &:hover { 
        color: #666;
    }
`;

const PrevIcon = styled(LeftOutlined)`
    ${defaultIconStyle}
`;

const NextIcon = styled(RightOutlined)`
    ${defaultIconStyle}
`;

const Slide = ({ images }) => {
    const [mainSlick, setMainSlick] = useState(null);
    const [pagingSlick, setPagingSlick] = useState(null);
    const mainSlickRef = useRef(null);
    const pagingSlickRef = useRef(null);

    useEffect(() => {
        setMainSlick(mainSlickRef.current);
        setPagingSlick(pagingSlickRef.current);
    }, []);

    const mainSettings = {
        dots: false,
        arrows: false,
        infinite: true,
        slidesToShow: 1,
        slidesToScroll: 1,
    };

    const pagingSettings = {
        dots: false,
        arrows: false,
        centerMode: true,
        slidesToShow: 8,
        swipeToSlide: true,
        focusOnSelect: true,
    };

    const onClickPrev = useCallback((ref) => () => ref.current.slickPrev(), []);
    const onClickNext = useCallback((ref) => () => ref.current.slickNext(), []);

    return (
        <Wrap>
            <Inner>
                <Slick 
                    ref={mainSlickRef} 
                    asNavFor={pagingSlick}
                    {...mainSettings}
                >
                    {images.map((v, i) => {
                        return (
                            <MainSlickItems key={`${v.title}_${i}`}>
                                <img src={v.src} />
                            </MainSlickItems>
                        )
                    })}
                </Slick>

                <>
                    <PrevButton onClick={onClickPrev(mainSlickRef)}>
                        <PrevIcon />
                    </PrevButton>

                    <NextButton onClick={onClickNext(mainSlickRef)}>
                        <NextIcon />
                    </NextButton>
                </>
            </Inner>

            <Inner>
                <Slick
                    ref={pagingSlickRef}
                    asNavFor={mainSlick}
                    {...pagingSettings}
                >
                    {images.map((v, i) => {
                        return (
                            <PagingItems 
                                key={`${v.title}_${i}`}
                                className="paging_items"
                            >
                                <img src={v.src} />
                            </PagingItems>
                        )
                    })}
                </Slick>

                <>
                    <PrevButton onClick={onClickPrev(pagingSlickRef)}>
                        <PrevIcon />
                    </PrevButton>

                    <NextButton onClick={onClickNext(pagingSlickRef)}>
                        <NextIcon />
                    </NextButton>
                </>
            </Inner>
        </Wrap>
    );
};

export default Slide;

 

슬라이드 완성 이미지

이미지 출처: pixabay 

 

 

동작도 확인해보겠습니다.

슬라이드 완성 이미지 gif

 

 

이렇게 하면 많은 이미지로도 깨지지않고 작업할 수 있습니다~

 

 

 

사진첩을 만들고 있는데,

그냥 정사각형으로 늘어놓으니.. 

 

정사각 예시

 

너무 안예쁘죠?

그래서 핀터레스트처럼 만들어보기로 했습니다.

핀터레스트에 사용한 레이아웃이름은 masonry layout이라고 하네요.

 

작업에 사용한 이미지들의 출처는 pixabay 입니다.

 

핀터레스트 메인

 

라이브러리로도 있는데,

어떠분이 css로 만들었더라구요!  => darrengwon.tistory.com/569

 

이 분의 코드를 참고해서 react에 적용했습니다.

거의 다 가져왔지만, ㅎㅎ 그 중에서 수정 두가지를 했는데요.

상단에 마진이 생기는 버그? 부분과 hover 시에 흑백으로 보여지는 것을 반대로 수정했습니다.

 

라이브러리 => masonry.desandro.com/

 

Masonry

Install Download CDN Link directly to Masonry files on unpkg. Package managers Install with Bower:  bower install masonry --save Install with npm:  npm install masonry-layout Getting started HTML Include the Masonry .js file in your site. Masonry works o

masonry.desandro.com

 

 

먼저 react에 적용해보기 전에 테스트로 만든 코드입니다.

 

HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div class="wrap"></div>
</body>
</html>

 

 

CSS

.wrap {
  column-count: 4;
  column-gap: 1em;
}

.items {
  display: flex;
  justify-content: center;
  margin-bottom: 1em;
}

.figure {
	display: inline-block;
}

.figure:hover {
	filter: grayscale(0.8);
}

.figure img {
	width: 100%;
}

 

 

JS

const wrap = document.querySelector('.wrap');
const list = [
  'https://cdn.pixabay.com/photo/2020/09/02/20/52/dock-5539524__340.jpg',
  'https://cdn.pixabay.com/photo/2021/02/03/13/54/cupcake-5978060__340.jpg',
  'https://cdn.pixabay.com/photo/2020/05/25/20/14/holland-iris-5220407__340.jpg',
  'https://cdn.pixabay.com/photo/2020/10/08/17/39/waves-5638587__340.jpg',
  'https://cdn.pixabay.com/photo/2019/01/30/11/17/zebra-3964360__340.jpg',
  'https://cdn.pixabay.com/photo/2021/02/01/13/37/cars-5970663__340.png',
  'https://cdn.pixabay.com/photo/2019/06/05/10/34/mimosa-4253396__340.jpg',
  'https://cdn.pixabay.com/photo/2020/08/04/14/42/sky-5463015__340.jpg',
  'https://cdn.pixabay.com/photo/2021/02/03/13/54/cupcake-5978060__340.jpg',
  'https://cdn.pixabay.com/photo/2020/01/09/01/00/the-eye-on-the-greek-4751572__340.png',
  'https://cdn.pixabay.com/photo/2021/01/30/12/19/couple-5963678__340.png',
  'https://cdn.pixabay.com/photo/2021/01/23/07/53/dogs-5941898__340.jpg',
  'https://cdn.pixabay.com/photo/2020/06/15/01/06/sunset-5299957__340.jpg',
];

for (i = 0; i < list.length; i++) {
  const items = document.createElement('div');
  const figure = document.createElement('div');
  const img = document.createElement('img');

  items.classList.add('items');
  figure.classList.add('figure');

  img.src = list[i];

  figure.append(img);
  items.append(figure);

  wrap.append(items);
}

 

 

list 배열로 반복문을 돌려서 돔을 생성해서 append 시켜주는 형태로 테스트 코드를 만들었습니다.

결과물은 아래와 같이 잘 나옵니다.

 

코드 결과물

 

 

예쁘게 잘 나오네요. 굿굿

css에서  wrap에 column-count: 4;의 숫자를 조절하면 가로로 늘어 놓을 사진의 수를 늘릴 수 있습니다.

 

 

자 그럼 react에 적용한 코드입니다.

import React, { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';

const Wrap = styled.div`
    padding-bottom: 6%;
    column-count: 4;
    column-gap: 1em;
`;

const Items = styled.div`
    display: flex;
    justify-content: center;
    margin-bottom: 1em; 
    cursor: pointer;
`;

const Figure = styled.div`
    display: inline-block;
    filter: grayscale(0.8);

    &:hover { 
        filter: none;
    }
`;

const Image = styled.img`
    width: 100%;
`;

const Card = () => {
    const sample = [
        'https://cdn.pixabay.com/photo/2020/09/02/20/52/dock-5539524__340.jpg',
        'https://cdn.pixabay.com/photo/2021/02/03/13/54/cupcake-5978060__340.jpg',
        'https://cdn.pixabay.com/photo/2020/05/25/20/14/holland-iris-5220407__340.jpg',
        'https://cdn.pixabay.com/photo/2020/10/08/17/39/waves-5638587__340.jpg',
        'https://cdn.pixabay.com/photo/2019/01/30/11/17/zebra-3964360__340.jpg',
        'https://cdn.pixabay.com/photo/2021/02/01/13/37/cars-5970663__340.png',
        'https://cdn.pixabay.com/photo/2019/06/05/10/34/mimosa-4253396__340.jpg',
        'https://cdn.pixabay.com/photo/2020/08/04/14/42/sky-5463015__340.jpg',
        'https://cdn.pixabay.com/photo/2021/02/03/13/54/cupcake-5978060__340.jpg',
        'https://cdn.pixabay.com/photo/2020/01/09/01/00/the-eye-on-the-greek-4751572__340.png',
        'https://cdn.pixabay.com/photo/2021/01/30/12/19/couple-5963678__340.png',
        'https://cdn.pixabay.com/photo/2021/01/23/07/53/dogs-5941898__340.jpg',
        'https://cdn.pixabay.com/photo/2020/06/15/01/06/sunset-5299957__340.jpg',
    ];
   
    return (
        <Wrap>
            {sample.map((v, i) => {
                return (
                    <Items key={i}>
                        <Figure>
                            <Image src={v} />
                        </Figure>
                    </Items>
                )
            })}
        </Wrap>
    );
};

export default Card;

 

 

이렇게 해서 완성!

react 코드 결과물

 

 

움직이는 것도 확인해봅시다.

 

react 코드 결과물 gif

 

 

+ Recent posts