프로젝트에서 Spring Cloud OpenFeign을 사용하고 있는데, 연동하고 있는 서비스에서 종종 오류가 나는 경우가 있어 급한 대로 예외 처리만 해두었으나, 장기적으로 안정적인 장애 예방을 위해 Resilience4j를 적용하려고 문서와 예제들을 찾아보고 있다.

Netflix Hystrix를 먼저 생각해두고 있었으나, 2018년 11월부로 개발이 중단되고, maintenance mode라고 하여, Resilience4j를 사용하기로 결정. (Google Trend 상으로는 아직 Hystrix를 더 많이 찾고 있는 듯)

Google Trends - Hystrix vs Resilience4j (파랑이 Hystrix)

 

Resilience4j는 공식 문서에서 "fault tolerance library for Java™"라고 소개하고 있고 Netflix Hystrix에서 영감을 받아 만들었으며, Java 8, 함수형 프로그래밍에 맞게 설계되었다고 한다.

Resilience4j의 코어 모듈인 CircuitBreaker, Bulkhead, RateLimiter, Retry, TimeLimiter, Cache와 라이브러리나 프레임워크와 연동할 수 있는 애드 온 모듈들이 있다.

 

당장은 서킷 브레이커만 사용하면 돼서 resilience4j-spring-boot2 디펜던시를 추가한 후 아래와 같이 작성하고 동작할 지 테스트를 해보았다.

@FeignClient(name = "api")
interface ApiClient {
    @CircuitBreaker(name = "api", fallbackMethod = "fallback")
    @GetMapping("/users")
    fun getUsers(): List<User>
    
    // Exception을 받을 파라미터가 필요.
    // getUsers와 동일한 시그니쳐로 할 경우 NoSuchMethodException 발생
    fun fallback(e: Exception): List<User> = emptyList()
}

 

우선 위 코드는 오류와 함께 애플리케이션이 실행되지 않는다. @FeignClient가 적용되어 프록시를 생성할 때 fallback 메서드에 매핑 정보가 없기 때문이다. 애너테이션이 없으면 그냥 무시할 줄 알았는데, Feign은 인터페이스에 정의된 메서드에 대해 모두 처리한다. Retrofit은 다른가 싶어 테스트 해봤으나 Retrofit도 이에 대해서는 동일하다. @Ignore 따위의 애너테이션이 있을까 싶어 찾아봤지만 없었고, GitHub Issue에 사용자들이 올린 글을 찾다보니 Feign, Retrofit 모두 default 메서드에 대한 처리에 어려움이 있어 아직은 지원하지 않는 기능이라고 한다.

fallback 메서드에 @GetMapping("/ignore")와 같이 붙여주면 애플리케이션은 실행되지만 getUsers()를 호출하여 오류가 발생했을 때 폴백 메서드를 호출하긴 하지만 프록시된 fallback을 호출하기 때문에 결과적으로는 GET /ignore를 요청하게 되어 원하는대로의 폴백으로 동작하진 않는다.

 

resilience4j-feign도 있고, resilience4-spring-boot2도 있지만 Spring Cloud Open Feign에 resilience를 위의 코드처럼 작성하는 건 괜히 더 복잡해져서 돌아가더라도 쓸 수 있는 형태로 적용해 보기로 했다. 아래의 두 가지 방법으로 시도했다.

1. Spring Cloud Open Feign으로 만들어진 빈의 래퍼 컴포넌트 작성(resilience4j-spring-boot2)

2. Spring Cloud Open Feign을 사용하지 않고 resilience4j-feign을 통해 Feign 빈 생성

 


환경

  • IntelliJ IDEA
  • Kotlin 1.3.71
  • Gradle 6.3
  • Gradle Dependencies(Spring Intializr로 프로젝트 생성)
    • org.springframework.cloud:spring-cloud-starter-openfeign
    • io.github.resilience4j:resilience4j-feign:1.1.0 (1.3.x 버전이 있으나 내부에서 버전 충돌)
    • io.github.resilience4j:resilience4j-spring-boot2:1.1.0
    • org.springframework.boot:spring-boot-starter-webflux
    • org.springframework.boot:spring-boot-starter-aop
    • org.springframework.boot:spring-boot-starter-actuator

 

 

