웹 프로그래밍/Spring Boot

[Spring Boot] CRUD 간단 구현

예찬예찬 2025. 5. 13. 20:24
728x90
반응형

Intro

사정이 있어 며칠만에 돌아와 글을 작성합니다.

 

지금까지 Spring Boot 기반으로 프로젝트 환경과 폴더 구조를 정리해왔습니다.
이제 본격적으로 간단한 CRUD 기능을 구현해보려 합니다.

전에, 제가 진행 중인 NoteAnywhere 프로젝트의 전반적인 구조를 간략히 소개드리겠습니다.
본격적인 코드 작성 전에 전체 흐름을 이해하는 도움이 될 것입니다.

 

ERD

사용자는 여러 개의 문서를 작성할 있고, 문서에는 여러 개의 댓글이 달릴 있는 구조입니다.

아래는 이를 시각화한 ERD입니다.

ERD

REST API 명세

API 명세는 Notion통해 관리하고 있습니다. 아래 링크를 참고해주세요.

https://imyeachan.notion.site/noteanywhere-api-docs

 

API 명세서 | Notion

✅ 사용자 API

imyeachan.notion.site

 

개발 환경 안내

  • 현재 프로젝트는 MySQLDocker 기반으로 구동하며,
    Spring Boot연결된 상태에서 API 개발을 진행합니다.
  • 프로젝트 전반은 도메인 중심 구조(Domain-Oriented Structure)따릅니다.

 

폴더 구조 재정의

 

자바의 클래스는 기본적으로 대문자 시작

 

기존에는 Controller, Service, Repository 파일들을 도메인과 별개의 폴더에 수평적으로 배치했지만,
보다 응집도 높은 구조, 도메인 단위 중심 구조(Domain-Oriented Structure)개편하였습니다.

 

이제는 도메인 관련 클래스들을 하나의 폴더에 모아
해당 도메인에 대한 코드의 응집도와 가독성을 크게 향상시켰습니다.

 

이런 구조를 가지게 되면 아래와 같은 이점이 있습니다.

  • 도메인 단위로 코드를 분리하면 서비스가 커져도 유지보수가 쉬움
  • 특정 도메인(user, note, comment)컨트롤러/서비스/엔티티를 한눈에 확인 가능
  • 향후 DDD모듈 분리로의 확장 시에도 유리함

다만, 이런 구조가 아직 익숙하지 않은 분들에게는 초반에 다소 혼란스러울 있습니다.
폴더가 많아 보이고 역할이 명확히 와닿지 않을 수도 있죠.

하지만 요즘 스프링 프로젝트의 트렌드는 도메인 중심 구조로 가는 흐름이고,
개발이나 장기적인 유지보수를 고려할 충분히 고려할 가치가 있는 구조라 판단하여 이 구조로 진항하려 합니다.

 

Lombok과 JPA

갑자기 이게 뭐여? 할 수 있습니다.

하지만 구현에 앞서 간단하게라도 꼭 알아두고 가야 할 부분이라 생각했습니다.

Lombok이란?

LombokJava에서 반복적으로 작성해야 하는 보일러플레이트 코드
간단한 어노테이션만으로 자동 생성해주는 도구입니다.

 

예로 아래처럼 @Getter, @Builder, @NoArgsConstructor 같은 애노테이션을 쓰면

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
    private Long id;
    private String name;
}

 

코드를 자동으로 만들어줍니다:

  • 모든 필드에 대한 get 메서드
  • 기본 생성자, 전체 생성자
  • 빌더 패턴을 활용한 객체 생성

덕분에 코드를 짧고 깔끔하게 유지할  있어 생산성 향상에 매우 유리하지요

JPA란?

JPA(Java Persistence API)Java에서 데이터베이스와 객체매핑해주는 기술입니다.
흔히 JPASpring Data JPA라는 구현체를 함께 사용합니다.

 

쉽게 말해, SQL 없이 자바 객체로 DB를 다룰 수 있게 해주는 기술입니다.

 

예로 User라는 클래스를 @Entity선언하고 JpaRepository상속하면

@Repository
public interface UserRepository extends JpaRepository<User, Long> {}

 

이런 SQL직접 작성하지 않아도 됩니다.

SELECT * FROM user WHERE user_id = 1;

 

그냥 userRepository.findById(1L); 이렇게 호출하면 끝!

 

정리해보면 아래와 같습니

  • Lombok은 👉 코드를 쉽고 짧게 쓰게 해주는 도구
  • JPA는 👉 SQL 없이 객체 지향 방식으로 DB다루게 해주는 기술

 

저희 프로젝트에서는 LombokJPA적극 사용할 예정입니다.

 

환경설정

 

이를 위해선 build.gradle에 아래와 같이 의존성을 추가해 줘야 합니다.

 

추가 후에는 IDE에서 Annotation Processing활성화주세요!
(IntelliJ: Settings > Build > Annotation Processors > Enable 체크)

 

또한 사전에 Docker로 실행해둔 MySQL 서버와도 연결해야 하니 application.properties에 DB 정보를 추가해 줘야 합니다.

 

