반응형

개요

이 게시글은 자바 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()));
    }
}

 


정리

 리스트는 컴파일 타입 에러를 잡아 런타임 시 타입 안전성을 확보할 수 있다는 이점이 있다. 배열과 제네릭을 사용하는 리스트에는 매우 다른 타입 규칙이 적용된다. 배열은 공변이고 실체화되는 반면, 리스트는 불공변이고 타입 정보가 소거된다. 그 결과 배열은 런타임에 타입 안전성을 확보할 수 없고. 리스트는 확보할 수 있다.

 성격이 다른 둘을 섞어 쓰기란 쉽지 않다. 만약 둘을 섞어 쓰다가 컴파일 오류나 경고를 만나면, 가장 먼저 배열을 리스트로 대체하는 방법을 적용해야한다.

 


참고

 이펙티브 자바 - 조슈아 블로크

반응형
반응형

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. 개요

 다이나믹 프록시를 공부하던 중 리플렉션이라는 개념이 두둥등장하였다. 간단하게 개념만 짚고 넘어가려했으나, 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

반응형
반응형

1. 개요

 - 토비의 스프링 책에 나온 계산기 예제 코드에 대해 템플릿/콜백 패턴을 적용한다.

 - 패턴을 도출해내는 과정을 이해하기 위함이다.


2. 템플릿/콜백 패턴이란?

 - 전략 패턴에 사용되는 인터페이스 구현체 클래스 대신 익명 내부 클래스가 사용되는 패턴이다. (전략패턴 + 익명 내부 클래스)

 - 코드 내에 고정된 부분(템플릿)과 변경되는 부분이 있을 때 고정된 부분은 클래스 또는 메서드로 분리하여 템플릿 역할을 갖게하고, 변경되는 부분 콜백(익명 클래스의 메서드)으로 처리한다.

 - 콜백으로 처리하는 이유는 변경되는 부분이 여러 클래스에서 재사용되는게 아닌 특정 클래스의 메서드 내에서 한번만 사용되기 때문이다. 만약, 여러 클래스에서 사용된다면 템플릿/콜백 메서드보다는 전략 패턴을 사용하는게 더 유리하다.

 


3. 템플릿/콜백 패턴 적용

 

3.1. 예제코드

public class Calculator{

    public Integer calcSum(String path) throws IOException {
        BufferedReader br = new BufferedReader(new FileReader(path));

        Integer sum = 0;
        String line = null;

        while((line = br.readLine()) != null){
            sum += Integer.valueOf(line);
        }

        br.close();
        return sum;
    }
}

 - path 경로에 있는 txt 파일을 읽어 각 라인에 적혀있는 숫자들을 연산하는 예제이다.

 

3.2. 코드 분석 및 템플릿 설계

 - 코드를 분석하여 어떤 전략을 사용해 템플릿을 설계할지 생각해본다. 일단 예제의 목적 자체가 템플릿/콜백 패턴 구현이므로 이에 초점을 맞추었다. 현재  'calcSum' 이라는 더하기 연산 처리 메서드만 있지만 곱하기, 나누기, 빼기의 메서드가 추가된다고 가정하고 분석하였다.

 

아래 사항들을 생각하며 분석하였다.

 1) 공통부분과 바뀌는 부분이 있는지

 2) 공통부분은 어떤 레벨로 분리할지

 3) 바뀌는 부분은 타 클래스 혹은 메서드에서 재사용할 가능성이 있는지

 

3.3. 분석결과

3.3.1. 공통부분

- path에 대한 BufferedReader를 생성하는 부분

- 반복문을 통해 BufferedReader에서 라인을 읽는 부분

- BufferedReader를 close 하는 부분 (+ try, catch, finally 로 처리)

 

3.3.2. 바뀌는 부분

- 값을 연산하는 부분

 

3.3.3. 정리

- 공통 부분은 BufferedReader의 생명주기와 관련있고, Integer형태의 결과값을 리턴하는 부분이므로 다른 클래스에서도 충분히 재사용할 수 있다고 판단. 클래스로 분리한다.

- 더하기, 빼기, 곱하기, 나누기 연산은 메서드마다 유연하게 바뀌어야 하므로 템플릿/메서드 패턴보다는 전략패턴을 고려하였으나, 각 연산 처리는 다른 클래스에서 재사용되지 않고, Calculator 클래스에 생성될 메서드에 한번만 사용될 것이기 때문에 일회성 성격을 지닌 내부 익명 클래스로 구현한다. 익명 클래스는 콜백 오브젝트 역할을 수행할 것이며, 이를 통해 템플릿/콜백 패턴을 적용한다.

 

