- TIL -

구현 목표)

타임리프를 사용해서 간단하게 회원을 관리할 수 있는 백오피스 페이지를 구현해보려 한다.

 

타임리프(Thymeleaf)란?

타임리프는 Java 기반의 서버사이드 템플릿 엔진으로써 현재 지원이 거의 중단되어버린 JSP 의 대안으로 떠오르는 템플릿 엔진이다.

JSP 가 jar 형태의 빌드를 지원하지 않고 war 형태의 빌드만 지원했던 반면 타임리프는 jar 와 war 형태의 빌드를 모두 지원한다.

또한 서버가 없는 서버리스 형태로도 템플릿을 실행시킬 수 있다는 장점이 있다.

 

 

 

구현 코드)

 

- login.html, login.js -

더보기
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
    <meta charset="UTF-8">
    <title>Admin Login</title>
    <link rel="stylesheet" th:href="@{/css/login/login.css}">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
    <div class="login-container">
        <h2>Admin Login</h2>
        <form id="login-form">
            <div class="input-group">
                <label for="username">Username</label>
                <input type="text" id="username" name="username" required>
            </div>
            <div class="input-group">
                <label for="password">Password</label>
                <input type="password" id="password" name="password" required>
            </div>
            <button type="submit">Login</button>
        </form>
        <div id="error-message" class="error-message" style="display: none;"></div>
    </div>

    <script th:src="@{/js/login/login.js}"></script>
</body>
</html>

login.html

( 로그인 정보를 form 태그로 담아 전송 )

 

 

 

document.getElementById("login-form").addEventListener("submit", async function (event) {
    event.preventDefault();

    const username = document.getElementById("username").value;
    const password = document.getElementById("password").value;

    try {
        const response = await fetch("https://api.random-chat.site/admin/login", {
            method: "POST",
            headers: { "Content-Type": "application/x-www-form-urlencoded" },
            body: `username=${username}&password=${password}`
        });

        if (response.ok) {
            const token = response.headers.get("Authorization").replace("Bearer ", "");
            localStorage.setItem("jwtToken", token); // JWT 저장
            window.location.href = "/admin/dashboard"; // 대시보드로 리디렉션
        } else {
            const errorMessage = document.getElementById("error-message");
            errorMessage.textContent = "Invalid username or password";
            errorMessage.style.display = "block";
        }
    } catch (error) {
        console.error("Error during login:", error);
    }
});

login.js

( 로그인 정보를 담아서 성공 시 토큰을 반환받아 토큰정보를 Local Storage 에 jwtToken 키로 저장 후 /admin/dashboard 경로로 리디렉션 )

 

 

 

- LoginController -

더보기
package com.randomchat.main.controller.backOffice;

import com.randomchat.main.domain.users.Users;
import com.randomchat.main.jwt.JWTUtil;
import com.randomchat.main.service.backOffice.AdminLoginService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
@RequiredArgsConstructor
public class LoginController {

    private final AdminLoginService adminLoginService;
    private final JWTUtil jwtUtil;

    @GetMapping("/admin/login")
    public String showLoginPage() {
        // template 디렉토리 하위의 login.html 렌더링
        return "login/login";
    }

    // 로그인 처리
    @PostMapping("/admin/login")
    public ResponseEntity<String> handleLogin(@RequestParam("username") String username,
                                              @RequestParam("password") String password,
                                              HttpServletResponse response) {

        Users user = adminLoginService.adminLogin(username, password);

        if(user != null) {
            // jwt 생성하여 반환
            String jwtToken = jwtUtil.createJwt(user.getEmail(), user.getNickname(), user.getRole().name(), user.getGender().name(), 60*60*1000L);
            response.addHeader("Authorization", "Bearer " + jwtToken);

            return ResponseEntity.ok("Success login");
        }else {
            // 로그인 실패
            return ResponseEntity.status(401).body("Fail login");
        }

    }
}

api.random-chat.site/admin/login 경로로 접근 시 login.html 리턴

유저 정보가 있는 경우 jwtToken 정보를 생성하여 접두사 Bearer 를 붙여 Header 에 담아 리턴

 

 

 

- AdminLoginService -

더보기
package com.randomchat.main.service.backOffice;

import com.randomchat.main.domain.users.Role;
import com.randomchat.main.domain.users.Users;
import com.randomchat.main.repository.users.UsersRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
@RequiredArgsConstructor
public class AdminLoginService {

    private final UsersRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public Users adminLogin(String username, String password) {
        Optional<Users> optionalUser = userRepository.findByEmail(username);
        if(optionalUser.isEmpty()) {
            System.out.println("AdminLoginService.class >>> 해당 이메일로 가입된 로그인 정보가 없음");
            return null;
        }else {
            Users user = optionalUser.get();
            if(bCryptPasswordEncoder.matches(password, user.getPassword()) && user.getRole().equals(Role.ADMIN)) {
                System.out.println("AdminLoginService.class >>> admin 권한을 가진 유저(" + user.getEmail() + ") 로그인 성공");
                return user;
            }else {
                System.out.println("AdminLoginService.class >>> 로그인 권한을 가지지 않은 유저가 로그인 시도 : " + user.getEmail());
                return null;
            }
        }
    }
}

 userRepository 를 사용해 입력된 Email 정보로 가입된 유저를 먼저 탐색

> 가입된 유저가 없다면 null 반환

> 가입된 유저가 있지만 Role 값이 ADMIN 이 아닌 경우 null 반환

> 가입된 유저이면서 Role 값이 ADMIN 인 경우 유저 객체 반환

 

 

 

- SecurityConfig -

더보기

 백오피스 로그인 페이지 경로와 css, js 파일의 경로를 렌더링하기 위해 전체 허용 설정

 

이후 dashboard 하위의 기능은 /admin/dashboard 하위 경로로 관리할 예정

 

전체 접근은 가능하게 설정하고 세부 기능 api 에서 Role 값을 기준으로 요청을 걸러낼 예정이다.

 

그렇다면 실제 관리자 dashboard 에서는 페이지 로드 시 토큰 값을 백엔드 측으로 보내 관리자인지 검증하게끔 하는 코드를 추가하여

관리자 경로의 일반 유저의 접근을 차단하는 방법의 구현이 필요하다.

 

 

 

728x90

 

 

 

728x90

 

 

 

 

 

- TIL -

구현 목표)

타임리프를 사용해서 간단하게 회원을 관리할 수 있는 백오피스 페이지를 구현하고 있는데 이때 관리자를 토큰 값을 기준으로 판별하는 코드가 추가되었다.

이 코드를 테스트하기 위해 미리 관리자를 생성할 필요가 있었고 서버 실행 시점에 초기화 작업을 위해 사용되는 @PostContruct 어노테이션을 활용해서 기본 admin 계정을 생성해보고자 했다.

 

 

 

구현 코드)

package com.randomchat.main.service;

