PPAK

알림 전송 모듈 개발기 본문

프로젝트

알림 전송 모듈 개발기

PPakSang 2023. 3. 10. 01:51

두달 전부터 학부 학생회  하나인 개발부( 시스템 도서 위원회)  속해 학부생들이 교내에서 겪는 문제를 찾고 해결하는 일에 참여하고 있다. 원래는 교내 실습실을 관리하는 업무를 주로 했지만, 방향을 조금 바꿔서 뜻이 맞는 학우들과 함께 부서를 발전시켜나가기로 했다.

 

해당 부서에 내가  번째로  일은 학부생들이 겪고 있는 문제를 조사하는 것이였다. 그러던  사실 고질적인 문제로   있는 공지사항 열람의 불편함 올해에도 어김없이 문제점으로 제기된다는 것을 확인했다.

 

문제 상황은 다음과 같다.

 

다른 학과의 상황은  모르지만 컴퓨터학부 공지사항에는 학생들에게 도움이 되는 공지사항(공모전, 특강, 마일리지 관련) 꾸준히 올라오고 있다. 특정 장학금이나 특강에 관한 공지사항은 선착순 마감이 존재하거나 신청 기한 매우 짧아 제때 확인하지 않으면 손해(?) 보는 경우가 있다. 따라서 학기 중에 친구들끼리 모여서 이야기하다 보면 "?? 그걸 언제했어 ??" 라는 이야기가 오가는 것이 부지기수다.

 

공지사항을 확인하는  또한 개인의 재량이라고 판단할  있으나 매년 학부생들에게서 들려오는 곡소리를 듣고 있자면 단순히 개인의 문제만은 아니라고 생각했다.

이제 와서?

이때까지 컴퓨터학부 학생들은 가만히 있었을까? 라고 묻는다면 절대 아니다 라고 말할  있다. 당장 나만 해도 학부 공지사항을 포함한 내가 관심있는 기관의 공지사항을  페이지에 긁어와서 열람하는 페이지를 만들기도 했고, 다른 학부생들 중 일부는 웹뷰로 공지사항을 손쉽게 열람하는 앱을 만들어 학부생들에게 공유하는  문제를 해결하려는 시도는 매년 있었다.

 

그럼에도 매년 공지사항 열람관련 문제가 제기되는 원인이 무엇인가에 대해 개인적으로 생각해본다면 시스템 유지보수가 안된다  때문이다. 

 

아무래도 학부생들 개인이 필요로해서 개발하는 경우가 많다보니 유지보수를 고려한 설계보다는 빠르게 개발하고 사용하는 방식으로 진행되는데,  경우 학부 홈페이지  공지사항의 구조가 바뀌는 경우(메뉴가 추가된다거나, 카테고리가 추가된다거나...)  발생하는 오류를 해결하기가 쉽지 않다는 것이다. 개발한 학생이 아직 학부에 남아있으면 어떻게든 부탁할  있겠지만, 졸업이라도 했는 경우에는 그냥 사용하지 못하는 시스템으로 남기 일수였다.

 

따라서 시간이 지나서 다른 사람이 코드를 넘겨받더라도 유지보수가 가능한 알림 모듈 구축을 목표로 본 프로젝트를 진행했다. 

서론

초기에 개발부원 8명이서 주변 인터뷰  아이디어를 던지는 과정부터 시작했다. 생각보다 많은 아이디어( 20가지)  나왔고, 위에서 언급한대로 공지사항 열람의 불편함 특히 많이 조사됐기 때문에 이를 프로젝트 주제로 정했다.

 

해결할 문제가 정해지고 나서부터는 주변 학우들을 대상으로 간단한 인터뷰를 진행했다. 

인터뷰 양식

 

인터뷰를 진행하고 보니 확실히 공지사항 열람 과정이 개선되면 좋겠구나라는 확신이 들었다.

 

공지사항 열람과 관련해 가장 많이 조사된 불편함 공지사항 작성  알림의 부재 였다. 생각해보면 컴퓨터학부 공지사항 페이지는 필요한 정보를 얻을 정도의 적당한 필터링 기능도 있고, 크게 문제삼을 부분이 존재하지 않았다. 따라서 새로운 형태의 공지사항 페이지를 만든다거나 기존의 페이지를 개선하기보다는 알림 기능과 같은 실질적인 효용을   있는 기능이 필요했다.

 

