Java/Spring2019. 7. 29. 00:14
반응형

Spring MVC를 이용하여 웹 어플리케이션을 개발할 경우 일반적인 MVC(Model View Controller)패턴과 동일하게 컨트롤러(Controller)를 이용하여 사용자의 입력을 처리하고 결과를 출력할 View로 연결합니다. 이때 컨트롤러의 메소드는 개발자가 직접 호출하지 않고 Dispatcher에 의해서 호출되므로 해당 메소드에 전달할 메소드의 Argument들을 해결(Resolve)해줄 방법이 필요합니다.

Spring에서는 HandlerMethodArgumentResolver를 이용하여 Argument를 해결하는 방법을 제공합니다. HandlerMethodArgumentResolver는 Argument들을 해결하기 위한 일종의 전략패턴으로 생각하시면 될 것 같습니다.

Spring에서는 컨트롤러의 Argument를 해결하기 위한 다양한 Built-in ArgumentResolver를 제공합니다. 다들 아시고 계시는 내용이겠지만 정리하는 차원에서 간단하게 정리해보았습니다.

RequestParamMethodArgumentResolver

Http요청의 파라미터를 해결해주는 ArgumentResolver입니다. @RequestParam 어노테이션을 지정했거나 MultipartFile 타입의 파라미터(MultipartResolver도 필요)를 해결해줍니다.

기본 사용법

@GetMapping("/params/simple")
public String getSimpleMapping(@RequestParam String test) {
    return test;
}

위의 예제와 같이 파라미터(매개변수)에 @RequestParam 어노테이션을 지정하면 파라미터명과 동일한 Http 파라미터를 받을 수 있습니다. 또한 문자열 뿐만 아니라 정수형, 실수형등 다양한 타입의 파라미터를 지정할 수 있습니다.

파라미터명 별도 지정

@GetMapping("/params/specific-parameter-name")
public String getSpecificNameMapping(@RequestParam(name = "test-2") String test) {
    return test;
}

Http의 파라미터명을 컨트롤러의 파라미터명과 별도로 지정해야할 필요성이 있는 경우 예를 들어 java의 변수명에 사용할 수 없거나 컨벤션에 맞지않는 문자를 지정해야 할 경우에 사용할 수 있습니다. 혹은 Spring의 설정에 따라 파라미터명이 Byte Code에 반영되지 않을 경우에 명시적으로 지정해야할 수 있습니다.

선택적 파라미터

@RequestParam을 지정한 파라미터의 경우 기본적으로 해당 파라미터가 Http 요청에 포함되어야 합니다. 지정한 파라미터가 존재하지 않을 경우 아래와 같이 예외가 발생하고 Http상태코드 400을 반환받게 됩니다.

 

지정한 파라미터가 존재하지 않는 경우

 

특정 파라미터를 선택적으로 받아야 할 경우에는 required 속성에 필수여부를 지정할 수 있습니다.

@GetMapping("/params/optional")
public String getOptionalParameter(@RequestParam(required = false) String test) {
    return test;
}

위와 같이 파라미터에 required = false를 지정하면 선택적으로 파라미터를 사용할 수 있습니다.

기본값

특정 파라미터에 값을 지정하지 않거나 파라미터가 존재하지 않을 경우 사용할 기본값을 지정할 수 있습니다.

@GetMapping("/params/default-value")
public String getDefaultValueMapping(@RequestParam(defaultValue = "not found") String test) {
    return test;
}

위와같이 defaultValue 속성에 지정한 값이 기본값으로 적용됩니다. 이 경우 required 속성을 지정하지 않더라도 암묵적으로 required = false와 동일하게 동작하게 됩니다. 이유는 파라미터가 존재하지 않더라도 지정한 기본값을 통해 파라미터에 값을 전달할 수 있기 때문입니다.

@GetMapping("/params/optional2")
public String getOptionalParameterMapping2(@RequestParam Optional<String> test) {
    return test.orElse("Not Found");
}

또한 파라미터의 타입으로 Optional을 지정할 경우 역시 선택적으로 파라미터를 사용할 수 있습니다.

Multi-Value 파라미터

동일한 파라미터를 다중으로 지정하거나 콤마(,)를 구분자로 지정된 파라미터를 Collection을 이용해서 파라미터를 받을 수 있습니다.

@GetMapping("/params/multi-value")
public List<String> getMultiValueMapping(@RequestParam List<String> test) {
    //Something to do 
}

RequestParamMapMethodArgumentResolver

RequestParamMethodArgumentResolver와 동일하게 Http 요청의 파라미터를 해결해주는 ArgumentResolver 입니다. 다른점은 파라미터의 타입이 Map인 경우에만 파라미터를 처리해 준다는 점입니다.

기본 사용법

파라미터의 타입으로 Map 혹은 MultiValueMap을 지정하고 @RequestParamname 속성을 별도로 지정하지 않은 경우 요청에 포함된 파라미터 전체를 한개의 파라미터로 받을 수 있습니다. 다만 Map을 지정한 경우에는 값을 여러개 가질 수 없으므로 요청된 동일한 이름의 파라미터중 HttpServletRequest에서 해당 파라미터명의 첫번째(0번) 인덱스의 값을 받을 수 있습니다.

