본문 바로가기

프로젝트 이야기/MyLeague API

[MyLeague] Spring HATEOAS 적용한 향상된 REST API 개발

REST API

REST API는 자원의 이름HTTP Method로 요청을 구분하여 자원의 상태(또는 정보)를 주고 받는 API를 의미합니다.

 

예를 들어 선수(Player)들의 정보를 받고 싶다면 다음과 같이 요청할것입니다 : GET /players

또는 1번 선수를 받고 싶다면 다음과 같이 하면 되겠죠 : GET /players/1

그렇다면 1번 선수의 team은 어떻게 하면 좋을까요? : GET /players/1/team

 

이렇듯 REST API는 주고 받을 정보를 Http Method와 자원의 이름으로 구성된 url을 보고 구분하게 됩니다.

 

위 사진은 제가만든 MyLeague API 문서 중 Player에 관련된 상태,정보를 다루는 파트입니다. 보다시피 Http Method와 자원 정보를 통해 자원에 접근합니다.

 

REST API는 메세지를 읽는 것만으로도 어떤 요청과 응답인지를 구분할 수 있는 매우 높은 가독성을 지니고 있습니다.

 

또한 서버와 클라이언트를 완전히 분리할 수 있다는것도 큰 장점입니다. 클라이언트와 서버의 분리는 민감한 데이터는 서버에서만 다루고 클라이언트는 제한된 접근만 허용함으로써 보안성을 높이고, 클랑리언트와 서버의 책임을 선명하게 구분하여 프로젝트의 유지보수성 확장성을 향상시킵니다.


 

그냥 이렇게 하면 끝난건가???

 

위에서 REST API의 가장 큰 장점은 서버와 클라이언트의 분명한 분리에 있다고 설명했습니다. 즉, 서버 내부 동작이 다양한 요청들로 캡슐링돼서 클라이언트는 내부 로직을 몰라도 정해진 메세지를 통해 자원의 상태나 정보를 주고 받을 수 있습니다.

 

서버와 클라이언트의 완벽한 분리라는 관점에서 하나의 걸림돌이 있습니다. 아래 상황을 생각해봅시다.

 

서버가 내부 정책이 변경돼서 선수의 포지션명이 변경됐다고 해봅시다. 기존에는 원딜 포지션을 BOT이라고 표현했지만, 이제는 ADC로 표현합니다. 자연스럽게 요청 url도 변경됩니다.

 

원딜 포지션 선수를 모두 가져옴
기존 : GET /players/position/BOT
변경 : GET / players/position/ADC

 

서버는 당연히(?) 아무 문제 없겠지만 클라이언트에는 중차대한 문제가 발생합니다. 평소에 원딜 포지션을 잘 가져오던 부분이 난데없이 404Error를 발생시키는 것입니다!

 

서버의 API 메세지 수정은 클라이언트의 변경을 불러옵니다. 이것은 객체지향적으로 봤을때 좋지 못한 현상입니다. 그렇다면 어떻게 하는게 좋을까요? 그 중 하나의 해법이 서버가 클라이언트에 응답을 보낼때 내부 링크 정보를 담아 보내는 것입니다. 이것이 오늘 소개한 Spring HATEOAS의 핵심 기능입니다.


 

링크(link)

클라이언트가 선수 전체 목록 화면에 있다고 합시다. 여기서 각 포지션 버튼을 누르면 해당 포지션 선수 목록이 나옵니다. 또는 어떤 선수의 팀을 누르면 해당 팀의 선수가 모두 나옵니다. 이런 식으로 하나의 자원 상태에서 다른 상태로 넘어가는 것을 전이라고 합니다. 이때 전이를 하기 위해서 다른 자원의 상태를 가져오는 메세지(또는 요청)가 필요할텐데 이것을 링크(Link) 라고 합니다.

 

서버가 Key-value 형태로 자원의 상태와 함께 전이될 수 있는 링크의 정보를 보냅니다. 그러면 해당 링크의 정보를 통해 클라이언트는 전이를 시도합니다. 그렇다면 서버에서 여러 요청 메세지 정책이 변경돼도 대응할 수 있습니다.

 

e.g. GET /players/1

 

기존

{
    "id": 1,
    "name": "kiin",
    "position": "TOP",
    "teamId": "3"
}

변경

{
    "id": 1,
    "name": "kiin",
    "position": "TOP",
    "teamId": 3
    "links": [
        {
            "rel": "self",
            "href": "http://localhost:8080/players/1"
        },
        {
            "rel": "players",
            "href": "http://localhost:8080/players"
        },
        {
            "rel": "positionPlayers",
            "href": "http://localhost:8080/players/position/TOP"
        },
        {
            "rel": "teamPlayers",
            "href": "http://localhost:8080/players/team/3"
        },
        {
            "rel": "team",
            "href": "http://localhost:8080/teams/3"
        }
    ]
}

기존 응답 메세지는 요청한 자원의 상태만 전달하지만, 변경된 응답 메세지는 링크 정보를 포함해 전송합니다. 그러면 클라이언트는 "link"
의 "rel"값을 통해 요청 url에 접근합니다. 즉, 요청 url의 형식이 바뀌어도 클라이언트는 올바른 요청 url로 전송할 수 있는것입니다.

 

이런 식으로 애플리케이션의 상태 전이를 구현하는 매커니즘을 HATEOAS(Hypermedia As The Engine Of Application State) 라고 합니다.


 

Spring HATEOAS

 