3.4. 1차 코드수정

3.4.1. Calculator2.java

public class Calculator2 {

	// 공통 부분을 구현한 Template 클래스
    private BufferedReaderTemplate bufferedReaderTemplate;

	// 외부로부터 DI받기위한 수정자 메서드
    public void setBufferedReaderTemplate(BufferedReaderTemplate bufferedReaderTemplate){
        this.bufferedReaderTemplate = bufferedReaderTemplate;
    }

	// 더하기 메서드
    public Integer calcSum(String path) throws IOException {

		// Template의 withCalculatorCallback 를 호출 시 CalculatorCallback에 대한 내부 익명클래스 구현 및 전달
        return bufferedReaderTemplate.withCalculatorCallback(path, new CalculatorCallback() {
            @Override
            public Integer calculate(String line, Integer value) {
                return value + Integer.valueOf(line);
            }
        });
    }
    
    // 곱하기 메서드
    public Integer calcMultiply(String path) throws IOException {
		// Template의 withCalculatorCallback 를 호출 시 람다식을 활용하여 내부 익명클래스 구현 및 전달
        return bufferedReaderTemplate.withCalculatorCallback(path, (line, value) -> value * Integer.valueOf(line));
    }
}

 - 여러 클래스에서 공통으로 사용될 여지가 있는 BufferedReaderTemplate은 외부(ObjFactory.java)로부터 DI 받는다.

 - 메서드 호출 시 템플릿/콜백 패턴이 적용된 bufferedReaderTemplate.withCalculatorCallback() 메서드를 호출하며, 이때 두번째 파라미터인 CalculatorCallback() 인터페이스에 대한 구현체를 내부 익명클래스로 구현한다.

 - 곱하기 메서드는 람다식을 사용하여 간단하게 구현했다. 사실상 더하기 메서드와 동일하다. 

 

3.4.2. BufferedReaderTemplate.java

public class BufferedReaderTemplate {

	// CalculatorCallback을 함께 받는 메서드 정의
    public Integer withCalculatorCallback(String path, CalculatorCallback callback) throws IOException {
        BufferedReader br = null ;
        try{
            br = new BufferedReader(new FileReader(path));
            String line;

            Integer res = 0;
            while((line = br.readLine()) != null) {
                res = callback.call(line, res); // 콜백 오프젝트의 call 메서드 호출
            }

            return res;
        }catch(FileNotFoundException e){
            e.printStackTrace();
            throw e;
        }finally {
            if(br != null){
                br.close();
            }
        }

    }
}

 - CalculatorCallback 구현체를 파라미터로 받는 withCalculatorCallback 메서드를 구현하였다. 만약 계산기가 아닌 다른 목적으로 이 클래스를 사용한다면 그에 맞게 메서드를 만들수 있으므로 확장성이 보장된다.

 - 중간 부분에 callback.call(line,res); 구문을 통해 콜백 오프젝트의 call 메서드를 호출하여 연산 처리가 되도록 하였다.

 

3.4.3. CalculatorCallback.java

public interface CalculatorCallback {

    public Integer call(String line, Integer value);
}

 - 템플릿에 사용될 콜백 오브젝트를 정의하였다. 목적은 파라미터로 들어온 line의 값과 value 의 값을 목적에 맞게 연산하기 위함이다.

 

3.4.4. ObjFactory.java

@Configuration
public class ObjFactory {

    @Bean
    public Calculator2 calculator2(){
        Calculator2 calculator2 = new Calculator2();
        calculator2.setBufferedReaderTemplate(bufferedReaderTemplate());
        return calculator2;
    }

    @Bean
    public BufferedReaderTemplate bufferedReaderTemplate(){
        return new BufferedReaderTemplate();
    }
}

 - DI 처리를 위한 Factory 클래스이다. BufferedReaderTemplate Bean을 만들고 Calculator2에 DI 하고있다.

 

3.5. 1차 테스트

 - Junit 코드 작성 후 테스트를 진행하였다. DI 정보는 ApplicationContext에서 읽어와 멤버필드에 넣어주었다.

 - numbers.txt 파일은 test 패키지의 resource 경로에 넣어두고, getResource().getPath() 메서드를 통해 파일 경로를 읽어 멤버필드에 넣어주었다.

 - numbers.txt 파일 내용은 아래와 같다.

