- 내가 부른 노래를 sns형태로 공유 할 수 있는 웹 노래방 서비스
- 실시간으로 사람들 앞에서 노래를 부르고, 피드에 공유할수 있습니다.
2024.01.04 ~ 2024.02.16
조현우 |
고정원 |
노성은 |
송준석 |
연정흠 |
이준범 |
OpenVidu | 피드 및 사용자 상호작용 | 노래 데이터 | DM |
알림기능(SSE)
인프라
|
- IDE :
IntelliJ IDEA
,Vidsual Studeo Code
- BE :
Java 11
Spring Boot 2.7.18
Redis 7.2.4
MariaDB 11.2.2
Gradle 6.8
- FE :
Node 20.11.0
Vue 3.4.15
Quasar 2.14.3
- ETC :
openvidu 2.29.0
ELK 7.17.0
Docker 25.0.1
jenkins 2.442
rabbitMQ 3.12.12
- webRTC와 활용한 실시간 화상 노래방 환경 제공
- 2가지 노래방 모드
- 일반 노래방
- MR과 가사 제공
- 녹화
- 퍼펙트 스코어
- 사용자의 음성 데이터 분석
- 분석된 데이터로 음정 평가
- 일반 노래방
- 노래방 부가 기능
- 인원제한, 비밀번호 설정
- 추방
- 채팅
- 입력 변경
- 종료 후 피드 작성
- 실시간 알림
- 녹화된 영상을 sns 피드 형태로 공유
- DM
- 방생성
- 초대
- 실시간 채팅
front
├─assets
│ ├─font
│ ├─icon
│ └─img
├─boot
├─components
│ ├─chat
│ └─karaoke
│ ├─list
│ ├─session
│ ├─song
│ └─video
├─css
├─js
│ ├─chat
│ ├─comment
│ ├─config
│ ├─encrypt
│ ├─feed
│ ├─friends
│ ├─hit
│ ├─karaoke
│ ├─like
│ ├─perfectScore
│ ├─song
│ └─user
├─layouts
├─pages
├─router
└─stores
server
├─api
├─audit
├─auth
│ ├─controller
│ ├─model
│ │ ├─dto
│ │ └─entity
│ ├─repository
│ ├─service
│ └─util
├─chat
│ ├─controller
│ ├─model
│ ├─repository
│ └─service
├─comment
│ ├─controller
│ ├─error
│ ├─model
│ ├─repository
│ └─service
├─common
│ ├─error
│ ├─filter
│ └─util
├─config
├─feed
│ ├─controller
│ ├─error
│ ├─model
│ ├─rank
│ │ ├─document
│ │ └─service
│ ├─repository
│ └─service
├─friends
│ ├─controller
│ ├─model
│ │ └─dto
│ ├─repository
│ └─service
├─hit
│ ├─controller
│ ├─document
│ ├─error
│ ├─model
│ ├─repository
│ └─service
├─karaoke
│ ├─controller
│ ├─model
│ ├─repository
│ └─service
├─like
│ ├─controller
│ ├─document
│ ├─error
│ ├─model
│ ├─repository
│ └─service
├─notification
│ ├─cotnoller
│ ├─dto
│ ├─entity
│ ├─repository
│ ├─service
│ └─util
├─point
│ ├─controller
│ ├─model
│ │ ├─dto
│ │ └─entity
│ ├─repository
│ └─service
├─song
│ ├─controller
│ ├─model
│ │ └─entity
│ ├─repository
│ └─service
└─user
├─controller
├─document
├─error
├─model
├─repository
├─secure
├─service
└─util
- DOCKER 설치 후, cmd 창에서
docker run -p 4443:4443 --rm -e O
2684
PENVIDU_SECRET=MY_SECRET -e OPENVIDU_RECORDING=true -e OPENVIDU_RECORDING_PATH=/opt/openvidu/recordings -v /var/run/docker.sock:/var/run/docker.sock -v /opt/openvidu/recordings:/opt/openvidu/recordings openvidu/openvidu-dev:2.29.0
cd S10P12A705/front
npm install
npm run serve
- 이후 localhost:8080으로 접속해서 확인
- localhost:4443에서 connectionTest 가능
- 필터 추가
sudo apt-get update
sudo apt-get install docker.io
sudo systemctl start docker
sudo systemctl enable docker
sudo apt-get install docker-compose
sudo apt-get install gedit
$ mkdir [프로젝트 폴더]
$ cd [프로젝트 폴더]
$ gedit docker-compose.yml
// docker-compose.yml
version: '3.6'
services:
Elasticsearch:
image: elasticsearch:7.17.16
container_name: elasticsearch
restart: always
volumes:
- elastic_data:/usr/share/elasticsearch/data/
environment:
ES_JAVA_OPTS: "-Xmx256m -Xms256m"
discovery.type: single-node
ports:
- '9200:9200'
- '9300:9300'
networks:
- elk
Logstash:
image: logstash:7.17.16
container_name: logstash
restart: always
volumes:
- ./logstash/:/logstash_dir
command: logstash -f /logstash_dir/logstash.conf
depends_on:
- Elasticsearch
ports:
- '9600:9600'
environment:
LS_JAVA_OPTS: "-Xmx256m -Xms256m"
networks:
- elk
Kibana:
image: kibana:7.17.16
container_name: kibana
restart: always
ports:
- '5601:5601'
environment:
- ELASTICSEARCH_URL=http://elasticsearch:9200
depends_on:
- Elasticsearch
networks:
- elk
volumes:
elastic_data: {}
networks:
elk:
// end of docker-compose.yml
$ mkdir logstash
$ cd logstash
$ gedit logstash.conf
// mysql 설정
# mysql connector java jar 다운로드
wget https://repo1.maven.org/maven2/mysql/mysql-connector-java/8.0.30/mysql-connector-java-8.0.30.jar
# 압축 해제
tar -xzf mysql-connector-java-8.0.30.tar.gz
# Logstash 설치 디렉토리로 이동
cd [logstash_dir]
# 다운로드 받은 JAR 파일을 해당 디렉토리로 이동
mv /path/to/mysql-connector-java-8.0.30.jar /path/to/logstash/
// logstash.conf
# synchronization with elasticseasrch & mysql
input {
jdbc {
jdbc_connection_string => "jdbc:mysql://i10a705.p.ssafy.io:3306/testuser"
jdbc_user => "root"
jdbc_password => "1234"
jdbc_driver_library => "/logstash_dir/mysql-connector-java-8.0.30.jar"
jdbc_driver_class => "com.mysql.cj.jdbc.Driver"
statement => "SELECT * FROM likes WHERE timestamp > :sql_last_value"
schedule => "*/10 * * * * *"
use_column_value => true
tracking_column => "timestamp"
tracking_column_type => "timestamp"
clean_run => false
type => "like"
}
jdbc {
jdbc_connection_string => "jdbc:mysql://i10a705.p.ssafy.io:3306/testuser"
jdbc_user => "root"
jdbc_password => "1234"
jdbc_driver_library => "/logstash_dir/mysql-connector-java-8.0.30.jar"
jdbc_driver_class => "com.mysql.cj.jdbc.Driver"
statement => "SELECT * FROM hit WHERE timestamp > :sql_last_value"
schedule => "*/10 * * * * *"
use_column_value => true
tracking_column => "timestamp"
tracking_column_type => "timestamp"
clean_run => false
type => "hit"
}
}
filter {
# mutate {
# remove_field => ["introduction", "profile_img_url", "role", "user_key"]
# }
}
output {
if [type] == "like" {
elasticsearch {
hosts => ["elasticsearch:9200"]
index => "likes"
}
}
if [type] == "hit" {
elasticsearch {
hosts => ["elasticsearch:9200"]
index => "hit"
}
}
stdout { codec => rubydebug }
}
filter {
}
output {
if [type] == "like" {
elasticsearch {
hosts => ["localhost:9200"]
index => "like" # index를 like로 데이터 동기화
}
}
if [type] == "view" {
elasticsearch {
hosts => ["localhost:9200"]
index => "hit" # index를 hit로 데이터 동기화
}
}
}
// end of logstash-config
$ docker-compose up
localhost:5601로 접속하면 확인 가능
회원가입 시 Elasticsearch에서 사용하는 UserDocument 타입 데이터를 저장합니다.
UserDocument는 userPk(int), nickname(String)을 갖고 있어 ElasticsearchRepository를 통해 유저 닉네임 기반 검색을 가능하게 합니다.
또한 유사도 있는 검색 결과도 보여줄 수 있도록 Elasticsearch Query의 fuzziness의 유연성을 설정하였습니다.
// UserServiceImpl.java
@Override
public List<UserDocument> searchUsersByNickname(String nickname) {
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(fuzzyQuery("nickname", nickname).fuzziness(Fuzziness.TWO))
.build();
SearchHits<UserDocument> searchHits = elasticsearchRestTemplate.search(searchQuery, UserDocument.class);
return searchHits.stream().map(SearchHit::getContent).collect(Collectors.toList());
}
인기 피드 랭킹은 Elasticsearch에 동기화된 데이터를 스케줄링을 통해 일정 주기로 새롭게 추가된 데이터를 대상으로 계산됩니다.
각 피드 별 점수에 영향을 미치는 요인은 좋아요 개수와 조회수가 있으며 각각은 5:3의 가중치를 갖고 계산됩니다.
업데이트된 피드 랭킹은 상위 100개의 피드가 내림차순으로 정렬됩니다. 이 결과물은 조회가 자주 일어날 것이 예상되므로 메모리 변수 형태로 유지 관리합니다.
@Scheduled(cron = "0 */15 * * * *")
public void calculateRank() {
String likesIndexName = "likes";
String hitIndexName = "hits";
List<SearchHit> likesData = fetchDataFromElasticsearch(likesIndexName);
List<SearchHit> hitsData = fetchDataFromElasticsearch(hitIndexName);
// 각 게시글 아이디 별로 점수 계산
calculateScores(likesData, hitsData);
// 상위 100개 게시글만 선택
updateTop100Ranking();
}
private void updateTop100Ranking() {
try {
top100Ranking = elasticsearchRestTemplate.search(
new NativeSearchQueryBuilder()
.withPageable(PageRequest.of(0, 100, Sort.by(Sort.Order.desc("score"))))
.build(), FeedStatsDocument.class)
.stream()
.map(searchHit -> (FeedStatsDocument) searchHit.getContent())
.collect(Collectors.toList());
} catch (...) {
...
}
}
MySQL DB와 Elasticsearch Document는 서로 동기화되어 일관성을 유지해야 합니다. 이를 만족시키기 위해 logstash를 활용하였습니다.
크론식 표현을 통해 일정 주기로 동기화 작업이 이뤄지도록 구성하였습니다.
좋아요, 조회수 정보는 각 DB SELECT 문을 통해 fetch 시 timestamp를 기준으로 새로운 데이터만 가져오도록 작성하였습니다.
이를 각각의 Elasticsearch Document에 해당하는 index로 데이터를 전달하도록 설정하였습니다.
input {
jdbc {
jdbc_connection_string => "jdbc:mysql://i10a705.p.ssafy.io:3306/karaoke"
jdbc_user => "root"
jdbc_password => "1234"
jdbc_driver_library => "/logstash_dir/mysql-connector-java-8.0.30.jar"
jdbc_driver_class => "com.mysql.cj.jdbc.Driver"
statement => "SELECT * FROM likes WHERE timestamp > :sql_last_value"
schedule => "*/10 * * * * *"
use_column_value => true
tracking_column => "timestamp"
tracking_column_type => "timestamp"
clean_run => false
type => "like"
}
jdbc {
...
}
}
output {
if [type] == "like" {
elasticsearch {
hosts => ["elasticsearch:9200"]
index => "likes"
}
}
if [type] == "hit" {
...
}
stdout { codec => rubydebug }
}
output {
if [type] == "like" {
elasticsearch {
hosts => ["localhost:9200"]
index => "like" # index를 like로 데이터 동기화
}
}
if [type] == "view" {
...
}
}
@RestControllerAdvice annotation을 통해 예외에 따라 모든 결과물이 동일한 로직을 거쳐 전달되도록 구현하였습니다.
@ExceptionHandler(ApiException.class) annotation을 통해 ApiException 클래스의 예외는 제네릭 타입의 일관된 리턴 타입 ResponseEntity<ApiResponse<?>>을 갖도록 구현하였습니다.
@RestControllerAdvice
public class ApiExceptionAdvice {
@ExceptionHandler(ApiException.class)
@ResponseStatus
public ResponseEntity<ApiResponse<?>> handleApiException(HttpServletRequest request, ApiException e) {
return ResponseEntity
.status(e.getStatus())
.body(ApiResponse.builder()
.status(String.valueOf(e.getStatus()))
.message(e.getCode())
.data(null)
.build());
}
}
각 도메인 별 에러 타입을 Enum Type을 통해 일관성을 갖춰 구현하였습니다. 테스트 단계에서 요청 실패에 대해서 어떤 예외가 발생했는지 빠르게 파악하고 관리할 수 있도록 하였습니다.
public enum CommentExceptionEnum implements ExceptionEnum {
COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "C00001", "댓글을 찾을 수 없습니다"),
COMMENT_CREATION_FAILED(HttpStatus.BAD_REQUEST, "C00002", "댓글을 생성할 수 없습니다"),
COMMENT_UPDATE_FAILED(HttpStatus.BAD_REQUEST, "C00003", "댓글을 업데이트할 수 없습니다"),
...
}
public enum LikeExceptionEnum implements ExceptionEnum {
LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "L00001", "좋아요를 찾을 수 없습니다"),
LIKE_CREATION_FAILED(HttpStatus.BAD_REQUEST, "L00002", "좋아요를 생성할 수 없습니다"),
LIKE_UPDATE_FAILED(HttpStatus.BAD_REQUEST, "L00003", "좋아요를 업데이트할 수 없습니다"),
...
}
RSA 2048 bit + bcrypt hash를 기반으로 제작하였습니다.
서버는 유저가 로그인 및 회원가입을 필요로 하는 페이지에 접속 시 유저 ip에 따라 RSA 비대칭키 쌍을 생성한 뒤, 이를 관리합니다.
jsbn, prng4, rng, rsa js 파일을 es6 형태에 맞게 포팅하였으며 이를 통해 서버 응답으로 넘어온 modulus, exponent public key를 기반으로 비밀번호 암호화를 수행합니다.
공개키로 암호화된 정보는 서버 측의 비밀키로 복호화한 원문 패스워드와 SALTING 기능이 내장된 bcrypt 해싱 결과를 DB로부터 가져와 비교합니다.
이를 통해 사용자 비밀번호 원문을 저장하지 않고 로그인 인증 성공/실패 판단이 가능합니다.
// 암호화 과정 (클라이언트)
import * as RSA from "./rsa.js";
export let rsa = new RSA.RSAKey();
...
{
pw: rsa.encrypt(pw),
}
// 복호화 과정 (서버)
String privateKey = RSA_2048.keyToString(keyManager.getPrivateKey(ip));
String password = RSA_2048.decrypt(rawPassword, privateKey);
// 비밀번호를 해싱하여 저장
String hashedPassword = passwordEncoder.encode(password);
// 생성된 해시된 비밀번호를 사용하여 사용자 엔터티를 생성
UserAuth userAuth = new UserAuth(id, hashedPassword);
비대칭키 쌍은 서버 메모리 변수로 관리됩니다. RSA Key Manager는 싱글톤 패턴으로 관리되며, 내부에는 각 클라이언트의 마지막 조회 시각을 의미하는 lastRequest와 클라이언트의 ip를 key 값으로 rsa key pair가 저장된 hash map이 존재합니다.
이는 스케줄러를 통해 일정 주기마다 마지막 키 조회 요청으로부터 10분이 지난 키는 삭제하여 메모리 낭비를 줄일 수 있도록 작성하였습니다.
public class RSAKeyManager { // 클라이언트 ip 를 키로 관리하는 비대칭키 쌍 & 마지막 요청 시각
private static HashMap<String, KeyPair> keyMap;
private static HashMap<String, Long> lastRequest;
private static RSAKeyManager instance = null;
...
}
@Component
public class RSAKeyManagerCleanupTask {
private final int period = 600000;
@Scheduled(fixedRate = period)
public void cleanupUnusedKeys() { // 사용하지 않는 비대칭 키 쌍을 삭제합니다
...
}
}
좋아요, 조회수가 급증하는 게시글 피드에 대해 바로 DB write 요청이 일어난다면 많은 부하가 일어날 수 있습니다.
이를 해결하기 위해 Redis cache를 사용하여 DB write가 각 요청에 대해 매번 일어나는 것을 방지하였습니다.
사용자 요청에 대해 우선적으로는 Redis cache에 저장하였으며, 이를 일정 주기로 비동기 DB 동기화를 통해 해결하고자 했습니다.
또한 게시글에 대한 통계 정보를 관리하는 테이블을 생성하여 각 게시글의 좋아요, 조회수 정보를 빠르게 확인할 수 있도록 구현하였습니다.
@Async
public CompletableFuture<Void> saveToMySQLAsync() {
HashOperations<String, Object, Object> hashOperations = redisTemplate.opsForHash();
long count = hashOperations.scan(LIKE_HASH_KEY, ScanOptions.scanOptions().match("*").build()).stream().count();
hashOperations.scan(LIKE_HASH_KEY, ScanOptions.scanOptions().match("*").build())
.forEachRemaining(entry -> {
Like like = (Like) entry.getValue();
// 조건에 따라 redis 데이터를 mysql 테이블 동기화
...
);
hashOperations.delete(LIKE_HASH_KEY, entry.getKey());
return;
}
Like elem = list.get(0);
if (like.isStatus() == elem.isStatus()) {
// 상태가 같은 경우 아무 것도 수행하지 않는다
...
} else {
// 다른 경우 집계 테이블 동기화
...
}
// 저장 후 해당 데이터를 해시에서 삭제
hashOperations.delete(LIKE_HASH_KEY, entry.getKey());
});
// 비동기 작업 완료
return CompletableFuture.completedFuture(null);
}
SQL을 백엔드 서버에서 생성하여 DB 요청하기 보다는 pre-compiled procedure를 통해 DB 작업 성능 개선을 이끌어 내며 한 번의 수많은 댓글을 로드하지 않고 페이지네이션을 통해 효율을 추구하였습니다.
CREATE DEFINER=`root`@`%` PROCEDURE `GetCommentsByFeedIdWithPagination`(
IN feedIdParam INT,
IN startIndexParam INT,
IN pageSizeParam INT
)
BEGIN
SELECT *
FROM comment
WHERE feed_id = feedIdParam
AND LOWER(is_deleted) NOT LIKE 'o%'
ORDER BY comment_id
LIMIT startIndexParam, pageSizeParam;
END
user key가 외부에 노출되면 해당 id의 유저를 특정할 수 있어 보안에 좋지 않은 방식이라 생각했습니다.
그럼에도 불구하고 Auto Increment 속성을 통해 생성 시각 별로 정렬되어 있으면서도, 데이터 조회에 있어서 빠른 int 형태의 user key를 쓰는 것이 성능면에서 좋다고 생각했습니다.
두가지 측면 모두 놓치지 않기 위해 두가지 방법 모두 사용하였으며 이를 위해 int uesr key와 uuid user key 사이의 변환을 담당하는 매핑 테이블을 추가하여
메소드를 통해 uuid를 int 타입으로 바꿀 수 있습니다.
int userPk = userService.getUserPk(UUID.fromString(uuid));
클라이언트에게 노출되지 않고 백엔드 서버 내부에서 사용되는 user key의 경우 int 형태의 데이터를 사용하도록 구현하였습니다.
클라이언트에게 노출되는 정보의 경우 int user key를 uuid user key로 변환하여 응답하도록 작성하였습니다.
이는 비단 유저 클래스 뿐 아니라 피드, 댓글, 좋아요 등에 담겨 있는 user key 정보도 마찬가지로 int 타입이 아닌 uuid를 반환하도록 적용되었습니다.
// 초기 객체 생성
OV = new OpenVidu();
session = OV.initSession();
// Session에 이벤트 설정
session.on('streamCreated', ({ stream }) => {
const subscriber = session.subscribe(stream, undefined, {
subscribeToAudio: true,
subscribeToVideo: true,
10000
span>
});
subscribers.push(subscriber);
});
const token = await axios.post(
APPLICATION_SERVER_URL + '/karaoke/sessions/getToken',
{
sessionName: sessionName,
// filter 사용을 위한 설정들
type: 'WEBRTC',
role: 'PUBLISHER',
kurentoOptions: {
allowedFilters: ['GStreamerFilter', 'FaceOverlayFilter'],
},
},
{
headers: {
Authorization: getCookie('Authorization'),
refreshToken: getCookie('refreshToken'),
'Content-Type': 'application/json',
},
}
);
session.connect(token.data, { clientData: userName }).then(() => {
// 원하는 속성으로 초기화된 발행자를 만듭니다.
let publisher_tmp = OV.initPublisher(undefined, {
audioSource: undefined, // 오디오의 소스. 정의되지 않으면 기본 마이크
videoSource: undefined, // 비디오의 소스. 정의되지 않으면 기본 웹캠
publishAudio: true, // 마이크 음소거 여부를 시작할지 여부
publishVideo: true, // 비디오 활성화 여부를 시작할지 여부
resolution: '320x240',
frameRate: 30, // 비디오의 프레임 속도
insertMode: 'APPEND', // 비디오가 대상 요소 'video-container'에 어떻게 삽입되는지
mirror: true, // 로컬 비디오를 반전할지 여부
});
publisher = publisher_tmp;
// 원격 스트림을 수신하기위해 subscribeToRemote() 호출
publisher.subscribeToRemote();
session.publish(publisher);
});
@RequestMapping(value = "/createSession", method = RequestMethod.POST)
public ResponseEntity<String> createSession(@RequestBody Map<String, Object> params) {
String sessionName = (String) params.get("sessionName");
// SessionProperties를 설정해준다
SessionProperties sessionProperties = new SessionProperties.Builder().customSessionId(sessionName).build();
Session session = openViduModel.getOpenvidu().createSession(sessionProperties);
return ResponseEntity.ok(sessionName);
}
@RequestMapping(value = "/getToken", method = RequestMethod.POST)
public ResponseEntity<String> getToken(@RequestBody Map<String, Object> params) {
String sessionName = (String) params.remove("sessionName");
// ConnectionProperties를 설정해준다
ConnectionProperties connectionProperties = ConnectionProperties.fromJson(params).build();
Connection connection = openViduModel.getMapSessions().get(sessionName).createConnection(connectionProperties);
String token = connection.getToken();
String connectionId = connection.getConnectionId();
return ResponseEntity.ok(token);
}
로그인 시 Http 1.1 통신을 통해 SSE를 위한 연결을 설정합니다.
알림의 경우 단방향통신이기 때문에 SSE연결이 최적의 선택이 될 수 있었습니다.
// NotifcationController.java
//요청보낸 유저의 구독(연결) 요청
@GetMapping(value = "/subscribe")
public ResponseEntity<SseEmitter> subscribe(HttpServletRequest request) throws IOException {
Integer userPk = ((User)SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getUserPk();
SseEmitter emitter = new SseEmitter(60 * 60 * 1000L); //새로운 연결 객체 생성. 매개변수로 만료시간 줄 수 있다. 1시간.
sseEmitters.add(userPk, emitter); //객체 메모리에 저장.
...
return ResponseEntity.ok(emitter);
}
//SseEmitter.java
package com.ssafy.server.notification.util;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@Component
@Slf4j
public class SseEmitters {
private static final AtomicLong counter = new AtomicLong();
private final Map<Integer, SseEmitter> emitters = new ConcurrentHashMap<>(); //thread-safe한 자료구조.
public SseEmitter add(Integer userPk, SseEmitter emitter) {
this.emitters.put(userPk, emitter);
log.info("new emitter added: {}", emitter);
log.info("emitter list size: {}", emitters.size());
log.info("emitter list: {}", emitters);
emitter.onCompletion(() -> {
log.info("onCompletion callback");
this.emitters.remove(userPk, emitter);
});
emitter.onTimeout(() -> {
log.info("onTimeout callback");
emitter.complete();
});
return emitter;
}
public SseEmitter getSseEmitter(Integer userPk){
return emitters.get(userPk);
}
public void remove(Integer userPk, SseEmitter emitter){
emitters.remove(userPk, emitter);
}
}
기본적으로 EventSource 객체를 이용해 SSE 연결 요청을 보낼수 있지만, 헤더에 인증 토큰을 추가하기위해 EventSourcePolyfill 객체를 사용했습니다.
//notificatinoStore.js
state: () => ({
...
sse : undefined,
...
}),
async setSse() {
const { setCookie, getCookie, removeCookie } = useCookie();
this.sse = new EventSourcePolyfill(pref.app.api.protocol + pref.app.api.host + "/notifications/subscribe",{
headers: {
Authorization : getCookie("Authorization"),
refreshToken : getCookie("refreshToken"),
heartbeatTimeout: 120000,
"Content-Type": "application/json",
},
});
...
this.sse.addEventListener('message', (message) => {
// const { data: receivedConnectData } = e;
console.log(' \'message\' event data shoud be notificationID: ', message.data); // "connected!"
...
채팅 기능 구현을 위해 STOMP, Redis, RabbitMQ 등을 활용하였습니다. 메시지 구독 및 발행을 위해 RabbitMQ를 사용하였으며, WebSocket을 통해 클라이언트와 서버 간 실시간 통신을 구현하였습니다. 또한, 사용자 간 실시간 채팅 데이터와 이전 채팅 데이터를 관리하기 위해 Redis를 활용하였습니다. 이를 통해 안정적이고 확장 가능한 채팅 서비스를 제공할 수 있도록 구성하였습니다.
WebSocketConfig 클래스는 WebSocket을 설정하는 역할을 합니다. STOMP 프로토콜을 사용하여 WebSocket 메시지 브로커를 활성화하고, 메시지 브로커의 구성 및 메시지 발행 및 구독 URL을 설정합니다.
WebSocketConfig 코드
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
// WebSocket 구성 및 메시지 브로커 설정
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableStompBrokerRelay("/exchange")
...
config.setApplicationDestinationPrefixes("/pub");
}
// WebSocket 엔드포인트 등록
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/api/ws")
...
RabbitMQ와 Redis 설정 클래스 코드는 생략하겠습니다.
front에서는 다음과 같은 방식으로 Stomp Connection을 합니다.
Stomp Connection 코드
onMounted(async () => {
roomId.value = route.params.roomPk;
const socket = new WebSocket(`${pref.app.api.websocket}/api/ws`);
stompClient.value = Stomp.over(socket);
stompClient.value.connect({}, () => {
stompClient.value.subscribe(
`/exchange/chat.exchange/room.${roomId.value}`,
(message) => {
handleIncomingMessage(JSON.parse(message.body));
}
);
user의 pk가 아닌 UUID를 사용하여 유저를 구분하므로 이를 서버에서 userPk로 변환하여 조회하고, 채팅 리스트는 페이지네이션을 적용하여 채팅방 리스트를 로드합니다.
chatRoomList 코드
@GetMapping("/list/{userUuid}")
public Page<UsersChats> chatRoomList(@PathVariable String userUuid,
@RequestParam(name="page", defaultValue = "0") int page,
@RequestParam(name="size", defaultValue = "10") int size){
Pageable pageable = PageRequest.of(page, size);
long pk = userService.getUserPk(UUID.fromString(userUuid));
return chatRoomService.findAllRoomByUserId(pk, pageable);
}
...
유저 초대는 방생성 시에 초대할 유저를 선택할 수 있으며, 채팅방 입장 후에도 추가로 유저를 초대할 수 있습니다.
createChatRoom/inviteUser 코드
public ChatRoom createChatRoom(String roomName, long host, List<String> guests){
ChatRoom chatRoom = new ChatRoom().create(roomName);
chatRoom.setRoomPk(chatRoomRepository.save(chatRoom).getRoomPk());
UsersChats hostChats = new UsersChats(host, chatRoom.getRoomPk(), String.valueOf(LocalDateTime.now()));
usersChatsRepository.save(hostChats);
inviteUser(guests, chatRoom.getRoomPk());
return chatRoom;
}
//roomId에 userId로 유저 초대하기
public void inviteUser(List<String> guests, long roomId){
String localTime = String.valueOf(LocalDateTime.now());
for(String guest : guests){
long pk = userService.getUserPk(UUID.fromString(guest));
Optional<UsersChats> existingChat = usersChatsRepository.findByUserPkAndRoomPk(pk, roomId);
if (existingChat.isPresent()) {
UsersChats guestChats = existingChat.get();
guestChats.setStatus('1');
usersChatsRepository.save(guestChats);
}
else {
UsersChats guestChats = new UsersChats(pk, roomId, localTime);
usersChatsRepository.save(guestChats);
}
}
}
실시간 참여중인 사람들의 리스트도 확인할 수 있으며 예전 채팅은 페이지네이션 처리되어, 맨 위로 스크롤을 올릴 시 역방향 무한 스크롤 형태로 불러올 수 있습니다. 또한 이미지를 s3를 통해 업로드 할 수 있고, 상대방의 타이핑 여부도 확인할 수 있습니다.
server에서는 아직 영구 데이터베이스에 저장되지 않은 채팅은 redis를 통해 불러오고, 오래된 대화 내역은 페이지네이션 처리를 하여 client에 전송합니다.
최신 메시지/기존 메시지 로드 코드
@GetMapping("/room/{chatRoomId}/newMsg")
public ResponseEntity<List<Object>> loadNewMsg(@PathVariable String chatRoomId) {
return ResponseEntity.ok(chatService.loadFromRedis(chatRoomId,false, false));
}
@GetMapping("/room/{chatRoomId}/oldMsg")
public ResponseEntity<List<Object>> loadOldMsg( @PathVariable String chatRoomId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) throws JsonProcessingException {
List<Object> res = chatService.loadFromRedis(chatRoomId, true, false);
if (res.isEmpty()) {
List<Chat> chatList = chatService.loadFromJPA(chatRoomId);
for (Chat chat : chatList) {
chatService.saveToRedis(chat, true);
}
res = chatService.loadFromRedis(chatRoomId, true, false);
}
int maxPage = (res.size() + size - 1) / size;
if (page > maxPage) {
return ResponseEntity.ok(Collections.emptyList());
}
page = Math.min(page, maxPage);
int startIndex = (maxPage - page) * size;
int endIndex = Math.min(startIndex + size, res.size());
List<Object> paginatedRes = res.subList(startIndex, endIndex);
return ResponseEntity.ok(paginatedRes);
}
...
일정 주기마다 채팅 대화 데이터 내역을 배치 작업하며, 주기적으로 Redis Cache를 지워줍니다.
채팅 데이터 배치 스케줄러 코드
public void updateData() throws JsonProcessingException {
Set<String> keySets = chatService.getRedisKeys();
for(String keyName : keySets){
if(keyName.startsWith("chat")) {
String keyNumStr = keyName.replaceAll("[^0-9]", "");
chatService.saveToJPA(chatService.loadFromRedis(keyNumStr, false, true));
}
else if(keyName.startsWith("oldChat")){
chatService.deleteKeyInRedis(keyName);
...
멜로디 악보를 토대로 MML(music macro language) 데이터를 만듭니다. MML 데이터는 템포, 옥타브, 음계, 박자, 가사 정보를 담고 있습니다.
{
mmlData: `t68 o3 l4
d'동'g.'해'f+8'물'e'과\t' g'백'd'두'c-'산'd'이\n' g'마'a8'르'b8'고\t'b+.'닳'b8'도' a2'록\n'.r
>d.'하'c8'느'<b'님'a'이\t' g'보'f+8'우'e8d'하'c-'사\n' d'우'g'리'a8'나'a8'라\t'b'만' g2.'세\n'r
f+.'무'g8a'궁'f+'화\t' b.'삼'>c8d'천'<b'리\n' a'화'g'려'f+'강'g a2.'산\n'r
>d.'대'c8'한'<b'사'a'람\t' g'대'f+8'한'e8d'으'c-'로\n' d'길'g'이\t'a8'보'a8'전'b'하'g2.'세\n'r`
}
MML 데이터를 파싱한 결과로, 음계 및 해당 음의 시간 데이터를 가지고 화면에 레더링 합니다.
데이터 신호를 주파수로 바꿔주는 푸리에 변환을 통해, 입력으로 들어온 데이터를 주파수로 바꾸고 주파수를 다시 음계로 파싱합니다. 음계와 시간데이터를 기반으로 화면에 렌더링합니다. 이를 평가 데이터와 비교합니다.
correlate(buffer, sampleRate) {
if (this.isSilentBuffer(buffer)) return -1; // 무음 버퍼인지 확인
const threshold = 0.2; // 임계값 설정
const buf = this.trimBuffer(buffer, threshold); // 임계값을 기준으로 버퍼 자르기
const size = buf.length; // 버퍼 크기
const c = new Array(size).fill(0); // 교차 상관 함수 배열 초기화
// 교차 상관 함수 계산
for (let i = 0; i < size; i++) {
for (let j = 0; j < size - i; j++) {
c[i] = c[i] + buf[j] * buf[j + i];
}
}
let d = 0;
while (c[d] > c[d + 1]) d++; // 최대값 위치 계산
let maxval = -1,
maxpos = -1;
// 최대값 찾기
for (let i = d; i < size; i++) {
if (c[i] > maxval) {
maxval = c[i];
maxpos = i;
}
}
let T0 = maxpos;
const x1 = c[T0 - 1],
x2 = c[T0],
x3 = c[T0 + 1];
const a = (x1 + x3 - 2 * x2) / 2;
const b = (x3 - x1) / 2;
if (a) T0 = T0 - b / (2 * a);
return sampleRate / T0; // 주파수 반환
}
MML 데이터를 파싱한 결과에는 음과 그 음에 해당 하는 가사, 그리고 그 음이 시작하는 시간값(노래 시작시간 0인 기준)이 있습니다. 이 데이터를 한번더 파싱하여 줄바꿈을 기준으로 마디 별 시작 시간을 담고 있는 데이터를 반환받습니다. 노래 시작 시, 시간을 변수에 저장하고, 애니메이션이 진행되는 동안 계속해서 (현재 시간) - (노래 시작 시간)값을 갱신하며, 그 값이 가사 마디의 시작 시간을 넘어 가면 렌더링 하는 방식으로 구현하였습니다.
// 애니메이션 함수 내부
if((Date.now() - this.startTimeRef) >= this.lyrics[this.lyricIndex-1].start+this.prelude) {
if(this.lyricFlag) { // 윗가사 업데이트
this.drawer.lyricUpper = this.lyrics[this.lyricIndex].lyric;
this.lyricFlag = !this.lyricFlag
this.drawer.lyricFlag = !this.drawer.lyricFlag
this.lyricIndex++;
} else { // 아랫가사 업데이트
this.drawer.lyricLower = this.lyrics[this.lyricIndex].lyric;
this.lyricFlag = !this.lyricFlag
this.drawer.lyricFlag = !this.drawer.lyricFlag
this.lyricIndex++;
}
}
핸드스크롤을 통해 무한스크롤 기능을 구현하여 가시성이 좋게 구현하였습니다. 피드 검색 창을 구현하여 닉네임과 노래 제목으로 검색할 수 있습니다.
전체 피드가 정렬되고 각 피드의 디테일 페이지로 이동할 수 있습니다.
좋아요, 조회수, 댓글 수를 계산하여 가져왔습니다.
Feed CRUD를 구현하였습니다.
홈페이지의 로그인 여부에 따라 로그인/회원가입 혹은 기능 네브바가 보일 수 있도록 구현하였습니다.
개인 유저들의 사용자 정보와 피드가 보이는 페이지를 구현하였습니다. 전체 친구 수, 좋아요 수, 댓글 수를 계산하여 화면에 출력되게 하였습니다. 피드는 Amazon s3에 업로드된 영상을 업로드하였습니다.
영상 녹화 후 피드를 업로드 및 수정할 수 있는 모달을 Quasar를 사용하여 생성하였습니다. 모달의 영상과, 게시글 내용, 공개 범위를 설정하여 업로드할 수 있습니다.