๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
Dev ๐Ÿ’ป/Back-end

[Spring/JS] Websocket์„ ์ด์šฉํ•œ ์‹ค์‹œ๊ฐ„ QnA ์ฑ„ํŒ…๋ฐฉ ๋งŒ๋“ค๊ธฐ

by GeekSean 2026. 7. 8.
๋ฐ˜์‘ํ˜•

 

๊ธฐ์กด๊นŒ์ง€ QnA ์›น์„ ๋งŒ๋“ค์–ด ์‚ฌ์šฉ์ค‘์ด์—ˆ๋Š”๋ฐ,

์ถ”๊ฐ€๋กœ ์ฑ„ํŒ…๋ฐฉ ์ฒ˜๋Ÿผ, ์‹ค์‹œ๊ฐ„ ์†Œํ†ต๋„ ๊ฐ€๋Šฅํ•˜๋ฉด ์ข‹๊ฒ ๋‹ค๋Š” ์ƒ๊ฐ์ด ๋“ค์—ˆ๋‹ค.

 

์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ…์„ ์œ„ํ•ด ์‚ฌ์šฉํ•  ๊ธฐ์ˆ  ์Šคํƒ์€ ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

๊ตฌ์„ฑ ์š”์†Œ
์„ ํƒ ๊ธฐ์ˆ 
์ด์œ 
๋ฉ”์‹œ์ง€ ๋ธŒ๋กœ์ปค
STOMP over WebSocket (Spring)
๋ณ„๋„ ์™ธ๋ถ€ ๋ธŒ๋กœ์ปค ์—†์ด ๋‚ด์žฅ์œผ๋กœ ๊ฐ€๋Šฅ
์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ์ €์žฅ์†Œ
Redis Sorted Set
๊ฐœ๋ณ„ ๋ฉ”์‹œ์ง€ TTL ์ฒ˜๋ฆฌ์— ์ตœ์ ํ™”
์‚ฌ์šฉ์ž ์‹๋ณ„
JSESSIONID (HTTP ์„ธ์…˜)
์ƒˆ๋กœ๊ณ ์นจํ•ด๋„ ๋‹‰๋„ค์ž„ ์œ ์ง€
๋ณด์•ˆ/์ธ์ฆ
Spring Security ์„ธ์…˜ ๊ธฐ๋ฐ˜
๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€์™€ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์—ฐ๋™

 

 

WebSocket๊ณผ STOMP

 

WebSocket์€ ํ•œ ๋ฒˆ ์—ฐ๊ฒฐ์ด ๋งบ์–ด์ง€๋ฉด ์„œ๋ฒ„์™€ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์–‘๋ฐฉํ–ฅ์œผ๋กœ ์ž์œ ๋กญ๊ฒŒ ๋ฐ์ดํ„ฐ๋ฅผ ์ฃผ๊ณ ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” ํ†ต์‹  ํ”„๋กœํ† ์ฝœ์ด๋‹ค.

์ฑ„ํŒ…์ฒ˜๋Ÿผ ์‹ค์‹œ๊ฐ„์„ฑ์ด ์ค‘์š”ํ•œ ๊ธฐ๋Šฅ์„ ๋งŒ๋“ค ๋•Œ๋Š” ํด๋ง ๋ฐฉ์‹๋ณด๋‹ค ํ›จ์”ฌ ํšจ์œจ์ ์ด๋‹ค.

 

STOMP๋Š” WebSocket ์œ„์—์„œ ๋™์ž‘ํ•˜๋Š” ๋ฉ”์‹œ์ง€ ํ”„๋กœํ† ์ฝœ์ด๋‹ค.

๋ฉ”์‹œ์ง€์˜ ๋ชฉ์ ์ง€์™€ ๊ตฌ๋… ๊ฐœ๋…์„ ์ง€์›ํ•ด ์ฃผ๊ธฐ ๋•Œ๋ฌธ์—,

๋‹จ์ˆœํžˆ WebSocket๋งŒ ์“ฐ๋Š” ๊ฒƒ๋ณด๋‹ค ์ฑ„ํŒ…๋ฐฉ ๊ด€๋ฆฌ, ํŠน์ • ์‚ฌ์šฉ์ž์—๊ฒŒ๋งŒ ๋ฉ”์‹œ์ง€ ๋ณด๋‚ด๊ธฐ ๋“ฑ์„ ๊ตฌํ˜„ํ•˜๊ธฐ๊ฐ€ ํ›จ์”ฌ ํŽธ๋ฆฌํ•˜๋‹ค.

 

 

