Skip to content

Commit

Permalink
Refactor/#34 주차장 데이터 삽입 성능개선 및 외부 api 장애 발생시 처리 로직 추가 (#86)
Browse files Browse the repository at this point in the history
* fix: 회원가입 및 비밀번호 조회시에 로그인 필요 없도록 인증코드도 추가

* feat: 주차장 batch로 insert 구현

* feat: 주차장 데이터 비동기로 읽어오도록 변경

* feat: 커낵션 타임 아웃, 리드 타임 아웃 및 실패시 재시도 로직 추가

* feat: 헬스 체크 추가

* feat: 서킷 브레이커 패턴 구현

* feat: 헬스 체크 구현 및 api 패키지로 이동

* feat: 서킷 브레이커 어노테이션 적용

* refactor: 테스트용 log 삭제

* test: thread sleep 대신 future get 사용

* fix: 테스트시 flyway 안돌도록 수정

* refactor: ExecutorService Bean으로 사용하도록 변경

* refactor: synchronized 키워드 대신 AtomicInteger 사용하도록 변경

- close 대신 open 이라는 네이밍으로 변경

* refactor: HealthCheckResponse 패키지 이동

* refactor: HealthCheckResponse 패키지 이동

* fix: 좌표계 지정

* refactor: 생성, 수정일 직접 넣도록 수정

* refactor: 운영 시간 표현 방식 변경
  • Loading branch information
This2sho authored Jun 13, 2024
1 parent 09815e8 commit a138079
Show file tree
Hide file tree
Showing 55 changed files with 1,157 additions and 263 deletions.
6 changes: 6 additions & 0 deletions app-scheduler/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// 프로메테우스 추가
implementation 'io.micrometer:micrometer-registry-prometheus'

implementation group: 'org.hibernate.orm', name: 'hibernate-spatial', version: '6.3.1.Final'

implementation 'org.springframework.boot:spring-boot-starter-aop'

implementation 'org.springframework.retry:spring-retry:2.0.6'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.parkingcomestrue.external.api;

import java.util.concurrent.atomic.AtomicInteger;

