Idempotency, Spring Boot (+ 조각 개념)

nan_nui·2024년 4월 16일
0

SOPT

목록 보기
2/5
post-thumbnail

1. Idempotency

Idempotency(멱등)하다는 것은, 한 번의 수행 이후 여러 번 수행해도 결과를 변경시키지 않는 기능의 속성을 말한다.

Idempotency은 멱등성이라는 뜻이다. 수학에서는 0을 여러 번 더하는 연산, 1을 여러 번 곱하는 연산, 절대값을 여러 번 취하는 연산은 결과가 여러 번 갱신되지 않으므로 멱등하다.

HTTP Method

멱등성은 리소스 관점에서 생각한다.

HTTP Method도 멱등한 것들과 멱등하지 않은 것들로 구분할 수 있다.

  1. GET
    GET은 DB에 있는 데이터를 조회하는 경우 사용하는 메소드이다.
    따라서 몇 번을 호출해도 DB의 상태가 GET 요청 호출 전후로 동일하다.
    GET은 멱등하다.

  2. POST
    POST는 새로운 자원을 생성하는 메소드이다.
    따라서 제약 조건이 없다면, 동일한 요청을 여러 번 보내면 보낸 만큼 새로운 데이터가 생성된다.
    따라서 POST는 멱등하지 않다.

  3. DELETE
    DELETE는 리소스를 삭제하는 메소드이다.
    만약 리소스를 삭제하는 요청을 보내면, 해당 리소스는 DB에서 사라질 것이다.
    동일한 요청을 여러 번 보내면, 이미 삭제된 리소스이므로 더 이상의 리소스 변경(삭제)는 일어나지 않고 DB의 상태는 DELETE 요청 전후로 동일하다.
    따라서 DELETE는 멱등하다.

  4. PUT
    PUT은 상황에 따라 리소스를 생성하거나, 대체하는 메소드이다.
    만약 PUT 요청을 보냈을 때에 해당하는 리소스가 DB에 없다면, 리소스를 새로 생성할 것이다. 그러나 그 이후부터는 동일한 PUT 요청을 보내도, 리소스가 매번 같은 내용의 리소스로 대체되므로 DB의 상태에는 변화가 없다.
    따라서 DELETE와 같은 맥락으로 PUT 메소드는 멱등하다.

  5. PATCH

    PUT은 리소스를 완전히 대체한다면, PATCH는 리소스의 일부를 수정한다.

    PATCH는 PUT과 동일한 것처럼 사용될 수도 있지만, 대체로 리소스의 일부를 수정하는 데에 사용된다.

    // [PUT과 같은 방법으로 PATCH를 사용하는 경우]
    {
    	id: 1,
    	name: "김철수",
    	age: 15  
    }
    
    PATCH /users/1
    {
    	age: 20
    }
    
    // 변경된 리소스
    {
    	id: 1,
    	name: "김철수",
    	age: 20
    }

    위와 같이, 상수 값으로 덮어 쓸 값을 body로 요청하는 PATCH 요청은 PUT과 그 동작 방식이 다를 것이 없다. 따라서 이런 경우 멱등성을 유지할 수 있지만,

    // [PATCH 사용의 다른 예]
    {
    	id: 1,
    	name: "김철수",
    	age: 15  
    }
    
    PATCH /users/1
    {
    	age: {
    		type: $inc,
    		value: 1
    	}
    }
    
    // 변경된 리소스
    {
    	id: 1,
    	name: "김철수",
    	age: 16
    }

    그러나 위와 같이 age 값이 1씩 증가하도록 구현된 PATCH 요청이라면, 여러 번 호출함에 따라 age 값이 계속해서 변경된다. 따라서 이런 경우 PATCH는 멱등하지 않다.

    따라서 PATCH는 멱등성을 보장하지 않는다.

  6. ETC
    [HEAD]
    HEAD는 GET 요청의 응답 값으로 오는 헤더를 요청하는 메소드이다.
    유효한 body 값을 가질 수 없고 DB의 리소스와 무관하므로 멱등하다.

    [OPTIONS]
    주어진 URL이나 서버에 대해 통신 허용 옵션을 요청하는 메소드이다.
    서버가 어떤 메소드를 허용하고 있는지 알기 위해 요청한다.
    마찬가지로 리소스와 무관하므로 멱등하다.

    [TRACE]
    TRACE는 Loopback test를 통한 디버깅 기능을 제공하는 메소드이다. HEAD 요청처럼 body에 값을 넣지 않으므로 멱등하다.

    [CONNECT]
    대상 리소스에 대한 서버와 양방향 통신 TCP 커넥션을 생성한다.
    매 요청마다 http 프록시를 통해 연결이 성립되어 멱등하지 않다.

안전성과 멱등성

안전성이란, 요청에 의해 DB의 리소스 값 변경 여부에 따른 것이다.

따라서, 주로 사용하는 5가지 메소드 중 GET 메소드만 안전성이 보장되며, 나머지 메소드들은 안전하지 않다.