number.txt

1
2
3
4
5

 

3.5.1. 테스트 코드

public class PracticeCalculatorTest {

    private Calculator2 calculator;
    private String filePath;

    @BeforeEach
    void setUp(){
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(ObjFactory.class);
        this.calculator = applicationContext.getBean("calculator2", Calculator2.class);
        this.filePath = getClass().getResource("/numbers.txt").getPath();
    }
    @Test
    public void sumOfNumbers() throws IOException {

        int sum = calculator.calcSum(filePath);

        assertThat(sum).isEqualTo(15);
    }

    @Test
    public void multiplyOfNumbers() throws IOException {

        int sum = calculator.calcMultiply(filePath);

        assertThat(sum).isEqualTo(120);
    }
}

 

3.5.5. 테스트 결과

 - 더하기는 문제가 없으나, 곱하기에서 실패한다. 원인은 템플릿 메서드인  withCalculatorCallback에 있었다. 최초 res 값이 0일 경우 어떤 값을 곱해도 0이 나오기 때문이다. 곱하기일 경우 변하는 값에 최초 res값이 있다는 사실을 놓쳤다.

1차 테스트 결과

 

3.6. 2차 코드수정

 - 최초 res 값을 처리할 부분을 생각해보자. 최초 값을 CalculatorCallback의 파라미터로 넣는 방법이 있고, withCalculatorCallback의 파라미터로 넣는 방법이 있다.

- CalculatorCallback는 String 형태로 들어온 line 값과 두번째 파라미터인 현재 결과 값을 연산하는 메서드이다. 만약 이 부분에 최초 응답 값이 있을 경우 분기하는 로직이 들어갈 것으로 예상되기에 withCalculatorCallback 메서드의 파라미터로 넘기는 방법을 선택했다.

 

3.6.1. Calculator2.java

public class Calculator2 {

    private BufferedReaderTemplate bufferedReaderTemplate;

    public void setBufferedReaderTemplate(BufferedReaderTemplate bufferedReaderTemplate){
        this.bufferedReaderTemplate = bufferedReaderTemplate;
    }

    public Integer calcSum(String path) throws IOException {

        return bufferedReaderTemplate.withCalculatorCallback(path, new CalculatorCallback() {
            @Override
            public Integer call(String line, Integer value) {
                return value + Integer.valueOf(line);
            }
        },0); // 최초 값인 0 추가
    }
    public Integer calcMultiply(String path) throws IOException {

		// 최초 값인 1 추가
        return bufferedReaderTemplate.withCalculatorCallback(path, (line, value) -> value * Integer.valueOf(line),1);
    }
}

 - withCalculatorCallback에 최초 값에 대한 파라미터를 전달하였다. 더하기는 0, 곱하기는 1이다.

 

3.6.2. BufferedReaderTemplate.java

public class BufferedReaderTemplate {
	
    // initVal 추가
    public Integer withCalculatorCallback(String path, CalculatorCallback callback, Integer initVal) throws IOException {
        BufferedReader br = null ;
        try{
            br = new BufferedReader(new FileReader(path));
            String line;

            Integer res = initVal; // initVal을 res값에 대입
            while((line = br.readLine()) != null) {
                res = callback.call(line, res);
            }

            return res;
        }catch(FileNotFoundException e){
            e.printStackTrace();
            throw e;
        }finally {
            if(br != null){
                br.close();
            }
        }

    }
}

 - 메서드에 대한 시그니처를 수정하고, res = initVal을 넣어 최초 값을 설정해주었다.

 

3.7. 2차 테스트

 

3.7.1. 테스트 결과

 - 더하기와 곱하기 테스트가 성공함을 확인할 수 있다.

2차 테스트 결과

 


4. 회고

 토비의 스프링 3장 템플릿 부분 중 템플릿/콜백 패턴의 사용을 도출해내는 과정이 잘 이해되지 않아 코드 분석 및 적용 패턴 도출 과정을 근거와 함께 생각해보며 구현해보았다. 패턴 적용이 완료된 코드를 한번 작성해본터라 그 코드를 따라가는 느낌도 없지않아 있었지만, 코드를 분석하고, 패턴을 사용하는 근거를 생각하는 과정을 통해 템플릿/패턴을 사용하는 이유를 보다 잘 이해하게 되었다.

 스프링에서 제공하는 많은 클래스들은 이러한 패턴들의 조합으로 이루어져있다고 한다. 여러 패턴들을 소비해보고 그 원리를 이해하려고 노력하는 게 스프링을 제대로 사용하는 것이 아닐까라는 생각이 문뜩 드는 하루였다. ㅎㅎ

