본문 바로가기

Spring 프레임워크/실습

Spring HATEOAS를 이용한 REST

이 포스트는 스프링의 튜토리얼을 실습하는 내용이다.

 

Building REST services with Spring

this tutorial is designed to be completed in 2-3 hours, it provides deeper, in-context explorations of enterprise application development topics, leaving you ready to implement real-world solutions.

spring.io

흔히 RESTful API 하면 생각나는 제일 큰 특징은 json 형식의 응답이다. 기존의 웹 서버가 사용자의 요청에 대해 템플릿 렌더링이나 JSP 등을 통해 HTML 문서를 응답으로 반환했다면 API 서버에서는 json, xml 등 특정 규격으로 사용자가 요청한 데이터 그 자체를 반환해준다.

 

스프링에서는 이런 RESTful 한 응답을 생성하기 위해 @RestController나 @RequestBody 같은 어노테이션을 기본으로 제공하고 있다. 그리고 MappingJackson2HttpMessageConverter라는 변환기를 이용하여 자바 클래스를 자동으로 json 객체로 변환하여 응답할 수 있다. 물론 RESTful API의 핵심은 json 형식의 응답이 아니다.

REST

REST라는 개념은 어떤 웹 표준 같은 게 아니라 확장성 있는 웹 애플리케이션을 설계하기 위한 일종의 제약, 스타일이다. 그러나 GET "/board/read/3"처럼 URL 호출 구조를 깔끔하게 작성하거나 HTTP의 GET, POST 메서드(HTTP Verb, Method)만 열심히 사용하여 CRUD 기능들을 나열해 놓는다고 REST 한 애플리케이션인 것은 아니다. 오히려 REST보다는 RPC, Remote Procedure Call에 가까운데 이 API를 사용하는 클라이언트 측에서는 API 문서나 튜토리얼 같은 사전 지식이 없다면 이를 어떻게 이용할지 알 수 없기 때문이다.

 

그렇기 때문에 RESTful 한 서비스에서는 클라이언트가 사전 지식 없이도 이런 자원들을 탐색할 수 있도록 하이퍼미디어를 응답에 같이 제공한다. Roy Fielding은 초기 인터넷인 월드 와이드 웹에서 다양한 곳에 분산되어 있는 자원들(대부분 문서)을 이어주던 메타데이터를 활용하는 것이 WWW를 더욱 강력하게 만들었다고 생각하였다. 그래서 API를 개발할 때도 이런 특징을 적용하여 클라이언트의 요청에 대한 서버의 응답에 다른 자원에 접근할 수 있는 링크도 같이 응답으로 제공하는 것을 권장하였다.

HATEOAS

 다른 자원에 접근할 수 있는 하이퍼미디어의 가장 대표적인 예시는 HTML에 포함된 link 태그다.

<link rel="stylesheet" href="https://t1.daumcdn.net/keditor/dist/0.4.9/style/editor-plugins.css">

해당 링크에서는 자원의 위치(reference)와 자원의 관계(relation)를 명시하고 있다. 자원의 타입(type) 등을 나타내는 부가적인 정보도 포함될 수 있지만 어쨌든 이 '위치'와 '관계'가 핵심이다. 그래야 클라이언트가 사전 정보 없이도 이 링크를 이용해 다른 자원에 접근할 수 있기 때문이다.

 

스프링에서는 이런 RESTful한 서비스를 적용하기 위해 Spring HATEOAS라는 프로젝트를 제공한다. HATEOAS라는 것은 하이퍼미디어를 이용하여 네트워크 자원에 접근하는 REST 아키텍처와 관련된 개념인데 이를 지원하는 프로젝트가 Spring HATEOAS다.

EntityModel

