공부/Spring Boot

API 개발 기본

Stair 2025. 1. 31. 13:45
반응형

API(Application Programming Interface) : 클라이언트(웹 브라우저, 모바일 앱)와 서버 간의 데이터 교환을 위한 인터페이스이다.

 

API의 역할

1. 클라이언트 요청을 받아 서버에서 처리

2. 데이터베이스에서 데이터를 가져와 JSON 형태로 응답

3. CRUD(Create, Read, Update, Delete)의 기능 제공

 

스프링 부트에서는 API가 기본적으로 JSON 형식의 데이터를 주고 받는다.

API가 정상적으로 동작하는지 알아보기 위해 우선 Postman을 설치한다.

https://www.postman.com/

 

Postman: The World's Leading API Platform | Sign Up for Free

Accelerate API development with Postman's all-in-one platform. Streamline collaboration and simplify the API lifecycle for faster, better results. Learn more.

www.postman.com

 

요즘에는 화면을 템플릿 엔진으로 만드는 경우가 적고, 싱글페이지 애플리케이션, Vue.js, React 등을 활용해서 개발한다. 그러면 서버 개발자 입장에서는 서버에서 렌더링해서 HTML에 내리는 것이 아닌 클라이언트단에서 해결을 한다.

 

요즘 추세는 마이크로 서비스 아키텍쳐로 바뀌어 감에 따라 API로 통신하는 일이 더욱 많아졌다.

 

JPA를 사용하면 Entity라는 개념이 있기 때문에 JPA를 사용하면서 API를 만들때 어떤 방식으로 개발하는것이 올바른 방향인지 알아보도록 하자.

 

API를 개발할때 화면 컨트롤러랑 API 컨트롤러랑 패키지를 구분하여 개발하는 것이 좋다. 공통으로 예외처리할때는 패키지나 구성 단위로 예외처리를 진행하는데, API랑 화면이랑은 공통처리해야되는 요소가 너무 다르다. 화면은 템플릿 엔진에서 문제가 생기면 공통 에러 화면이 나와야 하지만, API는 공통에러용 JSON API 스펙이 나가야 한다. 그렇기에 패키지를 나누어준다.

 

 

REST API를 사용하여 개발을 진행한다.

REST API(Representational State Transfer API) : HTTP 프로토콜을 기반으로 데이터를 주고받는 방식.

 

회원 등록 API

@RestController  //RestAPI 스타일로 만든다.
@RequiredArgsConstructor
public class MemberApiController {

    private final MemberService memberService;


    @PostMapping("api/v1/members")
    public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member) {
        Long id = memberService.join(member);
        return new CreateMemberResponse(id);
    }

    @Data
     static class CreateMemberResponse {
        private Long id;

        public CreateMemberResponse(Long id) {
            this.id = id;
        }
    }
}

 

PostMapping을 통해 member를 저장해보자.

JSON 형식이고 POST를 하면 hello와 id가 저장되는 것을 확인할 수 있다.

 

사실 name:hello를 입력하지 않고도, member가 저장되는데, Member엔티티에서 제약이 없어서 그렇다.

@NotEmpty
private String name;

 

member 엔티티의 name에 NotEmpty 어노테이션을 달아주면 여기 무조건 값이 들어가야만 한다.

 

위의 name에 NotEmpty 어노테이션을 달아주는 것은 문제가 하나 있다.

Presentation 계층을 위한 검증 로직이 엔티티에 들어가 있는 것이 문제이다.

어떤 API에서는 이 NotEmpty가 필요할수도 있지만, 어떤 API 에서는 필요하지 않을수도 있다. 즉 화면 벨리데이션을 위한 로직이 들어간 것이 문제이다.

또한 엔티티의 스펙을 name이 아닌 username으로 바꾸었다고 가정하자. 그렇게 되면 API 스펙 자체가 name -> username으로 스펙이 변경되게 된다. 엔티티를 손대서 API 스펙 자체가 변하는 것이 문제이다.

