본문 바로가기

프로젝트 이야기/물품 지급앱

2. 핵심 기능 구현 (2) - REST API 설계

개요

1편에서는 요구사항을 비지니스 로직으로 정리하고 비지니스 로직을 구현하는 도메인을 설계하였습니다. 2편에서는 이어서 설계한 도메인을 기반으로 실제 REST API를 설계해보겠습니다.

 

1편에서 정리한 도메인별 핵심 비즈니스 로직을 기반으로 요청 url을 매핑하겠습니다.

 

  • 유저 (Userinfo) : /userinfo
    • 유저 조회 (닉네임) : GET /nickname/{nickname}
    • 유저 닉네임 변경 : PUT /{nickname}/{newNickname}
  • 물품 (Item) : /items
    • 물품 조회 (이름) : GET /name/{name}
    • 물품 전체 조회 : GET /
  • 지급장부 (Gift) : /gift
    • 장부 생성 : POST /

 

이제 도메인별로 컨트롤러 클래스를 생성해 컨트롤러를 만들어줍니다.

 


Spring HATEOAS

REST는 기본적으로 소프트웨어를 구성하는 리소스에 고유한 url을 부여하여 이를 표현하는 소프트웨어 아키처 스타일입니다. 위와 같이 도메인 별로 고유한 URL을 할당하고, 요청이 들어오면 URL과 요청바디를 확인해 데이터를 처리하고 응답(response) 객체를 다시 클라이언트에게 건내준다. 그렇다면 이 API는 RESTful하다고 할 수 있을까??

 

RESTful API를 만들기 위해서는 마지막 단계로 클라이언트는 동적인 서버와 정적으로 상호작용이 가능해야 합니다. 쉽게 말해 서버에 변경사항이 발생해도 클라이언트는 문제 없이 서버에 요청이 가능해야 합니다. 하지만 단순히 요청을 받고 그 요청에 대한 결과값만을 응답하게 되면 위의 요구조건을 만족할 수 없습니다.


예를들어 어떤 요청의 URI가 변경됐다고 가정합시다. 하지만 클라이언트는 그 사실을 알 수 없습니다. 왜냐하면 클라이언트는 서버의 그 어떠한 정보도 가지고 있지 않기 때문이죠. 그러므로 서버는 수정사항도 모른체 이미 설정된 잘못된 URI로 요청을 보내고 404Error를 맞이하게 됩니다...

 

이를 미연에 방지하기위한 가장 좋은 방법은 클라이언트에 응답(response) 객체를 보낼때, 요청 URI를 포함시켜 보내는 것입니다. Spring에서 제공하는 Spring HATEOAS를 이용하면 간단한 코딩으로 응답 객체에 URI를 포함시켜 보낼 수 있습니다. 자세한 구현 방법은 각 컨트롤러를 구현하는 파트에서 설명하겠습니다.

 


컨트롤러 설계

 

기본적으로 Spring HATEOAS는 응답 객체로 EntityModel 타입을 이용합니다. EntityModel의 정적메서드인 of를 호출하여EntityModel 객체를 생성합니다. 이때, EntityModel의 body와 _links 배열을 매개변수를 통해 설정합니다. _links 배열은 응답 객체에 포함시킬 요청 URI 리스트입니다.

 

ItemController.java

public class ItemController {  

    private final ItemRepository itemRepository;  
    private final ItemAssembler itemAssembler;  

    @GetMapping("/items/{id}")  
    public EntityModel<ItemDto> one(@PathVariable Long id) {  
        Item findItem = itemRepository.findOne(id);  
        return EntityModel.of(findItem,  
            linkTo(methodOn(ItemController.class).one(item.getId())).withSelfRel(),  
            linkTo(methodOn(ItemController.class).all()).withRel("items"));
        ) 
    }

    ... 생략 ...
}

다음은 ItemController 클래스의 일부입니다. one 메서드는 클라이언트의 요청을 받아 경로 변수(path variable)에 담겨있는 아이디 정보에 맞는 물품을 찾아 클라이언트에게 보냅니다. 이때 EntityModel.of 메서드의 첫번째 매개변수는 응답바디인 findItem을 넣어줍니다. 그리고 그 뒤의 매개변수는 linkTo(methodOn...) 메서드를 호출하여 각 URI 별로 이름(key)를 지정하여 _links 에 넣어줍니다.

 

 

응답(response) 바디에 배열이 포함된 경우

