Skip to content

Commit 79aa8ea

Browse files
authored
feat : 장애 대응 및 재처리 로직을 적용한 고가용성 알림 시스템 구축 (#195)
* fix : mysql 드라이버 불러오지 못하는 문제 해결 - `runtimeOnly` 사용 시 실행할 때에만 classpath에 올라가므로, IDE/빌드 환경에 따라 드라이버를 찾지 못하는 경우가 발생함 * feat : MongoDB 트랜잭션 도입 및 적용 테스트 - mongo db에 트랜잭션을 도입하기 위해서 replica set 설정을 수행하고, 실제로 적용되었는지 확인하기 위해 테스트 수행 - 정상적으로 동작하는 것 확인 - 실제 배포 환경에 있는 mongo db 백업 후 replica set 적용해야 함 * refactor : 트랜잭션 관련 코드 개선 - 기존 코드에서는 @transactional 메서드 안에서 DB 저장과 알림 발송이 순차적으로 일어난다. - 알림이 발송된 직후, 커밋 단계에서 데이터베이스 문제 등 예측하지 못한 이유로 트랜잭션이 롤백된다면? - 사용자는 알림을 받았지만, 정작 DB에는 해당 알림 데이터가 존재하지 않는 유령 데이터가 발생한다. - 스프링에서 제공하는 @TransactionalEventListener를 사용하여 트랜잭션 성공 이후 알림이 발송되는 것을 보장하고, 코드의 관심사를 분리했다. * feat : 알림 데이터 저장 시 DB 장애 시를 대비하여 서킷 브레이커 패턴 적용 - 메시지를 처리하는 과정에서 DB에 장애가 발생했을 시, 처리되지 않은 메시지가 누적되어 시스템이 과부하되는 것을 방지하기 위해 서킷 브레이커 패턴을 적용했다. - Resilience4j 라이브러리 사용 - 테스트 코드를 통해 실제로 서킷 브레이커가 동작하는지 확인함 * feat : 서킷 브레이커 정상적으로 동작하도록 수정 - 서킷 브레이커에 대해 잘못 이해하고 있는 부분이 있어서 테스트 코드에 오류가 있었음 - 실제로 발생할만한 예외가 던져지도록 하고, 해당 예외가 fallback 메서드를 통해 NotificationException으로 던져지는 것을 확인하도록 함 - 새로 구현된 NotificationProcessLog document에 데이터가 있으면 서킷 브레이커 테스트 중 에러가 발생함. 추후 이 점 유의해야 함 * feat : consumer에서 메시지 처리 중 에러가 발생했을 때를 대비해 상태 기반 재처리 로직 구현 - consumer에 메시지가 들어오면 로그를 db에 적재 (PENDING) - 이후 메시지 처리 성공 여부에 따라 상태를 업데이트함 (SUCCESS, FAILD) - 실패했던 메시지들은 별도의 스케쥴러에서 다시 재시도 처리함 - 만약 특정 횟수의 시도 이후에도 실패한다면 PERMANENTLY_FAILED로 상태 변경 - 해당 메시지들은 추후 개발자가 직접 처리할 수 있도록 함 (Grafana 등 연동) * feat : 테스트 코드가 항상 성공하도록 수정 - AfterEach 추가해서 어떤 시점에 테스트를 실행해도 전부 통과할 수 있도록 함 - 그러나 DB를 매번 초기화하므로 개발, 배포 환경에 영향이 가지 않도록 disabled 해뒀음 * feat : 스크립트 shebang 추가 * feat : Mongo 트랜잭션 매니저 Bean 이름 충돌 문제 해결 - MongoDB 트랜잭션 매니저를 transactionManager라는 기본 이름으로 등록하면, JPA 등 다른 데이터소스용 기본 트랜잭션 매니저와 충돌하거나 덮어써서 애플리케이션 부팅 실패 혹은 잘못된 매니저 선택으로 이어질 수 있음 * feat : 커스텀 메시지 ID 누락 시 무한 재시도 위험 문제 해결 - null 예외 처리 추가해서 커스텀 message id 헤더가 존재하지 않는 메시지가 무한히 재시도 되는 상황을 방지함 * refact : 메시지 처리 메서드 호출 시 messageId에 해당하는 데이터가 없을 경우 로그 추가 - finishProcessing, finishProcessing 메서드의 findById()가 비어있다면 `ifPresent` 블록은 아무 일도 하지 않고 넘어감 - 개발자는 에러가 발생했다는 사실조차 모르고 넘어감 -> 조용한 실패(Silent Failure) - finishProcessing에서 로그가 없다는 것은 비즈니스적인 문제(사용자 잘못 등)가 아니라, 시스템 내부의 심각한 상태 불일치를 의미 - 따라서 NotificationException보다는 IllegalStateException이나 SystemException 같은 런타임 예외를 사용하는 것이 의미상 더 적합함 * refact : 더 이상 사용되지 않는 테스트 코드 disabled
1 parent 08fedac commit 79aa8ea

23 files changed

+909
-76
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ src/main/resources/application.properties
4343
docker-compose.dev.yml
4444
/data/
4545
/db/
46+
/replicaset
4647

4748
# 환경변수
4849
.env

build.gradle

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ dependencies {
5555

5656
// DB
5757
runtimeOnly 'com.h2database:h2'
58-
runtimeOnly 'com.mysql:mysql-connector-j'
58+
implementation 'com.mysql:mysql-connector-j'
5959

6060
annotationProcessor 'org.projectlombok:lombok'
6161

@@ -120,6 +120,10 @@ dependencies {
120120

121121
//AES 암호화
122122
implementation 'javax.xml.bind:jaxb-api:2.3.1'
123+
124+
// Circuit Breaker
125+
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
126+
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
123127
}
124128

125129
tasks.named('test') {

restart-mongodb-replica.sh

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/bin/bash
2+
3+
DATA_FILE_PATH="./replicaset"
4+
DOCKER_FILE_PATH="./docker-compose.dev.yml"
5+
MONGO_PRIMARY_NAME="rs01p"
6+
REPLICA_INIT_FILE_PATH="./scripts/rs-init.sh"
7+
MONGO_CREATE_USER_FILE_PATH="./scripts/mongo-create-user.sh"
8+
9+
UP_CONTAINER_DELAY=10
10+
REPLICA_CONFIG_DELAY=25
11+
12+
echo "****************** Reset docker container Shell Script ******************"
13+
echo "Data File Path: ${DATA_FILE_PATH}"
14+
echo "Docker File Path: ${DOCKER_FILE_PATH}"
15+
echo "MongoDB Primary name: ${MONGO_PRIMARY_NAME}"
16+
echo "Replica set init Script File Path: ${REPLICA_INIT_FILE_PATH}"
17+
echo "Mongo create user file path: ${MONGO_CREATE_USER_FILE_PATH}"
18+
19+
sleep 1;
20+
21+
echo "****************** Stop docker container ******************"
22+
docker-compose -f ${DOCKER_FILE_PATH} stop
23+
echo "****************** Completed Stop docker container ******************"
24+
25+
sleep 1;
26+
27+
echo "****************** Down docker container ******************"
28+
docker-compose -f ${DOCKER_FILE_PATH} down
29+
echo "****************** Completed Down docker container ******************"
30+
31+
sleep 1;
32+
33+
echo "****************** Remove Data ******************"
34+
rm -rf ${DATA_FILE_PATH}
35+
echo "****************** Completed Remove Data ******************"
36+
37+
sleep 1;
38+
39+
echo "****************** Up docker container ******************"
40+
docker-compose -f ${DOCKER_FILE_PATH} up -d
41+
echo "****************** Completed Up docker container ******************"
42+
43+
echo "****** Waiting for ${UP_CONTAINER_DELAY} seconds ******"
44+
sleep $UP_CONTAINER_DELAY;
45+
46+
echo "****************** Run Replica Set Shell Script ******************"
47+
docker exec -i ${MONGO_PRIMARY_NAME} bash < ${REPLICA_INIT_FILE_PATH}
48+
49+
echo "****** Waiting for ${REPLICA_CONFIG_DELAY} seconds for replicaset configuration to be applied ******"
50+
sleep $REPLICA_CONFIG_DELAY
51+
52+
echo "****************** Run Create DB User Shell Script ******************"
53+
docker exec -i ${MONGO_PRIMARY_NAME} bash < "${MONGO_CREATE_USER_FILE_PATH}"
54+
55+
echo "****************** Completed Replica Shell Script ******************"

scripts/mongo-create-user.sh

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/bin/bash
2+
3+
mongosh --port 10021 <<EOF
4+
use admin;
5+
db.createUser({
6+
user: "mongo",
7+
pwd: "mongo123",
8+
roles: [
9+
{
10+
role: "dbOwner",
11+
db: "ezcode",
12+
},
13+
],
14+
});
15+
db.getUsers();
16+
EOF

scripts/rs-init.sh

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/bin/bash
2+
3+
mongosh --port 10021 <<EOF
4+
use ezcode;
5+
var config = {
6+
"_id": "rs01",
7+
"version": 1,
8+
"members": [
9+
{
10+
"_id": 1,
11+
"host": "rs01p:10021",
12+
"priority": 2
13+
},
14+
{
15+
"_id": 2,
16+
"host": "rs01s:10022",
17+
"priority": 1
18+
},
19+
{
20+
"_id": 3,
21+
"host": "rs01a:10023",
22+
"priority": 0
23+
}
24+
]
25+
};
26+
rs.initiate(config);
27+
rs.status();
28+
db.setProfilingLevel(2);
29+
db.getProfilingStatus();
30+
EOF

src/main/java/org/ezcode/codetest/application/notification/exception/NotificationException.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,13 @@ public NotificationException(ResponseCode responseCode, String message) {
2424
this.httpStatus = responseCode.getStatus();
2525
this.message = responseCode.getMessage() + " : " + message;
2626
}
27+
28+
public NotificationException(ResponseCode responseCode, Throwable cause, String message) {
29+
super();
30+
31+
this.responseCode = responseCode;
32+
this.httpStatus = responseCode.getStatus();
33+
super.initCause(cause);
34+
this.message = message;
35+
}
2736
}

src/main/java/org/ezcode/codetest/application/notification/exception/NotificationExceptionCode.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ public enum NotificationExceptionCode implements ResponseCode {
1212

1313
NOTIFICATION_CANNOT_FIND_EVENT_TYPE(false, HttpStatus.INTERNAL_SERVER_ERROR, "해당 이벤트 타입의 mapper를 찾을 수 없습니다."),
1414
NOTIFICATION_CONVERT_MESSAGE_ERROR(false, HttpStatus.INTERNAL_SERVER_ERROR, "메시지 변환 과정에서 에러가 발생했습니다."),
15-
NOTIFICATION_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 ID의 notification 데이터를 찾지 못했습니다")
15+
NOTIFICATION_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 ID의 notification 데이터를 찾지 못했습니다"),
16+
NOTIFICATION_DB_ERROR(false, HttpStatus.INTERNAL_SERVER_ERROR, "알림 데이터 저장 중 문제 발생. 서킷 브레이커가 열렸습니다.")
1617
;
1718

1819
private final boolean success;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.ezcode.codetest.infrastructure.mongo.config;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.data.mongodb.MongoDatabaseFactory;
6+
import org.springframework.data.mongodb.MongoTransactionManager;
7+
8+
@Configuration
9+
public class MongoTransactionConfig {
10+
11+
@Bean(name = "mongoTransactionManager")
12+
public MongoTransactionManager transactionManager(MongoDatabaseFactory mongoDatabaseFactory) {
13+
return new MongoTransactionManager(mongoDatabaseFactory);
14+
}
15+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package org.ezcode.codetest.infrastructure.notification.event;
2+
3+
import org.ezcode.codetest.infrastructure.notification.dto.NotificationResponse;
4+
5+
public record NotificationSavedEvent(
6+
7+
String principalName,
8+
9+
NotificationResponse response
10+
11+
) {
12+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package org.ezcode.codetest.infrastructure.notification.model;
2+
3+
import java.time.LocalDateTime;
4+
5+
import org.springframework.data.mongodb.core.mapping.Document;
6+
7+
import org.springframework.data.annotation.Id;
8+
import lombok.AccessLevel;
9+
import lombok.AllArgsConstructor;
10+
import lombok.Getter;
11+
import lombok.NoArgsConstructor;
12+
13+
@Document(collection = "notification_process_log")
14+
@Getter
15+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
16+
@AllArgsConstructor
17+
public class NotificationProcessLog {
18+
19+
@Id
20+
private String messageId;
21+
22+
private String payload;
23+
24+
private ProcessStatus status;
25+
26+
private int retryCount;
27+
28+
private String errorMessage;
29+
30+
private LocalDateTime lastAttemptAt;
31+
32+
private LocalDateTime createdAt;
33+
34+
public enum ProcessStatus {
35+
PENDING, SUCCESS, FAILED, PERMANENTLY_FAILED
36+
}
37+
38+
public static NotificationProcessLog of(String messageId, String payload) {
39+
40+
return new NotificationProcessLog(
41+
messageId,
42+
payload,
43+
ProcessStatus.PENDING,
44+
0,
45+
null,
46+
LocalDateTime.now(),
47+
LocalDateTime.now()
48+
);
49+
}
50+
51+
public void markAsSuccess() {
52+
this.status = ProcessStatus.SUCCESS;
53+
this.lastAttemptAt = LocalDateTime.now();
54+
this.errorMessage = null;
55+
}
56+
57+
public void markAsFailed(String errorMessage, int maxRetries) {
58+
this.retryCount++;
59+
this.lastAttemptAt = LocalDateTime.now();
60+
this.errorMessage = errorMessage;
61+
62+
if (this.retryCount >= maxRetries) {
63+
this.status = ProcessStatus.PERMANENTLY_FAILED;
64+
} else {
65+
this.status = ProcessStatus.FAILED;
66+
}
67+
}
68+
69+
public void updateLastAttempt() {
70+
this.lastAttemptAt = LocalDateTime.now();
71+
}
72+
}

0 commit comments

Comments
 (0)