๊ธฐ๋Šฅ ๊ตฌํ˜„

 

๋จผ์ € ์ฑ„ํŒ…์„ ๋‹ด์„ DTO๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๊ตฌํ˜„ํ–ˆ๋‹ค.

id๋Š” Redis์—์„œ ์ฐพ๊ธฐ ์œ„ํ•œ ๊ณ ์œ  ์‹๋ณ„์ž๊ณ , sessionId์—๋Š” JSESSIONID๋ฅผ ๋‹ด๋Š”๋‹ค.

@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
    private String id;
    private String sender;
    private String content;
    private String roomId;
    private String timestamp;
    private String sessionId;
}
 

 

๊ทธ๋ฆฌ๊ณ  WebSocketConfig ํด๋ž˜์Šค

Spring Boot์—์„œ๋Š” @EnableWebSocketMessageBroker ์–ด๋…ธํ…Œ์ด์…˜์œผ๋กœ STOMP ๋ธŒ๋กœ์ปค๊ฐ€ ํ™œ์„ฑํ™”๋œ๋‹ค.

ํด๋ผ์ด์–ธํŠธ๊ฐ€ /pub์œผ๋กœ ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐœํ–‰ํ•˜๋ฉด, ์„œ๋ฒ„๋Š” /topic์„ ๊ตฌ๋…์ค‘์ธ ๋ชจ๋‘์—๊ฒŒ ์ „์†กํ•œ๋‹ค.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic", "/queue");   
        registry.setApplicationDestinationPrefixes("/pub");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-chat")
                .addInterceptors(new HttpSessionHandshakeInterceptor())
                .withSockJS();
    }
}
 

 

๊ทธ๋ฆฌ๊ณ  WebSocket ์—ฐ๊ฒฐ์€ ๋งค๋ฒˆ ์ƒˆ STOMP ์„ธ์…˜์„ ๋งŒ๋“ค๊ธฐ ๋•Œ๋ฌธ์—,

HTTP ์„ธ์…˜์˜ JSESSIONID๋ฅผ ๊ทธ๋Œ€๋กœ ์“ฐ๋ ค๋ฉด ํ•ธ๋“œ์…ฐ์ดํฌ ์‹œ์ ์— ๋ณต์‚ฌํ•ด์•ผ ํ•œ๋‹ค.

๋”ฐ๋ผ์„œ ์ธํ„ฐ์…‰ํ„ฐ์— ๋ช…์‹œ๋ฅผ ํ•ด์ฃผ์—ˆ๋‹ค.

public class HttpSessionHandshakeInterceptor implements HandshakeInterceptor {
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                                   WebSocketHandler wsHandler, Map<String, Object> attributes) {
        if (request instanceof ServletServerHttpRequest servletRequest) {
            HttpSession session = servletRequest.getServletRequest().getSession(false);
            if (session != null) {
                attributes.put("JSESSIONID", session.getId()); 
            }
        }
        return true;
    }
}
 

 

์ถ”๊ฐ€๋กœ ์ฑ„ํŒ…์„ ์œ„ํ•ด ํ•„์š”ํ•œ ์›น๋„ SecurityConfig์— ๋ช…์‹œํ•˜์˜€๋‹ค.

