Spring boot/Spring boot websocket

Spring Boot 폴링(Polling) 채팅 시스템

whs5758 2025. 9. 2. 12:33

학습 목표

  • 폴링(Polling) 통신 방식의 개념과 동작 원리 이해
  • 스프링 부트를 활용한 간단한 채팅 시스템 구현
  • 실시간 통신의 기본 개념 습득 (폴링 → SSE → WebSocket 로드맵의 첫 단계)
  • 스프링 부트 핵심 기술 (JPA, Mustache, MVC) 실습

사전 기반 지식

  • Spring Boot 기본: @Controller, @Service, @Repository 어노테이션 이해
  • Spring Data JPA: Entity, Repository 패턴 숙지
  • HTTP 기본: GET, POST 요청/응답 이해
  • HTML/JavaScript 기초: 기본 DOM 조작과 이벤트 처리
  • Mustache 템플릿 엔진: 기본 문법 ({{}}, {{#}})

핵심 개념 이해

1. 폴링(Polling)이란?

폴링은 클라이언트가 서버에 주기적으로 요청을 보내 새로운 데이터가 있는지 확인하는 통신 방식입니다.

클라이언트 ──[요청]──> 서버
클라이언트 <──[응답]── 서버
     (2초 대기)
클라이언트 ──[요청]──> 서버 (반복)
      (2초 대기)
클라이언트 ──[요청]──> 서버 (반복)

폴링(Polling)이라는 용어는 클라이언트가 서버로부터 데이터를 계속해서 당겨오는(pulling) 행위에서 유래했습니다. 즉, 클라이언트가 서버에게 "새로운 데이터가 있나요?"라고 지속적으로 질문을 던져 응답을 수집하는 행위를 말합니다.

  • 장점: 구현이 간단하고, HTTP 기반이므로 방화벽 문제에서 자유롭습니다.
  • 단점:
    • 불필요한 요청: 새로운 데이터가 없어도 계속 요청을 보냅니다.
    • 서버 부하 증가: 동시 접속자가 많을수록 서버에 과도한 부하를 줄 수 있습니다. 예를 들어, 1,000명의 사용자가 2초마다 요청하면 서버는 2초마다 1,000개의 요청을 처리해야 합니다.
    • 실시간성 제한: 데이터가 즉시 전달되지 않고, 폴링 주기에 따라 지연이 발생합니다.

시스템 목적

이 강의에서는 스프링 부트(Spring Boot)를 활용하여 간단한 실시간 채팅 시스템을 구축합니다. 이 시스템은 실시간 통신의 가장 기본적인 방법인 폴링(Polling) 방식으로 동작합니다.

시스템의 주요 기능

  • 메시지 작성: 사용자가 채팅 메시지를 입력하고 전송할 수 있습니다.
  • 메시지 조회: 웹 페이지에 접속하면 저장된 모든 채팅 메시지를 볼 수 있습니다.
  • 자동 업데이트: 웹 페이지는 2초마다 자동으로 새로고침되어 새로운 메시지가 있는지 확인하고 화면에 표시합니다.

이 시스템은 다음의 계층 구조로 구성됩니다.

  1. 데이터 모델 (Chat.java): 채팅 메시지의 구조를 정의합니다. 메시지 내용(msg)과 고유 식별자(id)를 가집니다.
  2. 데이터 접근 계층 (ChatRepository.java): 데이터베이스에서 메시지를 저장하고 조회하는 역할을 담당합니다.
  3. 비즈니스 로직 계층 (ChatService.java): 메시지 저장 및 전체 메시지 조회와 같은 핵심 비즈니스 로직을 처리합니다.
  4. 웹 컨트롤러 (ChatController.java): 사용자의 웹 요청(GET, POST)을 받아 적절한 응답(HTML)을 반환합니다.

프로젝트 생성 하기

의존성 확인
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-mustache'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-websocket'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

 

설정 확인
spring.application.name=class_step_websocket


# UTF-8 설정
# 웹 서버의 인코딩 UTF-8로 강제합니다.
# 한글이 깨지는 현상방지, 모든 요청과 응답에 대해서 UTF-8로 통일 함.
server.servlet.encoding.charset=utf-8
server.servlet.encoding.force=true

# 1. DB 연결
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:test
spring.datasource.username=sa

# 2. ORM -> 스프링 부트 (JPA) -> hibernate 셋팅
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

#3. 템플릿 엔진 = 기본설정
# 템플릿 파일 위치 : src/main/resources/templates
# 파일 확장자 : .mustache

 

채팅 작성 화면 시안

채팅 목록 화면 시안


프로젝트 구축 하기

데이터 모델 정의 (Chart.java)

엔티티란? 데이터베이스 테이블과 1:1로 매핑되는 클래스 입니다. 넓은 의미에서 모델 클래스라고 부르기도 하지만 JPA를 사용할 때는 엔티티라고 부르기도 합니다.

package com.example.class_step_websocket.chat;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor
@Getter
@Table(name = "chat_tb")
@Entity
public class Chat {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    private String msg;

    @Builder
    public Chat(Integer id, String msg) {
        this.id = id;
        this.msg = msg;
    }
}

 

2단계: 데이터 접근 계층 (ChatRepository.java)
package com.example.class_step_websocket.chat;

import org.springframework.data.jpa.repository.JpaRepository;

public interface ChatRepository extends JpaRepository<Chat, Integer> {
    // 기본적인 CRUD 완성 
}

 

 

3단계 : 비즈니스 로직 계층(ChatService.java)
package com.example.class_step_websocket.chat;

import org.springframework.boot.autoconfigure.data.web.SpringDataWebProperties;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Transactional(readOnly = true)  // 읽기 전용 트랜잭션 상태
@RequiredArgsConstructor
@Service
public class ChatService {

    private final ChatRepository chatRepository;

    // 채팅 메세지 저장
    @Transactional // 쓰기 작업이므로 읽기 전용 해제 처리
    public Chat save(String msg) {
        Chat chat = Chat.builder().msg(msg).build();
        return  chatRepository.save(chat);
    }

    // 채팅 메세지 리스트
    public List<Chat> findAll() {
        // 내림 차순으로 정렬하고 싶다면
        Sort desc = Sort.by(Sort.Direction.DESC, "id");
        return chatRepository.findAll(desc);
    }

}

 

컨트롤러 구축
package com.example.class_step_websocket.chat;


import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@RequiredArgsConstructor
@Controller // 뷰 반환
public class ChatController {

    private final ChatService chatService;

    // 메세지 작성 폼 페이지
    @GetMapping("/save-form")
    public String saveForm() {
        return "save-form";
    }

    // 채팅 목록 페이지
    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("models", chatService.findAll());
        return "index";
    }

    // 채팅 메세지 저장 요청
    @PostMapping("/chat")
    public String save(String msg) {
        chatService.save(msg);
        // POST 맵핑에서 (웹 에서 주의할 점) - 중복 등록 조심 
        return "save-form";
        // PRG 패턴
        //return "ridirect:/";
    }

}

 

index
<!doctype html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>채팅</title>
    <style>
        body { margin: 20px; font-family: Arial, sans-serif; }
        nav ul { list-style: none; padding: 0; display: flex; gap: 10px; }
        nav a { padding: 8px 15px; background: #666; color: white; text-decoration: none; }

        /* 채팅창 */
        .chat-area {
            border: 1px solid #ccc;
            height: 300px;
            padding: 10px;
            overflow-y: scroll;
            background: #f9f9f9;
        }

        /* 메시지 */
        .message {
            padding: 5px 10px;
            margin: 3px 0;
            background: white;
            border-radius: 8px;
            max-width: 80%;
        }

        .message:nth-child(odd) {
            background: #e1f5fe;
            margin-left: 20%;
        }
    </style>
</head>
<body>
<nav>
    <ul>
        <li><a href="/">목록</a></li>
        <li><a href="/save-form">작성</a></li>
    </ul>
</nav>

<h3>채팅</h3>
<p style="color: #666; font-size: 13px;">2초마다 업데이트</p>

<div class="chat-area">
    {{#models}}
        <div class="message">{{msg}}</div>
    {{/models}}
</div>

<script>
    <!--2초 후 새로고침-->
    setTimeout(() => location.reload(), 2000);

    <!--스크롤 아래로 - 클래스이름, ID 등으로 접근 -->
    document.querySelector(".chat-area").scrollTop = 999;

</script>

</body>
</html>

 

save-form
<!doctype html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>메시지 작성</title>
    <style>
        /* 기본 설정만 */
        body { margin: 20px; font-family: Arial, sans-serif; }

        /* 네비게이션 */
        nav ul { list-style: none; padding: 0; display: flex; gap: 10px; }
        nav a { padding: 8px 15px; background: #666; color: white; text-decoration: none; }

        /* 폼 */
        form { margin-top: 20px; }
        input { padding: 8px; width: 300px; margin-right: 10px; }
        button { padding: 8px 15px; background: #007bff; color: white; border: none; }
    </style>
</head>
<body>
<nav>
    <ul>
        <li><a href="/">목록</a></li>
        <li><a href="/save-form">작성</a></li>
    </ul>
</nav>

<h2>메시지 작성</h2>

<form action="/chat" method="post">
    <input type="text" name="msg" id="msg" placeholder="메시지 입력..." required>
    <button type="submit">전송</button>
</form>

<script>
    document.getElementById('msg').focus();

    document.getElementById('msg').onkeypress = function(e) {
        if(e.key == 'Enter') this.form.submit();
    };

</script>

</body>
</html>