반응형
반응형

1. 개요

 JWT는 인증, 인가에 사용하는 Json 형식의 토큰이고, 인코딩되어있다 정도로만 알고있었으나 이번 멘토링 과제에 적용하기 위해 공부해보니 자세해서 정리된 글들이 많이 보였다. 열심히 구글링하여 이해한 내용들과 필자가 궁금한 점들을 정리해보았다.

 

 


2. JWT란?

 JWT는 Json Web Token의 약자로 '웹에서 사용되는 Json 형식의 토큰'이다. 토큰에는 사용자의 권한 및 기본 정보, 서명 알고리즘 등이 포함되어 있다. 개인정보는 저장하지 않는데, 이유는 정보성 데이터가 저장되는 Payload는 쉽게 조회할 수 있기 때문이다.

 JWT는 서버에 저장되지 않고 클라이언트에서 저장하기 때문에 서버의 메모리 부담을 덜 수 있다. 이처럼 서버에 상태값을 저장하지 않는 것을 무상태(Stateless)라 하며 JWT는 이 무상태 성질을 갖는다.

 

JWT 사용 프로세스는 다음과 같다.

 1. 로그인을 성공했을 때 JWT를 생성하여 클라이언트에게 응답해준다.

 2. 클라이언트는 요청마다 Authrization 헤더에 Bearer 타입으로 JWT 값을 넣어 서버로 보낸다.

 3. 서버는 JWT 값을 검증하여 요청 처리 여부를 결정한다.

 


3. JWT 구조

 JWT는 Header, Payload, Signature로 구성되며, 각 부분은 온점(.)으로 구분된다.

 Header에는 토큰의 유형과 서명 알고리즘, Payload에는 권한 및 기본 정보, Signature에는 Header와 Payload를 Base64로 인코딩 한 후 Header에 명시된 해시 함수를 적용하고, 개인키로 서명한 값(이를 전자서명이라고 한다)이 담겨있다. Signature를 통해 토큰에 대한 위변조 여부를 체크할 수 있다.

 

 아래 이미지는 JWT 에 대한 인코딩, 디코딩 데이터를 확인할 수 있는 jwt.io 라는 사이트에서 제공하는 예제를 캡쳐한 것이다. 빨간색 부분이 Header, 보라색 부분이 Payload, 하늘색 부분이 Signature에 해당한다. 

jwt.io

 

 


