React를 사용한 큰 목록 성능


86

React로 필터링 가능한 목록을 구현하는 중입니다. 목록의 구조는 아래 이미지와 같습니다.

여기에 이미지 설명 입력

전제

작동 방식에 대한 설명은 다음과 같습니다.

  • 상태는 가장 높은 수준의 구성 요소 인 Search구성 요소에 있습니다.
  • 상태는 다음과 같이 설명됩니다.
{
    visible : 부울,
    파일 : 배열,
    필터링 됨 : 배열,
    쿼리 : 문자열,
    currentSelectedIndex : 정수
}
  • files 파일 경로를 포함하는 잠재적으로 매우 큰 배열입니다 (10000 개의 항목은 그럴듯한 숫자입니다).
  • filtered사용자가 2 자 이상을 입력 한 후 필터링 된 배열입니다. 나는 그것이 파생 데이터라는 것을 알고 있으며 그러한 주장은 그것을 상태에 저장하는 것에 대해 만들 수 있지만
  • currentlySelectedIndex 필터링 된 목록에서 현재 선택된 요소의 색인입니다.

  • 사용자가 Input구성 요소에 2 개 이상의 문자를 입력하면 배열이 필터링되고 필터링 된 배열의 각 항목에 대해 Result구성 요소가 렌더링됩니다.

  • Result구성 요소는 쿼리와 부분적으로 일치하는 전체 경로를 표시하고 경로의 부분 일치 부분이 강조 표시됩니다. 예를 들어, 사용자가 'le'을 입력했다면 Result 컴포넌트의 DOM은 다음과 같습니다.

    <li>this/is/a/fi<strong>le</strong>/path</li>

  • Input구성 요소에 포커스가있는 동안 사용자가 위쪽 또는 아래쪽 키를 누르면 어레이 currentlySelectedIndex에 따라 변경 filtered됩니다. 이로 인해 Result인덱스와 일치 하는 구성 요소가 선택된 것으로 표시되어 다시 렌더링됩니다.

문제

처음에는 filesReact의 개발 버전을 사용하여 충분히 작은 배열로 이것을 테스트 했으며 모두 잘 작동했습니다.

문제는 내가 files10000 개의 항목만큼 큰 배열 을 처리해야 할 때 나타났습니다 . 입력에 2 개의 문자를 입력하면 큰 목록이 생성되고 탐색을 위해 위아래 키를 눌렀을 때 매우 느립니다.

처음에는 요소에 대해 정의 된 구성 요소가 없었으며 구성 Result요소의 각 렌더링에서 즉시 목록을 작성했습니다 Search.

results  = this.state.filtered.map(function(file, index) {
    var start, end, matchIndex, match = this.state.query;

     matchIndex = file.indexOf(match);
     start = file.slice(0, matchIndex);
     end = file.slice(matchIndex + match.length);

     return (
         <li onClick={this.handleListClick}
             data-path={file}
             className={(index === this.state.currentlySelected) ? "valid selected" : "valid"}
             key={file} >
             {start}
             <span className="marked">{match}</span>
             {end}
         </li>
     );
}.bind(this));

알 수 있듯이 currentlySelectedIndex변경 될 때마다 다시 렌더링되고 매번 목록이 다시 생성됩니다. 나는 keyli요소에 값을 설정했기 때문에 React가 변경 li되지 않은 다른 모든 요소를 다시 렌더링하는 것을 피할 것이라고 생각 className했지만 분명히 그렇지 않았습니다.

마지막으로 Result요소에 대한 클래스를 정의하여 각 Result요소가 이전에 선택되었는지 여부와 현재 사용자 입력을 기반으로 다시 렌더링해야하는지 여부를 명시 적으로 확인합니다 .

var ResultItem = React.createClass({
    shouldComponentUpdate : function(nextProps) {
        if (nextProps.match !== this.props.match) {
            return true;
        } else {
            return (nextProps.selected !== this.props.selected);
        }
    },
    render : function() {
        return (
            <li onClick={this.props.handleListClick}
                data-path={this.props.file}
                className={
                    (this.props.selected) ? "valid selected" : "valid"
                }
                key={this.props.file} >
                {this.props.children}
            </li>
        );
    }
});

이제 목록이 다음과 같이 생성됩니다.

