알람 서비스 - 파일럿 프로젝트

짧지만 강렬했던 파일럿 프로젝트 이야기

소개

안녕하세요. 해당 글은 제가 지난 4주간의 파일럿 프로젝트를 진행하면서 경험했던 것들에 대해 이야기하고자 합니다.

1. 파일럿 프로젝트 시작

줌인터넷에 들어오면, 실무에 바로 투입시키지 않고 파일럿 프로젝트라는 것을 진행합니다. 특정한 주제를 선정하고 이후에 주어진 기술스택과 기능스펙을 가지고 하나의 서비스를 만드는 것인데, 저 또한 파일럿 프로젝트를 진행하게 되었습니다.

저에게 주어진 주제와 기술스택 및 기능스펙은 아래와 같습니다.

2. 알림 시스템 만들기

알림 시스템을 듣자마자 머릿 속에 떠오르는건, 유튜브 알림이 떠올랐습니다. 평소 유튜브를 자주 보고 듣는데, 내가 구독한 채널의 채널장이 스트리밍을 한다거나 혹은 새롭게 영상을 업로드하면 구독하고 있는 구독자에게 알림이 오는 모습을 생각했습니다. 추가로 누군가가 나의 댓글에 답글을 작성한 경우에도 알림이 오는 것도 연상이 되었습니다.

기술 스택

기능 스펙

파일럿 프로젝트와 기술 스택 및 기능 스펙에 대한 내용을 확인하면서, 들어는 봤고 제대로 해본 적이 없으니 이게 4주안에 내가 할 수 있을까라는 스스로 의구심이 들면서 이게 가능한 일인건가? 하는 불안함이 엄습했습니다. 팀 단위로 프로젝트를 진행해보기도 하고 나름 공부도 한다고 했지만 온전하게 스스로 무언가를 만들어 본 경험이 없어서 약간의 걱정이 앞섰습니다.

그리고 프로젝트 일정 중에 회사 내 플레이샵이라던지 신입사원 교육과 예비군 훈련 등이 있었습니다. 다행히 팀 내에서 그런 불가피한 일정들은 파일럿 프로젝트 기간에서 제외시켜 주었기 때문에 4주라는 기간보다 조금은 길게 프로젝트 일정을 가지고 갈 수 있었습니다.

추가로 파일럿 프로젝트를 진행함에 있어서 팀 내에 도움없이 스스로 문제를 해결하고 구현해야하다는 이야기가 있었습니다. 따라서 팀원 분들에게 프로젝트 관련 질문은 하지 않았습니다.

개발 일정 및 테이블 설계

파일럿 프로젝트를 받자마자 개발일정은 구글 드라이브에 있는 구글독스로 간단하게 작성해보았습니다. 지금에서야 좀 더 상세하고 체계적으로 작성하는 것이 좋지 않았나 생각이 듭니다. 실제 파일럿 프로젝트를 진행함에 있어서 일정과 동일한 속도로 진행되지 않았습니다. 여러 예외적인 사항들이 많았고 새로운 것을 익히는 학습속도라던가 다양한 변수들에 대해서 고려없이 작성되었습니다.

최초 작성한 개발 일정

테이블 설계 또한 구글독스로 작성하였습니다. 테이블 설계에 대해 제대로된 경험이 없다보니 그냥 직관에 따라 주먹구구식으로 나아갔습니다. 돌이켜 생각해보면 좀 더 여유를 가지고 접근했으면 하는 아쉬움이 있습니다.

최초 작성한 테이블 설계 내용

3. 파일럿 프로젝트 진행 및 완료

시간은 파일럿 프로젝트 발표 날을 향해 열심히 달려가고 있었습니다. 그에 따라 아무것도 보이지 않는 제 서비스도 조금씩 모습을 조금씩 갖추고 있었던 것 같습니다. 발표날이 다가오면 다가올수록 과연 무사히 마무리할 수 있을까라는 생각도 했습니다.

3.1 서비스 전체 구성도

3.2 데이터베이스 스키마

이 당시를 돌이켜 생각하면, 저는

3.3 Vue.js 와의 만남 (Feat.Vuetify)