4. 토큰은 암호화 된거 아냐?

 앞서 Payload는 쉽게 조회할 수 있어 개인정보를 저장하지 않는다고 했었는데, 위 사진을 보면 '어딜봐서 쉽게 조회할 수 있다는거지? 딱 봐도 암호화 되어있는것 같은데?' 라고 생각할 수 있다. 하지만 Header와 Payload는 Base64 URL-safe Encode 형식으로 인코딩되어있을 뿐이고 Signature 만 암호화 되어있다.

 아래는 실제 Header와 Payload를 base64 디코딩 사이트에서 디코딩한 결과이며, 토큰 값은 인코딩 값이라는 것을 알 수 있다. (인/디코딩 사이트 https://www.convertstring.com/ko/EncodeDecode/Base64Decode)

구분 토큰 값 디코딩 값
Header eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 {"alg":"HS256","typ":"JWT"}
Payload eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG5Eb2UiLCJpYXQiOjE1MTYyMzkwMjJ9 {"sub":"1234567890","name":"JohnDoe","iat":1516239022}

 

 


 

5. 토큰의 인가 정보를 바꾸면?

 지금은 Payload에 sub, name, iat와 같은 값들이 들어있으나 일반적으로 "role":"admin" 혹은  "role":"user"와 같은 권한 정보도 포함시킨다. 그런데 여기서 다음과 같은 궁금증이 생겼다. 권한정보를 임의로 바꾼다면 admin 권한도 갖을 수 있지 않을까?

 클라이언트가 응답 받은 JWT의 Payload 값을 디코딩한 후 role에 대한 value값을 user 에서 admin으로 바꾼 후 다시 인코딩하여 서버에게 전달하면 admin 권한을 갖는 것과 동일하게 처리될지 궁금했다. 어차피 서버에서도 인증 정보를 따로 저장하지 않기 때문이다.

결론은 그딴 상술은 통하지 않는다 였다.

 JWT의 세번째 항목인 Signature가 토큰의 위변조를 체크하기 때문이다. Signature는 Header와 Payload를 Base64로 인코딩 한 후 Header에 명시된 해시 함수를 적용한 값이 들어있다. 서버에서 토큰을  발급한 시점의 Header와 Payload 값에 대한 해싱 값이 Signature에 있으며, Header와 Payload 값이 위변조 됐다면 해싱 값이 일치하지 않아 위변조된 토큰임을 알아차릴 수 있다.

 

 


 

6. 클라이언트의 토큰 저장위치

JWT를 Access Token, Refresh Token으로 분리하고 refresh Token은 http Only secure 쿠키에, 액세스 토큰은 로컬변수에 저장하는 방식을 채택해야한다. 추가로 http only secure 옵션과 XSS 공격을 막는 필터를 추가해야한다. 여기서 액세스 토큰은 인가, 인증정보가 들어있는 토큰, 리프레시 토큰은 액세스 토큰을 재발급하기 위한 토큰이다.

 CSRF 공격을 막기 위해서는 쿠키에 액세스 토큰이 있어서는 안된다. 그러므로 쿠키에는 리프레시 토큰을 저장하고 액세스 토큰은 로컬변수에 저장해야한다. http only는 스크립트를 통한 쿠키 접근을 막는 옵션이고, secure는 네트워크 감청에 의한 쿠키 탈취를 막는 옵션이다. secure가 적용되어 있을 경우 https가 적용된 서버에 대해서만 통신이 가능하게 된다.

 이렇게 되면 해커가 CSRF 공격을 하더라도 쿠키에는 액세스 토큰이 없기 때문에 인증 불가 상태가 되어 요청이 차단되고, http only secure 쿠키 특성 상 리프레시 토큰 조회는 불가능하다. 액세스 토큰은 로컬 변수에 저장되어 있으나 XSS 공격을 막으므로 스크립트를 통한 접근도 불가능하다. (참고: https://pronist.dev/143)

 

 

 

 

 

반응형
반응형

1. 개요

 - Interceptor와 WHOIS OpenAPI를 사용하여 해외에서 접근하는 IP를 차단해보자.

 


2. 환경

 - SpringBoot

 - JDK 1.8

 


3. 구현

 - 핵심 로직은 크게 2가지이다. Client의 요청을 Controller 앞단에서 처리되게 할 Interceptor, WHOIS API 통신을 위한 CloseableHttpClient.

 

 1) IPCheckInterceptor.java

@Component
public class IPCheckInterceptor implements HandlerInterceptor, Constants {
	
	@Autowired
	private WSOpenAPIService WSService;

	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
	
		String clientIp = request.getHeader("X-Forwarded-For");
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("Proxy-Client-IP");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("WL-Proxy-Client-IP");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("HTTP_CLIENT_IP");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("HTTP_X_FORWARDED_FOR");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getRemoteAddr();
	    }
		
	    //로컬 테스트 시 주석 해제해주세요. 미국 IP입니다.
	    //clientIp = "54.211.120.28";
	    
	    if(!LOCAL_HOST.equals(clientIp)) {
	    	Map<String,String> clientInfo = WSService.getClientInfoByIPAddress(clientIp);
	    	
	    	if(clientInfo == null) {
	    		logger.error("IP에 대한 클라이언트 정보 조회에 실패하였습니다.");
	    		return false;
	    	}
	    	
	    	String country = clientInfo.get(WHO_IS_COUNTRY_CODE);

	    	if(!KOREA_COUNTRY_CODE.equals(country)) {
	    		logger.error("해외 IP가 감지되었습니다. 접근을 차단합니다. IP : {}, Country : {}", clientIp, country);
	    		return false;
	    	}
	    }
	    
	    return true;
	}
}

 - 'Client IP 추출 > WHOIS Service 메서드 실행 > 국적 코드 확인 > 접근 차단' 로직을 수행한다.

 

 - preHandler Method : Controller에 접근하기 이전에 수행되는 메서드이다. 메서드 내부에는 웹서버나 프록시를 거쳐 들어온 클라이언트의 IP를 request Header에서 추출하고, WHOIS API를 호출하는 로직이 포함되어있다.

 

 - !LOCAL_HOST.equals(clientIp) : LOCAL_HOST는 127.0.0.1 값에 대한 상수으로 인터페이스에 정의해두었다. 로컬 테스트 시 API를 태울 필요가 없어 추가하였다.

 

 - WSService.getClientInfoByIpAddress(String clientIp) : WHOIS API에 대한 서비스 클래스이다.

 

 - WHO_IS_COUNTRY_CODE, KOREA_COUNTRY_CODE : 각각 countryCode, KR 문자열에 대한 상수 값이다. WHOIS API의 json Response 값에 대한 key값이다.

 

 