Spring HATEOAS에서는 Link라는 클래스로 다른 자원에 접근할 수 있는 링크 하이퍼미디어를 제공한다. 이런 하이퍼미디어를 활용하여 자원을 표현하려면 RepresentationModel 클래스를 상속하는 EntityModel 템플릿 클래스를 사용할 수 있다. RepresentationModel 클래스는 Link 객체를 담을 수 있고 EntityModel 클래스에서는 해당 링크를 가진 자원 객체(content)를 담을 수 있다. Spring HATEOAS에서는 이 EntityModel을 이용하여 응답에 사용할 객체를 캡슐화하고 링크 객체를 등록하여 클라이언트에게 현재 접근할 수 있는 자원을 명시하는 RESTful 한 응답을 쉽게 생성할 수 있다.

@GetMapping("/employees")
public List<Employee> listAll(){
    return employeeRepository.findAll();
}

예를 들어 @RestController의 매핑 핸들러에서 위처럼 그냥 객체를 반환하면 다음처럼 Jackson2 컨버터에 의해 필드 이름과 필드 값만 적절히 변환돼서 출력된다.

[
    {
        "id": 1,
        "name": "Jason",
        "role": "gardener"
    },
    {
        "id": 2,
        "name": "Kwonkyu",
        "role": "programmer"
    }
]

하지만 다음처럼 Spring HATEOAS를 적용하면 링크도 추가되는 것을 볼 수 있다.

List<EntityModel<Employee>> listAll(){

    return employeeRepository.findAll().stream().map(employee -> {
        // Java Stream을 이용하여 각 Employee 객체의 엔티티 모델 생성.
        EntityModel<Employee> entityModel = EntityModel.of(employee);
        // 각 엔티티 모델마다 "/employees", "/employees/{id}" 링크 추가.
        entityModel.add(Link.of("/employees", "list"));
        entityModel.add(Link.of("/employees/"+employee.getId(), "detail"));
        return entityModel;
    // 컬렉션으로 반환.
    }).collect(Collectors.toList());
}
[
    {
        "id": 1,
        "name": "Jason",
        "role": "gardener",
        "links": [
            {
                "rel": "list",
                "href": "/employees"
            },
            {
                "rel": "detail",
                "href": "/employees/1"
            }
        ]
    },
    {
        "id": 2,
        "name": "Kwonkyu",
        "role": "programmer",
        "links": [
            {
                "rel": "list",
                "href": "/employees"
            },
            {
                "rel": "detail",
                "href": "/employees/2"
            }
        ]
    }
]

위의 코드에서는 Employee 객체를 EntityModel로 감싸서 리스트로 반환하고 있는데 모델의 add 메서드로 담긴 Link 객체들이 반환 값에 links 배열로 들어있는 것을 볼 수 있다. 각 링크에는 링크 객체 생성 시 매개변수로 정의한 위치(href)와 관계(rel)가 담겨있으며 클라이언트 측에서는 이 정보를 이용하여 다른 자원들에 접근하거나 서비스를 이용할 수 있는 것이다.

WebMvcLinkBuilder

하지만 위처럼 다른 자원에 접근하는 링크를 일일이 하드코딩으로 지정하면 해당 링크가 변경되었을 때 유연하게 대처하기 힘들다. 이때는 WebMvcLinkBuilder 클래스의 linkTo, methodOn 정적 메서드를 사용하여 다음처럼 특정 컨트롤러 클래스의 Mapping 메서드를 기반으로 자동으로 링크를 생성할 수 있다.

public EntityModel<Employee> getDetailEmployee(@PathVariable Long id){
    
    // id에 해당하는 Employee 객체의 엔티티 모델 생성.
    return EntityModel.of(employeeRepository.findById(id),
            // EmployeeController의 getDetailEmployee 메서드에 대한 자기 자신(self) 링크 생성.
            linkTo(methodOn(EmployeeController.class).getDetailEmployee(id)).withSelfRel(),
            // EmployeeController의 listAll 메서드에 대한 'list' 링크 생성.
            linkTo(methodOn(EmployeeController.class).listAll()).withRel("list")
            );
}
{
    "id": 1,
    "name": "Jason",
    "role": "gardener",
    "_links": {
        "self": {
            "href": "http://localhost:8080/employees/1"
        },
        "list": {
            "href": "http://localhost:8080/employees"
        }
    }
}

