IT

[SpringBoot] HATEOAS

data-cloud 2025. 1. 19. 12:47
반응형

 

 

🌱 HATEOAS란?

HATEOAS(Hypermedia As The Engine Of Application State)란 REST API를 통해 클라이언트와 서버 간에 동적인 상호작용을 가능케 하는 기술을 의미한다. 예를 들어, 클라이언트가 사용자를 조회했을 때 사용자 정보만 응답하는 것이 아니라 '사용자 수정', '사용자 삭제' 등을 처리할 수 있는 링크를 같이 포함하여 응답하는 것을 예시로 들 수 있다.

 

 

 

🌱 HATEOAS 사용 예시

앞서 설명한 내용을 토대로 실제 응답값이 어떻게 변화하는지 알아보자.

특정 사용자를 조회한다고 가정했을 때, HATEOAS를 적용하기 전에는 다음과 같이 사용자 정보만을 응답하게 된다.

{
    "resultCode": "0000",
    "resultData": {
        "memberId": 1,
        "name": "테스터1",
        "birth": 19920101
    }
}

만약 HATEOAS를 적용하게 된다면 다음과 같은 응답을 받을 수 있으며, 이후의 동작을 유추할 수 있게 된다.

{
    "resultCode": "0000",
    "resultData": {
        "memberId": 1,
        "name": "테스터1",
        "birth": 19920101
    },
    "_links": {
        "list": {
            "href": "http://localhost:8080/members",
            "type": "GET"
        },
        "self": {
            "href": "http://localhost:8080/members/1",
            "type": "GET"
        },
        "update": {
            "href": "http://localhost:8080/members/1",
            "type": "PUT"
        },
        "delete": {
            "href": "http://localhost:8080/members/1",
            "type": "DELETE"
        }
    }
}

 

 

 

🌱 HATEOAS 장단점

HATEOAS를 적용시킴으로써 얻을 수 있는 장점과 단점은 다음과 같다.

  • 장점
    - 다음 동작을 유추 가능하다.
    - 서버에서 URI를 변경해도 클라이언트단에서는 영향이 없다.
    - 클라이언트단에서 link가 있느냐 없느냐에 따라서 UI를 유기적으로 구성할 수 있다. (Ex. link가 없으면 버튼을 disabled)

  • 단점 
    - 전달되는 데이터가 커져서 네트워크 오버헤드가 발생한다.
    - 링크 구성과 관련하여 코드가 지저분해질 수 있다.

 

 

 

🌱 HATEOAS 적용

1. 의존성 설정

<dependencies>
    ...
	<dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-hateoas</artifactId>
	</dependency>
    ...
</dependencies>

 

2. 도메인 및 공통 응답 객체

// 사용자 Domain
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long memberId;

    private String name;

    private Integer birth;
    
    @Builder
    private Member(Long memberId, String name, Integer birth) {
        this.memberId = memberId;
        this.name = name;
        this.birth = birth;
    }

}
// 공통 응답 객체
import lombok.Builder;
import lombok.Getter;

@Getter
public class ResponseData {

    String resultCode;

    Object resultData;
    
    @Builder
    public ResponseData(String resultCode, Object resultData) {
        this.resultCode = resultCode;
        this.resultData = resultData;
    }

}

 

반응형

 

3. 컨트롤러

import com.web.api.domain.Member;
import com.web.api.domain.ResponseData;
import com.web.api.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.hateoas.EntityModel;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.stream.Collectors;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    /**
     * 회원 목록 조회
     */
    @GetMapping("/members")
    public ResponseEntity<EntityModel<ResponseData>> findMembers() {
        List<EntityModel<Member>> members = memberService.findAllMember().stream().map(member -> {
            return EntityModel.of(member,
                    linkTo(methodOn(MemberController.class).findMember(member.getMemberId())).withRel("detail").withType("GET"));
        }).collect(Collectors.toList());

        return ResponseEntity.ok().body(
                EntityModel
                        .of(ResponseData.builder()
                                .resultCode("0000")
                                .resultData(members)
                                .build())
                        .add(linkTo(methodOn(MemberController.class).findMembers()).withSelfRel())
        );
    }

    /**
     * 회원 조회
     */
    @GetMapping("/members/{memberId}")
    public ResponseEntity<EntityModel<ResponseData>> findMember(@PathVariable Long memberId) {
        return ResponseEntity.ok().body(
                EntityModel
                        .of(ResponseData.builder()
                                .resultCode("0000")
                                .resultData(memberService.findMember(memberId))
                                .build())
                        .add(linkTo(methodOn(MemberController.class).findMembers()).withRel("list").withType("GET"))
                        .add(linkTo(methodOn(MemberController.class).findMember(memberId)).withSelfRel().withType("GET"))
                        .add(linkTo(methodOn(MemberController.class).editMember(memberId, new Member())).withRel("update").withType("PUT"))
                        .add(linkTo(methodOn(MemberController.class).deleteMember(memberId)).withRel("delete").withType("DELETE"))
        );
    }

    /**
     * 회원 등록
     */
    @PostMapping("/members")
    public ResponseEntity saveMember(@RequestBody Member member) {
        // 생략
    }

    /**
     * 회원 수정
     */
    @PutMapping("/members/{memberId}")
    public ResponseEntity editMember(@PathVariable Long memberId, @RequestBody Member member) {
        // 생략
    }

    /**
     * 회원 삭제
     */
    @DeleteMapping("/members/{memberId}")
    public ResponseEntity deleteMember(@PathVariable Long memberId) {
        // 생략
    }

}

 

 

🌱 HATEOAS 적용 결과

1. 사용자 전체 조회

{
    "resultCode": "0000",
    "resultData": [
        {
            "memberId": 5,
            "name": "테스터5",
            "birth": 19920505,
            "_links": {
                "detail": {
                    "href": "http://localhost:8080/members/5",
                    "type": "GET"
                }
            }
        },
        {
            "memberId": 6,
            "name": "테스터66",
            "birth": 19990606,
            "_links": {
                "detail": {
                    "href": "http://localhost:8080/members/6",
                    "type": "GET"
                }
            }
        }
    ],
    "_links": {
        "self": {
            "href": "http://localhost:8080/members"
        }
    }
}

 

2. 사용자 조회

{
    "resultCode": "0000",
    "resultData": {
        "memberId": 6,
        "name": "테스터66",
        "birth": 19990606
    },
    "_links": {
        "list": {
            "href": "http://localhost:8080/members",
            "type": "GET"
        },
        "self": {
            "href": "http://localhost:8080/members/6",
            "type": "GET"
        },
        "update": {
            "href": "http://localhost:8080/members/6",
            "type": "PUT"
        },
        "delete": {
            "href": "http://localhost:8080/members/6",
            "type": "DELETE"
        }
    }
}

 

 

References.

1. https://caffeineoverflow.tistory.com/28
반응형