Dishcovery Project

레시피 공유 플랫폼 (2024.02 - 2024.08)

프로젝트 개요

프로젝트 배경

이전 프로젝트의 미완성에 대한 아쉬움을 바탕으로, 실제 서비스 가능한 수준의 레시피 공유 플랫폼을 목표로 시작되었습니다. 4인 팀 프로젝트로서, 저는 웹 관련 기술 전반 담당했습니다.

실시간 알림 시스템

초기 구현

처음에는 페이지 이동이 발생할 때마다 서버에 요청을 보내 알림 목록을 업데이트하는 방식을 고려했습니다.

문제 발생

이 방식은 기존 기술로 쉽게 구현할 수 있다는 장점이 있었으나, '실시간성'이라는 핵심 요구사항을 만족시키지 못했습니다.

해결 과정

Server-Sent Events(SSE)를 도입하여 진정한 실시간 알림을 구현했습니다.

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;
}
                            
                        
커스텀 Comparator 구현
                            


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());
}
                            
                        

기술 스택

Spring Boot Spring Security Server-Sent Events My batis Oracle Thymeleaf JavaScript HTML/CSS