프론트엔드 전체 구조

프론트엔드 컴포넌트 구조

컴포넌트에 따른 화면 구성 일부

이벤트버스(EventBus) 의 이용

위 Case1 과 Case2 를 살펴보면 하나의 공통점을 발견할 수 있습니다. 두 개의 컴포넌트가 서로 간 통신을 하는데 있어, 여러 컴포넌트를 지나간다는 것을. 따라서 저는 이를 해결하기 위해서 이벤트버스 를 이용하였습니다.

1. 이벤트버스의 생성 샘플코드

/**
 * 비어있는 Vue 인스턴스를 통해서 이벤트버스를 초기화할 수 있습니다.
 */

import Vue from 'vue'

const EVENT_BUS = new Vue();

export default EVENT_BUS

2. 이벤트버스를 통한 이벤트 발생 샘플코드 (CommentInput.vue)


import eventBus from '../../api/eventBus.js'

/** 생략 **/

writeCommentProcess(commentObject) {
    // [댓글] 등록 이후 형제 컴포넌트에게 이벤트 전달
    writeComment(commentObject).then(() => {
        this.commentContent = '';
        eventBus.$emit('setupWriteCommentEventBus');
        eventBus.$emit('setupTotalCountGroupNoEventBus');
    })
},

writeReplyProcess(commentObject) {
    // [답글] 등록 이후 형제 컴포넌트에게 이벤트 전달
    writeReply(commentObject).then(() => {
        this.commentContent = '';
        eventBus.$emit('closeReplyEventBus', this.replyIndex);
        eventBus.$emit('closeReplyCommentListEventBus', this.replyIndex);
    })
},

3. 이벤트버스를 통한 이벤트 감지 샘플코드 (GroupComment.vue)


import eventBus from '../../api/eventBus.js'

/** 생략 **/

created() {
    
    /** 생략 **/

    // ( [댓글] 작성 시 ) 형제 컴포넌트에게 이벤트를 받아서 수행
    eventBus.$on('setupWriteCommentEventBus', this.setupWriteComment);
    eventBus.$on('setupTotalCountGroupNoEventBus', this.setTotalCountGroupNo);

    // ( [답글] 작성 시 / 답글이 더 이상 존재하지 않는 경우)
    eventBus.$on('closeReplyEventBus', this.closeReply);
    eventBus.$on('closeReplyCommentListEventBus', this.closeReplyCommentList);
},

beforeDestroy() {
    eventBus.$off('setupWriteCommentEventBus');
    eventBus.$off('setupTotalCountGroupNoEventBus');

    eventBus.$off('closeReplyEventBus');
    eventBus.$off('closeReplyCommentListEventBus');
}

뷰엑스(Vuex) 의 필요성

만약 컴포넌트의 개수가 많아지고, 서비스의 규모가 커진다면 Vuex 를 고려해보아야 합니다. 왜냐하면 화면이 복잡해지고 이벤트 버스만으로는 더이상의 데이터 관리가 어렵기 때문입니다. 따라서 서비스의 규모에 따라 적절한 판단을 가지고 EventBus 또는 Vuex 를 이용하는 것이 좋습니다.
저는 Vuex 에는 최소한의 사용자 정보만을 관리하였습니다.

뷰엑스(Vuex) 의 이용

store.js 일부 코드


import Vue from 'vue'
import Vuex from 'vuex'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'

Vue.use(Vuex);

/** 공유자원 **/
const state = {
    user: [],
    isAuth: false,
    stream: {},
    targetUser: [],
    pageable: {page: 0, size: 3, total: 0},
};

export default new Vuex.Store({
    state,
    mutations,
    getters,
    actions
})

getters.js 일부 코드

/** getters.js **/
STREAM(state){
    state.stream = JSON.parse(localStorage.getItem(STREAM));
    return state.stream;
},

actions.js 일부 코드

/** actions.js **/
closeStream() {

    const API_NOTIFICATION_URL = "/api/notification";
    const STREAM = store.getters.STREAM;

    // 스트림 중단
    let url = API_NOTIFICATION_URL + "/user/push-close?uuid=" + STREAM.id;

    return new Promise(function (resolve) {
        Vue.prototype.$http.get(url)
            .then((response) => {
                resolve(response);
            })
    });
},