results = this.state.filtered.map(function(file, index) {
    var start, end, matchIndex, match = this.state.query, selected;

    matchIndex = file.indexOf(match);
    start = file.slice(0, matchIndex);
    end = file.slice(matchIndex + match.length);
    selected = (index === this.state.currentlySelected) ? true : false

    return (
        <ResultItem handleClick={this.handleListClick}
            data-path={file}
            selected={selected}
            key={file}
            match={match} >
            {start}
            <span className="marked">{match}</span>
            {end}
        </ResultItem>
    );
}.bind(this));
}

이로 인해 성능이 약간 향상되었지만 여전히 충분하지 않습니다. React의 프로덕션 버전에서 테스트했을 때 문제는 전혀 지연없이 부드럽게 작동했습니다.

최저 선

React의 개발 버전과 프로덕션 버전간에 눈에 띄는 불일치가 정상입니까?

React가 목록을 관리하는 방법에 대해 생각할 때 내가 이해하고 있거나 잘못하고 있습니까?

2016 년 11 월 14 일 업데이트

Michael Jackson의 프레젠테이션을 찾았습니다. 여기서 그는 다음과 매우 유사한 문제를 다루고 있습니다. https://youtu.be/7S8v8jfLb1Q?t=26m2s

솔루션은 아래 AskarovBeknar의 답변 에서 제안한 것과 매우 유사 합니다.

업데이트 2018 년 4 월 14 일

이것은 분명히 인기있는 질문이며 원래 질문이 제기 된 이후로 상황이 진전되었으므로 가상 레이아웃을 이해하려면 위에 링크 된 비디오를 시청하는 것이 좋습니다. 또한 React Virtualized 를 사용하는 것이 좋습니다. 당신이 바퀴를 재발 명하고 싶지 않다면 도서관.


React의 개발 / 프로덕션 버전이란 무엇을 의미합니까?
Dibesjr


아, 감사합니다. 따라서 질문 중 하나에 답하기 위해 버전간에 최적화에 불일치가 있다고 말합니다. 큰 목록에서주의해야 할 한 가지는 렌더링에서 함수를 만드는 것입니다. 거대한 목록에 들어가면 성능이 저하됩니다. 성능 도구 facebook.github.io/react/docs/perf.html
Dibesjr

2
Redux 사용을 재고해야한다고 생각합니다. 바로 여기에 필요한 것 (또는 모든 종류의 플럭스 구현)이기 때문입니다. 이 프레젠테이션을 확실히 살펴보아야합니다. Big List High Performance React & Redux
Pierre Criulanscy

2
사용자가 10000 개의 결과를 스크롤 할 때 어떤 이점이 있는지 의심합니다. 따라서 상위 100 개 정도의 결과 만 렌더링하고 쿼리를 기반으로 업데이트하면 어떨까요?
Koen.

답변:


18

이 질문에 대한 다른 많은 답변과 마찬가지로 주요 문제는 주요 이벤트를 필터링하고 처리하는 동안 DOM에서 너무 많은 요소를 렌더링하는 것이 느려질 것이라는 사실에 있습니다.

문제를 일으키는 React와 관련하여 본질적으로 잘못된 것은 아니지만 성능과 관련된 많은 문제와 마찬가지로 UI도 큰 책임을 져야 할 수 있습니다.

UI가 효율성을 염두에두고 설계되지 않은 경우 성능을 발휘하도록 설계된 React와 같은 도구조차도 어려움을 겪을 것입니다.

@Koen이 언급 한대로 결과 집합을 필터링하는 것은 좋은 시작입니다.

나는 아이디어를 조금 가지고 놀았고 이런 종류의 문제를 어떻게 해결할 수 있는지 보여주는 예제 앱을 만들었습니다.

이것은 결코 production ready코드가 아니지만 개념을 적절하게 설명하고 더 견고하게 수정할 수 있습니다. 코드를 자유롭게 살펴볼 수 있습니다. 최소한 몇 가지 아이디어를 제공하기를 바랍니다 ...;)

반응-대형-목록-예제

여기에 이미지 설명 입력


1
답을 하나만 고르는 게 정말 안타까워요. 모두 노력을 기울인 것 같지만, 지금은 PC없이 휴가 중이 어서 그들이 마땅히 받아야 할 관심으로 답을 확인할 수 없습니다. 휴대폰에서 읽을 때도 이해할 수있을만큼 짧고 요점이 있기 때문에 이것을 선택했습니다. 내가 아는 절름발이 이유.
Dimitris Karagiannis