import com.randomchat.main.domain.users.Users;
import com.randomchat.main.repository.users.UsersRepository;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class CreateDefaultAdmin {

    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final UsersRepository usersRepository;

    @PostConstruct
    public void createDefaultAdmin() {

        // 관리자 계정이 존재하는 경우 관리자 계정 생성을 패스
        if(usersRepository.findByEmail("admin").isPresent()) return;

        String email = "admin";
        String password = bCryptPasswordEncoder.encode("admin");
        String nickname = "admin";

        Users user = new Users();
        Users admin = user.createAdmin(email, password, nickname);

        usersRepository.save(admin);
    }

}

CreateDefaultAdmin.class 를 생성하여 컴포넌트로 선언

 

createDefaultAdmin() 메소드를 생성하여 @PostConstruct 어노테이션을 붙여줌으로써 CreateDefaultAdmin 빈이 초기화 되고 의존성 주입이 완료된 시점에 해당 메소드를 실행하게 설정

 

서버 실행 시 시작되는 코드이므로 admin 계정이 이미 존재하는 경우(서버를 재부팅하거나 재배포하는 과정) 관리자 계정을 생성하지 않고 넘어가게끔 if 문을 사용해서 관리

 

 

 

 

 

728x90

 

 

 

 

 

- TIL -

문제 발생)

package com.randomchat.main.domain.email;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor
public class EmailVerification {
    public EmailVerification(String email, String verificationCode) {
        this.email = email;
        this.verificationCode = verificationCode;
        this.isVerified = false;
    }

    @Id
    @GeneratedValue
    private Long id;

    @Column
    private String email;

    @Column
    private String verificationCode;

    @Column(nullable = false)
    private boolean isVerified;

    @CreationTimestamp
    @Column(nullable = false)
    private LocalDateTime attemptTime;

}

위 코드는 현재 이메일 인증을 관리하는 테이블의 구조이다.

 

attemptTime 컬럼은 이메일 인증을 시도한 시점의 시간을 저장하는 컬럼인데 5분 내 이메일 인증 시도 횟수를 아래와 같이 COUNT 를 사용하여 출력해보려 하였지만 몇 번을 요청하든 0회로 찍혀서 나오는 오류가 발생하였다.

네이티브 쿼리를 활용하여 현재 시간의 5분 내에 특정 이메일로 요청한 데이터의 갯수를 출력하도록 메소드를 작성

 

혹시 Hibernate 에서 쿼리를 Insert 하는 순간에 attempt_time 컬럼이 비어있는 상태로 넘어가는지 로그를 확인해보았지만 정상적으로 Insert 는 실행되는 모습이다.

정상적으로 attempt_time 컬럼의 값이 채워져서 insert 되는 모습

 

@CreateTimeStamp 어노테이션을 사용하면 jpa 를 사용하여 save() 메소드를 사용해 객체의 데이터를 데이터베이스에 저장하는 시점에 Hibernate 가 해당 어노테이션을 인식하고 자동으로 값을 채워넣어 주는 형태이기 때문에 save() 메소드를 사용해 저장하는 나는 당연하게도 값이 누락될 리는 없었다.

 

 

 

 

 

저장된 값을 확인해보려 MySQL 을 실행하였다.

( 로그로 확인할 수 없는 이유는 save() 메소드 실행 시 값이 채워지기 때문에 생성된 객체의 getter 를 사용해서 데이터를 뽑아도 출력되지 않기 때문 )

id 값이 202 ~ 352 까지가 오늘 요청한 부분

 

MySQL 을 열어 확인해보니 attempt_time 의 값은 채워지는데 시간대의 설정이 올바르게 되어있지 않는 듯 하였다.

 

우선적으로 attempt_time 의 값을 채워주는 Hibernate 는 JDBC 의 시간대 설정을 따른다고 검색을 통해 알아냈다.

 

application.yml 의 url 항목 설정을 확인해보니 serverTimezone 이 UTC(세계 협정 기준시) 로 설정되어 있는 것을 확인할 수 있었다.

 

JDBC 의 serverTimezone 을 한국의 시간대로 변경

 

시간대를 서울 기준으로 변경한 뒤 다시 api 요청을 보내보니 정상적인 한국 시간 기준으로 attempt_time 이 저장되는 것을 확인할 수 있었다.

 

한국 표준시로 정확하게 저장되는 것 확인, API 요청시 인증 횟수 5회 초과 요청시 문구 응답

728x90

 

 

 

 

 

- TIL -

문제 발생)

잘 구현해 놓았던 JWT 토큰을 검증하던 로직에서 아래와 같은 에러가 발생하였다.

발생된 오류

 

해당 내용에 대해 구글링을 해보니 이 에러의 원인으로 의심되는 부분이 한 군데 발견되었다.

 

바로 authenticate 메소드를 가진 객체인 AuthenticationManager 객체인데 이 부분에서 순환 참조 오류가 발생한 것을 확인할 수 있었다.

 

구글링 결과 AuthenticationManager 의 경우 프록시 객체 상태로 사용하면 안되고 명시적으로 메소드를 사용하여 객체를 생성하여 반환한 뒤에 사용해야

한다는 것을 확인했다.

 

그렇지만 나는 아래와 같이 이미 SecurityConfig.class 에 명시적으로 authenticationManager 메소드를 통해 AuthenticationManager 를 빈 객체로

등록해 놓은 상황인데.... 이 객체를 사용하는 부분에서 내가 선언한 객체가 아닌 프록시 객체를 사용한다는 부분을 확인하게 되었다.

 

 

 

SecurityConfig.class)

더보기
package com.randomchat.main.config.security;

import com.randomchat.main.jwt.JWTFilter;
import com.randomchat.main.jwt.JWTUtil;
import com.randomchat.main.jwt.LoginFilter;
import com.randomchat.main.repository.users.UsersRepository;
import com.randomchat.main.service.jwt.CustomUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    // AuthenticationManager 가 인자로 받을 AuthenticationConfiguration 객체 주입
    private final AuthenticationConfiguration authenticationConfiguration;
    private final JWTUtil jwtUtil;
    private final UsersRepository usersRepository;
    private final CustomUserDetailsService customUserDetailsService;


    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    // AuthenticationManager Bean 등록
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // 폼 로그인 비활성화
                .formLogin(formLogin -> formLogin.disable())

                // HTTP Basic 인증 비활성화
                .httpBasic(httpBasic -> httpBasic.disable())

                // CSRF 보호 비활성화 (Token 인증 방식에서는 불필요)
                .csrf(csrf -> csrf.disable())

                // CORS 커스텀을 적용하는 메소드
                .cors(c -> {
                    CorsConfigurationSource source = request -> {
                        CorsConfiguration config = new CorsConfiguration();
                        config.setAllowedOriginPatterns(List.of("*"));
                        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
                        config.setAllowedHeaders(List.of("*"));
                        config.setExposedHeaders(List.of("*"));

                        UrlBasedCorsConfigurationSource corsSource = new UrlBasedCorsConfigurationSource();
                        corsSource.registerCorsConfiguration("/**", config);
                        return config;
                    };
                    c.configurationSource(source);
                })

                // 세션 비활성화
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )

                // H2 콘솔 접근 허용
                .headers(headers -> headers
                        .frameOptions(frameOptions -> frameOptions.sameOrigin())
                )

                // 경로별 권한 인증 설정
                .authorizeRequests(auth -> auth
                    // 메모리를 사용하는 H2 데이터베이스의 접속 경로를 오픈
                    // http://localhost:8080/h2-console 경로로 콘솔 접근 가능
                    .requestMatchers("/h2-console/**", "/login", "/register/**").permitAll()
                    .requestMatchers("/chat/**", "/room/**", "/send/**", "/enter/**").permitAll() // 채팅 관련 엔드포인트 접속 허용 설정
                    .requestMatchers("/admin").hasRole("ADMIN")
                    .anyRequest().authenticated()
                )

                .addFilterAfter(new JWTFilter(jwtUtil), LoginFilter.class)

                // 커스텀 로그인 필터를 등록
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, usersRepository), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

 

 

 

