얼마 전 회사에서 기존 푸시서버 개선을 진행했다.
기존 푸시 서버의 문제점은
인증정보 기반의 타겟 푸시 발송에 국한되어 있는 포맷이라
서비스성 및 이벤트성 메세지 발송을 하기에는 비효율적이었다.
(ex) 보내려는 서비스성 푸시메세지가 기존 시스템의 포맷과 맞지 않아
새로운 DB 테이블을 생성하거나
새로운 객체에 맞게 발송 프로그램을 따로 구현하고 테스트 해야 했음
한 마디로 확장성과 유연성이 떨어지는 구조였다.
이를 해결하기 위해 인증 기반 푸시 메세지 뿐 아니라
다양한 서비스 유형 푸시 메세지를 발송할 수 있도록 DB와 코드를 재설계했다.
개선 전에는 새로운 유형의 푸시를 보낼 때
기존 포맷에 맞춰 추가 개발하고 테스트하는데 하루 정도 시간이 소요되었으나
개선된 시스템에서는 스펙만 정해지면 대상 쿼리, 발송 시점, 메세지 내용 등을 간편하게 설정할 수 있어
작업시간이 약 30분 이내로 단축되었다.
[ 다형성 활용 ]
< 변경 전 >
변경전 코드의 문제점을 요약해서 설명하자면
- 나열식 코드가 많아서 메소드 하나에 코드 내용이 너무 길었다.
- 통신 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')에 해당하는 데이터만 처리한다.
[ 개선 후기 ]
오늘은 이렇게 푸시 개선 작업을 위해
새롭게 서버를 구축하고 기존 푸시 발송 코드 부분을 개선했던 내용 중
중요하다고 생각한 부분을 정리해 보았다.
백지 상태에서 서버를 처음부터 새롭게 구성해 나가는 과정은 정말 값진 경험이었다.
"개선"의 목표는
- 기존 코드의 구조화
- 기존 코드보다 더 구조화된 형태로 재구성하여 유지보수와 확장성을 높이는 것이 목표
- DB 테이블 분리
- 기존에 하나의 테이블에서 관리되던 데이터를 (1) 푸시 발송 대상 테이블과 (2) 푸시 발송 결과 테이블로 분리하여 데이터 관리를 용이하게 하기 위함
통신사마다 방식이 다르기 때문에, 구조화를 적용하여 더 효과적인 결과를 얻을 수 있었고
테이블 하나에 발송 대상과 결과를 같이 관리했을 때 생겼던 단점들이 보완되었다.
(ex) 쿼리 성능저하, 부하, 확장성이 떨어짐
'Programming > Java & Spring 관련 내용 정리' 카테고리의 다른 글
MDC를 활용해 쓰레드 전환을 이해해보다. (4) | 2024.08.27 |
---|---|
JPA에서 @BatchSize 를 쓸 것인가 vs Fetch Join을 쓸 것인가 (0) | 2024.08.06 |
[Java] 인터페이스를 통해 if문의 향연을 고쳐보았다. (0) | 2023.09.11 |
[Java] 역직렬화로 JSON 포맷을 바꿔보았다. (0) | 2023.08.21 |
[Java] 인터페이스 사용하여 필터링 기능을 구현해 보았다. (0) | 2023.08.11 |