공통 구현

- Feign으로 호출할 테스트 컨트롤러 작성 - 404를 응답하는 핸들러 메서드를 작성

import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Flux

@RestController
class TestApiController {
    @GetMapping("/api/test")
    @ResponseStatus(HttpStatus.NOT_FOUND)
    fun test(): Flux<String> = Flux.just("NOT_FOUND")
}

- @EnableFeignClients 추가

 


 

1. Spring Cloud Open Feign Wrapper 컴포넌트로 Resilience4j 적용

Feign 클라이언트 인터페이스 작성 - TestApiOpenFeignClient

import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.GetMapping

@FeignClient(name = "test", url = "http://localhost:8080/api")
interface TestApiOpenFeignClient {
    @GetMapping("/test")
    fun getTest(): String
}

 

Wrapper 작성 - TestApiOpenFeignClientWrapper

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
import org.springframework.stereotype.Component

@Component
class TestApiOpenFeignClientWrapper(
        private val testApiOpenFeignClient: TestApiOpenFeignClient
) {
    @CircuitBreaker(name = "OPEN_FEIGN_API", fallbackMethod = "fallback")
    fun getTest(): String {
        return testApiOpenFeignClient.getTest();
    }

    /**
     * 서킷 브레이커에서 호출할 Fallback 메서드
     * @param e 발생한 예외
     */
    fun fallback(e: Exception): String {
        return "RESULT BY FALLBACK"
    }
}

 

테스트

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest

@SpringBootTest
internal class TestApiOpenFeignClientWrapperTest {
    @Autowired
    private lateinit var client: TestApiOpenFeignClientWrapper

    @Test
    fun getTest() {
        assertEquals("RESULT BY FALLBACK", client.getTest())
    }
}

 


 

2. Resilience4j-feign으로 Feign 인스턴스 생성

Feign 클라이언트 인터페이스 작성 - TestApiFeignClient

import feign.RequestLine

interface TestApiFeignClient {
    @RequestLine("GET /api/test")
    fun getTest(): String
}

 

Feign 빈 정의 - FeignConfiguration

import feign.FeignException
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
import io.github.resilience4j.feign.FeignDecorators
import io.github.resilience4j.feign.Resilience4jFeign
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class FeignConfiguration {
    @Bean
    fun testApiClient(registry: CircuitBreakerRegistry): TestApiFeignClient {
        val circuitBreaker = registry.circuitBreaker("FEIGN_API")

        val decorators = FeignDecorators.builder()
                .withCircuitBreaker(circuitBreaker)
                .withFallback(object: TestApiFeignClient {
                    override fun getTest() = "RESULT BY FALLBACK"
                }, FeignException::class.java)
                .build()

        return Resilience4jFeign.builder(decorators)
                .target(TestApiFeignClient::class.java, "http://localhost:8080/api")
    }
}

 

테스트

import feign.RequestLine

interface TestApiFeignClient {
    @RequestLine("GET /api/test")
    fun getTest(): String
}

하이버네이트 튜토리얼을 보며 따라하고 있는데 SessionFactory 빈을 생성하는 과정에서 예외가 발생하면서 아래와 같은 스택을 뿌리며 생성에 실패한다.


Caused by: java.util.MissingFormatArgumentException: Format specifier '%s'

at java.util.Formatter.format(Formatter.java:2519) ~[na:1.8.0_25]

at java.util.Formatter.format(Formatter.java:2455) ~[na:1.8.0_25]

at java.lang.String.format(String.java:2927) ~[na:1.8.0_25]

at org.jboss.logging.Slf4jLocationAwareLogger.doLogf(Slf4jLocationAwareLogger.java:81) ~[jboss-logging-3.1.3.GA.jar:3.1.3.GA]