@GetMapping("/map")
public Map<String, String> getMapMapping(@RequestParam Map<String, String> params) {
    return params;
}

@GetMapping("/multi-value-map")
public String getMultiValueMapMapping(@RequestParam MultiValueMap<String, String> params) {
    return params.toString();
}

파라미터 전체가 대상이므로 requireddefaultValue 속성도 반응하지 않는다는 점에 주의하시기 바랍니다.

PathVariableMethodArgumentResolver

@PathVariable을 파라메터에 지정하면 URI Template Variable(/some/{thing})에서 템플릿의('{', '}':Curly Brace로 감싸진 부분) 값을 파라메터로 받을 수 있습니다.

기본 사용법

@GetMapping("/orders/{orderNo}/lines/{lineNo}")
public void someController(@PathVariable Long orderNo, @PathVariable Long lineNo) {
    //Do Something
}

위와같이 파라메터명과 동일한 이름의 템플릿의 값을 파라미터로 받을 수 있으며 @PathVariablename 속성을 통해 템플릿 이름을 직접 지정할 수 있습니다. 또한 문자열뿐만 아니라 정수형, 실수형을 지정할 수 있습니다.

선택적 PathVariable

@PathVariable을 통해 PathVariable을 받을 경우에는 기본적으로 해당 값이 필수적으로 존재해야합니다. required 속성을 통해 @RequestParam과 동일하게 선택적으로 받도록 지정할 수 있습니다. 다만 PathVariable의 특성상 여러개의 PathVariable중 중간에 있는 값을 선택적으로 사용할 경우에는 사용할 수 없는 uri가 생성될 수 있으므로 주의가 필요합니다. 또한 마지막에 위치한 PathVariable을 선택적으로 사용할 경우에는 마지막 문자가 Path 구분자(/)인 uri 와 혼동될 수 있습니다.(Spring의 경우 런타임에 java.lang.IllegalStateException: Ambiguous handler methods mapped for 와 같은 예외를 발생합니다.)

@GetMapping("/optional/{path}")
public void getOptional(@PathVariable(required = false) String path){
    //Something to do
}

위와 같이 required = false를 지정하여 간단하게 선택적으로 사용할 수 있습니다.

Multi-Value Path Variable

Path Variable에 콤마(,)를 구분자로 지정된 한 값이 전달될 경우 Collection 타입의 파라미터로 받을 수 있습니다.

@GetMapping("/path/{variables}")
public List<String> getMultiValueMapping(@PathVariable List<String> variables) {
    return variables;
}

날짜타입

@PathVariable과 함께 @DateTimeFormat를 지정하면 날짜타입의 파라미터로도 받을 수 있습니다.

@GetMapping("/reservations/{date}")
public void getReservations(@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Date date) {
    //TODO
}

PathVariableMapMethodArgumentResolver

PathVariableMethodArgumentResolver와 동일하게 URI의 Path variable을 해결해주는 ArgumentResolver 입니다. 다른점은 파라미터의 타입이 Map인 경우에만 파라미터를 처리해 준다는 점입니다.

기본 사용법

파라미터의 타입으로 Map을 지정하고 @PathVariablename 속성을 별도로 지정하지 않은 경우 요청에 포함된 URI Path Variable 전체를 한개의 파라미터로 받을 수 있습니다.

@GetMapping("/{path}/{variable}")
public Map<String, String> getMapMapping(@PathVariable Map<String, String> variables) {
    return variables;
}

Path Variable 전체가 대상이므로 required는 속성은 적용되지 않습니다.

RequestHeaderMethodArgumentResolver

Http Request의 Header값을 해결해주는 ArgumentResolver로 @RequestHeader어노테이션을 파라메터에 지정하면 Http Request Header의 값을 파라미터로 받을 수 있습니다.

기본 사용법

@GetMapping("/something")
public String getSomething(@RequestHeader("user-agent") String userAgent) {
    return userAgent;
}

어노테이션의 name 속성에 지정한 이름의 Http Request Header의 값이 지정한 파라메터에 전달됩니다. 지정하지 않을 경우 변수명과 동일한 이름의 헤더의 키값이 지정되지만 변수에 사용할 수 없는 문자는 사용할 수 없으므로 지정하는 것을 추천합니다.(Http요청의 헤더에는 java의 변수명으로 사용할 수 없는 하이픈(-)이 포함되는 경우가 많습니다.)

선택적 Request Header

기본적으로 @RequestHeader를 통해 요청 헤더값을 받는 경우 해당 헤더가 존재하지 않을 경우 Http응답코드 400이 발생합니다. 이 경우 required 속성을 지정하면 요청 헤더값을 선택적으로 받을 수 있습니다.

@GetMapping("/something-optional")
public String getSomethingOptional(@RequestHeader(name = "optional-header", required = false) String header) {
    //Something to do
}

기본값

지정한 요청 헤더가 Http 요청에 포함되지 않는 경우 사용할 기본값을 지정할 수 있습니다.

@GetMapping("/something")
public String getHeaderDefaultValue(@RequestHeader(name = "some-header", defaultValue = "nothing") String header) {
    //Something to do
}

