반응형

개요

이 게시글은 자바 IO, NIO에 대해 정리하려고 했으나, 공부를 하다보니 InputStream, OutputStream 및 Stream과 같은 용어가 많이 등장했다. 곧 이에 대한 개념이 잡히지 않았되지 않다는 것을 알았고, 필요성을 느껴 이에 대해 정리해보았다. InputStream과 OutputStream에 대해 이해해보기 전 Stream의 개념에 대해 알아보자.

 

Stream이 뭐에요?

스트림이란 데이터, 패킷, 비트 등의 일련의 연속성을 갖는 흐름을 의미한다.

 

스트림과 비슷한 용어인 '스트리밍'을 생각해보자. 유투브에서 제공하는 영상들은 '동영상 스트리밍' 형태로 보게 되는데, 요즘은 영상 화질도 좋아져서 동영상의 용량이 어마어마하다는 것은 누구나 알것이다.

1.2GB 크기의 유투브 영상 추상화

 

하지만 이렇게 큰 용량을 가진 영상을 우리는 오랜 기다림 없이 바로 볼 수 있다. 그 이유가 뭘까? 서버에서 영상파일을 작은 단위로 쪼개어 클라이언트에게 전달하기 때문이다. 이처럼 데이터를 잘라 연속적인 데이터의 흐름 형태로 전달하는 것을 스트림이라고 한다.

 

네트워크가 불안정할 경우 영상이 끊긴 경험이 있을 것이다. 이 모두 영상 데이터에 대해 스트림 형태로 전달받다가 중간에 네트워크 문제가 생겨 다음 데이터를 전달을 받지 못해 발생하는 현상인 것이다. 만약 스트림이 아닌 영상 데이터를 한번에 전달받는 방식이었다면 1.2GB 크기의 유투브 영상을 모두 전달받기 전까지 영상을 재생할 수 없을 것이다.

 

InputStream이 뭐에요?

java.io 패키지에서 제공하는 이 InputStream는 데이터를 입력받기위한 스트림을 제공하는 추상 클래스이다.  입력받는다는 것은 데이터를 읽어오는 것이다. 

서브 클래스로 ByteArrayInputStream, FileInputStream, AudioInputStream 클래스 등 다양한 InputStream 클래스가 있는 것으로 보아 바이트 배열, 파일, 오디오 파일 등 다양한 형태의 데이터에 대한 스트림을 제공한다는 것을 알 수 있다. 한번 사용해보자.

 

Input, Output이 헷갈려요... 😫

Java 입장에서 생각해보자. Java 입장에서 Input은 들어오는 것이니 데이터를 읽어오는 것, Output은 나가는 것이니 데이터를 쓰는 것으로 이해하면 쉽다.

 

 

InputStream을 통해 텍스트 파일 읽어보기

// C:/testFile/txtFile.txt
aaaaaaaa
bbbbbbb
cccccccc
ddddddddd
eeeeeeee
ffffffff

 

위 텍스트 파일을 만든 후 InputStream으로 읽어오는 것을 테스트해보았다. 참고로 try/resources 를 사용해 close 메서드를 자동호출하도록 하였다. println 메서드를 통해 결과를 출력하면 '97'이 출력된다.

@Test
void fileInputStreamTest(){
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        System.out.println(inputStream.read()); // 97 출력
    }catch (IOException e){
        e.printStackTrace();
    }
}

 

 

 

아까 말했듯이 Stream은 잘게 잘린 데이터의 연속적인 흐름이다. FileInputStream을 사용하면 파일 데이터를 byte 타입의 쪼개진 데이터들로 받을 수 있고, read() 메서드는 스트림을 통해 1byte씩 읽는 메서드이기 때문에 97이라는 값이 출력된 것이다. 참고로 97은 'a' 문자의 아스키코드이다.

 

read()
Reads the next byte of data from the input stream.
= 입력 스트림에서 데이터의 다음 바이트를 읽습니다.

 

 

그럼 read() 메서드를 계속 호출하면 어떻게 될까? 🙄

 

아래와 같이 더 이상 읽을 데이터가 없는 시점부터 -1이 출력된다. 스트림이 비어있는 것이다.

@Test
void fileInputStreamTest(){
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        
        System.out.println(inputStream.read());
        System.out.println(inputStream.read());
        System.out.println(inputStream.read());
        ...
        ...
        System.out.println(inputStream.read()); //-1 출력
        System.out.println(inputStream.read()); //-1 출력
        System.out.println(inputStream.read()); //-1 출력
       
    }catch (IOException e){
        e.printStackTrace();
    }
}

 

 

이러한 특성때문에 일반적으로 스트림을 통해 데이터를 read 할때에는 while과 같은 반복문을 사용해 -1을 체크하는 구문이 들어가게 된다.

void fileInputStreamTest(){
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){

        int i = 0;
        while((i = inputStream.read()) != -1){
            System.out.write(i);
        }
    }catch (IOException e){
        e.printStackTrace();
    }
}

 

출력결과

 


잠깐, System.out.print와 System.out.write 의 차이

 

System.out.print는 다양한 타입의 데이터를 '텍스트 형식'으로 출력하도록 설계된 메서드이다. 예를들어 System.out.print(97)을 사용하면 숫자 97이 그대로 출력된다.

 

이에반해 System.out.write는 바이트 데이터를 출력하기 위해 사용하는 메서드이다. 메서드로 전달된 정수 값을 바이트로 변환하여 출력하기때문에 아스키 코드에 해당하는 문자를 출력한다. 예를들어 System.out.write(97)을 사용하면 97은 'a'로 변환되어 출력된다.

 

print는 사용자 친화적인 텍스트 출력에, write는 바이트 단위의 데이터 처리와 출력에 사용된다.

 


read() 메서드는 1byte씩 스트림으로 전달한다? 너무 느리지않나요? 🤔

1 바이트씩 처리하는 InputStream.read()

 

 

@Test
void fileInputStreamTest(){
    long start = System.currentTimeMillis();
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        int i = 0;
        while((i = inputStream.read()) != -1){
            //System.out.write(i);
        }
    }catch (IOException e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms"); // 2589ms
}

 

실제로 10만줄의 텍스트파일을 만들고, 불필요한 로직(System.out.write)을 제거한 후 실행해보니 온전히 InputStream을 통해 데이터를 읽는데만 약 2.5초(2589ms)가 걸렸다. Stream에서 1byte 씩 빼다보니 속도가 느린 것이다. 만약 Stream을 통해 데이터를 읽어올 때 1byte보다 큰 크기의 byte 데이터들을 읽어들인다면 속도가 개선되지 않을까?

 

 

b 바이트 만큼의 버퍼 단위로 처리하는 InputStream.read(byte[] b)

 

 
read(byte[] b)
Reads some number of bytes from the input stream and stores them into the buffer array b.
= 입력 스트림에서 일부 바이트를 읽고 이를 버퍼 배열에 저장합니다.

 

 

찾아보니 버퍼를 활용하는 read(byte[] b) 메서드가 있었다. 256byte의 버퍼 사이즈만큼 스트림에서 데이터를 읽도록 하니 속도가 12ms로 개선되었다. 아무래도 1byte씩 읽는 방식보다는 버퍼를 활용하는 방식을 사용하는 것이 좋아보인다. 

@Test
void fileInputStreamTest2(){
    long start = System.currentTimeMillis();
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        int i = 0;
        byte[] buf = new byte[256];
        while(inputStream.read(buf) != -1){
            //System.out.write(buf);
        }
    }catch (IOException e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms"); // 12ms
}

 

 

출력했더니 데이터가 누락됐어요! 😨

이제 작성한 코드에 있던 주석을 해제하고 읽어온 데이터를 콘솔에 출력해보았다. 그런데 ff 문자 두개가 누락된것 같다. 예외도 발생하지 않았다. 어째서 이런일이 발생하는 것일까? 

ff 문자가 누락된 모습

 

 

알고보니 데이터가 누락된게 아니었다. 이는 버퍼를 사용하는 메커니즘을 이해하지 못한 필자의 착각이었다. read(byte[] b) 메서드를 호출하면 직전 buffer에 스트림에서 읽은 데이터가 덮어 씌워진다. 필자는 buffer가 자동으로 비워질줄 알았는데 덮어 씌워지는 것이었다. 누락보다는 추가됐다고 할 수 있겠다.

 

Buffer는 있는 그대로 덮어 씌워버려요~

 

 

이때문에 InputStream.read(byte[] b) 메서드를 사용하려면 앞선 예제처럼 사용하면 안된다. buffer에서 read한 사이즈만큼 처리하는 로직을 따로 구현해야 한다.

앞서 read() 메서드는 스트림을 통해 읽은 바이트를 그대로 리턴한다. 이에반해 read(byte[] b) 메서드는 읽은 바이트의 수를 리턴한다. 이를 활용하여 i와 같은 임시 변수를 만든 후 읽은 바이트 수를 저장하고, 이 길이만큼 버퍼에서 읽도록 구현하자.

@Test
void fileInputStreamTest2(){
    long start = System.currentTimeMillis();
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        int i;
        byte[] buf = new byte[256];
        while((i = inputStream.read(buf)) != -1){
            for(int len = 0; len<i; len++){
                System.out.write(buf[len]);
            }
        }
    }catch (IOException e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms");
}

 


OutputStream은 뭐야?

OutputStream은 데이터를 출력하기 위한 스트림을 제공하는 추상 클래스이다. 자바 입장에서 데이터를 나가게 하므로 외부 파일에 데이터를 쓰는 것이라고 생각하면 쉽다.

 

이것도 마찬가지로 write(byte b) 메서드를 통해 1byte씩 출력 스트림을 통해 데이터를 전달할 수 있다.

@Test
void fileOutputStreamTest(){

    byte[] bytes = {97,97,97,97,97};

    try(OutputStream outputStream = new FileOutputStream("C:/testFile/txtFile.txt")){
        for(byte b : bytes){
        	outputStream.write(b);
        }
        outputStream.flush();
    }catch (Exception e){
        e.printStackTrace();
    }

}

 

 

txtFile.txt에 97을 가진 byte 배열을 쓰기(쓰기작업) 위해 write() 메서드를 호출한다. 그럼 txtFile.txt에는 97에 해당하는 아스키 코드 값인 a가 5개 작성된다.

OutputStream.write() 를 통해 aaaaa를 넣어보아요

 

 


1Byte씩? 이것도 느리지않나요? 🤔

속도 테스트를 위해 bigFile이라는 많은 데이터를 가진 텍스트파일을 만들고 이 byte 값을 OutputStream을 통해 전달해보았다. 텍스트 내 데이터를 복사하는 것이다. 첫번째는 InputStream을 통해 읽어들인 byte 데이터를 1byte씩 write하고, 두번째는 버퍼 사이즈만큼 write하도록 하였다. 결론부터 말하면 느렸고, 버퍼를 활용하는 것이 훨씬 빠르다.

 

1Byte씩 write

2654ms 초가 걸렸다. 1byte씩 Stream을 통해 데이터를 전달해서 그런지 속도가 느리다.

