개요
최근 예외 처리가 중요한 발송 관련 부분을 개발하고있다(발송 외에 다른 모든 도메인에서도 예외 처리가 중요하긴 하다). 실수를 줄이기 위해 다른 서비스의 발송 코드도 많이 참고하고 있는데, 코드 중간중간 DB 처리가 의도한 대로 처리되지 않을 경우 exception을 던져 처리하는 부분이 눈에 많이 보였다.
위 사진에서는 예외 처리를 하기 위해 modify, insert 등의 쿼리 결과를 result 변수에 받고 해당 값을 그때그때 비교하여 예외를 던진다. 예외 처리를 위해 꼭 필요한 부분이지만 그것을 위해 매번 변수에 쿼리 결과를 할당하고 비교문이 들어가는것이 코드 이해를 어렵게 하는것 같아서 관련 유틸을 만들어보았다.
걸림돌
아래는 쿼리를 수행하고, 그 쿼리의 결과를 확인한 후 예외 상황이라면 로그를 기록하고 예외를 던지는 코드이다. 서비스에서 DB 예외 처리는 대부분 아래와 비슷하게 되어 있으며 유틸 메서드를 만들기 위해 필수 요소를 파악해 보았다.
코드를 통해 정리한 유틸 메서드가 받아야 할 필수 요소는 아래와 같다.
- 필수 요소
- 쿼리를 수행할 Repository 메소드
- Repository 메소드에 전달될 parameter
- 예상되는 쿼리 결과값
- 예외 발생 시 던질 Exception
- 선택 요소
- 에러 메시지
- 예외 발생 시 던질 Exception에 넣어줄 메시지
이렇게 필수 요소와 선택 요소로 구분하였고, 먼저 필수요소만을 포함하는 QueryUtil을 만들어보려고 했다. 여기서 문제가 발생했는데, 예상되는 쿼리 결과값은 int 타입으로 받으면 되지만, 그 외 3가지의 요소에는 어떤 하나의 타입만 들어오는 것이 아닌 여러 타입이 들어가야 했다. 따라서 이 문제를 해결하기 위해 평소 깊게 알고 있지 못했던 제네릭에 대해서 알아보았고, 그 과정에서 알게 된 제네릭에 대해 먼저 정리한다.
참고. 마이바티스의 쿼리 결과값
- Insert: 삽입된 행의 갯수
- Update: 업데이트된 행의 갯수
- Delete: 삭제된 행의 갯수
제네릭
자바스크립트에서는 List에 String이든 Number든 원하는대로 넣을수 있지만, 자바에서는 List 생성 시 정해진 타입만 넣을 수 있다. 하지만, 필요에 의해 String, Integer 두 타입을 모두 지원하는 List를 만들어야 할 때가 있을 수 있는데 이 때 제네릭을 통해 문제를 해결할 수 있다. 평소에 많이 사용하는 List로 예를 들면, List<T> list = new ArrayList<>(); 와 같이 선언하여 사용할 수 있고 여기서 <>가 제네릭 표현식이다.
제네릭은 타입이 클래스 내부가 아닌 외부에서 지정되는데, 특정(Specific) 타입을 미리 정의해주지 않고 필요에 의해 지정되는 일반(Generic) 타입이라는 의미로 이해하면 쉽다. 보통의 제네릭 함수나 클래스들을 보면 아래와 같이 K, V 등 알 수 없는 문자를 사용하는 부분이 있는데, 이는 제네릭 컨벤션과 관련이 있다.
표현 | 설명 |
<T> | Type |
<E> | Element |
<K> | Key |
<V> | Value |
<N> | Number |
위는 제네릭 컨벤션을 정리한 표인데 컨벤션을 반드시 따라야 할 필요도, 표현과 설명이 일치하도록 사용해야할 필요도 없다. 심지어 <Elem>과 같이 써도 전혀 무방하지만, 통상적으로 공유되는 암묵적 규칙이기 때문에 되도록이면 컨벤션을 지키려고 노력하면 좋을것 같다.
제네릭 클래스
제네릭을 사용하는 클래스나 인터페이스를 제네릭 클래스, 제네릭 인터페이스 라고 하는데 이들은 아래와 같이 선언할 수 있다.
public class ClassName <T> { ... }
public Interface InterfaceName <T> { ... }
또한 제네릭 타입을 여러 개 쓸 수도 있는데, 제네릭을 두 개 사용한 클래스를 만들고 그에 대한 인스턴스를 생성하려고 할때, 아래와 같이 사용할 수 있다.
public class ClassName <K, V> { ... }
public class Student { ... }
public class Main {
public static void main(String[] args) {
ClassName<String, Integer> instance1 = new ClassName<String, Integer>();
ClassName<String, Student> instance2 = new ClassName<String, Student>();
}
}
Main의 첫 번째와 같이 사용하게 되면 K는 String, V는 Integer가 된다. 이처럼 제네릭은 사용 시점에 외부로부터 타입이 정해지게 되는데, 주의할 점은 타입 파라미터로는 Integer, Long 등의 Reference Type만 올 수 있고, int, long 같은 Primitive Type은 올 수 없다. 참조 타입이 파라미터로 올 수 있으므로 사용자가 정의한 타입도 사용될 수 있다(상단 예시의 Student).
제네릭 메소드
이제 제네릭 공부를 시작하게 된 이유인 제네릭 메소드위 선언, 사용 방법에 대해 정리한다. 제네릭 메소드는 제네릭 클래스, 인터페이스와 선언 방법이 조금 다른데, 아래와 같이 선언할 수 있다.
public <T> T genericMethod(T param) { ... }
[접근제어자] <제네릭타입> [리턴 타입] [메소드명] ([제네릭타입] [파라미터 이름]) { ... }
제네릭 클래스 내부에 제네릭 메소드를 선언하면 아래와 같이 된다.
class GenericClass<E> {
static E errorMethod(E elem) {
return elem;
}
static <E> E genericMethod1(E elem) {
return elem;
}
static <T> T genericMethod2(T elem) {
return elem;
}
}
public class Main {
public static void main(String[] args) {
GenericClass.errorMethod(3); // compile error
GenericClass.genericMethod1(3); // 3
GenericClass.genericMethod2("abcd"); // abcd
}
}
위 예제에서 E를 제네릭으로 받는 GenericClass 내부에 세개의 정적 메서드를 생성했다. errorMethod는 Class가 받는 타입 E를 그대로 사용했고, genericMethod1과 genericMethod2는 클래스와 별개의 제네릭을 사용했다. 세 메서드 모두 정적 메서드이므로 인스턴스를 생성하지 않고 바로 호출할 수 있는데 이 경우, errorMethod만 에러가 발생하고 나머지 두 개의 메서드는 정상 실행된다.
예전 JVM에 관해 정리한 글에서 알 수 있듯이 static 변수, 메서드는 실제 코드가 실행되기 전, Class Loader에서 메모리에 미리 적재 해둔다. 코드를 다시 보면 static 메서드인 errorMethod는 클래스 로더에서 메모리에 적재할 때 GenericClass로부터 E 타입이 무엇인지확인한다. 하지만 GenericClass의 E 타입은 인스턴스를 생성할 때 외부에서 결정되기 때문에 참조할 수 없고, Error가 발생한다.
따라서 제네릭 클래스 내부에서 정적 제네릭 메서드를 선언하고자 할 때에는 제네릭 클래스와 별도인 독립적인 제네릭을 사용해야 한다. 실제로 genericMethod1에는 E 타입이 쓰였지만 해당 제네릭은 GenericClass의 E와 아무 관련이 없는 독립적인 타입이다.
제네릭 더 알아보기
제한된 제네릭
앞에서 알아보았듯이 제네릭을 통해 타입 유연성을 확보할 수 있지만, 모든 타입을 허용하는 것이 아닌 숫자 타입만을 허용하는 클래스를 만드는것과 같이 허용 타입의 경계를 정하고 싶을 수 있다. 제네릭에서는 이를 위해 extends, super 키워드와 와일드카드(?)를 제공한다. 와일드카드는 모든 타입을 허용, extends 키워드는 상한 경계를, super 키워드는 하한 경계를 나타내는데 이를 예를 들어 설명하면 아래와 같다.
먼저 A, B, C, D, E 다섯 가지의 클래스 구조가 위 그림과 같다고 가정한다. 이 상태에서 extends와 super 키워드를 사용했을 때 올 수 있는 타입은 아래와 같다.
<T extends A> // A, B, C, D, E 전부 올 수 있음
<T extends D> // D, E만 올 수 있음
<T extends C> // C만 올 수 있음
<T super A> // A만 올 수 있음
<T super B> // B, A만 올 수 있음
<T super E> // E, D, A만 올 수 있음
위의 예에서 알 수 있듯이 extends 키워드를 사용하면 해당 클래스와 해당 클래스를 상속한 하위 클래스만 올 수 있기 때문에 상한 경계, super 키워드를 사용하면 해당 클래스와 해당 클래스의 부모 클래스만 올 수 있기 때문에 하한 경계라고 표현한다.
예를 들어, 어떤 제네릭 메소드의 리턴값이 정수일지, 실수인지는 모르지만 숫자 형태여야만 한다고 가정한다면 아래와 같이 사용할 수 있다. 참고로 Integer, Long, Byte, Double, Float, Short와 같은 래퍼 클래스들은 Number 클래스를 상속받는다.
public class GenericClass {
public <N extends Number> N getNumber(N value) {
return value;
}
}
public class Main {
public static void main(String[] args) {
GenericClass gc = new GenericClass();
Integer intValue = gc.getNumber(1); // intValue = 1
Long longValue = gc.getNumber(1L); // longValue = 1L
String stringValue = gc.getNumber("string"); // error
}
}
위와 같이 extends와 super 키워드를 적절히 사용한다면, 제네릭을 사용하면서도 타입 체크의 안전성을 동시에 가져갈 수 있다.
제네릭을 사용하지 못하는 경우
맨 처음 제네릭에 대한 지식이 없을 때 아래와 같이 제네릭 타입에 new 연산자를 사용해 인스턴스를 생성하려 했으나 컴파일 에러가 났던 경우가 있다.
class GenericClass<E> {
public void makeList() {
Object[] testList = new E[5]; // compile error
}
}
위 코드에서 컴파일 에러가 발생하는 이유는, new 생성자를 이용하면 먼저 heap 영역에 충분한 공간이 있는지 확인 후 메모리 할당을 하기 때문이다. 충분한 공간이 있는지 확인하려면 어떤 타입인지를 알아야 하는데, 컴파일 타임에는 E 제네릭 타입이 어떤 타입인지 알 수가 없기 때문에 위와 같은 방법으로 인스턴스를 생성할 수 없다.
제네릭을 사용하지 못하는 또 하나의 경우는 static 변수 이다. 이 또한 앞선 JVM 정리에서 알아보았듯이 static 변수는 코드가 실제 실행되기 전, Class Loader에 의해 미리 메모리에 적재되어 클래스 레벨로 공유된다. 이 때 클래스 로더의 링크 단계에서 static 변수를 위한 메모리 값을 계산하는데, 타입을 알 수 없으므로 계산이 불가능하기 때문에 static 변수에는 제네릭을 사용할 수 없다. 만약 임의의 값으로 메모리를 계산하여 클래스 로딩 단계를 넘어간다고 해도, 인스턴스에 따라 클래스 레벨의 공유 자원인 static 변수가 달라진다는 것은 static 개념에 위배되므로 사용이 불가능하다. 일반 static 메소드 또한 컴파일 타임에 타입을 알 수 없으므로 제네릭을 사용할 수 없다. 아래와 같이 오직 제네릭 메소드만 static으로 사용 가능하다.
class GenericClass<E> {
static E staicValue = 1; // compile error
static E staticMethod(E value) { // compile error
return value;
}
static <E> E staticGenericMethod(E value) { // Ok
return value;
}
}
앞서 언급했듯이 staticGenericMethod 앞에 쓰인 E는 GenericClass의 E와는 별개의 변수이다. 이 메서드에서 E는 지역변수와 같이 취급되기 때문에 클래스 로딩 단계에서 따로 메모리를 할당하지 않고 사용 시점에 메모리를 계산하게 된다.
제네릭을 활용한 유틸 메소드
위에서 정리한 내용들을 적용하여 요구사항에 맞는 유틸 클래스를 생성해 보았다. 앞서 정리한 메소드의 필수, 선택 요소는 아래와 같다.
- 필수 요소
- 쿼리를 수행할 Repository 메소드
- Repository 메소드에 전달될 parameter
- 예상되는 쿼리 결과값
- 예외 발생 시 던질 Exception
- 선택 요소
- 에러 메시지
- 예외 발생 시 던질 Exception에 넣어줄 메시지
먼저 쿼리를 수행할 Repository 메소드는 다수의 타입을 인자로 받고 Number 타입 중 하나의 값을 리턴하는 Function이어야 한다. Repository 메소드에 전달될 parameter는 특정할 수 없는 여러 타입의 dto가 될 수 있으며, 예상되는 쿼리 결과값은 마이바티스 스펙을 보면 int 값으로 볼 수 있다. 마지막으로 예외 발생 시 던질 Exception은 RuntimeException을 상속한 커스텀 Exception이어야 한다.
선택 요소인 에러 메시지를 처리하기 위해 필수, 선택 요소를 모두 포함하는 공용 private 정적 제네릭 메소드를 하나 생성한 뒤, 용도에 따라 public 정적 제네릭 메소드를 만들어 미리 만들어둔 공용 메소드를 사용하는 식으로 구현하였다.
요구사항을 정리하여 반영한 결과 위와 같이 유틸 클래스를 생성했으며, 예외 발생 시 Exception에 넣어줄 메시지는 exceptionSupplier에 포함시켜 넘기도록 했다. 각 경우에 따라 위 메서드를 호출하는 방법은 아래와 같다.
정리
위와 같은 과정을 통해 DB 예외 처리를 공통으로 처리해주는 쿼리 유틸을 만들어볼 수 있었고, 그 과정에서 제네릭에 대한 공부도 할 수 있었다. 이 유틸 클래스를 사용하는게 무조건 좋다고는 할 수 없지만, 적어도 반복되는 예외 처리 과정을 서비스 코드에서 제거할 수 있게 되어서 어느정도는 만족하게 되는 것 같다.
참고
'Java' 카테고리의 다른 글
[Spring Cloud] Service Discovery (0) | 2023.01.22 |
---|---|
Jackson 라이브러리의 직렬화/역직렬화 (0) | 2022.09.21 |
GC 개념 및 동작 원리 (0) | 2022.09.13 |
JVM 이란? (0) | 2022.08.23 |
[Effective Java] Item02. 생성자에 매개변수가 많다면 빌더 패턴을 고려하라 (0) | 2022.01.14 |
댓글