위와같이 defaultValue 속성에 지정한 값이 기본값으로 적용됩니다. 이 경우 required 속성을 지정하지 않더라도 파라미터에 값을 바인딩 할 수 있으므로 암묵적으로 required = false를 지정한 것처럼 예외가 발생하지 않습니다.

날짜타입

@RequestHeader과 함께 @DateTimeFormat를 지정하면 날짜타입의 헤더의 값도 받을 수 있습니다.

@GetMapping("/something")
public String getHeaderDateTime(@RequestHeader("some-header") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Date header) {
    //Something to do
}

RequestAttributeMethodArgumentResolver

Request Attribute의 속성을 해결해주는 ArgumentResolver 입니다. 서블릿 컨테이너에 의해서 설정된 속성이나 ServletRequest#setAttribute을 통해서 설정된 속성을 받을 수 있습니다.

기본 사용법

@GetMapping("/attribute/something")
public String getSomething(@RequestAttribute("something") String something) {
    //Something to do
}

위와 같이 파라미터에 @RequestAttribute 어노테이션에 지정한 이름 혹은 지정하지 않은 경우는 파라미터명과 동일한 속성값을 받을 수 있습니다.

선택적 Request Attribute

기본적으로 @RequestAttribute를 지정하여 Request Attribute를 받는 경우 해당 속성이 존재하지 않을 경우 예외가 발생하고 Http응답코드 400이 반환됩니다. 이 경우 required 속성을 지정하면 해당 속성값을 선택적으로 받을 수 있습니다.

@GetMapping("/attribute/something")
public String getSomething(@RequestAttribute(name = "something", required = false) String something) {
    //Something to do
}

SessionAttributeMethodArgumentResolver

HttpSession의 속성을 해결해주는 ArgumentResolver입니다. 컨트롤러의 파라미터에 @SessionAttribute 어노테이션을 지정하면 적용 가능합니다.

기본 사용법

@GetMapping("/session")
public void getSession(@SessionAttribute("someAttribute") String someAttribute) {
    //Something to do
}

위와 같이 파라미터에 @SessionAttribute 어노테이션에 지정한 이름 혹은 지정하지 않은 경우는 파라미터명과 동일한 세션의 속성값을 받을 수 있습니다.

선택적 Session Attribute

기본적으로 @SessionAttribute를 지정하여 Session Attribute를 받는 경우 해당 세션 속성이 존재하지 않을 경우 예외 처리되고 Http응답코드 400이 반환됩니다 이 경우 required 속성을 지정하면 해당 세션의 속성값을 선택적으로 받을 수 있습니다.

@GetMapping("/session")
public void getSession(@SessionAttribute(name = "someAttribute", required = false) String someAttribute) {
    //Something to do
}

ServletCookieValueMethodArgumentResolver

HttpServletRequest로부터 쿠키(Cookie)의 값을 해결하기 위한 ArgumentResolver입니다.

기본 사용법

@PostMapping
public void someController(@CookieValue(name="cookie_name") String cookie) {
    //Do Something
}

위와 같이 메소드의 파라메터에 @CookieVaule 어노테이션을 지정하면 HttpServletRequest에서 쿠키의 값을 파라미터로 받을 수 있습니다.

선택적 쿠키

@PostMapping
public void someController(@CookieValue(name="cookie_name", required = false) String cookie) {
    //Do Something
}

기본적으로 @CookieValue를 지저하여 쿠키값을 받는 경우 해당 쿠키가 존재하지 않을 경우 예외가 발생하고 Http응답코드 400이 반환됩니다. 이 경우 required 속성을 지정하면 해당 쿠키값을 선택적으로 받을 수 있습니다.

기본값

지정한 쿠키가 존재하지 않을 경우 사용할 기본값을 지정할 수 있습니다.

@GetMapping("/cookie")
public String getCookieDefaultValue(@Cookie(name = "some-cookie", defaultValue = "nothing") String header) {
    //Something to do
}

위와같이 defaultValue 속성에 지정한 값이 기본값으로 적용됩니다. 이 경우 required 속성을 지정하지 않더라도 파라미터에 값을 바인딩 할 수 있으므로 암묵적으로 required = false를 지정한 것처럼 예외가 발생하지 않습니다.

ModelAttributeMethodProcessor

Spring MVC 패턴에서 Model에 관한 속성을 처리해주는 ArgumentResolver입니다. 사용자의 입력 즉 Http요청의 파라미터를 Model에 바인딩해주는 역할을 수행합니다. 위에서 살펴본 RequestParamMethodArgumentResolver(@RequestParam)와 유사한 역할을 수행한다고 보면 되지만 POJO를 지정하여 여러개의 파라미터를 한개의 변수로 받을 수 있으므로 좀더 편리하게 사용하실 수 있습니다.

기본 사용법

@GetMapping("/model")
public void getModelFromParameter(@ModelAttribute("test") String test) {
    //Something to do
}

@GetMapping("/model")
public void getModel(@ModelAttribute TestDto test) {
    //Something to do
}

위와 같이 파라미터에 @ModelAttribute를 지정한 경우 해당 어노테이션에 지정한 이름에 해당하는 Http 파라미터를 받을 수 있습니다. 또한 POJO형태의 타입을 지정한 경우 해당 타입이 가진 프로퍼티에 Http 파라미터가 바인딩됩니다. 설정에 따라서 POJO를 지정한 경우 @ModelAttribute를 생략할 수 있으므로 편리하게 사용할 수 있습니다.

