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

 

 

 

 

 

엔틸롭 캐년 근처의 홀 슈 밴드로 이동하여 비가 너무 많이 와서 잠시 쉬는 동안에 뜬 쌍무지개🌈 와 말도 안되게 아름다웠던 홀 슈 밴드

 

절벽의 바로 앞까지 가서 구경할 수 있었던 홀 슈 밴드, 마치 말 발굽 같이 생겼다고 해서 이름이 홀 슈 밴드라고 한다

 

 

 

 

 

- TIL -

문제 발생)

 

[TIL] 2024.11.18 - jsp 를 활용하여 이메일 인증 코드 발송하기

- TIL - 구현 목표)오늘은 프론트로부터 이메일 정보를 넘겨받아 백에서 랜덤한 회원가입 코드를 생성한 뒤 전달 받은 회원의 이메일로 회원가입 코드를 전송하는 코드를작성해보려 한다. 보통

youngho3358.tistory.com

 

이전에 구현했던 [TIL] 2024.11.18 - jsp 를 활용하여 이메일 인증 코드 발송하기 코드 부분에서 빌드 이후 배포과정까지 진행하니 오류가 발생했다.

이메일 발송 요청을 보내면 403 Forbidden 코드가 응답으로 돌아왔으며 도커 컨테이너 로그를 확인한 겨로가 오류는 아래와 같았다.

오류 내용은 다음과 같다.

 

 

 

문제 해결)

클래스 패스에서 해당 리소스의 파일을 찾을 수 없다는 얘기인데....

 

검색해보니 Spring Boot 프로젝트는 JAR 파일로 배포하게되면 JSP 파일이 resources/templates 경로에 존재하지 않고

WEB-INF/classes 경로로 이동하게 된다고 한다.

 

resources/templates 경로의 경우는 JSP를 제외한 템플릿 엔진(Thymeleaf, FreeMarker 등) 을 저장하는데 사용되는 경로라고 한다.

 

나는 해당 프로젝트에서 JSP 를 웹 뷰로 사용하지 않고 파일 자체를 읽어서 텍스트로 사용하는 형태이기 때문에

src/main/resources/static/emailForm.jsp

위의 경로로 jsp 파일을 저장하여 사용하기로 하였다.

 

resources 하위의 static 경로의 경우 JAR 파일로 빌드한 뒤에도 해당 경로를 유지한다.

 

기존 경로 > 변경 경로

 

기존 코드 경로

 

변경 코드 경로

 

 

현재 코드에서는 resource.getFile() 을 통해 호출하는데

JAR 파일로 패키징된 애플리케이션에서는 JAR 내부 리소스를 파일 시스템의 파일로 처리하려고 하면 예외가 발생하게 된다.

 

그러므로 나는 이 부분의 코드를 InputStream 으로 대체하여 빌드하였다.

InputStream 으로 스트림으로 내부 파일을 읽어들여 스트림 빌더를 사용하여 문자열로 변환하며 저장하게끔 하여 처리하였다.

 

 

 

경로와 코드를 변경한 뒤 build 하여 서버에 다시 배포한 뒤 테스트를 진행하였다.

scp -P [포트 번호] [로컬의 전송할 파일 경로] [서버 유저 계정]@[서버 아이피]:[전송 받을 서버의 파일 경로]

 

기존 도커 컨테이너 종료 및 삭제

 

변경 사항을 --build 옵션으로 빌드하면서 컨테이너를 실행

 

 

 

테스트 결과 이메일 인증이 정상적으로 날아가는 것을 확인하였다!!!

 

인증 메일 전송 완료

 

 

 

728x90
728x90

 

 

 

 

 

엔틸롭 캐년에 방문했을때 건조한 사막기후라 비가 잘 오지 않는다 하였지만 럭키비키...? 하게 엔틸롭 캐년을 비를 맞으며 경험할 수 있었다!

엔틸롭 캐년에 가기 위해 베가스에서부터 끝도 없이 달리고 달렸던 사막 도로...!!! 상상도 못할 정도의 거리를 직진만 해야했다

 

 

 

 

 

- TIL -

 

구현 목표)

1. MySQL 컨테이너 정의하기

2. 가비아에서 도메인 구매하여 아이피랑 연결하기

3. Nginx 를 설치하여 저렴한 도메인을 구매한 뒤 도메인과 연결하여 경로를 처리하기

 

 

 

구현 과정)

1. 필요한 포트 정보를 오픈

더보기

1) 8080 포트와 80 포트 3000 포트를 오픈

8080, 80, 3000 포트를 any 로 오픈

 

 

 

2. randomchat 디렉토리(프로젝트 디렉토리 최상위)에 docker-compose.yml 작성

더보기

1) docker-compose.yml 작성

