Toss Slash 24 - 대규모 사용자 기반 서비스 운영 - (1)
이전 글에서 toss slash24에서 인상 깊었던 세션에 대해서 글을 적었었는데, 이를 실제로 간단한 코드로 구현해보고자 한다. 전체 세션 내용을 모두 적용하기에는 무리가 있어, 전체 중 '장애대응' 부분에 한해서 살펴보고자 한다.
핵심은 Coordinator 서버를 두어 api 제공기관 시스템 트래픽을 자동으로 제어하고자 하는 것이다.
기존 resilience4j를 사용했을 때는 서버 단위로 서킷 브레이커가 적용되어 차단 메커니즘에 왜곡이 있어, 전체 인스턴스 단위로 서킷 브레이커를 적용하자는 취지다. 추가하여 canary 배포 방식에 맞춰 처음과 끝 인스턴스 2대를 master 서버로 설정해 api 호출이 가능한지 지속 체크해준다.
배포 방식에 따라 master 서버를 설정해 api 호출이 정상인지 확인하는 부분은, 축소하여 인스턴스 2대만을 두어 1대를 서버 체크 용도로 활용하고자 한다.
구현의 중점은 다음과 같다. 서버는 로컬에서 총 4대(target, coordinator, source1, source2)를 멀티 모듈로 구성한다. api 제공 기관을 target-server, 코디네이터 서버는 말그대로 coordinator-server, api를 직접 호출하는 서버는 source1,2로 2대를 구성한다.
target-server는 단순히 api 호출에 대하여 응답하는 구조로 구성한다. 다만 호출이 간헐적으로 실패하거나, 서버 장애로 요청 자체를 처리하지 못하는 상태를 재현하기 위해 응답 실패률을 관리하는 failureRate, 차단되었는지 확인하는 block 변수를 두었다.
coordinator-server는 source 서버 n대에 대해 serverId를 등록하는 /establish, 제공 기관 api가 정상 동작하는지 확인 후 통지하는 /heartbeat 엔드포인트를 두고, source 서버에 대해 서킷을 open or close 하는 기능을 둔다. api 호출의 성공/실패 결과값은 배치 통지하지는 않고, coordinator 서버에 즉시 통지하는 방식으로 구현한다.
source-server는 target api 호출, coordinator 서버에 establish 통지, heartbeat 통지, api 호출 성공/실패 통지 기능을 구현한다.
우선은 각 서버를 멀티 모듈로 구성하고 target-server api, source-server의 coordinator 등록 및 인터페이스를 위한 client, api 호출, coordinator 서버의 establish를 구현했다.
source 서버가 api를 정상 호출하는지 heartbeat 체크하는 것과 api 호출 성공/실패 통지, coordinator 서버의 circuit open/close 기능은 추후 구현하고자 한다. 구현 중에 든 생각은 resilience4j, aop를 이용할 때 어노테이션 기반으로 간단하게 설정만 하면 서버 단위로 서킷브레이커를 간단하게 구현할 수 있는데, 시스템을 구성하면서까지 해야 할 규모인가?에 대한 의문이 들기는 하였다. 트래픽이 매우 많고 api 제공 기관이 우리 서버의 api 호출로 인해 과부하나 장애가 많이 발생하는 상황이라면 할 필요가 있겠지만, 그런 상황이 아니라면 오버 엔지니어링이 아닌가 하는 생각도 들기는 하였다.
target-server
@RestController
@RequestMapping("/api/target")
@Slf4j
public class TargetController {
private final Random random = new Random();
private double failureRate = 0.2;
private volatile boolean blockAll = false;
@GetMapping
public ResponseEntity<String> callTarget() {
double randomValue = random.nextDouble();
log.info("target api called, random: {}", randomValue);
if (blockAll || random.nextDouble() < failureRate) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body("fail");
}
return ResponseEntity.ok("target api called success");
}
public void setBlockAll(boolean block) {
this.blockAll = block;
}
}
source-server
@Component
@Slf4j
@RequiredArgsConstructor
public class CoordinatorConfig {
private final CoordinatorClient coordinatorClient;
@PostConstruct
public void registerWithCoordinator() {
coordinatorClient.register()
.doOnNext(id -> {
ServerInfoConfig.setServerId(id);
log.info("Registered with Coordinator, serverId: {}", id);
})
.subscribe();
}
}
@Component
@Setter
public class ServerInfoConfig {
private static String serverId;
public static void setServerId(String id) {
serverId = id;
}
public static String getServerId() {
return serverId;
}
}
@RestController
@RequestMapping("/api/source")
@RequiredArgsConstructor
public class SourceController {
private final TargetApiService targetApiService;
@GetMapping("/call-target")
public Mono<String> call() {
return targetApiService.call();
}
}
@Service
@Slf4j
public class TargetApiService {
private final WebClient webClient;
public TargetApiService(WebClient.Builder builder,
@Value("${target.server.url}") String targetServerUrl) {
this.webClient = builder.baseUrl(targetServerUrl).build();
}
public Mono<String> call() {
//TODO 호출 실패 시 coordinator 서버에 성공/실패 여부 반환
Mono<String> result = webClient.get()
.uri(uriBuilder -> uriBuilder
.queryParam("serverId", ServerInfoConfig.getServerId())
.build())
.retrieve()
.bodyToMono(String.class);
log.info("target call result(id:{}): {}", ServerInfoConfig.getServerId(), result);
return result;
}
}
@Service
public class CoordinatorClient {
private final WebClient webClient;
public CoordinatorClient(WebClient.Builder builder,
@Value("${coordinator.server.url}") String coordinatorServerUrl) {
this.webClient = builder.baseUrl(coordinatorServerUrl).build();
}
public Mono<String> register() {
return webClient.post()
.uri("/establish")
.retrieve()
.bodyToMono(String.class);
}
}
coordinator-server
@Getter
@Setter
@ToString
public class SourceServerInfo {
private String serverId;
private long registrationTime;
private long lastHeartbeat;
public SourceServerInfo(String serverId, long registrationTime) {
this.serverId = serverId;
this.registrationTime = registrationTime;
this.lastHeartbeat = registrationTime;
}
}
@RestController
@RequestMapping("/api/coordinator")
@Slf4j
public class CoordinatorController {
private final Map<String, SourceServerInfo> serverRegistry = new ConcurrentHashMap<>();
private final Collection<String> openCircuits = ConcurrentHashMap.newKeySet();
@PostMapping("/establish")
public String establish() {
String serverId = "source-" + UUID.randomUUID();
SourceServerInfo info = new SourceServerInfo(serverId, System.currentTimeMillis());
serverRegistry.put(serverId, info);
log.info("Source server established: {}", info);
return serverId;
}
@PostMapping("/heartbeat")
public String heartbeat(@RequestParam String serverId) {
SourceServerInfo info = serverRegistry.get(serverId);
if (Objects.isNull(info)) {
log.info("ServerId not registered");
return "ServerId not registered";
}
info.setLastHeartbeat(System.currentTimeMillis());
log.info("Heartbeat received from {}", serverId);
return "Heartbeat successfully received";
}
}
'java' 카테고리의 다른 글
json 역직렬화 성능 측정(feat. jackson, gson, JSONObject) - 2 (0) | 2025.01.20 |
---|---|
json 역직렬화(feat. jackson) (0) | 2025.01.12 |
중복 거래 방지를 위한 uuid 생성(UUIDv7, time-based uuid) (0) | 2024.11.11 |
동시성 이슈 해결하기(feat. spring(java), MySQL, Redis(Redisson)) (0) | 2024.06.24 |
LocalDateTime, Instant, OffsetDateTime, ZonedDateTime 사용법 차이 (0) | 2024.05.19 |