ExpressionValueMethodArgumentResolver

Spring의 표현식을 이용해서 값을 해결해주는 ArgurmentResolver입니다. @Value 어노테이션이 지정되어 있는 파라미터를 통해 값을 받을 수 있습니다. 보통 필드에 해당 어노테이션을 지정하는 방식을 많이 사용하므로 그다지 사용되지는 않지만 간단하게 알아보도록 하겠습니다.

기본 사용법

@GetMapping("/values/someValue")
public void getSomeValue(@Value("${someValue}") String someValue) {
    //Somthing to do
}

위와 같이 @Value 어노테이션에 지정한 표현식을 통해서 파라미터의 값이 바인딩 됩니다. 표현식에 대해서는 여기서는 별도로 다루지는 않겠습니다. 다만 위의 표현식의 경우 Spring의 환경변수 org.springframework.core.env.Enviroment#getProperty("someValue")을 호출한 결과를 얻게됩니다.

마치며

위에서 소개한 바와같이 HandlerMethodArgumentResolver를 이용하면 컨트롤러 내부에 작성해야할 보일러 플레이트 코드를 제거하여 코드를 깔끔하게 유지하여 비지니스 로직에만 집중할 수 있습니다. HandlerMethodArgumentResolver를 이용하여 공통화 가능한 중복코드가 있는지 확인해 보는 것을 추천합니다.

Posted by Reiphiel
Java/Spring2019. 5. 9. 02:00
반응형

Spring 트랜잭션 관리방법

Spring(스프링)에서 트랜잭션(Transaction)을 관리하는 방법은 크게 서로 대비되는 2가지 방법으로 나눌 수 있습니다.

프로그램에 의한(Programmatic) 트랜잭션 관리

첫번째로 알아볼 방법은 프로그램 코드에 의한 트랜잭션 관리입니다.

@Autowired
private PlatformTransactionManager transactionManager;

public void operateSome() {
    TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
    try {
    } catch (RuntimeException e) {
        transactionManager.rollback(status);
        throw e;
    } finally {
        if (status.isRollbackOnly()) {
            transactionManager.rollback(status);
        } else {
            transactionManager.commit(status);
        }
    }
}

위의 코드와 같이 트랜잭션 매니저를 통해서 직접 트랜잭션 개시, 커밋, 롤백등을 수행하는 방법으로 소스코드에 직접기술하기 때문에 가독성을 떨어트리고 실수할 가능성도 높아지므로 많이 사용하는 방식은 아닙니다만, 내부구현을 외부에 노출하고 싶지 않은 경우에 사용할 수 있습니다.

선언적(Declarative) 트랜잭션 관리

다음은 선언적 트랜잭션 관리 입니다. 선언적 트랜잭션 관리라는 말이 어려울 수 있지만 간단하게 설명하면 트랜잭션에 관한 코드를 비지니스 코드로 부터 분리해서 비침투적인 방법으로 기술하여 관리하는 방법을 의미합니다.

어노테이션(Annotation)으로 트랜잭션 선언

@Transactional
public class SomeService() {
    @Transactional
    public void operateSome() {
    }
}

@Transactional 어노테이션을 사용할 경우에는 프래그램을 사용할 때와같이 트랜잭션 매니저를 직접 지정하지 못하기 때문에 transactionManager 라는 속성을 통해서 사용할 트랜잭션 매니저를 지정할 수 있습니다.

AOP설정으로 트랜잭션 선언

<aop:config>
    <aop:pointcut id="serviceOperation"
          expression="execution(* service..*Service.*(..))"/>
    <aop:advisor pointcut-ref="serviceOperation" advice-ref="txAdvice"/>
</aop:config>

<tx:advice id="txAdvice">
    <tx:attributes>
        <tx:method name="*"/>
    </tx:attributes>
</tx:advice>
@Bean
public TransactionInterceptor transactionInterceptor(PlatformTransactionManager transactionManager) {
    return new TransactionInterceptor(transactionManager, transactionAttributeSource());
}

@Bean
public NameMatchTransactionAttributeSource transactionAttributeSource() {
    NameMatchTransactionAttributeSource tas = new NameMatchTransactionAttributeSource();

    Map<String, AttributeSource> matches = new HashMap<>();
    matches.put("get*", new RuleBasedTransactionAttribute());
    return tas;
}

@Bean
public AspectJExpressionPointcutAdvisor transactionAdvisor(TransactionInterceptor advice) {
    AspectJExpressionPointcutAdvisor advisor = new AspectJExpressionPointcutAdvisor();
    advisor.setAdvice(advice);
    advisor.setExpression("execution(* service..*Service.*(..))");
    return advisor;
}

스프링에서 제공하는 설정방식인 어노테이션이나 혹은 AOP설정 이용하여 트랜잭션 관리를 수행할 수 있습니다. @Transactional 어노테이션 이외의 방식은 거의 사용되지 않으므로 할 수 있다정도만 알고 있으면 되겠습니다.

Spring 트랜잭션 속성