@Test
void fileOutputStreamTest2(){
    long start = System.currentTimeMillis();
    try(
            OutputStream outputStream = new FileOutputStream("C:/testFile/txtFile.txt");
            InputStream inputStream = new FileInputStream("C:/testFile/bigFile.txt"))
    {
        byte[] buf = new byte[256];
        int i;
        while((i = inputStream.read(buf)) != -1){
            for(int len = 0; len<i; len++){
                outputStream.write(buf[len]); // 1byte씩 write
            }
        }
    }catch (Exception e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms");
}

 

 

Buffer write

25ms가 걸렸다. InputStream 때와 마찬가지로 Buffer를 사용하니 스트림으로의 쓰기 작업 횟수가 줄어들어 속도가 굉장히 빨라진 것을 확인할 수 있었다.

@Test
void fileOutputStreamTest3(){
    long start = System.currentTimeMillis();
    try(
            OutputStream outputStream = new FileOutputStream("C:/testFile/txtFile.txt");
            InputStream inputStream = new FileInputStream("C:/testFile/bigFile.txt"))
    {
        byte[] buf = new byte[256];
        int i;
        while((i = inputStream.read(buf)) != -1){
            outputStream.write(buf,0,i); // 버퍼의 0부터 읽은 사이즈까지 한번에 write
        }
        outputStream.flush();
    }catch (Exception e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms");
}

 

 

 

InputStream, OutputStream을 사용할때 무조건적으로 예제에서 제공하는 코드를 사용하다가는 큰코다칠 수 있다. 버퍼를 적극 활용하고 입출력에 대한 실행 시간을 고려해보는 습관을 갖자.

반응형
반응형

JVM이 뭐야?

JVM(Java Virtual Machine)은 바이트코드(.class)를 OS에 특화된 코드(기계어)로 변환하고, 이를 실행하는 '가상의 머신'이다. 특정 OS에 특화된 코드로 변환하기때문에 OS 종속적이다. JVM은 JRE에 내포되어 있다.

 

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, 데이터 액세스 관련 라이브러리를 제공하는 것이다.

 

JVM , JRE, JDK 의 구조

 

 


Oracle Java 11 버전부터는 JRE를 따로 제공하지 않아요

Oracle 홈페이지에 들어가면 Java 8의 경우 JRE를 따로 제공했지만, Java 11 이상은 제공하지 않음을 확인할 수 있다.

 

JRE와 JDK를 각각 지원하던 Java 8

https://www.oracle.com/kr/java/technologies/javase/javase8-archive-downloads.html

 

Java Archive Downloads - Java SE 8 | Oracle 대한민국

죄송합니다. 검색 내용과 일치하는 항목을 찾지 못했습니다. 원하시는 정보를 찾는 데 도움이 되도록 다음을 시도해 보십시오. 검색에 사용하신 키워드의 철자가 올바른지 확인하십시오. 입력

www.oracle.com

 

JDK만 지원하는 Java 11 이상 버전

https://www.oracle.com/kr/java/technologies/javase/jdk11-archive-downloads.html

 

Java Archive Downloads - Java SE 11 | Oracle 대한민국

WARNING: These older versions of the JRE and JDK are provided to help developers debug issues in older systems. They are not updated with the latest security patches and are not recommended for use in production. For production use Oracle recommends downlo

www.oracle.com

 


JVM의 구조

 

JVM 구조

 

 


Class Loader

Class Loader

 

 Java는 동적로딩을 하는 특징이 있다. 이 동적로딩을 담당하는 부분이 바로 클래스 로더이다.

 

동적로딩
어플리케이션 실행 시점에 모든 클래스 파일을 메모리에 올려두는 것이 아닌, 필요할 때 하나씩 메모리에 올리는 방식을 말한다. 즉, 런타임시 동적으로 클래스파일을 로드하는 것이다.

 

클래스 로더는 class 파일을 읽어 OS에서 할당한 JVM의 메모리 영역으로 동적 로딩한다. 이 과정은 로딩, 링크, 초기화라는 3단계로 구성된다.

 

로딩

.class 파일(바이트 코드)을 읽고, 이를 JVM 메모리의 메서드 영역에 저장한다.

저장되는 정보는 FQCN, 타입(클래스인지, 인터페이스인지, 이늄인지), 메서드, 변수이다.

리플렉션 API를 통해 읽어오는 FQCN, 메서드, 파라미터, 멤버필드와 같은 클래스 정보를 바로 이 메서드 영역에서 읽어온다.

 

* 로딩이 끝나면 해당 클래스 타입의 Class 객체를 생성하여 힙 영역에 저장한다.

 

FQCN(Fully Qualified Class Name)
패키지 경로를 포함한 클래스 풀 네임을 말한다.

 

링크

.class 파일이 유효한지 검증하고 클래스가 필요한 메모리 양을 미리 할당하며, 클래스가 참조하는 실제 메모리 주소값을 할당한다. 위 작업들은 검증단계, 준비단계, 분석단계로 구분된다.

 

검증단계 : class 파일이 유효한지 검증한다. 

준비단계 : 클래스가 필요한 메모리 양을 미리 할당한다. 

분석단계 : 클래스가 참조하는 실제 메모리 주소값을 할당한다.

 

초기화

클래스 변수(static 변수)를 초기화한다.

 

 

클래스 로더의 종류

클래스 로드 요청을 받으면 캐시에서 로드하고, 캐시에 없을 시 상위 클래스 로더부터 하위 클래스 로더 순으로 요청받은 클래스의 로드 작업을 수행한다.

 

클래스 로더의 계층구조

 

 

BootStrap Class Loader > JAVA_HOME/lib 경로에 있는 자바의 기본 클래스 로드

 

Plaform Class loader > JAVA_HOME/lib/ext 경로에 있는 자바의 확장 클래스 로드

 

Application Class loader > -classpath 옵션 또는 java.class.path 환경 변수의 값에 해당하는 위치에서 클래스를 로드 


JVM 메모리

 

메서드 영역

클래스 수준의 정보 (클래스 이름, 메서드, 변수, 부모클래스 이름)가 저장된다.

여러 쓰레드들이 공유하여 사용하는 공유자원 영역이다.

 

힙 영역

생성한 인스턴스들이 저장된다. 여러 쓰레드들이 공유하여 사용하는 공유자원 영역이다. 힙 영역의 인스턴스는 GC에 의해 메모리에서 제거된다.

 

스택영역

 스택영역에는 쓰레드마다 런타임 스택을 만들고, 그 안에 메서드 호출을 스택 프레임(메서드 콜)이라 부르는 블럭으로 쌓는다. 쓰레드를 종료하면 런타임 스택도 사라진다. 예외가 발생했을 때 로그에 쭉 쌓이는 스택들이 바로 스택영역으로부터 추출한 데이터들이다.

스택 영역에서 조회한 스택

 

PC 레지스터

쓰레드마다 현재 어느 메서드를 콜하고 있는지를 가리키는 포인터를 저장하는 곳이다.

 

네이티브 메서드 스택

네이티브 메서드를 호출할 때 사용하는 별도의 스택이 저장된다.

 

JNI(Java Native Interface)

 자바 어플리케이션에서 C, C++, 어셈블리로 작성된 함수를 사용할 수 있는 방법을 제공하는 인터페이스이며, native 키워드가 붙어 있다. Thread의 currentThread() 메서드 또한 네이티브 인터페이스 중 하나이다.

 

Thread.currentThread()

 

Native Method Library

 C, C++로 작성된 네이티브 라이브러리이다.

 

 

* 스택과 PC 레지스터, 네이티브 메서드 스택은 쓰레드별로 생성되며, 쓰레드끼리 공유하지 않는다.

 


실행엔진

 

인터프리터

 바이트 코드를 한줄 씩 실행하는 프로그램이다.

 

JIT 컴파일러

 인터프리터 효율을 높이기 위해, 인터프리터가 반복되는 코드를 발견하면 JIT 컴파일러로 반복되는 코드를 모두 네이티브 코드로 바꿔둔다. 그 다음부터 인터프리터는 네이티브 코드로 컴파일된 코드를 바로 사용한다.

 

GC(가비지 컬렉터)

 더 이상 참조되지 않는 인스턴스들을 정리해주는 프로그램이다.

 


출처

더 자바, 코드를 조작하는 다양한 방법 - 인프런 백기선님 강의

https://www.youtube.com/watch?v=-p5vM1PSOVs - 개발자 장고님의 유투브 동영상

반응형
반응형

개요

 배열과 리스트 모두 여러 값을 관리하게 위해 사용한다. 기능적으로 같은 역할을 하는 배열과 리스트는 어떤 차이점이 있고, 이 중 어떤 타입을 사용하는게 더 기능적으로 유용할까?

 


결론부터 말하면 리스트

결론부터 말하면 리스트를 사용해야 한다. 왜? 그걸 이해하기 위해서는 변성, 공변, 불공변(무공변), 소거, 실체화 타입, 실체화 불가 타입과 같은 개념들을 이해해야한다. 하나씩 이해하며 왜 리스트를 사용해야하는지 알아보자.

 


첫번째 차이. 변성

 

 리스트와 배열의 첫번째 차이는 변성이다. 변성이란 타입의 계층 관계에서 서로 다른 타입 간에 어떤 관계가 있는지 나타내는 개념이다. 변성은 크게 공변, 반공변, 불공변(== 무공변) 으로 나뉘며 배열과 리스트와 연관성이 있는 공변과, 불공변에 대해 알아보자.

 


배열은 공변 (共變)

 공변은 '함께 공(共)', '변할 변(變)' 이라는 한자어 그대로 '함께 변한다'는 뜻이다. 함께 변하는 주체는 바로 계층관계이다. 즉, 타입의 계층관계에 따라 배열의 계층관계도 함께 변하는 것이다. 예를들어 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 에 들어있던 클래스 정보를 하나씩 읽어 빈을 생성하게 된다.

jpaMappingContext에 대한&nbsp; 빈을 생성하려는 코드

 

 

 그리고 이 메서드의 하단부에 아래와 같이 beanFactory에서 getBean을 통해 jpaMappingContext 에 대한 빈을 생성/조회 를 시도한다. 이때 예외가 발생하는데 내부적으로 JpaMetamodelMappingContext 빈을 생성 실패하면서 발생하게 된다.

 

getBean 도중 예외 발생

 

 

 이 빈에 대한 생성자를 보면 메서드의 models 값이 빈값으로 들어와 Assert 구문에 의해 JPA metamodel must not empty라는 예외가 발생하는 것을 알 수 있다.

JpaMetamodelMappingContext의 생성자 메서드

 

 

어찌됐든  jpaMappingContext 가 어떤 이유로 인해 빈 등록을 시도하고 있고, 이를 찾아낸다면 이 에러의 원인도 해결할 수 있게 되었다.


DefaultListableBeanFactory 클래스가 뭐야

DefaultListableBeanFactory  클래스는 빈 팩터리 클래스 중 하나이며, 코드레벨에서 빈을 수동으로 등록할 때 이 클래스를 사용하여 빈 정보를 주입하기도 한다. 즉, 일반적인 빈 팩터리 클래스이다.

 


누가 JpaMappingContext 빈을 추가했나?

 그럼 이 클래스를 어떤 클래스가 빈으로 추가하려 했는지 알아보기 위해 다시한번 디버깅의 늪으로 들어갔다. 그리고 이 문제의 원인을 찾았다. 바로 Application 클래스가 문제였다. 테스트 코드 실행 시 Application 클래스의 정보를 로드하면서 @EnableJpaAuditing 어노테이션이 동작하게 된 것이다. 그리고 이 어노테이션의 설정 정보로 인해 japMappingContext 빈을 등록하려 했던 것이다.

 

Application 클래스에 떡하니 있는 @EnableJpaAuditing

 

 이 어노테이션은 설정 클래스로 JpaAuditingRegistrar.class를 로드하고 있는데 이 클래스는 ImportBeanDefinitionRegistrar의 구현체 클래스이다. 어플리케이션이 로드되면 구현체 클래스의 registerBeanDefinitions 메서드가 호출되는데, 바로 이때 jpaMappingContext 빈 정보를 등록하고, 뒤이어 auditingHandler 빈 정보도 등록한다.

(특이하게도 auditingHandler 라는 빈 핸들러를 추가할 때 빈 이름은 getAuditingHandlerBeanName(), 클래스는 null 로 등록하고 있는데 이 메서드의 반환 값이 바로 jpaAuditingHandler 였다.)

 

 결론은 @EnableJpaAuditing 에 의해 jpaMappingContext 빈 생성 정보가 빈 팩토리로 들어갔다는 것이다.

 

jpaMappingContext 와&nbsp;&nbsp;jpaAuditingHandler 빈 정보 등록

 

auditingHandlerBeanName 메서드

 


테스트를 실행하는데 왜 Application 클래스의 정보를 읽는거야?

 @WebMvcTest 를 사용하면 내부 동작에 의해 먼저 Application 클래스의 설정을 로드하고, 웹 레이어에 필요한 클래스를 스캔한다. 때문이다. 실제로 이 클래스에 있는 @EnableJpaAuditing 어노테이션을 제거하니 테스트코드에서 오류는 발생하지 않았다.

 


결론

  JPA 관련 예외가 발생한 이유는 Application 클래스에서 사용하고 있던 @EnableJpaAuditing 에 의해 jpaMappingContext 빈 생성 정보를 빈 팩토리가 로드했기 때문이었다. 이로 인해 테스트 코드에서 빈 생성을 시도하게 되고 필요한 JPA 설정들은 추가하지 않은 상태에서 필요한 JpaMetamodelMappingContext 빈을 생성하지 못해 발생했다. 

 JpaMetamodelMappingContext 를 MockBean으로 등록하면 문제가 해결됐던 것도 이해가 갔다.

 만약 누군가 이러한 에러를 마주하게 된다면 테스트 코드 어딘가에 JPA 관련 설정이 있는지와 Application 클래스에 관련 어노테이션이 있는지를 꼭 하길 바란다.

 

 

반응형
반응형
반응형

개요

 OAuth2와 JWT를 사용하여 인증 정책을 수립했다. 그런데 문제가 발생했다. 인증이 필요한 API 요청 시 403 에러가 발생하는 것이다. 헤더에 JWT 토큰도 잘 들어가 있고, 토큰 값을 통해 생성한 인증객체도 Security Context Holder에 들어가 있으며, 인가 권한은 ROLE_USER 인데 말이다. 원인을 파악해보자.

뜬금없이 발생하는 403


Security Filter Chain 주요 필터

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 였다. 

TRACE 에러는 왜...
TRACE 레벨로 발생하는 예외


AuthorizationFilter 예외 발생 원인 분석

 내부 소스를 확인해 보니 authrozationManager.check() 호출 후 얻은 AuthroziationDecision의 isGranted() 메서드의 호출 결과에 따라 Access Denied 예외가 발생함을 확인할 수 있었다. isGranted의 구현체 클래스를 따라가니 아래의 세 조건을 만족할 경우에만 true를 리턴하고 있었다.

예외 발생 부분
isGranted 메서드

 

  그런데 필자가 생성했던 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로 설정했다.

public UserAuthenticationToken(Long userId, String email, Collection<? extends GrantedAuthority> authorities) {
    super(authorities);
    super.setAuthenticated(true);
    this.principal = userId;
    this.credentials = email;
}

 


테스트

잘된다.   👷 (삽을 어디뒀더라...)


회고

 유저에 대한 '인가'를 하면 당연히 '인증'된 객체로 처리될 줄 알았으나, 안된다. 비록 삽질을 하긴 했지만 매우 건강한 삽질이 아니었나 싶다.

 Authentication 객체의 authenticated 값을 AuthorizationFilter 에서 확인한다는 것과 커스텀 Authentication 클래스를 사용할 때 권한을 인가할지라도 인증은 되지 않아 authenticated 값이 자동으로 설정되지 않는다는 것도 알게 되었다. 인증 객체의 부모 생성자 메서드를 좀더 자세히 확인했더라면, 이 문제는 발생하지 않았을 테지만 OAuth2 필터 복습도 하고, 인증 매커니즘에 대해 다시 한번 정리를 하게 된 건강한 삽질이었다!

 

 

반응형
반응형

1. 개요

 스프링 시큐리티에서 제공하는 OAuth2Login을 통해 사용자 정보를 받아오고, 자체적으로 JWT 토큰을 발급하고 있다. JwtAuthorizationFilter에서 JWT 토큰에 대한 인증 처리 로직을 구현하였고, 구글링을 통해 필터의 위치를 UsernamePasswordAuthenticationFilter 이후로 설정하였다.

 요청마다 JWT Filter가 호출되긴 했지만 막상 Security Filter 로그를 통해 Security Filter Chain 리스트를 보니  UsernamePasswordAuthenticationFilter 가 보이지 않았다. 😲

눈씻고 찾아봐도 없는&nbsp;UsernamePasswordAuthenticationFilter

 이 필터가 없는 이유없는 필터에 커스텀 필터가 추가되는 것이 이해 되지 않았다. 이 이유를 알아보자.


2. UsernamePasswordAuthenticationFilter 가 없는 이유

 이유는 매우 간단했다. UsernamePasswordAuthenticationFilter는 클라이언트에서 요청한 username과 password를 통해 인증을 처리하는 필터이다. formLogin 시 UsernamePasswordAuthenticationFilter 가, OAuth2Login시 OAuth2AuthorizationRequestRedirectFilter와 OAuth2LoginAuthenticationFilter 가 추가된다.

OAuth2Login 을 사용하므로 UsernamePasswordAuthenticationFilter 가 없는건 매우 당연했다. 

oauth2Login


3. OAuth2AuthorizationRequestRedirectFilter와 OAuth2LoginAuthenticationFilter

 OAuth2AuthorizationRequestRedirectFilter는 OAuth2 서비스 제공 서버의 로그인 페이지를 호출하는 필터이다. 기본 제공하지 않는 Naver나 Kakao의 로그인 페이지는 yml 설정한 정보를 조합하여 uri를 만든 후 호출한다.

 

 OAuth2LoginAuthenticationFilter는 AbstractAuthenticationProcessingFilter의 서브클래스로 RedirectURI를 통해 받은 AuthorizationCode토큰 인증 API를 호출하여 accessToken 및 refreshToken을 받아오고, 이를 통해 유저 정보 조회 API를 호출하여 유저 정보도 받아온다. 관련 로직은 아래와 같다.

 

1) getAuthenticationManager().authenticate() 메서드를 호출

OAuth2LoginAuthenticationFilter 의 AuthorizationCode를 통한 인증 메서드

 

2) OAuth2LoginAuthenticationProvider 클래스의 authorizationCodeAuthenticationProvider.authenticate() 메서드 호출하여 accessToken과 refreshToken 취득

