Stomp.js 를 이용한 WebSocket 연동

sunjae kim
40 min readNov 24, 2023

--

Check It 프로젝트에서 관리자와의 채팅기능을 구현하기위해 websocket 통신을 사용하였다.

이번 포스팅에서는 Stomp.js를 이용하여 서버와 클라이언트의 실시간 통신을 구현한 내용에 대해서 정리하겠다.

Stomp.js

JavaScript에서 STOMP 프로토콜을 사용하여
WebSocket 기반의 통신에 도움을 주는 라이브러리

STOMP

Simple/Stream Text Oriented Message Protocol

WebSocket 위에서 동작하는 프로토콜로써 클라이언트와 서버가 전송할 메세지의 유형, 형식, 내용들을 정의하는 매커니즘

- 규격을 갖춘 메시지를 보낼 수 있는 텍스트 기반 프로토콜

- publisher, broker, subscriber 를 따로 두어 처리 (pub / sub 구조)

- 연결시에 헤더를 추가하여 인증 처리 구현이 가능

- STOMP 스펙에 정의한 규칙만 잘 지키면 여러 언어 및 플랫폼 간 메세지를 상호 운영할 수 있음

pub / sub란 메세지를 공급하는 주체와 소비하는 주체를 분리해 제공하는 메세징 방법이다.

우체통(Topic)이 있다고 하였을때, 집배원(Publisher)신문(message)을 우체통에 배달하는 행위가 있고, 우체통에 신문이 배달되는 것을 기다렸다가 빼서 보는 구독자(Subscriber)의 행위가 있다.

이를 채팅 시스템에 적용하면 아래와 같다.

  • 채팅방 생성: pub/sub 구현을 위한 Topic 생성
  • 채팅방 입장: Topic 구독
  • 채팅방에서 메세지를 송수신: 해당 Topic으로 메세지를 송신(pub), 메세지를 수신(sub)

PUBLISH, SUBSCRIBE command 요청 Frame에는 메세지가 무엇이고, 누가 받아서 처리할지에 대한 Header 정보가 포함되어 있다.

이런 명령어들은 destination 헤더를 요구하는데 어디에 전송할지, 혹은 어디에서 메세지를 구독할 것 인지를 나타낸다.

위와 같은 과정을 통해 STOMP는 Publish-Subscribe 매커니즘을 제공한다.

즉, Broker를 통해 타 사용자들에게 메세지를 보내거나 서버가 특정 작업을 수행하도록 메세지를 보낼 수 있게 된다.

1. 채팅방 생성(api 요청)

const [roomId, setRoomId] = useState<string>()

// 채팅방 생성 api
async function creatChatroom() {
const access = localStorage.getItem('accessToken')

try {
const response = await axios.post(
'http://localhost:8080/api/v1/chat/rooms',
{},
{
headers: { Authorization: `Bearer ${access}` },
},
)
setRoomId(response.data.data)
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.error('에러 메시지:', error.response?.data?.errorMessage)
} else {
console.error(error)
}
}
}

먼저 채팅방을 생성한 후에 WebSocket 연결을 수행한다.

2. 클라이언트 생성

import Stomp from '@stomp/stompjs'
import { Client } from '@stomp/stompjs'

const [stompClient, setStompClient] = useState<Stomp.Client | null>(null)

const stomp = new Client()

클라이언트 객체를 생성한다.

3. 서버와 연결

const stomp = new Client({
brokerURL: 'ws://localhost:8080/chat',
connectHeaders: {
Authorization: `Bearer ${access}`,
},
debug: (str: string) => {
console.log(str)
},
reconnectDelay: 5000, //자동 재 연결
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
})
setStompClient(stomp)

stomp.activate()
  • 서버의 endpoint/chat 을 brokerURL 로 설정
  • connectHeaders 에 accessToken 추가하여 인증 처리 구현
  • stomp.active() : 클라이언트 활성화

4. 구독하기(Subscribe)

interface Content {
content: string
sender?: string
}

const [messages, setMessages] = useState<Content[]>([])

stomp.onConnect = () => {
console.log('WebSocket 연결이 열렸습니다.')
const subscriptionDestination = isAdmin
? `/exchange/chat.exchange/room.${selectedRoomId}`
: `/exchange/chat.exchange/room.${roomId}`

stomp.subscribe(subscriptionDestination, (frame) => {
try {
const parsedMessage = JSON.parse(frame.body)

console.log(parsedMessage)
setMessages((prevMessages) => [...prevMessages, parsedMessage])
} catch (error) {
console.error('오류가 발생했습니다:', error)
}
})
}
  • destination : 수신할 채널의 주소
  • 관리자 -> 선택한 채팅방을 수신
  • 사용자 -> 3에서 생성한 채팅방을 수신

