채팅창 문제의 시작점
프로젝트에서 채팅 컴포넌트와 관련된 코드 중 일부분이다.
chats 이라는 state가 업데이트 될 때 마다, 새로운 채팅요소를 생성하게 될 것인데
채팅방 엘리먼트에 채팅들이 넘치기 시작하면 문제가 발생한다.
문제가 발생한 프로젝트는 리액트였지만, 바닐라로 차근차근 정복해보자.
바닐라를 이해하면 리액트는 effect, ref 정도만 고려해서 그대로 적용하면 된다. 👍👍
HTML을 통한 문제 소개
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
<style>
body{
display:flex;
flex-direction: column;
justify-content: center;
align-items:center;
}
#chatBox{
width:250px;
height:800px;
background-color:burlywood;
display:flex;
flex-direction: column;
overflow:auto;
}
.chat{
display:flex;
justify-content: center;
align-items:center;
background-color: beige;
width:200px;
min-height:100px;
height:100px;
border:1px solid black;
}
</style>
</head>
<body>
<div id="chatBox"">
<div class="chat">chat 0</div>
</div>
<div>
<button id="add">채팅추가</button>
</div>
<script>
const chatBox = document.querySelector('#chatBox');
let chatIdx = 1;
document.querySelector('#add').addEventListener('click',()=>{
const div = document.createElement('div');
div.className='chat';
div.innerHTML = `chat ${chatIdx++}`;
chatBox.appendChild(div);
})
</script>
</body>
</html>
여기서 중요한 내용은
- chatBox에 채팅들이 담긴다.
- add button을 누를 때 마다, chatBox에 채팅이 하나씩 추가된다.
- css overflow 속성에 의해 chatBox 크기 이상으로 채팅들이 담겨 넘치면, 자동으로 스크롤이 생성된다.
실행 결과를 보자.
버튼을 누를 때 마다 채팅이 추가되고 있는데
우리 화면에서는 스크롤만 열심히 춤출 뿐, 추가된 채팅은 직접 스크롤 다운해야 보인다.
분명 뭔가 문제있는 상황이긴 하다.
단순히 이것만 해결하고 넘어가지 말고,
채팅방에서 사용자 편의를 위해 어떤 기능들이 있으면 좋을지 생각해보자.
1. 기본적으로는 항상 새로운 채팅을 보여준다.
2. 유저가 스크롤을 조절하여 특정 채팅을 보고 있다면, 새로운 채팅을 추가하지만 스크롤은 건드리지 않는다.
- 유저가 특정 채팅을 보고 난 뒤 스크롤을 최하위로 내렸다면, 다시 1번 동작을 수행하면 된다.
3. 스크롤 위치와 관계 없이, 유저 자신이 채팅을 보냈다면 새로운 채팅을 보여주도록 하자.
우선 이정도를 구현해보자.
이제 요소의 스크롤 값 속성에 어떻게 접근하면 좋을지 학습할 시간이다.
그렇다.. 이제는 스크롤 요소에 대해 공부해야 할 시간.
혹시 어떻게 해결했는지가 궁금하다면 이 챕터는 건너뛰면 되겠다.
스크롤에는 수평/수직 스크롤 2가지가 있으며, 수직 스크롤(상하 스크롤) 위주로 다룰 것이다.
해결할 문제에 대한 모든 해결책이 담긴 그림이다.
clientHeight, scrollTop, scrollHeight 값을 중심으로 다룰 예정이니,
다른 위치 값이 궁금하다면 아래 링크를 확인하길 바란다.
https://ko.javascript.info/size-and-scroll
elem.clientHeight
요소의 border를 기준으로, border의 내부 컨텐츠의 높이를 나타낸다.
border의 내부는 padding + content-box 이므로,
clientHeight = padding-top + padding-bottom + content-box width이다.
elem.scrollHeight
clientHeight와 같이 border 내부의 컨텐츠 크기를 나타낸다.
그러나, scrollHeight은 감춰진 영역을 포함한 요소의 전체 크기를 나타낸다.
그래서 높이가 처음 설정한 800px를 넘어가면, scrollHeight 값은 증가하게 된다.
반면에 clientHeight 값은 설정한 높이 800px를 유지한다.
elem.scrollTop
scrollTop은 스크롤에 의해 숨겨진 윗 부분 영역의 높이를 의미한다.
특히 scrollTop과 scrollLeft 속성은 스크롤 속성중에서 읽기전용이 아닌 쓰기도 가능한 프로퍼티다.
브라우저에서 동작하는 스크롤이 scrollTop 값을 기반으로 위치하기 때문이다.
그래서 스크롤의 위치를 건드리려면 scrollTop / scrollLeft을 이용하면 되겠다.
준비운동은 이정도로 마치고
이제는 앞서 정의한 기능들을 위해, 이 속성들을 어떻게 이용하면 좋을지 생각해볼 시간이다.
채팅방 스크롤 기능을 구현해보자.
다음은 정의한 기능 목록이다.
1. 기본적으로는 항상 새로운 채팅을 보여준다.
2. 유저가 스크롤을 조절하여 특정 채팅을 보고 있다면, 새로운 채팅을 추가하지만 스크롤은 건드리지 않는다.
- 유저가 특정 채팅을 보고 난 뒤 스크롤을 최하위로 내렸다면, 다시 1번 동작을 수행하면 된다.
3. 스크롤 위치와 관계 없이, 유저 자신이 채팅을 보냈다면 새로운 채팅을 보여주도록 하자.
1. 기본적으로는 항상 새로운 채팅을 보여준다.
가장 간단한 방법으로는 새로운 채팅이 추가될 때 마다, elem.scrollTop 값을 키워준다.
예를 들면, 새로운 채팅 렌더링마다 scrollTop = 1e9 와 같은 큰 값을 설정할 수 있겠다.
예시코드)
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
<style>
body{
display:flex;
flex-direction: column;
justify-content: center;
align-items:center;
}
#chatBox{
width:250px;
height:800px;
background-color:burlywood;
display:flex;
flex-direction: column;
overflow:auto;
}
.chat{
display:flex;
justify-content: center;
align-items:center;
background-color: beige;
width:200px;
min-height:100px;
height:100px;
border:1px solid black;
}
</style>
</head>
<body>
<div id="chatBox"">
<div class="chat">chat 0</div>
</div>
<div>
<button id="add">채팅추가</button>
</div>
<script>
const chatBox = document.querySelector('#chatBox');
let chatIdx = 1;
document.querySelector('#add').addEventListener('click',()=>{
const div = document.createElement('div');
div.className='chat';
div.innerHTML = `chat ${chatIdx++}`;
chatBox.appendChild(div);
chatBox.scrollTop = 1e9;
})
</script>
</body>
</html>
2. 유저가 스크롤을 조절하여 특정 채팅을 보고 있다면, 새로운 채팅을 추가하지만 스크롤은 건드리지 않는다.
1번 기능에 의하면, 새로운 채팅이 추가되면 항상 스크롤이 내려가게 된다.
그렇다면 사용자는 이미 지나가버린 채팅을 보기가 쉽지 않다.
이를 개선해보자.
사용자가 스크롤을 조절하여 특정 채팅을 있는 상황에서는, 1번의 상황보다 scrollTop 값이 작아진다.
그렇다면, 사용자가 특정 채팅을 보면 scrollTop + clientHeight < scrollHeight 를 만족해야 한다.
우선, 스크롤 가능 변수를 도입하고, 요소에서 scroll 이벤트를 감지해보자.
특정 채팅을 보는 상황에서는 스크롤 가능 변수를 비활성화하고,
특정 채팅을 보고 있지 않은 상황이라면 스크롤 가능 변수를 활성화해주면 된다.
예시코드
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
<style>
body{
display:flex;
flex-direction: column;
justify-content: center;
align-items:center;
}
#chatBox{
width:250px;
height:800px;
background-color:burlywood;
display:flex;
flex-direction: column;
overflow:auto;
}
.chat{
display:flex;
justify-content: center;
align-items:center;
background-color: beige;
width:200px;
min-height:100px;
height:100px;
border:1px solid black;
}
</style>
</head>
<body>
<div id="chatBox"">
<div class="chat">chat 0</div>
</div>
<div>
<button id="add">채팅추가</button>
</div>
<script>
const chatBox = document.querySelector('#chatBox');
let chatIdx = 1;
let isScrollable = true;
document.querySelector('#add').addEventListener('click',()=>{
const div = document.createElement('div');
div.className='chat';
div.innerHTML = `chat ${chatIdx++}`;
chatBox.appendChild(div);
if(isScrollable) chatBox.scrollTop = 1e9;
})
chatBox.addEventListener('scroll', (e)=>{
const {scrollHeight, scrollTop, clientHeight} = e.target;
if(clientHeight + scrollTop < scrollHeight){
isScrollable = false;
return;
}
isScrollable = true;
})
</script>
</body>
</html>
3. 스크롤 위치와 관계 없이, 유저 자신이 채팅을 보냈다면 새로운 채팅을 보여주도록 하자.
유저 자신이 채팅을 보내면, isScrollable 변수를 활성화시킨다.
그러면 1번 기능에 의해, 자동으로 요구사항을 만족하게 된다.
예시 코드는 생략
간단한 예시로만 구성해서 바로 적용하기에 무리가 있을수도 있다.
여러분 프로젝트에 다른 외부변수들이 있다면, 이것도 열심히 고민해봅시다!. ㅎㅎㅎ
그래서 리액트에는 어떻게 적용하는데..?
아마 2번기능까지 이해하셨다면, 방향이 보이겠지만
이대로 끝내기 아쉬워서 리액트에서 어떻게 적용할지에 대해서도 간단하게 적어보겠다.
채팅방 요소에 ref를 달아둔다.
useEffect의 의존성 배열에 채팅 상태(ex. chats)을 추가하고, 1번 기능을 설정한다.
새로운 ref 변수 하나를 추가한다.(=이건 jsx에 부착하지 않고, isScrollable로 사용할 것)
채팅방 요소의 jsx에 onScroll 이벤트리스너를 달아주고, 2번 기능을 설정한다.
3번 기능에 대해서는, 내가 보낸 채팅은 바로 isScrollabe=true로 바꿔주고 setChats를 실행하면 되겠다.
아마 코드로는 대충 이렇지 않을까?
// react
// ChattingRoom.jsx
function ChattingRoom(){
const [myChat, setMyChat] = useState('');
const [chats, setChats] = useState([]);
const chatContainerRef = useRef(null);
const isScrollable = useRef(true);
function onChange(e){
setMyChat(e.target.value);
}
function onKeyPress(e){
if(// 누른 키가 enter라면? ){
const newChat = {id:chats.length + 1, text: myChat}
setchats(prevChats => {
return [...prevChats, newChat];
});
setMyChat('');
isScrollable.current=true;
}
}
function onScroll(e){
const {scrollHeight, scrollTop, clientHeight} = e.target;
if(clientHeight + scrollTop < scrollHeight){
isScrollable.current = false;
return;
}
isScrollable.current = true;
}
useEffect(()=>{
if(isScrollable.current){
chatContainerRef.current.scrollTop = 1e9;
}
}, [chats]);
return (
<ChatContainer ref={chatContainerRef} onScroll={onScroll}>
{chats.map(chat => <Chat key={chat.id}>{chat.text}</Chat>)}
<input type='text' placeholder="채팅입력" value={myChat} onChange={onChange} onKeypress={onKeyPress}/>
</ChatContainer>
)
}
// Chat.jsx
export default React.memo(function Chat(){...});
물론 실행해보진 않아서, 의도한대로 동작하진 않을 수 있다. 아무튼 이런 느낌이다.
마지막에는 React.memo를 적용하기에 좋은 사례이기에 작게 추가해뒀다.
마치며
요즘은 기존의 프로젝트를 복습할 겸, 다시 재구성하고 있다.
실시간 채팅 기능을 구현하다가, 여차저차해서 문제를 해결을 하게 된 기념으로..!
나와 비슷한 상황을 접한 분들에게 도움이 되었으면 좋겠어서 정리하게 되었다.
이 포스팅 내용은 학습한 것을 기반으로 구현한 기능이라, 좀 더 효율적인 방법이 있을 수 있다.
(아마 있을 가능성이 크다.)
이외에 스크롤 이벤트나 채팅 렌더링에 대한 최적화라던가..는 또 열심히 고민해봅시다. ㅎㅎㅎ
참고한 자료
https://ko.javascript.info/size-and-scroll
'JavaScript > vanilla' 카테고리의 다른 글
[VanillaJS] 바닐라JS만으로 To Do List 만들어보자 3 (0) | 2021.04.30 |
---|---|
[VanillaJS] 바닐라JS만으로 To Do List 만들어보자 2 (0) | 2021.04.30 |
[VanillaJS] 바닐라JS만으로 To Do List 만들어보자 1 (0) | 2021.04.30 |