HTTP ETag

SexyWoong·2023년 11월 24일
0

spring

목록 보기
2/11

ETag

  • 클라이언트(예: 모바일 디바이스, 브라우저 등)가 이전에 요청했던 데이터와 최신 데이터의 변경사항 유무를 검증하는데 사용하는 HTTP 응답헤더이다.
  • 클라이언트가 서버로부터 리소스를 요청할 때 응답 헤더로 Etag 값이 반환된다.
    • GET요청과 HEAD요청에서 사용된다.

목적

  • 리소스의 상태가 변경되었는지 확인하고, 변경되었다면 서버로부터 새로운 리소스를 전달받고 변경되지 않았다면 캐시된 리소스를 사용함으로써 네트워크 비용을 줄이는 것이다.

흐름

  1. 처음 클라이언트가 서버로부터 리소스를 받기 위해 요청을 한다.
  2. 서버는 리소스와 함께 ETag를 응답 헤더에 넣어 응답한다.
  3. 클라이언트는 이후 요청 시 요청 헤더 If-None-Match에 이전에 서버로부터 전달받은 ETag값과 함께 서버로 요청을 한다.
  4. 서버는 클라이언트로 부터 받은 요청 헤더의 If-None-Match값을 통해 리소스가 변경되었는지 확인한다. 변경되었을 경우 변경된 리소스와 새로운 ETag를 클라이언트에게 응답해주고 그렇지 않은 경우 304 Not Modified응답을 해준다. 이 경우에는 body에 아무런 값이 담겨져 있지 않기 때문에 데이터 전송량이 줄어들어 네트워크 비용이 싸다.(네트워크 효율성 증가)

ETag 생성

ETag(Entit Tag)의 생성 방법은 여러가지가 있지만 보통 해시 기반의 방법을 사용한다.

  • 서버는 리소스의 내용을 해시 함수를 통해 처리하여 ETag를 생성한다.

  • 보통 파일 내용의 MD5 또는 SHA1해시가 ETag값으로 사용되고 이는 리소스의 내용(응답의 body)이 변경될때 마다 ETag의 내용이 변경되는것을 보장한다. -> 리소스마다 ETag가 모두 다르다!

위 글을 통해 알 수 있는 사실은 서버에서 response를 다 만든 후 응답값을 바탕으로 Etag를 생성한다. 그리고 request의 헤더 중 If-None-Match 값과 비교하여 동일하면 304응답을 해주고 그렇지 않으면 body에 값을 넣어 응답하는것을 알 수 있다.

  • SpringBoot에서는 ShallowEtagHeaderFilter 에서 ETag표준 기능을 구현하고 있고 생성 시 MD5 해시 함수를 사용하고 있다. 이는 서버가 응답 본문을 해시하여 ETag 값을 계산하고, 클라이언트의 요청에 If-None-Match 헤더가 있는 경우 이 값을 비교하여 적절히 응답하는 방식으로 작동한다.

Springboot

Configuration

1.

@Bean
public ShallowEtagHeaderFilter shallowEtagHeaderFilter() {
    return new ShallowEtagHeaderFilter();
}

2.

@Bean
public FilterRegistrationBean<ShallowEtagHeaderFilter> shallowEtagHeaderFilter() {
    FilterRegistrationBean<ShallowEtagHeaderFilter> filterRegistrationBean
      = new FilterRegistrationBean<>( new ShallowEtagHeaderFilter());
    filterRegistrationBean.addUrlPatterns("/foos/*");
    filterRegistrationBean.setName("etagFilter");
    return filterRegistrationBean;
}

위 1 또는 2의 설정을 해주면 된다.(Bean으로 등록하면 된다.)
1번 설정의 경우 ShallowEtagHeaderFilter 인스턴스를 Bean으로 등록한다. 이는 모든 URI에 대한 요청에 필터를 적용하고 모든 설정을 스프링에 의존한다는 설정이다.

2번 설정의 경우 FilterRegistrationBean인스턴스를 Bean으로 등록한다. 이는 필터가 적용될 URI를 구체적으로 명시해줄 수 있다. 위 예시에서는 '/foos/'로 시작하는 모든 URI에 적용할 수 있다는 의미이다.
setName메서드를 통해서 필터의 이름을 지정하여 관리 및 추적도 할 수 있다는 장점이 있다.

따라서 특정 URI에 대한 요청에 필터를 적용하고 싶다면 2번 설정을, 모든 요청에 대해 필터를 적용하고 싶다면 1번 설정을 해주면 된다.

참고) 모든 요청이라 해도 GET 요청에만 필터가 적용되도록 내부에 설정되어 있으므로 다른 요청 메서드에 대해서는 적용이 안된다.

Code

Foo Entity

package hello.etag;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;

@Entity
public class Foo {

    @Id
    @GeneratedValue
    private Long id;

    private String fooName;

