검색

레이블이 스프링인 게시물을 표시합니다. 모든 게시물 표시
레이블이 스프링인 게시물을 표시합니다. 모든 게시물 표시

2021년 8월 12일

JUMPUP - 싱글턴 패턴 (Singleton)

싱글턴(Singleton)은 디자인 패턴(Design Pattern)이지만 안티 패턴(Anti-pattern)이라고도 한다. 싱글턴(Singleton)을 둘러싸고 있는 상반된 의견을 이해하기 위하여 역사적 배경을 간단히 살펴보자.

싱글턴(Singleton) 패턴은 1995년 발간된 소프트웨어 개발 고적 서적  Design Patterns 통하여 세상에 알려지게 되었다.  


오랜동안 많은 개발자들이 사용을 즐겼지만 2000년 중반부터 코드를 망가트리는 원인으로 주목을 받으며 안티패턴(Anti-Pattern)의 하나로 간주되고 있다.

다음은 싱글톤에 대한 증오를 보여주는 초장기(온라인에서 검색가능한) 블로그 글이다.

오랜 기간동안 안티 패턴으로 간주되고 있지만  아니러니(irony) 그 편리함으로 인하여   많은 개발자들에 의하여 사용되고 있다.  


싱글턴 패턴(Singleton) 이란 ?

싱글턴은 인스턴스가 하나뿐인 객체를 만들 수 있게 해주는 디자인 패턴으로 클래스 디자인 관점에서 보면 아주 간단해보이지만 구현하는 데 있어서는 쉽지않은 장애물들이 있다.


싱글턴 패턴은 해당 클래스의 인스턴스가 하나만 만들어지고, 어디서든지 그 인스턴스에 접근할 수 있도록 하는 패턴이다. 
“Singletons are classes which have a restriction to have only one instance.”


다음 코드는 단일 인스턴스 객체를 구현하는 고전적인 싱글턴 코드이다. 

public class Singleton {

	// ❸ 유일한 인스턴스 저장을 위한 변수 
    private static Singleton instance ; 
    
    // ❷ 외부에서 인스턴스 생성이 불가하게 생성자를 private 로 선언
    private Singleton (){ }
    
    // ❶ Singleton 클래스 인스턴스를 생성하여 리턴.
    public static Singleton getInstance (){  
    	if(instance == null ){
        	instance = new Singleton();
        }
        return instance ;
    }

}

동시성(Concurrency) 문제

싱글턴 패턴 구현에 있어 가장 큰 장애물 중 하나는 동시성(Concurrency) 문제이다. 멀티 스레드 환경에서 고전적 싱글턴 구현 코드를 실행하는 경우 하나 이상의 객체가 생성되는 문제가 발생될 수 있다. 하나의 스레드에서 싱글턴 클래스를 호출하고 인스턴스가 초기화되는 중간에 다른 스레드에서 싱글턴 클래스를 호출하게 되면 새로운 객체 인스턴스가 생성된다. 이런 이유에서 위의 코드는 Thread-safe 하지 않는 코드라고 볼 수 있다.


간단하게 테스트 코드를 만들어 실행해보았다.

import java.lang.reflect.Constructor;

import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class SingletonTest {  

	public static void main(String[] args) { 
		new SingletonTest().test();
	}
	
	private static Logger log = LoggerFactory.getLogger(SingletonTest.class);
	
	public void test() {
		System.out.println("start sigleton test.");
		new MultiThread("thread01").start();
		new MultiThread("thread02").start();
		new MultiThread("thread03").start();
	};
	
	public class MultiThread extends Thread { 
		
		private String name;  
		
		public MultiThread(String name) {
		   this.name = name;
		} 
		
		public void run() { 
	    	int count = 0; 
	    	for(int i=0; i<5; i++) {
	    		Singleton singleton = Singleton.getInstance(); 
	    		System.out.println( name + " : " + singleton.toString());	 
	    		try {	
	    			Thread.sleep(400);
	    		} catch (Exception e) {
	    			e.printStackTrace();
	    		}
	    	}
	    }
	}
}       

결과를 보면 하나 이상의 객체 인스턴스가 생성었음을 확인 할 수 있었다.


① Earger Initialzation


문제해결은 여러가지 방법이 있지만 먼저 정적변수(static)에 인스턴스 만들어 초기화해보자. 이경우 클래스가 로드 될 때 객체 인스턴스가 생성되기 때문에 하나의 객체만 생성되게 된다.

public class Singleton {

    private static Singleton instalnce = new Singleton() ; // ❷ 유일한 인스턴스를 처음부터 생성 
    
    private Singleton (){ // ❶ 외부에서 인스턴스 생성이 불가하게 생성자를 private 로 선언
    
    }
    
    public static Singleton getInstance (){ // ❸ Singleton 클래스 인스턴스를 리턴.
        return instance ;
    }

}

주의 할 것은 자바는 JVM 당 하나의 인스턴스가 아닌 클래스 로더당 하나의 인스턴스를 보장한다. 예를 들어 톰겟서버에 동일한 웹 프로그램을 배포하고 싱글턴 인스턴스를 호출하는 경우 다른 객체가 생성되게 된다. 이는 독립된 클래스로더를 통하여 웹 프로그램이 로드되기 때문이다.

② Thread-Safe Initialzation (Lazy Initialzation)