그렇다면 여기서 프록시 객체를 사용하지 않고 현재 사용방식과 다른 방식으로 AuthenticationManager 객체를 명시적으로 선언하여 사용하는 방법에 대해 구글링을 해보았다.

 

 

Spring boot3 - AuthenticationManager + Stackoverflow feat. UserDetailsService

사건의 발단.. 이번에도 팀 시큐리티를 맡게 되었다 이번엔 기필코 깔끔하게 하겠다는 생각으로 임하게 되었는데.. 각 파트 다 짜고 이제 로그인해보자! 하고 들어가는 순간...!!!! 아.... 제일 보

hbyun.tistory.com

( 문제를 해결할 수 있게끔 도와주신 블로그에 무한한 감사를 드립니다.... )

 

 

 

정말 친절하게도 위의 블로그에서 DaoAuthenticationProvider 라는 객체를 사용해서 AuthenticationManager 를 생성하는 방법에 대해 설명해주고 있었고 나는 이 방법을 사용해보기로 하였는데 뭔가 이상함을 느꼈다....

 

위 블로그에서 작성해놓은 방법은 UserDetailsService 를 커스텀 구현한 서비스와 커스텀된 비밀번호 인코더가 필요한 부분이었는데 현재

SecurityConfig.class 에 비밀번호 인코더는 BCryptPasswordEncoder 객체를 반환하는 메소드를 통해 사용중인데 아무리 찾아봐도 이전에

구현해놓은 커스텀 CustomUserDetailsService 클래스가 보이질 않는다....

 

난 분명히 UserDetailsService 인터페이스를 구현한 CustomUserDetailsService 구현체를 작성했던 기억이 나는데 도통 보이질 않는다...

 

혹시 몰라 깃허브의 커밋 로그를 들어다 보았다.

 

 

 

초기 구현 부분에는 CustomUserDetailsService 가 존재한다.

 

 

 

스프링 시큐리티를 커스텀하면서 CustomUserDetailsService 클래스를 푸쉬했었고, 그 다음 JWT 토큰 커스텀 커밋 로그까지도 해당 클래스는

살아있었는데 이메일 인증 로직을 커밋한 로그를 살펴보니 아래와 같이 해당 서비스가 삭제되어 있는 모습을 확인할 수 있었다.

 

 

 

CustomUserDetailsService 가 삭제된 모습

 

 

 

아마도 코드를 정리하다가 CustomUserDetailsService 를 삭제한 모양이다....

 

위 클래스가 없으니 당연히 아래의 authenticationManager 메소드에서도 객체를 생성하여 반환할때 UserDetailsService 구현체를 불러오지 못해

프록시 객체로 동작 되는 것이 당연하였다....

 

삭제된 CustomUserDetailsService 파일을 다시 작성한뒤 메소드는 건들이지 않고 로직을 실행시켜 보았고 성공적으로 토큰을 받아오는 것을 확인할 수 있었다.

DaoAuthenticationProvider 객체를 사용하지 않고 기존 코드 유지

 

요청이 성공하여 토큰 정보를 헤더에 담아 응답하는 모습

 

 

 

랜덤 채팅을 구현하면서 화상 랜덤 채팅을 구현할때 토큰 값으로 사용자를 분류할 생각이었는데 이 오류를 잡는데 2주가 넘는 시간이 소비되었다...

이제 다시 토큰 값으로 사용자를 구분할 수 있게 되었다!

728x90

 

 

 

 

 

- TIL -

구현 목표)

현재 프로젝트의 경우 www.random-chat.site 로 접속 시에는 정상적으로 React 프로젝트의 루트 경로가 정상적으로 서빙되지만

www.randocm-chat.site/randomchat 과 같은 경로는 정상적으로 React 프로젝트로 서빙되지 않고 아래와 같이 Nginx 의 404 페이지가 서빙되는

문제가 발생했다.

세부 경로를 표현하면 React 프로젝트의 내부 경로로 서빙되지 않는 모습

 

오늘은 세부 경로를 기입하고 이동할때 정상적으로 세부 경로로 서빙되게끔 nginx 를 세팅해 볼 것이다.

 

 

 

docker-compose.yml 파일 설정 변경)

더보기

 

세부 경로의 경우 nginx 에서 서빙해주는 부분이다 보니 직접적으로 nginx 의 html 부분의 파일들을 react 의 파일들과 매핑해주는 작업을

해야했다.

 

위와 같이 nginx 컨테이너 내부의 /usr/share/nginx/html 경로에 리액트의 빌드 파일을 볼륨으로 잡아 동일한 리액트 빌드 파일을 nginx 컨테이너에도 위치시켰다.

 

 

 

Nginx 의 nginx.conf 파일 설정 변경)

더보기

nginx.conf 작성

기존 코드 >> 변경 코드

 

 nginx.conf 파일 내에 리액트 프로젝트로 서빙해주는 location / 부분에

 

root 경로를 명시적으로 표현해주고, index 파일 또한 명시적으로 표현해주었다.

 

이후 try_files 를 통해 요청한 URI 의 파일이 존재하지 않을 경우 /index.html 로 해당 URI 를 리다이렉트하여

SPA 내에서 경로를 처리할 수 있게 도와주는 코드를 작성했다!

 

 

 

 도커 컴포즈 종료 및 재빌드

$ sudo docker-compose down
$ sudo docker-compose up --build -d

도커 컴포즈를 종료시킨 뒤 빌드 변경사항을 반영하여 다시 빌드

 

 

 

코드 반영 결과)

정상적으로 세부 주소로 서빙이 된다!

 

이 코드를 구현함으로써 대화가 종료된 이후 다시 초기 채팅방으로 돌아갈 수 있는 구조를 생성할 수 있게 되었다!

 

 

 

 

 

▼ 사이트 방문하여 확인하기 ▼

 

Random-Chat

 

www.random-chat.site

728x90

 

 

 

 

 

- TIL -

문제 발생)