at org.jboss.logging.Logger.debugf(Logger.java:553) ~[jboss-logging-3.1.3.GA.jar:3.1.3.GA]

at org.hibernate.annotations.common.util.StandardClassLoaderDelegateImpl.classForName(StandardClassLoaderDelegateImpl.java:53) ~[hibernate-commons-annotations-4.0.4.Final.jar:4.0.4.Final]

at org.hibernate.cfg.annotations.SimpleValueBinder.fillSimpleValue(SimpleValueBinder.java:491) ~[hibernate-core-4.3.5.Final.jar:4.3.5.Final]

at org.hibernate.cfg.SetSimpleValueTypeSecondPass.doSecondPass(SetSimpleValueTypeSecondPass.java:42) ~[hibernate-core-4.3.5.Final.jar:4.3.5.Final]

at org.hibernate.cfg.Configuration.processSecondPassesOfType(Configuration.java:1470) ~[hibernate-core-4.3.5.Final.jar:4.3.5.Final]

at org.hibernate.cfg.Configuration.secondPassCompile(Configuration.java:1418) ~[hibernate-core-4.3.5.Final.jar:4.3.5.Final]

at org.hibernate.cfg.Configuration.buildSessionFactory(Configuration.java:1844) ~[hibernate-core-4.3.5.Final.jar:4.3.5.Final]

at org.hibernate.cfg.Configuration.buildSessionFactory(Configuration.java:1928) ~[hibernate-core-4.3.5.Final.jar:4.3.5.Final]

at org.springframework.orm.hibernate4.LocalSessionFactoryBuilder.buildSessionFactory(LocalSessionFactoryBuilder.java:372) ~[spring-orm-4.1.6.RELEASE.jar:4.1.6.RELEASE]

at org.springframework.orm.hibernate4.LocalSessionFactoryBean.buildSessionFactory(LocalSessionFactoryBean.java:454) ~[spring-orm-4.1.6.RELEASE.jar:4.1.6.RELEASE]

at org.springframework.orm.hibernate4.LocalSessionFactoryBean.afterPropertiesSet(LocalSessionFactoryBean.java:439) ~[spring-orm-4.1.6.RELEASE.jar:4.1.6.RELEASE]

at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1633) ~[spring-beans-4.1.6.RELEASE.jar:4.1.6.RELEASE]

at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1570) ~[spring-beans-4.1.6.RELEASE.jar:4.1.6.RELEASE]

... 43 common frames omitted





구글링으로 예외와 관련한 것들을 찾아보다가, 비슷한 유형이 나타나지 않길래 라이브러리 내에서 어떤 부분이 문제인지를 확인하려고 org.hibernate.annotations.common.util.StandardClassLoaderDelegateImpl 클래스를 따라가보니 아래 부분에서 예외가 발생했다.


catch ( Throwable ignore ) {

log.debugf( "Unable to locate Class [%s] using TCCL, falling back to HCANN ClassLoader" );

}


빨간색으로 표기한 %s에 전달되어야 할 문자열을 전달하지 않아서 발생하는 예외였다. 실수는 할 수 있지만.. 유틸리티성 클래스라 테스트 커버리지에 포함되지 않아서인지 릴리즈에 버젓이 나 버그요 어디 한번 실행해 보시지 하는 태도라니.


이는 하이버네이트 ORM 4.3.5.Final 버전에서 발생하며, 이 후 버전에서는 고쳐진 듯 하다. (4.3.10.Final 버전으로 확인하니 예외가 발생하지 않는다.)


사실 상용 프레임워크나 소프트웨어는 거의 사용하지 않고 오픈 소스를 주로 이용하다보니, 검증없이 신뢰하기 마련인데, 이번 계기로 모든 것에 의심을 가지자는 태도를 다시 갖춰야겠다.