    protected Foo() {
    }

    public Foo(String fooName) {
        this.fooName = fooName;
    }

    public Long getId() {
        return id;
    }

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

    public String getFooName() {
        return fooName;
    }

    public void setFooName(String fooName) {
        this.fooName = fooName;
    }
}

Foo 엔티티를 작성해주었다. id와 fooName을 변수로 선언해주었다.

Controller

package hello.etag;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;

@RestController
@RequestMapping(value = "/foos")
public class EtagController {

    private final FooService fooService;

    public EtagController(FooService fooService) {
        this.fooService = fooService;
    }

    @GetMapping(value = "/{id}")
    public ResponseEntity<Foo> findByIdWithCustomEtag(@PathVariable("id") final Long id) {

        Foo foo = fooService.find(id);
        System.out.println("foo = " + foo);
        System.out.println("foo.getId() = " + foo.getId());
        System.out.println("foo.getFooName() = " + foo.getFooName());

        return ResponseEntity.ok()
            .body(foo);
    }

    @PostMapping
    public ResponseEntity<Void> save(@RequestParam(value = "foo-name") String fooName) {
        fooService.save(fooName);

        return ResponseEntity.ok().build();
    }

    @PatchMapping("/{id}")
    public ResponseEntity<Void> update(@PathVariable Long id, @RequestParam(value = "foo-name") String fooName) {
        Long updatedFooId = fooService.update(id, fooName);
        URI location = URI.create("http://localhost:8080/foos/" + updatedFooId);

        return ResponseEntity.created(location).build();
    }
}

컨트롤러는 위와 같이 간단하게 저장, 수정, 조회할 수 있는 엔드포인트를 작성하였다.

Service

package hello.etag;

import jakarta.persistence.EntityNotFoundException;
import jakarta.transaction.Transactional;
import org.springframework.stereotype.Service;

@Service
public class FooService {

    private final FooRepository fooRepository;

    public FooService(FooRepository fooRepository) {
        this.fooRepository = fooRepository;
    }

    public void save(String fooName) {
        Foo foo = new Foo(fooName);
        fooRepository.save(foo);
    }

    public Foo find(Long id) {
        return fooRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 id"));
    }

    @Transactional
    public Long update(Long id, String fooName) {
        Foo savedFoo = fooRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 id"));
        savedFoo.setFooName(fooName);

        return savedFoo.getId();
    }
}

Repository

repository는 dataJPA를 사용하였기에 생략한다.

확인

데이터 저장

POST요청을 보내어 이름이 sexywoong인 데이터를 저장하였다.

데이터 조회


생성된 데이터의 id가 1이므로 위의 URI을 통해 조회를 하였다.

응답코드는 200인걸 확인할 수 있고, Etag도 응답 헤더에 담겨있는것을 확인할 수 있다.

데이터 재요청

한번 더 재요청을 하면 응답 코드가 304 Not Modified인것을 확인할 수 있고 Etag의 값이 이전 값과 동일한것을 볼 수 있다.

데이터 수정


이번엔 위 처럼 이름을 woong으로 수정을했다.

데이터 조회

다시 데이터를 조회하면 응답 body의 데이터가 변경되었으므로 (응답 body 데이터를 통해 Etag를 생성하므로) Etag가 변경되었고 위에서 조회했을 때 004로 시작하는 Etag와는 다른 새로운 Etag가 응답 헤더에 담긴것을 볼 수 있다.

Test

package hello.etag;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
class EtagControllerTest {

    @Autowired
    FooService fooService;

    @Autowired
    MockMvc mockMvc;


    @Test
    @DisplayName("처음 조회 시 ETag가 헤더에 존재한다..")
    void 한번_조회시_ETag_존재확인() throws Exception{
        // Given
        fooService.save("SexyWoong");
        String uriOfResource = "/foos/{id}";

        // When && Then
        mockMvc.perform(get(uriOfResource, 1))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(header().exists("ETag"));
    }

    @Test
    @DisplayName("동일한 리소스를 두번 조회할 경우 응답코드는 304이고 body에는 값이 없다.")
    void 동일_리소스_두번조회시_body에_데이터_없음() throws Exception{
        // Given
        fooService.save("SexyWoong");
        String uriOfResource = "/foos/{id}";

        // When && Then
        MvcResult mvcResult = mockMvc.perform(get(uriOfResource,1))
            .andExpect(status().isOk())
            .andReturn();

        // 응답 객체에서 데이터 추출
        String eTag = mvcResult.getResponse().getHeader("ETag");

        mockMvc.perform(get(uriOfResource, 1).header("If-None-Match", eTag))
            .andDo(print())
            .andExpect(status().isNotModified())
            .andExpect(header().string("ETag", eTag))
            .andExpect(content().string(""));

    }
}



참고

profile
함께 있고 싶은 사람, 함께 일하고 싶은 개발자

0개의 댓글