서버 피씨를 실행시키면 Docker Compose 가 자동으로 실행되게끔 설정되어 있는데 아래와 같이 nginx 의 도커 컨테이너가 먼저 실행되어 포트의 충돌이 일어나는 현상이 발생되었다.

 

 

 

문제 해결)

 

nginx 도커 종료 및 재실행 disable 처리

더보기

1) nginx 도커 종료

$ sudo systemctl stop nginx

 

 

 

2) nginx 도커 상태를 확인하여 inactive (dead) 로 작동 중지 확인

$ sudo systemctl status nginx
도커 종료 확인

 

 

 

 3) nginx 도커 재실행 disable 처리

$ sudo systemctl disable nginx

 

 

 

 4) 서버 피씨 재부팅 후 80포트 점유 중인 프로세스 확인

$ sudo lsof -i :80
$ sudo docker ps

80포트 점유중인 프로세스 확인, 활성화된 docker 컨테이너 확인

정상적으로 docker compose 를 통해 nginx 프로세스가 실행된 것을 확인

 

728x90

 

 

 

 

 

라스베가스의 벨라지오 호텔의 유명한 쇼인 태양의 서커스 O show 를 관람하러 들렀다. 무대의 규모에 넋을 잃고 어린 아이처럼 쇼를 관람했다.

무대 중 사진이나 동영상 촬영은 금지였지만 무대가 끝난 후 인사할때는 촬영이 가능해서 기뻤다...! 조금이나마 무대의 웅장함을 담은... 듯...?

 

 

 

 

 

- TIL -

구현 목표)

SSL 인증서를 발급하여 www.random-chat.site 경로와 api.random-chat.site 경로를 각각 443 포트를 사용해 https 요청을 받도록 적용할 것이다.

이때 지금 구현한 http 경로로 접근하는 요청은 전부 443 포트로 매핑하여 경로를 우회하여 접근되게끔 설정할 것이다.

 

 

 

 

 

SSL 인증서 발급을 위해 Cerbot 설치 및 인증서 발급)

더보기

1) apt update 진행

$ sudo apt-get update
$ sudo apt-get upgrade

 

 

 

2) SSL 발급을 위해 cerbot 을 설치

$ sudo apt-get install python3-certbot-nginx

Cerbot 은 Let's Encrypt 인증서를 사용하여 HTTPS 를 사용할 수 있게 해주는 오픈소스 툴이다.

 

 

 

3) nginx 컨테이너 동작 중지

$ sudo docker stop $(sudo docker ps -q)

 포트 충돌이 날 수 있기 때문에 잠시 올려뒀던 모든 컨테이너를 중지

 

 

 

4) 컨테이너를 중지시킨 후에도 자꾸 80포트를 점유중이라는 문구가 나와 80포트에 대한 모든 프로세스를 종료 후 5번 실행

$ sudo fuser -k 80/tcp

 5) 를 먼저 시도 후 정상적으로 SSL 인증서가 발급되었다면 4) 는 실행할 필요가 없다.

 

 

 

5) 설치한 cerbot 툴을 사용해서 SSL 인증서를 발급

$ sudo certbot certonly --standalone -d www.random-chat.site -d api.random-chat.site

 certonly 옵션을 적용하지 않으면 SSL 인증서를 발급하면서 nginx 설정까지 자동으로 해버리는데 현재 Docker 이미지를 사용해서 구현하는

입장에서 nginx.conf 설정과 docker-compose.yml 설정을 수동으로 진행해야 하는 부분이 있어서 설정은 수동으로 진행하도록 설치하였다.

 

-d 옵션을 사용해서 인증서를 발급받을 도메인 정보를 각각 기입해줬다.

 

--standalone 을 사용해서 Cerbot 이 자체적으로 간단한 웹 사이트를 작동시켜 해당 도메인의 소유권을 확인한 뒤 인증서를 발급한다.

 

인증서 발급 중 이메일을 기재하고 약관에 동의한다고 y 키를 눌러 승인해주면 된다

 

 

 

6) 인증서가 정상적으로 발급되었는지 확인

/etc/letsencrypt/live/www.random-chat.site 경로에 www.random-chat.site 도메인의 인증서와 api.random-chat.site 도메인의

인증서가 합쳐진 인증서 파일이 생성되었다.

 

 

 

7) 인증서가 정상적으로 두 도메인 모두 발급되었는지 확인

$ sudo openssl x509 -in /etc/letsencrypt/live/www.random-chat.site/fullchain.pem -text | grep DNS

 발급된 fullchain.pem 인증서의 텍스트 중 DNS 문자열을 가지고 있는 내용을 출력

인증서가 정상적으로 발급된 모습

 

 

 

 8) Docker Compose 재실행

$ sudo docker-compose up -d --build

Docker Compose 를 재실행, 이제부턴 발급된 인증서를 설정하기만 하면 되기 때문에 Docker Compose 를 다시 올려준다.

 

 

 

Nginx 설정)

더보기

1) 설치된 인증서 디렉토리를 nginx 설정 디렉토리쪽으로 이동

nginx 도커 설정파일이 있는 쪽으로 인증서 디렉토리 전체 이동

 

 

 

2) Dockerfile 설정

$ sudo vi Dockerfile

 vi 편집기로 Dockerfile 을 열어서 수정

From nginx:latest
COPY nginx.conf /etc/nginx/nginx.conf
COPY ./letsencrypt /etc/letsencrypt

 letsencrypt 디렉토리를 도커 컨테이너 내의 /etc/ 디렉토리로 카피하여 사용

 

 

 

3) nginx.conf 설정파일 수정

기존 nginx.conf > 변경 후 nginx.conf

 1) 80 포트인 http 를 통해 접속되는 도메인을 https 경로로 우회

 2) 443 포트(https)로 접속되는 www.random-chat.site 요청을 ssl 로 처리

 3) 443 포트(https)로 접속되는 api.random-chat.site 요청을 ssl 로 처리

 

ssl_certificate, ssl_certificate_key 는 인증서와 인증서 키가 있는 위치를 설정

 ssl_protocols, ssl_ciphers 는 지원할 SSL/TLS 프로토콜을 명시, 암호와 알고리즘 우선순위 설정

 

 proxy_set_header X-Forwarded-Proto $scheme 은 클라이언트의 원래 요청이 HTTPS로 들어왔는지, HTTP로 들어왔는지 백엔드 서버가 알 수 있도록 하기 위해 사용

 

 

 

docker-compose.yml 설정)

더보기
version: "3.8"
services:
  mysql:
    image: mysql:8.0
    container_name: randomchat-mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: "yy29623358"
      MYSQL_DATABASE: "randomchat"
      MYSQL_USER: "youngho3358"
      MYSQL_PASSWORD: "yy29623358"
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql

  spring:
    build:
      context: ./spring
    container_name: randomchat-spring
    restart: always
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://randomchat-mysql:3306/randomchat
      SPRING_DATASOURCE_USERNAME: youngho3358
      SPRING_DATASOURCE_PASSWORD: yy29623358
    ports:
      - "8080:8080"
    depends_on:
      - mysql

  react:
    build:
      context: ./react
    container_name: randomchat-react
    restart: always
    volumes:
      - ./react/build:/usr/share/nginx/html
    ports:
      - "3000:80"

  nginx:
    build:
      context: ./nginx
    container_name: randomchat-nginx
    restart: always
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - spring
      - react
    volumes:
      - ./nginx/letsencrypt:/etc/letsencrypt