version: "3.8"
services:
  mysql:
    image: mysql:8.0
    container_name: randomchat-mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: "{자신의 mysql root 비밀번호 설정}"
      MYSQL_DATABASE: "{사용할 데이터베이스 이름 설정}"
      MYSQL_USER: "{사용할 유저 아이디 설정}"
      MYSQL_PASSWORD: "{사용할 유저의 비밀번호 설정}"
    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/{위의 mysql 에서 정의한 데이터베이스 이름 설정}
      SPRING_DATASOURCE_USERNAME: {위의 mysql 에서 정의한 유저 아이디 입력}
      SPRING_DATASOURCE_PASSWORD: {위의 mysql 에서 정의한 유저 비밀번호 입력}
    ports:
      - "8080:8080"
    depends_on:
      - mysql

  react:
    build:
      context: ./react
    container_name: randomchat-react
    restart: always
    volumes:
      - ./react/build:/usr/share/nginx/html
      # 중요!!!! react 의 빌드파일을 nginx 의 서빙 디렉토리와
      # 맵핑하므로써 80포트로 접속시 리액트 프로젝트가 출력되게 한다!
    ports:
      - "3000:80"

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

volumes:
  mysql_data:

해당 설정을 통해 MySQL 컨테이너를 생성하면서 동시에 Spring 컨테이너와 의존 관계를 생성해주고

Nginx 의 리액트, 스프링 프로젝트의 의존 관계도 설정해준다.

 

 

 

2) randomchat 디렉토리 내부 상태

randomchat 디렉토리 내부 상태

 

 

 

3. 가비아에서 도메인 구매 후 아이피 연결

더보기

1) 가비아에서 도메인 구매

random-chat.site 도메인을 구매

 

2) DNS 설정하기

( 아이피와 도메인을 연결하는 과정 )

배포 서버의 공인 IP 값을 도메인과 연결, www.random-chat.site 로 접근시 api.random-chat.site 로 접근시 동일한 서버 아이피로 전송하게끔 세팅

( 도메인 경로를 나눠놓는 이유는 www 로 오게되면 Nginx 를 통해 React 서버를 호출, api 로 오게되면 Ngingx 를 통해 Spring 서버를 호출하기 위함 )

 

 

 

4. Nginx 설정

더보기

1) Nginx 리버스 프록시 파일 설정

( nginx 디렉토리에 nginx.conf 파일 생성 및 작성 )

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;
        }
    }
}

( 80 포트로 접속 시 접속 도메인 별로 www 로 접속하면 React 프로젝트를 호출, api 로 접속하면 Spring 프로젝트를 호출하게 설정 )

 

 

 

 

2) Dockerfile 파일 생성 및 작성

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

도커의 버전과 설정을 잡아준다.

 

 

 

3) nginx 디렉토리 내부 상태

nginx 디렉토리 내부 상태

 

 

 

5. 빌드 및 테스트

더보기

1) randomchat 디렉토리에서 아래 명령어로 빌드 실행

docker-compose up --build

 

 

 

2) 테스트

www.random-chat.site 로 접속하면 위와 같이 리액트 프로젝트가 잘 출력되는 모습

 

api.random-chat.site 의 하위 경로로 api 요청을 보내도 제대로 응답이 오는 모습이다.

 

 

 

구현 후기)

Spring 프로젝트까지는 제대로 Nginx 를 통해 매핑되어 호출이 이루어졌는데 React 프로젝트가 매핑되지 않아 URL 접속 시 Nginx 의 메인 화면이 계속

출력되는 상황이 발생하였다.

 

이는 docker-compose.yml 의 React 프로젝트의 볼륨을 Nginx 의 디렉토리로 잡아줌으로써 서빙되는 파일을 제대로 리액트 프로젝트의

index.html 파일로 맵핑해줌으로써 해결하였다.

 

위 오류로 인해 3일이라는 시간이 소비되었지만 결국은 배포에 성공하였고 이제 남은 것은 Jenkins 를 활용한 CI / CD 파이프라인 구축이다!

 

 

 

 

 

최종적으로 배포된 사이트의 주소는 아래로 접속하면 되며 아직은 프론트 세부 로직이 구현되지 않은 상태이므로 천천히 구현해나갈 것이다.

 

React App

 

www.random-chat.site

( 미니 PC 는 항상 켜놓지 않고 오전, 오후 시간대에만 실행시켜 서비스할 생각이다. )

 

728x90

 

 

 

 

 

베가스의 밤은 불이 꺼지지 않았다... 정말 번쩍번쩍한 호텔들이 많았고 길거리 어딜 가든지 카지노가 즐비했으며 대마초 냄새가 진동을 했다...

 

호텔앞에서는 정해진 시간마다 분수쇼가 5분 넘게 진행되었다

 

 

 

 

 

- TIL -

 

구현 목표)

프로젝트를 배포할 미니pc 한대를 구매하게 되었다.