token 취득 부분

 

3) OAuth2LoginAuthenticationProvider 클래스의 userService.loadUser() 메서드 호출하여 유저 정보 취득 (OAuth2UserService 를 재정의하여 사용하기도 함.)

유저 정보 취득 부분


4. Filter Chain에 없는 AbstractAuthenticationProcessingFilter 

 디버깅을 하면서 추적해 나가다보니 AbstractAuthenticationProcessingFilter 클래스의 특정 메서드가 호출되는 부분이 있었다. 이 필터에서는 요청 URI를 추출하여 OAuth2 서비스에 대한 redirectURI로 온 요청일 경우 attemptAuthentication 메서드를 통해 OAuth2LoginAuthenticationFilter의 인증 처리를 하고 있었다. 그런데 이 필터는 Security Filter Chain 리스트에 없다. 이 녀석의 정체는 뭘까? 🤔

AbstractAuthenticationProcessingFilter 코드 일부

 

 스프링 공식문서를 보면 아래와 같이 해당 클래스의 서브 클래스 리스트가 나온다. 그런데 아주 눈에 익은 클래스가 보인다. 그렇다. OAuth2LoginAuthenticationFilter가 이 클래스의 서브클래스였다.  😲

AbstractAuthenticationProcessingFilter

Direct Known Subclasses:
CasAuthenticationFilter, OAuth2LoginAuthenticationFilter, Saml2WebSsoAuthenticationFilter, UsernamePasswordAuthenticationFilter

 

 필터를 신경써서 봤다면 OAuth2LoginAuthenticationFilter는 이 클래스를 상속받고 있고, 추상 메서드인 attemptAuthentication 를 구현함을 알 수 있었을것이다. 참고로 OAuth2LoginAuthenticationFilter는 Security Filter Chain 목록에 있다.

OAuth2LoginAuthenticationFilter


5. UsernamePasswordAuthenticationFilter 가 없어도 JwtAuthorizationFilter 가 추가된 이유

 두번째로 궁금했던 UsernamePasswordAuthenticationFilter 가 없어도 커스텀한 필터가 추가된 이유를 알아보았다. 추가에 사용한 메서드는 addFilterAfter() 이며 Security 설정 부분에 아래와 같이 사용하였다.

.addFilterAfter(new JwtAuthorizationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);

 

 결론부터 말하면 스프링 시큐리티에서 필터를 유연하게 추가할 수 있도록 내부 로직이 구성되어 있기 때문이었다. UsernamePasswordAuthenticationFilter 처럼 실제로 사용하지 않는 필터라도 말이다.

 

 아래 코드는 addFilterAfter 메서드를 실행했을 때 FilterOrderRegistration 클래스의 getOrder 메서드를 통해 UsernamePasswordAuthenticationFilter의 위치를 Integer로 추출하는 로직이다.

필터의 순서를 조회하는 getOrder

 

 filterToOrder은 필터 클래스 이름과 순서를 Map 타입으로 관리하는 변수이다. 이는 FilterOrderRegistration 클래스의 생성자 메서드를 통해 생성된다. 여기서 중요한 점은 이 값은 순수하게 필터의 순서를 관리하기 위해 존재한다는 것이다.

필터의 클래스명과 순서를 관리하는 filtersToOrder

 

 위 로직을 통해 UsernamePasswordAuthenticationFilter가 1900번째 필터임을 알았으며, 해당 값에 offset 인 1을 추가하여 새로 추가될 필터인 JwtAuthorizationFilter를 1901번 필터로 추가하였다. 즉, UsernamePasswordAuthenticationFilter의 바로 다음 순서의 필터로 JwtAuthorizationFilter를 지정한 것이다.

그 후 filters에 JwtAuthorizationFilter와 1901 정보를 갖는 OrderedFilter 타입 인스턴스를 추가하였다.

filters가 실제 어플리케이션에서 사용될 필터들을 관리한다.

filters에 추가되는 부분

 

 최종적으로 등록된 filters 리스트를 살펴보면 filter와 order 필드를 갖는 OrderedFilter 타입의 객체들이 있으며, order를 오름차순으로 정렬해보면 이 포스팅의 제일 첫 그림과 동일한 순서를 갖는 필터 리스트임을 확인할 수 있다. 이러한 내부 로직에 의해 실제로 사용하지 않는 필터에 대해 addFilterAfter와 같은 메서드를 사용하여 필터를 추가해도 에러가 발생하지 않았던 것이었다.

filters 리스트


6. JwtAuthrizationFilter의 위치

 위를 근거로 하여 JwtAuthrizationFilter는 OAuth2LoginAuthenticationFilter 다음으로 수정하였다. 

.addFilterAfter(new JwtAuthorizationFilter(jwtProvider), OAuth2LoginAuthenticationFilter.class);

