레시피 공유 플랫폼 (2024.02 - 2024.08)
이전 프로젝트의 미완성에 대한 아쉬움을 바탕으로, 실제 서비스 가능한 수준의 레시피 공유 플랫폼을 목표로 시작되었습니다. 4인 팀 프로젝트로서, 저는 웹 관련 기술 전반 담당했습니다.
처음에는 페이지 이동이 발생할 때마다 서버에 요청을 보내 알림 목록을 업데이트하는 방식을 고려했습니다.
이 방식은 기존 기술로 쉽게 구현할 수 있다는 장점이 있었으나, '실시간성'이라는 핵심 요구사항을 만족시키지 못했습니다.
Server-Sent Events(SSE)를 도입하여 진정한 실시간 알림을 구현했습니다.
//Sever Sent Event의 http 연결의 엔드포인트
@GetMapping(value = "/getAlarm")
public SseEmitter getAlarm(Principal principal){
//sseEmitter 객체를 생성 후 emitter id에 유저의 정보를 저장
SseEmitter emitter = sseService.loginSSE(
principal.getName()
,alarmService.alarmList(
new MemberVO().withMemberId(principal.getName())
)
);
log.info(principal.getName());
return emitter;
}
//service
public SseEmitter loginSSE(String id, List<AlarmVO> list){
SseEmitter emitter = null;
emitter = createEmitter(id);
sendAlarmList(id, list);
return emitter;
}
public void notify(String id, List<AlarmVO> list){
sendAlarmList(id,list);
}
private void sendAlarmList(String id, List<AlarmVO> list){
SseEmitter emitter = sseRepository.get(id);
if(emitter != null){
try{
emitter.send(SseEmitter.event().id(id).name("notification").data(list));
}catch (IOException e){
sseRepository.deleteById(id);
emitter.completeWithError(e);
}
}
}
private SseEmitter createEmitter(String id){
//SseEmitter객체 생성 -> 기본생성자 초기 타임아웃 30분
SseEmitter emitter = new SseEmitter();
sseRepository.save(id, emitter);
// Emitter가 완료될 때(모든 데이터가 성공적으로 전송된 상태) Emitter를 삭제한다.
emitter.onCompletion(() -> sseRepository.complete());
// Emitter가 타임아웃 되었을 때(지정된 시간동안 어떠한 이벤트도 전송되지 않았을 때) Emitter를 삭제한다.
emitter.onTimeout(() -> sseRepository.deleteById(id));
return emitter;
}
public void deleteId(String id){
sseRepository.deleteById(id);
}
기본적으로 로그인 성공 시 메인 페이지로 이동하는 단순한 로직으로 구현했습니다.
response.sendRedirect("/main");
사용자가 특정 페이지에서 로그인을 시도했을 때, 무조건 메인 페이지로 이동하여 원래 보고 있던 페이지로 다시 돌아가야 하는 불편함이 발생했습니다.
인터셉터를 활용하여 로그인 전 페이지를 저장하고, 로그인 후 해당 페이지로 리다이렉트하는 방식으로 개선했습니다.
public class CheckLoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
HttpSession session = request.getSession();
String targetURI = request.getRequestURI();
try {
Principal principal = request.getUserPrincipal();
checkLogin(principal);
} catch (Exception e) {
session.setAttribute("target", request.getRequestURI());
response.sendRedirect("/member/loginForm");
return false;
}
return true;
}
}
대댓글 기능 구현을 위해 List 자료구조를 사용한 단순 순회 방식으로 구현했습니다.
// 초기 구현 - O(n²) 시간복잡도
for (CommentVO reComment : reCommentList) {
for (CommentVO comment : commentList) {
if (comment.getCommentId().equals(reComment.getReCode())) {
comment.addReComment(reComment);
break;
}
}
}
1. O(n²) 시간복잡도로 인한 성능 문제
2. 정렬된 상태로 댓글을 유지하는데 어려움
1. Map 자료구조 도입으로 조회 성능 개선
2. 커스텀 Comparator를 구현하여 정렬 로직 개선
public static List<CommentVO> sortReComment(
Map<String, CommentVO> commentMap,
List<CommentVO> reCommentList) {
// O(n) 시간복잡도로 개선
for (CommentVO reComment : reCommentList) {
commentMap.get(reComment.getReCode())
.addReCommentList(reComment);
}
List<CommentVO> resultList = new ArrayList<>(commentMap.values());
resultList.sort(CommentVOComparatorByRegDate
.getCommentVOComparatorByRegDate());
return resultList;
}
public class CommentVOComparatorByRegDate implements Comparator<CommentVO> {
private static DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static CommentVOComparatorByRegDate comparator =
new CommentVOComparatorByRegDate();
@Override
public int compare(CommentVO o1, CommentVO o2) {
LocalDateTime d1 = LocalDateTime.parse(o1.getReg_date(), formatter);
LocalDateTime d2 = LocalDateTime.parse(o2.getReg_date(), formatter);
return d1.compareTo(d2);
}
public static CommentVOComparatorByRegDate getInstance() {
return comparator;
}
}
처음에는 댓글 입력 시 페이지 전체가 새로고침되는 방식으로 구현했습니다.
댓글 입력만을 위해 전체 페이지가 새로고침되는 것은 불필요한 서버 요청과 사용자 경험 저하를 야기했습니다.
Fetch API를 활용하여 필요한 부분만 업데이트하는 비동기 방식으로 개선했습니다.
function submitComment(target) {
let formData = new FormData(target.closest('.submitBlock'));
let options = {
method: 'POST',
cache: 'no-cache',
body: formData
}
fetch(submitURL, options)
.then((resp) => {
if(!resp.ok) throw new Error();
return resp.text();
})
.then((data) => {
writeContent(replacePosition, data);
commentTotalCountRender();
})
.catch(e => {
pu_error();
});
}
@PostMapping("/submit")
@ResponseBody
public String submitComment(@ModelAttribute CommentVO commentVO) {
service.insertComment(commentVO);
// 새로운 댓글 목록만 렌더링하여 반환
return commentService.getCommentListHtml(commentVO.getFoodCode());
}