linkTo 메서드는 말 그대로 해당 자원에 대한 링크 객체를 생성하며 methodOn 메서드는 해당 클래스의 메서드를 참조하는 데 사용된다. 즉 'linkTo(methodOn(EmployeeController.class).listAll()).withRel("list")' 표현식은 EmployeeController 클래스의 listAll 메서드에 대하여 링크 객체를 생성하고 "list" 관계(relation)로 해당 EntityModel에 추가하라는 것을 의미한다.

 

이 경우 listAll 메서드의 GetMapping 주소를 따로 지정하지 않아도 "http://localhost:8080/employees"처럼 링크가 생성되는 것을 볼 수 있다. 클라이언트 측에서는 이 "list" 관계로 주어진 URL을 이용하여 특정 서비스(여기서는 employee들의 목록 조회)를 실행할 수 있다.

 

해당 자원에 접근할 수 있는 링크를 나타내는 "self" 링크는 withSelfRel 메서드로 등록할 수 있다. 이는 해당 자원에 접근할 수 있는 URI를 클라이언트에게 제공해야 한다.

 

이처럼 모델에 담기는 링크 객체들은 현재 서비스에서 접근할 수 있는 서비스나 자원들에 대한 참조로 이루어져야 클라이언트 측에서 어떤 서비스를 사용할 수 있는지 알 수 있다. 이는 서비스를 좀 더 RESTful 하게 만드는 방법 중 하나다.

 

링크 객체들은 "_links"라는 배열에 담겨서 반환되는데 Spring HATEOAS에서 사용하는 HAL이라는 언어의 특성이라고 한다. 실제로 응답 헤더를 확인해보면 Content-Type 헤더에 "application/hal+json"이라고 표시된 것을 볼 수 있다.

CollectionModel

이런 엔티티 모델을 좀 더 캡슐화해서 관리하거나 별도로 링크를 추가하고 싶다면 CollectionModel이라는 것을 사용할 수 있다. 이 클래스는 Spring HATEOAS에서 사용되는 컨테이너 클래스로 EntityModel이나 CollectionModel 같은 여러 타입을 캡슐화할 수 있다. 이는 HAL에서 컬렉션(collection)으로 제공되며 다음처럼 변환된다.

CollectionModel<EntityModel<Employee>> listAll(){

    // 각 Employee 객체마다 엔티티 모델 생성
    List<EntityModel<Employee>> detail = employeeRepository.findAll().stream().map(employee -> {
        return EntityModel.of(employee,
                // 각 엔티티 모델마다 링크 추가.
                linkTo(methodOn(EmployeeController.class).getDetailEmployee(employee.getId())).withSelfRel();
                linkTo(methodOn(EmployeeController.class).listAll()).withRel("list"));
    }).collect(Collectors.toList());

    // 엔티티 모델들과 별개로 listAll 메서드 링크 추가.
    return CollectionModel.of(detail,
            linkTo(methodOn(EmployeeController.class).listAll()).withSelfRel());
}
{
    "_embedded": {
        "employeeList": [
            {
                "id": 1,
                "name": "Jason",
                "role": "gardener",
                "_links": {
                    "self": {
                        "href": "http://localhost:8080/employees/1"
                    },
                    "list": {
                        "href": "http://localhost:8080/employees"
                    }
                }
            },
            {
                "id": 2,
                "name": "Kwonkyu",
                "role": "programmer",
                "_links": {
                    "self": {
                        "href": "http://localhost:8080/employees/2"
                    },
                    "list": {
                        "href": "http://localhost:8080/employees"
                    }
                }
            }
        ]
    },
    "_links": {
        "self": {
            "href": "http://localhost:8080/employees"
        }
    }
}

기존의 엔티티 모델들은 "_embedded"라는 요소 아래의 "employeeList"에 등록되었다. "_embedded"는 HAL에서 최상위 컬렉션을 나타내는 데 사용되는 이름이다. "_links" 필드에는 listAll 메서드의 링크가 있는데 이는 메서드 마지막에서 CollectionModel 클래스의 of 메서드 맨 마지막에 추가된 링크인 것을 볼 수 있다.

