JVM(Java Virtual Machine)은 바이트코드(.class)를 OS에 특화된 코드(기계어)로 변환하고, 이를 실행하는 '가상의 머신'이다. 특정 OS에 특화된 코드로 변환하기때문에 OS 종속적이다. JVM은 JRE에 내포되어 있다.
JRE는 뭔데?
JRE(Java Runtime Environment)는 자바 어플리케이션을 실행할 수 있도록 구성된 배포판이다. 자바 어플리케이션을 실행한다는 것은 코드를 '실행'한다는 것인데 바로 이를 JRE에 포함된 JVM이 처리한다. 코드를 실행하는데 있어 꼭 필요한 java.util, java.io, java.net 등의 라이브러리도 포함되어 있기에 JRE의 구조는 위처럼 JVM + Library 로 구성된다.
JDK는 뭔데?
JDK(Java Development Kit)는 자바 어플리케이션 개발에 필요한 도구 및 라이브러리를 JRE와 함께 제공하는 개발 키트이다. 개발에 필요한 javac, jconsole, javadoc 과 같은 도구와 컬렉션 프레임워크, 파일 I/O, 스트림 API, 데이터 액세스 관련 라이브러리를 제공하는 것이다.
Oracle Java 11 버전부터는 JRE를 따로 제공하지 않아요
Oracle 홈페이지에 들어가면 Java 8의 경우 JRE를 따로 제공했지만, Java 11 이상은 제공하지 않음을 확인할 수 있다.
필자의 주관적인 생각과 이해를 바탕으로 작성된 글입니다. 잘못된 부분이 있거나 있다면 댓글로 피드백 부탁드립니다!
개요
HashMap은 Key, Value 데이터쌍을 저장하는 자료구조로 익히 알고있다. 속도면에서 장점을 갖고 있어 코딩테스트에서도 많이 활용된다. 도대체 어떻게 생겨먹은 녀석이길래 이렇게 빠른지 알아보자.
HashMap = Hash + Map
Map Key, Value 쌍으로 이루어진 자료형. 순서를 보장하지 않음. 키는 중복이 허용되지 않음.
Hash 해시 함수를 사용하여 임의의 길이를 가진 데이터를 고정된 길이를 가진 데이터로 매핑한 값
즉, HashMap이란 Map은 Map인데 Hash를 활용한 Map인 것이다.
Map은 어떻게 Key, Value 쌍으로 관리할 수 있을까? 🤔
HashMap을 이해하기 위해선 Map과 Hash에 대해 이해해야한다. 먼저 Map이 어떻게 Key와 Value 쌍으로 관리할 수 있는 이유는 내부적으로 Key와 Value를 담을 수 있는 배열 타입으로 데이터를 관리하고 있기 때문이다.
먼저 일반적인 배열의 형태를 생각해보자. 인덱스마다 하나의 값을 넣을 수 있는 자료구조인데 Key와 Value를 둘 다 넣는다는 건 말이 되지 않아보인다. 그런데 배열의 인덱스에 Key값을 넣는다면 얘기가 된다. 0이라는 Key에 대한 Value는 010-1111-1111, 1이라는 Key에 대한 Value는 010-2222-2222로 관리한다고 가정한다면 아래와 같이 배열의 인덱스에는 Key 값을 넣어 관리할 수 있다.
배열의 인덱스를 키로 활용한다고? 그럼 Key는 정수만 가능하잖아... 🤔
그렇다. HashMap의 Key는 정수 뿐 아니라 모든 타입의 인스턴스가 들어올 수 있다. 그럼 인스턴스를 정수로 변환할 수 있다면 어떨까? 그럼 Key로 들어온 모든 인스턴스는 Key로 사용 가능하게 된다. HashMap의 Hash 의미를 여기서 알 수 있다. 키로 들어온 값을 해시함수를 통해 해시화 시키고, 이를 배열의 인덱스로 사용하는 것이다! 여기서 사용되는 해시함수는 들어온 인스턴스의 hashCode() 메서드이다.
String 타입의 Key와 Value를 저장하기 위해 String 타입의 HashMap을 생성하고 위 코드를 실행시킨다고 가정해보자. Sim, Park, Hong에 대한 해시 값이 아래와 같다.
지금까지의 설명을 토대로 HashMap의 구조를 상상해보면 다음과 같을것이다. 만약 새로운 Key, Value 쌍이 들어온다면 해시값과 Size 나머지 연산을 통해 구한 인덱스에 Value가 추가될것이다.
갑자기 % Size는 뭐야? 🤔
Hash Func, 즉 해시함수를 통해 해시 코드를 구하고 이를 Size 로 나머지 연산(실제로는 시프트 연산)을 한다. 나머지 연산을 하는 이유는 배열의 인덱스 중 하나로 매핑시키기 위함이다.
예를들어 Size가 10인 배열은 0~9까지의 인덱를 갖는다. 해시 값은 hashCode() 메서드 뿐 아니라 부가적인 연산도 함께 수행되어 구해지는데 아래와 같이 큰 숫자의 정수형이 리턴된다.
만약 3288449라는 값을 배열의 인덱스로 사용한다면 최소 3288449 크기의 배열을 생성해야한다. 메모리를 많이 차지할것이다. 때문에 이 값을 배열의 Size로 나눈 나머지를 구하고 이를 Index로 사용하는 것이다. 만약 HashMap 내부 배열 Size가 10이라면 3288449 % 10 = 9. 즉 9라는 인덱스를 갖게 된다.
실제로는 이렇지 않아요. 배열은 배열인데 Node 타입의 배열이랍니다. 🤭
실제로 데이터가 저장되는 곳은 제네릭 타입, Object 타입의 배열일까? 모두 아니다 Node 타입의 배열에 저장된다. HashMap에 선언된 Node 타입 변수 및 클래스이다.
transient Node<K,V>[] table; // hashMap 클래스 내에 선언되어있어요
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}
멤버필드로 hash, key, value, nextNode 가 존재한다. 사실 Index와 Value로만 Key, Value 쌍을 관리하면 문제가 많다. 해시가 충돌될 경우 처리도 못하고, 리사이징과 해시 재배치 시 문제가 된다. (문제가 되는 이유는 아래에서 설명하도록 하겠다!)
Index랑 value 만 관리하면 될 줄 알았는데 아니네요? 🤔
hash, key, value, nextNode 필드를 갖는 Node 타입의 인스턴스로 관리되는 이유를 HashMap의 리사이징과 재배치, 충돌 우회 전략과 함께 이해해보자.
리사이징
리사이징 새로운 길이의 배열을 생성한 후 데이터를 이관시킴으로써 결과적으로 배열의 사이즈를 변경시키는 작업
배열의 사이즈는 초기화 시 정해진다. 기본 생성자를 통해 HashMap을 생성한 후 put 메서드를 실행하면 기본사이즈인 16 사이즈의 Node 배열이 생성되고 리사이징 임계 값으로 12(0.75*16)가 결정된다. 여기서 리사이징 임계값이란 배열을 리사이징하는 기준값을 뜻하며 현재 배열길이의 두 배로 리사이징한다. 12개를 초과할 경우 배열을 16의 2배인 32 사이즈로 리사이징 하는것이다.
ArrayList도 내부적으로 배열을 사용하고, 배열에 더 이상 들어갈 공간이 없을 경우 현재 사이즈의 절반 사이즈를 추가한 새로운 배열로 리사이징하는데 이와 같은 이치이다.
재배치
재배치 배열의 사이즈가 변경될 때 기존 데이터들의 Index를 재배치하는 작업
리사이징과 이어지는 내용이다. 리사이징을 할 경우 두 배 사이즈로 배열을 재생성하고 데이터를 이관시킨다고 했다. 이는 기존에 저장되어 있던 Node들의 Index가 재배치되어야 함을 의미한다. Index를 구하는 공식은 hashCode % Size 이므로 Size가 바뀐다면 Index도 바뀌어야하기 때문이다. 예를들어 사이즈가 10 일때 해시값 5755151를 통해 구한 Index는 1이지만, 사이즈가 20일 경우 Index는 11이 된다.
Node에서 hash 값이 관리되는 이유
이 재배치 작업 때 해시 값을 가져와 연산을 해야하는데 해시 값을 hash에 저장해놨기 때문에 나머지 연산만 하면 된다. 만약 해시 값이 없다면 해시 값 추출을 위해 존재하는 데이터 수 만큼의 해시함수 연산을 해야할것이다.
충돌 우회 전략 - Separate Chaining
지금 Index 기반으로 값을 넣고 있는데 과연 Index가 충돌할 확률은 대략적으로 어느정도일까? 사이즈에 대한 나머지를 Index로 사용하므로 배열 사이즈가 20이라면, Index가 중복될 확률은 최소 20분의 1이 된다.
어찌됐든 충돌이 일어날 수 있는 상황이다. HashMap은 이런 충돌에 대한 우회 전략으로 Separate Chaining 방식을 사용하며, 이 전략을 위해 nextNode를 사용한다.
Separate Chaning 동일한 해시값이 이미 존재할경우 LinkedList로 관리한다. 즉, Node에 있는 필드 중 nextNode를 활용하여 중복된 해시 값에 대한 Value를 관리하는 것이다.
그런데 만약 동일한 Key 값이 들어왔다면 어떨까? Sim이라는 Key값이 들어있는 HashMap에 Sim이라는 Key 값으로 다른 Value를 넣는 것은 전혀 문제되지 않는다. 이 경우 Index에 대한 Value가 덮어씌워져야한다. 즉, 동일한 Key가 들어왔는지를 확인하려면 Index 값만 비교하는 게 아니라 실제 Key 값도 비교해봐야한다.
Node에서 key, nextNode 값이 관리되는 이유
동일한 Key가 들어왔는지 확인하고, Linked List 형태로 우회하는 Separate Chaining 전략을 사용하기 위해 key와 nextNode가 관리된다.
Separate Chaining 동작원리
해시함수로 해시값을 구하고 나머지 연산으로 추출한 Index 가 충돌할 경우를 가정했다.
충돌이 일어나면 들어온 Kim에 대한 해시 값과 키 값을 충돌한 Node의 값과 비교한다. 다를 경우 Separate Chaining 전략에 따라 key, value, hash, nextNode를 갖는 Node 인스턴스를 생성하여 NextNode에 할당한다. 만약 비교한 Key와 Hash 값이 같았다면 중복된 Key가 들어온 것이므로 해당 Node의 Value 값을 새로 들어온 Value 값으로 수정한다.
그럼 NextNode에 추가된 Kim을 조회할 땐 어떻게 동작할까?🤔
NextNode에 추가된 Kim에 대한 Value 값 조회를 시도하면 다음 과정을 수행하게 된다.
1. Hash Func % Size 연산을 통해 Index를 구한다.
2. Index 에 매핑된 노드가 존재하는지 확인한다.
3. 매핑된 노드가 존재하므로(충돌) 해당 노드의 Key, Hash 값과 요청으로 들어온 Key, Hash 값을 비교한다.
4. Kim과 Sim의 Key와 Hash 값이 다르므로 해당 노드의 nextNode가 있는지 존재한다.
5. nextNode가 존재하므로 해당 Node를 참조한다.
6. nextNode에 저장된 Key와 Hash 값이 들어온 Key와 Hash 값과 일치하므로 이 노드에 대한 Value 값을 리턴한다.
내부 구조를 이해한 후 다시 생각해본 HashMap의 장점
1. 조회가 빠르다.
조회 시 Key 값에 해시 및 나머지 연산만 하면 Index를 구할 수 있고, Index 기반으로 접근하니 당연히 조회 속도가 빠를수밖에 없다.
2. 저장, 삭제도 ArrayList보다 빠르다.
저장은 Key에 대한 Index를 구한 후 값을 넣기만 하면 되고, 삭제도 Key에 대한 Index를 구하고 삭제하면 된다. ArrayList의 경우 순서를 유지해야 하기 때문에 중간에 값을 삭제할 경우 빈자리를 채우기 위한 이동 연산이, 등록할 경우 빈자리를 만들기 위한 이동 연산이 수행되어야하는데 말이다.
내부 구조를 이해한 후 다시 생각해본 HashMap의 단점
1. 너무 많은 저장이 일어날 경우 오히려 속도가 느려진다.
저장이 많아지면 그만큼 리사이징과 재배치작업이 많아지기 때문이다. 만약 저장해야할 데이터가 많다면 HashMap의 사이즈를 너프하게 잡는것도 좋은 방법이다.
일반적으로 어플리케이션 내에서 사용되는 상수는 Enum을 통해 관리한다. Enum이 등장하기 전에는 어떤 방식으로 상수를 관리했고, Enum이 기존의 방식보다 어떤 장점을 갖고 있는지 알아보자.
Enum이 등장하기 전 상수 관리
public class Constant {
public static final int PIZZA_S_DIAMETER = 14;
public static final int PIZZA_M_DIAMETER = 16;
public static final int PIZZA_L_DIAMETER = 18;
public static final int TORTILLA_S_SIZE = 4;
public static final int TORTILLA_M_SIZE = 6;
public static final int TORTILLA_L_SIZE = 8;
}
위와 같이 상수만을 정의하는 클래스를 만들고 외부에서 바로 접근 가능하고, 변하지 않도록 public static final 키워드를 통해 정의한다. 이러한 구현 방식을 정수 열거 패턴이라고 한다.
정수 열거 패턴의 단점
1. 타입 안전을 보장할 방법이 없다.
상수를 타입으로 구분하지 못하기 때문에 PIZZA, TORTILLA와 같은 접두어를 썼다. 하지만 이 값을 받는 메서드나 클래스에서는 타입으로 값을 받기 때문에 int 타입으로 받게 된다. 그 결과 PIZZA 관련 상수를 받아야할 메서드에 TORTILLA 관련 상수를 보내도 컴파일 에러가 발생하지 않지만 의도한 값을 받지 못했기 때문에 추후 로직에서 버그가 발생할 수 있다.
public class Pizza {
private final int size;
public Pizza(int size){
this.size = size;
}
}
Pizza firstPizza = new Pizza(Constant.PIZZA_S_DIAMETER); // s 사이즈 피자
Pizza secondPizza = new Pizza(30); // 30 사이즈 피자 >> 버그가 발생할 수도...
Pizza thirdPizza = new Pizza(Constant.TORTILLA_S_SIZE); // 또띠아 S 사이즈 피자 >> 버그가 발생할 수도...
2. 문자열로 출력했을 때 의미를 알 수 없다.
상수 값을 출력하거나 이 값을 파라미터로 받은 메서드에서 이를 디버깅하면 값만 출력된다. 이 상태에서는 'S 사이즈 피자의 길이' 라는 의미는 알 수 없고 14라는 값만 알 수 있는 것이다. 14가 어떤 의미를 가지고 있는지도 함께 알려줄 수 있다면 디버깅 시 값의 의미를 파악하거나 출처를 알기 훨씬 쉬워질것이다.
3. 순회 방법이 까다롭다.
같은 열거그룹에 속한 모든 상수를 한 바퀴 순회하는 방법도 마땅지 않다. 예를들어 현재 존재하는 모든 피자 사이즈를 출력하고 싶다면 개발자가 일일이 하드코딩하거나 리플렉션을 사용해야 한다.
Enum 타입 자체는 클래스이며, 상수 하나당 인스턴스를 만들어 public static final 필드로 공개하는 방식이다. 아래의 예제는 내부적으로 각각의 size를 가진 S 인스턴스, M 인스턴스, L 인스턴스를 PizzaSize 타입으로 생성하고 이를 public static final로 공개하는 것이다.
public enum PizzaSize {
S(14), M(16), L(18);
private final int size;
PizzaSize(int size){
this.size = size;
}
}
public enum TortillaSize {
S(4), M(6), L(8);
private final int size;
TortillaSize(int size){
this.size = size;
}
}
열거 패턴의 단점을 극복한 Enum 타입
1. 컴파일 타임에서의 타입 안전성을 제공한다.
이제 상수를 타입으로 구분할 수 있으므로 Pizza 클래스의 생성자 메서드에서 int 가 아닌 PizzaSize 타입으로 값을 받을 수 있다. 이로써 PizzaSize 타입이 아닌 다른 타입이 들어올 경우 컴파일 에러가 발생하게 된다. 컴파일 타임에서의 타입 안정성을 제공받게 되었다.
public class Pizza {
private final PizzaSize size; // PizzaSize로 수정
public Pizza(PizzaSize size){ // PizzaSize로 수정
this.size = size;
}
}
Pizza firstPizza = new Pizza(PizzaSize.S); // s 사이즈 피자
Pizza secondPizza = new Pizza(30); // 30 사이즈 피자 >> 컴파일 에러!!
Pizza thirdPizza = new Pizza(TortillaSize.S); // 또띠아 S 사이즈 피자 >> 컴파일 에러!!
2. 문자열로 출력했을 때 의미를 알 수 있다.
Enum에는 값과 이름이 존재하기 때문에 문자열로 출력했을 때 이 값의 의미를 알 수 있다. 또한 메서드 레벨에서 Enum 타입으로 값을 받기 때문에 디버깅 시 값에 대한 의미를 파악하기 쉽다. 그냥 14가 들어왔을 때 보다 PizzaSize.size = 14, PizzaSize.name = "S"로 들어온다면 피자 사이즈 S는 14라는 것과, 이 값이 PizzaSize 라는 Enum에서 관리된다는 출처도 알 수 있다.
3. 순회 방법이 간단하다
Enum 타입은 정의된 상수 값을 배열에 담아 반환하는 정적 메서드인 values를 제공한다. 정의된 상수를 모두 출력하고 싶다면 values 메서드를 사용하면 된다. 리플렉션을 사용했던 방식에 비하면 매우 간단함을 알 수 있다.
enum도 결국은 클래스이다. 멤버 필드나 메서드를 추가하여 피자 사이즈 관련 책임을 부여할 수 있다. 예를들어 피자 사이즈별로 사용되어야 하는 밀가루 양이 정해져있고 이 공식이 size * 30g 이라고 가정해보자. 이를 외부에서 구할 수도 있지만 size에 따라 밀가루 양이 결정되므로 size를 관리하는 PizzaSize 에게 책임을 위임해도 된다.
public enum PizzaSize {
S(14), M(16), L(18);
private final int size;
private static final int SIZE_PER_FLOUR = 30; // 가중치
PizzaSize(int size){
this.size = size;
}
public int amountOfFlour(){
return size * SIZE_PER_FLOUR;
}
}
amountOfFlour 라는 메서드를 통해 모든 사이즈의 피자가 필요한 밀가루 양을 구하고 있다. 사이즈에 따라 밀가루 양을 구하는 로직이 다르지 않기 때문에 가능한 것이었다.
그럼 반대로 인스턴스마다 처리해야하는 로직이 다를 경우는 어떻게 처리해야 할까?
값에 따라 분기하는 Enum 타입
public enum Operation {
PLUS, MINUS, TIMES, DIVIDE;
public double apply(double x, double y){
switch (this){
case PLUS : return x+y;
case MINUS : return x-y;
case TIMES : return x*y;
case DIVIDE: return x/y;
}
throw new AssertionError("알 수 없는 연산 : "+this);
}
}
위 코드는 동작하기는 하나 좋은 코드는 아니다. 새로운 상수를 추가하면 case 문도 추가해야 한다. 만약 이를 깜빡한다면 컴파일은 되지만 새로 추가한 연산을 수행하려 할 때 "알 수 없는 연산"이라는 런타임 에러가 발생한다.
이를 구현하는 좋은 방법은 열거 타입에 apply 라는 추상 메서드를 선언하고 각 인스턴스에서 이를 재정의하는 방법이다.
이전 포스팅에서 매개변수화 타입을 사용하는 클래스에 유연성을 더하기 위해 한정적 와일드카드를 사용했고, 그 결과 자식 타입까지 허용 가능하도록 구현하였다. 타입의 다형성을 활용한 것이다. 이는 다형성을 활용하지 못하는 타입은 접근이 불허하다는 한계가 있다는 뜻이다. 이 한계를 타입 안전 이종 컨테이너 방식으로 극복할 수 있다.
Key를 Integer.class로 하고, value를 String 타입으로 넣어도 컴파일 에러가 발생하지 않는다. 이에 따라 런타임 시 해시맵의 Integer.class의 값을 조회할 때 ClassCastException이 발생하게 된다.
favorites.put(String.class, "hi");
favorites.put(Integer.class, "bye"); // 컴파일 에러는 발생하지 않는다.
..
Integer favoriteInteger = (Integer)favorites.get(Integer.class); // 런타임 에러 발생 !
단점 2. 조회 시 정적 타입 캐스팅이 필요하다
모든 타입을 받아야 하기에 Map의 값을 Object 타입으로 선언하였다. 이에 따라 값을 조회할 때 정적인 타입 캐스팅이 필요하다. Integer.class에 대한 값을 조회할 때는 Integer 타입으로, String.class에 대한 값을 조회할 때는 String 타입으로 개발자가 직접 캐스팅해야한다.
2. 타입 안전 이종 컨테이너 패턴을 통한 구현
타입 안전 이종 컨테이너 패턴 키 값을 매개변수화한 다음, 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공하는 패턴이다. 이에 따라 키와 값의 타입이 보장된다.
public class Favorites {
private final Map<Class<?>, Object> favorites = new HashMap<>();
public <T> void putFavorite(Class<T> type, T instance){
favorites.put(Objects.requireNonNull(type), instance);
}
public <T> T getFavorite(Class<T> type){
return type.cast(favorites.get(type));
}
}
HashMap 인스턴스를 감싸는 Favorites라는 래퍼 클래스를 만들고, 값을 넣거나 뺄 때 '키' 값에 매개변수화 타입 값을 함께 제공하고 있다. 이 방식이 Map 방식의 단점을 모두 극복했는지 알아보자.
1. 타입 안정성을 보장받는다.
Favorites f = new Favorites();
f.putFavorite(String.class, "Java");
f.putFavorite(Integer.class, 123);
f.putFavorite(Class.class, Favorites.class);
String favoriteString = f.getFavorite(String.class);
int favoriteInteger = f.getFavorite(Integer.class);
Class<?> favoriteClass = f.getFavorite(Class.class);
putFavorite 메서드에서 사용하는 제네릭 타입에 의해 내부 Map 인스턴스의 Key로 사용할 클래스 타입과 값이 모두 같은 타입임이 컴파일 타임에 보장된다.
만약 아래와 같이 String.class에 대해 123이라는 Integer 타입의 값을 사용한다면 메서드 시그니처에 맞지 않다는 컴파일 에러가 발생하게 된다. 즉, 기존 Map을 직접 사용한 방식과는 다르게 타입 안정성이 보장되고 있다.
f.putFavorite(String.class, 123); // 메서드 시그니처에 맞지 않는다는 컴파일 에러가 발생!
2. 조회 시 동적 타입 캐스팅이 가능하다.
public <T> T getFavorite(Class<T> type){
return type.cast(favorites.get(type));
}
조회 시 Class 클래스의 cast() 메서드를 사용하고 있다. 이 메서드는 매개변수로 들어온 값을 자신의 타입으로 캐스팅 할 수 있다면 캐스팅 후 반환하고, 캐스팅이 불가능할 경우 ClassCastException을 반환하는 메서드이다.
어차피 getFavorite 메서드의 파라미터로 들어온 클래스의 타입 T와 favorites.get(type)을 통해 조회한 클래스의 타입 T 는 일치할 수 밖에 없으므로 type.cast 메서드를 아주 적절하게 사용할 수 있는 부분이다.
public <T> T getFavorite(Class<T> type){
return (T)favorites.get(type);
}
물론 위와 같이 정적 형변환 방식으로 수정해도 되지만, 비검사 형변환이므로 '경고'가 발생하게 된다. 타입 안정성이 보장되는 부분이기에 @SuppressWarnings와 주석을 남겨야 한다.
제약 사항
Class 타입을 로 타입으로 넘길 경우 타입 안정성이 깨진다.
List<String> stringList = new ArrayList<>();
stringList.add("hi");
f.putFavorite(List<String>.class, stringList); // 컴파일 에러 발생
List<String>과 List<Integer>에 대한 값을 Favorites 클래스로 관리하기 위해 putFavorite 메서드에 List<Class>.class 형태로 파라미터를 넘길 경우 경우 컴파일 에러가 발생한다. List<String>.class 구문 자체가 문법 에러를 발생시키기 때문이다.
List<String> stringList = new ArrayList<>();
stringList.add("hi");
List<Integer> integerList = new ArrayList<>();
integerList.add(1);
f.putFavorite(List.class, stringList);
f.putFavorite(List.class, integerList); // 덮어 씌워진다.
이를 해결하기 위해 위와 같이 로 타입으로 값을 넘기게 되면 List<String>과 List<Integer> 모두 같은 키를 공유하게 되므로 List.class 키에 대한 값은 마지막에 넣은 값으로 덮어 씌워지게 된다. List<String>과 List<Integer> 타입에 대한 값을 따로 관리하려 했던 목적을 이루지 못했다.
정리
타입 매개변수를 사용하는 컬렉션 API는 한 컨테이너가 다룰 수 있는 타입의 수가 고정되어 있다. 하지만 타입 안정 이종 컨테이너 패턴을 사용하면 타입에 제약이 없는 컨테이너로 만들 수 있다.
매개변수화 타입은 불공변이다. 불공변은 계층적으로 설계된 타입을 아예 다른 타입으로 인식하는 성질이다. 일반적인 레퍼런스 타입의 경우 다형성을 활용하여 상위 타입 변수에 하위 타입 인스턴스가 할당될 수 있지만, 매개변수화 타입은 계층 관계가 있다 한들 그저 다른 타입으로 인식하기 때문에 할당이 불가능하다. 다형성을 활용할 수 없기 때문에 타입에 대해 유연하다고 할 수 없는 것이다.
List<Object> a = new ArrayList<String>(); // 불공변. 컴파일 에러
Object b = "hi";
List<Animal> c = new ArrayList<Cat>(); // 불공변. 컴파일 에러
Animal d = new Cat();
매개변수화 타입도 유연해질수 있다
매개변수화 타입도 여러 타입을 받을 수 있도록 유연해지는 방법이 있다. 바로 한정적 와일드카드를 사용하는 것이다. 먼저 기본적인 매개변수화 타입을 사용하는 스택 클래스를 보고 타입 유연성에 대한 문제점을 인지해보자.
스택 클래스의 문제점
아래는 필자가 간단하게 구현한 스택 클래스이다. Stack 인스턴스 생성 시 E 라는 타입 매개변수를 받고 있으며, 이때 받은 타입 매개변수가 여러 메서드에서 사용되고 있다. 이 스택 클래스의 첫번째 문제점은 pushAll에서 찾을 수 있다.
public class Stack<E> {
private final List<E> elementList = new ArrayList<>();
private int size = 0;
public void push(E element){
elementList.add(element);
size++;
}
public void pushAll(List<E> anotherList){
elementList.addAll(anotherList);
size += anotherList.size();
}
public void flush(List<E> anotherList){
while(!isEmpty()){
anotherList.add(pop());
}
}
private E pop(){
E element = elementList.get(--size);
elementList.remove(size);
return element;
}
private boolean isEmpty(){
return elementList.isEmpty();
}
public void print(){
elementList.forEach(System.out::println);
}
}
Number 타입 엘리멘트 저장용 스택
Number 타입 엘리멘트를 저장하기 위해 Number 타입의 스택을 생성하였다. Number 클래스는 Double, Integer와 같은 숫자 타입 클래스의 상위 클래스이며, push 메서드를 통해 값을 넣을 수 있다. push 메서드는 그저 제네릭 타입의 매개변수를 받고있기 때문이다. 여기서는 Double 타입으로 오토박싱될 1.1 값을 통해 push 메서드를 호출하고 있다.
Stack<Number> stack = new Stack<>();
stack.push(1.1);
문제는 매개변수화 타입을 받는 pushAll에서 발생한다. Double 타입의 리스트를 받지 못하는 것이다.
컴파일 타임에서의 pushAll 메서드 매개변수 타입은 List<Number> 인데 List<Double> 타입의 값을 매개변수로 사용하려 했기 때문이다. List<Number>와 List<Double>은 타입이 다르므로 컴파일 에러가 발생한다.
List<Double> list = new ArrayList<>();
list.add(2.2);
list.add(3.3);
stack.pushAll(list); // 컴파일 에러 발생
한정적 와일드카드를 통해 유연성을 높여보자.
한정적 와일드 카드를 사용하여 Number 타입과 같거나 서브 타입에 대한 타입 매개변수를 갖는 리스트로 변경하였다. List<Double> 타입의 Double은 Number의 하위타입이므로 컴파일 에러가 발생하지 않게 된다.
Integer 타입의 스택 인스턴스를 생성하고, Integer 리스트로 스택의 데이터를 모두 추출하여 전달하고 있다. 여기서의 문제도 마찬가지로 유연성이다. Stack을 Integer 타입으로 생성하면 flush 시 List<Integer> 타입의 매개변수밖에 받지 못한다.
Stack<Integer> stack = new Stack<>();
stack.push(1);
List<Integer> numberList = new ArrayList<>();
stack.flush(numberList);
Integer 상위 타입인 Number 리스트로 받을 수 있을 것이라 생각했지만 컴파일 에러가 발생해버린다.
List<Number> numberList = new ArrayList<>();
stack.flush(numberList); // 타입 불일치 컴파일에러 발생
이것도 마찬가지로 한정적 와일드카드를 사용하여 해결 가능하다. pushAll과의 차이는 extends가 아닌 super를 사용하는 것이다. <? super E> 는 E 클래스의 상위 타입을 의미한다. 즉, Number는 Integer의 상위타입이므로 컴파일 에러가 발생하지 않게 된다.
public void flush(List<? super E> anotherList){
while(!isEmpty()){
anotherList.add(pop());
}
}
이로써 수정된 Stack 클래스는 다형성을 지원하는 것처럼 유연한 클래스로 변경되었다.
팩스(PECS)
PECS는 Producer-Extends, Consumer-Super의 약자로, 들어온 매개변수화 타입이 생산자라면 extends를 사용하고 소비자라면 super를 사용하라는 공식이다.
pushAll 메서드는 들어오는 매개변수화 타입 인스턴스를 엘리멘트에 추가하기 위해 외부에서 생성해 들어온 값이다. 즉 생산자이므로 List<? extends E>를 사용하고, flush 메서드는 들어오는 매개변수화 타입 인스턴스에 엘리멘트를 소비한다. 즉, 소비자이므로 List<? super E> 를 사용한다.
정리
와일드카드 타입을 사용하면 API가 유연해진다. 생산자와 소비자를 잘 구분하여 적절한 한정적 와일드카드를 사용하자. 공식을 활용하는 것도 좋지만 왜 이런 공식이 나왔는지를 이해하고 사용하는 것이 중요하다고 생각한다.
생각만해도 한숨이 턱~ 나오는 나의 인생 첫 기술면접... 컨디션 관리도 제대로 못하고, 너무 긴장한 탓에 쉬운 질문도 이해되지 않았다. 어두컴컴했던 인터뷰를 복기하고나니 내가 개선해야할 점을 아주 확실하게! 찾아낼 수 있었다. 장담컨데 다음 기술면접은 이렇게 쭈구리처럼 보지 않을 것 같다! (기업 명은 비밀!)
1. 면접 전날...
면접 전날부터 심장이 벌렁벌렁거렸다. 생애 처음 넣은 이력서가 서류 합격, 과제테스트 합격, 기술면접까지 가리라곤 생각지 못했기 때문이다. 물론 현업 경력은 있지만 해당 기업은 학교에서 연결해주던 곳이었고, 정석적인 채용 절차를 거치지 않았었다. 어쨌든 두근거리는 마음과 함께 과제 테스트로 제출한 코드를 복기하며 코드 리뷰를 준비하는데 시간을 쏟아부었다.
거의 3주? 정도 전에 완성했던 코드라 복기를 하는데 시간이 조금 걸렸다. 새벽에 준비를 끝마치고 오후 5시경에 있을 기술면접을 생각하며 잠을 청하려고 했다. 그런데... 나의 머가리는 잠을 거부했다. 마치 초등학교 5학년 첫 수학여행을 가기 전날의 두근거림이었다. 머릿속으로 온갖 면접 시뮬레이션을 돌리다보니 어느덧 아침이됐다. @_@;
2. 면접 당일
아침밥을 먹고 면접 준비를 했다. 화상면접이라 마이크, 카메라, 목소리 등을 체크했다. 면접관님들의 눈을 고려하여 초록색 가상 배경도 준비했다. 그런데 너무 일찍부터 면접 준비를 했다. 만약 이게 대면 면접이었다면 거의 10시간 전에 면접장소에 도착한 격이었다. (홀리몰리) 시간이 많이 남아 자기 소개 연습을 하고 코드를 복기했다.
3. 면접 5시간 전
점심밥을 억지로 먹고 다시 화상 카메라 앞에 앉았다. 면접까지 약 5시간 남았었다. 이때부터였나 눈이 뻑뻑해지고 반쯤 감기기 시작했다. 하지만 여기서 잠을 자버리면 나의 첫 기술면접을 꿈에서 볼것같은 예감이 강하게 들었다. 혹시나 이런 생각 도중에 잠이 들어버릴까 겁나 박카스를 사러 편의점으로 뛰어갔다.
4. 면접 30분 전
박카스를 원샷했다. 내 마지막 발악이었던것 같다.
5. 면접
면접을 봤다... 결론부터 말하면 면접관님들께 너무 너무 죄송스러웠다. 그 분들도 나라는 사람을 평가하기 위해 시간을 낸것인데, 그 시간이 무색해질 만큼 대답을 하지 못했다. 면접이 끝나자마자 긴장이 풀림과 동시에 잠이 들었다.
다음날 면접에 나온 질문들을 복기해보며 면접관 님들이 어떤 의도로 질문했는지, 나는 어떻게 답변하는게 좋았을지를 정리해보았다. 솔직히 정말 솔직히 질문은 어렵지 않았다. 아마 나의 답변수준을 보고 질문 수준이 같이 낮아진것 같긴 하지만.. 어쨌든 어렵지 않은 질문을 이해하지 못한 것과 그때 내 생각을 자신감 있게 전달하지 못한 점. 무엇보다 내가 가진 지식에 대한 확신을 가지지 못한 점이 너무 후회스러웠다.
6. 질문 내용
Q1. userService.getUserInfo 메서드를 테스트하고 assertThat으로 검증하고 있으며, userRepository.findById 리턴 값을 USER_FOR_VALID_USER_ID 로 목킹하고 있다. assertThat 구문으로 동일한(?) 값을 굳이 검증한 이유가 뭔가?
그때 당시 질문를 이해하지 못해 진정한 개소리를 했다. 트루 개소리였다. 되돌아보니 면접관님께서 USER_FOR_VALID_USER_ID와 USER_INFO_FOR_VALID_USER_ID를 동일한 인스턴스로 착각하고 물어보신것 같다. 변수명이 비슷해서 나도 착각했기 때문이다.
어찌됐든 면접관님의 질문 의도는 “userRepositroy에서 A를 리턴하도록 목킹하고, 서비스 클래스에서는 이를 그대로 리턴하는 것 같은데 테스트 코드에서 A의 필드에 대해 따로따로 검증한 이유가 뭐냐. 그냥 A 인스턴스 자체를 체크하면 되지 않냐” 였던 것 같다.
# 돌아갈 수 있다면...
USER_FOR_VALID_USER_ID는 엔티티이며, USER_INFO_FOR_VALID_USER_ID는 해당 엔티티를 통해 최종적으로 리턴되는 유저 정보 DTO 클래스입니다. UserService.getUserInfo 메서드에서는 조회된 엔티티에 대해 주민등록번호 복호화, 마스킹처리, DTO 변환 작업을 수행합니다. 이에 대한 응답 테스트 픽스처를 USER_INFO_FOR_VALID_USER_ID로 정의했으며, 이 값들을 비교하기 위해 각 필드에 대해assertThat 을 사용하여 체크하였습니다.
Q2-1. aes256을 빈으로 등록하고 있는데, 혹시 빈으로 등록하는 본인만의 기준이 있는지
AES256 암복호화 클래스는 빈으로, SHA256 암호화 클래스는 일반 클래스로 두고, 정적 스태틱 메서드로 사용하고 있었다. 어쨌든 성격이 비슷한 이런 클래스들을 빈, 일반 클래스로 구분지어 사용하는 이유를 궁금해하셨던 것 같다.
AES를 빈으로 사용한 이유는 주저리 주저리 답변은 한것 같지만, 클래스를 빈으로 등록하는 본인만의 기준이 있냐는 질문과 확장성에 대한 답변을 못했다. 생각해본적이 없는 내용이었다. 정신이 말짱했어도 이건 대답을 못했을 것 같다.
스프링 IoC와 DI 개념을 다시 훑어보니 질문의 의도가 스프링의 기본 개념을 알고있는냐로 이해됐다. 클래스를 빈으로 등록하면 스프링 IoC 컨테이너에 의해 기본 싱글톤으로 관리되며, 이렇게 관리되는 빈들은 런타임 시 스프링에서 제공하는 여러 기능을 사용할 수 있다.
생성자 메서드만 만들어 놓으면 자동으로 의존 주입이 되고, 빈으로 등록된 클래스에 @Transactional 어노테이션을 사용하면 트랜잭션이 적용되고, @Controller 어노테이션을 사용한 클래스가 서블릿으로 사용되고, @PostMapping, @GetMapping 어노테이션들이 기능으로써 동작한다.
단순 어노테이션이 이렇게 어떤 기능으로써 동작할 수 있는 가장 첫번째 이유는 해당 클래스가 '빈'이기 때문이다. 내부적으로 IoC 컨테이너에서 관리되는 빈 리스트를 읽은 후 리플렉션과 프록시 등을 통해 이러한 기능들을 부여하기 때문이다.
# 돌아갈 수 있다면...
싱글톤으로 관리되며, 스프링에서 제공하는 기능을 사용할 클래스를 빈으로 등록하는 편입니다. 이러한 빈은 스프링에서 제공하는 여러 기능들을 런타임 시 부여할 수 있다는 점에서 확장성을 향상시킨다고 생각합니다.
# SHA256 을 유틸성 클래스로 설계한 이유 및 회고
public class SHA256 {
public static String encrypt(String plainText){
try{
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(plainText.getBytes());
return bytesToHex(md.digest());
}catch(Exception e){
log.error(e.getMessage());
throw new EncryptException(ErrorMessage.SHA256_ENCRYPT_FAIL);
}
}
private static String bytesToHex(byte[] bytes){
StringBuilder builder = new StringBuilder();
for(byte b : bytes){
builder.append(String.format("%02x", b));
}
return builder.toString();
}
}
위와 같이 SHA256 클래스를 static 메서드로 이루어진 유틸성 클래스로 설계한 이유는 다음과 같았다.
1. 스프링에서 제공하는 기능을 사용하지 않음
2. 상태 값이 없음
3. 멀티 쓰레드 환경에서 공유해도 안전함
4. 클라이언트에서 이를 사용하기 위한 추가작업(멤버필드 추가, 생성자 메서드 수정)이 필요없음
이렇게 놓고보니 결국 싱글톤으로 관리되는 빈을 사용하지 않고 static 메서드 클래스를 사용하는 이유는 딱 하나. 4번 뿐이었다. 결국 내가 static을 선택한 이유는 클라이언트 입장에서의 코드 편의성 하나였던 것이었다.
static에 대한 개발자들의 여러 의견들을 통해 static 사용을 지양하는 여러 이유를 찾을 수 있었다. 메모리의 메서드영역에 저장되어 GC 불가 및 메모리 문제를 야기할 수 있고, 확장이 불가하고, 상태 값 역할로 공유해서 사용할 경우 추론이 어렵고, 공유로 인한 문제가 발생할 수 있고 등등... 그 중 나의 프로젝트 구조에서 크게 와닿았던 문제가 바로 테스트가 까다롭다는 점이었다.
최초 테스트 코드 작성 시 SHA256 클래스를 모킹 후 스터빙하기 위해 시도했으나 실패했다. 확인결과 새로운 의존 라이브러리를 사용하여 테스트 메서드마다 클래스를 Mocking, 스태틱 메서드를 stubbing해야 했고, 테스트가 끝나면 클래스의 Mocking을 해제해야했다. 배보다 배꼽이 더커진 느낌이 들어 클래스를 목킹하지 않고 SHA256 호출 부분에서 문제가 발생하지 않도록 요청이나 응답 테스트 픽스처들을 SHA256 클래스의 실제 동작에 맞게 신경써서 생성하게 되었다.
그 결과, 요청 픽스처가 변경되면 응답 픽스처도 변경해야했고, SHA256 클래스 내에서 문제가 발생하면 테스트가 실패하는 구조가 되었다. 테스트 메서드와 테스트 픽스처가 SHA256 클래스 실제 구현부에 강하게 결합되버린 것이다. 결과적으로 단위 테스트가 아니라 단위 + SHA256 테스트가 되버린 것...이다.
만약 SHA256을 빈으로 등록했다면 쉽게 모킹, 스터빙이 가능했을 것이고, 테스트 메서드는 SHA256의 구현에 신경쓸 필요가 없으므로 단위 테스트의 목적에 맞게 구현됐을 것이다. 클라이언트 코드의 가독성도 떨어지지 않는다. 멤버필드 하나만 추가해주면 되기 때문이다. 빈으로 등록하는 것이 더 좋은 선택지였지 않았을까 하는 아쉬움이 남는다
Q3. 동일 ID 값을 가진 요청 데이터로 회원가입 요청이 빠르게 두번 연속으로 발생할 경우 동일한 ID로 회원가입이 될 것 같나요?
이것도 질문의 의도를 이해하지 못했다. 이 질문을 듣는 순간 머릿속이 아주 새 하얗게 변한게 생각난다. 면접관님께서는 동일 ID를 로그인에 사용될 ID로 말하고 있었지만, 나는 엔티티의 키로 사용하는 ID로 이해해버렸다.
쓰기 락을 걸거나 기본키로 ID를 사용한다고 말했다.
# 또...돌아갈 수 있다면...
네 유저 ID가 키 값이 아니기 때문에 동일한 userId로 요청이 빠르게 중복되어 들어올 경우 테이블에 데이터가 중복되어 적재될 것 같습니다. userId를 유니크 키로 하거나, Id 컬럼을 없애고 userId를 PK로 사용해도 될 것 같습니다!
# 실제 테스트 및 회고
실제 테스트를 해봤다. 그런데 이미 userId를 기본키로 해놨었다. 정신머가리가 빠졌다는 것을 다시한번 느꼈다. 어쨌든 동시성 테스트를 curl를 통해 진행하였다. 그 결과 첫번째 요청은 정상 수행됐으나 두번째 요청은 기본키 제약조건 위반으로 인해 런타임 예외가 발생하였다. 해당 예외에 대한 예외처리를 하지 않았기 때문에 '시스템에서 알수없는 에러가 발생하였습니다.'라는 문구가 클라이언트에게 응답되었다. 해당 예외에 대한 예외처리를 통해 적절한 메시지를 내려주면 좋았을 것 같다.
Q4. Spring Security에 적용하신 JsonAuthorizationFilter는 굳이 필요하다고 생각들지 않은데 사용한 이유가 있나요? 제가 생각했을 때는 UserDetails랑 UserDetailsService 추가해주면 될것같은데요?
A4. 어… 저는 필요하다고 생각합니다. 어쨌든간에 UsernamePasswordFilter에서는 폼 형식으로 온 요청에 대해 getParameter 메서드로 ID와 PW를 추출하고 있기 때문입니다. 로그인에서 Json 형식으로 올 경우 해당 필터가 값을 추출하지 못합니다.. 주저리 주저리.. 그래서 JsonAuthorizationFilter 클래스가 충분히 가치(갑자기 뭔 가치야 ㅁㅊ놈아..)있는 클래스라고 생각합니다.
# 또... 돌아갈 수 있다면...
JsonAuthorizationFilter 는 꼭 필요합니다. 말씀하신대로 UserDetails와 UserDetailsService를 추가하여 구현하는 방법도 있지만, 그 방법은 스프링 시큐리티 필터를 통해 인증을 처리할 때 유효합니다.
spring security 설정을 건드리지 않고 UserDetailsService를 구현하여 인증 처리를 수행한다는 것은 security의 기본 설정에 UserDetailsService를 커스텀하여 적용하는 경우입니다. security 기본 설정은 formLogin 방식이고, 이는 곧 UsernamePasswordAuthenticationFilter가 추가된다는 뜻입니다.
이 필터는 폼 방식으로 들어온 요청만을 필터링할 수 있으므로 Json 형식으로 들어오는 요청을 받아야한다면 이를 대신할 수 있는 커스텀 필터가 꼭 필요합니다. 저는 이 커스텀 필터로 JsonAuthorizationFilter를 사용한 것이기에 JsonAuthorizationFilter는 꼭 필요한 클래스라고 생각합니다.
Q5-1. CloseableHttpClient를 사용한 이유가 있나요?
나) 네 Connection Pool로 관리되어 Connection 생성으로 인한 오버헤드를 아낄... 주저리주저리…
면접관님) 흠.. 이거 말고 다른거 사용해보셨나요? restTemplate 같은거요. CloseableHttpClient는 요즘 잘 사용안하는것 같은데
나) 네 사용해봤습니다. (여기서부터 해탈했다. 질문에 대한 답변을 제대로 한게 없으니 내가 아는 지식에 대해 자신감을 갖지 못했기 때문이다.) 사실 이걸 사용한 이유는 실무 프로젝트 중 HttpClient 사용 시 주저리 주저리… 그때 이 CloseableHttpClient를 통해 잘 해결한 경험이 있기 때문에 활용하면 좋겠다 라고 판단했던것 같습니다. (왜 이딴식으로 대답했는지 모르겠다. 정말... CloseableHttpClient로 포스팅도 하고, 현업에서도 겪은 문제라 잘 아는데말이다... 이 상황을 벗어나고 싶었던걸가 ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ...훌쩎)
# 또...또 돌아갈 수 있다면...
CloseableHttpClient는 커넥션 풀에서 커넥션을 관리할 수 있습니다. RestTemplate을 사용할 경우 HTTP 통신에 필요한 커넥션을 생성하고, 통신이 끝나면 해당 커넥션을 삭제하는 걸로 알고 있습니다. 하지만 CloseableHttpClient는 커넥션 풀에서 관리되는 커넥션에 대한 설정도 할 수 있는데 이를 통해 통신이 끝난 커넥션을 일정 시간동안 대기시켜 놓을 수 있습니다. 즉, 재사용이 가능하며 이는 곧 커넥션을 맺는 오버헤드를 감소시킬 수 있습니다.
정리하면 커넥션 풀 및 커넥션 설정을 관리할 수 있고, 요청이 몰릴 경우 커넥션 생성으로 인한 오버헤드를 줄일 수 있어 성능상의 이점을 가져올 수 있기에 CloseableHttpClient을 사용하였습니다.
Q6. PrintWriter를 굳이 선택한 이유가 있나. (혼잣말이셨음)
이건 혼잣말로 말씀하신건데 생각해보지 않은 내용이었다. PrintWriter에 대해 찾아보고 도루마무를 해봤다.
# 진짜 마지막으로 돌아갈 수 있다면...
(면접관님의 혼잣말이 끝나시는 순간 자신감있게 일어서며) PrintWriter는 문자 텍스트를 응답할 때 사용하는데 accessToken은 문자 텍스트 형태이기 때문에 이를 사용했습니다. 하지만 범용성을 생각한다면 OutputStream을 사용하는 것이 더 좋은 선택지라고 생각이 드네요! 감사합니다!
...
...
...
...
...
자신의 생각을 말해야하는 자리이니만큼 컨디션 관리는 매우 중요했지만, 컨디션 관리를 너무 못했다. 너무 긴장한 탓인지 면접관님들의 질문에 큰 부담을 갖고 임한것도 문제였다. 면접관님들은 그냥 궁금해서 물어본건데 나는 모든 질문에 '이 코드에 뭔가 문제가 있어서 물어보는구나' 라는 생각을 깔고 임했기 때문이다. 그래서 질문을 받을수록 내가 알고 있는 것들이 잘못된걸까라는 의심을 하게 되고, 내 생각을 적극적으로 전달하지 못하게 되었다. 이 부분이 정말 후회되는 부분이다.
누군가 면접은 경험이라고 했다. 면접이 끝나는 순간 이 말이 크게 와닿았다. 하지만 결코 후회뿐인 면접은 아니었다. 면접을 통해 내 생각을 다시 한번 정리할 수 있고, 평소라면 생각할 수 없었던 물음들에 대해 고민하게 된 좋은 경험이었다.
너무 가고싶었던 기업인만큼 아쉬움이 크지만, 다음 면접에서는 이런 후회를 다시 겪지 않도록 자신감 있게 임할것이다. 화이팅!!
배열과 리스트 모두 여러 값을 관리하게 위해 사용한다. 기능적으로 같은 역할을 하는 배열과 리스트는 어떤 차이점이 있고, 이 중 어떤 타입을 사용하는게 더 기능적으로 유용할까?
결론부터 말하면 리스트
결론부터 말하면 리스트를 사용해야 한다. 왜? 그걸 이해하기 위해서는 변성, 공변, 불공변(무공변), 소거, 실체화 타입, 실체화 불가 타입과 같은 개념들을 이해해야한다. 하나씩 이해하며 왜 리스트를 사용해야하는지 알아보자.
첫번째 차이. 변성
리스트와 배열의 첫번째 차이는 변성이다. 변성이란 타입의 계층 관계에서 서로 다른 타입 간에 어떤 관계가 있는지 나타내는 개념이다. 변성은 크게 공변, 반공변, 불공변(== 무공변) 으로 나뉘며 배열과 리스트와 연관성이 있는 공변과, 불공변에 대해 알아보자.
배열은 공변 (共變)
공변은 '함께 공(共)', '변할 변(變)' 이라는 한자어 그대로 '함께 변한다'는 뜻이다. 함께 변하는 주체는 바로 계층관계이다. 즉, 타입의 계층관계에 따라 배열의 계층관계도 함께 변하는 것이다. 예를들어 Sub 클래스가 Super 클래스의 하위 클래스라면 배열 Sub[]도 배열 Super[]의 하위 타입이 된다.
공변과 불공변을 구분할 때 업 캐스팅이 가능한가의 여부로 판단하기도 하는데, 공변일 경우 계층 관계가 유지되니 다형성으로 인해 업 캐스팅이 가능하기 때문이다.
다형성 한 타입의 참조변수를 통해 여러 타입의 객체를 참조할 수 있도록 만든 것을 의미한다. 좀 더 구체적으로, 상위 클래스 타입의 참조 변수로 하위 클래스의 객체를 참조할 수 있도록 하는 성질이다.
정리하면 배열은 공변성을 띄므로 계층관계를 갖고, 아래와 같이 업 캐스팅이 가능하다.
public class Super {
}
public class Sub extends Super {
}
public static void main(String[] args) {
Super[] sup = new Sub[10];
}
리스트는 불공변
리스트는 불공변성을 띈다. Super 클래스와 Sub 클래스가 계층관계에 있더라도 리스트의 계층관계가 함께 변하지 않는다. 예를들어 Sub가 Super의 하위 클래스라도 List<Sub>는 List<Super>의 하위 타입이 되지 않는다. 그저 다른 타입으로 인식한다.
List<Super> supList = new ArrayList<Sub>(); // 타입 불일치 관련 컴파일 에러가 발생한다.
공변은 컴파일 타임에 타입 에러를 발견하지 못할 수 있다.
Object[] objectArray = new Long[1];
objectArray[0] = "안녕하세요";
이 코드에서 컴파일 에러는 발생하지 않는다. 배열의 공변성에 의해 Object 배열과 Long 배열은 계층 관계를 갖게 되고, 다형성에 의해 상위 클래스 타입 변수에서 하위 타입 인스턴스를 참조할 수 있기 때문이다. Object 타입의 objectArray가 String 타입 값을 참조할 수 있는것도 마찬가지이다.
문법적으로는 문제가 없기 때문에 컴파일 오류는 발생하지 않으나 컴파일 시 업캐스팅했던 objectArray의 실제 타입이 Long 타입으로 바뀔 것이기 때문에 ArrayStoreException 가 발생한다는 경고가 나온다.
//----- 컴파일 전 (.java)
Object[] objectArray = new Long[1];
// 경고 : 타입 'java.lang.String'의 요소를 'java.lang.Long' 요소의 배열에 저장하면 'ArrayStoreException'이 발생합니다
objectArray[0] = "안녕하세요";
//----- 컴파일 후 (.class)
Long[] arrayOfLong = new Long[1];
arrayOfLong[0] = "안녕하세요";
Long용 저장소에 String 값을 넣을 수 없는 건 당연하다. 다만 배열 사용시 그 실수를 런타임에야 알게 되지만, 리스트를 사용하면 컴파일타임에 알 수 있다. 이게 배열보다 리스트를 사용해야 하는 가장 큰 이유 중 하나이다.
List<Object> list = new ArrayList<Long>(); // 컴파일 에러가 발생한다.
list.add("안녕하세요");
두번째 차이. 실체화 / 실체화 불가 타입
실체화 타입이란 컴파일 타임에 사용된 타입이 런타임에 소거되지 않는 타입이다. 실체화 불가 타입은 컴파일 타임에 사용된 타입이 런타임에 소거되는 타입이다.
조금 더 정확히 말하면 실체화 불가 타입은 해당 타입을 컴파일 타임에만 사용하여 타입 문제가 있는지 확인하고, 최종적으로 생성된 class 파일에서는 타입을 포함시키지 않는 것이다. 즉, 런타임에는 타입이 없는 상태, 소거된 상태로 실행되게 된다.
소거 원소 타입을 컴파일 타임에만 검사하고 런타임에는 해당 타입 정보를 알 수 없는 것을 의미한다.
이 둘의 개념이 잘 이해가지 않는다면 소거와 실체의 의미를 생각해보자. 실체(reify)란 '실제적인 것으로 만든다'라는 뜻이다. 무엇인가 소거 되버린 것으로는 실제적인 것을 만들지 못한다는 맥락에서 '실체화 불가 타입', 소거가 되지 않는다면 실제적인 것을 만들 수 있으니 '실제화 타입'으로 이해해보자.
그럼 런타임에 소거되는 타입은 뭘까? 바로 제네릭 타입이다. 제네릭을 사용하는 타입은 소거되어 런타임에 타입 정보를 알 수 없다. 아래 java 파일을 컴파일하면 타입 소거된 class 파일이 생성되는 것을 확인할 수 있다.
// 컴파일 전 (.java)
List<Integer> dice = List.of(1,2,3,4,5,6);
List<Integer> dices = new ArrayList<>();
// 컴파일 후 (.class)
List localList = List.of(Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3), Integer.valueOf(4), Integer.valueOf(5), Integer.valueOf(6));
ArrayList localArrayList = new ArrayList();
실체화라는 개념은 제네릭의 탄생과 연관이 있는 것 같다. 제네릭은 컴파일 타임에 타입 안전성을 확보하기 위해 Java 1.5 버전부터 등장했다. 그런데 제네릭이 등장하기 전 사용하던 Raw 타입과의 호환성을 유지해야 했기 때문에 제네릭 타입은 컴파일 시 타입 체크에만 사용한 후 소거하게 된것이고, 소거된 타입을 분리하기 위해 실체화 타입, 실체화 불가 타입이라는 개념이 등장한게 아닐까 싶다 (뇌피셜)
int, double 과 같은 원시 타입, 일반 클래스 및 인터페이스 타입, Raw 타입, List<?> 와 Map<?,?>와 같은 비한정적 와일드카드 타입을 실체화 타입으로 구분하고, List<T>, List<String>, List<? extends Number> 등과 같은 제네릭 타입 매개변수를 갖는 타입을 실체화 불가 타입이라 한다. 즉, 배열은 실체화 타입, 리스트는 실체화 불가 타입이라는 차이점이 있다.
제네릭 배열을 만들지 못하는 이유
이러한 주요 차이로 인해 배열과 제네릭은 잘 어우러지지 못한다. 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다. 결과적으로 제네릭 배열 생성이 허용되면 타입 안전성이 깨질 수 있다. 이는 런타임 시 ClassCastException이 발생하는 것을 막아주겠다는 제네릭 타입의 취지에 어긋나게 되는 것이다.
만약 제네릭 배열을 만들 수 있다고 가정한다면 어떤 상황에서 ClassCastException 런타임 예외가 발생하는지 알아보자.
제네릭 배열의 사고 예제
// 컴파일 에러가 발생하지 않는다고 가정하며 제네릭 배열을 선언한다.
List<String>[] stringLists = new ArrayList<String>[1];
// Integer 타입의 리스트를 생성한다
List<Integer> intList = List.of(42);
// objects 배열의 시작주소에 stringLists 배열의 시작주소를 할당. 배열은 공변이니 문제없음
Object[] objects = stringLists;
// objects 첫번째 원소에 intList를 저장한다.
objects[0] = intList;
// stringLists[0] 에는 intList 인스턴스가 저장되어 있으며,
// get 메서드를 통해 조회 및 자동 형변환 시 ClassCastException 발생함.
String s = stringLists[0].get(0);
즉, 제네릭을 사용하더라도 런타임에 ClassCastException이 발생하여 타입 안전성을 보장하지 못하게 되는 것이다. 이런 이유로 제네릭 배열을 만들지 못하도록 컴파일 에러를 발생시킨 것이다.
코드 리팩토링하기 (Object[ ] > Generic[ ] > List 순)
배열로 형변환할 때 형변환 경고가 뜨는 경우 대부분은 배열인 E[] 대신 List<E>을 사용하면 해결된다. 코드가 조금 복잡해지고 성능이 살짝 나빠질 수도 있지만, 타입 안전성과 상호운용성은 좋아진다.
public class Chooser {
private final Object[] choiceArray;
public Chooser(Collection choices){
choiceArray = choices.toArray();
}
public Object choose(){
Random rnd = ThreadLocalRandom.current();
return choiceArray[rnd.nextInt(choiceArray.length)];
}
public static void main(String[] args) {
List<Integer> dice = List.of(1,2,3,4,5,6);
Chooser chooser = new Chooser(dice);
Integer choose = (Integer) chooser.choose();
System.out.println(choose);
// String choose1 = (String) chooser.choose(); 올바르지 않는 타입으로의 형변환 > 런타임 예외 발생
}
}
위 코드는 choose 메서드를 호출할 때마다 반환된 Object를 원하는 타입으로 형변환해야한다. 만약 타입이 다르다면 런타임에 형변환 오류가 발생한다. 먼저 제네릭을 도입해 리팩토링하자.
배열에 Generic 적용하기
형변환 코드를 제거하기 위해 제네릭을 사용했다. 클래스 내에서 사용될 타입 매개변수 T를 전달받고, 생성자 메서드에 T 타입 매개변수를 갖는 컬렉션 타입 인스턴스를 전달받도록 수정했다. 이로써 형변환 하는 코드를 굳이 넣어주지 않아도 되게 되었다.
public class Chooser<T> {
private final T[] choiceArray;
public Chooser(Collection<T> choices){
choiceArray = (T[]) choices.toArray();
}
public T choose(){
Random rnd = ThreadLocalRandom.current();
return choiceArray[rnd.nextInt(choiceArray.length)];
}
public static void main(String[] args) {
List<Integer> dice = List.of(1,2,3,4,5,6);
Chooser<Integer> chooser = new Chooser<>(dice);
Integer value = chooser.choose();
System.out.println(value);
}
}
생성자에서 (T[ ]) 코드를 추가해 형변환하고 있다. 이유는 toArray() 메서드의 반환 타입이 Object[ ] 이기 때문이다. 그런데 (T[ ]) 를 추가한 부분에서 확인되지 않는 형변환 경고가 발생한다. 확인되지 않는 이유는 T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보장할 수 없다는 메시지이다.
안전하다고 확신이 든다면 @SuppressWarnings 어노테이션과 함께 주석을 달아줘도 되지만 배열 대신리스트를 사용한다면 경고 자체를 제거할 수 있다.
배열을 List로 변경하기
멤버필드를 리스트로 수정하고, Chooser 생성자 메서드에서 ArrayList 의 생성자 메서드를 사용하여 멤버필드에 값을 넣고 있다. 리스트를 사용하였고, 컴파일 오류가 발생하지 않았으니 런타임 시 타입 안전성이 보장되게 되었다.
public class Chooser<T> {
private final List<T> choiceList;
public Chooser(Collection<T> choices){
choiceList = new ArrayList<>(choices);
}
public T choose(){
Random rnd = ThreadLocalRandom.current();
return choiceList.get(rnd.nextInt(choiceList.size()));
}
}
정리
리스트는 컴파일 타입 에러를 잡아 런타임 시 타입 안전성을 확보할 수 있다는 이점이 있다. 배열과 제네릭을 사용하는 리스트에는 매우 다른 타입 규칙이 적용된다. 배열은 공변이고 실체화되는 반면, 리스트는 불공변이고 타입 정보가 소거된다. 그 결과 배열은 런타임에 타입 안전성을 확보할 수 없고. 리스트는 확보할 수 있다.
성격이 다른 둘을 섞어 쓰기란 쉽지 않다. 만약 둘을 섞어 쓰다가 컴파일 오류나 경고를 만나면, 가장 먼저 배열을 리스트로 대체하는 방법을 적용해야한다.
Controller 클래스에 대한 테스트 코드 작성 시 @WebMvcTest 어노테이션을 사용하여 웹 레이어에 대한 단위 테스트를 한다. 웹 레이어만 테스트하므로 JPA 관련 빈을 사용할 일이 없으며, Mock 객체를 등록할 필요도 없었다. 하지만 테스트 코드를 실행하니 japMappingContext 빈을 생성하지 못했다는 런타임 예외가 발생했다.
예외 내용 일부 Error creating bean with name 'jpaAuditingHandler': Cannot resolve reference to bean 'jpaMappingContext' while setting constructor argument ... Caused by: java.lang.IllegalArgumentException: JPA metamodel must not be empty
이에 대한 해결책으로 아래와 같이 JpaMetamodelMappingContext 클래스를 Mock Bean으로 등록하면 된다고 하지만, 왜 굳이 사용하지도 않고, 테스트하지도 않는 Jpa 관련 클래스를 모킹하는지 이해가 되지 않았다. 삽질을 하는 절차를 밟아보도록 하자. @_@
@WebMvcTest(NameController.class)
@MockBean(JpaMetamodelMappingContext.class) // 간단한 해결책
class NameControllerTest {
...
}
JpaMetamodelMappingContext 란?
JpaMetamodelMappingContext란? Spring Data JPA의 일부로서, 엔티티에 대한 매핑 정보를 제공하는 역할을 하는 하는 클래스. 즉, Spring 에서 JPA 엔티티의 메타데이터를 필요로 할때 해당 메타데이터를 제공해준다.
JpaMetamodelMappingContext빈은 어디서 등록되나?
디버깅을 통해 확인한 결과 DefaultListableBeanFactory 클래스의 beanDefinitionNames 필드에 JpaMetamodelMappingContext 에 대한 클래스 정보가 들어가는 있는 것을 확인했다. 테스트 실행 시 먼저 등록해야할 빈의 클래스 정보를 먼저 수집한 후 beanDefinitionNames 에 넣고있었다. 즉, 어떤 이유로 인해 JpaMetamodelMappingContext가 생성되어야할 빈 리스트로 포함된것이다.
해당 작업이 끝나면 아래와 같이 beanDefinitionNames 에 들어있던 클래스 정보를 하나씩 읽어 빈을 생성하게 된다.
그리고 이 메서드의 하단부에 아래와 같이 beanFactory에서 getBean을 통해 jpaMappingContext 에 대한 빈을 생성/조회 를 시도한다. 이때 예외가 발생하는데 내부적으로 JpaMetamodelMappingContext 빈을 생성 실패하면서 발생하게 된다.
이 빈에 대한 생성자를 보면 메서드의 models 값이 빈값으로 들어와 Assert 구문에 의해 JPA metamodel must not empty라는 예외가 발생하는 것을 알 수 있다.
어찌됐든 jpaMappingContext 가 어떤 이유로 인해 빈 등록을 시도하고 있고, 이를 찾아낸다면 이 에러의 원인도 해결할 수 있게 되었다.
DefaultListableBeanFactory 클래스가 뭐야
DefaultListableBeanFactory 클래스는 빈 팩터리 클래스 중 하나이며, 코드레벨에서 빈을 수동으로 등록할 때 이 클래스를 사용하여 빈 정보를 주입하기도 한다. 즉, 일반적인 빈 팩터리 클래스이다.
누가 JpaMappingContext 빈을 추가했나?
그럼 이 클래스를 어떤 클래스가 빈으로 추가하려 했는지 알아보기 위해 다시한번 디버깅의 늪으로 들어갔다. 그리고 이 문제의 원인을 찾았다. 바로 Application 클래스가 문제였다. 테스트 코드 실행 시 Application 클래스의 정보를 로드하면서 @EnableJpaAuditing 어노테이션이 동작하게 된 것이다. 그리고 이 어노테이션의 설정 정보로 인해 japMappingContext 빈을 등록하려 했던 것이다.
이 어노테이션은 설정 클래스로 JpaAuditingRegistrar.class를 로드하고 있는데 이 클래스는 ImportBeanDefinitionRegistrar의 구현체 클래스이다. 어플리케이션이 로드되면 구현체 클래스의 registerBeanDefinitions 메서드가 호출되는데, 바로 이때 jpaMappingContext 빈 정보를 등록하고, 뒤이어 auditingHandler 빈 정보도 등록한다.
(특이하게도 auditingHandler 라는 빈 핸들러를 추가할 때 빈 이름은 getAuditingHandlerBeanName(), 클래스는 null 로 등록하고 있는데 이 메서드의 반환 값이 바로 jpaAuditingHandler 였다.)
결론은 @EnableJpaAuditing 에 의해 jpaMappingContext 빈 생성 정보가 빈 팩토리로 들어갔다는 것이다.
테스트를 실행하는데 왜 Application 클래스의 정보를 읽는거야?
@WebMvcTest 를 사용하면 내부 동작에 의해 먼저 Application 클래스의 설정을 로드하고, 웹 레이어에 필요한 클래스를 스캔한다. 때문이다. 실제로 이 클래스에 있는 @EnableJpaAuditing 어노테이션을 제거하니 테스트코드에서 오류는 발생하지 않았다.
결론
JPA 관련 예외가 발생한 이유는 Application 클래스에서 사용하고 있던 @EnableJpaAuditing 에 의해 jpaMappingContext 빈 생성 정보를 빈 팩토리가 로드했기 때문이었다. 이로 인해 테스트 코드에서 빈 생성을 시도하게 되고 필요한 JPA 설정들은 추가하지 않은 상태에서 필요한 JpaMetamodelMappingContext 빈을 생성하지 못해 발생했다.
JpaMetamodelMappingContext 를 MockBean으로 등록하면 문제가 해결됐던 것도 이해가 갔다.
만약 누군가 이러한 에러를 마주하게 된다면 테스트 코드 어딘가에 JPA 관련 설정이 있는지와Application 클래스에 관련 어노테이션이 있는지를 꼭 하길 바란다.
제네릭을 사용하면 많은 비검사 경고들을 마주한다. 필자의 경우 이러한 경고들은 IDE의 도움을 받아 수정했으며, 수정 자체에 큰 의의를 두진 않았다. 하지만 이러한 경고를 제거하는 것만으로 그 코드는 ClassCaseException이 발생할 일이 없는 타입 안전성을 보장하는 코드가 된다고 한다. 이제 비검사 경고를 제거하는 올바른 방법을 알아보자.
컴파일러의 도움을 받아 제거하라
대부분의 경고는 컴파일러가 알려준 대로 수정하면 사라진다.
Set<Coin> coinSet = new HashSet();
위 코드는 '매개변수화된 클래스 HashSet을 원시사용 했다.' 라는 경고가 발생한다. 매개변수화된 클래스는 제네릭 클래스를 나타낸다. 즉, 제네릭 클래스인데 타입 매개변수를 사용하지 않았다는 경고이다.
Set<Coin> coinSet = new HashSet<Coin>();
Set<Coin> coinSet = new HashSet<>(); // 컴파일러의 추론 기능 활용
위와 같이 타입 매개변수를 명시하여 경고를 해결할 수도 있지만 다이아몬드 연산자(<>) 만으로 해결할 수 있다. 컴파일러가 올바른 실제 타입 매개변수를 추론해주기 때문이다.
경고를 제거할 수 없지만 타입 안전함이 확신된다면 경고를 숨겨라
타입 관련 경고를 제거하려면 @SuppressWarnings("unchecked") 어노테이션 사용하면 된다. 이 어노테이션은 개별 지역변수 선언부터 클래스 전체까지 어떤 선언에도 달 수 있지만 가능한한 좁은 범위에 적용해야 한다. 보통 변수 선언, 아주 짧은 메서드, 생성자에 사용되며, 절대 클래스 전체에 적용해서는 안된다.
아래는 ArrayList의 toArray 메서드이다.
public <T> T[] toArray(T[] a) {
if (a.length < size)
// Make a new array of a's runtime type, but my contents:
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
return 문에는 해당 어노테이션을 적용할 수 없으므로, 아래와 같이 변수를 선언하고 어노테이션을 적용해준다. 또한 경고를 무시해도 되는 안전한 이유를 항상 주석으로 남겨둔다.
public <T> T[] toArray(T[] a) {
if (a.length < size) {
// 생성한 배열과 매개변수로 받은 배열의 타입이 모두 T[]로 같으므로 올바른 형변환이다.
@SuppressWarnings("unchecked")
T[] result = (T[]) Arrays.copyOf(elementData, size, a.getClass());
return result;
}
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
SuppressWarnings 옵션
옵션
내용
all
모든 경고
cast
캐스트 연산자 경고
dep-ann
사용하지 말아야할 주석 경고
deprecation
사용하지 말아야할 메서드 경고
fallthrough
switch 문 break 누락 경고
finally
반환하지 않은 finally 블럭 경고
null
null 경고
rawtypes
제네릭을 사용하는 클래스 매개변수가 불특정일때 경고
unchecked
검증되지 않은 연산자 경고
unused
사용하지 않은 코드 관련 경고
정리
비검사 경고는 중요하니 무시하지 말자. 모든 비검사 경고는 런타임에 ClassCastException을 일으킬 수 있다. 경고를 없앨 방법을 찾지 못했다면, 그 코드가 타입 안전함을 증명하고 가능한 한 범위를 좁혀 @SuppressWarnings("unchecked") 어노테이션으로 경고를 숨기자. 그런 다음 경고를 숨긴 근거를 주석으로 남긴다.
OAuth2와 JWT를 사용하여 인증 정책을 수립했다. 그런데 문제가 발생했다. 인증이 필요한 API 요청 시 403 에러가 발생하는 것이다. 헤더에 JWT 토큰도 잘 들어가 있고, 토큰 값을 통해 생성한 인증객체도 Security Context Holder에 들어가 있으며, 인가 권한은 ROLE_USER 인데 말이다. 원인을 파악해보자.
Security Filter Chain 주요 필터
원인을 찾기 전 현재 Security Filter의 구성이 어떻게 이루어져있는지 간단하게 정리해보았다.
1. OAuth2AuthorizationRequestRedirectFilter
OAuth2AuthorizationRequestRedirectFilter에서는 yml 또는 properties에 설정한 값을 기반으로, registrationId에 대한 OAuth2 인증 서버의 로그인 URI를 redirect 해준다.
시큐리티 설정에 OAuth2 로그인 요청 URI는 아래와 같은 형식으로 정의해달라고 하는데, 그 이유가 바로 이 필터를 거치게 하려 함이다.
/oauth2/authorization/[registrationId]
2. OAuth2LoginAuthenticationFilter
OAuth2LoginAuthenticationFilter 에서 하는 일이 아주 많다.
첫째, 로그인 성공 시 자신의 서버로 redirect 되는 authorization code를 받고,
둘째, authorization code 를 활용하여 OAuth2 인증 서버로 accessToken을 요청하고,
셋째, accessToken을 활용하여 OAuth2 리소스 서버로 유저 정보를 요청한다.
넷째, OAuth2UserService 구현체 인스턴스를 호출하며, 내부에서 Authentication 인스턴스를 생성한다.
정리하면, OAuth2 인증을 받고, 인증 유저에 대한 정보를 획득한 후 인증 객체를 생성하는 역할이다.
참고로 인증이 성공하면 추후 AuthenticationSuccessHandler의 구현체 인스턴스를 호출하게 되는데 이때 JWT 토큰을 발급하며, 클라이언트는 이 값을 받아 헤더에 추가한다. (이건 필자의 인증 정책 중 하나이니 굳이 이해할 필요는 없다. JWT 를 발급한다는 것만 알면 된다.
3. JWT Filter
헤더의 JWT 토큰 값을 추출 후 검증한다. 유효한 토큰일 경우 Authentication 객체를 생성하여 Security Context Holder 내에 저장한다.
로그인은 성공했으나, 인증된 자원에 대한 요청은 실패
OAuth2 로그인은 성공하고 JWT 토큰도 잘 발급이 되고, 헤더로 유효한 JWT 토큰도 오는 걸 확인했으나, 인증이 필요한 API 요청에만 실패했다. SpringSecurity 설정에 인증 필터를 거치지 않도록 설정한 view/login, /error 등에 대해서는 403 에러가 발생하지 않는 점을 고려해봤을 때 시큐리티 필터에서 '인증' 관련 문제가 있음을 추정하게 되었다.
public class SecurityConfig {
private final UserRepository userRepository;
private final JwtProvider jwtProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http
.authorizeHttpRequests((authz -> authz
.requestMatchers("/api/**").hasRole("USER")
.anyRequest().authenticated())
)
...
return http.build();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().requestMatchers("/view/login", "/error", "/error/*", "/img/**", "/favicon.ico");
}
}
발견되지 않는 에러 로그
일반적으로 예외가 발생하면 에러 로그가 콘솔에 출력될텐데, 에러 로그가 출력되지 않았다. 시큐리티 로그에 /error 경로에 대한 요청이 들어오는걸로 봐서 내부 어딘가에서 예외가 발생했고, /error 경로로 리다이렉트 된것 같은데, 에러 로그가 보이지 않아 근원지를 찾을 수 없었다.
루트 로그 레벨을 trace 로 변경
/error 리다이렉트 원인이 되는 로그가 분명 찍었을 것이라 판단하고 루트 로그 레벨을 trace로 변경해보았다. 그리고 다시 실행을 하니 원인을 찾을 수 있었다. AuthrozationFilter에서 AccessDeniedException이 발생했고, 이로 인해 /error 경로로 리다이렉트 되고 있던 것이었다. 놀랍게도 로그 레벨은 TRACE 였다.
AuthorizationFilter 예외 발생 원인 분석
내부 소스를 확인해 보니 authrozationManager.check() 호출 후 얻은 AuthroziationDecision의 isGranted() 메서드의 호출 결과에 따라 Access Denied 예외가 발생함을 확인할 수 있었다. isGranted의 구현체 클래스를 따라가니 아래의 세 조건을 만족할 경우에만 true를 리턴하고 있었다.
그런데 필자가 생성했던 Authentication 객체는... authenticated 값이 false 였던..것이었다.
아... 나 커스텀 Authentication 클래스 사용했었구나.
커스텀 Authentication 클래스 확인
헐레벌떡 확인해보니 부모 클래스에 authenticated 값이 있었지만, 따로 설정해주지 않았고, 부모 생성자만 호출해서는 authenticated 값도 설정되지 않고 있었다. 그렇다. 따로 설정을 해줬어야 했다.
public class UserAuthenticationToken extends AbstractAuthenticationToken {
private final Long principal;
private final String credentials;
public UserAuthenticationToken(Long userId, String email, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = userId;
this.credentials = email;
}
@Override
public String getCredentials() {
return credentials;
}
@Override
public Long getPrincipal() {
return principal;
}
}
super.setAuthenticated(true) 코드를 넣어 authenticated 값을 true로 설정했다.
유저에 대한 '인가'를 하면 당연히 '인증'된 객체로 처리될 줄 알았으나, 안된다. 비록 삽질을 하긴 했지만 매우 건강한 삽질이 아니었나 싶다.
Authentication 객체의 authenticated 값을 AuthorizationFilter 에서 확인한다는 것과 커스텀 Authentication 클래스를 사용할 때 권한을 인가할지라도 인증은 되지 않아 authenticated 값이 자동으로 설정되지 않는다는 것도 알게 되었다. 인증 객체의 부모 생성자 메서드를 좀더 자세히 확인했더라면, 이 문제는 발생하지 않았을 테지만 OAuth2 필터 복습도 하고, 인증 매커니즘에 대해 다시 한번 정리를 하게 된 건강한 삽질이었다!