volumes:
  mysql_data:

nginx 설정 부분에 443 포트를 매핑, volumes 로 로컬의 randomchat/nginx/letsencrypt 경로를 도커 컨테이너 내의 /etc/letsencrypt 로 연동

 

즉, 로컬의 randomchat/nginx/letsencrypt 의 파일 정보가 변경되면 도커 컨테이너의 /etc/letsencrypt 가 자동으로 변경된다는 의미이다.

 위 설정을 한 이유는 letsencrypt 의 경우 90일의 무료 인증서 기간을 제공하는데 추후 crontab 기능으로 90일 마다 인증서를 갱신하게끔

설정하여 도커 컨테이너의 인증서 파일을 90일 기준으로 갱신처리하기 위함이다.

 

 

 

도커 컴포즈 재빌드)

더보기

1) 전체 빌드 후 재시작

$ sudo docker-compose up --build

 

 

 

 2) 전체 빌드 후 백그라운드에서 실행 (-d 옵션)

$ sudo docker-compose up --build -d

 

 

 

3) 기존 컨테이너 정리 후 빌드

$ sudo docker-compose down && docker-compose up --build

 

 

 

 4) 캐시를 무시하고 빌드

$ sudo docker-compose build --no-cache && sudo docker-compose up -d

( 빌드 후에도 변경사항이 적용되지 않는 경우 실행 - 이전 캐시 정보로 계속 빌드하는 경우 )

 

 

 

 

 

 

 

위 빌드 방법 중 나는 캐시정보 때문인지 네트워크 정보 때문인지 빌드가 되지 않아 싹 날려버리고 아래와 같이 진행했다.

$ sudo docker-compose down --volumes --remove-orphans
$ sudo docker network prune -f
$ sudo docker volume prune -f

$ sudo docker-compose up --build -d

모든 컨테이너 볼륨, 네트워크, 캐시를 제거한 뒤 재빌드

 

 

 

 

 

 

문제 발생 및 해결)

 

인증서 권한문제 해결)

더보기

도커 컨테이너를 재실행 했을때 nginx 컨테이너가 계속 restarting 으로 표시되는 것을 확인....

2024/12/22 13:27:55 [emerg] 1#1: cannot load certificate "/etc/letsencrypt/live/www.random-chat.site/fullchain.pem": BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory:calling fopen(/etc/letsencrypt/live/www.random-chat.site/fullchain.pem, r) error:10000080:BIO routines::no such file)
nginx: [emerg] cannot load certificate "/etc/letsencrypt/live/www.random-chat.site/fullchain.pem": BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory:calling fopen(/etc/letsencrypt/live/www.random-chat.site/fullchain.pem, r) error:10000080:BIO routines::no such file)

 위와 같은 에러 로그가 남는 것을 확인하였다.

파일을 확인하지 못하는 이유는 실제 인증서 파일이 없거나 혹은 권한이 없기 때문이라고 한다.

 

나는 모든 코드를 sudo 를 사용해 관리자 권한으로 실행했으므로 nginx 에서 COPY 를 통해 파일을 도커 컨테이너로 복사해가도

root 권한이 아닌 이상 읽지 못하는 상태였을 것이다.

 

 

 

 1) letsencrypt 디렉토리의 권한 변경

$ sudo chown -R youngho3358:youngho3358 ./letsencrypt
$ sudo chmod -R 755 ./letsencrypt

사용자를 root 가 아닌 youngho3358 로 변경 한 뒤 letsencrypt 디렉토리를 모든 사용자에게 읽기, 실행 권한을 부여한 뒤 다시 빌드

 

 

 

 

 

 

권한 문제일거라 생각했지만... 결국 코드를 천천히 살펴보니 docker-compose.yml 파일에 nginx volumes 에

./nginx/letsencrypt:/etc/letsencrypt 로 기재해야 할 부분을 .nginx/letsencrypt:/etc/letsencrypt 로 기재해서 발생했던 문제였다.

 

겨우 / 하나 빠진걸로 시간을 3시간이나 소비했지만 결과적으로는 권한 문제로도 해당 에러가 발생할 수 있음을 인지하고 해당 디렉토리의

권한을 755 로 유지하기로 하였다.

 

 

 

 

 

front api 요청 경로 및 websocket 연결 경로를 https 기준에 맞게 변경)

기존에 https:// 와 ws:// 형태를 사용하면 https 쪽으로 이동되기 때문에 https 와 wss 를 적용해서 요청을 보내야 한다.

 

 

 

 

 

프론트 요청 주소를 변경하고 다시 빌드하여 사이트를 재배포하였습니다.

 

Random-Chat

 

www.random-chat.site

728x90

 

 

 

 

 

Wynn 호텔에 들러 식사를 하고나서 Area51 을 모티브로 만들어진 라스베가스의 Area15 을 다녀왔다...! 이상한 물건들 투성이었다.

 

긴 터널을 통과하면서 숨겨진 구역들을 구석구석 다녀볼 수 있었다.

 

 

 

 

 

- TIL -

구현 목표)

API 통신을 통해 채팅방 값을 획득

 

웹 소켓과 STOMP 를 사용하여 방 번호에 대한 채팅을 제어

 

WebSocketSTOMP 를 활용하여 랜덤한 유저끼리 1:1 채팅을 맺어주는 서비스를 개발할 것이다.

이때 A, B, C, D 순으로 웹소켓에 접근하게되면 접근 순서대로 A, B 를 매칭시키고 C, D 를 매칭시키는 형식으로 개발을 진행할 것이며

프론트에서 먼저 채팅 시작 버튼을 누르게되면 Spring 서버로 API 요청을 보내게 되며 서버는 이 요청을 통해 DB 에 매칭된 방에 대한 데이터를

저장한 뒤 랜덤하게 생성된 방번호의 ID 를 반환하게 되며 프론트에서는 이 ID 를 받아 해당 ID 를 붙인 STOMP 서버를 구독하게 만들 것이다.

 

 

 

- Spring 서버 코드 -

 

 

 

의존성 추가)

더보기
// Websocket 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-websocket'

// STOMP 가 포함된 Spring-Boot-Starter-Web 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-web'

spring-boot-starter-web 의존성은 기본적으로 스프링 서버를 구현할때 추가하는 의존성이므로 이미 존재한다면 추가하지 않아도 된다.

 

 

 

WebSocketConfig.class 작성)