멱등한 요청인지 알 수 있는 방법

멱등성을 보장하려면 멱등키를 API 요청에 포함한다.
동일한 멱등키를 가진 요청을 받으면 서버에서 이 요청을 중복으로 판단한 뒤 실제로 처리하지 않고 첫 요청과 같은 응답을 반환한다.
요청 본문, URL 쿼리 매개변수, 헤더 중 하나에 멱등키를 포함할 수 있지만, IETF에서는 요청 헤더에 포함하는 방법을 표준으로 제안하고 있다.

2. Spring Boot 구동 원리

위 사진은 웹 요청이 들어왔을 때 스프링 부트의 동작이다.

서블릿(Servlet)은, 클라이언트의 요청을 처리하는 자바 웹 프로그래밍 기술이다.
서블릿 컨테이너는 서블릿 객체를 생성하고 그 라이프사이클을 관리하는 역할을 수행하며, 대표적으로 Tomcat이 서블릿 컨테이너와 WAS의 역할을 한다.

서블릿 객체들은 싱글톤으로 구현되며, 멀티 스레딩을 지원한다.

Spring Boot에서는 Dispatcher Servlet이 서블릿의 역할을 수행한다.
이러한 Dispatcher Servlet의 동작은 아래와 같다.

  1. HttpServletRequest 요청이 들어오면, Dispatcher ServletHandler Mapping을 통해 요청 URL에 매핑된 Handler를 찾는다. Handler는 Spring Boot의 Controller에 해당한다.
  2. 그 후, Handler AdaptorController를 호출한다.
  3. Controller의 응답이 돌아오면 Model and View로 응답 값을 가공하여 반환한다.
  4. 만약 뷰 형식을 반환하는 Controller인 경우 ViewResolver를 통해 View를 받아 반환한다. 뷰 형식이 아닌 JSON 형식의 응답을 반환하는 경우, RestController를 사용한다.

위 사진은 RestController를 사용하는 REST API 요청 흐름이다.

Spring Boot를 사용한 구현은 대부분 JSON 응답을 반환하는 RestController를 사용하며, 그 구조는 아래와 같다.

Handler Mapping을 통해 RestController를 호출하고, ViewResolver가 아닌 MessageConverter를 통해 HTTP 응답을 받아 반환하는 것을 볼 수 있다.

3. ordinal

enum의 기본 속성 값은 ORDINAL이다.

public enum Demo {
	ZERO, // 0
    ONE, // 1
    TWO, // 2
    ;
}

위와 같이 가장 기본적인 형태로 enum 클래스를 정의할 수 있다.
이렇게 하면 각 값이 인덱스에 해당하는 정수 값을 할당 받는다.

이렇게 정의된 enum 클래스에 대해 @Enumerated(EnumType.ORDINAL)을 사용하면, JPA에서 enum을 ORDINAL 형태로 저장하게 되면서 각 요소들에 매핑된 정수 상수 값이 DB에 저장된다.

ORDINAL로 DB에 데이터를 저장하게 되면, 이후에 발생하는 변경사항에 대해 매우 취약하다.
ONE과 TWO 사이에 ONE_POINT_FIVE라는 상수가 추가된다고 해보자.

public enum Demo {
	ZERO, // 0
    ONE, // 1
    ONE_POINT_FIVE, // 2
    TWO, // 3
    ;
}

이제는 ONE_POINT_FIVE라는 상수가 2라는 값을 갖고 TWO는 3을 값으로 갖는다.
이렇게 되면, 기존에 TWO를 갖고 있던 row들은 데이터베이스에서 2라는 값을 갖고 있었으므로 ONE_POINT_FIVE가 2라는 상수 값으로 추가되면서 ONE_POINT_FIVE라는 데이터를 저장하는 것으로 이해된다.
이미 저장된 데이터의 의미가 완전히 달라지는 것이다.

따라서, ORDINAL이 아닌 STRING 값으로 DB에 저장하기 위해 @Enumerated(EnumType.STRING) 어노테이션을 사용해주어야 한다.

4. Spring Bean Lifecycle

Bean의 Lifecycle (6단계)

  1. Bean 객체 생성
  2. 의존성 주입
  3. 초기화 callback
  4. 사용
  5. 소멸 전 callback
  6. Spring 종료

이 때, 생성자 주입을 통해 Bean 객체 생성과 동시에 초기화를 진행하면 외부 커넥션을 연결하는 무거운 작업을 생성자 안에서 수행하기 때문에 생성과 초기화 로직은 분리하는 것이 좋다는 의견이 있었다.

그런데 생성자 주입을 사용하면 객체 생성과 동시에 초기화가 이루어지기 때문에 의도적으로 null을 대입하지 않는 이상 NPE(Null Pointer Exception)가 발생하지 않는다는 장점이 있다.
NPE는 치명적인 에러인데, 조금 무겁게 느껴지더라도 NPE를 피하고 Bean 객체를 final 형태로 주입 받도록 생성자 주입을 사용하는 것이 좋지 않을까? 라고 생각하였다. 정답은 없지만 더 고민해보는 것도 좋을 것 같았다.