호스트 파일 편집이란 무엇을 의미 127.0.0.1 * http://localhost:3001합니까?
stackjlei

@stackjlei 나는 그가하는 매핑 127.0.0.1을 의미 생각 로컬 호스트 : 3001 / etc / hosts 파일에서
매버릭

16

매우 유사한 문제에 대한 내 경험은 DOM에 한 번에 100-200 개 이상의 구성 요소가 있으면 react가 실제로 어려움을 겪는다는 것입니다. shouldComponentUpdate다시 렌더링 할 때 하나 또는 두 개의 구성 요소 만 변경하도록 매우주의 (모든 키 설정 및 / 또는 메서드 구현 )하더라도 여전히 상처의 세계에있을 것입니다.

현재 반응의 느린 부분은 가상 DOM과 실제 DOM의 차이를 비교할 때입니다. 수천 개의 구성 요소가 있지만 몇 개만 업데이트하는 경우에는 중요하지 않습니다. react는 여전히 DOM간에 수행 할 엄청난 차이 작업을 수행합니다.

지금 페이지를 작성할 때 구성 요소의 수를 최소화하도록 디자인하려고합니다. 큰 구성 요소 목록을 렌더링 할 때 이렇게하는 한 가지 방법은 ... 음 ... 큰 구성 요소 목록을 렌더링하지 않는 것입니다.

내 말은 : 현재 볼 수있는 구성 요소 만 렌더링하고 아래로 스크롤 할 때 더 많이 렌더링합니다. 사용자는 어떤 식 으로든 수천 개의 구성 요소를 아래로 스크롤하지 않을 것입니다.

이를위한 훌륭한 라이브러리는 다음과 같습니다.

https://www.npmjs.com/package/react-infinite-scroll

여기에 훌륭한 방법이 있습니다.

http://www.reactexamples.com/react-infinite-scroll/

하지만 페이지 상단에있는 구성 요소를 제거하지 않는 것이 두렵기 때문에 충분히 오래 스크롤하면 성능 문제가 다시 나타나기 시작합니다.

링크를 답변으로 제공하는 것은 좋지 않다는 것을 알고 있지만, 그들이 제공하는 예제는 내가 여기서 할 수있는 것보다 훨씬 더이 라이브러리를 사용하는 방법을 설명 할 것입니다. 큰 목록이 왜 나쁜지 설명했지만 해결 방법이 있기를 바랍니다.


2
업데이트 :이 답변에있는 패키지는 유지되지 않습니다. 포크는에 설정되어 npmjs.com/package/react-infinite-scroller
알리 알 아민

11

우선, React의 개발 버전과 프로덕션 버전의 차이가 큽니다. 프로덕션에서는 우회 된 온 전성 검사 (예 : prop 유형 확인)가 많기 때문입니다.

그런 다음 Redux 사용을 재고해야한다고 생각합니다. 필요한 사항 (또는 모든 종류의 플럭스 구현)에 매우 유용 할 것이기 때문입니다. 이 프레젠테이션을 확실히 살펴 봐야합니다 : Big List High Performance React & Redux .

그러나 redux에 뛰어 들기 전에 컴포넌트를 더 작은 컴포넌트로 분할하여 React 코드를 약간 조정해야합니다. 왜냐하면 shouldComponentUpdate완전히 자식 렌더링을 우회 할 것이기 때문에 엄청난 이득 입니다.

더 세분화 된 구성 요소가있는 경우 redux 및 react-redux로 상태를 처리하여 데이터 흐름을 더 잘 구성 할 수 있습니다.

나는 최근 1,000 개의 행을 렌더링하고 내용을 편집하여 각 행을 수정할 수 있어야 할 때 비슷한 문제에 직면했습니다. 이 미니 앱은 잠재적 중복 콘서트가있는 콘서트 목록을 표시하며, 확인란을 선택하여 잠재적 중복을 원래 콘서트 (복제 아님)로 표시하려면 각 잠재적 중복을 선택해야하며, 필요한 경우 편집합니다. 콘서트 이름. 특정 잠재적 중복 항목에 대해 아무것도하지 않으면 중복 항목으로 간주되어 삭제됩니다.