mutations.js 일부 코드

/** mutations.js **/
setStream(state, uuid){
    state.stream.id = uuid;
    state.stream.isStream = true;

    const serializedUserUUID = JSON.stringify(state.stream);

    localStorage.setItem(STREAM, serializedUserUUID);
},

프론트엔드 개발을 하면서 느낀 사항

위의 생각을 가지게 해준 링크입니다. 우아한 형제들에서 2018년 6월에 작성된 글입니다. 현재에는 종료된 서비스이지만 글을 읽어보면 이 당시 서비스에 vuex 를 적용하지 않았음을 이야기해주고 있는 듯합니다.

3.4 SpringBoot 와의 만남

(1) 백엔드 전체 구조

(2) 댓글 및 대댓글(답글) 작성

동적라우팅을 위한 일부 코드

const router =  new Router({
    mode: 'history',

    base: process.env.BASE_URL,

    routes: [
        /** 생략 **/
        {
            path: '/app',
            component: AppView,
            children: [
                {
                    path: '',
                    name: 'profileList',
                    component: ProfileList
                },
                {
                    path: 'profile',
                    name: 'profile',
                    component: Profile,
                },
                {
                    path: 'profile/:profileId/groupNo/:groupNo',
                    name: 'notificationComment',
                    component: Profile,
                },
                {
                    path: 'profile/:profileId/groupNo/:groupNo/groupOrder/:groupOrder',
                    name: 'notificationGroupComment',
                    component: Profile,
                }
            ]
        },
        /** 생략 **/
});

(3) 알림(:댓글 및 답글) 이동

BeforeRouterEnter 일부 코드

beforeRouteEnter(to, from, next) {

    /**
     * - to : 이동할 URL 정보가 담긴 라우터 객체
     * - from : 현재 URL 정보가 담긴 라우터 객체
     * - next : 훅을 해결하기 위해서 호출 (to 에서 지정한 url 로 이동하기 위해 반드시 호출)
     */

    let fromPath = from.path;
    let toPath = to.path;

    /** 알람을 통한 이동 **/
    next(vm => {

        /** vm을 통해 컴포넌트 인스턴스 접근 **/

        if (vm.$route.params.profileId === undefined) {
            return;
        }

        let profileId = vm.$route.params.profileId;
        let groupNo = vm.$route.params.groupNo;
        let groupOrder = vm.$route.params.groupOrder;

        if (groupOrder === undefined) {
            /** [댓글]에 알람 컴포넌트 세팅 **/
            vm.notificationCommentSetup(profileId, groupNo);
        } else {
            /** [답글]에 알람 컴포넌트 세팅 **/
            vm.notificationGroupCommentSetup(profileId, groupNo, groupOrder);
        }
    })
}

알림 댓글에 컴포넌트 세팅 코드

notificationCommentSetup(profileId, groupNo) {

    console.log("====> [댓글] 알림을 확인합니다.");

    /** 알람이 일어난 프로필 획득 **/
    getProfileByProfileId(profileId).then((response) => {

        let targetUser = response.data;

        this.user = targetUser;
        this.profile = targetUser.profile;
        this.$store.commit('setTargetUser', targetUser);
    });

    /** 알람이 일어난 댓글 획득 **/
    findNotiCommentByProfileId(profileId, groupNo).then((response) => {
        this.alarmComments = response.data;
    });
},

알림 답글에 컴포넌트 세팅 코드

notificationGroupCommentSetup(profileId, groupNo, groupOrder) {

    console.log("====> [대댓글] 알림을 확인합니다.");

    /** 알람이 일어난 프로필 획득 **/
    getProfileByProfileId(profileId).then((response) => {

        let targetUser = response.data;

        this.user = targetUser;
        this.profile = targetUser.profile;
        this.$store.commit('setTargetUser', targetUser);
    });

    /** 알람이 일어난 댓글 및 답글 획득 **/
    findAlarmGroupCommentByProfileId(profileId, groupNo, groupOrder).then((response) => {
        this.alarmComments = response.data;
        this.alarmGroupComment[0] = this.alarmComments[1];
        this.alarmComments.splice(1, 1);
    });
},

(4)알림 발생

Sse 를 이용 브라우저 화면

알림 서비스 다이어그램

EventSource 생성 및 스트림 연결 샘플 코드

let url = API_NOTIFICATION_URL + "/user/push?uuid=" + STREAM.id;

let eventSource = new EventSource(url, {withCredentials: true});

알림 컨트롤러 샘플 코드(Controller)

@Slf4j
@RestController
@RequestMapping("/api/notification")
public class NotificationController {

    private final NotificationService notificationService;

    public NotificationController(NotificationService notificationService) {
        this.notificationService = notificationService;
    }
	
    /** 생략 **/
    
    @GetMapping(value = "user/push", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public ResponseEntity<SseEmitter> fetchNotify(@AuthenticationPrincipal CustomOAuth2User oAuth2User,
    											  @RequestParam(required = false) String uuid) {

        if (oAuth2User == null || uuid == null) {
            throw new UnauthorizedException("식별되지 않은 유저의 요청입니다.");
        }

        final SseEmitter emitter = new SseEmitter();
        final User user = oAuth2User.getUser();
        final StreamDataSet DATA_SET = new StreamDataSet(user, emitter);
        final String UNIQUE_UUID = uuid;

        try {
            notificationService.addEmitter(UNIQUE_UUID, DATA_SET);
        } catch (Exception e) {
            throw new InternalServerException(e.getMessage());
        }

        emitter.onCompletion(() -> {
            notificationService.removeEmitter(UNIQUE_UUID);
        });
        emitter.onTimeout(() -> {
            emitter.complete();
            notificationService.removeEmitter(UNIQUE_UUID);
        });

        return new ResponseEntity<>(emitter, HttpStatus.OK);
    }
    
}

알림 서비스 레이어 샘플 코드 1 (Service)

@Slf4j
@Service
@EnableScheduling
public class NotificationService {

    /** 생략 **/
    
    private final ConcurrentHashMap<String, StreamDataSet> eventMap = new ConcurrentHashMap<>();
    
    void addEmitter(final String UNIQUE_UUID, final StreamDataSet dataSet) {
        eventMap.put(UNIQUE_UUID, dataSet);
    }

    void removeEmitter(final String UNIQUE_UUID) {
        eventMap.remove(UNIQUE_UUID);
    }
    
    @Scheduled(initialDelay = 2000L, fixedDelay = 5000L)
    public void fetch() {

        if (eventMap.size() == 0) {
            return;
        }

        this.handleAlert();
    }
    
}

알림 서비스 레이어 샘플 코드 2 (Service)

@Slf4j
@Service
@EnableScheduling
public class NotificationService {

    /** 생략 **/

    @Transactional
    public void handleAlert() {

        List<String> toBeRemoved = new ArrayList<>(eventMap.size());
        List<Long> alertIdList = new ArrayList<>();

        for (Map.Entry<String, StreamDataSet> entry : eventMap.entrySet()) {

            final String uniqueKey = entry.getKey();
            final StreamDataSet dataSet = entry.getValue();

            final User user = dataSet.getUser();
            final List<Notification> receivingAlert = notificationRepository.findByNotificationTargetUserUidAndIsReadIsFalse(user.getUid());
            final int noneReadCount = receivingAlert.size();

            /** 접속 유저가 읽지 않은 알람의 개수 **/
            if (noneReadCount == 0) {
                continue;
            }

            final SseEmitter emitter = dataSet.getSseEmitter();

            /** 30분 이내에 작성된 알람 목록 확인 **/
            final List<Notification> alertList = getListAnMinuteAndAlertFalse(receivingAlert);

            if (alertList.size() == 0) {
                continue;
            }

            /** 알림데이터 생성 **/
            NotificationAlert alert = NotificationAlert.builder()
                    .uid(user.getUid())
                    .notificationCount(noneReadCount)
                    .notifications(alertList)
                    .build();


            /** 알림 목록 ID 획득 **/
            alertIdList.addAll(alertList.stream()
                                    .map(Notification::getId)
                                    .collect(Collectors.toList()));

            try {

                /** 알림 전송 수행 **/
                emitter.send(alert, MediaType.APPLICATION_JSON_UTF8);

            } catch (Exception e) {
                log.error("이미터 센드 시 에러 발생 :: {}", e.getMessage());
                toBeRemoved.add(uniqueKey);
            }

        } // for

        /** 전송된 알람들 IS_ALERT 'Y' 로 변경 **/
        updateIsAlert(alertIdList);

        /** 전송 오류 SseEmitter 제거 **/
        for (String uuid : toBeRemoved) {
            eventMap.remove(uuid);
        }
    }

}
/**
 * - 30분 이전에 발생된 알람 여부
 * - 알람 푸시 수행 여부
 *
 * @param paramList 현재 접속 사용자에게 존재하는 전체 알림
 * @return 현재 시간으로부터 30분 이전에 발생한 알림 목록
 */
private ArrayList<Notification> getListAnMinuteAndAlertFalse(List<Notification> paramList) {

    ArrayList<Notification> alertList = new ArrayList<>();

    LocalDateTime beforeTime = LocalDateTime.now().minusMinutes(30);

    for (Notification notification : paramList) {

        boolean isAlert = notification.isAlert();
        LocalDateTime createdAt = notification.getCreatedAt();

        if (createdAt.isBefore(beforeTime) || isAlert) {
            continue;
        }

        // 30 분 이내 알리미 & 안 읽은 알리미
        alertList.add(notification);
    }

    return alertList;
}
/**
 * - 전송된 알림에 대해서 IS_READ 값을 'Y' 로 변경
 *
 * @param alertIds 전송된 알림 ID 목록
 */
private void updateIsAlert(List<Long> alertIds) {

    Set<Long> idSet = new HashSet<>(alertIds);
    idSet.stream().forEach(notificationRepository::updateNotificationIsAlertById);

}

예외처리 샘플 코드

@ResponseStatus(value = HttpStatus.UNAUTHORIZED, reason = "request does not contain authentication credentials")
public class UnauthorizedException extends RuntimeException {

    public UnauthorizedException(String message){
        super(message);
    }

}
@ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR, reason = "Internal Server Error")
public class InternalServerException extends RuntimeException {

    public InternalServerException(String message){
        super(message);
    }
    
}

알림 삭제 샘플 코드

/**
 * reference :: https://crontab.guru/#0_2_*_*_*
 * 일 단위 At 02 : 00 에 알림 데이터는 삭제된다.
 *
 */
@Scheduled(cron = "0 0 2 * * *")
public void deleteNotificationByCron() {
    
    notificationRepository.deleteNotificationByCron();
    
}

4. 파일럿 프로젝트를 마무리하며

처음 파일럿 프로젝트 주제와 기술스택 및 기능스펙을 들었을 때 이게 4주동안 가능한 일인건가 생각이 들었습니다. 하루하루 굼뜬 굼벵이마냥 진행되는 개발속도와 서비스 구현력에 있어서 저의 마음은 개발에 대한 성취감과 스스로에 대한 부끄러움을 넘나들었습니다.

이후 발표와 코드리뷰를 거치면서는 나는 정말 많은 부분을 고려하지 않고 코딩을 하고 있었구나 하는 생각을 하였고, 추가적으로 이건 왜 썼냐는 질문에 제대로 된 답변을 하지 못했습니다. 그제서야 다시 찾아보고 공부하고 확인했습니다. 개인적으로 저는 이 파일럿 프로젝트가 전반적인 웹개발의 경험 및 지식습득과 더불어 앞으로 개발자가 되기 위해서 갖추어야 할 자세들을 상기시켜 주었습니다.

이제 실무를 접하게 될텐데, 파일럿 경험을 바탕으로 개발자가 가져야할 자세를 머릿 속에 새기면서 프로그래밍에 임한다면 어제의 나보다 좀 더 나아지지 않을까 기대합니다.

감사합니다.