아마 Moneycomb 프로젝트를 할 때였던 것 같다. 나는 그때도 JSON으로 메시지를 변환하기 위해, 책과 튜토리얼에서 빈번하게 등장하고, 다들 문제없이 쓰는 것으로 보이던 MappingJacksonHttpMessageConverter를 '나도 당연히 되겠지?' 하며 튜토리얼들을 따라 서블릿 컨텍스트에 빈을 설정했다.


지옥의 시작

왜인가? 어째서인가? 남들 다 된다고 댓글에도 "Thank you!"가 넘치고, 누구하나 되지 않는다는 이 없는데, 나는 왜 안되는 것인가.

지금도 그렇지만 그 때에도 Google의 JSON 라이브러리인 Gson을 즐겨썼다. API가 복잡하지 않고 설정도 간단했기 때문이다.

하지만 스프링에서 기본적으로 제공하는 JSON 메시지 컨버터는 Jackson에 의존성이 있었는데, 일단 Jackson이라는 라이브러리 자체가 낯설었고, 스프링 MVC의 동작을 깊이 파악하고 있지 않았던 탓에 하라는 대로 하는 것 외에는 손대볼 수 있는게 없었다.

열심히 설정하고 코드를 따라서 친 후 실행, 두근거리는 마음으로 Postman으로 메시지를 날려보았다.

@RequestBody 애너테이션이 붙어있는 파라미터에서도 변환이 되질 않고, @ResponseBody 애너테이션이 붙어있는 파라미터에서도 변환이 되질 않는다.

이때는 log4j 설정도 잘 몰라서, 콘솔에서는 어떠한 로그도 뜨지 않길래, 스프링이 아무말 없이 응답으로만 "안돼"라고 하는 줄로만 알았다.


Content-Type, Accept

스프링 컨트롤러에서 @RequestMapping 애노테이션에는 Content-Type과 Accept를 설정할 수 있는 consumes와 produces 속성이 있다. JSON의 미디어 타입이 application/json 인 것은 웹 하는 사람이라면 누구라도 알고 있을 것이다.

튜토리얼에서는 명시적으로 지정해주지 않았지만, 안되길래 consumes와 produces에 "application/json"이라고 적어주었다. 안된다. 이게 문제가 아닌가보다.


오늘도 삽질

그 때와 똑같은 행태를 반복해서였는지는 모르겠지만 오늘도 똑같은 현상이 나타났다. JSON 직렬화도, 역직렬화도 안된다.

그 때는 해결하지 못했던 문제였지만 오늘은 나름 해결을 하긴 했다.

여러가지 오해와 착각과 습관과 편견과 게으름의 결과였던 듯 하다.


1. 반환 타입

다른 테스트 클래스를 만들거나 모델 클래스를 반환하려니 값을 넣기도 귀찮아서

@RequestMapping(....)

@ResponseBody

public Object test(@RequestBody ....) { ... }

위 처럼 Object를 반환했다. JSON 자체에서도 { } 라고 쓰기도 하거니와, Gson에서는 Object 객체도 잘 변환한다. 빈 객체를 표현할 수도 있으니 당연하다고 생각했는데, Jackson에서는 Object 객체를 JSON으로 직렬화할 수 없다.

"Can not create bean serializer for Object.class" 란다. 어떤 정책으로 인해서 이렇게 정해졌는지는 알 수 없지만, 이것 때문에 한방.


2. 테스트 Ajax 요청

테스트로 JSON 객체를 보낼 때도, 필드 몇 개를 대충 보내거나 했는데, 이 전 버전의 Jackson(codehaus)은 JSON에는 있지만 변환될 자바 타입에서 없는 속성, 빈 문자열은 변환할 수 없고 예외를 발생시킨다.

애초에 테스트 JSON 문자열을 잘 썼으면 문제가 없었을지도 모르지만... 이 역시 Gson을 쓰면서 Gson이 동작하는 대로 생각해서 였던 것 같다. Gson에서는 없는 프로퍼티는 무시하고, 빈 JSON 문자열의 경우 null을 반환한다.


어쨌든 이번 일로 인해 좀 더 MessageConverter의 동작을 명확히 알게 되었고, 더불어 아래와 같은 것 잡지식(?)이 늘었다.