다음은 다음과 같습니다.

여기에 이미지 설명 입력

기본적으로 4 개의 전원 구성 요소가 있습니다 (여기에는 행이 하나만 있지만 예제를위한 것입니다).

여기에 이미지 설명 입력

다음은 redux , react-redux , immutable , reselectrecompose를 사용 하는 전체 코드 (Working CodePen : Huge List with React & Redux )입니다 .

const initialState = Immutable.fromJS({ /* See codepen, this is a HUGE list */ })

const types = {
    CONCERTS_DEDUP_NAME_CHANGED: 'diggger/concertsDeduplication/CONCERTS_DEDUP_NAME_CHANGED',
    CONCERTS_DEDUP_CONCERT_TOGGLED: 'diggger/concertsDeduplication/CONCERTS_DEDUP_CONCERT_TOGGLED',
};

const changeName = (pk, name) => ({
    type: types.CONCERTS_DEDUP_NAME_CHANGED,
    pk,
    name
});

const toggleConcert = (pk, toggled) => ({
    type: types.CONCERTS_DEDUP_CONCERT_TOGGLED,
    pk,
    toggled
});


const reducer = (state = initialState, action = {}) => {
    switch (action.type) {
        case types.CONCERTS_DEDUP_NAME_CHANGED:
            return state
                .updateIn(['names', String(action.pk)], () => action.name)
                .set('_state', 'not_saved');
        case types.CONCERTS_DEDUP_CONCERT_TOGGLED:
            return state
                .updateIn(['concerts', String(action.pk)], () => action.toggled)
                .set('_state', 'not_saved');
        default:
            return state;
    }
};

/* configureStore */
const store = Redux.createStore(
    reducer,
    initialState
);

/* SELECTORS */

const getDuplicatesGroups = (state) => state.get('duplicatesGroups');

const getDuplicateGroup = (state, name) => state.getIn(['duplicatesGroups', name]);

const getConcerts = (state) => state.get('concerts');

const getNames = (state) => state.get('names');

const getConcertName = (state, pk) => getNames(state).get(String(pk));

const isConcertOriginal = (state, pk) => getConcerts(state).get(String(pk));

const getGroupNames = reselect.createSelector(
    getDuplicatesGroups,
    (duplicates) => duplicates.flip().toList()
);

const makeGetConcertName = () => reselect.createSelector(
    getConcertName,
    (name) => name
);

const makeIsConcertOriginal = () => reselect.createSelector(
    isConcertOriginal,
    (original) => original
);

const makeGetDuplicateGroup = () => reselect.createSelector(
    getDuplicateGroup,
    (duplicates) => duplicates
);



/* COMPONENTS */

const DuplicatessTableRow = Recompose.onlyUpdateForKeys(['name'])(({ name }) => {
    return (
        <tr>
            <td>{name}</td>
            <DuplicatesRowColumn name={name}/>
        </tr>
    )
});

const PureToggle = Recompose.onlyUpdateForKeys(['toggled'])(({ toggled, ...otherProps }) => (
    <input type="checkbox" defaultChecked={toggled} {...otherProps}/>
));


/* CONTAINERS */

let DuplicatesTable = ({ groups }) => {

    return (
        <div>
            <table className="pure-table pure-table-bordered">
                <thead>
                    <tr>
                        <th>{'Concert'}</th>
                        <th>{'Duplicates'}</th>
                    </tr>
                </thead>
                <tbody>
                    {groups.map(name => (
                        <DuplicatesTableRow key={name} name={name} />
                    ))}
                </tbody>
            </table>
        </div>
    )

};

DuplicatesTable.propTypes = {
    groups: React.PropTypes.instanceOf(Immutable.List),
};

DuplicatesTable = ReactRedux.connect(
    (state) => ({
        groups: getGroupNames(state),
    })
)(DuplicatesTable);


let DuplicatesRowColumn = ({ duplicates }) => (
    <td>
        <ul>
            {duplicates.map(d => (
                <DuplicateItem
                    key={d}
                    pk={d}/>
            ))}
        </ul>
    </td>
);

DuplicatessRowColumn.propTypes = {
    duplicates: React.PropTypes.arrayOf(
        React.PropTypes.string
    )
};