RepresentationModelAssembler

그런데 이렇게 Employee 객체를 EntityModel<Employee>로 변환하는 과정을 매번 일일이 코드로 작성해야 할까? 동일한 코드가 두 번 이상 반복된다면 이를 별도의 함수나 클래스로 빼내는 것도 생각해 볼 만하다. 그래서 Spring HATEOAS에서는 RepresentationModelAssembler 인터페이스를 제공한다.

 

이 템플릿 인터페이스는 toModel, toCollectionModel 메서드만 정의하고 있다. 말 그대로 매개변수로 받은 객체를 RepresentationModel로 변환하는 역할이며 앞서 사용했던 EntityModel이 이 RepresentationModel을 상속받고 있기 때문에 다음처럼 사용할 수 있다.

@Component
public class EntityToModelConverter implements RepresentationModelAssembler<Employee, EntityModel<Employee>> {
    @Override
    public EntityModel<Employee> toModel(Employee employee) {
        return EntityModel.of(employee,
                linkTo(methodOn(EmployeeController.class).getDetailEmployee(employee.getId())).withSelfRel(),
                linkTo(methodOn(EmployeeController.class).listAll()).withRel("detail"));
    }
}
@GetMapping("/employees/{id}")
public EntityModel<Employee> getDetailEmployee(@PathVariable Long id){
    return entityToModelConverter.toModel(employeeRepository.findById(id).get());
}
{
    "id": 1,
    "name": "Jason",
    "role": "gardener",
    "_links": {
        "self": {
            "href": "http://localhost:8080/employees/1"
        },
        "detail": {
            "href": "http://localhost:8080/employees"
        }
    }
}

Java 8의 map을 활용하면 여러 개의 엔티티 모델도 간편하게 변환할 수 있다.

@GetMapping("/employees")
List<EntityModel<Employee>> listAll(){

    return employeeRepository.findAll()
        .stream()
        .map(entityToModelConverter::toModel)
        .collect(Collectors.toList());
}
[
    {
        "id": 1,
        "name": "Jason",
        "role": "gardener",
        "links": [
            {
                "rel": "self",
                "href": "http://localhost:8080/employees/1"
            },
            {
                "rel": "detail",
                "href": "http://localhost:8080/employees"
            }
        ]
    },
    {
        "id": 2,
        "name": "Kwonkyu",
        "role": "programmer",
        "links": [
            {
                "rel": "self",
                "href": "http://localhost:8080/employees/2"
            },
            {
                "rel": "detail",
                "href": "http://localhost:8080/employees"
            }
        ]
    }
]

이런 여러 가지 Spring HATEOAS의 기능을 이용하면 하이퍼미디어를 활용한 RESTful API 서비스를 훨씬 더 효율적으로 개발할 수 있다는 장점이 있다.

Hypermedia As The Engine Of Application State

그래서 정말로 HATEOAS가 무엇일까? 위처럼 단순히 링크만 제공해 준 게 HATEOAS라고 할 수 있을까? 서비스에 HATEOAS를 적용하려면 이 하이퍼미디어, 링크만 제공하는 게 아니라 이를 이용하여 현재 애플리케이션의 상태를 표현할 수 있어야 한다.

 

예를 들어 어떤 주문 결제 시스템이 있다고 하자. 이 시스템은 주문 -> 결제 프로세스를 가지고 있고 어디서든 접근할 수 있는 취소 프로세스도 있다고 할 때 각 기능별 컨트롤러에서 Enum 타입으로 현재 상태를 관리하면서 주문 도메인을 제공할 수도 있을 것이다. 그러면 클라이언트는 해당 요청의 응답으로 받은 데이터를 파싱 해서 이후의 작업을 결정할 수 있다. 하지만 만약 새로운 프로세스가 추가되거나 프로세스 자체의 이름이 변경된다면 어떨까? 이는 클라이언트에게 직접적으로 영향을 끼칠 것이다.

 