2) WSOpenAPIService.java

@Service
public class WSOpenAPIService implements Constants{
	
	@Value("${whois.api.key}")
	private String apiKey; 
	
	@Value("${whois.api.uri}")
	private String apiUri; 
	
	@Autowired
	private CloseableHttpClient closeableHttpClient;
	
	@Autowired
	private RequestConfig requestConfig;
	
	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@SuppressWarnings("unchecked")
	public Map<String,String> getClientInfoByIPAddress(String ip) {
		
		ObjectMapper objectMapper = null;

		try {
			List<NameValuePair> nameValuePairs= new ArrayList<NameValuePair>();
			
			nameValuePairs.add(new BasicNameValuePair("query",ip));
			nameValuePairs.add(new BasicNameValuePair("key",apiKey));
			nameValuePairs.add(new BasicNameValuePair("answer","json"));
			
			HttpGet httpGet = new HttpGet(apiUri);
			httpGet.setConfig(requestConfig);
			httpGet.addHeader("Content-type", "application/json");
			
			URI uri = new URIBuilder(httpGet.getURI())
					.addParameters(nameValuePairs)
					.build();

			httpGet.setURI(uri);
			
			CloseableHttpResponse response = closeableHttpClient.execute(httpGet);

			int statusCode = response.getStatusLine().getStatusCode();
			
			if(statusCode == HttpStatus.OK.value()) {
				String json = EntityUtils.toString(response.getEntity(), "UTF-8");
				logger.info("WHO IS API Response json : "+json);
				objectMapper = new ObjectMapper();
				
				Map<String,Map<String,String>> map = objectMapper.readValue(json, Map.class);

				return map.get(WHO_IS);
				
			}
			return null;
		} catch (ClientProtocolException e) {
			logger.error(e.getMessage());
			e.printStackTrace();
			return null;
		} catch (URISyntaxException e) {
			logger.error(e.getMessage());
			e.printStackTrace();
			return null;
		} catch (IOException e) {
			logger.error(e.getMessage());
			e.printStackTrace();
			return null;
		}
	}
	
}

 - ConnectionPoolHttpClient를 사용하여 WHOIS API 서버로 통신 및 응답 값을 추출하는 로직을 수행한다.

 - 통신에 성공할 경우 ObjectMapper를 사용하여 whois 값을 Map 형태로 변환한다.

 - WHO_IS : whois 값에 대한 상수 값이다. WHOIS API의 json Response 값에 대한 key값이다.

 

3) HttpClientConfig.java

@Configuration
public class HttpClientConfig {

	private static final int MAX_CONNECTION_PER_ROUTE = 20;
	private static final int MAX_CONNECTION_TOTAL = 200;
	private static final int CONNECTION_TIMEOUT = 10;
	private static final int SOCKET_TIMEOUT = 5;
	private static final int CONNECTION_REQUEST_TIMEOUT = 5;
	
	@Bean
	public CloseableHttpClient closeableHttpClient() {
		CloseableHttpClient closeableHttpClient = 
				HttpClients.custom().setConnectionManager(poolingHttpClientConnectionManager()).build();
		
		return closeableHttpClient;
		 
	}
	
	private PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() {
		PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
		connectionManager.setDefaultMaxPerRoute(MAX_CONNECTION_PER_ROUTE);
		connectionManager.setMaxTotal(MAX_CONNECTION_TOTAL);
		return connectionManager;
	}
	
	@Bean
	public RequestConfig requestConfig() {
		RequestConfig requestConfig = RequestConfig.custom()
                .setSocketTimeout(SOCKET_TIMEOUT * 1000)
                .setConnectTimeout(CONNECTION_TIMEOUT * 1000)
                .setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT * 1000)
                .build();
		