const makeMapStateToProps1 = (_, { name }) => {
    const getDuplicateGroup = makeGetDuplicateGroup();
    return (state) => ({
        duplicates: getDuplicateGroup(state, name)
    });
};

DuplicatesRowColumn = ReactRedux.connect(makeMapStateToProps1)(DuplicatesRowColumn);


let DuplicateItem = ({ pk, name, toggled, onToggle, onNameChange }) => {
    return (
        <li>
            <table>
                <tbody>
                    <tr>
                        <td>{ toggled ? <input type="text" value={name} onChange={(e) => onNameChange(pk, e.target.value)}/> : name }</td>
                        <td>
                            <PureToggle toggled={toggled} onChange={(e) => onToggle(pk, e.target.checked)}/>
                        </td>
                    </tr>
                </tbody>
            </table>
        </li>
    )
}

const makeMapStateToProps2 = (_, { pk }) => {
    const getConcertName = makeGetConcertName();
    const isConcertOriginal = makeIsConcertOriginal();

    return (state) => ({
        name: getConcertName(state, pk),
        toggled: isConcertOriginal(state, pk)
    });
};

DuplicateItem = ReactRedux.connect(
    makeMapStateToProps2,
    (dispatch) => ({
        onNameChange(pk, name) {
            dispatch(changeName(pk, name));
        },
        onToggle(pk, toggled) {
            dispatch(toggleConcert(pk, toggled));
        }
    })
)(DuplicateItem);


const App = () => (
    <div style={{ maxWidth: '1200px', margin: 'auto' }}>
        <DuplicatesTable />
    </div>
)

ReactDOM.render(
    <ReactRedux.Provider store={store}>
        <App/>
    </ReactRedux.Provider>,
    document.getElementById('app')
);

방대한 데이터 세트로 작업 할 때이 미니 앱을 통해 얻은 교훈

  • React 구성 요소는 작게 유지 될 때 가장 잘 작동합니다.
  • Reselect는 재 계산을 피하고 동일한 인수가 주어지면 동일한 참조 객체 (immutable.js를 사용할 때)를 유지하는 데 매우 유용합니다.
  • connect구성 요소가 사용하지 않는 소품 만 전달하지 않도록하기 위해 필요한 데이터와 가장 가까운 구성 요소에 대한 ed 구성 요소를 만듭니다.
  • ownProps쓸모없는 재 렌더링을 피하기 위해 주어진 초기 prop 만 필요할 때 mapDispatchToProps를 만들기 위해 fabric 함수를 사용합니다.
  • React & redux는 확실히 함께 흔들립니다!

2
나는 redux에 의존성을 추가하는 것이 OP의 문제를 해결하는 데 필요하다고 생각하지 않습니다. 그의 결과 집합을 필터링하기위한 더 많은 디스패치 작업은 문제를 복합화 할뿐입니다. 디스패치는 생각만큼 저렴하지 않고 로컬 구성 요소로이 특정 상황을 처리합니다. 상태는 가장 효율적인 방법입니다
deowk

4
  1. 개발 버전에서 React는 개발 프로세스를 용이하게하기 위해 각 구성 요소의 proptype을 확인하지만 프로덕션에서는 생략됩니다.

  2. 문자열 목록을 필터링하는 것은 모든 키업에 대해 매우 비용이 많이 드는 작업입니다. JavaScript의 단일 스레드 특성으로 인해 성능 문제가 발생할 수 있습니다. 해결책은 debounce 메서드 를 사용 하여 지연이 만료 될 때까지 필터 함수의 실행을 지연시키는 것입니다.

  3. 또 다른 문제는 거대한 목록 자체 일 수 있습니다. 가상 레이아웃 을 생성하고 생성 된 항목을 데이터를 대체하는 것만으로 재사용 할 수 있습니다 . 기본적으로 고정 높이의 스크롤 가능한 컨테이너 구성 요소를 만들고 그 안에 목록 컨테이너를 배치합니다. 목록 컨테이너의 높이는 스크롤바가 작동하도록하려면 보이는 목록의 길이에 따라 수동으로 설정해야합니다 (itemHeight * numberOfItems). 그런 다음 스크롤 가능한 컨테이너 높이를 채우고 하나 또는 두 개의 모방 연속 목록 효과를 추가 할 수 있도록 몇 가지 항목 구성 요소를 만듭니다. 절대 위치로 만들고 스크롤에서 위치를 이동하여 연속 목록을 모방합니다 (구현 방법을 찾을 수있을 것 같습니다).

  4. 한 가지 더는 DOM에 쓰는 것이 특히 잘못한 경우 비용이 많이 드는 작업입니다. 캔버스를 사용하여 목록을 표시하고 스크롤에서 부드러운 경험을 만들 수 있습니다. 반응 캔버스 구성 요소를 확인하십시오. 그들은 이미 Lists에 대한 작업을 수행했다고 들었습니다.