인스턴스를 생성하는 getInstance 함수를 동기화처리하여 멀티스레딩 이슈를 해결할 수도 있다. 동기화는 많은 비용이 발생하며 보통 100배 정도의 성능 저하가 있다고 한다.
public class Singleton {

    private static Singleton instalnce  ; // ❷ 유일한 인스턴스 저장을 위한 변수 
    
    private Singleton (){ // ❶ 외부에서 인스턴스 생성이 불가하게 생성자를 private 로 선언
    }
    
    public static synchronized Singleton getInstance (){ // ❸ Singleton 클래스 인스턴스를 리턴. (인스턴스가 필요할 때 생성.)
        if( instance == null )
        	instance = new Singleton();
        return instance ;
    }
}


③ Thread-Safe Initialzation (Lazy Initialzation) : DCL(Double-Checking Locking)

동기화 비용 절감을 위하여 메소드가 아닌 객체를 생성하는 부분에 DCL(Double-Checking Locking) 기법을 적용하는 방법도 있다. DCL 은 실재 객체가 필요할 때 까지 객체를 생성(초기화) 하는 것을 지연하기 위한 디자인이다. 이들 통화여 프로그램을 더 빠르게 실행할 수 있게 된다.

public class Singleton {
 
	private volatile static Singleton instance ; // ❸ 유일한 인스턴스 저장을 위한 변수 
	
    private Singleton (){ // ❷ 외부에서 인스턴스 생성이 불가하게 생성자를 private 로 선언
    
    }
    
    public static Singleton getInstance (){ // ❶ Singleton 클래스 인스턴스를 생성하여 리턴.
    	if( instance == null ) {
    		synchronized( Singleton.class ) { // 객체를 생성하는 부분만 동기화를 적용한다.
    			instance = new Singleton();
    		}
    	}
        return instance ;
    }
}

Double-Checked Locking is Broken
엔지니어(Senior Software Engineer) Peter Haggar(haggar@us.ibm.com)가 2002년 5월에 발표한 Double-Checked Locking and the Singleton Pattern이라는 글이 있다. 

 그는 이 글에서 자신이 2000년 2월에 출간한 『Practical Java Programming Language Guide』에서 소개한 DCL이 잘못되었으며, 왜 잘못되었는지를 알려주고 있다. 이 같은 오류는 Peter Haggar뿐만 아니라 Allen Holub의 『Taming Java Threads』에서도 발견된다. 

Allen Holub 역시 JavaWorld에서 DCL에 대한 자신의 생각이 틀렸으며, 왜 사용해서는 안되는지, 그렇다면 올바른 방식은 무엇인지를 소개하고 있다. 여기에서 알 수 있듯이 현재 출간되어 있는 대부분의 자바 스레드 책들중에 DCL을 다룬 책들은 모두 잘못되었다는 것을 알 수 있다. 

 많은 자바 프로그래머들이 DCL을 해결하기 위해 내놓은 것중에 하나가 volatile 을 사용하는 것이다. volatile 키워드로 선언된 변수의 의미는 운영 체제, 멀티 스레드 등에 의해 프로그램에서 수정될 수 있다는 것이다. 

이에 대한 내용은 Peter Haggar의 Double-checked Locking and the Singleton Pattern에 자세히 설명되어 있으며 여기에 잠시 인용하면 아래와 같은 문제가 있다. JLS(Java Language Specification)을 참고할 때, 변수가 volatile로 선언되면 실행 순서가 일관적인 것으로 여겨지며, 재배치(reordering)이 일어나지 않는다. Peter Haggar은 두 가지 문제를 지적하고 있다.
  • 첫번째는 순서 일관성의 문제가 아니라 최적화를 통해 코드가 옮겨지는 문제(reordering),
  • 두번째는 많은 JVM이 volatile에 대한 순서 일관성조차 제대로 구현하고 있지 않다.
즉 이러한 차이점 때문에 Java에서는 volatile을 사용한 방법이 DCL에 대한 해법이 될 수 없다.

앞의 내용을 좀더 쉽게 설명하면 이렇다.

단일 스레드 환경에서 프로그램과 메모리의 상호 작용은 비교적 간단하게 보인다. 프로그램은 생성된 객체를 특정 메모리위치에 저장하고 다음에 해당 메모리 위치를 검사하면 그 객체는 계속 존재할 것으로 예상한다. 

우리는 프로그램은 코드에 지정된 순서에 따라 순차적으로 실행된다고 생각하지만 진실은 컴파일러,  프로세서, 캐시는 계산 결과에 영향을 미치지 않는 한 프로그램 및 데이터에 대해 모든것을 자유롭게 사용할 수 있다. 예를 들어 컴파일러는 프로그램 코드와 다른 순서로 명령어를 생성 하고 변수를 메모리가 아닌 레지스터에 저장할 수 있다. 또한 프로세스는 명령을 병렬로 순서없이 실행할 수 있다. 그리고 캐시는 변수 값이 주메모리에 저장되는 순서를 변경할 수 있다. JMM(Java Memory Model) 에 따르면 이러한 다양한 재정렬 및 최적화는 프로그램의 실행 결과가 동일한 결과를 얻는 한 모두 가능하다고 한다. 
JMM(Java Memory Model)  고려하면 Lock 을 이용한 싱글턴 구현은 volatile 키워드를 사용하더라도 동시성 문제를 (corherency problem) 유발시킬수 있다.  