그렇기 때문에 클라이언트가 자신의 요청으로 받은 json 응답을 파싱 해서 접근 가능한 자원, 서비스를 결정하도록 하는 게 아니라 서버 측에서 현재 상태에 따라 접근 가능한 자원, 서비스에 대한 링크만 클라이언트에게 제공하여 상태 기반 행위(state-based actions)를 데이터와 분리하는 방식이 HATEOAS다.

 

간단하게 말하면 클라이언트가 사전에 서버의 주문, 결제, 취소 프로세스로 접근하기 위한 링크를 미리 인지하고 애플리케이션을 개발하는 게 아니라 서버의 주문 프로세스로 접근할 경우 응답으로 주문 프로세스에서 접근 가능한 링크를 받아서 이를 기반으로 개발하는 것이다. 주문 프로세스에서는 물품 선택, 주문 취소 등의 링크만 제공하고 결제 프로세스에서는 결제 방식 선택, 결제 취소 등 필요한 링크만 제공하는 것이다.

 

그렇기 때문에 클라이언트 측에서는 이 프로세스가 현재 접근하는 프로세스에서 접근 가능한 프로세스인지 사전에 검증해야 할 필요가 없고 잘못 접근하는 일을 방지할 수 있다. 그리고 서버 측에서는 현재 프로세스에 따라 EntityModel에 적절한 링크를 삽입하는 방식으로 구현할 수 있다. 대신 HTTP 응답에 상태 코드와 헤더를 적극 활용해서 현재 수행한 작업에 어떤 결과가 발생했는지를 클라이언트에게 명확히 전달할 수 있어야 한다.

Evolving REST

REST에서 또 하나 중요한 점은 확장성이다. RESTful API를 개발해서 수많은 클라이언트들이 사용하고 있을 때 갑자기 사용 스펙이 바뀐다면 그 영향은 해당 API 서비스를 이용하는 클라이언트들에게 직접적으로 영향을 끼치게 된다. 예를 들어 위의 json API에서 name 필드가 삭제되고 firstName, lastName 필드로 분리돼야 한다면 어떨까? name 필드를 사용하는 모든 클라이언트에서 오류가 발생할 것이다.

Never delete a column in a database.

그래서 RESTful 서비스에서는 위와 같은 원칙(또는 미신)을 준수해야 한다. 즉 firstName, lastName 필드가 추가된다고 기존 name 필드와 관련된 코드를 전부 삭제하지 않고 서로 호환될 수 있도록 변경하여 하위 호환성을 챙기는 것이다. 예를 들어 name 필드에는 성과 이름이 합쳐진 이름이 저장됐고 새로 생긴 firstName, lastName 필드에는 각각 이름과 성이 저장돼야 한다고 할 때 다음처럼 변경할 수 있을 것이다.

private String name;

public void setName(String name) {
    this.name = name;
}

public String getName() {
    return name;
}


// ==============>

public String firstName;
public String lastName;

public void setName(String name) {
    String[] names = name.split(" ");
    firstName = names[0];
    lastName = names[1];
}

public String getName() {
    return firstName + " " + lastName;
}

이렇게 기존의 name 필드를 이용할 때 참조하던 getName, setName 메서드를 삭제하지 않고 내부 로직을 변경하여 일종의 가상 getter, setter 메서드로 남겨둔다. 그러면 새로 추가된 firstName, lastName과도 호환될 수 있다.

 

그냥 보기에는 별 의미가 없어 보이지만 클라이언트 측에서는 기존처럼 name 필드가 있다고 가정하고 서비스를 계속 사용할 수 있다.

ResponseEntity

RESTful 서비스를 이용하여 자원을 생성하거나 삭제, 조회할 경우 그에 대한 응답은 단순히 성공(200 OK)과 실패(400 Bad Request)로만 나뉘어선 안된다. POST 메서드로 자원을 생성한 경우 성공 시 201 Created가 반환된다던가, 자원을 찾지 못했다면 404 Not Found를 HTTP 상태 코드로 가지는 응답을 반환해야 한다. 그 외에도 요청에 의해 서버 측에 성공적으로 자원이 생성됐다면 Location 헤더에 생성된 자원에 접근할 수 있는 URL을 제공해야 한다. 이렇듯 HTTP Response의 바디뿐 아니라 헤더도 조작해야 할 필요가 있다면 스프링의 ResponseEntity 클래스를 사용할 수 있다.