- com.fasterxml.jackson은 org.codehaus.jackson를 승계한 프로젝트

- com.fasterxml.jackson은 MappingJackson2HttpMessageConverter와 바인딩

- org.codehaus.jackson은 MappingJacksonHttpMessageConverter와 바인딩

- Spring 4부터 GsonHttpMessageConveter가 내장

- Spring 4부터 MappingJacksonHttpMessage는 없어짐





Gson 기반의 메시지 컨버터 설정 (Maven, Spring 4.1.6.RELEASE)


Maven Dependency 추가

<dependency>

<groupId>com.google.code.gson</groupId>

<artifactId>gson</artifactId>

<version>2.3.1</version>

</dependency>


servlet-context.xml 설정

<beans:bean class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter">

<beans:property name="messageConverters">

<beans:list>

<beans:ref bean="jsonMessageConverter"/>

</beans:list>

</beans:property>

</beans:bean>


<beans:bean id="jsonMessageConverter" class="org.springframework.http.converter.json.GsonHttpMessageConverter">

   </beans:bean>



※ 참고 자료

- http://www.leveluplunch.com/java/tutorials/023-configure-integrate-gson-spring-boot/

- http://www.studytrails.com/java/json/jackson-create-json.jsp

- http://docs.spring.io/spring/docs/current/javadoc-api//org/springframework/http/converter/json/GsonHttpMessageConverter.html

- http://erictus.tistory.com/entry/Spring-JSON-View-구현하기2-ResponseBody

개발 환경

- IDE : Spring Tool Suite 3.3.0.RELEASE

- Spring Framework 3.1.1 (Web MVC 포함)

- OS : Max OSX 10.8.5


연습으로 해본 이미지 업로드 프로젝트입니다. 잘못된 내용이 있거나 개선할 내용이 있으면 댓글 남겨주시면 배우도록 하겠습니다 :)



먼저 STS에서 Spring MVC Web 프로젝트를 하나 만듭니다.








프로젝트 탐색기에 보이는 프로젝트의 모습입니다. 위 단계까지 따라오셨으면 아래에 보이는 파일 중에 없는 것 몇가지가 있습니다. 이 글을 끝까지 따라가면 보이는 프로젝트 파일의 모습입니다.

예제 프로젝트에서는 패키지를 따로 나누지 않고 통패키지(..)에 모든 클래스 파일을 담았습니다.






먼저 servlet-context.xml 파일에 Multipart 사용을 위해서 멀티파트 리졸버를 추가합니다.


<!-- Multipart를 받기 위한 리졸버 추가 -->
<beans:bean id="multipartResolver"
	class="org.springframework.web.multipart.commons.CommonsMultipartResolver"/>




MultipartResolver를 사용하게 되면 두개의 라이브러리가 추가적으로 필요합니다. Commons File Upload와 IO입니다.

메이븐을 사용한다면 의존 라이브러리를 추가해주고, 그 외 빌드 툴이나, 혹은 사용하지 않는다면 환경에 맞게, 두가지의 라이브러리를 추가해줍니다.

<!-- Apache Commons File Upload -->
<dependency>
	<groupId>commons-fileupload</groupId>
	<artifactId>commons-fileupload</artifactId>
	<version>1.2</version>
</dependency>

<!-- Apache Commons IO 추가 -->
<dependency>
	<groupId>org.apache.commons</groupId>
	<artifactId>commons-io</artifactId>
	<version>1.3.2</version>
</dependency><






이미지 업로드 컨트롤러 클래스 내용입니다.

이미지 업로드를 위한 폼 페이지와 결과 페이지 및 이미지를 얻기 위한 URL을 매핑합니다.

package com.nnoco.example;

import javax.annotation.Resource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;

@Controller
public class ImageUploadController {
	/*
	 * ImageView는 파일 시스템에 있는 이미지 파일을 응답으로 돌려주는 역할을 합니다.
	 * 뒷 부분에서 ImageView 클래스를 작성하게 됩니다.
	 */
	@Resource(name="imageView") ImageView imageView;

