- ๐ Goal: Spring Webflux์ MongoDB๋ฅผ ํ์ฉํด ์๋ฐฉํฅ์ผ๋ก ์ํตํ ์ ์๋ ์ฑํ ์๋ฒ๋ฅผ ๊ตฌํํ๋ค
1. ํ๊ฒฝ์ค์
- MongoDB
- ์ธ๋ถ์์๋ DB์ ์ ๊ทผํ ์ ์๋๋ก cloud cluster๋ฅผ ํ๋ ๊ตฌ์ฑํ๊ณ , Database์ collection์ ์์ฑํ๋ค. RDBMS์์์ Schema์ Table์ด๋ผ๊ณ ์๊ฐํ๋ฉด ๋๋ค.
- MongoDB์์ connection string์ ์ ๊ณตํ๋ฏ๋ก ๋ณต์ฌํด๋๊ณ , ์ถํ yml ํ์ผ ์ค์ ์์ ์ฌ์ฉํ๋ค.
mongodb+srv://<username>:<password>@<cluster-address>/<database-name>?retryWrites=true&w=majority
- Spring Boot
- Reactive-web๊ณผ MongoDB๋ฅผ ์ฌ์ฉํ ์ ์๋๋ก Gradle์ ์ถ๊ฐํด์ฃผ์๋ค.
- Reactive web์์๋ default ์๋ฒ๋ก netty๋ฅผ ์ฌ์ฉํ๋ค. ๋ฐ๋ผ์ ์ถ๊ฐ์ ์ผ๋ก ์ค์ ํ ๋ถ๋ถ์ ์๋ค.
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive' implementation 'org.springframework.boot:spring-boot-starter-webflux' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.projectreactor:reactor-test'
- applications.yml ํ์ผ์ connection string์ ์ ์ฅํด๋๊ณ spring boot์์ mongoDB๋ก ๋ฐ๋ก ์ ๊ทผํ ์ ์๊ฒ ํ๋ค.
spring: data: mongodb: uri: <connection string>
- ์ด๋ ๊ฒ ๊ตฌ์ฑํด๋ ์ค์ ์ ๋ณด๋ฅผ ํตํด ์ถํ์ spring boot์์ mongoTemplate๋ฅผ ์ฌ์ฉํ์ฌ ๋ฐ์ดํฐ๋ฅผ CRUDํ ์ ์๊ฒ ๋๋ค.
- Reactive-web๊ณผ MongoDB๋ฅผ ์ฌ์ฉํ ์ ์๋๋ก Gradle์ ์ถ๊ฐํด์ฃผ์๋ค.
2. Netty๋?
- ์ฐ์ Spring 5.X framework์์ ์ฌ์ฉํ๋ ๋๊ฐ์ง์ web stack์ ๋ํด์ ์์๋ณด์.
- Spring Framework5๋ Servlet Stack, Reactive Stack์ด๋ผ๋ ๋ ๊ฐ์ง ์น ์คํ์ ์ ๊ณตํ๋ค.
- ํ๋๋ ๋๋ถ๋ถ์ Java ์ํฐํ๋ผ์ด์ฆ ์ ํ๋ฆฌ์ผ์ด์ ์ด ์ฌ์ฉํ๋ ์ฐจ๋จ I/O๊ฐ ์๋ ๊ณ ์ ์ ์ธ 'Servlet Stack' ์ด๋ค. Servlet Stack์ Spring MVC ๋ฐ Spring Data๋ก ๊ตฌ์ฑ๋์ด ์์ผ๋ฉฐ, Tomcat, Jetty, Servlet Container์์ ๋์ํ๋ค.
- ๋ค๋ฅธ ํ๋๋ ์ด๋ฒคํธ ๋ฃจํ, ๋น์ฐจ๋จ ์คํ ๋ชจ๋ธ์ ๊ธฐ๋ฐ์ผ๋ก ํ์ฌ ๋ ์ ์ ํ๋์จ์ด ๋ฆฌ์์ค๋ก ๋์ ๋์์ฑ์ ์ฒ๋ฆฌํ ์ ์๋ 'Reactive Stack' ์ผ๋ก, Reactive Stack์ Spring WebFlux์ Spring Data ๋ฐ์ํ ์ ์ฅ์(Reactive Repositories)๋ฅผ ํ์ฉํ๋ค. Reactive Stack์ Tomcat, Jetty ์ธ์๋ Servlet 3.1+ Container์ Netty, Undertow ๊ฐ์ ๋ ผ๋ธ๋กํน ์๋ฒ ์์์ ๊ตฌ๋๋๋ค.
- ๊ธฐ๋ณธ์ ์ผ๋ก Servlet ๊ธฐ๋ฐ์ ์คํ๋ง์ ์ฌ์ฉ์๊ฐ request๋ฅผ ํ ๋๋ง๋ค ์ค๋ ๋๊ฐ ๋ง๋ค์ด์ง๋ค.
- ๋ฐ๋ผ์ ์ฌ๋ฌ ์ฌ์ฉ์๊ฐ ๋์์ ์์ฒญ์ ํ๊ฒ ๋๋ฉด ์ค๋ ๋๊ฐ ๊ณ์ ์์ฑ๋๋ฉด์ ๊ฐ์๊ฐ ๋์ด๋๊ฒ ๋๊ณ , ์๋ฒ์ ๊ณผ๋ถํ๊ฐ ๊ฑธ๋ฆฌ๊ฒ ๋๋ค.
- ๋ฐ๋ผ์ Thread ๊ธฐ๋ฐ ์๋ฒ๋ค์ ์๋ฒ ํ๋๊ฐ ์๊ฐ์ ์ชผ๊ฐ์(time slicing) ์๋ค๊ฐ๋ค ํ๋ฉฐ(context switching) ์ฌ๋ฌ๊ฐ์ง ์ผ์ ํ๊ฒ ๋๋๋ฐ, ์ด๊ฐ ์์ฃผ ๋ฐ์ํ๋ฉด ์น ์๋ฒ๊ฐ ๋๋ ค์ง๋ ๊ฒฝํฅ์ด ์๋ค.
- ํ์ง๋ง Netty๋ ๋น๋๊ธฐ ์๋ฒ์ด๋ฏ๋ก, ์๋นํ ๋น ๋ฅด๋ค.
- ๋ง์ฝ ํ ์ฌ์ฉ์๊ฐ ์๋ฒ์ ์์ฒญ์ ํ๊ณ , ์ด ์๋ฒ๊ฐ DB๋ก ์ฟผ๋ฆฌ๋ฅผ ๋ ๋ ค ์๋ต์ด ๋์์ค๊ธฐ๊น์ง 3์ด์ ๋๊ฐ ๊ฑธ๋ฆฐ๋ค๊ณ ๊ฐ์ ํ์๋, ๋ค๋ฅธ ์ฌ์ฉ์์ ์๋ก์ด ์์ฒญ์ด ๋ค์ด์จ๋ค๋ฉด A์ ์๋ต์ ๊ธฐ๋ค๋ฆฌ๋ ๋์ B์ ์์ฒญ์ ์ฒ๋ฆฌํด์ค๋ค.
- ์ด๋ ๊ฒ ๊ธฐ๋ค๋ฆฌ๋ ์๊ฐ๋์ ํ ์ ์๋ ์ผ์ ์ฐพ์์ ๋น๋๊ธฐ์ ์ผ๋ก task๋ฅผ ์ฒ๋ฆฌํ๊ฒ ๋๋ค. ์ฆ ๋น๋๊ธฐ ์๋ฒ๋ ์ฑ๊ธ ์ค๋ ๋ ๋ฐฉ์์ ์ฌ์ฉํ๋ ๋์ idleํ ์๊ฐ์ด ์๊ณ , ๋งค์ฐ ๋นจ๋ผ์ง๊ฒ ๋๋ค.
- ํ์ง๋ง ์ด ์๋ฒ๋ฅผ ์ฌ์ฉํ๋ ค๋ฉด ์ญ์ ๊ฐ์ ๋ฐฉ์์ผ๋ก ๋์ํ๋ ๋น๋๊ธฐ DB๋ฅผ ์ฌ์ฉํด์ผ ํ๋ค.
3. Tailable - Repository & Model ๊ตฌํ
@Data
@Document(collection="chat-app")
public class Chat {
@Id
private String id;
private String msg;
private String sender;
private String receiver;
private LocalDateTime createdAt;
}
- Chat model์ ๊ตฌํํ๋ค. MongoDB์๋ document ํ์์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ์ฝ์ ํ๋ค.
public interface ChatRepository extends ReactiveMongoRepository {
@Tailable
@Query("{sender:?0,receiver:?1}") //ํด๋น ์ฟผ๋ฆฌ๊ฐ ๋์ํ๊ฒ ๋จ.
Flux<Chat> mFindBySender(String sender, String receiver);
//Flux- ๋ฐ์ดํฐ์ ํ๋ฆ,๋๊ธฐ์ง ์๊ณ ๋ฐ์ดํฐ๋ฅผ ์ง์์ ์ผ๋ก ๋ฐ๊ฒ ๋ค๋ ์๋ฏธ
@Tailable
@Query("{ roomNum: ?0 }") //๋ฐฉ ๋จ์๋ก ์กฐํํ๋ค
Flux<Chat> mFindByRoomNum(Integer roomNum);
}
- client๊ฐ controller๋ฅผ ํตํด์ sender๊ฐ ๋์ธ ๋ฐ์ดํฐ๋ฅผ ์ฐพ๋ Tailable Query๋ฅผ ์์ฒญํ์๋, ๋ค๋ฅธ ํด๋ผ์ด์ธํธ๊ฐ sender๊ฐ ๋์ธ ๋ฐ์ดํฐ๋ฅผ DB์ ์๋ก ์ฝ์ ํ๋ค๊ณ ๊ฐ์ ํ์.
- ์ฌ๊ธฐ์ ์ปค์๋ฅผ ๋ซ์ง ์๊ณ ๊ณ์ ์ ์งํ๊ณ ์ต์ DB ๋ด์ฉ์ ๋ฐํํ๊ฒ ๋๋ @Tailable ์ด๋ ธํ ์ด์ ์ ์ถ๊ฐํ๋ฉด Flux๋ฅผ ํตํด์ ๋ฐ์ดํฐ๊ฐ ๊ณ์ ํ๋ฌ๋ค์ด์ค๊ฒ ๋๋ฏ๋ก ์๋ก ์ถ๊ฐ๋ ๋ฐ์ดํฐ๊น์ง ๋ฐํ์ด ๋๋ค.
4. SSE Protocol- Controller ๊ตฌํ
- SSE ํ๋กํ ์ฝ์ Server-Sent Events์ ์ฝ์ด๋ก, 1ํ์ฑ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ์ฃผ๊ณ ๋ฐ์ ๋ค ์ฐ๊ฒฐ์ ์ข ๋ฃํ๋ ์ผ๋ฐ์ ์ธ HTTP์๋ ๋ค๋ฅด๊ฒ ์ต์ด ์ฐ๊ฒฐ ์ดํ์ ์๋ฒ์ ๋ฐ์ดํฐ๋ฅผ ์ง์์ ์ผ๋ก, ์ค์๊ฐ์ผ๋ก streamingํ ์ ์๊ฒ ๋๋ค. (์๋ฒ๊ฐ ์ผ๋ฐฉ์ ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ์ก์ ํ๋ ๋จ๋ฐฉํฅ ํต์ ) SSE๋ ๊ธฐ์กด HTTP ์๋ฒ์์ HTTP API ๋ง์ผ๋ก ๋์๋๋ฉฐ, ๊ตฌํ๋ Websocket๋ณด๋ค ๊ฐ๋จํ๋ค. ๋ํ Topic์ ์ ํ๊ณ ๋ฐ์ดํฐ๋ฅผ ๊ตฌ๋ ํ ์ ์๋ค.
- ์ฃผ๋ก Server์์ ์๋์ ํธ์ํ๊ฑฐ๋, SNS๋ฑ์์ ์๋ก์ด ํผ๋๋ ๊ฒ์๋ฌผ์ ๋ฐ์์ฌ ๋ ์ฌ์ฉํ๋ค.
+) Websocket vs Server-Side-Event
- SSE๋ ๊ธฐ์กด์ ์น ์๋ฒ ํต์ ๋ฐฉ์์ ๊ทธ๋๋ก ์ฌ์ฉํ๊ธฐ ๋๋ฌธ์ ๊ตฌํํ๊ธฐ ์ํด ๋ฐ๋ก ์ค์ ํ ๊ฒ์ด ์๋ค. ๋ฐ๋ผ์ ์๋ฒ์ชฝ์์๋ SSE๋ฅผ ์ฌ์ฉํ๊ฒ ๋ค๋ ํค๋๋ง์ ์ค์ ํ๋ฉด ๋๋ฉฐ, ์ด๋ MediaType์ text_event_stream_value๋ก ์ง์ ํด์ฃผ๋ฉด ๋๋ค.
- Controller๋ Restfulํ๊ฒ ๊ตฌํํ์๋ค.
@RequiredArgsConstructor
@RestController //๋ฐ์ดํฐ ๋ฆฌํด ์๋ฒ
public class ChatController {
private final ChatRepository chatRepository;
@CrossOrigin
@GetMapping(value="/sender/{sender}/receiver/{receiver}",produces= MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Chat> getMsg(@PathVariable String sender,
@PathVariable String receiver){
return chatRepository.mFindBySender(sender,receiver)
.subscribeOn(Schedulers.boundedElastic());
}
@PostMapping("/chat")
public Mono<Chat> setMsg(@RequestBody Chat chat){
chat.setCreatedAt(LocalDateTime.now());
return chatRepository.save(chat);
}
@CrossOrigin //๋จ์ฒด ์ฑํ
๋ฐฉ ๊ตฌํ
@GetMapping(value = "/app/chats/chatrooms/{roomNum}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Chat> findByRoomNum(@PathVariable Integer roomNum) {
return chatRepository.mFindByRoomNum(roomNum)
.subscribeOn(Schedulers.boundedElastic());
}
}
- /chat: ์ฑํ ์ ์ ์กํ๋ฉด ์ด๋ฅผ ์๋์ผ๋ก MongoDB์ ์ฝ์ ํ๋ค.
- /sender/{name}/receiver/{name}: ํด๋น url๋ก ์ ๊ทผํ๋ฉด path variable์ sender/receiver์ ๋ฐ๋ผ์ ์ก์์ ์๊ฐ ๋ช ํํ ๊ตฌ๋ถ๋ ๋ฉ์ธ์ง๋ฅผ ์กฐํํ ์ ์๊ณ , ๋ํ ์๋ฒ๋ ๋ฉ์ถ์ง ์๊ณ ๊ณ์ ์๋ก ์ฝ์ ๋๋ ๋ฐ์ดํฐ๋ฅผ ๋ฐ์์ค๊ฒ ๋๋ค.
- /chat/roomNum/{roomNum} : ํด๋น url๋ก ์ ๊ทผํ๋ฉด ์ฑํ
๋ฐฉ ์ ์ฒด๋ฅผ ์์ ์๋ก ํ์ฌ ์ฑํ
์ด ์ ์ฅ๋๋ฉฐ, ๋๊ฐ ๋ณด๋๋์ง๋ง ์ ํํ ๋ช
์ํ๋ฉด ๋๋ค. ์ฃผ๋ก ๋จ์ฒด ์ฑํ
๋ฐฉ์ ๊ตฌํํ ๋ ์ฌ์ฉ๋๋ค.
5. ์ฑํ ๋ฐฉ ๋ทฐ ๊ตฌํ
- Api ๊ตฌํ ๋ง์ผ๋ก๋ ์ฑํ ์ด ์ ๋๋ก ์ด๋ฃจ์ด์ง๊ณ ์๋์ง ํ์ธ์ด ์ด๋ ค์ ๊ธฐ ๋๋ฌธ์ ๋ฐฑ์๋ ํํธ์์ ์ฑํ ๋ฐฉ ๋ทฐ๊น์ง ๊ตฌํํ๊ณ , api๋ฅผ ์ฐ๋ํ๋ฉด์ ํ ์คํธ๋ฅผ ์งํํ์๋ค.
- ์ฑํ ๋ฐฉ index, ๋์ index, ์ด๋ฆ, ์๋๋ฐฉ ์ด๋ฆ ์ url parameter์ ๋ด์ requestํ๋ฉด ๋ฐ์ดํฐ๋ฅผ ์ฝ์ด์ค๋ api๋ฅผ ์ ์ฉํ์ฌ ์ด์ ์ฑํ ๋ด์ญ์ ์กฐํํ๊ณ , ๋์ ์๋๋ฐฉ์ ๋ถ๋ณํ๋ฉฐ, ๋๊ฐ ์ด๋ค ์ฑํ ๋ฐฉ์ ์ฑํ ์ ๋ณด๋๋์ง ๋ช ์ํ์ฌ DB์ ์ฑํ ์ ์ ์ฅํ๋ค.
- ๋ํ ์๋ ์ ์ก์ฐฝ์์ ์ฑํ ์ ๋ณด๋ด๋ฉด DB์ ์ฑํ ์ด ์ ์ฅ๋๋๋ฐ, ์ด๋ ์ฑํ ๋ฐฉ์ ์กฐํํ๋ api(Server)์ ์๋กญ๊ฒ ๋ฐ์ดํฐ๊ฐ ํ๋ฌ๋ค์ด์ event๊ฐ ๋ฐ์ํ๋ฉด ์ฑํ ์ฐฝ์ ์ด๊ธฐํํ์ฌ ์๋กญ๊ฒ ๋ ๋๋งํด ์๋ก์ด ์ฑํ ๊น์ง ๋ทฐ์ ์ถ๊ฐํ์ฌ ์ค์๊ฐ์ผ๋ก ์ฑํ ์ ํ์ธํ ์ ์๋๋ก ํ์๋ค.
const searchParams=new URLSearchParams(location.search);
let userIdx=searchParams.get('user-idx') //์ ์ ์ธ๋ฑ์ค
let userName=searchParams.get('user-name'); //ํ์ฌ ์ ์ ์ด๋ฆ
let roomNum=searchParams.get('room-num') // ์ฑํ
๋ฐฉ ๋ฒํธ
let otherName=searchParams.get('other-name')// ์๋๋ฐฉ ์ด๋ฆ
console.log(userIdx)
document.querySelector("#username").innerHTML = otherName;
//SSE ์ฐ๊ฒฐ - DB์ ์๋ก์ด ์ฑํ
๋ด์ญ์ด ๋ค์ด์ฌ๋๋ง๋ค event ๋ฐ์ (์ด๊ธฐํ)
let eventSite= "http://{aws ip ์ฃผ์}" //spring application์ด ๋์๊ฐ๋ ์ฃผ์ ex) "http://localhost:9001"
const eventSource= new EventSource(`${eventSite}/chatrooms/${roomNum}`);
// ๊ณ์ ๋ฐ์ดํฐ๊ฐ ํ๋ฌ๋ค์ด์ค๋ฏ๋ก postman์์ ํ
์คํธ X, ๊ทธ๋ฅ url ๊ทธ๋๋ก ์ ์
eventSource.onmessage=(event)=>{
const data=JSON.parse(event.data);
//์๋๋ฐฉ์ผ๋ก๋ถํฐ ๋ฉ์ธ์ง๊ฐ ์ ์ก๋๋ ์ด๋ฒคํธ๊ฐ ๋ฐ์ํ๋ฉด DB์ ์ ์ฅ๋ ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์จ๋ค.
if(data.sender_idx==userIdx){ //์ ์ก์๊ฐ ๋ด๊ฐ ์๋๋ฉด ๋ค๋ฅธ์ฌ๋์ด๋ฏ๋ก ๋ฐ๋์ชฝ์ ๋ ๋๋งํ๋ฉด ๋จ
//ํ๋๋ฐ์ค (๋ด๊ฐ ๋ณด๋ธ ๋ฉ์ธ์ง)
initMyMessage(data);
}
else{
//ํ์๋ฐ์ค (์๋๋ฐฉ์ด ๋ณด๋ธ ๋ฉ์ธ์ง)
initYourMessage(data);
}
}
function getSendMsgBox(data) {
let md = data.createdAt.substring(5, 10)
let tm = data.createdAt.substring(11, 16)
convertTime = tm + " | " + md
//๋ด๊ฐ ์
๋ ฅํ ์ฑํ
๋ฐ์ค ์์ฑํ๊ธฐ
return `
<div class="sent_msg">
<p>${data.msg}</p>
<span class="time_date"> ${convertTime} / <b>${data.sender_name}</b> </span>
</div>
`;
}
function getReceivedMsgBox(data) {
let md = data.createdAt.substring(5, 10)
let tm = data.createdAt.substring(11, 16)
convertTime = tm + " | " + md
//์๋ํธ์์ ๋ณด๋ธ ์ฑํ
๋ฐ์ค ์์ฑํ๊ธฐ
return `
<div class="received_withd_msg">
<p>${data.msg}</p>
<span class="time_date"> ${convertTime} / <b>${data.sender_name}</b></span>
</div>
`;
}
//์ต์ด ์ฑํ
๋ฐฉ ๋ก๋์ ์ด์ DB ์ ์ฅ ๋ด์ญ ๋ถ๋ฌ์ค๋ฉฐ ์ด๊ธฐํ
function initMyMessage(data){
//๋ด๊ฐ ๋ณด๋ธ ๋ฉ์ธ์ง ์ด๊ธฐํ
let chatBox= document.querySelector("#chat-box");
let chatOutGoingBox =document.createElement("div");
chatOutGoingBox.className="outgoing_msg";
chatOutGoingBox.innerHTML=getSendMsgBox(data);
chatBox.append(chatOutGoingBox);
document.documentElement.scrollTop = document.body.scrollHeight;
}
function initYourMessage(data){
//์๋ ์ชฝ์ผ๋ก๋ถํฐ ๋ฉ์ธ์ง๊ฐ ์ ์ก๋๋ฉด DB๋ก๋ถํฐ ๋ถ๋ฌ์ด
let chatBox= document.querySelector("#chat-box");
let chatReceivedBox =document.createElement("div");
chatReceivedBox.className="received_msg";
chatReceivedBox.innerHTML=getReceivedMsgBox(data);
chatBox.append(chatReceivedBox);
document.documentElement.scrollTop = document.body.scrollHeight;
}
//AJAX๋ก ์ฑํ
๋ฉ์ธ์ง ์ ์ก
async function newChat(){
//DB์ insertํ๋ฉด ์๋์ผ๋ก event๊ฐ ๋ฐ์ํ๋ฉด์ chatroom์ ๋ค๋ฅธ ์๋๋ฐฉ์๊ฒ ๋ณด๋ด์ง (๋ค์ค ์ฑํ
๋ ๊ฐ๋ฅ)
let msgInput=document.querySelector("#chat-outgoing-msg");
let date=new Date();
let now= date.getHours()+":"+date.getMinutes()+" | "+date.getMonth()+"- "+date.getDate();
let chat={
sender_idx:userIdx,
sender_name:userName,
room_num:roomNum,
msg: msgInput.value
};
fetch(`${eventSite}`,{
method:"post",//http post ๋ฉ์๋ (์๋ก์ด ๋ฐ์ดํฐ๋ฅผ writeํ ๋ ์ฌ์ฉ)
body:JSON.stringify(chat),
headers:{
"Content-Type":"application/json; charset=utf-8"
}
});
msgInput.value="";
}
//์ฑํ
์ ์ก ๋ฒํผ ๋๋ฅผ ์
document.querySelector("#chat-send").addEventListener("click",()=>{
newChat();
})
//enter ๋๋ฅผ ์
document.querySelector("#chat-outgoing-msg").addEventListener("keydown",(e)=>{
if(e.keyCode==13){
newChat();
}
})
- ์ ์ฒด ์ฝ๋๋ ํ๋จ ๊นํ๋ธ ์ฃผ์ ์ฐธ๊ณ
https://github.com/ashlovesliitea/silverlining-chatapp
๐ ์ฐธ๊ณ ์๋ฃ