자바에서는 Lock 이 아닌 synchronized 키워드를 사용하면 DCL 이슈가 해결될 것 같지만 단일객체 생성을 보장하지는 못한다.


Joshua Bloch 는 Effective Java, Second Edition 에서 보다 조심스러운 접근 방벙을 제안하고 있다.

public class Singleton {
 
	private volatile static Singleton instance ; // ❸ 유일한 인스턴스 저장을 위한 변수 
	
    private Singleton (){ // ❷ 외부에서 인스턴스 생성이 불가하게 생성자를 private 로 선언
    
    }
    
    public static Singleton getInstance (){ // ❶ Singleton 클래스 인스턴스를 생성하여 리턴.
        Singleton instanceToUse = instance ;
    	if( instanceToUse != null ) {  // First check (no locking)
        	return instanceToUse ;
        }
        
    	synchronized( this ) { // 객체를 생성하는 부분만 동기화를 적용한다.
            if( instance == null ) // second checking (with locking)
    	        instance = new Singleton();
            return instance ;    
    	}
    }
}

이방법은 synchronized 구문 내부에서 다시한번 객체의 존재 유무를 검사하는 코드를 추가하였다. 이렇게 하면 앞의 문제가 해결되는 것처럼 보인다. 코드를 예상해보면 Thread2 는 synchronized 블럭에서 instance != null 인것을 확인하고 Thread1 에서 생성한 Singleton 객체를 리턴할 것같이 보여진다.



그러나 위의 코드 역시 단일객체생성을 보장하지는 못한다. 코드를 좀더 분석해보면 아래와 같다.

  1. Thread 1은 getInstance() 메소드로 들어간다.
  2. Thread 1은 synchronized 블록으로 들어간다. instance가 null이기 때문이다.
  3. Thread 1은 ❶ ❷ non-null 인스턴스를 만든다. 생성자를 실행하기 전이다.
  4. Thread 1은 thread 2에 선점된다.
  5. Thread 2는 인스턴스가 null인지를 점검한다. null이 아니기 때문에, thread 2는 instance 레퍼런스를 (부분적으로 초기화된 Singleton 객체)를 리턴한다.
  6. Thread 2는 thread 1에 선점된다.
  7. Thread 1은 ❷ 생성자를 실행함으로서 Singleton 객체의 초기화를 완료하고 레퍼런스를 리턴한다.

④ Lazy Initialization. LazyHolder(Thread-safe)

관용구 "initialization on demand holder"  을  바탕으로 하는 Bill Pugh Singleton 은 가장 많이 사용하는 싱글턴 구현방식으로 volatilesynchronized 키워드 없이도 동시성 문제를 해결하고 있기 때문에 가장 우수한 성능을 보여준다.

public class Singleton {

	private static Singleton instance ; 
    
    private Singleton (){ }
    
    private static class Holder(){
        private static final Singleton instance = new Singleton();
    }
    
    public static Singleton getInstance (){  
        return Holder.instance ;
    }

}

static 클래스는 클래스 로딩 시점에 한번만 호출된다는 점을 이용하였으며, final 키워드를 사용하여 값이 한번만 생성되도록 하여 싱글턴을 구현하고 있다. 주의 할 것은 클래스 로더가 하나 이상 사용되는 환경이라면 하나 이상의 인스턴스가 생성되게 된다. 가장 나은 접근 방법이라고 하지만 자바 Reflection 사용하는 경우 단일 객체 생성을 보장하지 못한다.

import java.lang.reflect.Constructor;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class SingletonTest {  

	public static void main(String[] args) { 
		new SingletonTest().test();
	}
	
	private static Logger log = LoggerFactory.getLogger(SingletonTest.class);

	public void test() {
		System.out.println("start sigleton test.");
		Singleton instance = Singleton.getInstance();
		log.debug("sigleton instance : " + instance.hashCode()); 
		Singleton instance2 = null;
		try {
            Constructor[] cstr = Singleton.class.getDeclaredConstructors();
            for (Constructor constructor: cstr) { 
                constructor.setAccessible(true);
                instance2 = (Singleton) constructor.newInstance();
                break;
            }
        } catch (Exception e) {
            System.out.println(e);
        }
		log.debug("sigleton instance : " + instance2.hashCode());  
	};
}


⑤ Lazy Initailization. Enum(Thread-safe)

Reflection 이슈를 극복하기 위하여 Joshua Bloch 가 싱글턴을 구현에 Enum 사용을 제안한 방법이다. Enum 자체가 Thread-safe하기 때문에 스레드(Thread)를 고려한 코드가 요구되지 않는다.

public enum Singleton {
	INSTANCE;
}


싱글턴의 문제

싱글턴이 처음 소개되었는 시대와 다르게 프로그램은 더 커지고 복잡해졌다. 개발 팀 규모는 더 커졌고 자동화된 테스트가 일반화되었다. 아마도 싱글턴 패턴의 남용과 오용이 난무했을 것이다. 많은 개발자들의 사랑을 받았던 싱글터은 이제 안티 패턴으로 불리고 있다.

싱클턴 페턴은 어려 문제를 해결하면서 다양한 구현 방법이 고안되어 진화하고 있다.
  1. 싱글턴에 의존하는 객체는 테스트를 위한 분리가 매우 어렵다.
  2. 코드들이 매우 강하게 연결되어 있어 재구성이 어렵다. (싱글턴은 인테페이스가 아닌 구현 클래스를 미리 생성하고 정적 함수를 이용하여 인스턴스에 접근하기 때문에 구조적으로 높은 결합도를 갖는다.)
  3. 참조되는 모든 클래스를 수정해야 하기 때문에 전역 객체(싱글턴)에서 비전역 객체로 변경이 어렵다.