컴퓨터학부 공지사항 페이지

 

한계

개인적으로 이번 프로젝트를 진행하면서도 느낀 아쉬운   하나기도 한데, 프로젝트의 개요를 설명해드렸지만 학부에서 선뜻 공지사항이 저장된 데이터베이스의 접근 권한을 주지 않는다는 것이다. 물론 "만약에" 라는 것이 존재하기 때문에 학부 입장에서는 허용하지 않는 것이 좋겠지만 아쉬운 마음이 드는 것은 어쩔  없는  같다. 아무튼 이러한 이유로 공지사항을 스크래핑해야만 했고, 여기서 발생하는 여러가지 문제점이 있지만 우선은 감수해야하는 부분이라 판단했다.

 

학부 공지사항 페이지에 대한 접근 권한이나, 데이터베이스를 사용하지 못하는 입장에서는 이벤트(공지사항의 생성, 수정)  발생시키는 과정이 굉장히 러프해진다. 따라서 지속적인 스케줄링으로 공지사항을 모두 열람하고, 데이터의 존재 여부, 기존 데이터와의 정합성을 비교하고 존재하지 않는 데이터는 추가하고, 수정된 공지사항을 찾아 이벤트를 생성하는 방식으로 설계했다.


기획이 진행되다 보니 스크래핑 데이터를 바탕으로 시중에 나와있는 알림 플랫폼(카카오톡, 디스코드 ...) 채널을 통해 새로운 공지사항 알림을 전송하는 것으로 1 MVP  잡혔고, 클라이언트쪽 개발은 불필요하게 느껴져 서버 개발자 3명이서 진행하기로 결정됐다.

 

기능 자체는 굉장히 단순하다. 하지만 앞서 언급했듯 유지 보수가 가능한 코드를 작성하기 위해 노력했다. 따라서  포스팅에서는 개발  유지 보수  유용하다고 판단해 도입한 것들을 공유하고자 한다.

 

본론

포스팅 제목에도 나와있듯 내가 이번 프로젝트에서 개발하고자 했던 것은 알림 전송 모듈이다. 크게 변경되기 쉬운 (공지사항 데이터)  변경되기 어려운 (알림 전송 플랫폼) 사이의 결합을 최소화 하는 코드를 작성하면서도 손쉽게 알림 전송 플랫폼을 추가해나갈  있는 시스템을 설계하고자 했다.

 

처음에는 알림을 보낼 데이터 자체도 추상적으로 설계하려고 했는데, 앞으로 어떤 데이터를 추가적으로 전송하게 될지 명확하지 않고 '알림을 보낼 데이터' 라는  자체가 이미 너무 추상적이기 때문에 1 설계에서는 배제했다. 

 

기본적인 코드 외에 지속적인 유지보수 고려해 작성한 코드는 다음과 같다.

구현체 매핑 클래스

우선 아래와 같이 공지사항을 전송하는 플랫폼 인터페이스를 추가했다. 따라서  플랫폼 별로 해당 인터페이스를 구현하는 클래스를 작성하도록 유도한다. 또한 이와 같은 인터페이스를 통해 NoticeSender 객체를 통합적으로 관리할  있도록 설계했다.

/**
 * 알림 데이터를 통해 알림 전송 역할을 부여하는 인터페이스
 */
public interface NoticeSender {
    void send(NoticeDto dto);
}

 

NoticeSenderMapper  Sender(알림 플랫폼) 별로 NoticeSender 객체를 저장하는 클래스다. NoticeSender 구현체가  때마다 아래 SenderConfig  등록만 하면 된다. 따라서 Sender 별로 하나의 NoticeSender 구현체를 가져올  있는 클래스를 완성했다.

( 부분은 어노테이션과 리플렉션으로 충분히 리팩토링   있는 코드 영역이기도 하다.)

@Configuration
public class SenderConfig {