속성 설명
propagation 트랜잭션 개시할지 등 전파행위에 관한 속성.
isolation 트랜잭션 격리레벨에 관한 속성으로 기본값은 Default레벨이며 실제 사용하는 데이터베이스(JDBC) 등의 기본값을 따릅니다.
readOnly 트랜잭션을 읽기전용으로 지정하는 속성. 최적화 관점에서 지원되는 프로터티이므로 현재 트랜잭션 상태에따라 다르게 동작할 수 있습니다.
timeout 트랜잭션의 타임아웃(초단위)을 지정하는 속성으로 지정하지 않을 경우 사용하는 트랜잭션 시스템의 타임아웃을 따릅니다.
rollbackFor Checked 예외 발생시에 롤백을 수행할 예외를 지정하는 속성.
rollbackForClassName rollbackFor와 동일하지만 문자열로 클래스명을 지정하는 속성.
noRollbackFor Spring의 트랜잭션은 기본적으로 Runtime예외만 롤백처리를 수행하지만 Runtime예외중 특정 예외는 롤백을 수행하지 않아야 할 경우 사용하는 속성.
noRollbackForClassName noRollbackFor와 동일하지만 문자열로 클래스명을 지정하는 속성.

설정예

@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public class SomeService {

    @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.DEFAULT, timeout = 10)
    public void operateSome() {
    }
    //...
}

위와같이 클래스 단위 혹은 메소드 단위로 지정할 수 있습니다. 메소드에 기술한 설정이 우선 적용되며 지정하지 않은 경우 클래스에 기술한 설정이 적용됩니다.

전파행위(Propagation Behavior)

전파행위(거동)는 트랜잭션을 개시할지 혹은 기존 트랜잭션을 이용할지 등 트랜잭션 경계(Transaction Boundary)를 설정할 때 이용하는 속성으로 가장 중요한 속성이라고 할 수 있습니다.

설정 가능한 전파행위 목록

속성 설명
MANDATORY 트랜잭션이 존재할 경우 해당 트랜잭션을 이용하며 존재하지 않을 경우 예외발생.
NESTED 트랜잭션이 존재할 경우 중첩된 트랜잭션을 개시하고 존재하지 않을 경우는 REQUIRED와 동일하게 동작.
NEVER 트랜잭션이 존재할 경우 예외발생.
NOT_SUPPORTED 트랜잭션이 존재할 경우 중단(Suspend)해서 트랜잭션을 이용하지 않음.
REQUIRED 트랜잭션이 존재하는 경우 해당 트랜잭션을 그대로 하며 개시된 트랜잭션이 없는 경우 트랜잭션 개시.
REQUIRES_NEW 항상 신규트랜잭션을 개시함. 트랜잭션이 존재하는 경우 해당 트랜잭션을 중단하고 새로운 트랜잭션 개시.
SUPPORTS 트랜잭션이 존재할 경우 해당 트랜잭션을 이용하고 존재하지 않을 경우는 트랜잭션을 이용하지 않음.

각각의 전파행위에 대해 좀더 자세히 알아보도록 하겠습니다.

MANDATORY

트랜잭션이 개시된 것을 강제해야할 경우 사용하는 속성으로 데이터베이스의 락을 취득한다던지 시퀀셜한 번호를 생성하거나 하는등 단독으로 사용할 이유가 없는 경우에 해당 상황을 배제하기 위해 사용합니다.

NESTED

중첩트랜잭션은 이미 트랜잭션이 시작되어있는 상태에서 트랜잭션 내부에 새로운 트랜잭션 경계를 설정하고자 할 때 사용하는 속성으로 JDBC의 Savepoint 기능을 이용합니다. Savepoint 기능이 JDBC 3.0이후부터 지원되는 영향인지 전파행위의 다른 속성들은 동일한 이름으로 EJB에 존재하지만 중첩 트랜잭션은 존재하지 않습니다.

중첩트랜잭션을 사용할만한 상황을 예를 들어보면 주문 트랜잭션 내부에서 포인트 적립을 처리하는 부분만 별도로 경계를 설정하고 해당 경계 내부의 처리에 실패하더라도 주문자체는 정상 처리시키고자 할 때 사용할 수 있을 듯 합니다. 하지만 최근에 많이 사용하는 JPA를 사용하는 경우, 변경감지를 통해서 업데이트문을 최대한 지연해서 발행하는 방식을 사용하기 때문에 중첩된 트랜잭션 경계를 설정할 수 없어 지원하지 않습니다.

JPA(Hibernate 구현체)에서 중첩 트랜잭션을 사용할려고 하면 아래와 같은 예외를 만나게 됩니다.

org.springframework.transaction.NestedTransactionNotSupportedException: JpaDialect does not support savepoints - check your JPA provider's capabilities

JDBC의 Savepoint관련 내용은 이전글( JDBC Transaction Savepoints)을 참조하세요.

NEVER

트랜잭션이 존재하는 경우에 예외를 발생시켜 트랜잭션을 사용하지 않는 것을 강제하는 속성입니다.

NOT_SUPPORTED

트랜잭션이 존재할 경우 해당 트랜잭션을 중단하고 트랜잭션이 없는 상태로 처리를 수행합니다.

REQUIRED