남용으로 인한 싱글턴(모든 전역 참조 객체)의 부작용은 코드의 강한 결합에 따라 코드 리팩토링이 어렵고 자동화된 테스트 적용이 어렵다는 점이 가장 큰 이유일 것이다.

싱글턴 문제 해결하기

싱글턴 패턴에서 자동화된 테스트가 가능하고 프로그램 변경이 유연한 아키텍처를 갖게하려면 어떻게 해야할까? 아래와 같은 방법을 생각할 수 있다.
  1. 객체 생성에 대한 책임이 개체 자신이 되면 않된다.
  2. 싱글턴을 참조하는 클래스는 직접적인 강한 연결이 되면 않된다.(구현 클래스를 직접 참조하는 것을 의미)

스프링 프레임워크는 싱글턴 구현을 위하여 전통적인 static 키워드가 아닌 DI(Dependency Injection) 접근 방식을 사용하고 있다. 전역(싱글턴) 객체을 참조하는 클래스들이 생성될 떄 자동으로 전역 객체를 주입하는 방식을 적용하는 것인데 이를 통하여 자동화 테스트가 가능하고 객체 의존성을 최소화하는 싱글턴 구현이 가능하다.

이때 주의할 점은 자바의 특성상 전통적인 static 방식은 classloader 당 유일한 전역 객체를 의미하고 스프링 프레임워크에서는 컨테이너 (스프링 컨텍스트) 내에서 유일한 전역 겍체를 의미한다는 것이다.



참고자료 

2021년 7월 13일

Spring 기반 웹 프로그램에서 Filepond 를 이용한 이미지 업로드 구현하기

이미지 업로드에 최적화되어 있다고 하는 Filepond 을 사용하여 업로드를 구현해보자. Filepond 다양한 Framework 에 대한 Adaptor 를 제공하고 있고 사용이 아주 편리하다. 여기에서는 jQuery 기반의 Front-End 와 Spring 기반의 Back-End  환경에서 구현하였다.


개발 및 운영 환경은 아래와 같다.

Development
  • JAVA 1.8.x
  • Eclipse 4.17.0
  • Tomcat 8.x 
  • npm 6.14.13 , webpack 4.44.1 
  • HW : MacBook Pro 2019 
    • macOS Big Sur 11.4
    • 2.3Ghz 8Core Intel Corei9 
    • 16GB 2667 Mhz DDR4
    • SSD 1TB 
Server 
  • Tomcat 8.5.58.0
  • Open JDK 1.8.0_292-b10
  • Oracle Cloud : VM.Standard.E2.1.Micro 
    • CentOS Linux release 7.9.2009 (Core)
    • 1 OCPU, 1GB MEMORY, 50GB Block Volume
    • 1Network 0.48 Gbps

설치하기

filepond 는 npm 을 사용하여 아래와 같이 npm 을 사용하여 설치한다. (☛ Using FilePond with JavaScript 참고.)
npm 을 사용하지 않는 경우라면 Git 에서 직접 다운로드 하여 사용하면 된다. 
 
npm install filepond --save

filepond 는 유용한 여러 기능들을 플러그인 형태로 지원하고 있다. 이중에서 필수적이라고 생각되는 File Size Validation, Image Preview, media-preview  를 추가로 설치하였다.

 

npm install filepond-plugin-file-validate-size --save
npm install filepond-plugin-image-preview --save
npm install filepond-plugin-media-preview --save

사용하기

Filepond 는 Back-End 부분 즉 서버 사이드 부분을 쉽게 변경하여 사용하는 것을 지원한다. 여기에서는 업로드와 삭제의 경우만 구현하여 사용하였다. 

추가로 JWT 기반의 인증을 사용했다. (참고. Spring 기반 웹 프로그램에서 JWT 인증 사용하기 )  서버 세션을 사용하는 경우는 불필요한 코드이다. 

  • 업로드(Process) 는 ⑴ multipart/form-data POST 형태로 파일을 전달하고 ⑵ 서버는 해당 요청에 대하여 파일을 저장하고 ⑶ 업로드된 파일을 구분하는 유니크한 값을 텍스트 형태로 응답한다. 
  • 삭제(Revert)는 ⑴ BODY 에 텍스트 형태로 파일 구분을 위한 유니크한 값을 포함하여 DELETE 형태로 요청하고 ⑵ 서버는 해당하는 파일을 삭제하고 ⑶ 빈값을 응답한다. 

Front-End 부부은 아래같은 방법으로 구현하였다.  (☛ Setting up FilePond with jQuery 참고) 
  • HTML 코드에서 파일 업로드를 위한 input tag 추가
  • JavaScript 에서 FilePond 인스턴스 생성  

input Tag 에 여러가지 속성 값을 사용하여 FilePone 와 플러그인에 대한 설정이 가능하다. (☛ 이부분에 대한 명확한 문서는 발견하지 못했다)
 


        

 

// ==================================
// Utility for filepond instance create

import * as FilePond from "filepond";
import "filepond/dist/filepond.min.css";