7. 회고

 단순히 UsernamePasswordAuthenticationFilter가 왜 없지? 라는 단순한 호기심으로 시작했지만 내부 코드를 까보며 OAuth2 인증이 스프링 내부에서 어떻게 처리되는지, 필터는 어떻게 구성되는지에 대해 이해하게 되었고, 1,2년 전쯤 스프링 시큐리티의 폼 인증에 대한 내부 로직에 대해 정리한 적이 있는데, 이를 한번 더 상기하게 되었고, 모든 코드에는 근거가 있어야함을 다시한번 느끼게된 좋은 계기가 되었다.

반응형
반응형

1. 개요

 토비의 스프링을 공부하다가 SQL 쿼리 정보를 담은 XML 파일을 언마샬링하여 객체로 만들고, 이를 DAO 로직에 적용하는 부분이 있었다. 이때 사용했던 마샬링과 언마샬링에 대해 포스팅한다.

 


2. 마샬링

2.1. 마샬링이 뭔가요?

 마샬링이란 객체나 특정 형태의 데이터저장 및 전송 가능한 데이터 형태로 변환하는 과정을 말한다.

언마샬링은 변환했던 데이터를 원래대로 복구하는 과정을 말한다.

 

2.2. 개념이 잘 와닿지 않아요

 데이터를 다른 형태로 변환한다는 개념이 너무 추상적이라 그런지 마샬링의 개념이 잘 이해가 되지 않았다. 그래서 데이터 형태를 변환하는 다른 개념들을 함께 찾아보고, 비교하면서 개념을 이해해보기로 했다.

 

2.3. 여러가지 변환 과정

 찾아보니 인코딩, 디코딩, 파싱과 같은 친숙한 용어들이 보였다. 마샬링도 이와 비슷한 맥락의 개념이라고 생각하니 이해가 훨씬 쉬웠다.

 

- 마샬링 / 언마샬링

 객체나 특정 형태의 데이터저장 및 전송 가능한 데이터 형태로 변환하는 과정이다. 예를 들어, 네트워크 통신에서 객체를 전송하기 위해 바이트 스트림으로 변환하는 것이 마샬링이고, 받은 데이터 스트림을 다시 객체로 변환하는 것이 언마샬링이다.

 

- 인코딩 / 디코딩

 데이터를 특정 형식이나 표현 방식으로 변환하는 과정이다. 예를 들어, 텍스트를 UTF-8이나 Base64 형식으로 변환하는 것이 인코딩이고, 원래의 텍스트로 변환하는 것이 디코딩이다.

 

- 파싱

 웹, 문자열, 파일 등으로부터 정보를 추출하여, 원하는 데이터 구조나 객체로 변환하는 과정이다. 예를 들어, 특정 웹페이지의 HTML 문자열을 로드하여 필요한 데이터만을 추출한 후 객체 형태로 저장하는 것이 파싱이다.

 

- 정규화 / 비정규화

 데이터베이스에서 데이터를 효과적으로 저장하거나 조회하기 위해 테이블 구조를 변환하는 과정이다.

 

- 직렬화 / 역직렬화

 객체나 데이터 구조를 연속적인 바이트 스트림으로 변환하는 과정이다. 웹에서는 그 의미가 확장되어 Json 데이터를 객체로 변환하거나, 객체를 Json 데이터로 변환하는 것을 의미한다. 네트워크를 통한 데이터 전송이나 파일 저장 등에 사용된다.

 

 직렬화는 마샬링과 비슷한 개념을 갖고 있는데, 직렬화는 연속적인 바이트 스트림으로의 변환을, 마샬링은 특정 통신 프로토콜이나 파일 포맷에 맞게 데이터를 변환하는 것에 초점을 둠으로써 마샬링이 직렬화보다 더 큰 범위의 과정을 의미한다. 즉, 직렬화는 마샬링이라고 할 수 있지만, 마샬링은 꼭! 직렬화다! 라고 할 순 없다.

 


3. 마샬링, 어디에 쓰이는데?

 필자의 경우 마샬링이라는 용어를 2년차에 처음 접했다. 특정 설정 정보들을 DB에 저장하고, 이 데이터를 통해 XML 파일로 변환하는 방식을 알아보라는 윗분의 요청이 있었고, 이때 마샬링이라는 개념이 XML에만 국한된 것으로 이해했다. 하지만 앞서 내용을 보다시피 아주 포괄적인 개념임을 알 수 있다.

 

 그렇다면, 전송 가능한 데이터 형태로 변환하는 것은 어플리케이션 개발을 함에 있어 꼭 들어가야하는 과정 중 하나인데, 왜 빨리 접하지 못했고, 마샬링이란 기술을 사용한 기억이 없을까? 그 이유는 대부분 프레임워크 내부에서 마샬링이 진행되기 때문이다.

 

3.1 @RequestBody와 @ResponseBody

 Controller 클래스를 작성한다면, 요청은 @RequestBody를 통해 Json 형식의 데이터를 객체로 변환하여 사용하고, 응답은 @ResponseBody를 통해 객체 형태의 데이터를 Json 형식으로 변환하여 내려준다.

 

 이러한 어노테이션을 사용하면 내부적으로 MappingJackson2HttpMessageConverter에 의해 객체와 Json간 변환 과정을 거치게 된다. 웹에서는 이러한 변환 과정을 직렬화, 역직렬화라고 하며, 이는 곧 마샬링, 언마샬링이라고도 할 수 있다.

 

3.2. Mybatis

 전세계적으로 데이터 처리를 위해 JPA를 사용하고 있지만, 우리나라에서 만큼은 Mybatis도 꽤 많이 사용한다고 한다. 필자도 현업에서 JPA 사용을 안하고 Mybatis만을 사용했다. Mybatis는 SQL 쿼리를 자바 코드와 분리하여 개발자가 비지니스 로직에만 집중할 수 있도록 한다. 이때 사용되는 쿼리 정보들은 XML 파일로 관리한다.

 

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
 	PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 	"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 
<mapper namespace="com.sksim.testRepository">
 
    <select id="selectMember" resultType="com.sksim.Member">
        select id, nickname, email, phone from tbl_member
    </select>
 
</mapper>

 

 그렇다면 어떻게 XML 에 정의한 쿼리를 가져오고, DB로 쿼리를 실행시키는 걸까? 바로 Mybatis 초기화 시 위와 같은 XML 설정파일들을 로드하고, 데이터를 파싱한 후 최종적으로 Java 객체로 변환하는 언마샬링 과정을 거치기 때문이다.

 이렇게 생성된 객체들을 기반으로 하여 JDBC를 통해 연결된 데이터베이스와 통신하여 쿼리를 실행시킨다.

 


4. 마치며

 이처럼 마샬링, 언마샬링은 프레임워크 안에 숨어서 유용하게, 많이 사용되고 있다. 물론 상황에 따라 마샬링, 언마샬링 로직을 직접 구현하는 경우도 많다. 때문에 스프링과 같은 프레임워크를 사용하는 개발자라면 꼭 알아야 할 개념 중 하나라고 생각한다.

 

반응형
반응형
반응형

1. 개요

 트랜잭션을 적용하기 위해 사용했던 @Transactional이 어떻게 트랜잭션이라는 부가기능을 제공하는지에 대한 매커니즘을 이해해보자.

 


2. AOP 기반의 트랜잭션 기능

 스프링 AOP를 활용하면 특정 패턴을 가진 클래스의 메서드에 부가 기능을 부여할 수 있고, 트랜잭션 기능도 어렵지 않게 부여할 수 있다.

 이는 PlatformTransactionManager와 같이 스프링에서 제공하는 트랜잭션 추상화 클래스를 사용하여 구현할수도 있다.

 

https://tlatmsrud.tistory.com/113

 

[Spring] 스프링 AOP / 용어 / 빈 후처리기

1. 개요 스프링 AOP를 이해하고, 빈 후처리기를 활용해 AOP를 구현해보자. 2. AOP 2.1. AOP가 뭔가요? AOP는 말 그대로 Aspect(관점) Oriented(지향) Programming(프로그래밍). 관점 지향적인 프로그래밍이다. 풀

tlatmsrud.tistory.com

 

 포스팅했던 트랜잭션 기능 구현 내용을 정리하면, 어드바이스에는 트랜잭션 기능을, 포인트컷에는 타겟 설정을 하고, 이 둘을 갖는 객체인 어드바이저어드바이저 빈을 스캔하는 자동 프록시 생성 객체를 빈으로 등록하여 구현한다.

 

 어찌됐던 아래와 같이 트랜잭션을 시작하고, 타겟 메서드를 실행하고, 트랜잭션을 Commit 또는 Rollback한 후 트랜잭션을 종료하게 된다.

 

스프링 AOP를 통한 트랜잭션 적용

 

 이와 같이 트랜잭션 기능을 부여하려면 AOP 기반의 여러 설정들이 필요한데, 이러한 설정들과 상호작용하여 트랜잭션 기능을 제공받게 하는 것이 바로 @Transactional 이다. 

 


2. @Transactional 선언만 해줬을 뿐인데??

 이 어노테이션을 메서드, 클래스, 인터페이스에 선언만 하면 타겟 메서드 호출 시 기본 속성을 갖는 트랜잭션이 시작된다. 4 가지 속성이 있으며 전파속성, 격리수준 속성, 제한시간 속성, 읽기전용 속성이다.

 

2.1. 전파 속성

 트랜잭션이 어떻게 전파되는지를 정의하는 속성이다. 예를들어 AClass, BClass에 @Transactional을 선언하고, AClass의 메서드 내부에서 BClass의 메서드가 실행된다고 가정해보자. 이때 트랜잭션은 어떻게 전파될까? AClass의 메서드 호출 시 트랜잭션이 시작되고, BClass의 메서드 호출 시 새로운 트랜잭션이 시작될까?

아니다. @Transactional 을 선언하게 되면 기본 전파 방식인 PROPAGATION_REQUIRED 방식을 사용하게 되어 BClass의 메서드는 자신에게 전파된 트랜잭션에 참여하게 된다.

 

 반대로 다른 전파 속성으로 설정한다면, 트랜잭션에 참여하지 않거나, 새로운 트랜잭션을 생성하도록 할 수 도있다. 

 

@Service
@Transactional
public class AClass {

    private final BClass bClass;
    
    public AClass(BClass bClass){
        this.bClass = bClass;
    }

    public void method(){
        // Data 처리 로직
        bClass.method();
        // Data 처리 로직
    }
}

..

@Service
@Transactional
public class BClass {

    public void method(){
        // Data 처리 로직
    }
}

 

 

2.2. 전파속성의 종류

 일반적으로 사용되는 3가지의 전파 속성이다.

 

1) PROPAGATION_REQUIRED

 가장 많이 사용되는 트랜잭션 전파 속성이다. 진행 중인 트랜잭션이 없으면 새로 시작하고, 있으면 이에 참여한다. 이 방식을 사용할 경우 AClass.method와 BClass.method는 하나의 작업 단위 즉, 하나의 트랜잭션으로 구성되게 되고, 두 메서드가 종료되기 전에 내부에서 예외 (정확히는 런타임 예외)가 발생한다면 둘다 롤백된다.

 

PROPAGATION_REQUIRED 전파 속성

 

2) PROPAGATION_REQUIRES_NEW

 NEW ! 항상 새로운 트랜잭션을 시작한다. 진행 중인 트랜잭션이 있건 없건 새로운 트랜잭션을 생성하고 시작한다. 이 방식을 사용할 경우 BClass의 method 실행 시 1번 트랜잭션과 독립되어 동작하는 2번 트랜잭션이 생성된다.

 BClass의 method가 정상적으로 호출 및 종료되면 2번 트랜잭션은 Commit 되므로, 이후 AClass의 method에서 예외가 발생한다 한들 BClass의 Commit된 내용은 Rollback되지 않는다.

 