기본값으로 사용되는 속성으로 트랜잭션이 존재하지 않을 경우 개시하고 있으면 그대로 사용하는 속성입니다. 어노테이션기반의 설정일 경우에는 어노테이션에 기본값으로 명시되어있으며 그 이외의 경우에는 별도로 지정하지 않은경우 org.springframework.transaction.support.DefaultTransactionDefinition의 기본값이 사용됩니다.

REQUIRES_NEW

트랜잭션이 존재할 경우 해당 트랜잭션을 중단하고 새로운 트랜잭션 경계를 설정하는 속성으로 트랜잭션이 존재하지 않는 경우에는 REQUIRED와 동일한 동작을 합니다. 모든 트랜잭션 매니저가 실제 트랜잭션 중단을 지원하는 것은 아니기 때문에 일반적으로 기존 트랜잭션을 방치한 상태로 새로운 트랜잭션을 생성합니다.

다시 말해서 물리적으로 데이터베이스 커넥션을 새로 얻는다는 의미입니다. 요청이 많은 특정 서비스에 사용할 경우 데이터베이스 커넥션을 얻기위해 대기하는 리소스 데드락(Resource Deadlock - 특정리소스를 점유한 스레드들이 동일한 리소스를 얻으려고 대기하는 상태) 을 유발할 가능성이 있으므로 주의해서 사용해야할 필요가 있습니다. 위에서 설명한 NOT_SUPPORTED의 경우도 트랜잭션이 중단된 상태에서 다시 REQUIRED를 만나게 되면 동일한 현상이 발생할 가능성이 있습니다.

SUPPORTS

트랜잭션이 존재하면 해당 트랜잭션을 사용하고 존재하지 않을 경우 트랜잭션 경계를 설정하지 않는 속성입니다. 내부적으로 트랜잭션관련된 처리는 없지만 실패할 경우 트랜잭션 롤백을 해야할 경우에 사용할 수 있습니다.

격리레벨(Isolation Level)

JDBC에서 제공하는 격리레벨을 설정하는 속성 입니다. 격리레벨에 대한 설명은 JDBC나 데이터베이스에 관련된 내용이고 대부분의 경우에 기본 격리레벨로 충분하므로 어떤 값이 있는지만 간단하게 알아겠습니다.

속성 설명
DEFAULT 사용하는 저장소의 기본 격리레벨을 이용하는 속성으로 데이터베이스마다 다를 수 있습니다.
READ_UNCOMMITTED 다른 트랜잭션에의해 커밋되지 않은 변경사항을 읽을 수 있는 격리레벨로 Dirty read, Nonrepeatable read, Panthom read가 발생합니다.
READ_COMMITTED 다른 트랜잭션에 의해 커밋된 내용이 읽을때마다 반영되는 격리레벨입니다. Nonrepeatable read, Panthom read가 발생합니다. Oracle(오라클), PostgreSQL등의 기본 격리레벨입니다.
REPEATABLE_READ 동일 트랜잭션 경계안에서 반복해서 읽을 경우 다른 트랜잭션에 의해 커밋된 내용이 반영되지 않고 동일한 내용이 읽히는 격리레벨입니다. 다만 Panthom read는 발생합니다.
SERIALIZABLE 해당하는 테이블을 모두 잠그는 가장 높은 수준의 격리레벨로 다른 레벨에서 발생할 수 있는 모든 문제가 차단됩니다. 하지만 성능적인 측면에서 문제가 있어서 거의 사용되지 않습니다.
  • Dirty read는 커밋되지 않는 변경사항이 읽히는 문제로 해당하는 내용이 롤백될 경우 읽은 내용이 유효하지 않을 가능성이 있는 문제입니다.

  • Nonrepeatable read는 트랜잭션 경계 내부에서 반복적으로 읽기를 수행할때 다른 데이터를 읽을 수 있는 가능성이 있는 문제입니다.

  • Panthom read는 다른 트랜잭션에 의해서 추가된 행(Row)이 읽히는 문제입니다.

주의사항

Spring에서 제공하는 트랜잭션을 사용하면서 가장 중요하게 인식해야할 사항은 트랜잭션이 적용된 메소드를 경계로 트랜잭션의 상태가 관리된다는 점입니다. 예외를 트랜잭션이 적용된 메소드 외부로 던지면 트랜잭션에 롤백해야한다고 표시되며 해당 트랜잭션을 커밋할려고 시도할 경우 아래와 같은 예외와 만나게 됩니다.

org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only

혹은

Could not commit JPA transaction; nested exception is javax.persistence.RollbackException: Transaction marked as rollbackOnly

따라서 예외가 트랜잭션이 적용된 메소드 경계를 지나 던져졌을 경우 catch를 이용하여 잡더라도 특정 처리후에 다시 던지거나 다른 예외를 던저야 한다는 점에 주의해야합니다.

또 한가지 주의할 점은 Load Time Weaving(LTW)나 Compile Time Weaving등을 별도로 사용하지 않는 경우 AOP가 Proxy기반으로 동작한다는 점입니다. 이는 특정 Spring 빈 내부에서 this를 통해서 다른 메소드를 호출할 경우 트랜잭션에 관한 설정이 적용되지 않는다는 점입니다.