// Import filepond plugins
import FilePondPluginFileValidateSize from "filepond-plugin-file-validate-size";
import FilePondPluginImagePreview from "filepond-plugin-image-preview";
import FilePondPluginMediaPreview from "filepond-plugin-media-preview";

// Import filepond plugin styles
import "filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css";


/**
 * create filefond instance
 * @param {*} renderTo 
 * @param {*} options 
 * @returns filepond instance.
 */
function createFilePond(renderTo, options) {
  options = options || {};
  // Register the plugin
  FilePond.registerPlugin(
    FilePondPluginImagePreview,
    FilePondPluginMediaPreview,
    FilePondPluginFileValidateSize
  );
  return FilePond.create(renderTo.get(0), options);
}

다음 코드는 Spring 기반의 filepond 서버 프로그램이다. (☛ Server configuration 참고)
파일을 저장하거나 삭제하는 부분은 자신의 환경에 맞게 구현하는 작업이 필요하다. 다음 코드에서는 파일 저장을 위하여 미리 구현된 코드를 사용하였다.



다음은 위의 코드를 사용하여 구현된 최종 Front-End 이다

2020년 7월 26일

JWT ( JSON WEB TOKEN ) 인증환경에서 이미지 다운로드

서버 세션이 아닌 JWT 토큰을 기반으로 하는 웹 프로그램은 서버와의 통신이 필요하면 HTTP 헤더에 JWT 토큰을 포함하여  요청하고 서버는 클라이언트 요청 헤더의  JWT 토큰을 꺼내어 인증 정보를 꺼네어 사용하게 된다. 이미지의 경우는 어떻까 ? 

HTML 에서 img  태그는 JWT 와 같은 보안 토큰을 헤더에 포함하는 기능을 제공하지 않는다. 그러나 이미지 데이터를 스크립트에서 다운로드하고  data : image / FILETYPE; base64 URI 체계를 사용하여 이미지를 웹 페이지에 포함시키는 방법을 사용하면 간단하게 제한을 극복할 수 있다. ( vue-auth-image 모듈이나 관련 글들을 참고하는 것이 많은 도움이 되었다.) 



다수의 이미지를 다운로드 하는 경우 요청간 지연을 주기 위하여 아래와 같은 방법을 사용했다.
  var delay = 600 ;
  $("#grid figure img[is-secure]").each(function () {
    var $this = $(this);
    delay = delay + 50 ;
    $this.parent().addClass('is-loading');
    studio.ui.setSecureImage($this, delay);
  });
  
이미지를 가져오지 못하는 경우에는 재요청을 수행하도록 코드하였다.
  
  function setSecureImage(elemment, delay, callback) {
  var delay = delay || 1000;
  let url = elemment.attr("secure-image");
  setImageAsBase64(
    url,
    function (data) {
      elemment.attr("src", data);
      elemment.parent().removeClass("is-loading");
      if (isFunction(callback)) callback();
    },
    function () {
      let maxRetry = 3,
        retry = 0;
      if (defined(elemment.data("max-retry"))) {
        maxRetry = elemment.data("max-retry");
      }
      if (defined(elemment.data("retry"))) {
        retry = elemment.data("retry");
      }
      if (retry < maxRetry) {
        elemment.data("retry", retry + 1);
        sleep(delay);
        setSecureImage(elemment);
      } else {
        elemment.parent().removeClass("is-loading");
      }
    }
  );
  }

  async function setImageAsBase64(url, successHandler, errorHandler) {
  const headers = {};
  // JWT 토큰 값 설정
  Object.assign(headers, studio.services.accounts.authHeader()); 
  await axios
    .get(url, { headers: headers, responseType: "arraybuffer" })
    .then((response) => {
      var mineType = response.headers["content-type"].toLowerCase();
      var b64encoded = Buffer.from(response.data, "binary").toString("base64");
      var prefix = "data:" + mineType + ";base64,";
      if (isFunction(successHandler)) successHandler(prefix + b64encoded);
    })
    .then((error) => {
      if (isFunction(errorHandler)) errorHandler();
    });
  } 
  



아쉽지만 이미지를 다운로드하여 data 방식으로 설정하는데 있어 오류가 발생되어 다시 시도하도록 추가한 코드는 효용성이 없는 것 같다. 테스트 환경 이슈일 수도 있으나 다운로드에 있어 무작위로 오류가 발생하며 일단 오류가 발생되면 다시 시도한 경우 역시 오류가 발생한다. (→ 서버 이슈인지 코드 이슈인지는 확실하지 않다.)

참고자료

2020년 6월 8일

Spring 기반 웹 프로그램에서 JWT 인증 사용하기

JWT ( JSON WEB TOKEN ) 는 URL 형식으로 구현할 수 있는 전자서명 기술이다. JWT 는 주요한 속성 정보를 RFC7519 표준에 따라 JSON 테이터 구조로 표현한다.

JWT 기술을 활용하면 손쉽게 클라이언트(앱) 기반 프로그램에서 토큰 기반 인증 및 REST API  호출을 구현할 수 있다.

클라이언트와 서버간의 인증 시나리오를 정리하면 아래와 같다. 

❶ 사용자가 로그인을 하면, ❷ 서버는 사용자의 정보를 기반으로한 JWT 토큰을 발급한다.
그 후, ❸ 사용자가 서버에 요청을 할 때 마다 JWT를 포함하여 전달한다. ❹ 서버는 클라이언트에서 요청을 받을때 마다, 해당 토큰이 유효하고 인증됐는지 검증을 하고, 사용자가 요청한 작업에 권한이 있는지 확인하여 작업을 처리.