실제 운영 환경에서는 위와 같은 DB 설정을 .env 파일이나 Secret Manager따로 분리해 관리하는 것이 일반적입니다.
하지만 지금은 로컬 개발 단계이므로 간단히 properties 파일에 작성하도록 하겠습니다.

 

그럼 진짜 최소한의 준비는 끝났으니 User 도메인부터 진행해보도록 하겠습니다.

User 도메인

1. User 엔티티 (domain/user/User.java)

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity // 이 클래스는 JPA가 관리하는 엔티티이며, DB의 테이블과 매핑됩니다.
@Table(name = "user") // 이 엔티티는 user라는 이름의 테이블과 매핑됩니다.
@Getter // 모든 필드의 Getter 메서드를 Lombok이 자동 생성해줍니다.
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 기본 생성자를 생성하되, 외부에서 직접 생성하지 못하게 보호합니다.
@AllArgsConstructor // 모든 필드를 매개변수로 받는 생성자를 Lombok이 생성해줍니다.
@Builder // 빌더 패턴으로 객체를 생성할 수 있게 합니다.
public class User {

    @Id // 기본키(primary key)로 지정합니다.
    @GeneratedValue(strategy = GenerationType.IDENTITY) // MySQL의 AUTO_INCREMENT 전략으로 ID를 생성합니다.
    private Long userId;

    // 사용자 이름 (null 불가 조건 없음, 기본 문자열)
    private String userName;

    // 프로필 이미지 URL
    private String userProfile;

    // 가입 시각 (엔티티 저장 시 자동 설정됨)
    private LocalDateTime createdAt;

    // 탈퇴 시각 (soft delete에 사용)
    private LocalDateTime deletedAt;

    @PrePersist // 엔티티가 저장되기 전에 실행되는 메서드
    public void onCreate() {
        this.createdAt = LocalDateTime.now(); // 최초 생성 시 현재 시간으로 createdAt 설정
    }
}

 

간단 설명

User 클래스는 DBuser 테이블과 매핑되는 JPA 엔티티입니다.

  • @Entity, @Table통해 JPA에서 관리됩니다.
  • @Id, @GeneratedValue자동 증가되는 기본키를 지정합니다.
  • @Getter, @Builder Lombok통해 반복 코드를 줄였습니다.
  • @PrePersist사용해 생성 시점에 createdAt자동으로 채워줍니다.
  • 추후 deletedAt 필드를 활용하여 soft delete구현 가능합니다.

 

2. UserRepository (domain/user/UserRepository.java)

package com.dannyPlayground.noteAnywhere.domain.user;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

/**
 * UserRepository는 User 엔티티를 위한 JPA 인터페이스입니다.
 * JpaRepository를 상속받으면 기본 CRUD 메서드(findAll, save, delete 등)를 자동으로 사용할 수 있습니다.
 */
public interface UserRepository extends JpaRepository<User, Long> {

    /**
     * soft delete가 적용되지 않은 사용자만 조회합니다.
     * deletedAt이 null인 사용자 중 userId로 조회합니다.
     *
     * Spring Data JPA는 메서드 이름만으로도 자동으로 SQL 쿼리를 생성합니다.
     * → SELECT * FROM user WHERE user_id = ? AND deleted_at IS NULL;
     */
    Optional<User> findByUserIdAndDeletedAtIsNull(Long userId);
}

간단 설명

  • JpaRepository상속하여 기본적인 CRUD 메서드를 자동 제공합니다.
  • findByUserIdAndDeletedAtIsNull 메서드를 통해 soft delete적용되지 않은 유저만 조회합니다.

 

3. UserCreateRequest DTO (domain/user/dto/UserCreateRequest.java)

 
package com.dannyPlayground.noteAnywhere.domain.user.dto;

/**
 * 사용자를 생성할 때 클라이언트로부터 전달받는 요청 데이터를 담는 DTO입니다.
 * 
 * 이 클래스는 주로 POST /users API의 요청 바디를 매핑하는 데 사용되며,
 * userName과 userProfile 필드를 포함합니다.
 * 
 * Java 16 이상의 record 문법을 사용하여 생성자, getter, equals, hashCode 등을 자동으로 생성합니다.
 */
public record UserCreateRequest(
    String userName,      // 사용자 이름
    String userProfile    // 프로필 이미지 URL
) {}

간단 설명

  • 클라이언트가 사용자 생성 요청 보내는 JSON받을 DTO입니다.

 

4. UserService (domain/user/UserService.java)

 
package com.dannyPlayground.noteAnywhere.domain.user;
import com.dannyPlaygroud.noteAnywhere.domain.user.dto.UserCreateRequest;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;


import java.time.LocalDateTime;
import java.util.List;