API를 호출하는 입장에서는 API 스펙이 변경되면 안된다.

--> 결론적으로 API 스펙을 위한 별도의 Data Transfer Object(DTO)를 만들어야 한다.

--> API 요청 스펙에 맞춰서 별도의 DTO를 파라미터로 해서 받는게 좋다. API를 만들땐 엔티티를 파라미터로 받지 말자.

--> API를 만들때는 엔티티를 외부에 노출해서도 안된다.

 

@PostMapping("api/v2/members")
public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request) {

    Member member = new Member();
    member.setName(request.getName());
    Long id = memberService.join(member);

    return new CreateMemberResponse(id);
}

@Data
static class CreateMemberRequest {
    private String name;
}

CreateMemberRequest라는 별도의 DTO를 만들어서 파라미터랑 엔티티를 컨트롤하여 맵핑해주었다.

이렇게 DTO를 받으면 API 스펙 자체가 네임만 받게 되어 있다는 것을 한눈에 정리할 수 있다. 내가 필요한 타이밍에 validation을 API 스펙에 맞게 넣어줄 수 있어 코드가 유연해지는 장점도 있다.

@Data
static class CreateMemberRequest {
    @NotEmpty
    private String name;
}

 

 

** 정리 : 엔티티를 외부에 노출하지 않고 API 스펙에 맞는 별도의 DTO를 만들어서 개발하도록 하자.

 

 

회원 수정 API

RESTful API 스타일로 가져갈 것이고, PUT을 사용할 예정이다.

PUT은 멱등하다. = 똑같은 수정을 여러번 호출해도 결과가 같다.

 

    @PutMapping("api/v2/members/{id}")
    public UpdateMemberResponse updateMemberV2(@PathVariable("id") Long id,
                                               @RequestBody @Valid UpdateMemberRequest request) {
        memberService.update(id, request.getName());
        Member findMember = memberService.findOne(id);
        return new UpdateMemberResponse(findMember.getId(), findMember.getName());
}

    @Data
    static class UpdateMemberRequest {
        private String name;
    }

    @Data
    @AllArgsConstructor
    static class UpdateMemberResponse {
        private Long id;
        private String name;
    }

등록이랑 수정은 API 스펙이 다 다르다. 수정은 제한적이어야 한다.

 

MemberService에 update 메서드를 하나 추가해야 한다.

@Transactional
public void update(Long id, String name) {
    Member member = memberRepository.findOne(id);
    member.setName(name);
}

이미 저장된 member를 수정하는 것임으로 변경 감지를 활용하여 수정한다.

memberRepository에서 이미 저장된 member를 찾아오고, setName을 통해 member의 이름을 바꾼다.

 

update의 순서는 다음과 같다.

1. 트랜잭션 시작

2. 영속성 컨텍스트에서 일치하는 member 찾기, 없으면 DB를 조회해서 찾기

3. DB에서 조회한 member를 영속성 컨텍스트에 올리기

4. member의 이름을 변경 후 트랜잭션 종료

5. 변경감지에 의해 플러시 및 영속성 컨텍스트를 커밋

 

 

회원 조회 API

가장 단순하게 조회하는 API를 우선 만들어보자.

@GetMapping("api/v1/members")
public List<Member> membersV1() {
    return memberService.findMembers();
}

[
    {
        "id": 1,
        "name": "new_hello",
        "address": null,
        "orders": []
    },
    {
        "id": 2,
        "name": "member1",
        "address": {
            "city": "",
            "street": "",
            "zipcode": ""
        },
        "orders": []
    },
    {
        "id": 3,
        "name": "member2",
        "address": {
            "city": "부산",
            "street": "1234",
            "zipcode": "12345"
        },
        "orders": []
    },
    {
        "id": 52,
        "name": "new-hello",
        "address": null,
        "orders": []
    },
    {
        "id": 53,
        "name": "hello1",
        "address": null,
        "orders": []
    },
    {
        "id": 102,
        "name": "hello3",
        "address": null,
        "orders": []
    }
]
 

 

 