따라서 빈 내부에서 트랜잭션이 선언된 내부의 메소드를 호출할 경우에도 ApplicationContext를 통해서 빈의 레퍼런스를 얻는 방법을 통해서 Proxy를 경유해서 호출해야 트랜잭션에 관한 선언이 적용됩니다.

마치며

Spring에서 제공하는 트랜잭션관리 기능을 사용하는 방법과 주의사항에 대해서 알아보았습니다. 평소에 의식하지 않고 사용하는 기능이므로 정리하는 차원에서 작성했습니다. 이기회에 다같이 정리해 보는건 어떨까요?

참고자료

Transaction Management

Spring AOP : Replace XML with annotations for transaction management?

Posted by Reiphiel
Java/Spring2019. 4. 29. 01:18
반응형

Spring Security로 CSRF 프로텍션 적용

최근의 Java기반의 웹 프로젝트는 대부분 Spring(스프링) 프레임워크 기반으로 구현되기 때문에 자연스럽게 Spring Security를 이용하여 보안관련 기능들을 구현하게 됩니다. 그중 CSRF(Cross Site Request Forgery : 사이트 간 요청 위조)는 개발중에는 매우 귀찮은 존재입니다. 웹에서 CSRF 프로텍션은 보안 대책으로 거의 필수적으로 요구되지만 개발 편의성 문제로 개발중에는 전혀 신경쓰지 않거나 비활성화해두는 경우가 많이 있습니다. 결국에는 보안점검을 통해 지적받고 일괄적으로 소스코드를 변경합니다. 하지만 급하게 고친 코드는 항상(?) 문제를 일으킵니다. 또한 운영중에도 잘못된 수정으로 배포후에 예외를 만나는 경우가 많이 있습니다.

CSRF 프로텍션 활성화

먼저 다들 아시는 내용이겠지만 복습차원에서 Spring Security에서 CSRF 기능을 활성화 시키는 방법에 대해서 알아보겠습니다.

dependencies {
    //...
    compile group: 'org.springframework.security', name: 'spring-security-config', version: '5.1.5.RELEASE'
    compile group: 'org.springframework.security', name: 'spring-security-web', version: '5.1.5.RELEASE'
    //...
    //compile('org.springframework.boot:spring-boot-starter-security') - starter를 이용하는 경우
}

위와같이 Spring Security 의존성을 추가하거나 Spring Boot를 사용하는 경우는 Starter를 통해서 의존성을 추가할 수 있습니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...Some Configuration
        http.csrf();
        //...Other Configuration
    }
}

설정방법은 위와 같지만 @EnableWebSecurity에 의해서 Spring Security가 설정되면 기본적으로 CSRF 프로텍션은 활성화되므로 http.csrf(); 이 설정은 사실상 필요없습니다만 설정방식을 명시적으로 보여주는 차원에서 위와같이 기술했습니다.

Spring Security의 CSRF 프로텍션은 Http 세션과 동일한 생명주기(Life Cycle)을 가지는 토큰을 발행한 후 Http 요청(PATCH, POST, PUT, DELETE 메소드인 경우)마다 발행된 토큰이 요청에 포함되어(Http헤더 혹은 파라메터 둘중 하나) 있는지 검사하는 가장 일반적으로 알려진 방식의 구현이 설정되어 있습니다.

Spring Security에서는 토큰의 저장소로 Cookie를 사용하는 방식도 아래의 예와같은 설정을 통해서 지원하고 있습니다. 이 경우에는 Http헤더 혹은 파라메터를 통해 전송된 토큰을 쿠키를 통해서 전송된 토큰과 검증하는 방식으로 동작하게 됩니다. API서버 등과 같이 세션을 사용하지 않는 Stateless한 경우에는 어쩔수 없이 채택하는 방식이긴 합니다만 일반적으로 세션의 속성으로 토큰을 보관하는 방식이 좀더 안전하다고 알려져 있으므로 추천드리지 않습니다. 자세한 내용을 설명하는 것은 이 글의 범위를 벗어나므로 간단하게 설명하면 Cookie를 저장소로 사용할 경우 Cookie에 토큰이 주입되거나 토큰의 유출이 발생할 경우 토큰의 무효화를 할 수 없는 등의 취약점이 있습니다. 더 자세한 내용은 Spring Security의 문서를 참조하시기 바랍니다.

예) http.csrf().csrfTokenRepository(new CookieCsrfTokenRepository());

CSRF 토큰의 구조

public interface CsrfToken extends Serializable {
    String getHeaderName();
    String getParameterName();
    String getToken();
}

위와같이 Http헤더의 헤더명과 파라메터명 그리고 토큰으로 구성되어 있습니다. Http헤더를 사용할 경우는 헤더명을 키로 토큰을 값으로 전송하면 되고 파라메터로 전송할 경우는 파라메터명을 키로 역시 토큰을 값으로 전송하면 됩니다.

CSRF 토큰을 Http요청에 포함시키는 방법