/**
 * UserService는 사용자와 관련된 비즈니스 로직을 처리하는 계층입니다.
 * - @Service: 해당 클래스를 서비스 빈으로 등록
 * - @RequiredArgsConstructor: final 필드 기반 생성자 자동 주입 (Lombok)
 */
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    /**
     * 전체 사용자 목록을 조회합니다.
     * deletedAt이 null인 사용자만 필터링하여 반환합니다.
     * soft delete된 사용자는 포함되지 않습니다.
     *
     * @return 삭제되지 않은 사용자 목록
     */
    public List<User> getAllUsers() {
        return userRepository.findAll().stream()
                .filter(user -> user.getDeletedAt() == null)
                .toList();
    }

    /**
     * 특정 사용자 ID로 사용자 정보를 조회합니다.
     * soft delete가 적용되지 않은 사용자만 조회 대상입니다.
     *
     * @param id 사용자 ID
     * @return 해당 사용자 객체
     * @throws IllegalArgumentException 사용자가 존재하지 않을 경우 예외 발생
     */
    public User getUserById(Long id) {
        return userRepository.findByUserIdAndDeletedAtIsNull(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 유저가 존재하지 않습니다."));
    }

    /**
     * 새로운 사용자를 생성합니다.
     * Builder 패턴을 활용해 User 엔티티 객체를 생성하고 저장합니다.
     *
     * @param request 사용자 생성 요청 DTO
     * @return 생성된 사용자 객체
     */
    @Transactional
    public User createUser(UserCreateRequest request) {
        return userRepository.save(
                User.builder()
                        .userName(request.userName())
                        .userProfile(request.userProfile())
                        .build()
        );
    }

    /**
     * 사용자를 soft delete 처리합니다.
     * 실제로 데이터를 삭제하지 않고, deletedAt 필드에 현재 시각을 기록합니다.
     * → 추후 getAllUsers() 등에서 필터링되어 조회되지 않도록 함
     *
     * @param id 삭제할 사용자 ID
     */
    @Transactional
    public void deleteUser(Long id) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."));

        if (user.getDeletedAt() != null) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 삭제된 사용자입니다.");
        }

        // 기존 필드를 복사하여 deletedAt만 추가된 새 객체로 재저장
        userRepository.save(
                User.builder()
                        .userId(user.getUserId())
                        .userName(user.getUserName())
                        .userProfile(user.getUserProfile())
                        .createdAt(user.getCreatedAt())
                        .deletedAt(LocalDateTime.now())
                        .build()
        );
    }
}

 

간단 설명

  • @Transactional통해 DB 트랜잭션을 관리합니다.
  • deletedAt 필드를 활용한 soft delete 구현이 핵심입니다.
  • DTO통해 입력값을 받고, Builder 패턴으로 엔티티 생성.
  • 간단한 예외처리도 진행했씁니다.

5. UserController (domain/user/UserController.java)

package com.dannyPlayground.noteAnywhere.domain.user;

import com.dannyPlayground.noteAnywhere.domain.user.dto.UserCreateRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * UserController는 사용자 관련 요청을 처리하는 REST API 컨트롤러입니다.
 * @RestController: JSON 형태로 응답을 반환하는 컨트롤러임을 명시
 * @RequestMapping("/users"): 모든 요청은 /users 경로를 기준으로 처리
 */
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor // final 필드를 생성자로 주입 (Lombok)
public class UserController {

    private final UserService userService;

    /**
     * [GET] /users
     * 전체 사용자 목록을 조회합니다.
     * soft delete된 사용자는 포함되지 않습니다.
     *
     * @return 삭제되지 않은 사용자 리스트 (200 OK)
     */
    @GetMapping
    public ResponseEntity<List<User>> getAllUsers() {
        return ResponseEntity.ok(userService.getAllUsers());
    }

    /**
     * [GET] /users/{id}
     * 특정 사용자 ID로 사용자 정보를 조회합니다.
     * soft delete된 사용자는 조회되지 않습니다.
     *
     * @param id 사용자 ID (PathVariable)
     * @return 해당 사용자 정보 (200 OK)
     */
    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        return ResponseEntity.ok(userService.getUserById(id));
    }

    /**
     * [POST] /users
     * 새로운 사용자를 생성합니다.
     *
     * @param request 사용자 생성 요청 DTO (RequestBody)
     * @return 생성된 사용자 정보 (200 OK)
     */
    @PostMapping
    public ResponseEntity<User> createUser(@RequestBody UserCreateRequest request) {
        return ResponseEntity.ok(userService.createUser(request));
    }

    /**
     * [DELETE] /users/{id}
     * 사용자를 soft delete 처리합니다.
     * 실제 삭제하지 않고 deletedAt 필드만 갱신합니다.
     *
     * @param id 사용자 ID (PathVariable)
     * @return 204 No Content (성공적으로 삭제됨)
     */
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.noContent().build(); // 204 응답 반환
    }
}

간단 설명

  • @RestControllerJSON 기반 REST API제공합니다.
  • /users 경로로 사용자 관련 CRUD API매핑합니다.
  • 서비스 계층을 호출해 비즈니스 로직을 처리하며, 응답은 ResponseEntity래핑.

마무리

comment와 note도 마찬가지로 기본 구현은 동일하게 진행됩니다.

간단하기 crud만 나머지도 구현해봅시다.

 

728x90
반응형