더보기
package com.randomchat.main.config.chat;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebsocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // WebSocket 의 엔드포인트 설정
        registry.addEndpoint("/chat") // 클라이언트의 연결 엔드포인트
                .setAllowedOrigins("*"); // 모든 도메인에서의 연결을 허용
    }
}

클라이언트의 웹소켓 접근 엔드포인트를 설정

 

 

 

ChatController.class 작성)

더보기
package com.randomchat.main.controller.chat;

import com.randomchat.main.service.chat.MatchingService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

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

    private final MatchingService matchingService;

    @PostMapping("/enterChatRoom")
    public ResponseEntity<Map<String, Long>> enterChatRoom(@RequestBody String nickname) {
        Long chatUUID = matchingService.enterChatRoom(nickname);
        Map<String, Long> response = new HashMap<>();
        response.put("roomId", chatUUID);
        return ResponseEntity.ok(response); // OK 상태코드와 함께 생성된 방의 UUID 를 반환한다.
    }

}

 클라이언트는 최초 채팅방 접속 시도 시 서버에 API 요청을 통해 자신의 닉네임 정보를 전송하고 서버는 이를 받아 DB 에 새로운 채팅방 정보를

저장한 뒤 생성된 방번호의 ID 정보를 클라이언트에게 리턴한다.

( 이후 클라이언트는 이 방번호로 채팅 채널을 구독한다. )

 

 

 

MatchingService.class 작성)

더보기
package com.randomchat.main.service.chat;

import com.randomchat.main.domain.chat.ChatRoom;
import com.randomchat.main.repository.chat.ChatRoomRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

@Service
@RequiredArgsConstructor
public class MatchingService {

    private final ChatRoomRepository chatRoomRepository;
    private final Map<String, Long> chatRoomList = new ConcurrentHashMap<>();

    public Long enterChatRoom(String nickname) {
        Optional<ChatRoom> findRoom = chatRoomRepository.findFirstBySecondUserIsNullAndOpenTrue();

        if(findRoom.isPresent()) {
            ChatRoom chatRoom = findRoom.get();
            ChatRoom changeChatRoom = chatRoom.addUser(nickname);
            chatRoomRepository.save(changeChatRoom);
            chatRoomList.put(nickname, chatRoom.getId()); // 채팅방 관리 Map 에 닉네임과 채팅방 ID 등록
            return chatRoom.getId();
        }else {
            // 매칭 가능한 방이 없는 경우 새 방 생성
            ChatRoom chatRoom = new ChatRoom(nickname);
            chatRoomRepository.save(chatRoom);
            chatRoomList.put(nickname, chatRoom.getId()); // 채팅방 관리 Map 에 닉네임과 채팅방 ID 등록
            return chatRoom.getId();
        }
    }

    public void closeChatRoom(String channel) {
        String[] parts = channel.split("/");
        Long roomId = Long.parseLong(parts[parts.length - 1]);
        ChatRoom room = chatRoomRepository.findById(roomId).get();
        ChatRoom ClosedChatRoom = room.closeRoom();
        chatRoomRepository.save(ClosedChatRoom);
    }
}

 사용자가 접속 시 채팅방을 생성 및 접속 해제 시 채팅방을 닫는 메소드

( chatRoomList 는 추후에 토큰 값으로 유저를 관리하려고 생성해둔 부분 )

 

 

 

ChatRoomRepository.class 작성)

더보기
package com.randomchat.main.repository.chat;

import com.randomchat.main.domain.chat.ChatRoom;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
    Optional<ChatRoom> findFirstBySecondUserIsNullAndOpenTrue();
}

Spring Data JPA 구현체를 활용하여 채팅방을 탐색하여 두 번째 유저가 null 이며 채팅방 상태가 활성화(Open == true)인 데이터를 가져오는

메소드 생성

 

 

 

ChatRoom.class 작성)

더보기
package com.randomchat.main.domain.chat;

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

@Entity
@Getter
@NoArgsConstructor
public class ChatRoom {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column(nullable = false)
    private String firstUser;

    @Column
    private String secondUser;

    @Column(nullable = false)
    private boolean open;

    public ChatRoom(String nickname) {
        this.firstUser = nickname;
        this.open = true;
    }

    public ChatRoom addUser(String nickname) {
        this.secondUser = nickname;
        return this;
    }

    public ChatRoom closeRoom() {
        this.open = false;
        return this;
    }
}

채팅방 유저 둘과 채팅방 아이디, 채팅방 활성화 상태를 관리하는 ChatRoom 도메인

 

 

 

ChannelEventListener.class 작성)

더보기
package com.randomchat.main.config.chat;

import com.randomchat.main.dto.chat.SendMessageDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionSubscribeEvent;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

@Component
@RequiredArgsConstructor
public class ChannelEventListener {
    // 매칭 이벤트를 담당하는 클래스
    // 2명이 같은 채널에 입장 시 그 채널로 메세지 발송

    private final ConcurrentHashMap<String, AtomicInteger> channelSubscribers = new ConcurrentHashMap<>(); // 채널별 구독자 수를 관리하기 위한 맵
    private final SimpMessagingTemplate simpMessagingTemplate;
    public final Map<String, String> sessionRoomNumberList = new ConcurrentHashMap<>(); // 세션과 채널을 매칭하는 맵

    // 구독 이벤트 처리
    @EventListener
    public void handleSubscribeEvent(SessionSubscribeEvent event) {
        SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.wrap(event.getMessage());
        String destination = headers.getDestination(); // 구독 경로
        String sessionId = headers.getSessionId(); // 세션 아이디

        if (destination != null) {
            // 채널의 구독자 수 증가
            int subscriberCount = channelSubscribers
                    .computeIfAbsent(destination, key -> new AtomicInteger(0))
                    .incrementAndGet();
            sessionRoomNumberList.put(sessionId, destination);

            if (subscriberCount == 2) {
                // 구독자가 2명이 되면 매칭 안내
                sendMatchingMessage(destination);
            }else {
                // 구독자가 1명이면 기다리는 중 안내
                sendWaitingMessage(destination);
            }
        }
    }

    public void sendWaitingMessage(String destination) {
        // 구독 시간 대기 0.3초
        try {
            Thread.sleep(300);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        SendMessageDTO message = new SendMessageDTO("waiting", "", "상대방을 기다리는 중입니다.");
        simpMessagingTemplate.convertAndSend(destination, message);
    }

    public void sendMatchingMessage(String destination) {
        // 구독 시간 대기 0.3초
        try {
            Thread.sleep(300);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        SendMessageDTO message = new SendMessageDTO("matched", "", "매칭되었습니다.");
        simpMessagingTemplate.convertAndSend(destination, message);
    }
}

 채널을 구독하게 되면 이벤트 리스너를 통해 감지하게 되며 구독 경로와 경로의 접속인원 수를 ConcurrentHashMap 객체를 사용해서 체크한 뒤

1명만 접속 중이라면 "상대방을 기다리는 중입니다." 메세지를 전송, 2명이 접속되었다면 "매칭되었습니다." 메세지를 전송

 

 또한, 접속한 Socket 세션 아이디와 방번호를 ConcurrentHashMap 객체를 사용해서 저장한 뒤 소켓 연결이 끊어지면 해당 방 번호에 상대방이

떠났음을 안내하는 용도로 사용

 

 

 

package com.randomchat.main.dto.chat;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class SendMessageDTO {
    private String type;
    private String sender;
    private String message;
}

SendMessageDTO.class 의 구조

 

 

 

ChatMessageController.class 작성)

더보기
package com.randomchat.main.controller.chat;

import com.randomchat.main.dto.chat.ReceiveMessageDTO;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;

@Controller
public class ChatMessageController {

