[Spring] CRUD API 공부 및 구현

이성민·2024년 11월 15일
post-thumbnail

스프링 프로젝트 생성

Spring Boot 프로젝트를 생성합니다.

Spring Initializr 사용 (https://start.spring.io)

  • Project: Gradle (혹은 Maven)
  • Language: Java
  • Spring Boot Version: 최신 안정 버전 선택
  • Dependencies:
  • Spring Web: API를 개발하기 위해 필요
  • Spring Data JPA: 데이터베이스 연동 (필요 시)
  • H2 Database: 간단한 인메모리 데이터베이스 (개발 환경용)
  • Lombok: 코드를 간결하게

Generate 버튼을 눌러 프로젝트를 다운로드하고, IntelliJ IDEA에서 열어줍니다.


Spirng 기본 프로젝트 구조 확인

src/main/java/projectname/app
├── app.java      // 메인 클래스
└── controller    // API 요청을 처리할 컨트롤러
└── service       // 비즈니스 로직을 처리할 서비스 
└── repository    // 데이터베이스와 연동 
└── domain         // 데이터 구조를 정의할 모델


프로젝트 생성 과정

1. App Domain 정의

먼저 model 클래스를 생성합니다. (저는 할 일을 구현하기위해 Task 객체를 생성했습니다.)

package com.example.techeer_partners_api_session.domain;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Task {

    @Id // 데이터베이스 테이블의 기본키임을 나타냄.
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 자동 생성 ID

    private Long id;
    private String title; // 할 일 내용
    private Boolean isDone = false; // 기본값 : false

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public void setIsDone(Boolean isDone) {
        this.isDone = isDone;
    }

    public Boolean getIsDone() {
        return isDone;
    }
}

2. AppRepository 생성

데이터베이스와 통신하기 위해 TaskRepository 클래스를 생성합니다.

package com.example.techeer_partners_api_session.TaskRepository;

import com.example.techeer_partners_api_session.domain.Task;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface TaskRepository extends JpaRepository<Task, Long> {
    // 기본적인 CRUD 메서드는 JpaRepository에서 제공
    // 추가적인 쿼리 메서드는 필요에 따라 선언할 수 있음

    // 완료된 할 일만 조회
    List<Task> findByIsDoneTrue(); // isDone == true인 할 일 목록만 조회하는 메서드입니다.

    // 미완료된 할 일만 조회
    List<Task> findByIsDoneFalse(); // isDone == false인 할 일 목록만 조회하는 메서드입니다.
}
  • 참고 : @Repository 어노테이션을 TaskRepository에 명시하지 않은 이유는 JpaRepository가 이미 @Repository를 포함하고 있기 때문입니다.

  • 주의 : JpaRepository로 쉽게 코드를 짤 수 있지만, JpaRepository의 기능에 대해 자세히 알고 사용하는 것이 좋다.

다음 단계로 넘어가기 전에, JpaRepository를 짧게 설명하고 넘어가겠습니다.
JpaRepository는 CrudRepository와 PagingAndSortingRepository를 상속받아, 다음과 같은 주요 기능을 제공합니다:

기본적인 CRUD (Create, Read, Update, Delete) 기능:

  • save(): 새로운 엔티티를 저장하거나, 기존 엔티티를 업데이트합니다.
  • findById(): 엔티티 ID로 데이터를 조회합니다.
  • findAll(): 모든 엔티티를 조회합니다.
  • deleteById(): ID로 엔티티를 삭제합니다.
  • count(): 테이블에 있는 데이터의 개수를 반환합니다.

페이징과 정렬:

  • findAll(Pageable pageable): 데이터를 페이지 단위로 조회할 수 있는 기능을 제공합니다.
  • findAll(Sort sort): 특정 필드로 정렬하여 데이터를 조회할 수 있는 기능을 제공합니다.

Spring Data JPA의 상속 구조
JpaRepository는 CrudRepository와 PagingAndSortingRepository를 상속받고, CrudRepository는 다시 Repository 인터페이스를 상속합니다.

Repository (기본 인터페이스)
    ↓
CrudRepository (CRUD 메서드 제공)
    ↓
PagingAndSortingRepository (페이징/정렬 메서드 제공)
    ↓
JpaRepository (JPA 관련 메서드 제공)

3. AppService 구현

비즈니스 로직(예: 데이터 저장)을 처리할 서비스 클래스입니다.

package com.example.techeer_partners_api_session.TaskService;

import com.example.techeer_partners_api_session.TaskRepository.TaskRepository;
import com.example.techeer_partners_api_session.domain.Task;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;

@Service
public class TaskService {

    private final TaskRepository taskRepository;

    @Autowired
    //생성자 주입
    public TaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository; // 초기화
    }

    // Task 생성
    public Task createTask(Task task) { // 단일 객체로 반환
        return taskRepository.save(task);
    }

    // 모든 Task 조회
    public List<Task> getAllTasks() { // 리스트로 반환
        return taskRepository.findAll();
    }

    // 완료된 할 일 조회
    public List<Task> getCompletedTasks() {
        return taskRepository.findByIsDoneTrue();
    }

    // 미완료된 할 일 조회
    public List<Task> getIncompleteTasks() {
        return taskRepository.findByIsDoneFalse();
    }

    // ID로 특정 Task 일부 수정
    public Task partialUpdateTask(Long id, Map<String, Object> updates) {
        Task existingTask = taskRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Task not found"));

        if (updates.containsKey("title")) { // 요청에서 title 필드가 포함되어 있는지 확인.
            existingTask.setTitle((String) updates.get("title"));
        }
        if (updates.containsKey("isDone")) { // 요청에 isDone 필드가 포함되어 있는지 확인.
            existingTask.setIsDone((Boolean) updates.get("isDone"));
        }

        return taskRepository.save(existingTask); // 업데이트된 Task 저장
    }

    // ID로 특정 Task 삭제
    public void deleteTask(Long id) { // 반환 값 필요 없음.
        taskRepository.deleteById(id);
    }
}

