본문 바로가기

Programming/Java & Spring 관련 내용 정리

Push 서버 재구성: 유연한 이벤트성 푸시 발송 시스템 구현

 

얼마 전 회사에서

이벤트성 및 서비스성 푸시 메세지를 발송하기 위한 별도의 Push Server를 만들었다.

 

기존에 Push Server가 있는 상태이긴 했는데,

기존 Push Server는 사용자가 인증한 정보를 기반으로

타겟팅하여 푸시를 발송하는 포맷으로 정해져 있었다.

 

 

그런데 이벤트성, 서비스성 푸시를 보낼 때는

기존 Push Server를 이용하면 비효율적인 부분이 있었다.

 

예를들면, 보내려는 서비스성 푸시메세지가 기존 시스템의 포맷과 맞지 않아

새로운 DB 테이블을 생성하거나

새로운 객체에 맞게 발송 프로그램을 따로 구현하고 테스트 해야 했다.

 

이를 해결하기 위해

다양한 서비스 유형 푸시 메세지를 발송할 수 있도록

새로운 Push Server를 만들었다.

 

기존 Push Server를 이용할 때는

새로운 유형의 푸시를 보낼 때

기존 포맷에 맞춰 추가 개발하고 테스트하는데 하루 정도 시간이 소요되었으나

개선된 시스템에서는 스펙만 정해지면 대상 쿼리, 발송 시점, 메세지 내용 등을 간편하게 설정할 수 있어

작업시간이 약 30분 이내로 단축되었다.

 

 

아래 내용은 새로운 Push Server를 만들면서

기존 Push Server에서 아쉬웠던 점과 추가로 개선한 사항들을 정리한 것이다.

 

 


[ 다형성 활용 ]

 

< 변경 전 > 

 

 

 

 

기존 코드의 문제점을 요약해서 설명하자면

- 나열식 코드가 많아서 메소드 하나에 코드 내용이 너무 길었다. 

- 통신 3사마다 요청 포맷이 다 다른데, 구조화된 느낌이 거의 없었다. (통신사 별 포맷이 다른 것도 case 분기 처리)

- 한 클래스 내에 코드가 죄 나열되어 있는 느낌이 강했다.

 

 

 

< 변경 후 >   

 

 

 

 

변경 후 코드는 Abstract class를 사용해서 구조화 시켰다.

 

 위 그림에서 Dog에 해당하는 것이

아래 AbstractNotificationSender 클래스이다.

 

그리고 하위 클래스 3개가 아래와 같다.

- SktNotificationSender

- KtNotificationSender

- LguNotificationSender

 

 

 

개선된 이유

- 다형성 지원 : sendNotification과 getMediaSeq와 같은 추상 메서드를 통해

서브클래스에서 구체적인 구현을 정의하도록 하여,

다양한 매체에 대해 유연하게 대응할 수 있도록 한다.

 

- 공통 기능 제공 : NotificationDao, StringRedisTemplate, Environment 등

공통적으로 사용되는 의존성들을 한 곳에서 관리하고,

getNeedAgreeCdList()와 같은 공통 메서드를 제공하여 코드 중복을 줄인다.

 

 

이렇게 추상 클래스를 사용하면

공통 기능과 상태를 중앙에서 관리하고, 다양한 매체에 대해 유연하게 확장할 수 있다는 장점이 있다.

 

즉 사용자 정상 여부 체킹, 사용자 동의 여부 체킹, 푸시 수량 제한 체킹은 모두 공통적으로 이루어지기 때문에

이 AbstractNotificationSender 에 모두 코드를 작성하고,

실제로 통신사 포맷에 맞춰 API를 호출하고 응답을 받는 과정과 같이 다르게 작성되어야 하는 부분만

extends 하고 있는 서브클래스에서 구현해주면 되는 것이다.

 

그리고 이렇게 구조화가 되어 나뉘어져 있기 때문에

각 통신사마다 다른 부분을 구현하는 코드가 한 클래스 내에 너무 방대하게 있지 않다는 장점이 있고,

통신사 별로 수정이 필요할 때도 빠르게 찾을 수 있어서 좋다.

 

 

 


[ Redis 를 활용한 푸시 수량 체크 ]

 

 

푸시가 하루에 너무 여러 번 발송되면 사용자는 피로감을 많이 느낀다.

그렇기 때문에 푸시 수량 제한 기능이 필요한데, 이 때 Redis를 적극 활용했다.

 

일단 통신사별로 푸시 수량 개수가 다를 수 있기 때문에

제한 숫자는 DB에 저장해 두었다.

 

Redis를 활용한 부분은 사용자별로 오늘 푸시 보낸 수량을 체킹하는 부분이다.

 

 

< 변경 후 > 

 

 

 

 

 

