1. 시작하면서
이번에는 그래도 오랜만이 아니네요...?ㅋㅋㅋㅋㅋ 최근에는 뭔가 개발속도가 빨라지도 버그 수정도 빨라지는 느낌이 나는 듯합니다. 이제 실무에 투입된지 거의 1년 정도되었으니 당연하다면 당연할 수도 있지만 그래도 왠지 모르게 신기하기도 합니다ㅎ
동시에 이전에 작성된 코드들을 보면서...우와 이걸 대체 왜 이렇게 짰지? 라는 생각도 들고, 이걸 대체 언제 고치지...? 라는 생각도 듭니다. 언젠가는 전체적인 리팩토링이 필요한데 이게 참 벌써부터 두려워요ㅠㅠ
그렇지만 제가 늘 하던대로 일단 기능부터 특히 제가 해보고 싶었던 기능들을 이것저것 마구마구 넣어볼 생각입니다. 일단 다 때려박고...지금부터 짜는 코드라도 어떻게 잘 짜면 나중에 할 일이 줄어들지 않을까? 하는 바램입니다.
이번에도 그런 사심을 잔뜩 담아서 접속 통계 모니터링 기능과 blacklist 차단 기능 개발을 완료했습니다. 물론 해당 기능은 여기서 마무리가 아닌 1차로 개발된 기능들이고 추가로 개발할 사항도 더 있습니다.
추가로 이번에는 단순히 java 개발 뿐만 아니라 쿠버네티스와 prometheus 의 promeQL 을 이용한 쿼리, Grafana 의 기능들 정말 다양한 기능을 활용했기에 하나의 포스팅으로 정리하기는 어려울 듯하고, 크게 spring server 와 kubernetes service 2가지로 나눠서 글을 작성할 예정입니다.
개발 완료
- Client 접속 분석 및 prometheus 와 grafana 를 이용한 모니터링 시스템 개발
- 딱 저 지도와 워드 클라우드는 직접 promeQL 과 Grafana 를 이용해 만든...대시보드이다! 이쁘다! 멋지다!ㅋㅋㅋㅋㅋㅋㅋ
- blacklist 기반 위험한 접속 정보 차단 기능
2 - 1) Prometheus 와 Grafana 란?
Prometheus는 오픈 소스 시스템 모니터링 및 경고 도구로, 서버, 애플리케이션, 컨테이너 등 다양한 환경에서 데이터를 수집하고 저장한 다음 이 데이터를 쿼리하고 경고를 생성하는 데 사용된다. 이러한 특징들로 인해 Prometheus는 애플리케이션의 성능 및 가용성을 실시간으로 모니터링하는데 특히 유용하다
Grafana는 데이터 시각화 및 대시보드 도구로, Prometheus와 같은 데이터 소스에서 데이터를 가져와 각종 그래프, 차트 및 대시보드를 만들고 시각화하는 도구이다. 예를들어 Grafana 와 Prometheus 를 함께 사용하면 Prometheus에서 수집한 데이터를 직관적으로 표현하고 사용자 정의 대시보드를 구성하여 모니터링 및 분석에 활용할 수 있다. 또한 Grafana의 대시보드 기능을 통해 여러 데이터 소스를 시각적으로 통합할 수 있으며, 경고 규칙을 설정하여 문제 상황을 신속하게 감지하고 대응 가능하다.
이 두 개의 서버는 Kubernetes 에 올라가 있으며 이를 kubernetes 에 배포하는 방법은 다음 포스팅에 정리하도록 하겠다.
2 - 2) Actuator 가 뭐야?
Spring Boot Actuator는 Spring Boot 기반 애플리케이션의 운영 및 모니터링을 지원하기 위한 라이브러리로 이를 통해 애플리케이션의 상태를 측정하고 모니터링할 수 있다. 특히 해당 기능을 통해 애플리케이션을 더 효율적으로 관리하고 문제를 더 빨리 식별하고 해결할 수 있다고 한다. 주요한 기능은 아래 4가지 이다.
1. Endpoint 제공: Spring Boot Actuator는 다양한 엔드포인트(Endpoint)를 제공한다. 이 엔드포인트를 통해 애플리케이션의 다양한 정보에 접근할 수 있다. 기본으로 제공해주는 대표적인 endpoint 는 아래와 같다.
- /actuator/health: 애플리케이션의 건강 상태를 확인할 수 있는 엔드포인트.
- /actuator/info: 사용자 정의 정보를 제공할 수 있는 엔드포인트.
- /actuator/metrics: 메트릭 데이터(예: CPU 사용률, 메모리 사용량)를 확인할 수 있는 엔드포인트.
- /actuator/env: 애플리케이션 환경 속성을 확인할 수 있는 엔드포인트.
2. 메트릭 수집: Actuator는 애플리케이션의 메트릭 데이터를 수집하고 노출한다. 이러한 메트릭은 Prometheus, Grafana 등과 통합하여 시각화가 가능하다.
3. 보안: Actuator 엔드포인트에 대한 보안 설정을 지원합니다. 이를 통해 민감한 정보를 노출하지 않고도 모니터링 기능을 안전하게 사용할 수 있습니다.
4. 사용자 정의 엔드포인트: 필요한 경우 사용자 정의 엔드포인트를 만들 수 있다. 이를 통해 애플리케이션에 특정한 비즈니스 로직을 노출하고 관리 가능!
5. 애플리케이션 정보 제공: /actuator/info 엔드포인트를 통해 애플리케이션의 사용자 정의 정보(예: 버전 정보, 환경 설정)를 노출할 수 있다.
6. 예외 및 로그 수집: Actuator를 사용하여 애플리케이션의 예외 정보와 로그 메시지를 확인할 수 있다.
3. SpringBoot 환경 구성
기능 추가를 위해 gradle 에 아래 3가지를 추가하자.
- actuator 와 prometheus 는 모니터링을 위한 라이브러리이고, geoip 는 clientIP 기준으로 접속 정보를 분석하기 위한 라이브러리 이다.
- 특히 geoip 의 경우 단순히 라이브러리를 추가하는 것으로 끝나는 것이 아니라 해당 사이트 MaxMind 에 가입하여 GeoLite2-City.mmdb 를 다운받아서 사용해야한다. github 의 코드에는 당연히 해당 코드가 미포함되어있다.
// 스프링 부트 모니터링을 위한 엔트포인트 활성화
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// 프로메테우스 라이브러리
// https://mvnrepository.com/artifact/io.micrometer/micrometer-registry-prometheus
implementation 'io.micrometer:micrometer-registry-prometheus:1.11.3'
// geoip 라이브러리
// https://mvnrepository.com/artifact/com.maxmind.geoip2/geoip2
implementation 'com.maxmind.geoip2:geoip2:4.0.1'
또한 application.properties 에 아래 설정을 추가하여 endpoint 를 활성화 한다.
## endpoint active
management.endpoints.web.exposure.include=*
4. Actuator Endpoint 활성화 및 접근 제한
Actuator Enpoint 는 현재 내 애플리케이션에 대한 굉장히 많은 정보를 보여준다. 단순하게는 uptime 이나 cpu 및 메모리 사용량부터 내가 직접 설정한 prometheus endpoint 의 정보까지도 보여지게 된다.
그렇기에 당연하게도 외부에서 함부로 접근이 가능하면 안되고, 특정 사용자 혹은 특정 IP 만 접근을 허락하여야 한다. 특히 이 작업이 필요한 이유는 관리자가 직접 endpoint 에 접근하는 경우뿐만 아니라 prometheus 에서 애플리케이션 endpoint 에 접근을 허용해주어야 하기 때문이다.
0) application.properties
- 가장 먼저 해야할 것은 application.properties 에서 endpoint 에 접근을 허용하는 ip 와 CIDR 을 적어두는 것!!
- 이때 중요한 것은 allowed_ip_addresses 는 정말 딱 ip 를 적어두고, CIDR 형식의 IP 주소 범위를 적어두어야한다.
## allow subnet for endpoint
endpoint.allowed_subnet=192.168.0.0/24, [CIDR 추가]
endpoint.allowed_ip_addresses=127.0.0.1, 0:0:0:0:0:0:0:1
1) SecurityCofnig
- 스프링에서 외부의 접근을 제한하는 가장 좋은 방법은 역시 springSecutiry 를 활용하는 방법일 것이다. 따라서 SecurityConfig 클래스에 actuator 경로에 접근 가능한 ip 와 subnet 을 설정한다.
- 여기서 가장 중요한 부분은 getIPccessControl() 이다. 이 메서드는 application.properties 에 설정된 CIDR 과 ipaddress 를 하나로 합쳐서 secutiry 의 .access 에 적합한 문법으로 바꿔준다.
- 뭔말이냐면 antMatchers 로 "/actuator/**" 에 접근가능한(access) 아이피를 아래처럼 일종의 리스트로 줄 수 있다. 즉 allowedSubnet 과 allowedIpAddresses 의 두가지 리스트를 하나로 합쳐서 아래처럼 hasIpAddress(IP) 로 만드는 것이다.
hasIpAddress("192.168.0.1") or hasIpAddress("127.0.0.1")
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final PrincipalOauth2UserService principalOauth2UserService;
@Value("${endpoint.allowed_subnet}")
private List<String> allowedSubnet;
@Value("${endpoint.allowed_ip_addresses}")
private List<String> allowedIpAddresses;
// Security 를 이용한 각종 권한 접근 경로 등 설정
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
// /actuator/** 경로에 대한 접근을 제한
.antMatchers("/actuator/**")
.access(getIpAccessControl())
.antMatchers("/**").permitAll()
.and()
.exceptionHandling()
.accessDeniedHandler((request, response, accessDeniedException) -> {
if (request.getRequestURI().startsWith("/actuator")) {
throw new ExceptionController.AccessDeniedException("access denied");
} else {
response.sendRedirect("/chatlogin");
}
})
[ 중략 ]
}
private String getIpAccessControl() {
allowedSubnet.addAll(allowedIpAddresses);
return allowedSubnet
.stream()
.map(ip -> "hasIpAddress('" + ip + "')")
.collect(Collectors.joining(" or "));
}
}
2) ClientCheckSericeImpl
- ClientcheckServiceImpl 의 기능을 크게 2가지이다. 첫번째는 actuator endpoint 에 접근 가능한 '허가된 IP 및 Subnet' 체크하는 것과 두번째는 블랙리스트 유저를 체크하는 것이다.
- 블랙리스트에 대한 부분은 아래에 다시 설명 할 것이고, 이번에는 허가된 IP, subnet 인지를 체크하는 부분을 보자.
- 사실 크게 어려운 부분은 없는데, checkIsAllowIp 는 ip 를 매개변수로 받는다. 이후 가장 먼저 해당 아이피가 allowedIpAddresses 에 포함되는지를 확인하고 만약 여기에 포함되지 않는다면 isInRange 메서드가 동작한다.
- isInRange 는 allowedSubnet 의 내용을 for문으로 돌리면서 cidr 과 ip 정보와 함께 매개변수로 받는다. 이후 해당 ip 가 CIDR 에 속하는지 확인한다. 당연히 true 인 경우 endpoint 에 접근이 가능하고, 아니라면 접근이 불가능하고 403 페이지로 이동한다.
@Service
@RequiredArgsConstructor
public class ClientCheckServiceImpl implements ClientCheckService {
private static final Logger log = LoggerFactory.getLogger(ClientCheckServiceImpl.class);
private final String blackListJsonPath = "geodata/firehol_level1.txt";
@Value("${endpoint.allowed_subnet}")
private List<String> allowedSubnet;
@Value("${endpoint.allowed_ip_addresses}")
private List<String> allowedIpAddresses;
// CIDR 서브넷 체크 로직을 별도의 메소드로 분리
@Override
public Boolean checkIsAllowedIp(String ip) {
if (allowedIpAddresses.contains(ip)) {
return true;
}
for (String cidr : allowedSubnet) {
try {
if (isInRange(cidr, ip)) {
return true; // 일치하는 경우 즉시 반환
}
} catch (UnknownHostException e) {
e.printStackTrace(); // 에러 로깅
throw new ExceptionController.AccessDeniedException("Unknow Host");
}
}
return false; // 일치하는 CIDR이 없는 경우
}
/**
* cidr 에 ip 가 속해있는지 검사
* @param ip
* @param cidr
* @return
* @throws UnknownHostException
*/
private boolean isInRange(String cidr, String ip) throws UnknownHostException {
String[] parts = cidr.split("/");
String ipSection = parts[0];
int prefix = (parts.length < 2) ? 0 : Integer.parseInt(parts[1]);
InetAddress ipAddr = InetAddress.getByName(ip);
BitSet ipBits = BitSet.valueOf(ipAddr.getAddress());
InetAddress networkAddr = InetAddress.getByName(ipSection);
BitSet networkBits = BitSet.valueOf(networkAddr.getAddress());
int maxLength = Math.max(ipBits.length(), networkBits.length());
if (maxLength < prefix) {
maxLength = prefix; // CIDR 접두사 길이가 더 긴 경우
}
ipBits.clear(prefix, maxLength);
networkBits.clear(prefix, maxLength);
return ipBits.equals(networkBits);
}
}
5. GeoLite2-City 를 활용한 Client 의 접속 정보 분석 및 metric 에 정보 저장
springboot 에서 접속자의 IP 를 분석하기 위해서는 request 객체의 request.getRemoteAddr() 를 활용한다. 사실 코드상으로는 단순히 HandlerInterceptor 를 사용하여 요청을 캐치한 후 request 를 확인하면 될 일이었다. 문제는 '언제' 클라이언트의 접속 정보를 캐치해서 사용하냐였다.
나에게 중요한 것은 모든 요청에 대해 일일히 prehandler 에서 검증 한 후 metric 에 저장해서 정보를 활용하는게 아닌 1회성으로만 정보를 수집할 예정이었기에 모든 요청에 대해서 일일히 확인할 필요는 없었다. 따라서 웹 접속 시 유저에게 정보 이용 동의를 알리는 팝업을 띄운 후 '동의하기' 버튼을 눌렀을 대 이를 활용하도록 개발하였다.
1) MonitoringConfig
- Interceptor 를 사용하기 위한 config 설정. 이때 addInterceptor 에 monitoringServiceImpl 을 넣어두고, PathPatterns 에는 /user_agree 를 넣어둔다. user_agree 는 웹에서 '동의하기' 버튼이 클릭되면 호출되는 api 주소이다.
// 인터셉터를 위한 config 설정
// HandlerInterceptor 를 사용하기 WebMvcConfigurer 를 구현한 클래스에 registry 에
// intercepter 하려는 클래스를 등록해야한다.
@Configuration
@RequiredArgsConstructor
public class MonitoringConfig implements WebMvcConfigurer {
private final MonitoringServiceImpl monitoringServiceImpl;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// addInterceptors 의 파라미터에는 HandlerInterceptor 를 구현한 구현체 클래스를 넣는다
// addPathPatterns 는 특정한 패턴 즉 특정한 요청에 대해서만 인터셉터 가능!
registry.addInterceptor(monitoringServiceImpl)
.addPathPatterns("/user_agree");
}
}
2) ClientInfo
- GeoIP 를 통해 분석된 client 정보를 저장하는 clientInfo 클래스.
- 이 클래스에는 client 의 각종 정보를 담기위한 변수들과 해당 객체를 Prometheus 에서 사용가능한 tag : value 로 이루어진 값들로 변환하기 위한 메서드로 구성된다.
- 특히 isBlack 의 경우 해당 유저가 blacklist 에 해당하는 유저인지 아닌지 체크하는 용도로도 활용하며 이는 grafana 대시보드에서도 추가적으로 활용된다.
@Data
@Builder
public class ClientInfo {
String ipAddr;
String subnet;
String country;
// String city;
String countryCode;
Double latitude;
Double longitude;
String timeZone;
String continentCode;
boolean isBlack;
/**
* clientinfo 의 내용을 prometheus 에서 사용가능한 tag : value 로 변경
* @param clientInfo
* @return tag list
*/
public static List<Tag> toPrometheusMetric(ClientInfo clientInfo){
List<Tag> tags = Arrays.asList(
Tag.of("ipAddr", clientInfo.getIpAddr()),
Tag.of("subnet", clientInfo.getSubnet()),
Tag.of("country", clientInfo.getCountry()),
Tag.of("countryCode", clientInfo.getCountryCode()),
Tag.of("Latitude", clientInfo.getLatitude().toString()),
Tag.of("Longitude", clientInfo.getLongitude().toString()),
Tag.of("timeZone", clientInfo.getTimeZone()),
Tag.of("continentCode", clientInfo.getContinentCode()),
Tag.of("isBlack", String.valueOf(clientInfo.isBlack()))
);
return tags;
}
}
3) MonitoringServiceImpl
- 사실상 가장 중요한 메인이 되는 부분으로 GeoLite2-city.mmdb 내용을 가져와서 ipAddress 정보를 기준으로 clientInfo 객체를 생성하는 부분이다.
- DatabaseReader 는 maxmind.geoip2 라이브러리에 포함된 클래스로 GeoLite2-city.mmdb 의 정보를 활용할 수 있게 한다.
- 사실 country 나 city 나 가져올 수 있는데이터는 대동소이하다. 따라서 뭘 사용할지는 본인 마음!!
@Override
public ClientInfo getClientInfoByAddrs(String ipAddress) {
try {
InetAddress inetAddress = InetAddress.getByName(ipAddress);
// ClassPathResource countryResource = new ClassPathResource("geodata/GeoLite2-Country.mmdb");
// ClassPathResource asnResource = new ClassPathResource("geodata/GeoLite2-ASN.mmdb");
ClassPathResource cityResource = new ClassPathResource("geodata/GeoLite2-City.mmdb");
// DatabaseReader cityDataBaseReader = new DatabaseReader.Builder(cityResource.getFile()).build();
DatabaseReader cityDataBaseReader = null;
try (InputStream cityResourceInputStream = cityResource.getInputStream()) {
cityDataBaseReader = new DatabaseReader.Builder(cityResourceInputStream).build();
}
Optional<CityResponse> client = cityDataBaseReader.tryCity(inetAddress);
if(client.isPresent()){
CityResponse info = client.get();
return ClientInfo.builder()
.ipAddr(info.getTraits().getIpAddress())
.subnet(info.getTraits().getNetwork().toString())
.country(info.getCountry().getNames().get("en"))
.countryCode(info.getCountry().getIsoCode())
.latitude(info.getLocation().getLatitude())
.longitude(info.getLocation().getLongitude())
.timeZone(info.getLocation().getTimeZone())
.continentCode(info.getContinent().getCode())
.build();
}
return null;
} catch (Exception e) {
// return ClientInfo.builder().build();
throw new ExceptionController.AccessForbiddenException("can not find ipAddrs");
}
}
6. BlackList 확인 및 차단
사실 블랙리스트를 만들어서 차단하는 것은 동적으로 해야 의미가 있다. 즉 이미 있는 특정 IP 를 차단하는 것 뿐만 아니라 실제로 공격이 들어왔을 때 해당 IP 를 확인하고, 블랙리스트로 등록한 후 추후 활용할 수 있어야한다.
다만 아쉽게도 현재 단계에서는 이 정도까지 개발하지 못 하였고, 앞전에 이야기했듯 FireHol 이라는 사이트에서 제공하는 subent 을 블랙리스트로 등록한 후 접속자의 Subnet 와 비교해 블랙리스트에 해당한다면 차단하는 방법을 사용했다.
1) ClientCheckServiceImpl
- blackListJsonPath 에 firehol_level1.txt 파일의 경로를 저장하는 변수이다.
- @cacheable 은 springboot 에서 제공하는 어노테이션으로 해당 메서드의 결과를 캐싱하여 동일한 인자로 호출될 때 이전 결과를 반환하는 데 사용된다. 즉, postconstruct 를 사용해서 ClientCheckServiceImpl 의 Bean 이 등록된 후 postconstruct 를 사용해서 blackListJsonPath 를 한번 불러놓으면 추후 chckBlackList 에서 clientInfo 안에서 subnet 를 가져와서 비교할 때는 파일을 다시 읽는 대신 캐쉬된 데이터를 가져오기 때문에 성능 및 속도 향상을 노릴 수 있다.
- checkAllowedIp 는 앞서 설명했듯 actuator endpoint 에 접근 가능한 아이피 리스트를 검증한다.
- isInRange 는 서브넷과 ip 주소를 던져주면 해당 ip 주소가 서브넷에 속하는지 여부를 boolean 값으로 return 해준다.
@Service
@RequiredArgsConstructor
public class ClientCheckServiceImpl implements ClientCheckService {
private static final Logger log = LoggerFactory.getLogger(ClientCheckServiceImpl.class);
private final String blackListJsonPath = "geodata/firehol_level1.txt";
@Value("${endpoint.allowed_subnet}")
private List<String> allowedSubnet;
@Value("${endpoint.allowed_ip_addresses}")
private List<String> allowedIpAddresses;
@PostConstruct
private void initBlackListJson() {
this.blackListJson(blackListJsonPath);
}
@Override
public Boolean checkBlackList(ClientInfo clientInfo) {
List<String> blackList = blackListJson(blackListJsonPath);
log.debug("##########################################");
log.debug("clientInfo :::: " + clientInfo.toString());
log.debug("##########################################");
log.debug("##########################################");
log.debug("blackList ::: " + blackList.toString());
log.debug("##########################################");
boolean isBlack = blackList.stream().anyMatch(black -> {
return clientInfo.getSubnet().equals(black);
});
if (isBlack) {
clientInfo.setBlack(true);
}
return isBlack;
}
[ 중략 ]
@Cacheable("blackList")
public List<String> blackListJson(String path) {
try {
// classpath 로 blackList txt 파일 가져오기
ClassPathResource blackList = new ClassPathResource(path);
log.debug("blackList URI :: " + blackList.getURI());
try (InputStream inputStream = blackList.getInputStream()) {
return new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))
.lines()
.collect(Collectors.toList());
}
} catch (Exception e) {
log.error("error path :: " + path);
throw new ExceptionController.ResourceNotFoundException("there is No BlackList file");
}
}
}
아쉬운 점 & 추후 계획
먼저 첫째로 모니터링에 대한 부분이다. 이번 기능 개발을 통해 결국 화상채팅 기능+모니터링 기능이 한번에 들어간 형태의 웹 서비스가 되었다. 나는 아키텍쳐 설계를 배운 적은 없지만 당연히 하나의 웹 서비스에 두 가지 기능이 모두 들어간 것은 결코 좋을리가 없다는 것도 알고있다. 특히 모니터링은 꾸준히 내용을 저장하고 관리하는 만큼 추후 성능상 어떤 이슈가 발생할지 알 수가 없다. 더해서 단순히 웹 접속 모니터링 뿐만 아니라 로그 모니터링 이라도 하게 된다면 더욱 더 화상 채팅 서버 + 모니터링 서버 로 분리가 필요하다는 것을 느낀다.
두번째로는 블랙리스트 차단에 대한 부분이다. 이 부분은 블랙리스트 아이피를 동적으로 저장하고 차단하지 못하는 점이나 redis 가 아닌 @Cacheable 로 대체한 점이 너무나도 아쉬웠다. 물론 내가 원하는 기능을 개발하는 것이 결코 쉽지 않다는 점을 알고 있기에 이번에는 이렇게 맛? 만 보는 정도에서 마무리했지만 추후 추가 기능 개발작업에 들어가면 반드시! 개선하고 싶다.
마무리하며
이번 프로젝트는 아쉬움이 많이 남았다. 동시에 아쉬움이 남기에 더욱 좋은 프로젝트였지 않나 하는 생각도 든다. 왜냐하면 아쉽다는 건 결국 더 개선하고 발전할 여지가 있다는 것이기 때문이다. 개발하면서 이걸 나중에 이렇게 바꿀 수도 있지 않을까? 이렇게 변경할 수 있지 않을까하면서 계획하고 설계하는 나의 모습을 보면서 신기하기도 하고 우습기도 했던 것 같다. 동시에 이렇게 단순하게 하나하나 하는 것들이 나의 개발 역량에 얼마나 도움이 될지는 모르겠다. 때로는 "사실 이것들이 아무 의미 없는게 아닐까?" 라는 생각이 강하게 들 때도 있다.
개발은 아주 단순한 것이고, 또 아주 복잡한 것이다.
때문에 우직하게 자신을 쌓아가는 사람만이 살아남는다.
라는 이야기를 예전에 개발자 선배님께 들은 적이 있다. 당시에는 이해를 못 했고, 할 수도 없었던 말이 이제야 조금 와닿는다. 이런저런 고민을 할때마다 또 흔들릴때마다 저 말이 더더욱 생각하게 된다. 앞으로 어떤 결과를 마주할지는 모르겠다. 난 성공보다 실패를 더 많이했고, 내가 원했던 결과보다 후회를 더 많이 마주한 사람이기 때문이다. 그럼에도 이번에는 우직하게 쌓아가려고한다. 모래성을 쌓았는지 금자탑을 쌓았는지는 적어도 지금은 알 수 없으니까.
Reference
FireHol
http://iplists.firehol.org/?ipset=firehol_level1
MaxMind
https://www.maxmind.com/en/home
GeoIP2 다루기
https://wildeveloperetrain.tistory.com/285
Springboot Actuator
https://hudi.blog/spring-boot-actuator-prometheus-grafana-set-up/
Prometheus & springboot
https://brunch.co.kr/@springboot/734
Springboot & Prometheus & Grafana
댓글