미니 피씨에 Docker Compose 를 활용하여 Spring, React, MySQL, Nginx 컨테이너를 각각 생성할 것이며

Nginx 의 리버스 프록시 기능을 사용하여 접속 도메인 별로 React 프로젝트와 Spring 프로젝트로 분류하여 응답할 것이다.

 

 

 

프로젝트 디렉토리 구조)

randomchat/
├── nginx/
│   ├── nginx.conf
│   └── Dockerfile
├── spring/
│   └── Dockerfile
├── react/
│   └── Dockerfile
├── docker-compose.yml

 

 

 

구현 과정)

1. 도커 컨테이너를 구현하기 위해 Docker 설치와 생성된 컨테이너들을 하나로 묶어서 관리할 수 있게 해주는 Docker Compose 를 설치해준다.

더보기

1) 아래 명령어를 사용하여 Docker 와 Docker Compose 를 설치

sudo apt update
sudo apt install docker.io docker-compose -y

 

 

 

2) 각 컨테이너를 관리할 디렉토리를 생성

( 홈 디렉토리 위치는 root 계정의 홈이 아닌 사용자 계정의 홈 디렉토리로 생성한다 )

mkdir -p ~/randomchat/{spring,mysql,react,nginx}
cd ~/randomchat

 

 

 

2. Spring Dockerfile 작성

더보기

1) Spring 컨테이너로 이동하여 빌드된 스프링 파일을 위치시킨다.

( scp 를 사용하여 빌드 파일을 미니 피씨로 전송하였다 )

-P 뒤에는 사용하는 SSH 포트 번호를 기재, youngho3358 은 사용자 계정이며 @ 뒤에는 아이피 주소가 들어간다.

 

정상적으로 빌드 파일이 spring 디렉토리 하위에 생성되었다.

 

위 작업 이후에  jar 파일 이름을 app.jar 로 mv 명령어를 사용해 변경해주었다.

 

 

 

 2) Dockerfile 작성

( /randomchat/spring 경로에 작성 )

FROM openjdk:21-jdk-slim
WORKDIR /app # 컨테이너 내부의 작업 경로
COPY app.jar app.jar
# 로컬의 빌드파일인 app.jar 파일을 도커 컨테이너 내부 경로에 동일한 이름인 app.jar 로 복사하여 사용
# 여기서 앞의 app.jar 이 로컬의 위치인데 docker-compose.yml 에 context 항목을
# 현재 위치인 . 으로 표기했기 때문에 현재 위치인 spring 디렉토리부터의 경로를 기재해주면 된다.
ENTRYPOINT ["java", "-jar", "app.jar"]

 

 

 

3) 디렉토리 내부 상태

app.jar 와 Dockerfile 존재

 

 

 

3. React Dockerfile 작성

더보기

1) React 컨테이너로 이동하여 빌드된 리액트 파일을 위치시킨다.

( scp 를 사용하여 빌드 파일을 미니 피씨로 전송하였다 )

리액트 빌드 파일을 scp 를 사용해서 가져온 뒤 압축을 풀어 react 디렉토리에 위치시켜준다.

 ( 주의할 점 : build.zip 을 압축해제 하면 build/build/{빌드파일} 형태로 디렉토리로 하나 더 감싸져 있는 경우가 있기 때문에 주의해야 한다. )

 

 

 

 2) Dockerfile 작성

( /randomchat/react 경로에 작성 )

FROM nginx:latest
WORKDIR /user/share/nginx/html
COPY build/ .
CMD ["nginx", "-g", "daemon off;"]

 ( COPY 를 통해 react 디렉토리 내부에 build 파일 하위의 파일을 복사하여 도커 컨테이너 내부에 복사하여 사용한다 )

 

 

 

 3) 디렉토리 내부 상태

디렉토리 내부 상태

 

 

 

To-Do List)

1. MySQL 컨테이너 정의하기

2. 가비아에서 도메인 구매하여 아이피랑 연결하기

3. Nginx 를 설치하여 저렴한 도메인을 구매한 뒤 도메인과 연결하여 경로를 처리하기

4. Jenkins 를 사용하여 깃허브에 코드가 커밋될때마다 자동으로 배포하게 설정하기

728x90

 

 

 

 

 

고든램지가 운영하는 RAMSAY'S KITCHEN 를 방문했다. 에피타이저로 SHRIMP COCKTAIL 을 시키고 시그니처 메뉴인 BEEF WELLINGTON 를 시키고 스테이크 하나와 리조또 한 종류를 시켜 먹었다.

 

 

 

 

 

- TIL -

 

문제 발생)

 

분명히 배포가 성공되었고 제대로 통신이 되는지 테스트까지 마친 뒤 프론트 서버를 개발하는 지인에게 서버의 주소를 알려주고 통신이 가능한 상태라고

얘기했다.

 