		return requestConfig;
	}
}

 - ConnectionPool HttpClient 에 대한 config 클래스이다.

 - requestConfig, closeableHttpClient를 Bean으로 등록해 사용한다.

 - closeableHttpClient Bean 생성 시 커스텀한 PoolingHttpClientConnectionManager 객체를 주입시킨다.

 

 - 각 상수에 대한 설명은 다음과 같다.

상수 의미
MAX_CONNECTION_PER_ROUTE  CONNECTION 하나의 ROUTE에 연결 가능한 최대 CONNECTION 수
MAX_CONNECTION_TOTAL CONNECTION POOL에 저장될 수 있는 최대 CONNECTION 수
CONNECTION_TIMEOUT 커넥션 (3-HAND)을 맺는 시간에 대한 TIMEOUT
 SOCKET_TIMEOUT 커넥션을 맺은 후 응답을 받는 시간에 대한 TIMEOUT
CONNECTION_REQUEST_TIMEOUT CONNECTION POOL에서 CONNECTION을 꺼내오는 시간에 대한 TIMEOUT

 

4) WebConfig.java

@Configuration
public class WebConfig implements WebMvcConfigurer {

	@Autowired
	private IPCheckInterceptor ipCheckInterceptor;
	
	@Override
	public void addInterceptors(InterceptorRegistry registry) {

		registry.addInterceptor(ipCheckInterceptor) .addPathPatterns("/**")
		.excludePathPatterns("/js/**", "/css/**", "/img/**","/assets/**");

	}

	
}

 - js, css, img, assets 과 같은 정적 페이지 요청을 제외한 모든 요청에 대해 ipCheckInterceptor를 적용시킨다.

 

 

5) IP V4 설정

 - 클라이언트로부터 추출되는 IP가 IPv6 형식이라면 IPv4 형식으로 변경해야한다. STS 환경이라면 'Run Configurations /  Spring Boot App / Arguments' 설정의 VM arguments 값에 '-Djava.net.preferIPv4Stack=true' 값을 넣어준다.

VM arguments 설정

 - 만약 외부 tomcat에서 운용된다면 catalina.sh 파일에 JVM 설정을 추가해준다.

catalina.sh 설정


4. 테스트

 - 로컬에서 테스트 시 IP가 127.0.0.1로 들어오기 때문에 IPCheckInterceptor에서 IP 값을 임의의 미국 IP로 변경한 후 테스트를 진행하였으며, 다음과 같이 API 응답 값을 얻고 접근을 차단하였다. 실제 클라이언트에서는 다음과 같이 빈 화면이 조회되게 된다.

INFO  2022-02-18 01:29:24[http-nio-8088-exec-1] [WSOpenAPIService:87] - WHO IS API Response json : {"whois":{"query":"54.211.120.28","queryType":"IPv4","registry":"ARIN","countryCode":"US"}}
ERROR 2022-02-18 01:29:24[http-nio-8088-exec-1] [IPCheckInterceptor:66] - 해외 IP가 감지되었습니다. 접근을 차단합니다. IP : 54.211.120.28, Country : US

요청이 차단된 화면

 


5. 마치며

 API 통신을 하여 국가코드를 조회하는 방식을 구현하고 나니, 동접자가 많아져 Connection이 모두 사용될 경우 서비스 속도가 느려질수 있겠다라는 생각이 들었고 DB 기반의 geoIp를 사용하는 이유도 이해가 갔다. 

 

 혹시 이 글을 보시는 분들 중 geoIp를 사용해보신 분이 있다면 댓글로 후기를 남겨주셨으면 좋겠다 ㅎㅎ;

(Help...)

반응형
반응형

1. 개요

 이번 동계 올림픽 하이라이트를 보니 '쭝' 얘기가 많더라. 그러고보니 한창 배그에 빠져있을 때였나... 그날도 회사갔다가 친구들이랑 배그를 하려고 접속했더니 XING, MING 으로 시작하는 '쭝' 사람들과 필자의 아이디가 4인 스쿼드를 돌리고 있더라. 나쁜 X끼들.

 

 본론으로 넘어가겠다. 서비스 오픈 후 국가별 서비스 접근 내역을 확인했다. 우리나라가 대부분이나 타국(중국, 미국, 러시아, 체코 기타등등...)에서의 접근도 있었다.

 타국 사용자를 타겟으로 한 서비스가 아니었기에 신경을 쓰지 않았으나, 필자의 서버는 호스팅 서버에 묶여있었고, 트래픽에 따른 추가비용 결제가 걸려있는 호스팅 서버의 특성 상 불필요한 접근을 막아야 했다. 타국에서의 접근은 불순한 의도일 확률이 높기 때문에 보안을 위해서도 해외 IP를 차단해야했다는 생각이 들었다.

 해외 IP를 차단하는 방법에 대해 서치를해보니 GeoIP를 사용하는 방법이 많던데, 몇천건 이상으로는 유료라는 썰이 있어 KISA의 무료 오픈 API인 WHOIS를 채택하게 되었다.