PROPAGATION_REQUIRES_NEW 전파 속성

 

3) PROPAGATION_NOT_SUPPORTED

전파 지원 안해줘! 진행 중인 트랜잭션이 있건 없건 트랜잭션 없이 동작하도록 한다. 트랜잭션이 없으니 트랜잭션 전파 지원도 없다는 뜻으로 해석했다. 이 방식을 사용할 경우 DB Connection이 발생할 때마다 트랜잭션 없이 DB 연산이 수행되고 BClass 내부에서 Exception이 발생한다 하더라도, 그 전에 Commit 됐던 내용은 Rollback 되지 않는다.

이와 별개로  AClass는 호출했던 BClass 메서드에서 발생한 예외로 인해 Rollback 된다.

PROPAGATION_NOT_SUPPORTED 전파속성

 

 

 

2.3. 격리수준

 트랜잭션 격리 수준은 DB에서 동시에 여러 트랜잭션이 처리될 때, 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 어느정도까지 허용할거냐를 결정하는 것이다.

 격리 수준은 SERIALIZABLE, REPEATABLE READ, READ COMMITED, READ UNCOMMITED 가 있고, 기본적으로 DB 설정에 따르지만 트랜잭션 레벨에서도 설정이 가능하다. 이 내용은 매우 중요하기 때문에 따로 포스팅하도록 하겠다. 

 

2.4. 제한시간

 트랜잭션에 대한 제한시간을 설정할 수 있다. 기본 설정은 제한시간이 없는 것이다.

 

2.5. 읽기전용

 트랜잭션에서 읽기 작업만 수행할 수 있도록 제한하는 것이다. 만약 Update, Delete, Insert와 같이 데이터를 조작하는 행위를 할 경우 예외가 발생한다.

 


3. 어드바이스와 포인트컷

 @Transactional은 특정 메서드(포인트컷)에 특정 속성(어드바이스)을 갖는 트랜잭션을 생성하고 시작하도록 한다. 그럼 어디선가 @Transactional 을 포인트컷으로 지정하고, @Transactional에 속성 값에 설정한 트랜잭션 속성을 가져다가 어드바이스에서 트랜잭션 생성 시 사용해야 한다. 이 기능을 하는 클래스들이 뭔지 알아보자.

@Transactional을 통한 속성 설정

 

3.1. 포인트컷

 @Transactional이 선언된 타입 혹은 메서드에 대해서만 대상으로 선정하는 포인트컷이 필요한데, 이 기능을 하는 포인트컷이 바로 TransactionAttributeSourcePointcut 이다.

 이 클래스는 @Transactional 붙은 타입 혹은 메서드를 찾아 포인트 컷의 선정 결과로 돌려준다. @Transactional의 속성을 통해 트랜잭션의 속성도 정의하지만 포인트컷의 선정 대상으로도 사용된다.

 

3.2. 어드바이스

 @Transactional에 속성 값을 읽어 트랜잭션을 생성하는 어드바이스가 필요한데, 이 기능을 하는 어드바이스가 바로 TransactionInterceptor 이다. 정확히는 트랜잭션 매니저와 트랜잭션 속성 두가지를 설정하는데, 트랜잭션 속성은 AnnotationTransactionAttributeSource 라는 클래스에게 요청하면, 해당 클래스에서 @Transactional 에 입력된 속성을 읽어온다.

 

3.3. 어드바이저

 이 포인트컷과 어드바이스를 갖고 있는 어드바이저는 BeanFactoryTransactionAttributeSourceAdvisor 로 어플리케이션 실행 시 자동으로 빈으로 등록된다.


4. @Transactional 선언만 해줬을 뿐인데!!

정리하면, @Transactional 선언만 해주면 아래와 같은 매커니즘에 따라 동작하게 된다.

 

1) 어플리케이션 시작 시 TransactionAttributeSourcePointcut 포인트컷과 TransactionInterceptor 어드바이스를 갖는 BeanFactoryTransactionAttributeSourceAdvisor 어드바이저가 빈으로 등록된다. 

 

2) 빈 후처리기가 트랜잭션 기능을 부여하는 어드바이저 빈을 조회한다.

 

3) 어드바이저의 포인트 컷을 통해 @Transactional이 붙은 Bean 을 선정하고 프록시로 생성한다.

 

4. 생성한 프록시에 어드바이저를 연결한다. 

 

5. 완성된 프록시를 스프링 컨테이너에게 전달한다.

 

6. 스프링 컨테이너는 전달받은 프록시를 빈으로 등록하고 사용한다.

 

7. 추후 해당 프록시 빈이 호출될 경우 트랜잭션 속성에 따른 트랜잭션 부가기능이 수행된다.

 

반응형
반응형
반응형

1. 개요

 스프링 AOP를 이해하고, 빈 후처리기를 활용해 AOP를 구현해보자.

 


2. AOP

2.1. AOP가 뭔가요?

 AOP는 말 그대로 Aspect(관점) Oriented(지향) Programming(프로그래밍). 관점 지향적인 프로그래밍이다. 풀어 말하면 어떤 로직에 대해 핵심 기능과 부가 기능이라는 관점으로 나누어 모듈화하는 프로그래밍 기법을 말한다. 토비의 스프링에도 비슷하게 정의되어 있어 가져와봤다.

 

AOP란 어플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 애스펙트라는 독특한 모듈로 만들어서 설계하고 개발하는 방법이다.

 

 예를들어 @Transactional을 사용하지 않고 DB 데이터 처리를 하는 특정 메서드에 대한 트랜잭션 기능을 부여한다면, 아래와 같은 코드를 구현할 수 있다.

    public void upgradeLevels() {
        TransactionStatus status = transactionManager.getTransaction(
                new DefaultTransactionDefinition()
        );

        try{
            userService.upgradeLevels(); // 핵심기능
            transactionManager.commit(status);
        }catch(Exception e){
            transactionManager.rollback(status);
        }
    }

 

 

 현재는 upgradeLevels() 메서드에 대해서만 트랜잭션을 적용하였으나 다른 메서드에도 적용해야한다면 아래와 같이 많은 중복코드가 생길것이다.

    public void upgradeLevels() {
        TransactionStatus status = transactionManager.getTransaction(
                new DefaultTransactionDefinition()
        );

        try{
            userService.upgradeLevels();
            transactionManager.commit(status);
        }catch(Exception e){
            transactionManager.rollback(status);
        }
    }
    
    public void saveUserInfo() {
        TransactionStatus status = transactionManager.getTransaction(
                new DefaultTransactionDefinition()
        );

        try{
            userService.saveUserInfo();
            transactionManager.commit(status);
        }catch(Exception e){
            transactionManager.rollback(status);
        }
    }
    
    ...
    
    public void update() {
        TransactionStatus status = transactionManager.getTransaction(
                new DefaultTransactionDefinition()
        );

        try{
            userService.update();
            transactionManager.commit(status);
        }catch(Exception e){
            transactionManager.rollback(status);
        }
    }
    
    ...

 

 중복을 없애려면 어떻게 해야할까? 중복되는 트랜잭션 로직을 분리하고, 다른 클래스에서도 재사용할 수 있도록 모듈화 해야한다. 이 말은 UserService의 메서드와 트랜잭션 로직을 각각 핵심기능과 부가기능으로 분리하고, 부가기능을 모듈화 해야 한다는 것인데. 이렇게 접근하는 프로그래밍 방식이 AOP, 관점 지향 프로그래밍이라고 할 수 있다.

 

2.2. AOP 용어

 AOP 사용 시 자주 사용되는 용어들이 있다. 이 중에서도 스프링 AOP에서 주로 사용되는 Advice, Pointcut, Advisor는 꼭 숙지하자.

 

Target 부가 기능을 부여할 대상이다. 핵심기능을 담은 클래스일 수도 있지만 경우에 따라 다른 부가기능을 제공하는 프록시 오브젝트일 수도 있다.
Advice 타겟에게 제공할 부가 기능을 담은 모듈이다. 어드바이스는 오브젝트로 정의하기도 하지만 메서드 레벨에서 정의할 수도 있다
JoinPoint 어드바이스가 적용될 수 있는 위치를 말한다. 스프링 AOP에서 조인포인트는 메서드의 실행단계 뿐이다. 타깃 오브젝트가 구현한 인터페이스의 모든 메서드는 조인 포인트가 된다.
Pointcut  어드바이스를 적용할 조인 포인트를 선별하는 작업 또는 그 기능을 정의한 모듈을 말한다. 스프링 AOP의 조인포인트는 메서드의 실행이므로 스프링의 포인트컷은 메서드를 선정하는 기능을 갖고 있다. 그래서 포인트컷 표현식에서도 메서드의 시그니처를 비교하는 방법을 주로 사용한다. 메서드는 클래스 안에 존재하는 것이기 때문에 메서드 선정이란 결국 클래스를 선정하고 그 안의 메서드를 선정하는 과정을 거치게 된다.
Advisor  어드바이저와 포인트컷을 하나씩 갖고 있는 오브젝트이다. 어드바이저는 어떤 기능(어드바이스)을 어디에(포인트컷) 전달할 것인가를 알고 있는 AOP의 가장 기본이 되는 모듈이다. 어드바이저는 스프링 AOP에서만 사용되는 용어이고, 일반적인 AOP에서는 사용되지 않는다.

 

* JoinPoint와 Pointcut이 헷갈려요

더보기

예를 들어서 살펴보면 좀 더 쉬운데요. MemberService의 hello()라는 메소드 실행 전,후에 hello랑 bye를 출력하는 일을 한다고 가정해보죠. 이때 MemberService 빈이 타겟, "hello() 메소드 실행 전,후"가 포인트컷, "메소드 실행 전,후"라는게 조인포인트, "hello랑 bye를 출력하는 일"이 Advice입니다. 포인트컷과 조인포인트가 많이 햇갈릴텐데 조인포인트가 메타적인 정보라고 생각하시면 되고 포인트컷이 좀 더 구체적인 적용 지점이라고 생각하시면 됩니다.

- 인프런 문의사항 답변 내용 (답변자 : 백기선님)

 


3. 빈 후처리기를 통한 AOP

스프링에서는 AOP를 위한 다양한 모듈을 제공한다. 일단 빈 후처리기를 활용하여 AOP를 적용해보도록 하겠다.

 

3.1. 빈 후처리기가 뭔가요?

 BeanPostProcessor 인터페이스를 구현한 클래스로 빈을 생성한 후 후처리 기능을 하는 클래스이다.

 스프링의 대표적인 빈 후처리기는 DefaultAdvisorAutoProxyCreator로 등록된 빈 중에서 Advisor 인터페이스를 구현한 클래스에 대한 자동 프록시 생성 후처리기이다.

 이를 활용하면 스프링이 생성하는 빈 오브젝트 중 일부를 프록시로 포장하고, 프록시를 빈으로 대신 등록할 수도 있다. 그림과 함께 동작과정을 이해해보자.

 

3.2. DefaultAdvisorAutoProxyCreator 동작과정

DefaultAdvisorAutoProxyCreator 동작과정

 

1) 어플리케이션이 시작되면 빈 설정파일을 읽어 빈 오브젝트를 생성한다.

 

2) BeanPostProcessor 인터페이스를 구현한 클래스(DefaultAdvisorAutoProxyCreator)가 빈으로 등록되어 있다면 생성된 빈을 여기로 전달한다.

 