그런데 프론트 개발자 분이 통신을 시도했을때 정상적으로 통신되지 않는 현상이 발생되었고 포스트맨 앱으로 확인해보니 다음과 같은 문제가 발생했다.

 

분명히 어제는 테스트시 정상 통신이 되었는데 오늘 확인해보니 통신이 되지 않는 모습

 

혹시 몰라 GCP 서버가 내려갔는지 확인해보았지만 정상적으로 살아있는 모습이다.

서버가 활성화 되어있는 모습

 

즉 서버 PC 는 켜져있는데 웹서버만 다운되었다는 소리인데...

이유를 검색해봐도 마땅한 해결책이 나오지 않았고 웹서버를 다시 CLI 환경에서 가동시키니 정상적으로 작동되는 것을 확인했다.

 

그렇다면 웹서버가 다운되는 이유를 알기 위해 웹서버가 켜지고 다운되는데 까지 시간을 한번 측정해보고자 했다.

( 각 테스트는 1분마다 주기적으로 연결이 되는지 확인을 통해 진행되었다 )

 

16:48 웹서버 실행 후 SSH 접속 종료 -> 16:58 웹서버 다운

17:04 웹서버 실행 후 SSH 접속 유지 -> 17:14 웹서버 정상 작동 확인 후 SSH 종료 -> 17:24 웹서버 다운

 

테스트 결과 10분 주기로 웹서버가 종료되는 것 확인....!

 

 

 

 

 

문제 해결 방법1)

 

나는 저 10분 단위가 뭔지 고민하던 와중에 혹시 절전모드 때문에 웹서버가 종료되는 것이 아닌가라는 생각을 하게 되었고 리눅스의 절전모드

해제 방법에 대해 구글링하여 절전 모드를 종료하고 테스트 해보기로 하였다.

 

1) Ubuntu(Linux) 절전 모드 해제하기

더보기

절전 모드를 해제하기 전에 절전모드가 설정되어 있는지 확인

( sudo systemctl status sleep.target suspend.target hibernate.target hybrid-sleep.target )

enabled 로 설정되어 있는 것으로 보아 모두 활성화 되어 있는 상태인가 보다

 

 

 

 절전 모드를 모두 비활성화

( sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target )

절전 모드를 비활성화

 

 

 

절전 모드 확인 

( sudo systemctl status sleep.target suspend.target hibernate.target hybrid-sleep.target ) 

아까완 달리 enabled 가 사라진 모습!

 

 

 

절전모드를 다시 설정해야하는 상황에는 아래 명령어를 입력하면 된다.

( sudo systemctl unmask sleep.target suspend.target hibernate.target hybrid-sleep.target )

 

2) 서버 작동 테스트

 

17:41 웹서버 정상 작동 -> 17:50 웹서버 다운

 

테스트 결과 절전모드로 인한 문제는 아닌 것으로 보인다....

 

 

 

 

 

해결 방법2)

 

곰곰히 다른 문제를 생각해보던 와중 혹시 SSH 연결이 끊기면서 콘솔이 닫혀서 웹서버가 종료되는 것은 아닌지에 대한 의문이 생겼다.

 

그래서! 콘솔이 닫혀도 계속 서버가 작동되는 방법을 서치하던 와중에 nohup 이라는 명령어를 알게 되었고 이 방법으로 다시 테스트를 진행해보고자 한다!

 

1) nohup 명령어를 사용하여 웹서버 실행

더보기

기존 명령어와 동일하지만 앞에 nohup 을 붙여주어서 shell 창이 껴져도 백그라운드에서 서버가 작동할 수 있게 작동시켰다.

( sudo nohup java -jar [자바 프로젝트의 jar 파일] )

nohup 으로 실행

 

2) 서버 작동 테스트

18:00 웹서버 정상 작동 -> 18:10 웹서버 정상 작동 -> 18:20 웹서버 정상 작동 -> 18:30 웹서버 정상 작동

-> 23:02 웹서버 정상 작동

 

 

 

 

 

후기)

결론은 SSH 연결을 종료하면서 10분 정도 텀을 두고 서버의 쉘이 종료되기 때문에 발생된 일이었다.

nohup 명령어를 통해 백그라운드로 프로레스를 실행해 놓은 상태인데 이보다 좋은 방법은 nginx 를 사용하여 배포하는 방식이라고 하니 해당 방식을 구현해보도록 해야겠다.

 

그리고 무료 할당된 서버의 성능이 썩 좋은 편은 아니라서 조만간 미니PC 를 하나 할당해서 공인 IP 를 부여하고 DNS 와 매치하여 배포하는 과정을 진행해보려 한다.

728x90

 

 

 

 

 

시애틀에서 비행기를 타고 라스베이거스에 도착!!! 도착하자마자마 반겨주는 베가스 간판의 매장이 보인다

 

 

 

 

 