    @Bean
    public NoticeSenderMapper noticeSenderMapper(
            NoticeDiscordSender discordSender) {
        Map<Sender, NoticeSender> noticeSenderInfo = new HashMap<>();
        noticeSenderInfo.put(Sender.DISCORD, discordSender);
        return new NoticeSenderMapper(noticeSenderInfo);
    }


    /**
     * Sender - NoticeSender 쌍 저장소
     */
    public static class NoticeSenderMapper {
        private final Map<Sender, NoticeSender> noticeSenderMapper;

        NoticeSenderMapper(Map<Sender, NoticeSender> noticeSenderMapper) {
            this.noticeSenderMapper = noticeSenderMapper;
        }

        public NoticeSender getNoticeSender(Sender sender) {
            return noticeSenderMapper.get(sender);
        }
    }
}

 

중간테이블 

다음은 NoticeRecord 라는 클래스(테이블) 추가했다. NoticeRecord  새로운 이벤트(공지사항 생성, 수정)  바탕으로 Sender  전송할 데이터를 식별하는데 사용된다.

 

이와 같이 Sender  Notice 사이 중간 테이블을 추가해서 Sender  Notice  읽기 전용으로만 사용하고 NoticeRecord 데이터를 조작하는 방식으로 설계했다. 이를 통해 Notice 데이터를 보호하고, NoticeRecord 데이터는 Sender 측에서 장애가  경우에 회복 로직에 사용할 수 있도록 했다. (isSent 필드)

public class NoticeRecord {
    @EmbeddedId
    private NoticeRecordId id;

    /**
     * Record 가 생성될 당시의 Notice 상태값
     */
    @Column(name = "notice_type")
    @Convert(converter = NoticeTypeConverter.class)
    private NoticeType noticeType;

    /**
     * 알림 전송 여부
     */
    @Column(name = "is_sent")
    private Boolean isSent;

    @MapsId("noticeId")
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(insertable = false, updatable = false)
    private Notice notice;

    @Builder
    public NoticeRecord(NoticeRecordId id, NoticeType noticeType, Boolean isSent, Notice notice) {
        this.id = id;
        this.noticeType = noticeType;
        this.isSent = isSent;
        this.notice = notice;
    }

    /**
     * Notice - Sender 복합 키 매핑
     * @see Sender
     */
    @Data
    @Embeddable
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public static class NoticeRecordId implements Serializable {
        private static final Long serialVersionUID = 1L;

        private Long noticeId;

        @Enumerated(EnumType.STRING)
        private Sender sender;

        @Builder
        public NoticeRecordId(Long noticeId, Sender sender) {
            this.noticeId = noticeId;
            this.sender = sender;
        }
    }
}

 

Root Manager

다음으로 저장된 NoticeRecord  바탕으로 전송되지 않은 알림을 식별하고, 지정된 NoticeSender 에게 전송 요청하는 NoticeSenderManager  작성했다.

 

해당 클래스를 작성하면서 NoticeSenderMapper  효용을 느낄  있다. NoticeSender 구현체가 추가됐을  Mapper  등록만 해놓으면 아래 NoticeSenderManager 클래스에서 Sender  알맞은 NoticeSender  불러와 알림 메소드를 호출해준다.

 

같은 역할을 수행하는 서로 다른 구현체를 식별자를 사용해 구분할  있을  사용하기 좋은 패턴이라고 생각한다.

public class NoticeSenderManager implements SenderManager {
    private final NoticeRecordService noticeRecordService;
    private final NoticeSenderMapper noticeSenderMapper;

    /**
     * 미발송 알림을 모두 전송합니다.
     */
    public void sendAll() {
        doSend(noticeRecordService.findAllNotSent());
    }


    /**
     * NoticeRecord 의 Sender, Notice 정보를 참조해 알림 전송
     * @param record: 알림 전송 참조 레코드
     * @see NoticeRecord
     */
    private void doSend(NoticeRecord record) {
        Sender sender = record.getId().getSender();
        Long noticeId = record.getId().getNoticeId();
        NoticeDto dto = NoticeDto.ofEntity(record.getNotice());
        NoticeSender noticeSender = noticeSenderMapper.getNoticeSender(sender);
        try {
            noticeSender.send(dto);
            postSend(record);
        } catch (HttpClientErrorException e) {
            log.error(String.format("Sender[%s] Notice[%d] 발송 실패", sender, noticeId), e);
        } catch (Exception e) {
            log.error(String.format("Sender[%s] Notice[%d] 저장 실패", sender, noticeId), e);
        }
    }