2. WHOIS API

https://whois.kisa.or.kr/kor/openkey/keyCre.do

 

KISA 후이즈검색 whois.kisa.or.kr

한국인터넷진흥원 인터넷주소자원 검색(후이즈검색) 서비스 입니다.

xn--c79as89aj0e29b77z.xn--3e0b707e

 

 WHOIS API는 KISA에서 제공하는 무료 Open API로 IP에 대한 국가코드 값을 얻을 수 있다. OpenAPI 사용 안내를 보면 다음과 같이 IP주소/AS 번호에 대한 국가코드를 요청하는 API를 확인할 수 있다.

 무료라는 큰 장점이 있지만, API를 위해 서버 내에서 HTTP 통신을 한번 태워야한다는 단점이 있다.

 * 만약, 더 좋은 방법이 있다면 댓글로 공유부탁해요!

WHOIS API

 

WHOIS API 응답값


3. 키 발급

 API 사용을 위해서는 먼저 키를 발급받아야 한다. WHOIS API 사이트에 접속 후 전자우편을 통해 발급받는다.

키 발급

 필자의 경우 네이버 메일로 받았으며, 다음과 같이 메일에 키 값이 포함되어 온다. @_@. 준비는 끝났다. (벌써)

WHOIS API 인증 키 메일


4. 구현

 필자는 단순 API 테스트를 위해 JUnit으로 단순하게 로직을 구현하였으며, 응답받은 Json String 값을 객체로 변환하기 위해 ObjectMapper를 사용하였다.

@Test
	public void whoisAPI() {

		String ip = "[IP]";
		String apiKey = "[API KEY]";
		String apiUri = "http://whois.kisa.or.kr/openapi/ipascc.jsp";
		
		List<NameValuePair> nameValuePairs= new ArrayList<NameValuePair>();
		
		nameValuePairs.add(new BasicNameValuePair("query",ip));
		nameValuePairs.add(new BasicNameValuePair("key",apiKey));
		nameValuePairs.add(new BasicNameValuePair("answer","json"));
		
		HttpGet httpGet = new HttpGet(apiUri);
		
		URI uri = null;
		ObjectMapper objectMapper = null;
		
		try {
			uri = new URIBuilder(httpGet.getURI())
					.addParameters(nameValuePairs)
					.build();
			
			httpGet.setURI(uri);
			
			CloseableHttpClient httpClient = HttpClientBuilder.create().build();
			CloseableHttpResponse response = httpClient.execute(httpGet);

			int statusCode = response.getStatusLine().getStatusCode();
			
			if(statusCode == HttpStatus.OK.value()) {
				String json = EntityUtils.toString(response.getEntity(), "UTF-8");
				objectMapper = new ObjectMapper();
				
				Map<String,Map<String,String>> map = objectMapper.readValue(json, Map.class);
				Map<String,String> whois = map.get("whois");
				
				System.out.println("response : "+ map.toString());
				System.out.println("contryCode :"+whois.get("countryCode"));
			}
		} catch (URISyntaxException e) {
			e.printStackTrace();
		} catch (ClientProtocolException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} 
	}

 


5. 실행 결과

 실행 결과 key,value 형태로 IP 및 국가코드가 들어있는 걸 확인할 수 있다. 너무 간단하쥬?

실행결과


6. 마치며

 다음 포스팅에서는 인터셉터를 활용하여 해외 IP일 경우 페이지 접근을 차단시키는 로직을 구현해보도록 하겠다. HttpClient 또한 ConnectionPool 한 HttpClient로 커스텀하여 사용해보도록 하겠다.

 

아참, KISA 홈페이지를 둘러보다 우연히 발견한건데, 이 API가 개방된지 얼마 되지않았더라. 많이 쓰세요 여러분.

될놈될...헤헤...

 

반응형

+ Recent posts