- TIL -

 

구현목표)

프로젝트를 빌드하여 GCP 서버에 배포하자!

 

 

 

구현과정)

 

1. 프로젝트를 빌드한다!

더보기

IntelliJ 를 사용하므로 IntelliJ 내에서 편하게 빌드할 수 있는 방법으로 빌드를 구현하였다.

 

프로젝트 디렉토리에 접근하여 ./gradlew build 명령어로도 빌드가 가능하다.

 

- IntelliJ 로 빌드하기 -

 

 1) 우측의 Gradle 버튼 클릭

2) 프로젝트 하위의 build 하위의 build 클릭

( 만약, 빌드 실패 시 아래 clean 을 클릭하여 이전 빌드 정보를 지운 뒤 다시 시도하거나 빌드 오류를 파악하여 해결해야 함 )

( 나는 DB 연결설정 때문에 오류가 발생하여 해결한 뒤 빌드를 성공하였음, 빌드 오류시 html 파일로 친절하게 오류 내용을 알려줌 )

3) 빌드 성공시 좌측 하단에 빌드 여부가 초록색 체크표시로 표시됨

4) 빌드된 파일은 프로젝트 내부의 build 디렉토리 하위 libs 디렉토리 하위에 저장된다.

( main-0.0.1-SNAPSHOT.jar 가 빌드된 파일 )

 

2. 빌드된 프로젝트 파일을 GCP 서버에 SSH 를 사용하여 전송한다!

더보기

GCP 에서는 웹으로 SSH 접속을 쉽게 할 수 있도록 지원한다.

 

1) GCP 에서 웹을 통해 SSH 접속

GCP VM 인스턴스 항목에서 사용될 서버 피씨로 SSH 연결...! 웹으로 SSH 연결을 하여 커맨드 입력이 된다니 AWS 에서도 놀랐지만 한번 더 놀랐다...!

 

 

 

2) SSH 를 통해 서버에 빌드된 jar 파일을 전송

( 파일 업로드를 클릭하여 파일을 GCP 의 서버로 전송 )

세상이 참 편해졌다, 원래는 SSH 도 커맨드 라인을 통해 접속했어야 했는데 이제는 파일 업로드까지 웹 상에서 파일을 선택하여 전송이 된다

 

 

 

3) 업로드된 파일을 프로젝트 디렉토리로 이동시키자!

업로드된 파일은 홈 디렉토리에 존재한다

 

업로드된 파일을 프로젝트 디렉토리로 옮겨주었다!

 

3. 빌드된 프로젝트를 실행시킨다!

더보기
java -jar [빌드된 파일명] 명령어를 사용하여 프로젝트를 실행!!

 

4. 정상적으로 빌드되었는지 테스트 진행!

더보기

테스트는 포스트맨으로 진행하였다.

포스트맨을 사용하여 할당받은 아이피의 8080 포트로 회원가입 요청을 보내니 응답이 오는 모습

( 409 Conflict 코드가 출력된 이유는 이미 동일한 요청을 스크린샷 찍기 전에 한번 보냈기 때문! )

 

 

 

해결해야 할 사항)

 

1) 구현하는 동안 추가적으로 진행해야할 사항은 우선 SSL 인증이 되어있지 않으므로 https 요청을 처리하지 못하는 문제점이 발생한 것이다.

위 문제점은 SSL 을 무료로 인증받는 방법을 사용하거나 다른 방안을 탐색할 것이다.

 

2) Nginx 의 리버스 프록시 기능을 사용하여 프론트도 동일한 서버에 배포를 진행해보고자 한다.

 

3) 도메인을 연결해보자.

 

 

이후 글에서는 위의 세가지를 처리해볼 것이다!

728x90

 

 

 

 

 

한국에는 판매하지 않는 기아의 텔루라이드 모델과 맥도날드의 Filet-O-Fish(필레오피쉬)

 

 

 

 

- TIL -

 

구현 목표)

오늘은 프론트로부터 이메일 정보를 넘겨받아 백에서 랜덤한 회원가입 코드를 생성한 뒤 전달 받은 회원의 이메일로 회원가입 코드를 전송하는 코드를

작성해보려 한다.

 

보통은 이메일 인증을 redis 와 같은 인메모리 DB 로 구현하지만 나는 사용 중인 주 DB 인 MySQL 을 사용하여 이메일 인증 정보를 저장하고 사용하고자 하였다.

 

코드의 흐름은 간단하게 아래와 같이 구현하고자 했다.

 

더보기

1. 사용자가 회원가입시 자신의 이메일을 작성한 뒤 인증번호 받기 버튼을 클릭

 

2. 프론트에서 작성된 이메일 정보를 백엔드에게 전달

 

3. 백엔드에서 이메일 정보를 전달 받은뒤 회원가입된 이메일인지 아닌지 확인