3) DefaultAdvisorAutoProxyCreator는 생성된 빈 중에서 Advisor 인터페이스를 구현한 클래스가 있는지 스캔한다.

 

4) Advisor 인터페이스를 구현한 클래스가 있다면 Advisor의 포인트 컷을 통해 프록시를 적용할지 선별한다.

 

5) Advisor가 없거나 포인트 컷 선별이 되지 않았다면 전달받은 빈을 그대로 스프링 컨테이너에게 전달하고, 선별됐다면 프록시 생성기 역할을 하는 객체에서 프록시 생성 요청을 한다.

 

6) 프록시 생성기는 프록시를 생성하고 프록시에 어드바이저를 연결한다.

 

7) 완성된 프록시를 스프링 컨테이너에게 전달한다.

 

8) 스프링 컨테이너는 빈 후처리기가 돌려준 오브젝트를 빈으로 등록하고 사용한다.

 

3.3. DefaultAdvisorAutoProxyCreator 예제

  UserService 인터페이스에 대한 구현체 클래스는 UserServiceImpl은 DB 데이터 처리를 하는 IUserDao와 비지니스 로직을 담당하는 UserLevelUpgradePolicy를 DI받는다. 

 UserServiceImpl 메서드 실행 도중 예외가 발생할 경우 모든 트랜잭션을 rollback하기 위해 트랜잭션 처리를 하는 부가기능을 빈 후처리기를 통해 구현해보도록 하자. 매커니즘을 이해하는 것에 초점을 맞췄기 때문에 부가적인 코드는 첨부하지 않도록 하겠다.

 

1) UserService

public interface UserService {

    void upgradeLevels();
    void add(User user);
}

 

2) UserServiceImpl

public class UserServiceImpl implements UserService {

    private IUserDao userDao;

    private UserLevelUpgradePolicy userLevelUpgradePolicy;
    
    public void setUserDao(IUserDao userDao){
        this.userDao = userDao;
    }

    public void setUserLevelUpgradePolicy(UserLevelUpgradePolicy userLevelUpgradePolicy){
        this.userLevelUpgradePolicy = userLevelUpgradePolicy;
    }

    public void upgradeLevels() {
        List<User> users = userDao.getAll(); // DB /
        for(User user : users) {
            if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
                userLevelUpgradePolicy.upgradeLevel(user);
            }
        }
    }

    public void add(User user) {
        if(user.getLevel() == null){
            user.setLevel(Level.BASIC);
        }
        userDao.add(user);
    }
}

 

3.3.1. 빈 후처리기 등록

 DefaultAdvisorAutoProxyCreator를 빈으로 등록하면 된다. xml 설정을 통해 빈을 등록하였다.

 

<bean class ="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"></bean>

 

 

3.3.2. 포인트컷 정의

 DefaultAdvisorAutoProxyCreator는 등록된 빈 중에서 Advisor 인터페이스를 구현한 클래스를 모두 찾는다. 구현 클래스를 생성하기 전, Advisor에 필요한 포인트컷과 어드바이스를 준비해야 한다. 먼저 포인트컷을 생성하였다.

 

public class NameMatchClassMethodPointcut extends NameMatchMethodPointcut {

    public void setMappedClassName(String mappedClassName){
        this.setClassFilter(new SimpleClassFilter(mappedClassName));
    }

    static class SimpleClassFilter implements ClassFilter {

        String mappedName;

        private SimpleClassFilter(String mappedName){
            this.mappedName = mappedName;
        }

        public boolean matches(Class<?> clazz){
            return PatternMatchUtils.simpleMatch(mappedName,
                    clazz.getSimpleName());
        }
    }
}

 

  포인트컷은 NameCatchMethodPointcut내부 익명 클래스 방식으로 확장해서 만들었다. 이름에서 알 수 있듯이 메서드 선별 기능을 가진 포인트컷인데, 클래스에 대해서는 필터링 기능이 없는게 아닌 모든 클래스를 다 허용한는 기본 클래스 필터가 적용되어 있다. 때문이 이 클래스 필터를 재정의 하였다.

 

StaticMethodMatcherPointcut 의 기본 classFilter
TrueClassFilter.INSTANCE

Canonical instance of a ClassFilter that matches all classes
 : 모든 클래스와 일치하는 ClassFilter의 정식 인스턴스

 

주석을 보면 알 수 있듯이 기본 클래스 필터인 TrueClassFilter.INSTANCE는 모든 클래스와 일치시킨다.

 

 

3.3.3. 어드바이스 정의

 이제 부가기능을 포함하는 어드바이스를 정의해보겠다. MethodInterceptor 인터페이스를 구현하면 된다. invoke 메서드에 부가기능 및 타겟 오브젝트 호출 로직을 넣어준다.

public class TransactionAdvice implements MethodInterceptor {

    private PlatformTransactionManager transactionManager;

    public void setTransactionManager(PlatformTransactionManager transactionManager){
        this.transactionManager = transactionManager;
    }


    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        TransactionStatus status = transactionManager
                .getTransaction(new DefaultTransactionDefinition());

        try{
            Object ret = invocation.proceed(); //타겟 메서드 실행
            transactionManager.commit(status);
            return ret;
        } catch (RuntimeException e){
            transactionManager.rollback(status);
            throw e;
        }
    }
}

 

3.3.4. 어드바이저 등록

 어드바이저는 스프링에서 제공하는 DefaultPointcutAdvisor를 사용한다.

앞서 DefaultAdvisorAutoProxyCreator는 등록된 빈 중에서 Advisor 인터페이스를 구현한 클래스를 모두 찾는다고 했는데 DefaultPointcutAdvisor 클래스가 Advisor 인터페이스의 구현체 클래스 중 하나이다. 

 어드바이저 등록은 'XML을 통한 빈 설정' 부분에 기재하였다.

 

3.3.5. XML을 통한 빈 설정

 이제 작업한 내용을 바탕으로 스프링 빈 설정을 한다. 어드바이저에 대한 자동 프록시 생성 후처리기인 DefaultAdvisorAutoProxyCreator 빈을 등록하고, 스캔할 어드바이저로 DefaultPointcutAdvisor 타입의 transactionAdvisor 빈을 등록한다. 생성 시 필요한 어드바이스와 포인트컷도 마찬가지로 빈으로 등록해줬다.

 필자는 클래스 이름의 suffix가 ServiceImpl인 클래스, 메서드 이름의 prefix가 upgrade 인 메서드에 대해 포인트컷을 설정하기 위해 mappedClassName엔 "*ServiceImpl"를, mappedName엔 "upgrade*" 를 설정해주었다.

	...
    
    <bean id = "userService" class = "org.example.user.service.UserServiceImpl">
        <property name="userDao" ref = "userDao"></property>
        <property name="userLevelUpgradePolicy" ref = "defaultUserLevelUpgradePolicy"></property>
    </bean>
    
    ...
    
    <!-- 빈 후처리기 등록 -->
    <bean class ="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"/>

    <!-- 어드바이스 설정 -->
    <bean id = "transactionAdvice" class = "org.example.proxy.TransactionAdvice">
        <property name="transactionManager" ref = "transactionManager"></property>
    </bean>
    
    <!-- 포인트컷 설정 -->
    <bean id = "transactionPointcut" class = "org.example.proxy.NameMatchClassMethodPointcut">
        <property name="mappedClassName" value = "*ServiceImpl"/>
        <property name="mappedName" value = "upgrade*"/>
    </bean>

    <!-- 어드바이저 (어드바이스 + 포인트컷) 설정 -->
    <bean id = "transactionAdvisor" class = "org.springframework.aop.support.DefaultPointcutAdvisor">
        <property name="advice" ref = "transactionAdvice"></property>
        <property name="pointcut" ref ="transactionPointcut"></property>
    </bean>
    
    ...

 

3.3.6. 테스트

 게시글에 누락시킨 클래스들이 많아 테스트 케이스를 이해하기 힘든 관계로 간단히 설명하자면, 모든 유저들에 대해 정해진 조건을 만족할 경우 다음 레벨로 업그레이드하는 UserService의 upgradeLevels()를 메서드를 테스트하며, 메서드 실행 도중 예외발생 시 트랜잭션이 적용되는지 확인하기 위함이다.

 

 테스트를 위해 upgradeLevels 내부에서 실행되는 UserLevelUpgradePolicy의 upgradeLevel() 메서드에 대해 예외가 발생하도록 Stubbing 처리하였다.

 

 test2 유저의 경우 SIVER로 업그레이드 됐었으나, 커밋 전 예외 발생으로 인해 BASIC 레벨로 롤백되는지 확인하는 케이스를 진행하였고, 트랜잭션이 적용되어 테스트가 성공함을 확인하였다.

public class UserServiceTest {

    @Autowired
    private UserService userService;

    @SpyBean
    private IUserDao userDao;

    @SpyBean
    private DefaultUserLevelUpgradePolicy userLevelUpgradePolicy;

    private final List<User> users = Arrays.asList(
            new User("test1","테스터1","pw1", Level.BASIC, 49, 0, "tlatmsrud@naver.com"),
            new User("test2","테스터2","pw2", Level.BASIC, 50, 0, "tlatmsrud@naver.com"),
            new User("test3","테스터3","pw3", Level.SILVER, 60, 29, "tlatmsrud@naver.com"),
            new User("test4","테스터4","pw4", Level.SILVER, 60, 30, "tlatmsrud@naver.com"),
            new User("test5","테스터5","pw5", Level.GOLD, 100, 100, "tlatmsrud@naver.com")
    );
    @BeforeEach
    void setUp(){

        // 모든 유저 조회 시 미리 정의한 유저 텍스처 조회
        given(userDao.getAll()).willReturn(users);

        // 4번째 유저에 대한 업그레이드 메서드 실행 시 예외 발생
        willThrow(new RuntimeException()).given(userLevelUpgradePolicy).upgradeLevel(users.get(3));
    }


    @Test
    void upgradeAllOrNothing(){
        // 테이블 데이터 초기화
        userDao.deleteAll();

        // 테이블에 유저 정보 insert
        users.forEach(user -> userDao.add(user));

        // 유저 레벨 업그레이드 메서드 실행 및 예외 발생 여부 확인 (setUp 메서드에 4번째 유저 업그레이드 처리 시 예외 발생하도록 스터빙 추가)
        assertThatThrownBy(() -> userService.upgradeLevels())
                .isInstanceOf(RuntimeException.class);

        // DB
        assertThat(userDao.get("test1").getLevel()).isEqualTo(Level.BASIC);
        assertThat(userDao.get("test2").getLevel()).isEqualTo(Level.BASIC);
        assertThat(userDao.get("test3").getLevel()).isEqualTo(Level.SILVER);
        assertThat(userDao.get("test4").getLevel()).isEqualTo(Level.SILVER);
        assertThat(userDao.get("test5").getLevel()).isEqualTo(Level.GOLD);

        System.out.println(userService.getClass().getName()); //com.sun.proxy.$Proxy50

    }
}

 

추가적으로 Autowired한 userService의 클래스 타입은 자동 프록시 생성 빈 후처리기에 의해 Proxy 객체가 생성된 관계로 UserServiceImpl가 아닌 Proxy임을 확인할 수 있었다. 

포인트컷과 일치하여 생성된 프록시 빈

 

만약 포인트컷에 선별되지 않도록 mappedName 혹은 mappedClassName을 변경한다면 프록시를 생성하지 않고 아래와 같이 UserServiceImpl 타입으로 출력되는 것도 확인할 수 있었다.

포인트컷과 일치하지 않아 그대로 리턴된 빈

 


4. 세밀한 포인트컷

 