Spring 기반의 서버 사이드  JWT

다행스러운 것은 JWT 토큰 생성과 검증을 위한 라이브러리가  JWT 사이트에서 다양한 언어 버전으로 제공하고 있어 적절하게 이를 활용하여 손쉽게 JWT 를 적용할 수 있다.



글에서는  "https://github.com/jwtk/jjwt" 를 사용하였다. 


서버 프로그램 개발환경이 maven 으로 구현되어 있어 아래와 같이 라이브러리 의존성을 추가하였다. 

<!--  JWT  -->

<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->

<dependency>

    <groupId>io.jsonwebtoken</groupId>

    <artifactId>jjwt</artifactId>

            <version>0.9.1</version>

</dependency>


서버 프로그램이 Spring 을 기반으로 하고 있어 Spring Security 설정에 JWT 처리를 위한 커스텀 필터를 구현하여 추가하였다. (커스텀 필터는 클라이언트에서 헤더에 JWT 토큰을 가지고 오는 경우 이를 검증하고 인증 정보를 보안 컨텍스트에 추가하는 역할을 수행한다.)

package architecture.community.security.spring.authentication.jwt;

import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException;

public class JwtTokenProvider {

	private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);

	private static final String AUTHORITIES_KEY = "auth";

	/**
	 * JWT Token expire time.
	 */
	static final long EXPIRATIONTIME = 864_000_000; // 10 days

	/**
	 * Secret Key string
	 */
	static final String SECRET = "ThisIsASecret";

	/**
	 * JWT Token prefix
	 */
	static final String TOKEN_PREFIX = "Bearer";

	/**
	 * Header key for JWT Token
	 */
	static final String HEADER_STRING = "Authorization";

	public String createToken(Authentication authentication) {
		String authorities = authentication.getAuthorities().stream().map(authority -> authority.getAuthority())
				.collect(Collectors.joining(","));
		ZonedDateTime now = ZonedDateTime.now();
		ZonedDateTime expirationDateTime = now.plus(EXPIRATIONTIME, ChronoUnit.MILLIS);
		Date issueDate = Date.from(now.toInstant());
		Date expirationDate = Date.from(expirationDateTime.toInstant());
		return Jwts.builder().setSubject(authentication.getName()).claim(AUTHORITIES_KEY, authorities)
				.signWith(SignatureAlgorithm.HS512, SECRET).setIssuedAt(issueDate).setExpiration(expirationDate)
				.compact();
	}

	public Authentication getAuthentication(String token, UserDetailsService userDetailsService, boolean refresh) {
		Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
		Collection authorities = Arrays
				.asList(claims.get(AUTHORITIES_KEY).toString().split(",")).stream()
				.map(authority -> new SimpleGrantedAuthority(authority)).collect(Collectors.toList());
		UserDetails details = userDetailsService.loadUserByUsername(claims.getSubject());
		return new UsernamePasswordAuthenticationToken(details, "", refresh ? details.getAuthorities() : authorities);
	}

	public Authentication getAuthentication(String token) {

		Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
		Collection authorities = Arrays
				.asList(claims.get(AUTHORITIES_KEY).toString().split(",")).stream()
				.map(authority -> new SimpleGrantedAuthority(authority)).collect(Collectors.toList());

		User principal = new User(claims.getSubject(), "", authorities);

		return new UsernamePasswordAuthenticationToken(principal, "", authorities);

	}

	public boolean validateToken(String authToken) {
		try {
			Jwts.parser().setSigningKey(SECRET).parseClaimsJws(authToken);
			return true;
		} catch (SignatureException e) {
			logger.info("Invalid JWT signature: " + e.getMessage());
			logger.debug("Exception " + e.getMessage(), e);
		} catch (MalformedJwtException e) {
			logger.error("Invalid JWT token: {}", e.getMessage());
		} catch (ExpiredJwtException e) {
			logger.error("JWT token is expired: {}", e.getMessage());
		} catch (UnsupportedJwtException e) {
			logger.error("JWT token is unsupported: {}", e.getMessage());
		} catch (IllegalArgumentException e) {
			logger.error("JWT claims string is empty: {}", e.getMessage());
		}
		return false;
	}
}

(JwtTokenProvider 사용하여 Jwt 토큰을 검증하고 userDetailsService 을 이용하여 보안 컨텍스트에 인증 객체를 설정한다.)
package architecture.community.security.spring.authentication.jwt;

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.Assert;
import org.springframework.web.filter.GenericFilterBean;

import architecture.ee.util.StringUtils;
import io.jsonwebtoken.ExpiredJwtException;

public class JWTFilter extends GenericFilterBean {

	private Logger logger = LoggerFactory.getLogger(getClass());

	private final JwtTokenProvider jwtTokenProvider;

	@Autowired(required = false)
	@Qualifier("userDetailsService")
	private UserDetailsService userDetailsService;

	public JWTFilter(JwtTokenProvider jwtTokenProvider) {
		Assert.notNull(jwtTokenProvider, "JwtTokenProvider cannot be null");
		this.jwtTokenProvider = jwtTokenProvider;
	}

