๋ฐฐ์น ๋์ ์๋ฃ ์์ ๋ง๋ค ํ๋ฉด์ update๋ฅผ ํด์ค์ ์ฌ์ฉ์๊ฐ ์ต์ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ๋ก ํ์ธํ ์ ์๋๋ก ์๋น์ค๋ฅผ ์ ๊ณตํ๊ธฐ์ํด
์ ์ ํ ๊ธฐ์ ๊ฒํ ์ค์ SSE ์ถ์ฒ์ ๋ฐ์ ๋ถ์ ํ ๊ฐ๋ฐ์ ์งํํ๊ฒ ๋์๋ค.
๊ด๋ จ ๋ด์ฉ์ ์ ๋ฆฌ
SSE๋, Server Sent Event
์น ๋ธ๋ผ์ฐ์ ์ ์๋ฒ ๊ฐ ๋จ๋ฐฉํฅ ํต์ ์ ๊ฐ๋ฅํ๊ฒ ํ๋ ์น ๊ธฐ์
์๋ฒ์์ ํด๋ผ์ด์ธํธ(๋ธ๋ผ์ฐ์ )๋ก ์ค์๊ฐ ์ด๋ฒคํธ๋ฅผ ๋ณด๋ผ ์ ์๋ค.
ํด๋ผ์ด์ธํธ ์ธก์์ ์๋ฒ๋ก์ ์ฐ๊ฒฐ์ ์ด์ด๋๊ณ ์๋ฒ์์ ์ด๋ฒคํธ๋ฅผ ํธ์ฌํ๋ ๋ฐฉ์
์๋ฒ๋ ์ด๋ฒคํธ๋ฅผ ์์ฑํ๊ณ , ์ด๋ฒคํธ์ ํ์ํ Data๋ฅผ ํฌํจํ์ฌ Client์๊ฒ ์ ๋ฌํ๋ค.
Client๋ ์ด๋ฒคํธ ์์ ํ ํ์ํ ์์ ์ ์ํํ๋๋ฐ ๋ฐ์ดํฐ๋ฅผ ์ฌ์ฉ
SSE ํน์ง
- ๋จ๋ฐฉํฅ ํต์ ์๋ฒ -> ํด๋ผ์ด์ธํธ๋ก๋ง Data ์ ์ก
- ๊ธฐ๋ณธ HTTP ํ๋กํ ์ฝ ์ฌ์ฉ -> ์ถ๊ฐ Library๋ ํ๋ ์์ํฌ ์์ด๋ ์ฝ๊ฒ ๊ตฌํ์ด ๊ฐ๋ฅํ๋ค.
- ์ค์๊ฐ์ผ๋ก ์๋ฒ์์ ๋ฐ์ดํฐ๋ฅผ ์์ ํ์ฌ ๋์ ์ผ๋ก ์ ๋ฐ์ดํธ๊ฐ ๊ฐ๋ฅํจ
- client๋ same sse emitter๋ฅผ ๊ณต์ ํ ์ ์๋ค. ๊ตฌ๋ ๋น ํ๋์ emitter๋ฅผ ๋ง๋ค์ด์ผํ๋ค. (๋์ผ ๋ธ๋ผ์ฐ์ ๋ ๋ค๋ฅธ user๋ ์ด๋์๋ ๋ ๋ฆฝ์ ์ผ๋ก ์ ์ง๊ฐ ๋จ)
- ๋ธ๋ผ์ฐ์ ์ต๋ ๋์์ ์์๊ฐ http1.1์ ๊ฒฝ์ฐ 6๊ฐ, http2์ ๊ฒฝ์ฐ 100๊ฐ ๊น์ง ๊ฐ๋ฅํ๋ค.
- ํด๋ผ์ด์ธํธ์์ emitter๋ฅผ closeํด๋ ์๋ฒ์์ ๊ฐ์งํ๊ธฐ ์ด๋ ต๋ค. (๋จ๋ฐฉํฅ ๋จ์ )
Sample ๊ตฌํ
- ์ง์ง Sample Level์์ ์กฐ๊ธ ์๋ด
- API, Client, Batch(ํน์ ์์ ์๋ฃ ํ broadcast ํธ์ถ์ ์ํจ)
- ๊ธฐ๋ณธ ํต์ฌ ๊ตฌ์ฑ Subscribe, Broadcast ์ ๋๋ง
API
๊ตฌ์ฑ
- subscribe(api)
- ๊ตฌ๋ ์์ฒญ -> emitter ์์ฑ -> client์ ์์ฑ์ด ์๋ฃ๋์๋ค๋ ์ด๊ธฐ ๋ฐ์ดํฐ ์๋ต -> ์๋ฃ
- broadcast(mq)
- ๋จ์ผ ์๋ฒ๋ผ๋ฉด API์ ๋๊ณ ์์ฑํด๋ ์๊ด์ด ์์ง๋ง ์ด์คํ ๋ ์๋ฒ์ด๊ธฐ ๋๋ฌธ์ ์ด๋์ ์์ฑ์ ํ๋ ์ด์๊ฐ ์๋๋ก MQ๋ฅผ ์ ์ฉ
- ํน์ ์์ ์๋ฃ ์ดํ broadcast ์์ฒญ -> ๋งคํ ์ด๋ฒคํธ ์ฐพ์ ์๋ต -> ์๋ฃ
์ฝ๋ ์ํ
1. ๊ตฌ๋ API
//Controller
@GetMapping(value = {"/api/sse/subscribe/{orgId}/{menuName}"}, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribeSse(
@PathVariable String orgId,
@PathVariable String menuName) {
return sseService.subscribe(orgId, menuName);
}
//Service
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
private static final long SSE_TIMEOUT_LIMIT_MINUTE = 3;
private final RabbitTemplate rabbitTemplate;
public SseEmitter subscribe(String orgId, String menuName) {
SseEmitter emitter = new SseEmitter(TimeUnit.MINUTES.toMillis(SSE_TIMEOUT_LIMIT_MINUTE));
String eventId = getEventIdPrefix(cmpnId, menuName) + "_" + Utils.getUuid();
emitters.put(eventId, emitter);
emitter.onCompletion(() -> emitters.remove(eventId));
emitter.onTimeout(emitter::complete);
emitter.onError(error -> {
log.error("[Sse Error] eventId : {}, cause: {}", eventId, error.getMessage());
emitter.complete();
});
sendEventToClient(emitter, eventId, menuName, SseExecutionStatus.EVENT_CREATED.getValue());
return emitter;
}
private String getEventIdPrefix(String orgId, String menuName) {
StringJoiner eventIdBuilder = new StringJoiner("_");
eventIdBuilder.add(orgId)
.add(menuName);
return eventIdBuilder.toString();
}
private void sendEventToClient(SseEmitter emitter, String eventId, String menuName, String data) {
try {
emitter.send(
SseEmitter.event()
.id(eventId)
.name(menuName)
.data(data));
} catch (Exception e) {
emitter.completeWithError(e);
}
}
2. ์ด๋ฒคํธ Broadcasting API
// Service (mq ์ฌ์ฉํ์ฌ ๋ฐ๋ก API ์์ด consumer ๋ถ๋ถ)
// getEventIdPrefix, sendEventToClient๋ ์์ ๋์ผ
public void broadcastToMappedEvent(SseBroadcastModel message) {
String idPrefix = getEventIdPrefix(message.getCmpnId(), message.getMenuName());
emitters.entrySet().stream()
.filter(entry -> entry.getKey().startsWith(idPrefix))
.forEach(entry -> {
SseEmitter emitter = entry.getValue();
String eventId = entry.getKey();
sendEventToClient(emitter, eventId, message.getMenuName(), eventId + "_" + message.getSubId()+ "_" + message.getStatus());
});
}
Client
๊ตฌ์ฑ
- sse ์์ฑ ํ์ ๋ฉ๋ด ์ง์ ์ event ์์ฑ
- vue store์์ ์ด๋ฒคํธ ์์ฑ ๋ฐ ์ ์ฅ
- api์์ broadcast๋ฅผ ํตํด response data๊ฐ ์ ๋ฐ์ดํธ ๋๋ฉด, ๊ฐ ํ๋ฉด์์ ํ์ ์์ ์ํ
์ฝ๋ ์ํ
1. ํ๋ฉด
...
created: function() {
...
this.initSse();
},
computed: {
...mapGetters({
....
sseEventSource: 'sse/sseEventSource',
sseResponseData: 'sse/sseResponseData',
isSseEventSourceClosed: 'sse/isSseEventSourceClosed',
lastEventId: 'sse/lastEventId'
}),
},
watch: {
...
sseResponseData(newVal) {
if (newVal.indexOf('_complete') !== -1) {
this.getDataList();
}
},
isSseEventSourceClosed: function() {
this.getDataList();
}
}
methods: {
...,
initSse() {
let payload = {
orgId : this.orgId,
menuName : 'sampleMenu'
}
if (!this.sseEventSource || (this.orgId !== this.getOrgIdInLastEventId(this.lastEventId))) {
this.$store.dispatch('sse/completeSseEventSource');
this.$store.dispatch('sse/createSseEventSource', payload);
}
},
getOrgIdInLastEventId(lastEventId) {
return lastEventId.substring(0, lastEventId.indexOf('_'));
}
}
2. Store
//actions.js
const createSseEventSource = (context, payload) => {
let url = `${UrlsConfig.govUrl}/api/sse/subscribe/${payload.orgId}/${payload.menuName}`;
let eventSource = new EventSource(url);
eventSource.addEventListener(payload.menuName,event => {
context.commit('setLastEventId', event.lastEventId);
context.commit('setSseResponseData', event.data+'_'+event.timeStamp);
})
eventSource.onerror = (event) => {
if (event.target.readyState === EventSource.CLOSED) {
context.commit('setIsSseEventSourceClosed', true);
console.error('SSE CREATE ERROR : ' + '(' + event + ')');
}
}
context.commit('setSseEventSource', eventSource);
}
const completeSseEventSource = (context) => {
if (!context.state.sseEventSource) {
return;
}
context.state.sseEventSource.close();
context.commit('setSseEventSource', null);
let url = `/api/sse/unsubscribe/${context.state.lastEventId}`;
try {
axios.delete(url);
} catch (err) {
console.error("SSE COMPLETE ERROR : ", err);
} finally {
context.commit('setLastEventId', '');
}
}
//getters.js
const sseEventSource = (state) => state.sseEventSource;
const sseResponseData = (state) => state.sseResponseData;
const isSseEventSourceClosed = (state) => state.isSseEventSourceClosed;
const lastEventId = (state) => state.lastEventId;
//index.js
const state = {
sseEventSource: null,
lastEventId: '',
sseResponseData: null,
isSseEventSourceClosed: false
}
//mutations.js
const setSseEventSource = (state, sseEventSource) => {
state.sseEventSource = sseEventSource;
};
const setSseResponseData = (state, sseResponseData) => {
state.sseResponseData = sseResponseData;
};
const setIsSseEventSourceClosed = (state, isClosed) => {
state.isSseEventSourceClosed = isClosed;
}
const setLastEventId = (state, lastEventId) => {
state.lastEventId = lastEventId;
}
Batch
์ญํ
- ํน์ ์์ ์๋ฃ ์ ๊ฐ ํ๋ฉด์์ ํ์ํ ์์ ์ ์ํํ๊ธฐ ์ํด Broadcast๋ก ๋ฉ์์ง ์ ๋ฌ(Trigger)
- ์ค์ Broadcast ์์ฒด๋ ๋ฐฐ์น๊ฐ ์๋ API์์ ์ด๋ฃจ์ด์ง (๋ฐฐ์น๋ ํธ๋ฆฌ๊ฑฐ๋ง + ์ด์คํ ์ํด MQ ์ฌ์ฉ)
public void sseBroadcast(String orgId, SseExecutionStatus status) {
SseBroadcastModel sseBroadcastModel = SseBroadcastModel.builder()
.cmpnId(cmpnId)
.menuName(menuName)
.status(status.getValue())
.build();
rabbitTemplate.convertAndSend(RabbitMqConfig.EXCHANGE_SSE_BROADCAST, "", sseBroadcastModel);
}
'๊ฐ๋ฐ์ํ > ์ด๊ฒ์ ๊ฒ' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
SSE ์ ์ฉ (2) - ์ด์คํ๋ฅผ ์ ์ฉํด๋ณด์ (0) | 2024.08.19 |
---|---|
H2 ๋ฐ์ดํฐ ๋ฒ ์ด์ค ์ค์น ๋ฐ ์ด๊ธฐ ์ค์ ๋ฐฉ๋ฒ (0) | 2024.04.15 |
ํ๋ก์ ํธ Tree ๊ตฌ์กฐ ์ถ๊ฐ (0) | 2023.10.15 |
221209 @JsonProperty (1) | 2022.12.09 |
OOM์ ์์ธ๊ณผ ์์ฃผ ๊ฐ๋จํ๊ฒ OOM ๋ฐ์ ์ํค๊ธฐ (0) | 2022.04.16 |