4.1. 리플렉션 API 활용?!

 예제에서는 단순히 클래스나 메서드의 이름으로만 포인트컷을 지정했는데, 더 세밀하고 복잡한 기준을 적용해 포인트컷을 지정할 수도 있다. 바로 리플렉션 API를 활용하는 것이다. 어차피 TransactionAdvice의 invoke 메서드 파라미터인 MethodInvocation도 리플렉션이 적용된 파라미터이기 때문에 메서드나 클래스, 리턴 값 등 대부분의 정보를 얻을 수 있기 때문이다.

invoke 메서드의 invocation 파라미터

 

 하지만 리플렉션 API를 사용하면 코드가 지저분해지고 포인트컷 비교 정보가 달라질때마다 해당 로직을 수정해야한다. 이에 스프링은 표현식을 통해 간단하게 포인트컷의 클래스와 메서드를 선별할 수 있도록 하는 방법을 제공하는데 이를 포인트컷 표현식이라고 한다.

 

4.2. 포인트컷 표현식

 포인트컷 표현식을 지원하는 포인트컷을 적용하려면 포인트컷 빈으로 AspectExpressionPointcut 클래스를 사용하면 된다. 

 

4.3. 포인트컷 표현식 문법

 포인트컷 표현식은 포인트컷 지시자를 이용하여 작성하며 대표적으로 execution()이 있다. 메서드의 풀 시그니처를 문자열로 비교하는 개념이며, 문법은 아래와 같다. 참고로 괄호([ ])안은 생략 가능하다.

 

포인트컷 표현식

 

 예를들어 execution("* org.test.service.*ServiceImpl.upgrade*(..)) 는 모든 접근제한자 및 리턴타입을 갖고, ServiceImpl로 끝나는 클래스 명을 갖고, 메서드명이 upgrade로 시작하는 모든 메서드 시그니처를 의미한다.

 

4.4. AspectExpressionPointcut 적용해보기

 포인트컷 표현식 사용을 위한 의존성을 추가하고, xml에 설정했던 포인트컷 빈을 수정해보자.

 

1) 의존성 추가

implementation 'org.aspectj:aspectjtools:1.9.19'

 

2) xml 설정 변경

<bean id = "transactionPointcut" class = "org.springframework.aop.aspectj.AspectJExpressionPointcut">
    <property name="expression" value = "execution(* org.example.user.service.*ServiceImpl.upgrade*(..))"/>
</bean>

 기존엔 포인트컷에 대한 클래스를 생성해주었지만, AspectJExpressionPointcut을 사용하니 그럴 필요가 없게 되었다. 테스트 코드를 실행해보면 테스트가 성공하는 것을 확인할 수 있다.

 

4.5. 스프링 AOP 정리

 스프링의 AOP를 적용하려면 최소한 네 가지 빈을 등록해야 한다.

 

* 자동 프록시 생성기

 스프링의 DefaultAdvisorAutoProxyCreator 클래스를 빈으로 등록한다. 빈으로 등록된 어드바이저를 이용해서 프록시를 자동으로 생성하는 기능을 담당한다.

 

* 어드바이스

 부가기능을 구현할 클래스를 빈으로 등록한다. TransactionAdvice는 AOP 관련 빈 중 유일하게 직접 구현한 클래스이다.

 

* 포인트컷

 스프링의 AspectJExpressionPointcut을 빈으로 등록하고 포인트컷 표현식을 넣어주면 된다. 

 

* 어드바이저

 스프링의 DefaultPointcutAdvisor를 빈으로 등록한다. 어드바이스와 포인트컷을 참조하는 것 외에는 기능이 없다. 자동 프록시 생성기에 의해 검색되어 사용된다.

 


5. AOP 네임스페이스

 포인트컷 표현식을 사용하니 어드바이스를 제외하고는 모두 스프링에서 제공하는 클래스를 사용하고 있다. 스프링에서는 이렇게 AOP를 위해 기계적으로 적용해야하는 빈들을 간편하게 등록하는 방법을 제공한다. 바로 aop 스키마를 이용하는 것이다.

 aop 스키마를 사용하려면 bean xml 설정파일에 aop 네임 스페이스 선언을 추가해줘야 한다.

 

aop 네임스페이스 추가

 

 이를 추가하면 빈 후처리기, 포인트컷, 어드바이저가 자동으로 등록되므로 xml 설정에서 제거할 수 있다. 이제 aop 네임 스페이스를 사용하여 bean 설정 xml을 아래와 같이 작성할 수 있다. 단위 테스트를 통해 트랜잭션이 적용됨을 확인할 수 있었다.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
                           http://www.springframework.org/schema/beans/spring-beans.xsd
                           http://www.springframework.org/schema/aop
                           http://www.springframework.org/schema/aop/spring-aop-3.0.xsd"
>

   ...
   
    <!-- 빈 후처리기, 포인트컷, 어드바이저 빈 생성 코드 제거
    <bean class ="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"/>
    
    <bean id = "transactionPointcut" class = "org.example.proxy.NameMatchClassMethodPointcut">
        <property name="mappedClassName" value = "*ServiceImpl"/>
        <property name="mappedName" value = "upgrade*"/>
    </bean>

    <bean id = "transactionAdvisor" class = "org.springframework.aop.support.DefaultPointcutAdvisor">
        <property name="advice" ref = "transactionAdvice"></property>
        <property name="pointcut" ref ="transactionPointcut"></property>
    </bean>
    -->

    <bean id = "transactionAdvice" class = "org.example.proxy.TransactionAdvice">
        <property name="transactionManager" ref = "transactionManager"></property>
    </bean>

    <aop:config>
        <aop:advisor advice-ref="transactionAdvice"
                     pointcut="execution(* org.example.user.service.*ServiceImpl.upgrade*(..))"></aop:advisor>
    </aop:config>
</beans>

 

반응형
반응형

1. 개요

 다이나믹 프록시를 공부하던 중 리플렉션이라는 개념이 두둥등장하였다. 간단하게 개념만 짚고 넘어가려했으나, Spring DI의 동작원리와 밀접하고, 프레임워크를 이해하는데 중요한 개념이라고 판단되어 자세히 알아보았다.

 


2. Reflection이 뭔가요?

 

2.1. 사전적 의미

 많은 영상이나 글에서 리플렉션에 대해 설명할 때 사전적 의미를 짚고 넘어간다. 사전적 의미와 유사한 기능을 하는 개념들이 많은데, 리플렉션 또한 이와 같기도 하고, 개념을 이해하기가 쉬운 편은 아니라 그런 것 같다. 사전적 의미는 다음과 같다.

 

1. (거울에 비친) 상, 모습
2. 반사

 

이제 이 사전적 의미를 기술적 의미와 함께 이해해보자.

 

2.2. 기술적 의미

런타임 단계에서 클래스의 정보를 분석해내는 자바 API로 클래스의 메서드, 타입, 필드, 어노테이션 등의 정보를 접근하거나 수정할 수 있다.

 

 정리하면 리플렉션이란 클래스의 정보 통해 '거울에 비친 상'과 같이 똑같은 형태를 만들고 이를 통해 메서드, 타입, 필드, 어노테이션과 같은 자원에 접근하거나 수정할 수 있는 자바 API이다.

 그렇다면, 실제 클래스와 똑같은 형태를 가진 정보는 대체 어디서 얻어오는 걸까??

 

리플렉션을 보고있는 클래스

 

 

2.3. 어디서? JVM에서!

 정확히는 JVM의 메모리 영역에서 가져온다.

 어플리케이션을 실행하면 작성한 자바 코드컴파일러에 의해 .class 형태의 바이트 코드로 변환되고, 이 정보들은 클래스 로더를 통해 JVM 메모리 영역에 저장된다. 그리고 클래스 정보를 통해 객체가 생성된다면 이는 JVM 힙 영역에 저장된다. 즉, JVM의 메모리영역에서 클래스의 정보를 가져올 수 있다.

런타임 시 JVM 의 동작과정 (출처 : 우아한 테크 파랑, 아키의 리플렉션)

 

2.4. 리플렉션이란?!

 다시! 리플렉션이란, 어플리케이션이 실행되어 JVM 메모리 영역에 클래스 정보들이 저장된 시점인 '런타임' 시에 이 영역에 접근하여 클래스의 정보를 분석, 수정하는 작업을 하는 API바로 자바 리플렉션이다!

 


3. 리플렉션 실습

 리플렉션 API를 테스트하는 간단한 실습을 해보자.

 

3.1. 초간단 Human 클래스 생성

public class Human {

    private String name;

    public Human(String name){
        this.name = name;
    }

    private Human(){

    }

    public void goRestRoom(){
        System.out.println(name +"이 화장실로 갑니다.");
    }

    public void offPants(){
        System.out.println(name +"이 바지를 내립니다.");
    }

    public void doWork(){
        System.out.println(name + "이 볼일을 봅니다.");
        poopOut();
    }

    private void poopOut(){
        System.out.println("똥이 나왔습니다.");
    }
}

 화장실에서 볼일을 보는 Human 클래스를 생성하였다. name 파라미터를 받는 생성자 메서드private 접근 제어자를 가진 기본 생성자 메서드를 생성하였다. poopOut 메서드는 외부에서 호출되는 것을 막기 위해 private 접근 제어자로 설정하였다.

 

3.2. 클래스 정보 조회하기

먼저 JVM에 저장될 클래스 정보를 조회하는 코드이다. 아래와 같이 크게 세가지 방법이 있다.

// JVM에 있는 클래스 정보 가져오기
Class<?> class1 = human.getClass();
Class<?> class2 = Human.class;
Class<?> class3 = Class.forName("org.example.reflection.Human");

 

3.3. 생성자 조회 및 호출

 이제 리플렉션 기능을 사용해보자. 먼저 클래스의 생성자 정보를 가져오고 이를 호출해보도록 하겠다.

 

3.3.1. getConstructor()

// JVM에 있는 클래스 정보 가져오기
Class<?> class1 = Class.forName("org.example.reflection.Human");

// 리플렉션을 통해 생성자 가져오기
Constructor<?> constructor1 = class1.getConstructor(); // NoSuchMethodException !!
Constructor<?> constructor2 = class1.getConstructor(String.class);

// 가져온 생성자를 통해 객체 생성하기
Object human1 = constructor1.newInstance();
Object human2 = constructor2.newInstance("승갱이");

 위 코드를 실행시키면 메서드 호출 시 NoSuchMethodException이 발생한다. 리플렉션 기능을 통해 생성자 메서드 정보를 가져오려 시도하였으나, 기본 생성자의 접근 제어자가 private 라 메서드를 찾지 못해 발생했다. 접근 제어자에 관계 없이 클래스 정보를 가져오려면 getConstructor() 대신 getDeclaredConstructor() 메서드를 사용하면 된다.

 

getXXX와 getDeclaredXXX의 차이 이해하기

더보기

getXXX vs getDeclaredXXX


리플렉션에서 호출하는 대부분의 메서드는 getXXX, getDeclaredXXX 처럼 쌍을 이루고 있다. 아래의 특징을 숙지하여 상황에 맞게 사용해야 한다.

getXXX
 상위 클래스와 상위 인터페이스에서 상속한 메서드를 포함하여 public인 값들을 가져온다. private와 같은 메서드를 조회할 경우 NoSuchMethodException 예외가 발생한다.

getDeclaredXXX
 접근 제어자와 관계 없이 상속한 메서드들을 제외하고 직접 클래스에서 선언한 값들을 가져온다.

 

3.3.2. getDeclaredConstructor()

// JVM에 있는 클래스 정보 가져오기
Class<?> class1 = Class.forName("org.example.reflection.Human");