1~6단계에서, 객체 생성 - 의존성 주입 - 사용 - 소멸 단계는 직관적으로 이해할 수 있었다. 추가로 초기화 이후와 소멸 전에 호출되는 callback 함수 호출 과정이 포함되어 있다.

Bean Lifecycle Callback

1. Initializing/Disposable Bean

public class ExampleBean implements InitializingBean, DisposableBean {
	@Override
	public void afterPropertiesSet() throws Exception {
		// 초기화 콜백 (의존관계 주입이 끝나면 호출)
	}
	@Override
	public void destroy() throws Exception {
		// 소멸 전 콜백 (메모리 반납, 연결 종료와 같은 과정)
	}
}

위와 같이 InitializingBean, DisposableBean 인터페이스를 상속 받아 초기화 콜백, 소멸 전 콜백 메소드를 오버라이딩 할 수 있다.
InitializingBean으로부터 afterPropertiesSet 메소드를, DisposableBean으로부터 destroy 메소드를 상속 받는 것이다.

afterPropertiesSet에서는 초기화 로직이 포함되고, destroy에서는 리소스 해제를 위한 close()와 같은 로직이 포함될 수 있다.

2. 설정 정보에 초기화/종료 메소드 지정

public class ExampleBean {
	public void initialize() throws Exception {  
		// 초기화 콜백 (의존관계 주입이 끝나면 호출)   
	}     

	public void close() throws Exception {       
		// 소멸 전 콜백 (메모리 반납, 연결 종료와 같은 과정)   
	}
} 

@Configurationclass 
LifeCycleConfig {    
	@Bean(initMethod = "initialize", destroyMethod = "close")  
	public ExampleBean exampleBean() {    
		// 생략   
	}
}

LifeCycleConfig라는 생애주기 관련 설정 파일을 만들어서, @Bean 어노테이션에서 초기화 메소드와 소멸 전 메소드로 지정해 줄 수 있다. 그러나 메소드 이름을 텍스트 형태로 관리하고 있기 때문에, 가독성 및 유지보수 측면에서 아쉽게 느껴진다.

3. @PostConstruct, @PreDestroy

public class ExampleBean {     
	@PostConstruct    
	public void initialize() throws Exception {
		// 초기화 콜백 (의존관계 주입이 끝나면 호출)
	}     
    
	@PreDestroy    
	public void close() throws Exception {
		// 소멸 전 콜백 (메모리 반납, 연결 종료와 같은 과정)
	}
}

위와 같이 초기화 콜백과 소멸 전 콜백에 대한 어노테이션을 사용하는 방법이 있다.
위 방법이 가장 직관적이고, 가독성 측면에서도 적합한 것 같다.
최신 스프링에서도 가장 권장하는 방법이라고 한다.

Annotations

@Transactinal

Transaction은 쪼갤 수 없는 최소 작업 단위로, @Transactional은 해당 태스크가 트랜잭션으로 동작하도록 한다.

트랜잭션의 특성은 다음과 같다.

  • 트랜잭션 내의 연산은 모두 독립적이다.
  • 트랜잭션 도중에 다른 연산을 수행할 수 없다.
  • 트랜잭션 수행 도중 오류가 발생한다면, 롤백된다.
  • 트랜잭션 내 모든 연산이 성공해야 트랜잭션이 완료된다.

@Transactional 어노테이션은 DB에서 트랜잭션 단위의 작업이 필요할 때 사용된다. 예를 들어, GET과 같은 DB 상태가 변경되지 않는 작업은 @Transactional(readOnly=true)로 명시하고, POST나 PUT처럼 DB 상태에 변화가 생기는 경우 @Transactional 어노테이션으로 명시한다.

@Transactional의 readOnly 옵션은 HTTP Method의 안전성과 관련이 있나?

readOnly 옵션이 true인 경우와 false인 경우, 발생하는 lock의 형태가 다른가?

@RestController

@RestController는 @Controller에 @ResponseBody가 추가된 것이다.

JSON 형식의 응답을 반환하는 controller이다. REST API를 개발할 때 주로 사용하며 객체를 ResponseEntity로 감싸서 반환한다.

@Test

테스트 메소드를 명시하는 어노테이션으로, 실행 결과가 DB에 영구적으로 반영되지 않는다.

테스트 어노테이션이 붙은 메소드를 실행하면, 그 동작은 테스트이므로 로직이 모두 정상 수행되더라도 그 결과가 DB에 영구적으로 반영되지 않는다.

만약 테스트 도중 의도적으로 예외가 발생하는 상황을 테스트하고 싶다면, expected 옵션을 사용할 수 있다.

@Test(expected = IllegalArgumentException.class)
void test() { ... }

...

[나중에 ... 읽어볼 것]
https://dev-coco.tistory.com/170
https://blog.neonkid.xyz/289

profile
nannui의 개발로그

0개의 댓글