응답 바디와 위와 같이 단순한 하나의 객체가 아닌 객체의 배열이 될 수도 있습니다. 예를 들자면 전체조회를 하거나 검색을 하게 되면 그렇게 될 것입니다. 이런 경우에는 EntityModel의 배열(Array)을 리턴하면 되지 않을까라고 생각할 수 있습니다. 이를 가능케 하는 것이 CollectionModel 타입입니다.

 

CollectionModel은 바디(body) 값으로 EntityModel로 구성된 배열을 받습니다. 그리고 EntityModel과 마찬가지로 _links 배열에 요청 URI를 받습니다.

@GetMapping("/items")  
public CollectionModel<EntityModel<ItemDto>> all() {  
    List<EntityModel<ItemDto>> items = itemRepository.findAll().stream()  
            .map((item) -> {
                EntityModel.of(item,  
                linkTo(methodOn(ItemController.class).one(item.getId())).withSelfRel(),  
                linkTo(methodOn(ItemController.class).all()).withRel("items"));
            })  
            .collect(Collectors.toList());  

    return CollectionModel.of(items,  
            linkTo(methodOn(ItemController.class).all()).withSelfRel());  
}

위 코드는 물품의 전체 조회를 요청받아 처리하는 메서드입니다. 일단 findAll()을 호출해 전체 물품을 리턴받아 map 메서드를 통해 각 Item 객체를 EntityModel객체로 매핑합니다. 그리고 이를 List로 만들어 EntityModel로 구성된 List를 생성합니다. 그리고 CollectionModel의 생성메서드 CollectionModel.of를 호출하여 미리 생성해둔 EntityModel 리스트와 필요한 URI목록을 매개변수로 넣어줍니다.

 

 

Assembler를 이용한 코드간소화

위에 작성된 코드들을 보게되면 EntityModel.of(..., linkTo(), ..., linkTo()) 이런 식의 코드가 반복되는 것을 볼 수 있습니다. 이부분을 Assembler 클래스를 통해 간소화 해줍니다. 일단, ItemAssembler 클래스를 생성해줍니다. ItemAssembler는 Item 객체를 받아 dto를 바디로 하고, link 정보를 담은 EntityModel 객체를 생성해 리턴합니다.

@Component  
public class ItemAssembler implements RepresentationModelAssembler<Item, EntityModel<ItemDto>> {  

    @Override  
    public EntityModel<ItemDto> toModel(Item item) {  
        ItemDto dto = new ItemDto(item.getId(), item.getName());  

        return EntityModel.of(dto,  
                linkTo(methodOn(ItemController.class).one(item.getId())).withSelfRel(),  
                linkTo(methodOn(ItemController.class).all()).withRel("items"));  
    }  
}

RespresentationModelAssembler 인터페이스를 구현하는 클래스로 개발합니다.

 

이제 위에 적은 EntityModel.of(..., linkTo(), ..., linkTo()) 대신 Assembler 클래스를 불러와 toModel 메서드를 호출하면 됩니다.

@GetMapping("/items/name/{name}")  
public EntityModel<ItemDto> oneByName(@PathVariable String name) {  
    Item findItem = itemRepository.findOneByName(name);  
    return itemAssembler.toModel(findItem);  
}

위 코드는 ItemAssembler.toModel 메서드를 호출해 코드를 간소화 하였습니다.

 


자세한 코드

이제 같은 방식으로 ItemController, GiftController, UserInfoController 를 개발하였습니다. 코드는 아래와 같습니다.

 

ItemController.java

@RestController  
@RequiredArgsConstructor  
@Slf4j  
@CrossOrigin(origins = "http://localhost:5173")  
public class ItemController {  

    private final ItemRepository itemRepository;  
    private final ItemAssembler itemAssembler;  

    @GetMapping("/items/{id}")  
    public EntityModel<ItemDto> one(@PathVariable Long id) {  
        Item findItem = itemRepository.findOne(id);  
        return itemAssembler.toModel(findItem);  
    }  

    @GetMapping("/items")  
    public CollectionModel<EntityModel<ItemDto>> all() {  
        List<EntityModel<ItemDto>> items = itemRepository.findAll().stream()  
                .map(itemAssembler::toModel)  
                .collect(Collectors.toList());  

        return CollectionModel.of(items,  
                linkTo(methodOn(ItemController.class).all()).withSelfRel());  
    }  