( 이미 가입된 회원이라면 상태코드 409 반환 )

 

4. 한 이메일을 기준으로 5분에 3번까지만 인증 시도를 허용할 것이므로 DB 를 시간 기준으로 출력하여 5분 내에 회원가입 시도가 5회 이상인지 확인

( 5회 이상으로 시도한 회원이라면 상태코드 429 반환 )

 

5. 가입되지 않은 이메일 정보라면 랜덤 회원가입 코드를 생성하여 DB 에 이메일 정보와 인증 시간 값을 저장한 뒤 회원가입 코드를 전송

 

위 과정 중 오늘 구현할 부분은 회원가입 시도 횟수 체크 부분인 4번을 제외한 모든 부분을 구현해 볼 것이다.

 

 

 

 

 

구현 코드)

더보기

1. Controller

package com.randomchat.main.controller.register;

import com.randomchat.main.dto.register.EmailVerificationDTO;
import com.randomchat.main.service.email.EmailVerificationService;
import com.randomchat.main.service.register.RegisterService;
import jakarta.mail.MessagingException;
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.io.IOException;

@RequiredArgsConstructor
@RestController
@RequestMapping("/register")
public class EmailVerificationController {

    private final EmailVerificationService emailVerificationService;
    private final RegisterService registerService;

    @PostMapping("/email/verification")
    public ResponseEntity<String> emailVerification(@RequestBody EmailVerificationDTO emailVerificationDTO) throws MessagingException, IOException {

        String email = emailVerificationDTO.getEmail();

        // 1. 이메일 인증이 들어온 이메일 정보로 가입된 계정이 있다면 deny
        if(registerService.checkEmailDuplication(email)) return ResponseEntity.status(409).body("이미 가입된 이메일입니다.");

        // 2. 이메일 인증이 들어온 기준 시간으로 부터 5분 내로 DB 내에 5회 인증 요청이 있다면 deny
        // emailVerificationService.esExceededLimit(email);

        // 3. 이메일 인증용 난수 생성
        String verificationCode = emailVerificationService.createCode();

        // 4. 이메일을 발송한 뒤 이메일 인증 데이터 DB 에 저장
        emailVerificationService.sendEmail(email, verificationCode); // 인증 이메일 발송
        emailVerificationService.saveEmailVerification(email, verificationCode); // DB 에 인증 내용 저장

        return ResponseEntity.ok("이메일이 발송되었습니다.");
    }
}

 

2. RegisterService ( JPA 쿼리 메소드를 사용하여 동일한 메일이 있는지 체크 )

package com.randomchat.main.service.register;

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

@Service
@RequiredArgsConstructor
public class RegisterService {
    private final UsersRepository usersRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    public void register(RegisterDTO registerDTO) {
        String email = registerDTO.getEmail();
        String password = registerDTO.getPassword();

        // 받아온 비밀번호 정보를 암호화
        registerDTO.setPassword(bCryptPasswordEncoder.encode(registerDTO.getPassword()));

        Users users = new Users();
        Users user = users.createUser(registerDTO);
        usersRepository.save(user);
    }

    public boolean checkEmailDuplication(String email) {
        return usersRepository.existsByEmail(email);
    }

    public boolean checkNicknameDuplication(String nickname) {
        return usersRepository.existsByNickname(nickname);
    }
}

 

3. emailVerificationService

 

- createCode 메소드 -

1) 난수에 사용될 문자들을 미리 선언 후 RANDOM 클래스의 nextInt 메소드를 사용하여 문자 길이만큼의 수 내에서 랜덤 숫자를 생성

2) 랜덤 숫자를 다시 CHARACTERS 문자열의 인덱스로 접근하여 랜덤한 문자를 뽑아 StringBuilder 에 추가한 뒤 반복문이 다 돌면 빌드

 

- renderJspToString 메소드 -

1) templates 디렉토리에 있는 jsp 파일을 불러와 Resource 객체로 생성

2) 생성된 객체의 정보를 UTF-8 으로 인코딩된 String 으로 변환

3) String 으로 변환된 내용 중 ${verificationCode} 문자를 실제 인증 코드로 변경

 

- sendEmail 메소드 -

1) 이메일을 전송하기 위해 Mimemessage 객체를 생성

2) Mimemessage 를 쉽게 설정하도록 도와주는 헬퍼 클래스인 MimemessageHelper 클래스를 선언하여 다음과 같이 설정

 

true : HTML 형식을 지원하도록 설정

UTF-8 : 이메일 내용의 인코딩 방식을 설정

 

3) 헬퍼 플래스를 사용하여 이메일 주소, 제목, 내용을 다음과 같이 설정

 

setTo : 수신자 이메일 작성

setSubject : 메일 제목 설정

setText : 메일 본문을 설정, true 로 HTML 형식 적용

setFrom : 발신자의 이메일 주소를 설정