public class ApiCounter {

private final int MIN_TOTAL_COUNT;

private AtomicInteger totalCount;
private AtomicInteger errorCount;
private boolean isOpened;

public ApiCounter() {
this.MIN_TOTAL_COUNT = 10;
this.totalCount = new AtomicInteger(0);
this.errorCount = new AtomicInteger(0);
this.isOpened = false;
}

public ApiCounter(int minTotalCount) {
this.MIN_TOTAL_COUNT = minTotalCount;
this.totalCount = new AtomicInteger(0);
this.errorCount = new AtomicInteger(0);
this.isOpened = false;
}

public void countUp() {
while (true) {
int expected = getTotalCount();
int newValue = expected + 1;
if (totalCount.compareAndSet(expected, newValue)) {
return;
}
}
}

public void errorCountUp() {
countUp();
while (true) {
int expected = getErrorCount();
int newValue = expected + 1;
if (errorCount.compareAndSet(expected, newValue)) {
return;
}
}
}

public void reset() {
totalCount = new AtomicInteger(0);
errorCount = new AtomicInteger(0);
isOpened = false;
}

public boolean isOpened() {
return isOpened;
}

public void open() {
isOpened = true;
}

public boolean isErrorRateOverThan(double errorRate) {
int currentTotalCount = getTotalCount();
int currentErrorCount = getErrorCount();
if (currentTotalCount < MIN_TOTAL_COUNT) {
return false;
}
double currentErrorRate = (double) currentErrorCount / currentTotalCount;
return currentErrorRate >= errorRate;
}

public int getTotalCount() {
return totalCount.get();
}
public int getErrorCount() { return errorCount.get(); }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.parkingcomestrue.external.api;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AsyncApiExecutorConfig {

@Bean
public ExecutorService executorService() {
return Executors.newFixedThreadPool(100, (Runnable r) -> {
Thread thread = new Thread(r);
thread.setDaemon(true);
return thread;
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.parkingcomestrue.external.api;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CircuitBreaker {

int minTotalCount() default 10;
double errorRate() default 0.2;
long resetTime() default 30;
TimeUnit timeUnit() default TimeUnit.MINUTES;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.parkingcomestrue.external.api;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Slf4j
@Aspect
@Component
public class CircuitBreakerAspect {

private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(10);
private final Map<Object, ApiCounter> map = new ConcurrentHashMap<>();

@Around("@annotation(annotation)")
public Object around(ProceedingJoinPoint proceedingJoinPoint, CircuitBreaker annotation) {
ApiCounter apiCounter = getApiCounter(proceedingJoinPoint, annotation.minTotalCount());
if (apiCounter.isOpened()) {
log.warn("현재 해당 {} API는 오류로 인해 중지되었습니다.", proceedingJoinPoint.getTarget());
return null;
}
try {
Object result = proceedingJoinPoint.proceed();
apiCounter.countUp();
return result;
} catch (Throwable e) {
handleError(annotation, apiCounter);
return null;
}
}

private ApiCounter getApiCounter(ProceedingJoinPoint proceedingJoinPoint, int minTotalCount) {
Object target = proceedingJoinPoint.getTarget();
if (!map.containsKey(target)) {
map.put(target, new ApiCounter(minTotalCount));
}
return map.get(target);
}

private void handleError(CircuitBreaker annotation, ApiCounter apiCounter) {
apiCounter.errorCountUp();
if (apiCounter.isErrorRateOverThan(annotation.errorRate())) {
apiCounter.open();
scheduler.schedule(apiCounter::reset, annotation.resetTime(), annotation.timeUnit());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.parkingcomestrue.external.api;

import lombok.Getter;

@Getter
public class HealthCheckResponse {

boolean isHealthy;
int totalSize;

public HealthCheckResponse(boolean isHealthy, int totalSize) {
this.isHealthy = isHealthy;
this.totalSize = totalSize;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.parkingcomestrue.external.api;

public interface HealthChecker {

HealthCheckResponse check();
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.parkingcomestrue.external.coordinate;
package com.parkingcomestrue.external.api.coordinate;

import com.parkingcomestrue.common.domain.parking.Location;
import com.parkingcomestrue.external.coordinate.dto.CoordinateResponse;
import com.parkingcomestrue.external.coordinate.dto.CoordinateResponse.ExactLocation;
import com.parkingcomestrue.external.api.HealthChecker;
import com.parkingcomestrue.external.api.coordinate.dto.CoordinateResponse;
import com.parkingcomestrue.external.api.HealthCheckResponse;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
Expand All @@ -13,7 +14,7 @@
import org.springframework.web.util.UriComponentsBuilder;

@Component
public class CoordinateApiService {
public class CoordinateApiService implements HealthChecker {

private static final String KAKAO_URL = "https://dapi.kakao.com/v2/local/search/address.json";

Expand All @@ -32,12 +33,12 @@ public Location extractLocationByAddress(String address, Location location) {
return location;
}

ExactLocation exactLocation = getExactLocation(result);
CoordinateResponse.ExactLocation exactLocation = getExactLocation(result);
return Location.of(exactLocation.getLongitude(), exactLocation.getLatitude());
}

private ExactLocation getExactLocation(ResponseEntity<CoordinateResponse> result) {
List<ExactLocation> exactLocations = result.getBody().getExactLocations();
private CoordinateResponse.ExactLocation getExactLocation(ResponseEntity<CoordinateResponse> result) {
List<CoordinateResponse.ExactLocation> exactLocations = result.getBody().getExactLocations();
return exactLocations.get(0);
}

Expand All @@ -59,4 +60,15 @@ private boolean isEmptyResultData(ResponseEntity<CoordinateResponse> result) {
Integer matchingDataCount = result.getBody().getMeta().getTotalCount();
return matchingDataCount == 0;
}

@Override
public HealthCheckResponse check() {
UriComponents uriComponents = makeCompleteUri("health check");
ResponseEntity<CoordinateResponse> response = connect(uriComponents);
return new HealthCheckResponse(isHealthy(response), 1);
}

private boolean isHealthy(ResponseEntity<CoordinateResponse> response) {
return response.getStatusCode().is2xxSuccessful();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.parkingcomestrue.external.coordinate;
package com.parkingcomestrue.external.api.coordinate;

import com.parkingcomestrue.external.support.exception.SchedulerException;
import com.parkingcomestrue.external.support.exception.SchedulerExceptionInformation;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.parkingcomestrue.external.coordinate.dto;
package com.parkingcomestrue.external.api.coordinate.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.parkingcomestrue.external.api.parkingapi;

import com.parkingcomestrue.common.domain.parking.Parking;
import com.parkingcomestrue.external.api.HealthChecker;
import java.util.List;

public interface ParkingApiService extends HealthChecker {

default boolean offerCurrentParking() {
return false;
}

List<Parking> read(int pageNumber, int size);

int getReadSize();
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
package com.parkingcomestrue.external.parkingapi.korea;
package com.parkingcomestrue.external.api.parkingapi.korea;

import static com.parkingcomestrue.common.domain.parking.TimeInfo.MAX_END_TIME;

import com.parkingcomestrue.common.domain.parking.BaseInformation;
import com.parkingcomestrue.common.domain.parking.Fee;
Expand Down Expand Up @@ -114,7 +116,7 @@ private TimeInfo toTimeInfo(String beginTime, String endTime) {

private LocalTime parsingOperationTime(String time) {
if (time.equals(HOURS_24)) {
return LocalTime.MAX;
return MAX_END_TIME;
}
try {
return LocalTime.parse(time, TIME_FORMATTER);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package com.parkingcomestrue.external.parkingapi.korea;
package com.parkingcomestrue.external.api.parkingapi.korea;

import com.parkingcomestrue.common.domain.parking.Parking;
import com.parkingcomestrue.external.parkingapi.ParkingApiService;
import com.parkingcomestrue.external.api.CircuitBreaker;
import com.parkingcomestrue.external.api.HealthCheckResponse;
import com.parkingcomestrue.external.api.parkingapi.ParkingApiService;
import java.net.URI;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -34,26 +34,20 @@ public KoreaParkingApiService(KoreaParkingAdapter adapter,
}

@Override
public List<Parking> read() throws Exception {
Set<KoreaParkingResponse> result = new HashSet<>();
for (int pageNumber = 1; ; pageNumber++) {
KoreaParkingResponse response = call(pageNumber, SIZE);
String resultCode = response.getResponse().getHeader().getResultCode();
if (NORMAL_RESULT_CODE.equals(resultCode)) {
result.add(response);
continue;
}
break;
}
return result.stream()
.flatMap(response -> adapter.convert(response).stream())
.toList();
@CircuitBreaker
public List<Parking> read(int pageNumber, int size) {
ResponseEntity<KoreaParkingResponse> response = call(pageNumber, size);
return adapter.convert(response.getBody());
}

private KoreaParkingResponse call(int startIndex, int size) {
URI uri = makeUri(startIndex, size);
ResponseEntity<KoreaParkingResponse> response = restTemplate.getForEntity(uri, KoreaParkingResponse.class);
return response.getBody();
@Override
public int getReadSize() {
return SIZE;
}

private ResponseEntity<KoreaParkingResponse> call(int pageNumber, int size) {
URI uri = makeUri(pageNumber, size);
return restTemplate.getForEntity(uri, KoreaParkingResponse.class);
}

private URI makeUri(int startIndex, int size) {
Expand All @@ -66,4 +60,15 @@ private URI makeUri(int startIndex, int size) {
.queryParam("type", RESULT_TYPE)
.build();
}

@Override
public HealthCheckResponse check() {
ResponseEntity<KoreaParkingResponse> response = call(1, 1);
return new HealthCheckResponse(isHealthy(response), response.getBody().getResponse().getBody().getTotalCount());
}

private boolean isHealthy(ResponseEntity<KoreaParkingResponse> response) {
return response.getStatusCode().is2xxSuccessful() && response.getBody().getResponse().getHeader()
.getResultCode().equals(NORMAL_RESULT_CODE);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.parkingcomestrue.external.parkingapi.korea;
package com.parkingcomestrue.external.api.parkingapi.korea;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
Expand Down Expand Up @@ -29,6 +29,7 @@ public static class Header {
public static class Body {

private List<Item> items;
private int totalCount;

@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
Expand Down
Loading

0 comments on commit a138079

Please sign in to comment.