	@Override
	public void afterPropertiesSet() {
		Assert.notNull(jwtTokenProvider, "JwtTokenProvider cannot be null");
	}

	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {

		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;

		String header = request.getHeader(JwtTokenProvider.HEADER_STRING);
		if (StringUtils.isNullOrEmpty(header)) {
			chain.doFilter(request, response);
			return;
		}

		try {
			String jwt = this.resolveToken(request);
			if (!StringUtils.isNullOrEmpty(jwt)) {
				logger.debug("jwt token : {}", jwt);
				if (this.jwtTokenProvider.validateToken(jwt)) {
					Authentication authentication;
					if (userDetailsService != null) {
						authentication = this.jwtTokenProvider.getAuthentication(jwt, userDetailsService, true);
					} else {
						authentication = this.jwtTokenProvider.getAuthentication(jwt);
					}
					SecurityContextHolder.getContext().setAuthentication(authentication);
				}
			}
		} catch (ExpiredJwtException eje) {
			logger.info("Security exception for user {} - {}", eje.getClaims().getSubject(), eje.getMessage());
			response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
			logger.debug("Exception " + eje.getMessage(), eje);
			return;
		}

		chain.doFilter(request, response);
		this.resetAuthenticationAfterRequest();
	}

	private void resetAuthenticationAfterRequest() {
		logger.debug("reset authentication as null.");
		SecurityContextHolder.getContext().setAuthentication(null);
	}

	private String resolveToken(HttpServletRequest request) {
		String bearerToken = request.getHeader(JwtTokenProvider.HEADER_STRING);
		if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(JwtTokenProvider.TOKEN_PREFIX)) {
			String jwt = bearerToken.substring(7, bearerToken.length());
			return jwt;
		}
		return null;
	}
}  
  
필터를 정의하고 사용자 정의 필터를 추가해준다.

<http auto-config="true" use-expressions="true" disable-url-rewriting="true">

  <cors configuration-source-ref="corsSource"/> 

  <headers>

    <frame-options policy="SAMEORIGIN" />

  </headers>

  <intercept-url pattern="/*" access="permitAll"/>

<intercept-url pattern="/error/*" access="permitAll"/>

<intercept-url pattern="/data/**" access="permitAll"/>

<intercept-url pattern="/display/**" access="permitAll"/>

<intercept-url pattern="/accounts/**" access="permitAll"/>

<intercept-url pattern="/secure/studio/**" access="hasRole('ROLE_ADMINISTRATOR') or hasRole('ROLE_SYSTEM') or hasRole('ROLE_DEVELOPER')" />

<intercept-url pattern="/secure/data/**" access="hasRole('ROLE_USER') or hasRole('ROLE_SYSTEM')" /> 

  <!-- Form Login Page Setting -->

  <form-login login-page="/accounts/login"

username-parameter="username" 

password-parameter="password"

login-processing-url="/accounts/auth/login_check"

authentication-success-handler-ref="authenticationSuccessHandler"

authentication-failure-handler-ref="authenticationFailureHandler" />

  <http-basic />

  <custom-filter before="BASIC_AUTH_FILTER" ref="jwtFilter" />


  ...


</http>


<beans:bean id="jwtTokenProvider" class="architecture.community.security.spring.authentication.jwt.JwtTokenProvider"></beans:bean>

<beans:bean id="jwtFilter" class="architecture.community.security.spring.authentication.jwt.JWTFilter">

  <beans:constructor-arg ref="jwtTokenProvider" />

</beans:bean>



③ CORS 지원
주의할 것은 보안상 이유에서 웹 브라우져는 스크립트에서 웹페이지가 로드된 원 도메인(IP)이 아닌 타 도메인(IP) 자원을 호출하는 것을 금지하고 있다. 

CORS(Cross-origin resource sharing) 는 대부분의 웹 브라우져들이 구현하고 있는 W3C 표준이며 이를 통하여 다른 도메인의 자원을 호출 할 수 있게 할 수 있다. Spring Security 는 필터 기반의 CORS 를 지원하고 있어 아래와 같이 손쉽게 처리가 가능하다. 

<http auto-config="true" use-expressions="true" disable-url-rewriting="true">

  <cors configuration-source-ref="corsSource"/> 

  <headers>

    <frame-options policy="SAMEORIGIN" />

  </headers>

  <intercept-url pattern="/*" access="permitAll"/>

<intercept-url pattern="/error/*" access="permitAll"/>

<intercept-url pattern="/data/**" access="permitAll"/>

<intercept-url pattern="/display/**" access="permitAll"/>

<intercept-url pattern="/accounts/**" access="permitAll"/>

<intercept-url pattern="/secure/studio/**" access="hasRole('ROLE_ADMINISTRATOR') or hasRole('ROLE_SYSTEM') or hasRole('ROLE_DEVELOPER')" />

<intercept-url pattern="/secure/data/**" access="hasRole('ROLE_USER') or hasRole('ROLE_SYSTEM')" /> 

  <!-- Form Login Page Setting -->

  <form-login login-page="/accounts/login"

username-parameter="username" 

password-parameter="password"

login-processing-url="/accounts/auth/login_check"

authentication-success-handler-ref="authenticationSuccessHandler"

authentication-failure-handler-ref="authenticationFailureHandler" />

  <http-basic />

  ...


</http>