package com.randomchat.main.service.email;

import com.randomchat.main.domain.email.EmailVerification;
import com.randomchat.main.repository.EmailVerificationRepository;
import com.randomchat.main.repository.UsersRepository;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.SecureRandom;
import java.util.Random;

@Service
@RequiredArgsConstructor
public class EmailVerificationService {

    private final UsersRepository usersRepository;
    private final EmailVerificationRepository emailVerificationRepository;
    private final ResourceLoader resourceLoader;
    private final JavaMailSender mailSender;
    private static final Random RANDOM = new SecureRandom();
    private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; // 난수에 사용될 문자


    public String createCode() {
        StringBuilder stringBuilder = new StringBuilder();

        // 10 자리 난수를 생성할 것이므로 10회 반복
        for (int i = 0; i < 10; i++) {
            // 난수에 사용될 문자의 길이만큼의 랜덤한 값을 추출
            int randomIndex = RANDOM.nextInt(CHARACTERS.length());
            // 랜덤한 index 의 문자를 추출하여 문자열로 추가
            stringBuilder.append(CHARACTERS.charAt(randomIndex));
        }

        return stringBuilder.toString();
    }

    public void saveEmailVerification(String email, String verificationCode) {
        EmailVerification emailVerification = new EmailVerification();
        EmailVerification createEmailVerification = emailVerification.createEmailVerification(email, verificationCode);
        emailVerificationRepository.save(createEmailVerification);
    }

    public String renderJspToString(String verificationCode) throws IOException {
        // JSP 파일 내용을 String 으로 읽어오기
        Resource resource = resourceLoader.getResource("classpath:/templates/emailForm.jsp");
        String content = new String(Files.readAllBytes(resource.getFile().toPath()), StandardCharsets.UTF_8);

        // 인증 코드를 내용에 삽입하여 리턴
        return content.replace("${verificationCode}", verificationCode);
    }

    public void sendEmail(String email, String verificationCode) throws IOException, MessagingException {
        String emailContent = renderJspToString(verificationCode);

        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

        helper.setTo(email);
        helper.setSubject("랜덤 채팅의 이메일 인증 번호입니다.");
        helper.setText(emailContent, true);  // HTML 형식으로 보낼 때 true 설정
        helper.setFrom("youngho3358@gmail.com");

        mailSender.send(message);
    }
}

 

4. emailForm.jsp

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Email Verification</title>
    <style>
        * {
            text-align: center;
        }
        body {
            font-family: Arial, sans-serif;
            text-align: center;
        }
        h1 {
            color: #4CAF50;
        }
        .code {
            font-size: 24px;
            font-weight: bold;
            color: #333;
        }
        p {
            font-size: 16px;
        }
        .footer {
            font-size: 14px;
            color: #999;
        }
    </style>
</head>
<body>
    <h1>이메일 인증</h1>
    <p>RandomChat 가입을 환영합니다!</p>
    <p>아래의 인증코드를 입력하여 회원가입을 완료해주세요.</p>
    <div class="code">${verificationCode}</div>
    <p class="footer">감사합니다.</p>
</body>
</html>

 

5. application.yml 설정

gmail 의 smtp 서버를 활용

 

 

 

 

 

구현 후기)

 

우선은 jsp 를 활용하여 치환하면 더 편할거란 생각으로 jsp 파일을 사용하였는데 단순하게 파일을 문자열로 읽어와서 문자를 치환하는 형태이기 때문에

jsp 파일을 사용할 필요가 없었다.

결국은 간단하게 txt 파일이나 html 파일로도 구현한 부분이었다...

 

그리고 아래는 redis 를 사용하여 구현하였을때의 장, 단점과

주 DB 인 MySQL 로 구현하였을때의 , 점이다.

구분 Redis 사용 MySQL 사용
장점 - 속도: 메모리 기반으로 매우 빠르게 데이터 처리 가능.
- TTL 지원: 인증 코드의 유효 기간 설정을 TTL(Time To Live)로 간편하게 관리.
- 부하 분산: 주 DB에 부하를 덜어주어 시스템의 성능을 최적화.
- 통합성: 기존 시스템(DB)에 통합되어 추가적인 외부 스토리지 필요 없음.
- 데이터 영속성: 인증 기록이 데이터베이스에 저장되어 추적 및 분석 가능.
- 트랜잭션 지원: 인증 관련 데이터와 다른 테이블의 데이터 간 연관성을 보장.
단점 - 데이터 영속성 부족: Redis는 메모리 기반이므로 서버 재시작 시 데이터 손실 위험.
- 추가 인프라 필요: Redis 설치 및 관리 필요.
- 데이터 용량 제한: 메모리 용량에 제한이 있으므로 대규모 데이터 저장 시 비효율적.
- 속도: Redis에 비해 상대적으로 느린 데이터 처리 속도.
- 복잡성 증가: TTL 구현을 별도로 코딩해야 하며 인증 코드 만료 관리가 번거로움.
- 부하 증가: 인증 관련 데이터가 많아지면 주 DB에 부하가 가중될 가능성.
적합한 상황 - 대량의 인증 요청을 처리해야 하는 고성능 시스템.
- 인증 요청 처리 시간이 매우 중요한 서비스.
- 인증 데이터가 비교적 적고, 인증 기록을 영속적으로 저장해야 하는 경우.
- 이미 MySQL 환경에서 동작 중이며 추가적인 데이터베이스 사용이 부담스러운 경우.

 

 

 