    private void doSend(List<NoticeRecord> records) {
        records.forEach(this::doSend);
    }

    /**
     * 알림 발송 후처리 작업
     */
    private void postSend(NoticeRecord record) {
        noticeRecordService.process(record.getId());
    }
}

 

fetch join

위의 코드에서 NoticeRecord  연관 관계에 있는 Notice 데이터를 항상 사용하기 때문에 (N+1 문제가 발생할  있기 때문에) 아래와 같이 fetch join 문을 추가했다.

@Query("select nr from NoticeRecord nr join fetch nr.notice where nr.isSent = :isSent")
List<NoticeRecord> findAllByIsSent(@Param("isSent")boolean isSent);
NoticeDto dto = NoticeDto.ofEntity(record.getNotice());

 

Converter Class

Sender(카카오톡, 디스코드...) 별로 메시지를 전송하는 스펙이 다르다. 가령 디스코드의 경우 Embed 메시지와 같이 특수한 형태의 알림을 보낼  있다.

 

전송 메시지 양식의 경우에도 변경 가능성이 굉장히  영역이기 때문에 개별 클래스로 분리해야 유지보수하기 편하다고 판단했다.

따라서 공지사항 데이터를 바탕으로 사전에 정의된 메시지로 변환해주는 Converter Class  추가했다.

 

DiscordMessage 클래스 내부에 변환 로직을 작성하는 것이 아니라 Converter 클래스를 고려한 이유 변환 데이터  느슨한 결합이 필요했기 때문이다. 따라서 알림 형식을 변경할 필요가 있을 때는 아래 Converter 클래스를 수정하면 된다.

public class NoticeDiscordMessageConverter {
    public static DiscordMessage convertToDiscordMessage(String botName, NoticeDto dto) {
        DiscordMessage message = new DiscordMessage();
        message.setUsername(botName);
        message.setContent("\uD83D\uDCE2 " + dto.getType());
        message.setEmbeds(createEmbedMessages(dto));
        return message;
    }