messages 배열에 새로 받은 메시지를 추가하였다.

5. 전송하기(Publish)

const [messages, setMessages] = useState<Content[]>([]) 

const sendMessage = () => {
// 메시지 전송
if (stompClient && stompClient.connected) {
const destination = isAdmin
? `/pub/chat.message.${selectedRoomId}`
: `/pub/chat.message.${roomId}`

stompClient.publish({
destination,
body: JSON.stringify({
content: inputMessage,
sender: user,
}),
})
}

setInputMessage('')
}
  • destination : 전송할 채널 주소
  • 관리자 -> 선택한 채팅방에 전송
  • 사용자 -> 3에서 생성한 채팅방에 전송
  • body : 입력한 message 와 sender

6. 메세지 구분

<SearchPage.tsx>


import base64 from 'base-64'
import {useEffect, useState } from 'react'

const [userName, setUserName] = useState('')

useEffect(() => {
if (access) {
let payload = access.substring(access.indexOf('.') + 1, access.lastIndexOf('.'))

let dec = base64.decode(payload)

try {
// JSON 문자열을 JavaScript 객체로 파싱
const jsonObject = JSON.parse(dec)

// "sub" 속성에 접근하여 값을 추출
const subValue = jsonObject.sub
const authValue = jsonObject.auth

setUserName(subValue)

if (authValue == 'ROLE_ADMIN') {
setIsAdmin(true)
}
} catch (error) {
console.error('Error parsing JSON:', error)
}
}
}, [])
<ChatModal.tsx>


interface MessagesProps {
sender?: string
userName: string
}

const user = userName

<MessageList>
{messages.map((message, index) => (
<Messages
key={index}
sender={message.sender}
userName={user}
style={{ fontFamily: 'bmfont' }}>
{message.content}
</Messages>
))}
</MessageList>


const MessageList = styled.div`
width: 35rem;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
`