CSRF토큰은 Http 세션과 동일한 생명주기를 가지기 때문에 세션속성으로 저장되게 됩니다. Spring Security의 기본구현은 Http 세션에 org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository.CSRF_TOKEN 와 같이 다소 긴이름으로 저장되기 때문에 세션을 직접 참조하는 방식을 사용하기가 곤란합니다. 따라서 Spring Security는 해당 토큰을 Http요청마다 '_csrf' 라는 간단한 이름의 Http Request 속성으로 바인딩시켜 사용성을 올려 줍니다.

1. Form의 파라메터

위에서 설명한 바와 같이 Http Request 속성에 바인딩 되어 있기때문에 el표현식을 사용할 수 있는 템플릿 엔진을 사용중이라면 간단하게 Form의 파라메터로 추가할 수 있습니다.

<!-- Some HTML -->
<form action="/some-uri" method="post">
  <input type="text" name="some" value="thing" />
  <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
  <input type="submit" />
</form>
<!-- Other HTML-->

위와같이 '_csrf' 라는 Http Request 속성을 통해서 파라메터 명과 토큰을 취득하여 Form의 숨겨진 요소로 추가하면 간단하게 포함 시킬 수 있습니다.

Spring MVC에서 제공하는 <form:form> 태그를 사용하거나 Thymeleaf 2.1이상이면서 @EnableWebSecurity를 사용한 경우에는 CsrfRequestDataValueProcessor가 적용되어 필요한 form에 자동으로 CSRF 토큰이 포함되게 됩니다.

2. Ajax 요청시 헤더

사용자 경험을 향상시키기 위해서 화면이동을 제외한 처리는 대부분 Ajax를 통한 JSON 요청으로 처리하는 경우가 많습니다. 이경우 위와같이 파라메터로 토큰을 전송할 수 없으므로 Http 헤더를 통해서 전송할 수 있습니다.

$.ajaxPrefilter(function (options) {
  var headerName = '${_csrf.headerName}';
  var token = '${_csrf.token}';
  if (options.method === 'POST') {
      options.headers = options.headers || {};
      options.headers[headerName] = token;
  }
});

위의코드는 jQuery를 사용하는 경우 모든 Ajax Post 요청에 대해 Http 헤더에 CSRF 토큰을 설정하는 코드 입니다. 혹시 초반에는 CSRF설정을 무효화 하고 구현하는 경우에는 Ajax요청을 특정 라이브러리로 단일화 하여 구현하기를 추천드립니다. 그럴 경우에는 위처럼 단순한 설정만으로도 모든 요청에 CSRF 토큰을 추가하는 것이 가능하므로 소스코드 수정을 최소화 할 수 있습니다.

그 이외의 설정

비활성화

CSRF 프로텍션을 비활성화 시키고 싶은경우에는(추천하지 않지만) 아래와 같이 disabled()를 호출하면 됩니다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    //...Some Configuration
    http.csrf().disabled();
    //...Other Configuration
}

특정 요청만 예외처리

@Override
protected void configure(HttpSecurity http) throws Exception {
    //...Some Configuration
    http.csrf()
        .ignoringAntMatchers()
        .ignoringRequestMatchers();
    //...Other Configuration
}

Ant Matcher 혹은 Request Matcher를 지정하여 특정 요청은 대상에서 제외할 수 있습니다. 예를들어 외부에서 오는 요청이나 콜백의 경우 제외해야할 필요가 있는 경우가 있습니다.

특정 요청은 적용

@Override
protected void configure(HttpSecurity http) throws Exception {
    //...Some Configuration
    http.csrf()
        .requireCsrfProtectionMatcher();
    //...Other Configuration
}

그다지 쓸일이 있어보이지는 않습니다만 특정 요청은 적용되어야 할 경우에는 위의 설정을 사용할 수 있습니다. 예를들어 기본적으로 제외되어있는 GET, HEAD, TRACE, OPTIONS 메소드중 일부 요청을 추가할 수 있을 것 같습니다.

예외처리

토큰검증에 실패 했을경우 403 Http 응답코드가 반환됩니다. 이때 이동할 페이지 혹은 예외처리를 위와같은 설정을 통해 할 수 있습니다. 세션이 만료되어 서버에서 토큰정보를 취득할 수 없는 경우에는 MissingCsrfTokenException 예외가 발생하고 전송된 토큰이 다른 경우에는 InvalidCsrfTokenException 예외가 발생하므로 필요에 따라 적절한 예외처리를 기술하면 되겠습니다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    //...Some Configuration
    http.exceptionHandling()
        .accessDeniedPage("/denied.html")
        .accessDeniedHandler(new AccessDeniedHandler() {
            @Override
            public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exception) throws IOException, ServletException {
                if (exception instanceof MissingCsrfTokenException) {
                    //Some Exception Handling
                } else if (exception instanceof InvalidCsrfTokenException) {
                    //Some Exception Handling
                }
        });
    //...Other Configuration
}

이상으로 Spring Security를 통해 CSRF 프로텍션을 적용하는 방법에 대해서 알아보았습니다. 위에서 언급한대로 CSRF 프로텍션은 웹 보안에있어 거의 필수로 요구되므로 가급적 개발초기부터 고려해서 빠지지 않게 적용되도록 합시다.

참고자료

2014/07/14 - [Security] - CSRF(Cross Site Request Forgery)

Spring Security 5.0.x - 19. Cross Site Request Forgery

Posted by Reiphiel