    private static List<Embed> createEmbedMessages(NoticeDto dto) {
        List<Embed> embeds = new ArrayList<>();
        List<Field> fields = new ArrayList<>();
        fields.add(new Field("\u200B", dto.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm"))));

        Embed embed = Embed.builder()
                .title(String.format("[%s] %s", dto.getCategory().getDesc(), dto.getTitle()))
                .url(dto.getLink())
                .description("")
                .fields(fields)
                .footer(new Footer("#시스템도서위원회"))
                .build();
        embeds.add(embed);
        return embeds;
    }
}

 

DB  저장된 데이터와 Enum 객체  데이터를 바꿀  사용할  있는 Enum Converter 클래스도 추가했다.

코드 작성에는 https://techblog.woowahan.com/2600/ 참고했다해당 기술 포스팅에서는 Legacy 데이터를 직접 조작할 수 없 을  Converter 클래스를 사용해 서버  문맥에 맞게 데이터를 전환하는데 사용했다.  외에도 아래와 같은 목적으로도 사용할  있겠다는 생각이 들었다.

 

Legacy DB의 JPA Entity Mapping (Enum Converter 편) | 우아한형제들 기술블로그

{{item.name}} 안녕하세요. 저는 우아한형제들 비즈상품개발팀의 이은경입니다. Legacy DB의 JPA Entity Mapping (복합키 매핑 편)에 이어 저는 DB의 코드값과 Java Enum을 연결해주는 과정에서 유용하게 사용

techblog.woowahan.com

 

기존에는 Enumerate(EnumType.String)  같이 Enum 클래스의 name value  사용하는 방식으로 개발했고 이는 자바 클래스에 상당히 의존적인 데이터를 생성한다고   있다.  관점에서 해당 클래스의 필요성을 언급해보자면

 

우선 초기 개발 단계에서 데이터베이스에 Enum 타입으로 변형될  있는 데이터(카테고리와 같은 인덱싱에 사용되는 데이터)  어떤 값으로 저장할지 명확히 결정되지 않았을 에도 독립적인 개발이 가능하다는 것이다.

 

우선 Enum 클래스의 경우 DB 데이터를 불러올  있는 Convertible 인터페이스를 구현하도록 하고 EnumDbConvertUtils 클래스에서 해당 Enum 클래스 객체와 DB 데이터를 이용해 상호 변환 가능한 구조를 만든다.

 

public class EnumDbConvertUtils {
    public static <T extends Enum<T> & Convertible> T ofDbData(String dbData, Class<T> enumClazz) {
        return Arrays.stream(enumClazz.getEnumConstants())
                .filter(e -> e.getDbData().equals(dbData))
                .findAny()
                .orElseThrow(() -> new RuntimeException(dbData + " 가 존재하지 않음"));
    }

    public static <T extends Enum<T> & Convertible> String toDbData(T enumV) {
        return enumV.getDbData();
    }
}

 

아래와 같이 DB  저장될 데이터를 Enum name  별개로 유지할  있기 때문에 개발  데이터베이스에 저장할 데이터값이 변경된다 할지라도 dbData  저장되는 값만 변경해주면 된다. (데이터베이스에 저장되는 값과 별개로 개발을 진행할 수 있다.)

public enum Category implements Convertible {
    ALL("전체", "전체"),
    NORMAL("일반공지", "일반"),
    STUDENT("학사", "학사"),
    SCHOLARSHIP("장학", "장학"),
    SIM_COM("심컴", "심컴"),
    GL_SOP("글솝", "글솝"),
    GRADUATE_SCHOOL("대학원", "대학원"),
    GRADUATE_CONTRACT("대학원 계약학과", "대학원 계약학과");

    private final String dbData;
    private final String desc;

    Category (String dbData, String desc) {
        this.dbData = dbData;
        this.desc = desc;
    }

    @Override
    public String getDbData() {return dbData;}
    public String getDesc() {return this.desc;}
}

실제로 데이터베이스에 이해하기 쉬운 이름(공지사항 페이지에 나와있는 카테고리 이름 그대로)으로 데이터를 저장할  있어서 테스트  테이블을 조회할  굉장히 읽기 편했다.

 

DB에 저장된 카테고리 데이터

 

마무리

초기 알림 플랫폼으로 카카오톡과 디스코드를 선정했는데, 카카오톡은 현재 메시지 전송비용 때문에 MVP 개발에서는 제외했다...

주어진 인터페이스(NoticeSender)를 바탕으로 개발한 DiscordSender 의 경우 정상적으로 동작한다. 앞으로 NoticeSender 구현체를 추가하는 방식으로 개발하면 새로운 알림 플랫폼을 등록하기 한층 수월해졌다고 생각한다.

 

또한 변동 가능성이 큰 영역(전송 객체, 메시지 양식, 스크래핑 로직) 을 각각 분리함으로써 유지보수에 용이한 설계를 했고, 코드 주석을 비롯한 슬랙과 깃허브에 히스토리를 남겨 나중에 개발할 인원이 프로젝트를 파악하기 쉽도록 개발을 진행했다.

 

 

 

+디스코드의 경우 아래와 같은 형태로 신규/수정 메시지를 구분해서 실시간(5 간격) 으로 알림을 전송한다.

신규 공지사항 알림

+ 스크래핑 서버는 파이썬으로 작성됐고, 독립된 환경에서 스케줄링되어 작동하고 있다.

 

+ 홍보 첫날에 70명의 학부생들이 채널에 참여해주었다. 아직 별다른 피드백을 받진 못했지만 피드백을 바탕으로 꾸준히 기능을 추가하고자 한다. -> 이튿날 150명의 학부생이 참여했다 -> 현재는 280 340명

 

+ 초기 이벤트를 발행해 비동기적으로 메시지를 생성하고 알림을 전송하려 했으나, 오히려 시스템의 복잡성이 높아져 학부생 입장에서 유지보수가 더 어렵워질 것이라 판단해 하나의 메소드에서 트랜잭션을 작성했다.

Comments