// 리플렉션을 통해 생성자 가져오기
Constructor<?> constructor1 = class1.getDeclaredConstructor();
Constructor<?> constructor2 = class1.getDeclaredConstructor(String.class);

// 가져온 생성자를 통해 객체 생성하기
Object human1 = constructor1.newInstance(); // IllegalAccessException !!
Object human2 = constructor2.newInstance("승갱이");

 

 이로써 private로 선언된 생성자 정보는 가져왔으나, 생성자를 통해 객체 생성 시 IllegalAccessException이 발생했다. 이유는 접근 제어자가 private 이기 때문에 외부 호출이 불가능하기 때문이다. 앞서 발생한 예외는 클래스의 정보에서 기본 생성자 메서드를 찾지 못해 발생했고, 이번 예외는 해당 메서드를 호출하지 못해 발생한 것이다.

이를 해결하기 위해서 Human 클래스의 기본 생성자를 public으로 수정하여야 할까? 아니다. 리플렉션을 통해 private 메서드에도 접근할 수 있도록 조작하면 된다. 

 

3.3.3. setAccessible(true)

 setAccessible(true) 메서드를 통해 해당 생성자에 접근할 수 있도록 설정하였다. 여기서 중요한 점은 클래스를 수정하지 않고, 리플렉션을 통해 클래스의 생성자 정보를 조작한 후 호출까지 했다는 점이다.

// JVM에 있는 클래스 정보 가져오기
Class<?> class1 = Class.forName("org.example.reflection.Human");

// 리플렉션을 통해 생성자 가져오기
Constructor<?> constructor1 = class1.getDeclaredConstructor();
Constructor<?> constructor2 = class1.getDeclaredConstructor(String.class);

constructor1.setAccessible(true); // 해당 생성자에 접근할 수 있도록 설정

// 가져온 생성자를 통해 객체 생성하기
Object human1 = constructor1.newInstance();
Object human2 = constructor2.newInstance("승갱이");

 

기본 생성자를 통해 생성된 Human 객체

 

3.4. 멤버필드 조회하기

 다음은 리플렉션 기능을 사용하여 클레스의 멤버필드를 조회해보자.

 

3.4.1. getFields()

Class<?> class1 = Class.forName("org.example.reflection.Human");

for(Field field : class1.getFields()){
    System.out.println(field);
}

 Human 클래스에 name 멤버필드가 있지만 콘솔에 조회되지 않았다. 클래스 정보를 조회했더니 name은 찾을 수 없어 조회가 되지 않았다. 이유는 name의 접근제어자가 private이기 때문이다. getDeclaredFields() 메서드를 사용해야 한다.

 

3.4.2. getDeclaredFields()

Class<?> class1 = Class.forName("org.example.reflection.Human");

for(Field field : class1.getDeclaredFields()){
    System.out.println(field); // private java.lang.String org.example.reflection.Human.name
}

 private 접근제어자를 가진 필드 정보도 조회됨을 알 수 있다.

 

 3.4.3. 객체에 대한 멤버필드 조회하기

 이제 위 리플렉션 기능을 사용하여 객체를 생성하고, 해당 객체의 필드 정보를 조회해보자.

 호출한 생성자의 접근제어자는 public이므로 setAccessible 메서드를 사용하지 않았다. 하지만 객체의 name 필드는 접근 제어자가 private이므로 field.get(human) 메서드를 호출하기전 리플렉션에 대한 접근 설정을 true로 설정하였다.

Class<?> class1 = Class.forName("org.example.reflection.Human");

Constructor constructor = class1.getDeclaredConstructor(String.class);
Object human = constructor.newInstance("승갱이");

for(Field field : class1.getDeclaredFields()){
    System.out.println(field);
    field.setAccessible(true);
    System.out.println("value : "+ field.get(human));
}

출력결과

 

3.4.4. 객체에 대한 멤버필드 수정하기

 단순히 조회 뿐 아니라 멤버필드의 값도 수정할 수 있다. Setter 메서드가 없어도, 접근제어자가 private라도 이를 무시하고 값을 바꿔버릴 수 있는 아주 강력한 녀석임을 알 수 있다.

Class<?> class1 = Class.forName("org.example.reflection.Human");

Constructor constructor = class1.getDeclaredConstructor(String.class);
Object human = constructor.newInstance("승갱이");

for(Field field : class1.getDeclaredFields()){
    System.out.println(field);
    field.setAccessible(true);
    field.set(human, "변경된 승갱이"); // human 객체의 field 값 변경
    System.out.println("value : "+ field.get(human));
}

출력결과

 

3.5. 메서드 조회 및 호출하기

 이번엔 메서드의 정보를 조회하고 호출해보자.

Class<?> class1 = Class.forName("org.example.reflection.Human");

Constructor<?> constructor = class1.getConstructor(String.class);
Object human = constructor.newInstance("승갱이");

Method goRestRoomMethod = class1.getDeclaredMethod("goRestRoom");
Method offPantsMethod = class1.getDeclaredMethod("offPants");
Method doWorkMethod = class1.getDeclaredMethod("doWork");
Method poopOutMethod = class1.getDeclaredMethod("poopOut");

poopOutMethod.setAccessible(true);
poopOutMethod.invoke(human);
goRestRoomMethod.invoke(human);
offPantsMethod.invoke(human);
doWorkMethod.invoke(human);

 

 poopOut 메서드만 접근제어자가 private이므로 invoke 메서드 호출 전에 setAccessible(true) 메서드를 호출해주었다. 필자의 의도는 poopOut 메서드의 접근 제어자를 private 로 생성하여 바지를 내리기 전에 똥을 싸거나, 화장실에 들어가기 전에 똥을 싸는 불상사를 막으려 했는데, 리플렉션을 사용하니 똥을 먼저 싸버리는 걸 볼 수 있다.

다시한번 리플렉션의 강력함(?)을 느낄 수 있는 부분이다.

출력결과

 


4. 어디서 사용하나요?

 근데 이런 기능들을 대체 어디서 사용할까? 필자가 이 글을 쓰는 이유인 '다이나익 프록시' 라는 API에서도 사용하나, 대부분의 프레임워크나 라이브러리에서도 리플렉션 기능을 사용한다. 프레임워크나 라이브러리에서는 들어오는 클래스의 정보를 모르기 때문이다.

 코드를 작성한 개발자는 당연히 내가 작성한 클래스의 정보를 알 수 있지만, 프레임워크 입장에서 보면 모르는게 당연하다. 이때 리플렉션을 통해 런타임 시 클래스의 정보를 얻고 이를 기반으로 하여 프레임워크나 라이브러리가 지원하는 기능을 수행하는 것이다. 스프링의 주요 기능인 DI도 리플렉션의 원리가 들어있다.

 


5. 리플렉션을 통한 DI 프레임워크 구현해보기

 DI를 지원하는 초간단 프레임워크를 구현해보았다.

 

5.1. SSKAutowired

먼저 커스텀 어노테이션을 구현하였다. 특정 클래스의 멤버필드에 @SSKAutowired 어노테이션이 붙어있을 경우 해당 리플렉션을 통해 객체를 생성하기 위함이다.

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SSKAutowired {
}

 

5.2. Robot

 기본 생성자를 갖는 간단한 Robot 를 구현하였다. 특정 클래스의 멤버필드로 사용되며, DI를 위해 @SSKAutowired를 붙여줄 예정이다.

public class Robot {

    public void fight(){
        System.out.println("로봇이 싸웁니다.");
    }

    public void clean(){
        System.out.println("로봇이 청소합니다.");
    }

    private void destroy(){
        System.out.println("로봇이 파괴됩니다.");
    }
}

 

5.3. TestService

 @SSKAutowired가 붙은 robot 멤버필드를 갖고, robot의 기능을 추상화한 메서드를 갖는 클래스이다. 테스트 단계에서 robot 객체가 주입됐는지 확인하기 위해 getRobot 메서드도 추가하였다.

public class TestService {

    @SSKAutowired
    private Robot robot;

    public Robot getRobot(){
        return robot;
    }
    public void start(){
        robot.fight();
        robot.clean();
    }
}

 

5.4. CustomApplicationContext

 특정 클래스를 스캔하여 필요한 의존성을 주입해주는 클래스이다. getInstance(TestService.class) 메서드를 호출할 경우 @SSKAutowired 멤버필드에 대한 의존성이 주입된 TestService 객체를 생성 및 리턴한다.

public class CustomApplicationContext {

    /**
     * 클래스의 멤버필드 중 SSKAutowired가 붙어있을 경우 의존성 주입
     * @param clazz - 스캔 클래스
     * @return - 의존주입이 완료된 스캔 클래스
     * @throws Exception
     */
    public static <T> T getInstance(Class<T> clazz) throws Exception{

        T instance = createInstance(clazz);
        Arrays.stream(clazz.getDeclaredFields()).forEach(field -> {
            if(field.getAnnotation(SSKAutowired.class) != null){ // SSKAutowired가 붙은 멤버필드일 경우
                try {
                    Object fieldInstance = createInstance(field.getType()); // 멤버필드에 대한 객체 생성
                    field.setAccessible(true);
                    field.set(instance, fieldInstance); // 생성된 객체를 instance에 셋팅 (DI)
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            }
        });
        return instance;
    }

    /**
     * 리플렉션 기본 생성자를 통해 객체 생성
     * @param clazz - 클래스 타입
     * @return 클래스 객체
     * @throws Exception
     */
    private static <T> T createInstance(Class<T> clazz) throws Exception{
        Constructor<T> constructor = clazz.getDeclaredConstructor(); // 리플렉션을 통해 클래스의 기본생성자 정보 조회
        constructor.setAccessible(true);
        return constructor.newInstance(); // 객체 생성
    }
}

 

5.5. Test

 CustomApplicationContext의 테스트 코드이다. TestService를 파라미터로 한 getInstance 메서드를 호출하면 의존성이 주입된 TestService 객체를 리턴받고, 확인하는 메서드이다. testService.start()를 통해 콘솔에 출력도 해보았다.

@Test
void getInstance() throws Exception {

    TestService testService = CustomApplicationContext.getInstance(TestService.class);

    assertNotNull(testService.getRobot());
    testService.start();
}

출력결과

 


6. 강력한 단점

 강력한 리플렉션, 그만큼 단점도 강력하기 때문에 사용 시 굉장한 주의를 요구한다.

 

6.1. 일반 메서드 호출보다 성능이 떨어진다.

 리플렉션은 동적으로 클래스를 생성하기 때문에 JVM 컴파일러가 최적화 할 수 없다. 컴파일 시에는 타입이 정해지지 않았기 때문이다. 해당 클래스의 타입이 맞는지, 생성자가 존재하는지 등의 벨리데이션 과정을 런타임 시 처리해야하기 때문에 성능이 떨어진다.

 

6.2. 컴파일 시 타입 체크가 불가능하다.

 리플렉션은 런타임 시점에 클래스의 정보를 알게 되므로 컴파일 시점에 타입 체크가 불가능하다.

 

6.3. 추상화를 파괴한다.

 리플렉션을 사용하는 모든 클래스의 정보를 알 수 있다. 외부로 노출시키지 않기 위해 private 접근제어자를 사용해도 접근할 수 있다. 즉, 추상화가 파괴된다.

 


7. 참고

파랑, 아키의 리플렉션 - https://www.youtube.com/watch?v=67YdHbPZJn4 

자바 리플렉션 - https://roadj.tistory.com/7

리플렉션의 단점 - https://middleearth.tistory.com/72

반응형

+ Recent posts