@Bean
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/", "/questions/**", "/admin/**", "/css/**", "/js/**", "/images/**", "/webjars/**",
                         "/ws-chat/**", "/chat/**", "/api/chat/**")
        ...
    return http.build();
}
 

 

์ด์ œ ์„œ๋น„์Šค ๋ถ€๋ถ„์„ ๊ตฌํ˜„ํ•ด๋ณด์ž.

ChatService ํด๋ž˜์Šค๋ฅผ ์ƒ์„ฑ ํ›„, ๋ฉ”์‹œ์ง€ ์ „์†ก ๋ฉ”์†Œ๋“œ๋ฅผ ๊ตฌํ˜„ํ–ˆ๋‹ค.

Sorted Set์˜ score๋กœ ๋ฉ”์‹œ์ง€ ์ƒ์„ฑ ์‹œ๊ฐ๊ณผ ZADD๋กœ JSON์„ ์ €์žฅํ•ด์„œ ์ „์†กํ•œ๋‹ค.

public void sendMessage(ChatMessage message) {
    if (message.getId() == null) {
        message.setId(UUID.randomUUID().toString());
    }

    String roomKey = "chat:zset:room:" + message.getRoomId();
    long score = Instant.parse(message.getTimestamp()).toEpochMilli();
    String json = objectMapper.writeValueAsString(message);
    redisTemplate.opsForZSet().add(roomKey, json, score);

    messagingTemplate.convertAndSend("/topic/room/" + message.getRoomId(), message);
}
 

 

๋ฉ”์‹œ์ง€๋Š” 6์‹œ๊ฐ„์ด ์ง€๋‚˜๋ฉด ๋งŒ๋ฃŒ์ฒ˜๋ฆฌ๋ฅผ ํ•  ๊ฒƒ์ด๋ฏ€๋กœ, ํ•ด๋‹น ๋ฉ”์†Œ๋“œ๋„ ๊ตฌํ˜„ํ•ด๋ณด์ž

์ฒ˜์Œ์—๋Š” Redis List๋ฅผ ์ผ์ง€๋งŒ, List๋Š” ํ‚ค ์ „์ฒด์— TTL์ด ๊ฑธ๋ ค์„œ 6์‹œ๊ฐ„ ์ด์ง€๋‚˜๋ฉด ๋ชจ๋“  ๋ฉ”์‹œ์ง€๊ฐ€ ์‚ฌ๋ผ์ง€๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ๋‹ค.

 

๋”ฐ๋ผ์„œ, Sorted Set์œผ๋กœ ๋ฐ”๊พธ๊ณ  ์กฐํšŒํ• ๋•Œ๋งˆ๋‹ค ZREMRANGEBYSCORE๋กœ ์˜ค๋ž˜๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์“ธ์–ด๋ƒˆ๋‹ค.

์ด๋กœ์จ ๋ณ„๋„ ์Šค์ผ€์ค„๋Ÿฌ ์—†์ด๋„ ํ•ด๋‹น ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ ํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.

public List<ChatMessage> getRecentMessages(String roomId) {
    String roomKey = "chat:zset:room:" + roomId;

    long cutoff = Instant.now().minusSeconds(60 * 60 * 6).toEpochMilli();
    redisTemplate.opsForZSet().removeRangeByScore(roomKey, 0, cutoff);

    Set<String> jsonSet = redisTemplate.opsForZSet().range(roomKey, 0, -1);
    if (jsonSet == null || jsonSet.isEmpty()) return Collections.emptyList();

    return jsonSet.stream()
            .map(json -> {
                try { return objectMapper.readValue(json, ChatMessage.class); }
                catch (Exception e) { return null; }
            })
            .filter(Objects::nonNull)
            .sorted(Comparator.comparingLong(m -> Instant.parse(m.getTimestamp()).toEpochMilli()))
            .collect(Collectors.toList());
}
 

 

๋งˆ์ง€๋ง‰์œผ๋กœ ๊ด€๋ฆฌ์ž์šฉ ๋ฉ”์‹œ์ง€ ์‚ญ์ œ ๊ธฐ๋Šฅ๋„ ๊ตฌํ˜„ํ–ˆ๋‹ค.

public boolean deleteMessage(String roomId, String messageId) {
    String roomKey = "chat:zset:room:" + roomId;
    Set<String> all = redisTemplate.opsForZSet().range(roomKey, 0, -1);
    if (all == null) return false;

    for (String json : all) {
        try {
            ChatMessage msg = objectMapper.readValue(json, ChatMessage.class);
            if (messageId.equals(msg.getId())) {
                redisTemplate.opsForZSet().remove(roomKey, json);

                ChatMessage notice = new ChatMessage();
                notice.setId(UUID.randomUUID().toString());
                notice.setSender("์‹œ์Šคํ…œ");
                notice.setContent("๊ด€๋ฆฌ์ž๊ฐ€ ๋ฉ”์‹œ์ง€๋ฅผ ์‚ญ์ œํ–ˆ์Šต๋‹ˆ๋‹ค.");
                notice.setRoomId(roomId);
                notice.setTimestamp(Instant.now().toString());
                messagingTemplate.convertAndSend("/topic/room/" + roomId, notice);
                return true;
            }
        } catch (Exception e) { ... }
    }
    return false;
}
 

 

๊ทธ๋ฆฌ๊ณ  ChatSessionService ํด๋ž˜์Šค๋„ ๊ตฌํ˜„

 

ConcurrentHashMap์œผ๋กœ ์„œ๋ฒ„ ๋ฉ”๋ชจ๋ฆฌ์— ๋‹‰๋„ค์ž„์„ JSESSIONID๋ณ„๋กœ ์ €์žฅํ•˜๊ณ 

์ด๋ฆ„์€ ์ต๋ช…1~99 ์‚ฌ์ด์—์„œ ๋ฌด์ž‘์œ„๋กœ ์ƒ์„ฑ ์‹œ์ผฐ๋‹ค.

์ฐจ๋‹จ์€, Redis Set์— JSESSIONID๋ฅผ ๋„ฃ๊ณ  6์‹œ๊ฐ„ TTL์„ ๊ฑธ์—ˆ๋‹ค. (์‹œ๊ฐ„ ์ง€๋‚˜๋ฉด ์ž๋™ ํ•ด์ œ)

@Service
public class ChatSessionService {

    private final ConcurrentHashMap<String, String> sessionNames = new ConcurrentHashMap<>();
    private final StringRedisTemplate redisTemplate;

    public String getOrCreateName(String sessionId) {
        return sessionNames.computeIfAbsent(sessionId,
                id -> "์ต๋ช…" + ThreadLocalRandom.current().nextInt(1, 100));
    }

    public boolean isBanned(String sessionId) {
        return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember("chat:banned:sessions", sessionId));
    }

    public void banSession(String sessionId) {
        redisTemplate.opsForSet().add("chat:banned:sessions", sessionId);
        redisTemplate.expire("chat:banned:sessions", Duration.ofHours(6));
    }
}
 

 

์ด์ œ Controller๋ฅผ ๊ตฌํ˜„ํ•ด๋ณด์ž, ๋ฉ”์‹œ์ง€ ์ „์†ก๊ณผ ์ฐจ๋‹จ ์•Œ๋ฆผ ๊ธฐ๋Šฅ์ด ํ•„์š”ํ•˜๋‹ค.

 

์ฒ˜์Œ์—๋Š” convertAndSendToUser๋ฅผ ์จ์„œ ๊ฐœ์ธ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋‚ด๋ ค ํ–ˆ์ง€๋งŒ,

/user/queue/ban ๊ตฌ๋…์ด ์ œ๋Œ€๋กœ ๋™์ž‘ํ•˜์ง€ ์•Š์•˜๋‹ค.

 

๋”ฐ๋ผ์„œ ์ฐจ๋‹จ๋ฉ”์‹œ์ง€๋ฅผ ๊ณต๊ฐœ ์ฑ„๋„(/topic/room/main)์œผ๋กœ ๋ณด๋‚ด๊ณ 

ํด๋ผ์ด์–ธํŠธ์—์„œ ์ž์‹ ์˜ JSESSIONID์™€ ๋น„๊ตํ•˜์—ฌ ํ‘œ์‹œํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋ฐ”๊ฟจ๋‹ค.

@MessageMapping("/chat.send")
public void sendMessage(ChatMessage message, Principal principal,
                        SimpMessageHeaderAccessor headerAccessor) {
    String jsessionId = (String) headerAccessor.getSessionAttributes().get("JSESSIONID");
    String sessionId = (jsessionId != null) ? jsessionId : headerAccessor.getSessionId();

    if (chatSessionService.isBanned(sessionId)) {
        ChatMessage banNotice = new ChatMessage();
        banNotice.setSender("์‹œ์Šคํ…œ");
        banNotice.setContent("๊ด€๋ฆฌ์ž์— ์˜ํ•ด ์ผ์‹œ์ ์œผ๋กœ ์ฐจ๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.");
        banNotice.setTimestamp(Instant.now().toString());
        banNotice.setRoomId(message.getRoomId());
        banNotice.setSessionId(sessionId);

        messagingTemplate.convertAndSend("/topic/room/" + message.getRoomId(), banNotice);
        return;
    }

    String nickname = chatSessionService.getOrCreateName(sessionId);
    if (principal != null) {
        Authentication auth = (Authentication) principal;
        boolean isAdmin = auth.getAuthorities().stream()
                .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
        message.setSender(isAdmin ? "๊ด€๋ฆฌ์ž" : nickname);
    } else {
        message.setSender(nickname);
    }

    message.setSessionId(sessionId);
    message.setTimestamp(Instant.now().toString());
    chatService.sendMessage(message);
}
 

 

AdminChatController์—์„œ๋Š” ์œ ์ € ์ผ์‹œ ์ฐจ๋‹จ, ๋ฉ”์‹œ์ง€ ์‚ญ์ œ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ–ˆ๋‹ค.

@RestController
@RequestMapping("/admin/chat")
@RequiredArgsConstructor
public class AdminChatController {

    private final ChatSessionService chatSessionService;
    private final ChatService chatService;

    @PostMapping("/ban/{sessionId}")
    public ResponseEntity<?> banUser(@PathVariable String sessionId) {
        chatSessionService.banSession(sessionId);
        return ResponseEntity.ok().build();
    }

    @DeleteMapping("/messages/{messageId}")
    public ResponseEntity<?> deleteMessage(@PathVariable String messageId) {
        boolean deleted = chatService.deleteMessage("main", messageId);
        return deleted ? ResponseEntity.ok().build() : ResponseEntity.notFound().build();
    }
}
 

 

๊ธฐ๋Šฅ ๊ตฌํ˜„์€ ์™„๋ฃŒํ–ˆ์œผ๋‹ˆ ํ™”๋ฉด์„ ๊ตฌํ˜„ํ•ด๋ณด๋„๋ก ํ•˜์ž

 

 

ํ™”๋ฉด ๊ตฌํ˜„

 

๊ธฐ๋ณธ์ ์ธ HTML ๋ผˆ๋Œ€๋ฅผ ๋งŒ๋“ค๊ณ  (์ฝ”๋“œ ์ƒ๋žต)

ํ•„์š”ํ•œ ๊ธฐ๋Šฅ๋“ค์„ javascript๋กœ ๋งŒ๋“ค์–ด์•ผ ํ•œ๋‹ค.

 

WebSocket ์—ฐ๊ฒฐ๊ณผ ์‹œ์Šคํ…œ ๋ฉ”์‹œ์ง€ ํ•„ํ„ฐ๋ง์„ ํ•˜๋Š” ๊ธฐ๋Šฅ

์ฐจ๋‹จ ์•Œ๋ฆผ์€ ๊ณต๊ฐœ ์ฑ„๋„๋กœ ์˜ค์ง€๋งŒ, ์ฐจ๋‹จ ๋‹นํ•œ ๋ณธ์ธ์ผ ๊ฒฝ์šฐ์— showMessage๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค.

var stompClient = null;
var roomId = "main";

function connect() {
    var socket = new SockJS('/ws-chat');
    stompClient = Stomp.over(socket);
    stompClient.connect({}, function(frame) {
        stompClient.subscribe('/topic/room/' + roomId, function(message) {
            var msg = JSON.parse(message.body);
            if (msg.sender === '์‹œ์Šคํ…œ' && msg.sessionId !== myJSessionId) {
                return;
            }
            showMessage(msg);
        });
        loadHistory();
    });
}
 

 

๋ฉ”์‹œ์ง€ ์ „์†ก์€ STOMP๋ฅผ ํ†ตํ•ด, /pub/chat.send๋กœ JSON์„ ๋ณด๋‚ธ๋‹ค.

์„œ๋ฒ„๋Š” ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ›๊ณ  ๋ฐœ์‹ ์ž ๋‹‰๋„ค์ž„์„ ๊ฒฐ์ •, redis์— ์ €์žฅ ํ›„ ๊ตฌ๋…์ž๋“ค์—๊ฒŒ broadcast ํ•œ๋‹ค.

function sendMessage() {
    var content = document.getElementById('message-input').value.trim();
    if (content && stompClient) {
        stompClient.send("/pub/chat.send", {}, JSON.stringify({
            content: content,
            roomId: roomId
        }));
        document.getElementById('message-input').value = '';
    }
}
 

 

๋ฉ”์‹œ์ง€ ํ‘œ์‹œ์™€, ๊ด€๋ฆฌ์ž ์ „์šฉ ๋ฒ„ํŠผ ๋ฉ”์†Œ๋“œ

 

์‚ญ์ œ ๋ฒ„ํŠผ์€ ์†์„ฑ์— ๋ฉ”์‹œ์ง€ UUID๋ฅผ ๋‹ด์•„, ํด๋ฆญ์‹œ DELETE ์š”์ฒญ์„ ๋ณด๋‚ด๊ณ 

์ฐจ๋‹จ ๋ฒ„ํŠผ์€ ๋งค ์ฑ„ํŒ…์— ๋ณด์ด๋ฉด ๋ณต์žกํ•ด์ง€๋ฏ€๋กœ, ํ•ด๋‹น ์„ธ์…˜์˜ ๋งˆ์ง€๋ง‰ ๋ฉ”์‹œ์ง€์—๋งŒ ๋ณด์ด๊ฒŒ ํ–ˆ๋‹ค.

var lastBanButtonElements = {};

function showMessage(message) {
    var container = document.getElementById('chat-messages');
    var div = document.createElement('div');

    if (message.sender === '์‹œ์Šคํ…œ') {
        div.className = 'chat-message system-chat';
    } else if (message.sender === '๊ด€๋ฆฌ์ž') {
        div.className = 'chat-message admin-chat';
    } else {
        div.className = 'chat-message user-chat';
    }

    var html = '<strong>' + message.sender + ':</strong> ' + message.content +
               ' <small>' + new Date(message.timestamp).toLocaleTimeString() + '</small>';

    if (isAdmin && message.id && message.sender !== '์‹œ์Šคํ…œ') {
        html += ' <button class="delete-msg-btn" data-message-id="' + message.id + '">โœ•</button>';
    }

    div.innerHTML = html;
    container.appendChild(div);

    if (isAdmin && message.sessionId && message.sender !== '๊ด€๋ฆฌ์ž' && message.sender !== '์‹œ์Šคํ…œ') {
        addOrReplaceBanButton(div, message.sessionId);
    }

    ...
}
 

 

๊ธฐ๋Šฅ๊ณผ ํ™”๋ฉด๊นŒ์ง€ ์™„๋ฃŒํ–ˆ์œผ๋‹ˆ ๋งˆ์ง€๋ง‰ CSS๋กœ ๋””์ž์ธ์„ ๋‹ค๋“ฌ์–ด ๋ณด์ž

 

๋ฉ”์‹œ์ง€ ์Šคํƒ€์ผ์€, ๋ฌธ์ž์—ด์ด ์ฑ„ํŒ…๋ฐฉ์„ ๋„˜์ง€ ์•Š๊ฒŒ ํ–ˆ๊ณ 

๊ด€๋ฆฌ์ž๋‚˜, ์‹œ์Šคํ…œ ๋ฉ”์‹œ์ง€๋Š” ๋””์ž์ธ ๋ณ„๋„๋กœ ์ฃผ์—ˆ๋‹ค.

.chat-message {
  padding: 8px 12px;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 1px 3px rgba(0,0,0,0.05);
  margin-bottom: 6px;
  word-break: break-word;
}

.admin-chat {
  background: #e3f2fd;
  border-left: 4px solid #007aff;
}

...
 

 

์ฑ„ํŒ… ์ž…๋ ฅ์ฐฝ๊ณผ ์ „์†ก ๋ฒ„ํŠผ์˜ ๋””์ž์ธ๋„ ์„ค์ •

์ž…๋ ฅ์ฐฝ๊ณผ ์ „์†ก๋ฒ„ํŠผ์ด ํ•œ์ค„์— ์œ„์น˜ํ•˜๊ฒŒ nowrap๊ณผ flex์†์„ฑ์„ ์กฐ์ •ํ–ˆ๋‹ค.

.input-group {
  display: flex;
  flex-wrap: nowrap;
  align-items: stretch;
  width: 100%;
}

...
 

 

๋ชจ๋ฐ”์ผ ๋Œ€์‘์—์„œ๋Š” ์ฑ„ํŒ…์ฐฝ์ด ๋„ˆ๋ฌด ์ž‘์•„์ง€์ง€ ์•Š๊ฒŒ calc๋กœ ๋™์  ๋†’์ด๋ฅผ ์„ค์ •ํ–ˆ๋‹ค.

@media (max-width: 768px) {
  .chat-container {
    height: calc(100vh - 220px);
    min-height: 280px;
  }
  ...
}
 

 

์ด์™ธ ๊ธฐํƒ€ ์„ธ๋ถ€์ ์ธ ๋””์ž์ธ ์กฐ์ •์„ ํ•˜๊ณ  ๊ฐœ๋ฐœ์€ ๋.

https://github.com/101Sean/devlog-qna

 

GitHub - 101Sean/devlog-qna: ๊ฐœ๋ฐœ ๋ธ”๋กœ๊ทธ QnA๋ฅผ ์œ„ํ•œ ๋Œ“๊ธ€ + ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ… ํ˜•์‹ ์›น

๊ฐœ๋ฐœ ๋ธ”๋กœ๊ทธ QnA๋ฅผ ์œ„ํ•œ ๋Œ“๊ธ€ + ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ… ํ˜•์‹ ์›น. Contribute to 101Sean/devlog-qna development by creating an account on GitHub.

github.com

 

 

ํ…Œ์ŠคํŠธ

 

์ด์ œ ๋ผ์ด๋ธŒ ์„œ๋ฒ„์— ์˜ฌ๋ฆฌ๊ธฐ ์ „, ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด๋ณด์ž

 

๋จผ์ € ์ฑ„ํŒ…์€ ์„ธ์…˜๋ณ„๋กœ ์ •์ƒ์ ์œผ๋กœ ๋™์ž‘์ด ๋๋‹ค.

 

๊ด€๋ฆฌ์ž ๊ด€์ ์—์„œ๋Š” ์ฑ„ํŒ… ์‚ญ์ œ์™€, ์ฐจ๋‹จ ๋ฒ„ํŠผ์ด ์ •์ƒ ๋™์ž‘

 

๊ทธ๋ฆฌ๊ณ  ์ฐจ๋‹จ๋œ ์„ธ์…˜์€ ๋ฉ”์‹œ์ง€๋ฅผ ์น  ์ˆ˜ ์—†๊ฒŒ ๊ฒฝ๊ณ  ์•Œ๋žŒ๊นŒ์ง€ ์™„๋ฃŒ

 

 

์ด๋กœ์จ ์‹ค์‹œ๊ฐ„ QnA ๊ธฐ๋Šฅ๊นŒ์ง€ ์™„์„ฑํ•˜๊ฒŒ ๋˜์—ˆ๋‹ค.

๋‹น๋ถ„๊ฐ„์€ ๋” ์ถ”๊ฐ€ํ•  ๊ธฐ๋Šฅ์€ ์—†์–ด ๋ณด์ด์ง€๋งŒ ํ•„์š”ํ• ๋•Œ ํ•˜๋‚˜ํ•˜๋‚˜ ์ถ”๊ฐ€ํ•ด ๋‚˜๊ฐ€์•ผ๊ฒ ๋‹ค.

 

 

์›๋ณธ ๊ฒŒ์‹œ๊ธ€

https://blog.naver.com/101artspace/224339806196

 

[Spring/JS] Websocket์„ ์ด์šฉํ•œ ์‹ค์‹œ๊ฐ„ QnA ์ฑ„ํŒ…๋ฐฉ ๋งŒ๋“ค๊ธฐ

๊ธฐ์กด๊นŒ์ง€ QnA ์›น์„ ๋งŒ๋“ค์–ด ์‚ฌ์šฉ์ค‘์ด์—ˆ๋Š”๋ฐ, ์ถ”๊ฐ€๋กœ ์ฑ„ํŒ…๋ฐฉ ์ฒ˜๋Ÿผ, ์‹ค์‹œ๊ฐ„ ์†Œํ†ต๋„ ๊ฐ€๋Šฅํ•˜๋ฉด ์ข‹๊ฒ ๋‹ค๋Š” ์ƒ๊ฐ...

blog.naver.com

๋ฐ˜์‘ํ˜•