const Messages = styled.div<MessagesProps>`
background: ${(props) =>
props.sender !== props.userName ? 'rgba(231, 227, 227, 0.8)' : 'rgba(191, 198, 106, 0.8)'};
border-radius: 16px;
padding: 8px;
margin: 16px;
font-size: 26px;
align-self: ${(props) => (props.sender !== props.userName ? 'flex-start' : 'flex-end')};

SearchPage.tsx 에서 accessToken 을 decode 하여 username 을ChatModal.tsx 으로 props 전송하였고

sender !== username 일때 스타일링을 다르게 하여 사용자의 메세지와 관리자의 메세지를 구분하였다.

7. 관리자 문의하기

관리자로 로그인할 때, 원하는 사용자와의 채팅을 시작하기 위해 Websocket 연결 이전에 원하는 채널을 선택할 수 있도록 구현했다.

7–1. 채팅방 조회(api 요청)

const [chatRooms, setChatRooms] = useState<ChatRoom[]>([])

// 채팅방 조회 api
async function roomInfo() {
try {
const response = await axios.get('http://localhost:8080/api/v1/chat/rooms/list', {
headers: { Authorization: `Bearer ${access}` },
})
console.log('응답 값', response.data)
const roomData: ChatRoom[] = response.data.data
setChatRooms(roomData)
} catch (error) {
console.log(error)
}
}

기존에 생성되있는 채팅방 목록들을 조회한다.

7–2. 입장할 채팅방 설정

<ChatRoomList>
{chatRooms.map((room, index) => (
<div
key={index}
className="chat-room-item"
onClick={() => {
setSelectedRoomId(room.chat_room_id.toString())
setIsSelect(true)
}}
style={{ fontFamily: 'bmfont' }}>
{room.nickname}
</div>
))}
</ChatRoomList>

조회한 채팅방 리스트를 해당 사용자의 nickname 으로 구분하여 연결할 채널을 선택한다.

구현 화면

참고사항(외부 브로커 도입)

Spring에 내장된 Simple Message Broker는 SpringBoot 서버 내부 메모리에서 동작한다.

하지만, 서버가 다운되어 메시지 전송에 실패할 경우에 이러한 In-Memory 기반의 메시지 큐(Message Broker)로 인해 메시지가 손실될 수 있는 가능성이 높아진다.

추가적으로, In-Memory 기반 시스템은 메시지 모니터링이 쉽지 않다.

이를 RabbitMQ, Redis의 Pub/Sub 와 같은 외부 브로커를 도입하여 해결 할 수 있다.

전체 코드

<SearchPage.tsx>

<SearchPage.tsx>

import React, { useEffect, useState } from 'react'
import axios from 'axios'
import Ask from '../components/search/Ask'
import SearchBar from '../components/search/SearchBar'
import MyHeader from '../components/Header/MyHeader'
import ViewedBooks from '../components/search/ViewedBooks'
import PopularBooks from '../components/search/PopularBook'
import RecentBooks from '../components/search/RecentBooks'
import ChatModal from '../components/search/ChatModal'
import AdminChatModal from '../components/search/AdminChatModal'
import base64 from 'base-64'

const SearchPage = () => {
const [activeSwipe, setActiveSwipe] = useState<number | null>(null)
const [searchQuery, setSearchQuery] = useState<string>('')
const [books, setBooks] = useState<any[]>([])
const [isAsk, setIsAsk] = useState(false)
const [userName, setUserName] = useState('')
const [isAdmin, setIsAdmin] = useState(false)

const access = localStorage.getItem('accessToken')

useEffect(() => {
if (access) {
let payload = access.substring(access.indexOf('.') + 1, access.lastIndexOf('.'))

let dec = base64.decode(payload)

try {
// JSON 문자열을 JavaScript 객체로 파싱
const jsonObject = JSON.parse(dec)

// "sub" 속성에 접근하여 값을 추출
const subValue = jsonObject.sub
const authValue = jsonObject.auth

setUserName(subValue)

if (authValue == 'ROLE_ADMIN') {
setIsAdmin(true)
}
} catch (error) {
console.error('Error parsing JSON:', error)
}
}
}, [])

const handleSwipeClick = (index: number) => {
setActiveSwipe((prev) => (prev === index ? null : index))
}

const handleSearch = async () => {
await fetchBooks(searchQuery)
}

const handleAsk = () => {
setIsAsk(true)
}

const disableHandleAsk = () => {
setIsAsk(false)
}

// 책 정보를 가져오는 함수
const fetchBooks = async (query: string) => {
try {
const response = await axios.get('http://localhost:8080/api/v1/books/search', {
params: { title: query },
})
const fetchedBooks = response.data
if (fetchedBooks.length === 0) {
alert('검색 결과가 없습니다.')
}
setBooks(fetchedBooks)
} catch (error) {
console.error('Failed to fetch books', error)
alert('서버 오류')
}
}

return (
<>
<MyHeader />
<form>
<SearchBar onSearch={handleSearch} onInputChange={setSearchQuery} />
</form>
<ViewedBooks onSwipeClick={handleSwipeClick} active={activeSwipe === 0} books={books} />
<RecentBooks onSwipeClick={handleSwipeClick} active={activeSwipe === 0} />
<PopularBooks onSwipeClick={handleSwipeClick} active={activeSwipe === 0} />
{isAsk ? (
isAdmin ? (
<AdminChatModal
disableHandleAsk={disableHandleAsk}
userName={userName}
isAdmin={isAdmin}
/>
) : (
<ChatModal disableHandleAsk={disableHandleAsk} userName={userName} isAdmin={isAdmin} />
)
) : (
<Ask handleAsk={handleAsk} />
)}
</>
)
}

export default SearchPage

<ChatModal.tsx>

import React, { useState, useEffect } from 'react'
import styled from 'styled-components'
import sendImg from '../../assets/images/send.png'
import Stomp from '@stomp/stompjs'
import { Client } from '@stomp/stompjs'
import axios from 'axios'

interface ChatProps {
disableHandleAsk: () => void
userName: string
isAdmin: boolean
selectedRoomId?: string
}

interface Content {
content: string
sender?: string
}

interface MessagesProps {
sender?: string
userName: string
}

const Chat: React.FC<ChatProps> = ({ disableHandleAsk, userName, isAdmin, selectedRoomId }) => {
const [messages, setMessages] = useState<Content[]>([])
const [inputMessage, setInputMessage] = useState('')
const [stompClient, setStompClient] = useState<Stomp.Client | null>(null)
const [roomId, setRoomId] = useState<string>()
const user = userName
const access = localStorage.getItem('accessToken') // 토큰 저장

// 채팅방 생성 api
async function creatChatroom() {
const access = localStorage.getItem('accessToken')

try {
const response = await axios.post(
'http://localhost:8080/api/v1/chat/rooms',
{},
{
headers: { Authorization: `Bearer ${access}` },
},
)
setRoomId(response.data.data)
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.error('에러 메시지:', error.response?.data?.errorMessage)
} else {
console.error(error)
}
}
}

useEffect(() => {
const initializeChat = async () => {
try {
if (!isAdmin) {
await creatChatroom() // 채팅 룸이 생성될 때까지 기다립니다.
}
const stomp = new Client({
brokerURL: 'ws://localhost:8080/chat',
connectHeaders: {
Authorization: `Bearer ${access}`,
},
debug: (str: string) => {
console.log(str)
},
reconnectDelay: 5000, //자동 재 연결
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
})
setStompClient(stomp)

stomp.activate()

stomp.onConnect = () => {
console.log('WebSocket 연결이 열렸습니다.')
const subscriptionDestination = isAdmin
? `/exchange/chat.exchange/room.${selectedRoomId}`
: `/exchange/chat.exchange/room.${roomId}`

stomp.subscribe(subscriptionDestination, (frame) => {
try {
const parsedMessage = JSON.parse(frame.body)

console.log(parsedMessage)
setMessages((prevMessages) => [...prevMessages, parsedMessage])
} catch (error) {
console.error('오류가 발생했습니다:', error)
}
})
}
} catch (error) {
console.error('채팅 룸 생성 중 오류가 발생했습니다:', error)
}
}

// 채팅 초기설정
initializeChat()

return () => {
if (stompClient && stompClient.connected) {
stompClient.deactivate()
}
}
}, [roomId])

const sendMessage = () => {
// 메시지 전송
if (stompClient && stompClient.connected) {
const destination = isAdmin
? `/pub/chat.message.${selectedRoomId}`
: `/pub/chat.message.${roomId}`

stompClient.publish({
destination,
body: JSON.stringify({
content: inputMessage,
sender: user,
}),
})
}

setInputMessage('')
}

return (
<ChatContainer>
<Header>
<Title>
<TitleImg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="50"
height="40"
viewBox="0 0 54 47"
fill="none">
<path
d="M54 22.0343C54 34.2035 41.9108 44.0686 27 44.0686C24.3258 44.0719 21.6627 43.7481 19.0789 43.1053C17.1079 44.0371 12.582 45.825 4.968 46.9897C4.293 47.0904 3.78 46.4357 4.04663 45.8502C5.24137 43.2187 6.32137 39.7121 6.64537 36.5139C2.511 32.6422 0 27.5743 0 22.0343C0 9.86506 12.0892 0 27 0C41.9108 0 54 9.86506 54 22.0343ZM16.875 22.0343C16.875 21.1994 16.5194 20.3988 15.8865 19.8085C15.2535 19.2182 14.3951 18.8865 13.5 18.8865C12.6049 18.8865 11.7464 19.2182 11.1135 19.8085C10.4806 20.3988 10.125 21.1994 10.125 22.0343C10.125 22.8691 10.4806 23.6698 11.1135 24.2601C11.7464 24.8504 12.6049 25.182 13.5 25.182C14.3951 25.182 15.2535 24.8504 15.8865 24.2601C16.5194 23.6698 16.875 22.8691 16.875 22.0343ZM30.375 22.0343C30.375 21.1994 30.0194 20.3988 29.3865 19.8085C28.7535 19.2182 27.8951 18.8865 27 18.8865C26.1049 18.8865 25.2465 19.2182 24.6135 19.8085C23.9806 20.3988 23.625 21.1994 23.625 22.0343C23.625 22.8691 23.9806 23.6698 24.6135 24.2601C25.2465 24.8504 26.1049 25.182 27 25.182C27.8951 25.182 28.7535 24.8504 29.3865 24.2601C30.0194 23.6698 30.375 22.8691 30.375 22.0343ZM40.5 25.182C41.3951 25.182 42.2535 24.8504 42.8865 24.2601C43.5194 23.6698 43.875 22.8691 43.875 22.0343C43.875 21.1994 43.5194 20.3988 42.8865 19.8085C42.2535 19.2182 41.3951 18.8865 40.5 18.8865C39.6049 18.8865 38.7465 19.2182 38.1135 19.8085C37.4806 20.3988 37.125 21.1994 37.125 22.0343C37.125 22.8691 37.4806 23.6698 38.1135 24.2601C38.7465 24.8504 39.6049 25.182 40.5 25.182Z"
fill="white"
/>
</svg>
</TitleImg>
문의하기
</Title>
<CloseButton onClick={disableHandleAsk}>X</CloseButton>
</Header>

<MessageList>
{messages.map((message, index) => (
<Messages
key={index}
sender={message.sender}
userName={user}
style={{ fontFamily: 'bmfont' }}>
{message.content}
</Messages>
))}
</MessageList>
<InputBox>
<InputField
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
style={{ fontFamily: 'bmfont' }}
/>
<SendButton
// onClick={handleSendMessage}
onClick={sendMessage}></SendButton>
</InputBox>
</ChatContainer>
)
}

export default Chat

const ChatContainer = styled.div`
width: 35rem;
height: 50rem;
border-radius: 2.5rem;
background: #fff;
box-shadow: 4px 4px 10px 0px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;

