최근 코딩을 하다 알게된 예상과 다르게 동작하는것들이 있어 기록 하고자 글을 쓴다.
SortedSet
아래와 같은 코드가 있다. 아래 코드의 출력값은 무엇일까??
public class Main {
public static void main(String[] args) {
SortedSet<Car> cars = new TreeSet<>();
cars.add(new Car("Ford", 2018));
cars.add(new Car("Toyota", 2018));
System.out.println(cars.size());
}
static class Car implements Comparator<Car> {
private String name;
private int year;
public Car(String name, int year) {
this.name = name;
this.year = year;
}
@Override
public int compare(Car o1, Car o2) {
return o1.getYear() - o2.getYear();
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
Car car = (Car) obj;
return year == car.year && Objects.equals(name, car.name);
}
@Override
public int hashCode() {
return Objects.hash(name, year);
}
}
}
서로 다른 객체를 Set에 넣었기에 나는 2가 출력될 것이라고 예상했지만 의도와는 다르게 1이 출력이 되었다.
SortedSet은 컬렉션 내부에 있는 값의 동등성, 동일성 검사를 할 때 equals, hashcode를 사용하지 않고 compareTo 만을 이용해 비교를 한다.
트리셋인 경우 내부적으로 트리맵을 사용하는데, 내부적응로 아래의 메소드를 사용한다. 보면 hashcode, equals 를 사용하는 코드는 없다.
private void addEntryToEmptyMap(K key, V value) {
compare(key, key); // type (and possibly null) check
root = new Entry<>(key, value, null);
size = 1;
modCount++;
}
private V put(K key, V value, boolean replaceOld) {
Entry<K,V> t = root;
if (t == null) {
addEntryToEmptyMap(key, value);
return null;
}
int cmp;
Entry<K,V> parent;
// split comparator and comparable paths
Comparator<? super K> cpr = comparator;
if (cpr != null) {
do {
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else {
V oldValue = t.value;
if (replaceOld || oldValue == null) {
t.value = value;
}
return oldValue;
}
} while (t != null);
} else {
Objects.requireNonNull(key);
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else {
V oldValue = t.value;
if (replaceOld || oldValue == null) {
t.value = value;
}
return oldValue;
}
} while (t != null);
}
addEntry(key, value, parent, cmp < 0);
return null;
}
Spring Security
Spring Security를 사용하다보면 anonymous 유저라는 것을 마주치게 된다. 인증되지 않은 사용자인 경우 SecurityContext에 anonymous 유저라는 객체가 들어가게 되고, 이는 세션에도 들어가게 된다. 알고 있었지만 별다른 생각 없이 넘겼던 부분이다.
근데 어느 순간부터 메모리 누수가 발생하는 것 처럼 레디스 메모리 사용량 그래프가 계속해서 우상향을 하는 것을 발견했다.
원인을 파악해보니 anonymous 유저가 문제였다.
위 세션은 어나니머스 유저 정보의 세션인데 2kb의 메모리를 사용한다. 현재 서비스에서는 anonymous 유저 정보를 통해 아무 정보도 이용하지 않는데 2kb 메모리를 사용하는 것을 보니 꽤나 많은 메모리를 사용한다고 생각이 든다. 심지어 인증되지 않은 사용자의 정보를 레디스 세션에 넣는다는 것도 의도하지 않은 부분이었다.
브라우저에서 api 호출을 한다면 세션쿠키에 SESSIONID 가 담겨 요청이 가서 anonymous 유저가 한번밖에 만들어지지 않기 때문에 별 문제가 되지 않는 상황이지만 SESSIONID 없이 요청을 보내면 매 요청마다 anonymous 유저가 생기는 문제가 발생할 수 있다. (anonymous 유저 세션의 ttl은 세션 ttl과 동일한 시간을 갖는다.)
우리의 상황에서 위와 같은 브라우저 없이 요청을 보내는 예외 케이스는 메트릭 서버가 api 서버에 메트릭 정보를 스크랩을 해올 때 발생했다. 최근 메트릭 서버의 인스턴스 배포를 다시하여 메트릭 서버의 ip가 변경되었고, 메트릭 정보 노출 경로는 권한 체크를 ip 기반으로 했기 때문에 메트릭 서버의 요청이 계속 거절되고 이는 어나니머스 유저 객체로 만들어져서 메모리 누수 때 보이는 그래프와 동일한 그래프가 보였던 것이다.
(메트릭 수집 도구는 프로메테우스를 이용했기 때문에 api 서버가 메트릭 시스템에 쏘는 것이 아닌 메트릭 시스템이 api 서버에 긁어오는 구조였다.)
해결방법은 간단하다. 어나니머스 유저인 경우 세션 정보를 저장하지 않도록 시큐리티 필터체인을 구성하면 된다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 세션 없이 들어오는 미인증 요청을 처리하는 과정에서 과도한 세션 데이터가 생성되는 것을 방지
return http.requestCache(RequestCacheConfigurer::disable).build();
}
이번 문제를 겪고 되게 가볍게 여겼던 것이 정말 예상하지 못한 결과를 초래할 수 있다는 것을 또한번 느끼게 됐다.
Logback
어느날 로그의 에러 메시지가 Loki 서버에 전송되지 않는 문제가 발생했다. 원인을 찾아보니 logback의 xml 파일이 의도와 다르게 동작했다.
현재 서비스가 의존하고 있는 라이브러리 중 팀에서 개발한 라이브러리도 존재하는데, 해당 라이브러리에 있는 logback 파일이 라이브러리를 이용하고 있는 프로젝트의 로그백 설정을 덮어 쓰고 있던 것이다. 로그백 파일 설정하는 부분은 브레이크 포인트도 무시하고 동작해서(이 부분의 원인은 클래스 로딩과 관련이 있을 것 같은데 일단 추측만하고 원인 파악은 못했다.) 원인 파악이 어려웠다. 결국 이렇게 동작하는 원인을 찾기는 했는데, 아래 코드가 문제였다.
ch.qos.logback.classic.util.DefaultJoranConfigurator 클래스가 로그백 설정을 로딩하는 코드가 있는데, 여기 configure 메소드를 보면 왜 그런지 확인이 가능하다.
@Override
public ExecutionStatus configure(LoggerContext context) {
URL url = performMultiStepConfigurationFileSearch(true);
if (url != null) {
try {
configureByResource(url);
} catch (JoranException e) {
e.printStackTrace();
}
// You tried and that counts Mary.
return ExecutionStatus.DO_NOT_INVOKE_NEXT_IF_ANY;
} else {
return ExecutionStatus.INVOKE_NEXT_IF_ANY;
}
}
private URL performMultiStepConfigurationFileSearch(boolean updateStatus) {
ClassLoader myClassLoader = Loader.getClassLoaderOfObject(this);
URL url = findConfigFileURLFromSystemProperties(myClassLoader, updateStatus);
if (url != null) {
return url;
}
url = getResource(ClassicConstants.TEST_AUTOCONFIG_FILE, myClassLoader, updateStatus);
if (url != null) {
return url;
}
return getResource(ClassicConstants.AUTOCONFIG_FILE, myClassLoader, updateStatus);
}
1. java 옵션으로 빌드 시점에 로그백 파일의 위치를 받으면 해당 로그백 파일을 로그백 설정 xml 파일로 사용한다.
2. 1번 분기에서 return 되지 않으면 logback-test.xml (TEST_AUTOCONFIG_FILE에 담긴 값) 파일을 로그백 파일로 설정한다.
3. 2번 분기에서 return 되지 않으면 logback.xml (AUTOCONFIG_FILE에 담긴 값) 파일을 로그백 파일로 설정한다.
이 때 라이브러리에 존재했던 logback-test.xml 파일이 라이브러리를 이용하는 프로젝트의 logback.xml 파일을 덮어썼기 때문에 프로젝트의 logback 설정이 동작하지 않았던 것이다.
라이브러리에 존재했던 logback-test.xml 은 개발자의 실수로 지어진 이름이었기에 이를 수정하여 문제를 해결하기는 했는데, 의도적으로 악의를 갖고 라이브러리에 logback-test.xml 을 만들고 오픈소스로 배포하면 보안에 상당한 위험이 된다고 생각이 든다. (해당 프로젝트의 로그를 전부 제어할 수 있으니깐.. 예를 들어 어떤 appender를 만들어 로그를 내 계정의 클라우드 워치로 보낼수도..)
별거 아닌듯 하지만 별거인 이슈를 겪고 디버깅을 하고 해결하다보니 고통스럽긴 해도 책으로는 알 수 없는 내용들을 익힐 수 있어 괜찮은 것 같다.