[코드 부분해석 정리]

- final란? :
final 키워드가 붙은 필드는 초기화 이후 값이 변경되지 않음을 보장합니다.

  • 언제 final를 붙이는가? :
    - 의존성 주입된 필드는 대부분 final로 선언하는 것이 좋습니다.
    - 변경될 필요가 없는 변수나 데이터는 final로 선언하여 코드의 안정성을 높이고, 유지보수를 쉽게 만듭니다.

 public Task partialUpdateTask(Long id, Map<String, Object> updates) {
        Task existingTask = taskRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Task not found"));

- Map<String, Object>란?:
데이터를 키-값 쌍으로 저장하는 자료구조이며 동적으로 업데이트할 필드를 처리하는 데 유용합니다.

  • String (키의 타입): 맵의 키는 항상 String 타입입니다. 이는 updates 맵에 저장된 각 항목이 어떤 필드를 업데이트하려는지를 나타냅니다.

  • Object (값의 타입): 맵의 값은 Object 타입입니다. 이는 해당 필드를 어떤 값으로 수정할 것인지를 나타내며, 모든 객체를 저장할 수 있다는 의미입니다. 예를 들어, String, Integer, Boolean, Date 등 다양한 타입의 값을 저장할 수 있습니다

  • updates는 그냥 Map의 이름(변경 가능)

- taskRepository.findById(id).orElseThrow()란?

  • Optional을 사용하여 값이 없을 경우 예외를 발생시키는 방법으로, 예외 처리 흐름을 명확히 할 수 있는 유용한 메서드입니다.

  • findById에서 데이터베이스에 값이 없다면 Optional.empty()를 반환하는데 이걸 받으면 .orElseThrow()가 예외를 발생시켜 RuntimeException을 던짐.

  • -> new RuntimeException("Task not found") : 메시지는 "Task not found". 출력


if (updates.containsKey("title")) { // 요청에서 title 필드가 포함되어 있는지 확인.
    existingTask.setTitle((String) updates.get("title"));
}
  1. updates.get("title"): 요청에서 title의 값을 가져옴.
  2. setTitle: Task 객체의 title을 새 값으로 변경.
  3. 캐스팅: updates의 값은 Object 타입이므로 (String)으로 명시적 변환.

if (updates.containsKey("isDone")) { // 요청에 isDone 필드가 포함되어 있는지 확인.
    existingTask.setIsDone((Boolean) updates.get("isDone"));
}
  1. updates.get("isDone"): 요청에서 isDone의 값을 가져옴.
  2. setIsDone: Task 객체의 isDone 값을 새 값으로 변경.
  3. 캐스팅: updates의 값은 Object 타입이므로 (Boolean)으로 변환.

나머지 코드 설명은 코드에 주석으로 처리하였습니다.


4. AppController 구현

HTTP 요청을 받아 서비스 계층을 호출하여 처리 결과를 클라이언트에 반환하는 클래스입니다.

package com.example.techeer_partners_api_session.Controller;

import com.example.techeer_partners_api_session.TaskService.TaskService;
import com.example.techeer_partners_api_session.domain.Task;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/tasks")
public class TaskController {

    private final TaskService taskService;

    @Autowired
    // 생성자 주입
    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    // Task 생성
    @PostMapping // POST 요청이 오면 해당 메서드 실행
    public ResponseEntity<Map<String, Object>> createTask(@RequestBody Task task) {
        Task createdTask = taskService.createTask(task);

        // 응답 메시지 구성
        Map<String, Object> response = new LinkedHashMap<>();
        response.put("status", "success");
        response.put("message", "할 일이 생성되었습니다.");
        response.put("data", null);

        // 201 Created 상태 코드와 함께 응답 반환
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    // 모든 Task 조회
    @GetMapping // GET 요청이 오면 해당 메서드 실행
    public ResponseEntity<Map<String, Object>> getAllTasks() {
        List<Task> tasks = taskService.getAllTasks();

        // 응답 데이터 구성
        Map<String, Object> response = new LinkedHashMap<>();
        response.put("status", "success");
        response.put("message", "모든 일이 조회되었습니다.");
        response.put("data", tasks);

        // 200 OK 상태 코드와 함께 반환
        return ResponseEntity.ok(response);
    }

    // 완료된 Task 조회
    @GetMapping("/completed") // /tasks/completed로 GET 요청이 들어오면 해당 메서드 호출
    public ResponseEntity<Map<String, Object>> getCompletedTasks() {
        List<Task> completedTasks = taskService.getCompletedTasks();

        // 응답 데이터 구성
        Map<String, Object> response = new LinkedHashMap<>();
        response.put("status", "success");
        response.put("message", "완료 된 일이 조회되었습니다.");
        response.put("data", completedTasks);

        // 200 OK 상태 코드와 함께 반환
        return ResponseEntity.ok(response);
    }

    // 미완료된 Task 조회
    @GetMapping("/incomplete") // /tasks/incomplete로 GET 요청이 들어오면 해당 메서드 호출
    public ResponseEntity<Map<String, Object>> getIncompleteTasks() {

        List<Task> incompleteTasks = taskService.getIncompleteTasks();

        // 응답 데이터 구성
        Map<String, Object> response = new LinkedHashMap<>();
        response.put("status", "success");
        response.put("message", "미완료 된 일이 조회되었습니다.");
        response.put("data", incompleteTasks);

        // 200 OK 상태 코드와 함께 반환
        return ResponseEntity.ok(response);
    }

    // Task 수정
    @PatchMapping("/{id}") // /tasks/{id}로 PATCH 요청이 들어오면 해당 메서드 호출
    public ResponseEntity<Map<String, Object>> partialUpdateTask(
            @PathVariable Long id,
            @RequestBody Map<String, Object> updates) {

        taskService.partialUpdateTask(id, updates);

        // 응답 메시지 구성
        Map<String, Object> response = new LinkedHashMap<>();
        response.put("status", "success");
        response.put("message", "할 일이 수정되었습니다.");
        response.put("data", null);

        // 200 OK 상태 코드와 함께 응답 메시지 반환
        return ResponseEntity.ok(response);
    }

    // Task 삭제
    @DeleteMapping("/{id}") // /tasks/{id}로 DELETE 요청이 들어오면 해당 메서드 호출
    public ResponseEntity<Map<String, Object>> deleteTask(@PathVariable Long id) {
        taskService.deleteTask(id);

        // 응답 메시지 구성
        Map<String, Object> response = new LinkedHashMap<>();
        response.put("status", "success");
        response.put("message", "할 일이 삭제되었습니다.");
        response.put("data", null);

        // 200 OK 상태 코드와 함께 응답 메시지 반환
        return ResponseEntity.ok(response);
    }
}

[코드에 사용된 Annotation 일부 정리]

- @RestController란?

  • @Controller와 @ResponseBody를 결합한 형태로, 메서드의 반환 값을 JSON 또는 XML 형태로 반환합니다.

  • REST API의 엔드포인트를 구현하는 클래스에 사용합니다.

- @RequestMapping()란?

  • 클래스 또는 메서드에 매핑되는 URL 경로를 정의합니다.

  • 이 경우, /tasks로 시작하는 모든 요청은 이 컨트롤러에서 처리합니다.

- @RequsetBody란?

  • 클라이언트가 보낸 JSON 데이터를 Task 객체로 변환해 전달합니다.
  • 사용 예:
  1. 클라이언트 요청 (json)
{
  "title": "Learn Spring Boot",
  "description": "Study the basics of Spring Boot"
}
  1. 서버에서 Task 객체로 매핑 (java)
Task task = new Task("Learn Spring Boot", "Study the basics of Spring Boot");

- @PathVariable란?

  • URL 경로에서 값을 추출하는 데 사용됩니다.

  • 이 경우, /tasks/{id}로 들어오는 요청에서 {id} 값을 메서드 파라미터로 매핑합니다.
    (예: /tasks/5 → id = 5)

나머지 Annotation은 위 코드 내에 주석처리 하였습니다.


[코드 일부 해석 정리]

- ResponseEntity란?

  • HTTP 응답 상태 코드와 데이터를 함께 반환하는 데 사용됩니다.
  • 예:
return ResponseEntity.ok(createdTask); // HTTP 200 + 데이터
return ResponseEntity.noContent().build(); // HTTP 204 (No Content)
return ResponseEntity.notFound().build(); // HTTP 404 (Not Found)
  • 추가 설명
@GetMapping // GET 요청이 오면 해당 메서드 실행
    public ResponseEntity<Map<String, Object>> getAllTasks() {
        List<Task> tasks = taskService.getAllTasks();

        // 응답 데이터 구성
        Map<String, Object> response = new HashMap<>();
        response.put("status", "success");
        response.put("message", "모든 일이 조회되었습니다.");
        response.put("data", tasks);

        // 200 OK 상태 코드와 함께 반환
        return ResponseEntity.ok(response);
    }
  1. <Map<String, Object>> 타입을 맞춰줍니다.

    • Key (String): 응답 데이터 항목의 이름을 나타냅니다.
    • Value (Object): 항목의 값으로, 문자열, 숫자, 객체 등 어떤 타입도 올 수 있습니다
  2. LinkedHashMap<>을 사용하여 응답 메시지의 데이터를 저장할 공간을 생성하면서 입력된 순서대로 키를 유지합니다.

  3. response.put(key, value)로 저장

  4. ResponseEntity.ok(response)로 200 ok과 함께 response를 반환

- 수정 할 때 PUT vs PATCH

특징 :                   PUT	          ㅣ         PATCH
사용 목적 :        전체 리소스를 교체    ㅣ    리소스의 일부를 수정
전송 데이터 :     리소스 전체 데이터 필요 ㅣ  수정하려는 데이터만 필요
누락된 필드 :     기본값(null)로 초기화  ㅣ       기존 값 유지
멱등성 보장 :             보장          ㅣ     구현에 따라 다름
HTTP 요청 크기 :    크기가 클 수 있음	  ㅣ   크기가 상대적으로 작음

나머지 코드 해석은 위 코드 내에 주석처리 하였습니다.


postman을 이용하여 API 동작 확인

할 일 생성

전체 할 일 조회

완료된 일 조회

미완료된 일 조회

할 일 수정

할 일 삭제

아직 많이 부족 부분도 많지만 최대한 공부하면서 실습한 내용을 정리해 보았습니다. 감사합니다.

0개의 댓글