Spring HATEOAS는 HATEOAS 매커니즘을 Spring 환경에서 구현할 수 있게끔 해줍니다.

dependencies {  
   implementation 'org.springframework.boot:spring-boot-starter-hateoas'  
}

다음과 같이 build.gradle 파일에 의존성을 추가해줍니다.

 

EntityModel은 Spring HATEOAS가 제공하는 응답 객체입니다. 응답 바디에 들어갈 자원의 상태링크 정보를 담습니다.

 

다음은 MyLeague API에서 /participants/{id} 요청에 대한 응답 객체를 생성하는 코드입니다.

EntityModel.of(participantDto,  
        linkTo(methodOn(ParticipantController.class).one(participant.getId())).withSelfRel(),  
        linkTo(methodOn(ParticipantController.class).all()).withRel("participants"),  
        linkTo(methodOn(ParticipantController.class).allRecords(participant.getId())).withRel("records"),  
        linkTo(methodOn(TeamController.class).one(participant.getTeam().getId())).withRel("team"));

linkTo 메서드를 통해 요청 url과 이름인 key값의 역할을 하는 rel값을 매핑하여 추가합니다. methodOn은 컨틀롤러 메서드로부터 요청 URL을 리턴합니다.


구현

모든 요청을 자원의 상태를 반환하는 요청은 Spring HATEOAS가 제공하는 EntityModel 객체를 응답 객체로 리턴해야 합니다. 그중에서 Participant 도메인에 관한 요청을 살펴보겠습니다.

 

다음은 Particiant 도메인을 단일 요청했을때 응답하는 객체입니다. Participant의 정보를 ParticipantDto에 담고, 링크정보를 담아 전송합니다.

EntityModel.of(participantDto,  
        linkTo(methodOn(ParticipantController.class).one(participant.getId())).withSelfRel(),  
        linkTo(methodOn(ParticipantController.class).all()).withRel("participants"),  
        linkTo(methodOn(ParticipantController.class).allRecords(participant.getId())).withRel("records"),  
        linkTo(methodOn(TeamController.class).one(participant.getTeam().getId())).withRel("team"));

이때 요청 받은 상태인 Participant 도메인을 응답할 수 있는 형식인 EntityModel로 변환하는 책임을 인터페이스로 위임하여 코드를 간소화할 수 있습니다. 이때 Spring HATEOAS가 제공하는 RepresentationModelAssembler인터페이스를 구현하여 사용하게 됩니다.

@Component  
public class ParticipantAssembler implements RepresentationModelAssembler<Participant, EntityModel<ParticipantDto>> {  

    @Override  
    public EntityModel<ParticipantDto> toModel(Participant participant) {  

        ParticipantDto participantDto = new ParticipantDto(  
                participant.getId(),  
                participant.getTeam().getId(),  
                participant.getLeague().getId(),  
                participant.getTotalRank());  

        return EntityModel.of(participantDto,  
                linkTo(methodOn(ParticipantController.class).one(participant.getId())).withSelfRel(),  
                linkTo(methodOn(ParticipantController.class).all()).withRel("participants"),  
                linkTo(methodOn(ParticipantController.class).allRecords(participant.getId())).withRel("records"),  
                linkTo(methodOn(TeamController.class).one(participant.getTeam().getId())).withRel("team"));  
    }  

}

RepresentationModelAssembler 인터페이스를 구현하기 위해서는 toModel 메서드를 오버라이딩해야 합니다. toModel 메서드는 파라미터로 받은 객체의 상태를 기반으로 dto 객체를 생성해 링크 정보를 담아 EntityModel 객체를 생성해 리턴합니다.

 

다음은 ParticipantController.java에서 참가팀 단건 조회를 수행하는 메서드이다.

@ApiOperation(value = "참가팀 단건 조회", notes = "입력 받은 id에 해당하는 참가팀을 조회합니다")  
@GetMapping("/participants/{id}")  
public EntityModel<ParticipantDto> one(  
        @ApiParam(value = "참가팀 아이디", required = true) @PathVariable Long id) {  

    Participant participant = participantRepository.findOne(id);  

    return participantAssembler.toModel(participant);  
}

요청에 따라 전달받은 id에 해당하는 Participant 객체를 불러와 해당 객체를 기반으로 EntityModel을 생성해 리턴한다.

 

만약 응답 객체의 자원이 리스트 형태라면 ColletionModel을 리턴한다. CollectionModel은 리스트 자체의 링크 정보와 함께 리스트의 각 요소의 링크 정보를 모두 담아 리턴한다.

@ApiOperation(value = "참가팀 기록 전체 조회", notes = "데이터 베이스의 저장된 특정 참가팀의 모든 기록을 조회합니다.")  
@GetMapping("/participants/{id}/records")  
public CollectionModel<EntityModel<RecordDto>> allRecords(  
        @ApiParam(value = "참가팀 아이디", required = true) @PathVariable Long id) {  
    Participant findParticipant = participantRepository.findOne(id);  

    List<EntityModel<RecordDto>> records = findParticipant.getRecords().stream()  
            .map(recordAssembler::toModel)  
            .collect(Collectors.toList());  

    return CollectionModel.of(records, linkTo(methodOn(ParticipantController.class).allRecords(id)).withSelfRel());  
}

위 코드는 PariticipantController.java에서 참가팀 전체 조회를 하는 코드이다. CollectionModel 객체를 생성해 리턴함과 동시에 map 스트림 메서드를 통해 리스트의 각 요소를 EntityModel로 변환해준다.