사용자의 custSeq, 푸시 카테고리, 날짜 등을 조합하여 Redis 키를 생성한다.

이 키를 사용해 Redis에서 값을 조회하고, 오늘 보낼 수 있는 푸시 수량을 초과했는지 확인하는 것이다.

또한 오늘 자정부터 다음 날 자정까지의 기간 동안 dailyLimitKey가 유효하도록 만료시간을 설정할 수 있다.

 

개선된 이유

- 효율적인 푸시 수량 관리:

사용자의 custSeq, 푸시 카테고리, 날짜 등을 조합한 Redis 키를 사용함으로써

개별 사용자와 푸시 유형에 따른 세밀한 관리를 할 수 있다.

Redis의 메모리 기반 저장소 덕분에 높은 속도와 성능을 보장한다.

 

- 자동 만료 지원: Redis의 TTL(Time-to-Live) 기능을 활용하여,

푸시 수량 체크와 관련된 데이터를 자동으로 관리하고 만료시킬 수 있다.

이로 인해 매일 초기화가 필요하지 않으며, 관리 작업을 줄일 수 있다.

 

데이터 일관성 및 정확성: Redis를 통해 푸시 수량을 관리함으로써

데이터베이스의 복잡한 트랜잭션과 동시성 문제를 피하고, 푸시 수량의 일관성과 정확성을 유지할 수 있다.

 

 

 


[ 스케쥴링 방식 변경 ]

 

< 변경 전 > 

 

 

 

< 변경 후 > 

 

 

개선된 이유

- 변경 전: 예를 들어 DB를 읽고 푸시를 보내는 작업이 2분 12초가 걸렸다면,

다음 호출까지 48초동안 대기를 하고 있다. cron은 1분단위로만 설정이 가능하기 때문이다.

 

- 변경 후: 작업이 완료된 후 1초 대기하고 다음 작업을 시작한다.

이렇게 하면 대기 시간을 줄이고 작업이 더 지속적으로 이루어진다. 

 

대량의 푸시를 보내는 경우에는

위와 같은 예시 상황에서 48초 대기시간이 상당히 비효율적이다. 

변경된 코드 내용으로는 대기시간을 최소화 할 수 있다. (1초 정도는 서버의 안정성을 위해 쉬어주는 느낌!)

 

 

 

현재의 스케줄링 방식은 서버 1대일 때 최적의 성능을 발휘하도록 설계되었다.

그러나 향후 트래픽 증가나 비즈니스 요구사항의 변화에 대비하여

다음과 같은 확장 방안을 고려해 볼 수 있을 것 같다.

 

- 비동기 처리 및 메시지 큐 도입

여러 서버가 병렬로 작업을 처리할 수 있도록 비동기 처리 방식을 도입하고,

메시지 큐 시스템(예: RabbitMQ, Kafka 등)을 활용하여

각 서버가 독립적으로 작업을 분담할 수 있도록 설계하는 방법을 생각해보고 있다.

 


[ DB 구조 변경 ]

 

< 기존 DB 구조 > 

 

보내야할 푸시와 보낸 결과를 모두 하나의 테이블에서 관리했다.

보낸 결과는 기존에 등록된 푸시 status를 업데이트 하는 방식이었다.

 

 

< 변경 후 DB 구조 > 

 

보내야 할 푸시와 푸시 보낸 결과 테이블을 2개로 분리하였다.

 

 

 

 

장점

- 유지보수 용이성:

(1) 푸시 발송 대상 데이터 테이블 (2) 푸시 발송 결과 테이블

이렇게 각 테이블의 역할이 명확해져 데이터 관리와 유지보수가 쉬워진다.

 

- 성능 개선:

두 테이블로 분리하면 각 테이블의 데이터 양이 줄어들어 인덱스 성능과 쿼리 성능이 향상된다.

예를 들어 푸시 발송 결과를 자주 조회해야 하는 경우

불필요한 데이터를 포함하지 않는 테이블에서 조회 속도가 빨라진다.

 

- 스케일링:

데이터가 분리되어 있으면, 향후 데이터베이스 스케일링(예: 파티셔닝)이나 데이터베이스 성능 조정이 더 용이하다.

푸시 요청과 결과를 별도로 관리하여 스케일 아웃이나 분산 처리가 수월해진다.

 

 

 


[ @PostConstruct 와 @Value 활용 ]

 

 

< 변경 전 > 

 

3사 통신사마다 호출해야할 도메인 서버 주소가 달랐다.

기존 방식은 코드가 쭉 진행되다가 통신사로 API를 호출하는 시점에 세팅해주는 방식이었다.

 

 

< 변경 후 > 

 

개선된 이유

각 통신사 서브클래스 init() 메서드에서

@PostConstruct를 활용하여 RestClient를 설정하는 방식은