public ResponseEntity<?> insert(@RequestBody Employee employee){
    // 이전에 작성한 컨버터를 이용해서 Employee 객체를 엔티티 모델로 변환.
    EntityModel<Employee> entityModel = entityToModelConverter.toModel(employeeRepository.save(employee));

    // 요청에 대한 응답으로 ResponseEntity 객체를 반환.
    return ResponseEntity
            // 201 Created 응답 및 생성된 자원의 주소를 Location 헤더에 등록.
            .created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri())
            // HTTP Response의 Body에는 위에서 변환한 엔티티 모델 저장.
            .body(entityModel);
}

위의 코드에서는 생성된 자원의 주소를 HTTP Location 헤더에 등록하여 반환하고 있다. 성공적으로 자원을 생성했다는 응답, 정확히는 BodyBuilder를 생성하는 created 메서드는 매개변수로 Location 헤더에 등록할 URL 주소를 받는다. 이는 HTTP 응답이 Redirection일 때는 Location 헤더가 브라우저에게 이동할 위치를 가리키는 역할이지만 Created일 때는 생성된 자원의 주소를 가리키는 역할이기 때문이다. 그래서 클라이언트는 다른 정보가 필요 없이 이 헤더 값만으로 자신이 생성한 자원에 접근할 수 있으며 이 역시 REST의 특성에 해당한다.

 

생성된 자원의 주소는 EntityModel 클래스가 상속하는 RepresentationModel의 getRequiredLink 메서드를 이용하여 얻을 수 있는데 이는 매개변수로 미리 정의된 SELF 상수값을 넘기면 EntityModel 생성 시 "self" 링크로 등록된, 즉 자기 자신에 접근할 수 있는 주소를 반환한다. 그럼 언제 이 "self" 링크를 등록했던 것일까?

linkTo(methodOn(EmployeeController.class).getDetailEmployee(employee.getId())).withSelfRel(),

이전에 작성했던 코드에서 링크의 관계를 설정할 때 붙였던 withSelfRel 메서드가 바로 이 자기 자신을 나타내는 URI를 지정하는 역할을 한다.

/*
 * (non-Javadoc)
 * @see org.springframework.hateoas.LinkBuilder#withSelfRel()
 */
public Link withSelfRel() {
	return withRel(IanaLinkRelations.SELF);
}

정적 메서드 linkTo, methodOn를 사용하면서 얻은 WebMvcLinkerBuilder의 메서드인 withSelfRel은 실제로 코드를 살펴보면 링크의 관계(LinkRelation)를 설정하는 withRel 메서드에 내부적으로 SELF 값을 설정해서 넘기는 것을 볼 수 있다.

{
    "id": 4,
    "name": "Haruhi",
    "role": "leader",
    "_links": {
        "self": {
            "href": "http://localhost:8080/employees/4"
        },
        "detail": {
            "href": "http://localhost:8080/employees"
        }
    }
}

실제로 생성된 자원을 살펴보면 Location 헤더에 "self" 링크 값이 설정된 것을 볼 수 있다.

 

비슷하게 자원을 삭제하는 DELETE 메서드에는 204 No Content, 자원을 조회하는 GET 메서드에는 자원이 없을 때 발생하는 404 Not Found를 설정하면 된다. 이처럼 HTTP 프로토콜의 각종 메서드와 헤더 값을 적절하게 이용하며 자원을 다루는 것이 RESTful 한 서비스를 제작하는 기본적인 발걸음이 될 것이다.

 

 

 

[참고 | spring.io/guides/tutorials/rest/]

[참고 | docs.spring.io/spring-hateoas/docs/1.2.4/reference/html/#fundamentals]

'Spring 프레임워크 > 실습' 카테고리의 다른 글

Spring의 DB  (0) 2021.03.20
Spring에서 illegal reflective access 경고문 해결  (0) 2021.02.21