2023/07/24
이번 우리조는 스터디 및 발표를 준비 하면서 이것저것 주제를 받았다.
WebSocket
STOMP
Mail (JavaMailSender)
Spring Batch & scheduler
해당스펙으로 많은 기능구현을 할 수 있겠지만 수업을 겸하며 따로 시간을 빼서 준비하기엔 일정이 빠듯하므로 해당 기술에 대해 개념을 익히고 중요한 부분만 간단만 미니프로젝트로 구현을 하기로 하였다.
따라서.. 우리가 해볼 미니프로젝트는 미니 카카오톡을 만들어보기로 하였다.
우리가 주제로 정한 스킬들로 구현할 부분들은 아래와 같다.
WebSocket ( 채팅 기능 )
STOMP ( 오픈채팅방, 대화 시 대화가 쌓였다는 카톡 실시간 알림 )
Mail ( 회원가입 시 이메일 인증 )
Spring Batch & scheduler ( 대화내역 백업 및 대화 리마인더 )
랜딩페이지
로그인화면
회원가입
친구창
오픈채팅방
채팅창
그 외..
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
//websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:sockjs-client:1.1.2'
implementation 'org.webjars:stomp-websocket:2.3.3-1'
//view
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-freemarker'
implementation 'org.springframework.boot:spring-boot-devtools'
implementation 'org.webjars.bower:bootstrap:4.3.1'
implementation 'org.webjars.bower:vue:2.5.16'
implementation 'org.webjars.bower:axios:0.17.1'
implementation 'com.google.code.gson:gson:2.8.0'
// mail..
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '3.1.1'
}
Chatconfig
@Configuration
// WebSocket 메시지 브로커를 활성화
@EnableWebSocketMessageBroker
public class ChatConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/chat").setAllowedOriginPatterns("*").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
}
}
위의 코드에서는 "/ws/chat" 경로로 접속하면 WebSocket 연결이 가능하도록 설정하였다.
또한 cors 설정도 해주었고.. SockJS 를 사용해서 웹소켓을 지원하지 않는 브라우저도 지원하게 설정해주었다.
그리고 configureMessageBroker 가 클라이언트와 서버와의 메시지 전달을 담당 할 것이다.
registry.enableSimpleBroker("/topic"): SimpleBroker를 사용하여 클라이언트가 "/topic"으로 시작하는 주제(토픽)를 구독하고 메시지를 수신할 수 있도록 합니다."/topic"은 모든 구독자에게 보내지는 브로드캐스트 메시지를 의미합니다.
registry.setApplicationDestinationPrefixes("/app"):클라이언트에서 메시지를 "/app"으로 시작하는 경로로 보낼 경우, 해당 메시지는 컨트롤러에서 처리될 수 있습니다.
MessageController
package com.example.chattest.controller;
import com.example.chattest.dto.ChatMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class MessageController {
private final SimpMessageSendingOperations sendingOperations;
@MessageMapping("/chat/message")
public void enter(ChatMessage message) {
if (ChatMessage.MessageType.ENTER.equals(message.getType())) {
message.setMessage(message.getSender()+"님이 입장하였습니다.");
}
sendingOperations.convertAndSend("/topic/chat/room/"+message.getRoomId(),message);
}
@MessageMapping("/chat/addRoom")
public void addRoom(ChatMessage message) {
sendingOperations.convertAndSend("/topic/chat/addRoom", message);
}
}
메시지를 처리하는 부분은 이렇게 설정이 되어있다. 오픈채팅과 실시간 채팅의 flow를 보자면..
방만들기
package com.example.chattest.dto;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
@Data
@NoArgsConstructor
public class ChatRoom {
private String roomId;
private String roomName;
public static ChatRoom create(String name) {
ChatRoom room = new ChatRoom();
room.roomId = UUID.randomUUID().toString();
room.roomName = name;
return room;
}
}
대화방 입장
roomId 를 받아서 채팅방으로 접속.
접속과 동시에 socket 연결..
socket connection error 시 reconnect code 구현
socket 연결 후 /app/chat/message 으로 type:ENTER 타입으로 채팅방에 들어왔다는 알림을 전송 하고 현재 roomId 를 구독
MessageController 의 @MessageMapping("/chat/message") 에서 이를 응답받고 현재 roomId 를 구독한 클라이언트에게 메시지 전송
구독한 클라이언트는 해당 메시지를 받아서 후처리 후 화면에 뿌려줌..
대화 시 실시간 알림도 사실 같다. 원래 새로운 엔트리를 파서 하면 좋지만 시간관계상 모든 룸을 구독하고 그 룸에서 일어나는 모든 대화가 일어날시 count를 늘려주는게 끝이다..
sendMessage: function() {
ws.send("/app/chat/message", {}, JSON.stringify({type:'TALK', roomId:this.roomId, sender:this.sender, message:this.message}));
this.message = '';
this.currentTime = this.getCurrentFormattedTime();
},
recvMessage: function(recv) {
this.messages.push({"type":recv.type,"sender":recv.type=='ENTER'?'[알림]':recv.sender,"message":recv.message})
this.currentTime = this.getCurrentFormattedTime();
}
function connect() {
ws.connect({}, function(frame) {
ws.subscribe("/topic/chat/room/"+vm.$data.roomId, function(message) {
var recv = JSON.parse(message.body);
vm.recvMessage(recv);
});
ws.send("/app/chat/message", {}, JSON.stringify({type:'ENTER', roomId:vm.$data.roomId, sender:vm.$data.sender}));
}, function(error) {
if(reconnect++ <= 5) {
setTimeout(function() {
console.log("connection reconnect");
sock = new SockJS("/ws/chat");
ws = Stomp.over(sock);
connect();
},10*1000);
}
});
}
메일 인증 서비스는 간단히 구현 하였다.
build.gradle 은 위에서 참고..
구현 전.. smtp 설정 해줘야한다.,
a. 네이버 메일서버 연결을 위한 설정
네이버 메일서버를 외부에서 연결하기 위해서는 POP3/SMTP 설정을 필수로 진행해야한다! 아래와 같이 설정하면 된다
a-1. 네이버 메일 하단 환경설정
MailConfig
@Configuration
public class MailConfig {
@Bean
public JavaMailSender javaMailService() {
JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();
javaMailSender.setHost("smtp.naver.com"); // SMTP 서버 주소..
javaMailSender.setUsername("naver 아이디");
javaMailSender.setPassword("naver 비밀번호");
javaMailSender.setPort(465);
javaMailSender.setJavaMailProperties(getMailProperties());
return javaMailSender;
}
private Properties getMailProperties() {
Properties properties = new Properties();
properties.setProperty("mail.transport.protocol", "smtp"); // 프로토콜 설정
properties.setProperty("mail.smtp.auth", "true"); // smtp 인증
properties.setProperty("mail.smtp.starttls.enable", "true"); // smtp strattles 사용
properties.setProperty("mail.debug", "true"); // 디버그 사용
properties.setProperty("mail.smtp.ssl.trust","smtp.naver.com"); // ssl 인증 서버는 smtp.naver.com
properties.setProperty("mail.smtp.ssl.enable","true"); // ssl 사용
return properties;
}
}
MailApi
@Controller
@RequestMapping("/api/v1/mail")
@RequiredArgsConstructor
public class MailServiceApi {
// 이메일 인증
private final MailService mailService;
@PostMapping("{email}")
@ResponseBody
public String mailConfirm(@PathVariable String email) throws Exception{
return mailService.sendSimpleMessage(email);
}
}
MailService
@Service
@Slf4j
@RequiredArgsConstructor
public class MailService {
// 메일 내용 작성
private final JavaMailSender emailSender;
// 사용자가 메일로 받을 인증번호
private String ePw;
// 메일 내용 작성
public MimeMessage creatMessage(String to) throws MessagingException, UnsupportedEncodingException {
//System.out.println("메일받을 사용자" + to);
//System.out.println("인증번호" + ePw);
MimeMessage message = emailSender.createMimeMessage();
message.addRecipients(MimeMessage.RecipientType.TO, to); // 메일 받을 사용자
message.setSubject("회원가입을 위한 이메일 인증코드 입니다"); // 이메일 제목
StringBuilder msgg = new StringBuilder();
msgg.append("<div align='center' style='border:1px solid black'>");
msgg.append("<h3 style='color:blue'>회원가입 인증코드 입니다</h3>");
msgg.append("<div style='font-size:130%'>");
msgg.append("<strong>" + ePw + "</strong></div><br/>") ; // 메일에 인증번호 ePw 넣기
msgg.append("</div>");
message.setText(msgg.toString(), "utf-8", "html"); // 메일 내용, charset타입, subtype
// 보내는 사람의 이메일 주소, 보내는 사람 이름
message.setFrom(new InternetAddress("pajang1514@naver.com", "Admin"));
return message;
}
// 랜덤 인증코드 생성
public String createKey() {
int leftLimit = 48; // numeral '0'
int rightLimit = 122; // letter 'z'
int targetStringLength = 10;
Random random = new Random();
String key = random.ints(leftLimit, rightLimit + 1)
.filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97))
.limit(targetStringLength)
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
.toString();
//System.out.println("생성된 랜덤 인증코드"+ key);
return key;
}
// 메일 발송
// sendSimpleMessage 의 매개변수 to는 이메일 주소가 되고,
// MimeMessage 객체 안에 내가 전송할 메일의 내용을 담는다
// bean으로 등록해둔 javaMail 객체를 사용하여 이메일을 발송한다
public String sendSimpleMessage(String to) throws Exception {
ePw = createKey(); // 랜덤 인증코드 생성
//System.out.println("********생성된 랜덤 인증코드******** => " + ePw);
MimeMessage message = null;
message = creatMessage(to);
//System.out.println("********생성된 메시지******** => " + message);
try { // 예외처리
//System.out.printf(String.valueOf(message));
emailSender.send(message);
} catch (Exception e) {
log.error(e.getMessage());
throw new IllegalArgumentException();
}
return ePw; // 메일로 사용자에게 보낸 인증코드를 서버로 반환 인증코드 일치여부를 확인하기 위함
}
}
이번 프로젝트에서는 Spring Scheduler 와 Quartz 중 간단히 Spring Scheduler로 스케줄러의 개념만 파악할 것 이다.
Spring-boot 자체적으로 지원하는 Scheduler로 @EnableScheduling, @Scheduled만으로 간단하게 구현 가능하다. 간단한 Job Scheduling을 할 때 사용하면 좋다.
Scheduler란?
: 일정한 시간 간격 또는 일정한 시각에 특정 로직을 돌리기 위해서 사용하는 것을 scheduler라고 함.
Dependency
: Spring Boot starter에 기본적으로 의존 org.springframework.scheduling
Main 클래스에 Annotation 달아줌.
@SpringBootApplication
@EnableScheduling
public class ChatTestApplication {
public static void main(String[] args) {
SpringApplication.run(ChatTestApplication.class, args);
}
}
MessageController
// 5초마다 실행
@Scheduled(fixedDelay = 5000)
// 초, 분, 시, 일, 월, 요일
//@Scheduled(cron = "0 0 7 * * *")
public void sendPeriodicMessage() {
ChatMessage message = new ChatMessage();
message.setType(ChatMessage.MessageType.TALK);
message.setRoomId("your_room_id");
message.setSender("System");
message.setMessage("주기적으로 보내는 메시지입니다.");
sendingOperations.convertAndSend("/topic/chat/room/system", message);
}
fixedDelay: 이전 실행이 끝난 시점부터 고정된 시간(ms)만큼 지난 후 메소드를 실행합니다. 즉, 이전 작업이 끝난 시점부터 일정 시간이 지나면 다음 작업을 실행합니다.
fixedRate: 이전 실행과 상관없이 일정한 주기(ms)로 메소드를 실행합니다. 이전 작업이 끝나지 않았더라도 일정 주기마다 메소드를 실행합니다.
initialDelay: 애플리케이션 시작 후, 해당 시간(ms)만큼 대기한 후 최초 한 번만 메소드를 실행합니다.
이런식으로 각종 로직에 사용가능하고 사용방도는 무궁무진하다.
Spring Batch 를 이용하여 간단하게 대화내용을 백업하는 코드를 작성해보자.
buildscript {
}
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.14'
id 'io.spring.dependency-management' version '1.1.0'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
//batch
implementation 'org.springframework.boot:spring-boot-starter-batch'
testImplementation 'org.springframework.batch:spring-batch-test'
//websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:sockjs-client:1.1.2'
implementation 'org.webjars:stomp-websocket:2.3.3-1'
//view
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-freemarker'
implementation 'org.springframework.boot:spring-boot-devtools'
implementation 'org.webjars.bower:bootstrap:4.3.1'
implementation 'org.webjars.bower:vue:2.5.16'
implementation 'org.webjars.bower:axios:0.17.1'
implementation 'com.google.code.gson:gson:2.8.0'
// mail..
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '2.7.14'
}
tasks.named('test') {
useJUnitPlatform()
}
이번엔 의존성 때문에 spring boot 버전을 3.1.1 에서 2.7.14로 낮추게 되었다.
3.1.1 에서 해당 모듈을 찾을 수 없다는 부분을 찾다가 일단 테스트라도 해보자 하고 버전을 내렸다.
@Configuration
@EnableScheduling
@EnableBatchProcessing
@RequiredArgsConstructor
public class ChatBackupBatchConfig {
private final JobBuilderFactory jobBuilderFactory;
private final StepBuilderFactory stepBuilderFactory;
private final DataSource dataSource;
private final ChatMessageBackRepository chatMessageBackRepository;
private final JobLauncher jobLauncher; // JobLauncher 주입
@Scheduled(fixedDelay = 30000)
public void runChatBackupJob() throws Exception {
Job job = jobBuilderFactory.get("chatBackupJob")
.start(chatBackupStep())
.build();
JobParameters jobParameters = new JobParametersBuilder()
.addLong("time", System.currentTimeMillis())
.toJobParameters();
jobLauncher.run(job, jobParameters); // JobLauncher로 Job 실행
}
@Bean
public Step chatBackupStep() {
return stepBuilderFactory.get("chatBackupStep")
.<ChatMessage, ChatMessageBack>chunk(10) // Chunk의 타입을 ChatMessage에서 ChatMessageBack으로 변경
.reader(chatMessageReader())
.processor(chatMessageProcessor())
.writer(chatMessageWriter())
.build();
}
@Bean
@StepScope
public JdbcCursorItemReader<ChatMessage> chatMessageReader() {
JdbcCursorItemReader<ChatMessage> itemReader = new JdbcCursorItemReader<>();
itemReader.setDataSource(dataSource);
itemReader.setSql("SELECT * FROM chat_message"); // 백업하려는 방의 roomId를 지정
itemReader.setRowMapper((resultSet, i) -> {
ChatMessage chatMessage = new ChatMessage();
chatMessage.setId(resultSet.getLong("id"));
chatMessage.setSender(resultSet.getString("sender"));
chatMessage.setMessage(resultSet.getString("message"));
chatMessage.setType(resultSet.getString("type"));
chatMessage.setRoomId(resultSet.getString("room_id"));
return chatMessage;
});
return itemReader;
}
@Bean
public ItemProcessor<ChatMessage, ChatMessageBack> chatMessageProcessor() {
return chatMessage -> {
// ChatMessage를 ChatMessageBack으로 변환하는 로직을 여기에 작성
ChatMessageBack chatMessageBack = new ChatMessageBack();
chatMessageBack.setSender(chatMessage.getSender());
chatMessageBack.setMessage(chatMessage.getMessage());
chatMessageBack.setType(chatMessage.getType());
chatMessageBack.setRoomId(chatMessage.getRoomId());
return chatMessageBack;
};
}
@Bean
public ItemWriter<ChatMessageBack> chatMessageWriter() {
return chatMessageBackRepository::saveAll;
}
}
@Scheduled(fixedDelay = 30000): 스케줄링을 사용하여 30초마다 runChatBackupJob() 메서드가 자동으로 실행됩니다.
jobBuilderFactory.get("chatBackupJob"): "chatBackupJob"이라는 이름으로 Batch Job을 생성하기 위한 빌더를 가져옵니다.
chatBackupStep(): chatBackupStep() 메서드를 시작으로 하는 Step을 정의한 Batch Job을 생성합니다.
jobLauncher.run(job, jobParameters): 생성한 Job과 JobParameters를 사용하여 Job을 실행합니다.
stepBuilderFactory.get("chatBackupStep"): "chatBackupStep"이라는 이름으로 Step을 생성하기 위한 빌더를 가져옵니다.
.chunk(10): Chunk 단위로 데이터를 처리하도록 설정합니다. 여기서는 10개의 ChatMessage를 한 번에 처리합니다. 그렇다고 DB에서 10개의 행을 꺼내와서 10개씩 처리하는건 아니다.
예를 들어, 만약 데이터베이스에 100개의 ChatMessage가 있다고 가정하겠습니다. chunk(10)으로 설정하면 Spring Batch는 데이터베이스에서 10개씩 데이터를 읽어온 후에 이 10개의 아이템을 한 번에 처리합니다. 이후 다시 10개씩 데이터를 읽어와서 처리하는 과정을 총 10번 반복하게 됩니다.
.reader(chatMessageReader()): chatMessageReader() 메서드에서 구현한 JdbcCursorItemReader를 사용하여 데이터를 읽어옵니다.
.processor(chatMessageProcessor()): chatMessageProcessor() 메서드에서 구현한 ItemProcessor를 사용하여 데이터를 가공합니다.
.writer(chatMessageWriter()): chatMessageWriter() 메서드에서 구현한 ItemWriter를 사용하여 데이터를 저장합니다.
@StepScope: Step의 실행 범위에서만 이 Bean이 유효함을 나타냅니다.
itemReader.setDataSource(dataSource): 데이터베이스 연결 정보를 설정합니다.
itemReader.setSql("SELECT * FROM chat_message"): 해당 SQL 쿼리로 데이터베이스에서 데이터를 조회합니다.
itemReader.setRowMapper(...): 데이터베이스에서 읽어온 결과를 ChatMessage 엔티티로 매핑하는 RowMapper를 설정합니다.
ItemProcessor<ChatMessage, ChatMessageBack>: ChatMessage를 ChatMessageBack으로 변환하는 ItemProcessor를 정의합니다.
chatMessage -> { ... }: 람다 표현식을 사용하여 chatMessage를 ChatMessageBack으로 변환하는 로직을 작성합니다.
ItemWriter: ChatMessageBack을 저장하는 ItemWriter를 정의합니다.
chatMessageBackRepository::saveAll: ChatMessageBackRepository의 saveAll 메서드를 사용하여 ChatMessageBack을 저장합니다. Spring Batch가 Chunk 단위로 자동으로 저장합니다.
이제 ChatMessage를 조회하여 ChatMessageBack으로 변환하고, ChatMessageBack을 데이터베이스에 저장하는 Batch Job이 구성되었습니다. 이렇게 구성한 Batch Job은 runChatBackupJob() 메서드를 실행함으로써 주기적으로 실행될 수 있습니다.
BATCH_JOB_INSTANCE: 배치 작업의 인스턴스 정보를 저장하는 테이블로, 하나의 Job에 대한 실행 인스턴스를 나타냅니다.
BATCH_JOB_EXECUTION: 배치 작업 실행 정보를 저장하는 테이블로, 하나의 Job의 실행 정보를 나타냅니다.
BATCH_JOB_EXECUTION_PARAMS: 배치 작업 실행에 사용되는 파라미터 정보를 저장하는 테이블로, Job 실행 시 전달된 파라미터들을 보관합니다.
BATCH_STEP_EXECUTION: 배치 단계 실행 정보를 저장하는 테이블로, 하나의 Step의 실행 정보를 나타냅니다.
BATCH_STEP_EXECUTION_CONTEXT: 배치 단계 실행 컨텍스트 정보를 저장하는 테이블로, Step 실행 중에 사용된 컨텍스트 정보를 보관합니다.
BATCH_JOB_EXECUTION_SEQ: 배치 작업 실행 ID를 생성하는 시퀀스를 관리하는 테이블입니다.
BATCH_STEP_EXECUTION_SEQ: 배치 단계 실행 ID를 생성하는 시퀀스를 관리하는 테이블입니다.