position: fixed;
bottom: 20px;
right: 50px;
z-index: 1000;
`

const Header = styled.div`
width: 35rem;
height: 6rem;
border-top-right-radius: 30px;
border-top-left-radius: 30px;
display: flex;
justify-content: space-between;
background: rgba(51, 109, 26, 0.9);
align-items: center;
padding: 16px;
font-family: 'bmfont';
`

const Title = styled.h3`
display: flex;
color: #fff;
font-size: 30px;
`
const TitleImg = styled.h3`
display: flex;
color: #fff;
margin-left: 10px;
margin-right: 10px;
`
const CloseButton = styled.button`
background: none;
border: none;
color: #fff;
font-size: 30px;
cursor: pointer;
`
const MessageList = styled.div`
width: 35rem;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
`

const Messages = styled.div<MessagesProps>`
background: ${(props) =>
props.sender !== props.userName ? 'rgba(231, 227, 227, 0.8)' : 'rgba(191, 198, 106, 0.8)'};
border-radius: 16px;
padding: 8px;
margin: 16px;
font-size: 26px;
align-self: ${(props) => (props.sender !== props.userName ? 'flex-start' : 'flex-end')};
`

const InputBox = styled.div`
width: 33rem;
display: flex;
align-items: center;
padding: 16px;
border-top: 1px solid #ccc;
`

const InputField = styled.input`
height: 4rem;
flex: 1;
padding: 8px;
border: 1px solid #ccc;
border-radius: 50px;
font-size: 20px;
`

const SendButton = styled.button`
width: 3rem;
height: 3rem;
margin-left: 8px;
padding: 16px;
background: url(${sendImg}) center/cover no-repeat;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
`

<AdminChatModal.tsx>

import React, { useState, useEffect } from 'react'
import styled from 'styled-components'
import sendImg from '../../assets/images/send.png'
import axios, { AxiosError } from 'axios'
import ChatModal from './ChatModal'

interface AdminChatProps {
disableHandleAsk: () => void
userName: string
isAdmin: boolean
}

interface ChatRoom {
chat_room_id: number
nickname: string
}

const AdminChat: React.FC<AdminChatProps> = ({ disableHandleAsk, userName, isAdmin }) => {
const [selectedRoomId, setSelectedRoomId] = useState<string>()
const [chatRooms, setChatRooms] = useState<ChatRoom[]>([])
const [isSelect, setIsSelect] = useState<boolean>(false)
const access = localStorage.getItem('accessToken') // 토큰 저장

// 채팅방 조회 api
async function roomInfo() {
try {
const response = await axios.get('http://localhost:8080/api/v1/chat/rooms/list', {
headers: { Authorization: `Bearer ${access}` },
})
console.log('응답 값', response.data)
const roomData: ChatRoom[] = response.data.data
setChatRooms(roomData)
} catch (error) {
console.log(error)
}
}
useEffect(() => {
roomInfo()
}, [])

return (
<>
{isSelect ? (
<ChatModal
selectedRoomId={selectedRoomId}
userName={userName}
isAdmin={isAdmin}
disableHandleAsk={disableHandleAsk}
/>
) : (
<ChatContainer>
<Header>
<Title>
<TitleImg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="50"
height="40"
viewBox="0 0 54 47"
fill="none">
<path
d="M54 22.0343C54 34.2035 41.9108 44.0686 27 44.0686C24.3258 44.0719 21.6627 43.7481 19.0789 43.1053C17.1079 44.0371 12.582 45.825 4.968 46.9897C4.293 47.0904 3.78 46.4357 4.04663 45.8502C5.24137 43.2187 6.32137 39.7121 6.64537 36.5139C2.511 32.6422 0 27.5743 0 22.0343C0 9.86506 12.0892 0 27 0C41.9108 0 54 9.86506 54 22.0343ZM16.875 22.0343C16.875 21.1994 16.5194 20.3988 15.8865 19.8085C15.2535 19.2182 14.3951 18.8865 13.5 18.8865C12.6049 18.8865 11.7464 19.2182 11.1135 19.8085C10.4806 20.3988 10.125 21.1994 10.125 22.0343C10.125 22.8691 10.4806 23.6698 11.1135 24.2601C11.7464 24.8504 12.6049 25.182 13.5 25.182C14.3951 25.182 15.2535 24.8504 15.8865 24.2601C16.5194 23.6698 16.875 22.8691 16.875 22.0343ZM30.375 22.0343C30.375 21.1994 30.0194 20.3988 29.3865 19.8085C28.7535 19.2182 27.8951 18.8865 27 18.8865C26.1049 18.8865 25.2465 19.2182 24.6135 19.8085C23.9806 20.3988 23.625 21.1994 23.625 22.0343C23.625 22.8691 23.9806 23.6698 24.6135 24.2601C25.2465 24.8504 26.1049 25.182 27 25.182C27.8951 25.182 28.7535 24.8504 29.3865 24.2601C30.0194 23.6698 30.375 22.8691 30.375 22.0343ZM40.5 25.182C41.3951 25.182 42.2535 24.8504 42.8865 24.2601C43.5194 23.6698 43.875 22.8691 43.875 22.0343C43.875 21.1994 43.5194 20.3988 42.8865 19.8085C42.2535 19.2182 41.3951 18.8865 40.5 18.8865C39.6049 18.8865 38.7465 19.2182 38.1135 19.8085C37.4806 20.3988 37.125 21.1994 37.125 22.0343C37.125 22.8691 37.4806 23.6698 38.1135 24.2601C38.7465 24.8504 39.6049 25.182 40.5 25.182Z"
fill="white"
/>
</svg>
</TitleImg>
문의하기(관리자)
</Title>
<CloseButton onClick={disableHandleAsk}>X</CloseButton>
</Header>
<MessageList>
<ChatRoomList>
{chatRooms.map((room, index) => (
<div
key={index}
className="chat-room-item"
onClick={() => {
setSelectedRoomId(room.chat_room_id.toString())
setIsSelect(true)
}}
style={{ fontFamily: 'bmfont' }}>
{room.nickname}
</div>
))}
</ChatRoomList>
</MessageList>

<InputBox>
<InputField type="text" placeholder="채팅방을 선택해주세요" />
<SendButton></SendButton>
</InputBox>
</ChatContainer>
)}
</>
)
}

export default AdminChat

const ChatContainer = styled.div`
width: 35rem;
height: 50rem;
border-radius: 2.5rem;
background: #fff;
box-shadow: 4px 4px 10px 0px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;