통신 서비스마다 서로 다른 URL 및 설정을 동적으로 관리할 때 유리하다.

 

1. 동적 설정 적용

- 문제: 외부 서비스마다 통신 URL과 설정이 다를 경우, 각각의 설정을 하드코딩하거나 별도로 관리해야 한다.

 

- 개선: @PostConstruct를 사용하면 클래스가 생성된 후

외부 설정값(예: URL, 타임아웃)을 주입받아 동적으로 RestClient를 설정할 수 있다.

각 서비스의 URL과 설정값을 환경 설정 파일에서 관리할 수 있다.

 

 

2. 환경 기반 설정

- 문제: 각 서비스의 설정이 다를 경우, 다양한 환경(개발, 테스트, 프로덕션)에 맞게 각각 다른 설정을 적용하기 어렵다.

 

- 개선: @Value를 사용하여 환경 설정 파일(application.properties 또는 application.yml)에서

서비스별 URL과 타임아웃을 읽어와 RestClient를 구성한다.

이를 통해 환경에 따라 동적으로 설정을 조정할 수 있어, 코드 변경 없이 설정만으로 다양한 환경을 지원할 수 있다.

 

 

3. 코드 유지보수 용이

- 개선: 초기화 로직을 @PostConstruct 메서드에 집중시킴으로써

설정값이 변경될 경우 환경 설정 파일만 수정하면 되어 코드의 수정 없이 설정을 쉽게 업데이트할 수 있다.

 

 


[ 곳곳에  ENUM 활용하기 ]

 

 

 


< 변경 후 > 

 

푸시 발송 실패는 여러가지 원인이 있을 수 있다.

그렇기 때문에 그 원인이 발생한 시점에 진행을 멈추고, 그 원인을 DB에 저장해 주어야 한다.

 

SendResultCode enum을 활용하여 실패 원인을 한 곳에서 관리할 수 있도록 했다.

 

 

 


[ @DiscriminatorColumn 과 @SQLRestriction 활용 ]

 

cust-info 라는 테이블에서 사용자 정보를 조회해 와야 하는데,

각 통신사마다 추가로 더 필요한 사용자 정보는

cust_dtl_info 라는 테이블에 추가로 저장되어 있는 상황이다.

 

이 때 @DiscriminatorColumn 어노테이션,

@SQLRestriction 어노테이션,

@Inheritance 어노테이션을 활용했다.

 

 

 

< 변경 후 >

 

 

 

@Inheritance(strategy = InheritanceType.SINGLE_TABLE)을 사용하면

하나의 테이블이 여러 자식 엔티티의 데이터를 관리하게 된다.

 

구분자 컬럼(DiscriminatorColumn)은 각 행이 어떤 자식 엔티티와 연결되는지 식별하는 데 사용된다.

예를 들어, MEDIA_SEQ 컬럼을 사용해

어떤 행이 KtCustInfoEntity, LguCustInfoEntity 등과 연결되는지 구분할 수 있다.

 

 

 

 

 

 

@DiscriminatorColumn은 싱글 테이블 전략에서 구분자 역할을 하는 컬럼을 지정한다.

이 컬럼의 값에 따라 각 행이 어떤 엔티티 타입에 해당하는지 구분한다.

 

예를 들어 @DiscriminatorColumn(name = "MEDIA_SEQ")를 사용하면,

MEDIA_SEQ 컬럼의 값에 따라 데이터베이스 레코드가 어느 자식 엔티티의 데이터인지 식별할 수 있다.

 

 

 

 

 

 

각 엔티티에 @SQLRestriction을 사용하여

특정 조건에 맞는 데이터만 해당 엔티티에서 처리되도록 제한할 수 있다.

 

예를 들어 KtCustDtlInfoEntity 클래스에 @SQLRestriction("CUST_DTL_TYPE_CD = 'OO'")를 적용하면,

이 엔티티는 CUST_DTL_TYPE_CD 값이 'OO'인 데이터만을 처리한다.

 

이렇게 하면 단일 테이블 전략을 사용하면서도

각 엔티티가 자신에게 맞는 데이터만 다루도록 할 수 있어

데이터 무결성을 유지하고 관리가 용이해진다.

 

 

 

 

 

 

CustDtlInfoEntity :

여러 엔티티에서 공통으로 사용할 수 있는 필드와 매핑 정보를 정의하는 추상적인 클래스이다.

이 클래스는 자체로는 테이블에 매핑되지 않는다.

 

KtCustDtlInfoEntity :

CustDtlInfoEntity의 필드들을 상속받아 실제로 테이블에 매핑되는 엔티티이다.

이 클래스는 cust_dtl_info 테이블에 매핑되며,

특정 조건(CUST_DTL_TYPE_CD = 'OO')에 해당하는 데이터만 처리한다.