[ 배포 시에 해당 부분 코드에 문제가 발생하여 따로 글을 포스트하여 에러 픽스 부분을 기술했습니다 ]

 

[TIL] 2024.12.14 - 인증 코드 발송 오류 해결 ( 이메일 인증 구현 )

절벽의 바로 앞까지 가서 구경할 수 있었던 홀 슈 밴드, 마치 말 발굽 같이 생겼다고 해서 이름이 홀 슈 밴드라고 한다     - TIL -문제 발생) [TIL] 2024.11.18 - jsp 를 활용하여 이메일 인증 코드 발

youngho3358.tistory.com

 

 

 

 

728x90

 

728x90

 

 

 

 

 

시애틀에 있는 스타벅스 리저브 1호점!! 시애틀의 스타벅스 리저브에서는 맥주를 판매한다 :)

 

 

 

로스팅될 원두가 이동되는 모습이다.

 

 

 

 

 

- TIL -

 

구현 목표)

AWS 의 RDS 서비스를 이용하다가 17만원이 과금된 적이 있으므로 AWS 서비스는 뒤로하고 Google 의 GCP 서비스를 이용해 볼 생각이다.

 

구글에서 제공하는 클라우드 서비스인 GCP 서버를 활용하여 구현중인 Spring Boot 프로젝트를 배포해볼 예정이다.

배포될 서버는 API 서버이며 GCP 무료 서버 설정은 아래 블로그를 참고하였다.

 

 

[Linux] GCP 무료 티어 서버 만들기

GCP에서 무료 티어 서버를 만들어보자. 1. GCP 홈페이지 방문 구글 계정 로그인 후 아래 GCP 홈페이지에 들어간다. https://cloud.google.com/free?hl=ko 들어가서 '무료로 시작하기' 버튼을 클릭한다. 그러면

velog.io

참고가 된 블로그에 무한한 감사를 드립니다

 

 

 

구현 과정)

1. GCP 에서 VM(가상머신) 을 생성

2. JAVA 설치

더보기
설치된 패키지를 업데이트

 

apt 저장소를 사용하여 openjdk 21 버전을 설치

 

java -version 명령어를 사용하여 설치된 자바 버전을 확인

3. 빌드된 파일을 보관할 디렉토리 생성

더보기
빌드 파일을 보관할 randomchat 경로 생성

4. MySQL 설치 및 설정

더보기
설치된 패키지를 업데이트

 

mysql 설치

 

mysql 포트 허용

 

mysql 데몬 실행 및 서버 재부팅 시 자동 실행 설정

 

mysql root 계정으로 접속

 

root 계정 비밀번호 변경 후 변경사항 FLUSH PRIVILEGES 로 적용

 

sudo 를 제외하고 mysql 을 root 권한으로 접속 >> 비밀번호 입력 후 엔터

 

DB 관리용 유저 아이디를 생성 ( 빨간색 : 비밀번호 ), root 권한을 모두 부여

 

vi 편집기를 사용하여 외부에서 sql 서버에 접속가능하게끔 설정

 

기존의 127.0.0.1 인 로컬에서만 접속 가능하던 아이피를 외부에서도 접근 가능하게 0.0.0.0 으로 수정 후 저장

 

sql 데몬을 종료 후 재시작

 

mysql 서버의 시간을 서울 시간 기준으로 변경

 

데몬 재실행

 

사용자 계정으로 로그인 ( 비밀번호 입력 )

 

Spring 프로젝트에서 jpa 를 활용하여 사용할 데이터베이스 생성 후 종료

5. 기존 프로젝트에 MySQL 의존성 추가 및 DB 접속 설정 파일 작성

더보기
build.gradle 에 mysql 의존성 추가

 

기존에 사용하던 개발용 H2 데이터베이스의 설정 값을 주석처리한 뒤 mysql 서버의 값으로 변경

6. VM 방화벽 설정

 

 

이제 MySQL 설정과 JAVA 설치는 모두 끝났으니 다음 글에서는 프로젝트 파일을 jar 파일로 변경하여 가동중인 GCP 서버에 jar 파일을 전송하여 서버를

돌려볼 것이다.

728x90

+ Recent posts