position: fixed;
bottom: 20px;
right: 50px;
z-index: 1000;
`

const Header = styled.div`
width: 35rem;
height: 6rem;
border-top-right-radius: 30px;
border-top-left-radius: 30px;
display: flex;
justify-content: space-between;
background: rgba(51, 109, 26, 0.9);
align-items: center;
padding: 16px;
font-family: 'bmfont';
`

const Title = styled.h3`
display: flex;
color: #fff;
font-size: 30px;
`
const TitleImg = styled.h3`
display: flex;
color: #fff;
margin-left: 10px;
margin-right: 10px;
`
const CloseButton = styled.button`
background: none;
border: none;
color: #fff;
font-size: 30px;
cursor: pointer;
`

const InputBox = styled.div`
width: 33rem;
display: flex;
align-items: center;
padding: 16px;
border-top: 1px solid #ccc;
`

const InputField = styled.input`
height: 4rem;
flex: 1;
padding: 8px;
border: 1px solid #ccc;
border-radius: 50px;
font-size: 20px;
`

const SendButton = styled.button`
width: 3rem;
height: 3rem;
margin-left: 8px;
padding: 16px;
background: url(${sendImg}) center/cover no-repeat;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
`

const ChatRoomList = styled.div`
display: flex;
flex-direction: column;
padding: 16px;
background-color: #f2f2f2;
border-bottom: 1px solid #ccc;

// 채팅방 목록 아이템의 스타일
.chat-room-item {
font-size: 25px;
padding: 10px;
margin: 4px;
border: solid 2px #e0e0e0;
border-radius: 20px;
cursor: pointer;
transition: background-color 0.3s ease;

&:hover {
background-color: #e0e0e0;
}
}
`
const MessageList = styled.div`
width: 35rem;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
`

--

--