    @GetMapping("/items/name/{name}")  
    public EntityModel<ItemDto> oneByName(@PathVariable String name) {  
        Item findItem = itemRepository.findOneByName(name);  
        return itemAssembler.toModel(findItem);  
    }  

    @GetMapping("/items/search/{keyword}")  
    public CollectionModel<EntityModel<ItemDto>> search(@PathVariable String keyword) {  
        List<EntityModel<ItemDto>> items = itemRepository.findAll().stream()  
                .filter(item -> item.getName().contains(keyword))  
                .map(itemAssembler::toModel)  
                .collect(Collectors.toList());  

        return CollectionModel.of(items,  
                linkTo(methodOn(ItemController.class).search(keyword)).withSelfRel());  
    }  
}

@CrossOrigin 애노테이션은 CORS 정책에 관한 것입니다. 매개변수로 입력된 url의 요청을 허용합니다.

 

GiftController.java

@RestController  
@RequiredArgsConstructor  
@Slf4j  
@CrossOrigin(origins = "http://localhost:5173")  
public class GiftController {  

    private final GiftRepository giftRepository;  
    private final GiftService giftService;  
    private final GiftAssembler giftAssembler;  

    @GetMapping("/gifts/{id}")  
    public EntityModel<GiftDto> one(@PathVariable Long id) {  
        Gift findGift = giftRepository.findOne(id);  
        return giftAssembler.toModel(findGift);  
    }  

    @PostMapping("/gifts")  
    public EntityModel<GiftDto> newOne(@RequestBody GiftDto giftDto) {  
        Gift gift = giftService.create(  
                giftDto.getSendUserNickname(),  
                giftDto.getReceiveUserNickname(),  
                giftDto.getGiftItemName(),  
                giftDto.getMemo(),  
                giftDto.getExpireDate());  
        return giftAssembler.toModel(gift);  
    }  
}

 

UserinfoController.java

@RestController  
@RequiredArgsConstructor  
@Slf4j  
@CrossOrigin(origins = "http://localhost:5173")  
public class UserInfoController {  

    private final UserInfoRepository userInfoRepository;  
    private final UserInfoService userInfoService;  
    private final UserInfoAssembler userInfoAssembler;  

    @GetMapping("/userinfo")  
    public CollectionModel<EntityModel<UserInfoDto>> all() {  
        List<EntityModel<UserInfoDto>> userInfos = userInfoRepository.findAll().stream()  
                .map(userInfoAssembler::toModel)  
                .collect(Collectors.toList());  

        return CollectionModel.of(userInfos,  
                linkTo(methodOn(UserInfoController.class).all()).withSelfRel());  

    }  

    @GetMapping("/userinfo/{id}")  
    public EntityModel<UserInfoDto> one(@PathVariable Long id) {  
        UserInfo findUserInfo = userInfoRepository.findOne(id);  
        return userInfoAssembler.toModel(findUserInfo);  
    }  

    @GetMapping("/userinfo/nickname/{nickname}")  
    public EntityModel<UserInfoDto> oneByNickname(@PathVariable String nickname) {  
        UserInfo findUserInfo = userInfoRepository.findOneByNickname(nickname);  
        if (findUserInfo == null) return null;  

        return userInfoAssembler.toModel(findUserInfo);  
    }  

    @PostMapping("/userinfo")  
    public EntityModel<UserInfoDto> newOne(@RequestBody UserInfoDto dto) {  
        UserInfo newUserInfo = userInfoService.create(dto.getUID(), dto.getNickname(), dto.getOption());  
        return userInfoAssembler.toModel(newUserInfo);  
    }  

    @PutMapping("/userinfo/{nickname}/{newNickname}")  
    public ResponseEntity<?> replaceUserInfoByNickname(  
            @PathVariable String nickname, @PathVariable String newNickname) {  
        UserInfo replaceUserInfo = userInfoService.editUserNickname(nickname, newNickname);  

        EntityModel<UserInfoDto> entityModel = userInfoAssembler.toModel(replaceUserInfo);  

        return ResponseEntity  
                .created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri())  
                .body(entityModel);  

    }  

    @PutMapping("/userinfo/{id}")  
    public ResponseEntity<?> replaceUserInfo(  
            @PathVariable Long id, @RequestBody UserInfoDto dto) {  
        UserInfo replacedUserInfo = userInfoService.update(id, dto);  

        EntityModel<UserInfoDto> entityModel = userInfoAssembler.toModel(replacedUserInfo);  

        return ResponseEntity  
                .created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri())  
                .body(entityModel);  
    }  

}