	/**
	 * 이미지를 관리하는 서비스 계층 클래스입니다. 예제에서는 디비를 사용하긴 버거워지므로
	 * 서비스 클래스를 따라하는 모양만 서비스인 클래스입니다.
	 */
	@Autowired ImageService imageService;
	
	/**
	 * 이미지 업로드를 위한 페이지 매핑 
	 */
	@RequestMapping("/uploadPage")
	private String uploadView() {
		return "upload";
	}
	
	/**
	 * 이미지 업로드 페이지의 폼에서 전송 시 받게 되는 메서드 
	 */
	@RequestMapping(value="/upload", method=RequestMethod.POST)
	private String upload(@RequestParam MultipartFile imageFile, ModelMap modelMap) {
		ImageFile fileInfo = imageService.save(imageFile);
		
		modelMap.put("imageFile", fileInfo);
		
		return "uploadComplete";
	}
	
	@RequestMapping("/image/{imageId}")
	private ImageView getImage(@PathVariable String imageId, ModelMap modelMap) {
		ImageFile imageFile = imageService.get(imageId);
		
		modelMap.put("imageFile", imageFile);
		
		return imageView;
	}
}




업로드한 이미지 정보를 담고있는 ImageFile 클래스입니다.

package com.nnoco.example;

public class ImageFile {
	/**
	 * 업로드한 이미지 파일이 저장될 경로
	 */
	public static final String IMAGE_DIR = "/web/upload_images/";

	private String id;
	private String contentType;
	private int contentLength;
	private String fileName;

	public ImageFile(String id, String contentType, int contentLength,
			String fileName) {
		this.id = id;
		this.contentType = contentType;
		this.contentLength = contentLength;
		this.fileName = fileName;
	}

	public String getId() {
		return id;
	}

	public void setId(String id) {
		this.id = id;
	}

	public String getContentType() {
		return contentType;
	}

	public void setContentType(String contentType) {
		this.contentType = contentType;
	}

	public int getContentLength() {
		return contentLength;
	}

	public void setContentLength(int contentLength) {
		this.contentLength = contentLength;
	}

	public String getFileName() {
		return fileName;
	}

	public void setFileName(String fileName) {
		this.fileName = fileName;
	}

}






이미지 뷰 클래스입니다.

package com.nnoco.example;

import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.view.AbstractView;


@Component("imageView")
public class ImageView extends AbstractView{
	@Override
	protected void renderMergedOutputModel(Map model,
			HttpServletRequest req, HttpServletResponse res) throws Exception {
		ImageFile imageFile = (ImageFile)model.get("imageFile");
		
		// 응답 메시지에 파일의 길이를 넘겨줍니다.
		res.setContentLength(imageFile.getContentLength());

		// 응답의 타입이 이미지임을 알려줍니다.
		res.setContentType(imageFile.getContentType());
		
		// 파일로부터 byte를 읽어옵니다.
		byte[] bytes = readFile(imageFile.getFileName());
		write(res, bytes);
	}

	/**
	 * 파일로부터 byte 배열 읽어오기 
	 */
	private byte[] readFile(String fileName) throws IOException {
		String path = ImageFile.IMAGE_DIR + fileName;
		
		BufferedInputStream bis = new BufferedInputStream(new FileInputStream(path));
		int length = bis.available();
		byte[] bytes = new byte[length];
		bis.read(bytes);
		bis.close();
		
		return bytes;
	}

	/**
	 * 응답 OutputStream에 파일 내용 쓰기
	 */
	private void write(HttpServletResponse res, byte[] bytes) throws IOException {
		OutputStream output = res.getOutputStream();
		output.write(bytes);
		output.flush();
	}
}




이미지 서비스 클래스입니다.

모양만 서비스이고, 구현은 전혀 아닙니다. 맵 자료구조를 사용해서 DB를 대신했습니다.