API만 놓고 보면 사실 너무 쉽게 만들어졌다.

가져와서 리스트에 entity를 반환하면 끝이였다.

만약 API라는게 회원에 대한 정보만 달라고 했는데, Orders등 원하지 않은 엔티티 정보가 노출되게 된다. member를 직접 반환하게 엔티티가 외부에 전부 노출되게 된다.

 

이걸 @JsonIgnore를 활용하여 해결할 수 있다.

@JsonIgnore
@OneToMany(mappedBy = "member",fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
[
    {
        "id": 1,
        "name": "new_hello",
        "address": null
    },
    {
        "id": 2,
        "name": "member1",
        "address": {
            "city": "",
            "street": "",
            "zipcode": ""
        }
    },
    {
        "id": 3,
        "name": "member2",
        "address": {
            "city": "부산",
            "street": "1234",
            "zipcode": "12345"
        }
    },
    {
        "id": 52,
        "name": "new-hello",
        "address": null
    },
    {
        "id": 53,
        "name": "hello1",
        "address": null
    },
    {
        "id": 102,
        "name": "hello3",
        "address": null
    }
]
정상적으로 orders가 빠지는것을 postman을 통해 확인할 수 있다.
 
하지만 이 방법은 문제가 있다. 굉장히 많은 클라이언트들이 다양한 API 스타일을 요구할텐데, JsonIgnore를 사용하게 되면 아까와 같이 API의 스펙이 변하여서, 엔티티에 이런것을 녹이기 시작하면 답이 안나온다. 즉 유연하지 못하다.

+ 엔티티에 화면에 뿌리기 위한 로직이 들어가게 되었다.

--> 이 문제를 해결하기 위해 API 응답 스펙에 맞추어 별도의 DTO를 반환하도록 하는것이 좋다.

 

@GetMapping("api/v2/members")
public Result memberV2() {
    List<Member> findMembers = memberService.findMembers();
    List<MemberDto> collect = findMembers.stream()
            .map(m -> new MemberDto(m.getName()))
            .collect(Collectors.toList());

    return new Result(collect);
}

@Data
@AllArgsConstructor
static class Result<T> {
    private T data;
}

@Data
@AllArgsConstructor
static class MemberDto {
    private String name;
}

Object 타입으로 반환하는 것이기 때문에 Result로 껍데기를 씌워주고, 거기서 data 필드의 값은 list가 나가게 된다.

-> 껍데기를 씌우지 않으면 JSON 배열 타입으로 나가버리기 때문에 유연성이 떨어진다.

 

동작 순서

1. memberService.findMembers로 멤버 전체를 찾음

2. 찾은 findMembers를 MemberDto를 통해 name만 걸러서 List로 만든 collect를 만듦

3. name을 List로 만든 collect를 Result라는 껍데기를 씌워서 반환

 

postman의 결과는 다음과 같다.

{
    "data": [
        {
            "name": "new_hello"
        },
        {
            "name": "member1"
        },
        {
            "name": "member2"
        },
        {
            "name": "new-hello"
        },
        {
            "name": "hello1"
        },
        {
            "name": "hello3"
        }
    ]
}

 

 

**중요**

1. API를 만들때는 파라미터를 받던 나가던 절대 엔티티를 노출하거나 받으면 안된다.

2. 중간에 API 스펙에 맞는 DTO를 만들고 그것을 활용해야 한다.

반응형

'공부 > Spring Boot' 카테고리의 다른 글

API 개발 고급 - 지연 로딩과 조회 성능 최적화  (0) 2025.02.03
API 개발 고급 - 준비  (0) 2025.01.31
웹 계층 개발  (1) 2024.11.25
애플리케이션 구현(도메인 개발)  (0) 2024.11.21
도메인 분석 설계  (2) 2024.11.18