    @MessageMapping("/send/{roomId}")
    @SendTo("/room/{roomId}")
    public ReceiveMessageDTO sendMessage(ReceiveMessageDTO receiveMessageDTO) {
        return receiveMessageDTO;
    }

}

클라이언트가 메세지 전송 채널로 메세지를 전달하면 해당 내용을 서버에서 받아 다시 구독 채널로 뿌려준다.

 

 

 

package com.randomchat.main.dto.chat;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class ReceiveMessageDTO {
    private String sender;
    private String message;
}

RecieveMessageDTO.class 의 구조

( 클라이언트는 메세지를 전송할 때 자신의 닉네임을 sender 로, 채팅 내용을 message 로 JSON 형태로 매핑하여 전송한다. )

 

 

 

 

 

 

 

 

 

 

- React 코드 -

 

 

 

randomchat_info.js 작성)

더보기
import './../css/randomchat_info.css';
import { useContext, useEffect, useState } from 'react';
import { Client } from '@stomp/stompjs'; // STOMP 클라이언트
import { MyContext } from '../../App';
import { IoMdPerson } from "react-icons/io";
import { FiSearch } from "react-icons/fi";
import { GiSouthAfrica } from 'react-icons/gi';

const Randomchat_info = () => {
    const { api } = useContext(MyContext);
    const [stompClient, setStompClient] = useState(null); // STOMP 클라이언트 상태
    const [input, setInput] = useState(''); // 메시지 입력 상태
    const [messages, setMessages] = useState([]); // 메시지 목록
    const [nickname, setNickname] = useState(''); // 닉네임 상태
    const [roomId, setRoomId] = useState(null); // 구독할 채널 번호 상태
    const [isComposing, setIsComposing] = useState(false); // IME 조합 상태 관리

    // STOMP 클라이언트 초기화
    useEffect(() => {
        const client = new Client({
            brokerURL: '{서버 주소}', // STOMP 서버 WebSocket URL
            reconnectDelay: 5000, // 재연결 간격
            debug: (str) => console.log(str), // 디버그 로그
        });

        client.onConnect = () => console.log("STOMP connected");
        client.onStompError = (error) => console.error("STOMP error:", error);

        client.activate(); // 연결 시작
        setStompClient(client);

        return () => client.deactivate(); // 컴포넌트 언마운트 시 연결 종료
    }, []);

    // 닉네임 설정 후 채팅방 연결
    const handleConnect = () => {
        if (nickname.trim()) {
            fetch('{서버주소}/chat/enterChatRoom', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ nickname }), // 닉네임 전송
            })
                .then((response) => response.json())
                .then((data) => {
                    setRoomId(data.roomId); // 응답값에서 채널 번호 추출
                    subscribeToRoom(data.roomId); // 채널 구독
                })
                .catch((error) => console.error('Error:', error));
        }
    };

    // 채널 구독
    const subscribeToRoom = (roomId) => {
        if (stompClient) {
            stompClient.subscribe(`/room/${roomId}`, (message) => {
                const msg = JSON.parse(message.body); // 서버로부터 메시지 수신

                console.log("msg 내용입니다 >>>>> ", msg)

                setMessages((prevMessages) => [
                    ...prevMessages,
                    { type: msg.type, sender: msg.sender, message: msg.message }, // 메시지 목록 업데이트
                ]);
            });
            console.log(`Subscribed to room ${roomId}`);
        }
    };

    // 메시지 전송
    const sendMessage = () => {
        if (input.trim() && stompClient && roomId) {
            const message = { message: input };
            stompClient.publish({
                destination: `/send/${roomId}`, // 메시지 전송 경로
                body: JSON.stringify({"sender" : nickname, "message" : message.message}),
            });
            console.log("메세지 전송 완료!!! >>> " , message.message);
            setInput('');
        }
    };

    // 디버그용 메시지 로그
    useEffect(() => {
        console.log("Messages:", messages);
    }, [messages]);

    // 메세지별 css 클래스네임 정의
    const getMessageClassName = (msg) => {
        console.log("메세지 내용 >>>>> " , msg)
        if(msg.type !== undefined) return 'randomchat_info_text_alert';
        else if(msg.sender === nickname) return 'randomchat_info_text_me';
        else return 'randomchat_info_text_other';
    }

    return (
        <div className='randomchat_info_container'>
            {!roomId ? (
                <div className='randomchat_waiting_screen'>
                    <div className='randomchat_nickname_div'>
                        <input
                            className='randomchat_nickname_input_box'
                            type="text"
                            placeholder="닉네임을 입력하세요"
                            value={nickname}
                            onChange={(e) => setNickname(e.target.value)}
                        />
                        <button onClick={handleConnect}>연결</button>
                    </div>
                </div>
            ) : (
                <>
                    <div className='randomchat_info_title_content'>
                        <div className='randomchat_info_title_icon_content'>
                            <IoMdPerson className='randomchat_info_title_icon'></IoMdPerson>
                        </div>
                        <div className='randomchat_info_title_text'>채팅방</div>
                        <div className='randomchat_info_title_search'>
                            <FiSearch />
                        </div>
                    </div>
                    <div className='randomchat_info_content'>
                        {messages.length === 0 ? (
                            <div className='randomchat_info_none_content'>대화 내용이 없습니다.</div>
                        ) : (
                            <div className='randomchat_info_text_container'>
                                {messages.map((msg, index) => (
                                    <div
                                        key={index}
                                        className={getMessageClassName(msg)}
                                    >
                                        {msg.message}
                                    </div>
                                ))}
                            </div>
                        )}
                    </div>
                    <div className='randomchat_info_input_content'>
                    <input
                        value={input}
                        onChange={(e) => setInput(e.target.value)}
                        onKeyDown={(e) => {
                            if (e.key === 'Enter' && !isComposing) { 
                                // IME 조합 중이 아닐 때만 실행
                                e.preventDefault();
                                if (input.trim()) {
                                    sendMessage();
                                }
                            }
                        }}
                        onCompositionStart={() => setIsComposing(true)} // IME 입력 시작
                        onCompositionEnd={() => setIsComposing(false)}  // IME 입력 완료
                        className='randomchat_info_input'
                    />
                        <div onClick={sendMessage} className='randomchat_info_input_btn'>
                            Send
                        </div>
                    </div>
                </>
            )}
        </div>
    );
};