<beans:bean id="corsSource" class="org.springframework.web.cors.UrlBasedCorsConfigurationSource">

  <beans:property name="corsConfigurations">

    <util:map>

      <beans:entry key="/**">

      <beans:bean class="org.springframework.web.cors.CorsConfiguration">

      <beans:property name="allowCredentials" value="true"/>

      <beans:property name="allowedHeaders">

      <beans:list>

        <beans:value>Authorization</beans:value>

        <beans:value>Content-Type</beans:value>

        <beans:value>responseType</beans:value>

        <beans:value>encoding</beans:value>

      </beans:list>

      </beans:property>

      <beans:property name="allowedMethods">

        <beans:list>

        <beans:value>POST</beans:value>

        <beans:value>GET</beans:value>

        <beans:value>PUT</beans:value>

        <beans:value>DELETE</beans:value>

        <beans:value>OPTIONS</beans:value>

        </beans:list>

      </beans:property>

      <beans:property name="allowedOrigins" value="*" />

      <beans:property name="exposedHeaders">

      <beans:list>

        <beans:value>Location</beans:value>

        <beans:value>Content-Disposition</beans:value>

      </beans:list>

      </beans:property>

      <beans:property name="maxAge" value="86400" /></beans:bean>

    </beans:entry>

    </util:map>

  </beans:property>

</beans:bean>


JWT 기반 인증 및 검증

아이디/비밀번호 기반의 인증에서 JWT 토튼을 사용하려면 인증이 성공하면 클라이언트에 토큰 값을 응답해주어야 한다. 아래는 스프링 컨트롤러에 간단하게 로그인 인증후 Jwt 토큰 값을 리턴하는 예이다.

LoginRequest.java

@RequestMapping(value = "/signin.json", method = { RequestMethod.POST})

public ResponseEntity<JwtResponse> authenticateUser(@RequestBody LoginRequest loginRequest) { 

Authentication authentication = authenticationManager.authenticate(

new UsernamePasswordAuthenticationToken(

loginRequest.getUsername(), 

loginRequest.getPassword()));

SecurityContextHolder.getContext().setAuthentication(authentication);

String jwt = jwtTokenProvider.createToken(authentication); 

CommuintyUserDetails details = SecurityHelper.getUserDetails(authentication);

return ResponseEntity.ok( 

            new JwtResponse(

                jwt

                details.getUser(), 

                getRoles(details.getAuthorities())));


웹 프로그램은 아래와 같이 인증후 획득한 토큰값을 저장하고 서버와 통신이 필요한 경우 헤더에 토큰 값을 포함하여 전달하게 된다. 서버 통신은 axois 를 사용하여 구현하였다. 참고로 코드 구현에 있어 UI 부분은 kendoui 를 사용하였다. ( kendoui 를 사용하려면 개발자라이선스가 필요함)

var renderTo = $('form[name=form-signin]');
var observable = new kendo.observable({
    username : null,
    password : null
});

var validator = renderTo.kendoValidator({
  errorTemplate: "

#=message#

" }).data("kendoValidator");; kendo.bind( renderTo , observable ); renderTo.submit(function(e) { e.preventDefault(); if( validator.validate() ){ kendo.ui.progress(renderTo, true); axios.post(studio.services.getApiUrl('/data/accounts/signin.json'), JSON.stringify(observable), { headers: { "Content-Type": "application/json" }} ).then(response => { const data = response.data; studio.services.accounts.loginSuccess(studio.services.accounts.state, data); window.location.replace("index.html"); }).catch(function (error) { // handle error studio.ui.handleAxiosError(error); observable.set('password', null); }) .then(function () { // always executed kendo.ui.progress(renderTo, false); }); } });

index.js
 
 const user = JSON.parse(localStorage.getItem("user"));
 const initialState = user
  ? { status: { loggedIn: true }, user }
  : { status: { loggedIn: false }, user: null };
  
 function authHeader() {
  // return authorization header with jwt token
  let _user = JSON.parse(localStorage.getItem("user"));
  if (_user && _user.jwtToken) {
    return { Authorization: "Bearer " + _user.jwtToken };
  } else {
    return {};
  }
  }

 function loginSuccess(state, data) {
  if (data.jwtToken) {
    // store user details and jwt token in local storage to keep user logged in between page refreshe
    localStorage.setItem("user", JSON.stringify(data));
  }
  state.loggedIn = true;
  state.user = data;
}

서버 호출이 필요한 경우 아래와 같은 방식으로 헤더에 jwt 포함하여 전송한다. 

sttings-locale.html 
const headers = {
  Accept: "application/json",
  "Content-Type": "application/json"
};
Object.assign(headers, studio.services.accounts.authHeader());

...

axios({
  url: studio.services.getApiUrl('/data/secure/mgmt/locale/save-or-update.json'),
  method: "post",
  data: JSON.stringify({ locale: $this.locale, timeZone: $this.timezone }),
  headers: headers
}).then(response => {
  let data = response.data;
  dialog.close();
}).catch(function (error) {
  studio.ui.handleAxiosError(error);
}).then(function () {
  // always executed
  kendo.ui.progress($('.k-dialog'), false);
});
                      

JWT 기반 인증 적용 후  경험한 문제들

  • spring security 에서 중복로그인 방지를 설정한 경우 중복 로그인 오류로 인하여 정상적인 응답을 받지 못하는 경우가 다수 발생했다. 
  • 인증된 사용자에게만 보여지는 이미지와 같은 바이너리 형식을 처리하는 경우에 어려움이 있었다. 

참고자료

Securing Spring Boot with JWTs
JWT (JSON Web Token) 이해와 활용
CORS support in Spring Framework