에 대한 정보가 React in development있습니까? 왜 각 구성 요소의 원형을 확인합니까?
Liuuil

4

React Virtualized Select를 확인하세요.이 문제를 해결하도록 설계되었으며 제 경험에서 인상적인 성능을 발휘합니다. 설명에서 :

react-virtualized 및 react-select를 사용하여 드롭 다운에 많은 옵션 목록을 표시하는 HOC

https://github.com/bvaughn/react-virtualized-select


4

내 의견 에서 언급했듯이 사용자가 브라우저에서 한 번에 10000 개의 결과를 모두 필요로한다고 생각합니다.

결과를 훑어보고 항상 10 개의 결과 목록 만 표시하면 어떨까요?

나는 한 예를 생성 돌아 오는 같은 다른 라이브러리를 사용하지 않고,이 기술을 사용. 현재는 키보드 탐색 만 가능하지만 스크롤 작업을 위해 쉽게 확장 할 수 있습니다.

예제는 컨테이너 애플리케이션, 검색 구성 요소 및 목록 구성 요소의 세 가지 구성 요소로 구성됩니다. 거의 모든 논리가 컨테이너 구성 요소로 이동되었습니다.

요점은 startselected결과를 추적 하고이를 키보드 상호 작용으로 전환하는 데 있습니다.

nextResult: function() {
  var selected = this.state.selected + 1
  var start = this.state.start
  if(selected >= start + this.props.limit) {
    ++start
  }
  if(selected + start < this.state.results.length) {
    this.setState({selected: selected, start: start})
  }
},

prevResult: function() {
  var selected = this.state.selected - 1
  var start = this.state.start
  if(selected < start) {
    --start
  }
  if(selected + start >= 0) {
    this.setState({selected: selected, start: start})
  }
},

필터를 통해 모든 파일을 전달하는 동안 :

updateResults: function() {
  var results = this.props.files.filter(function(file){
    return file.file.indexOf(this.state.query) > -1
  }, this)

  this.setState({
    results: results
  });
},

그리고 기반으로 결과를 얇게 start하고 limitrender방법 :

render: function() {
  var files = this.state.results.slice(this.state.start, this.state.start + this.props.limit)
  return (
    <div>
      <Search onSearch={this.onSearch} onKeyDown={this.onKeyDown} />
      <List files={files} selected={this.state.selected - this.state.start} />
    </div>
  )
}

전체 작동 예제가 포함 된 바이올린 : https://jsfiddle.net/koenpunt/hm1xnpqk/


3

React 컴포넌트에로드하기 전에 필터를 시도하고 컴포넌트에 적절한 양의 항목 만 표시하고 요청시 더 많이로드하십시오. 아무도 한 번에 많은 항목을 볼 수 없습니다.

나는 당신이 아니라고 생각하지만 인덱스를 키로 사용하지 마십시오 .

개발 및 프로덕션 버전이 다른 실제 이유를 알아 profiling보려면 코드를 사용해 볼 수 있습니다.

페이지를로드하고 기록을 시작하고 변경을 수행하고 기록을 중지 한 다음 타이밍을 확인하십시오. Chrome에서 성능 프로파일 링에 대한 안내는 여기를 참조 하세요 .


2

이 문제로 어려움을 겪는 사람을 위해 react-big-list최대 1 백만 개의 레코드까지 목록을 처리 하는 구성 요소 를 작성했습니다 .

또한 다음과 같은 멋진 추가 기능이 제공됩니다.

  • 정렬
  • 캐싱
  • 맞춤 필터링
  • ...

우리는 꽤 많은 앱에서 프로덕션에 사용하고 있으며 훌륭하게 작동합니다.


당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.