package com.nnoco.example;

import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

@Service
public class ImageService {
	private Map imageFilesMap;
	
	public ImageService() {
		init();
	}
	
	/**
	 * 초기화
	 */
	private void init() {
		imageFilesMap = new HashMap();
	}
	
	/**
	 * ID로 이미지 파일 가져오기
	 */
	public ImageFile get(String id) {
		return imageFilesMap.get(id);
	}
	
	/**
	 * Multipart File을 파일로 저장하고 DB(를 빙자한 맵)에 업로드 파일 정보 저장, 실패하는 경우 null리
	 */
	public ImageFile save(MultipartFile multipartFile) {
		// UUID로 유일할 것 같은 값 생성.. 낮은 확률로 중복 가능성이 있음
		String genId = UUID.randomUUID().toString();
		ImageFile imageFile = null;
		
		try {
			String savedFileName = saveToFile(multipartFile, genId);
			
			imageFile = new ImageFile(genId, 
					multipartFile.getContentType(),
					(int)multipartFile.getSize(),
					savedFileName);
			
			imageFilesMap.put(genId, imageFile);
		} catch (IOException e) {
			e.printStackTrace();
		}
		
		return imageFile;
	}
	
	/**
	 * Multipart File의 내용을 파일로 저장, 저장 후 저장된 파일 이름을 반환
	 */
	private String saveToFile(MultipartFile src, String id) throws IOException {
		String fileName = src.getOriginalFilename();
		byte[] bytes = src.getBytes();
		String saveFileName = id + "." + getExtension(fileName);
		String savePath = ImageFile.IMAGE_DIR + saveFileName;

		/* 파일 쓰기 */
		BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(savePath));
		bos.write(bytes);
		bos.flush();
		bos.close();
		
		return saveFileName;
	}
	
	/**
	 * 파일이름으로부터 확장자를 반환하는 메서드
	 * 파일이름에 확장자 구분을 위한 . 문자가 없거나. 가장 끝에 있는 경우는 빈문자열 ""을 리턴
	 */
	private String getExtension(String fileName) {
		int dotPosition = fileName.lastIndexOf('.');
		
		if (-1 != dotPosition && fileName.length() - 1 > dotPosition) {
			return fileName.substring(dotPosition + 1);
		} else {
			return "";
		}
	}
}


파일 업로드를 위한 JSP 페이지와 결과 확인 페이지입니다.

upload.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>이미지 업로드</title>
</head>
<body>
	<form action="./upload" method="post" enctype="multipart/form-data">
		<input type="file" name="imageFile"><br>
		<input type="submit" value="전송">
	</form>
</body>
</html>


uploadComplete.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>업로드 결과 페이지</title>
<style type="text/css">
	.failed {
		color: red;
		font-style: bold;
		font-size:18pt;
	}
</style>
</head>
<body>
	<c:choose>
		<c:when test="${imageFile != null }">
		파일 업로드 완료
		<ul>
			<li>파일 ID : ${imageFile.id }</li>
			<li>저장된 파일 이름 : ${imageFile.fileName }</li>
			<li>파일 길이 : ${imageFile.contentLength }</li>
			<li>MIME 타입 : ${imageFile.contentType }</li>
		</ul>
		
		<img src="${pageContext.request.contextPath}/image/${imageFile.id}">
		</c:when>
		<c:otherwise>
		<span class="failed">파일 업로드 실패</span>		
		</c:otherwise>
	</c:choose>
</body>
</html>




여기까지 필요한 설정과 파일을 추가하고, 서버에 올려 접속을 하면 아래와 같은 아주 간단한 폼과 함께 파일을 업로드 해볼 수 있습니다.

이미지 업로드라고 하긴 했지만 사실 이미지 업로드를 위해 한 일은 없고, 파일 업로드를 하는 작업만 한 것 같네요.





위 예제 프로젝트는 아래 파일을 다운로드하시어 살펴보실 수 있습니다.

ImageUploadExample.zip


+ Recent posts