export default Randomchat_info;

 STOMP 라이브러리를 설치 한 이후 진행

useEffect 를 사용하여 메세지가 수, 발신 될때마다 메세지를 리렌더링

메세지 내용을 useState 를 사용하여 관리

 

 

 

.randomchat_info_container{
    width: 1280px;
    margin: auto;
}

/* 제목 */
.randomchat_info_title_content{
    height: 120px;
    display: flex;
    align-items: center;
    border-bottom: 1px solid rgba(0,0,0,0.5);
}
.randomchat_info_title_icon_content{
    width: 90px;
    height: 90px;
    background-color: rgba(128,128,128,0.2);
    display: flex;
    justify-content: center;
    align-items: center;
    border-radius: 50%;
}
.randomchat_info_title_icon{
    width: 80px;
    height: 80px;
    color: rgba(128,128,128,0.7);
    border-radius: 50%;
}
.randomchat_info_title_text{
    font-size: 20px;
    font-weight: bold;
    margin-left: 10px;

}
.randomchat_info_title_search{
    margin-left: 1060px;
    font-size: 50px;
    color: #3578FF;
    display: flex;
    align-items: center;
}

/* 채팅 정보 */
.randomchat_info_content{
    height: 600px;
    margin-top: 10px;
    overflow-y: auto;
}
/* 스크롤바를 완전히 숨기기 */
.randomchat_info_content::-webkit-scrollbar {
    display: none;  /* 스크롤바를 숨깁니다 */
} 
/* 없음 */
.randomchat_info_none_content{
    height: 600px;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 25px;
    color: rgba(0,0,0,0.5);
}

.randomchat_info_text_container{
    /* display: flex; */
}

/* 아이콘 */
.randomchat_info_other_icon_container{
    display: flex;
    flex-direction: column;
}
.randomchat_info_other_icon_content{
    width: 40px;
    height: 40px;
    background-color: rgba(128,128,128,0.2);
    border-radius: 50%;
    display: flex;
    justify-content: center;
    align-items: center;
    margin-top: auto;
}
.randomchat_info_other_icon{
    width: 30px;
    height: 30px;
    color: rgba(128,128,128,0.7);
    border-radius: 50%;
}
/* 텍스트 */
.randomchat_info_text_content{
    width: 1280px;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
}
.randomchat_info_text_me,
.randomchat_info_text_other,
.randomchat_info_text_alert {
    max-width: 600px;
    width: auto;
    min-height: 40px;
    color: white;
    font-size: 20px;
    background-color: #3578FF;
    display: flex;
    align-items: center;
    justify-content: center; /* 텍스트를 중앙 정렬 */
    border-radius: 8px;
    margin-top: 5px;
    padding-left: 10px;
    padding-right: 10px;
    word-wrap: break-word;
    white-space: normal;
    width: 100%; /* 한 줄을 차지하도록 설정 */
    box-sizing: border-box; /* padding 포함한 전체 크기 계산 */
}

.randomchat_info_text_other {
    background-color: rgba(128,128,128,0.5);
    margin-left: 0; /* 왼쪽으로 정렬 */
    margin-right: auto; /* 오른쪽 여유 공간 */
    text-align: left; /* 텍스트 왼쪽 정렬 */
    justify-content: left;
}

.randomchat_info_text_me {
    background-color: #3578FF;
    margin-left: auto; /* 왼쪽 여유 공간 */
    margin-right: 0; /* 오른쪽으로 정렬 */
    text-align: right; /* 텍스트 오른쪽 정렬 */
    justify-content: right;
}

.randomchat_info_text_alert {
    background-color: #fc0000;
    margin-left: auto; /* 왼쪽 여유 공간 */
    margin-right: auto; /* 오른쪽 여유 공간 */
    text-align: center; /* 텍스트 중앙 정렬 */
}

.randomchat_nickname_div {
    height: 600px;
    overflow-y: auto;
    display: flex; /* 플렉스 박스 활성화 */
    justify-content: center; /* 가로 정렬 */
    align-items: center; /* 세로 정렬 */
}

.randomchat_nickname_input_box {
    /* 닉네임 인풋박스 css*/
    border: 1px solid rgb(159, 153, 153);
    border-radius: 8px;
    text-align: center;
    height: 30px;
    width: 210px;
}

.randomchat_nickname_div > button {
    /* 연결 버튼 css */
    margin-left: 10px;
    background-color: #3578FF; /* 메인 색 배경 */
    color: white; /* 글자색 */
    font-size: 15px; /* 글자 크기 */
    height: 20px;
    width: 40px;
    border: none; /* 테두리 제거 */
    border-radius: 5px; /* 모서리 둥글게 */
    cursor: pointer; /* 마우스 포인터 변경 */
    box-shadow: 0 4px #184192; /* 그림자 */
    transition: background-color 0.3s ease; /* 배경색 변화 효과 */
}

.randomchat_nickname_div > button:hover {
    background-color: #143371;
}

.randomchat_nickname_div > button:active {
    box-shadow: 0 2px #184192; /* 그림자 줄이기 */
  transform: translateY(2px); /* 버튼 눌리는 효과 */
}



/* 나 */
.randomchat_info_me_content{

}
.randomchat_info_me_text_content{
    background-color: #3578FF;
    color: white;
}


/* 입력 */
.randomchat_info_input_content{
    margin-top: 10px;
    margin-bottom: 30px;
    display: flex;
}
.randomchat_info_input{
    width: 1190px;
    height: 50px;
    padding: 0px 0px 0px 10px;
    outline: none;
    font-size: 22px;
}
.randomchat_info_input_btn{
    width: 80px;
    height: 53px;
    background-color: #3578FF;
    color: white;
    font-size: 20px;
    font-weight: 500;
    display: flex;
    justify-content: center;
    align-items: center;
    cursor: pointer;
}

randomchat_info.css 작성

 

 

 

Nginx 추가 설정)

더보기
worker_processes 1;

events {
    worker_connections 1024;
}

http {
    server {
        listen 80;

        server_name www.random-chat.site;
        location / {
            proxy_pass http://randomchat-react:80;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
    }

    server {
        listen 80;

        server_name api.random-chat.site;
        location / {
            proxy_pass http://randomchat-spring:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_cache_bypass $http_upgrade;
        }
    }
}

 현재 Nginx 의 리버스 프록시 기능을 사용하는데 웹 소켓 통신의 경우 헤더에 소켓 정보를 담아 보내기 때문에

필요한 헤더 정보를 추가하는 3줄의 코드를 추가해줬다.

 

proxy_set_header Uprade $http_upgrade;

proxy_set_header Connection 'upgrade';

 

proxy_cache_bypass $http_upgrade;

 

 

 

 

 

랜덤 채팅 작동 영상

랜덤채팅 사이트에 접속해서 확인가능합니다.

 

 

 

 

 

 

▼ 완성된 랜덤채팅 사이트 방문하기 ▼

 

React App

 

www.random-chat.site

 

728x90

+ Recent posts