본문 바로가기
2026 Dev Log

[Spring Boot] Service 파일 CRUD

by Dev.후크리 2026. 4. 29.

일반적인 로직을 짤 때 다음과 같은 기본 과정을 우선 떠올린다.

Controller 요청 → Repository 호출 → Entity 저장/조회/수정/삭제 → DTO 반환

 

여기서 Repository와 Entity 는 기능 집합 라이브러리와 객체의 개념으로 이해했다.

그렇기에 우린 이 기능과 객체를 활용할 코드 파일을 만들어야 한다.

그것이 Service 파일이다.

 

우리는 서비스 파일에서 흔히 로직, 알고리즘이라 부르는 코드를 주로 작성한다.

단순히 데이터를 DB에 삽입(Create)하고, 읽고(Read), 수정(Update)하고, 삭제(Delete)하는 것을 넘어 읽은 데이터를 활용해 작업하여 DB에 삽입하거나 기존 DB의 데이터에서 수정한다. 

결국엔 백엔드는 데이터를 다루고 관리하는 일을 한다.

 

우선 가장 기본이 되는 CRUD에 해당하는 코드를 먼저 보고 설명을 하겠다.

@Service
@RequiredArgsConstructor
@Transactional
public class PostService {

    private final PostRepository postRepository;

    // 생성
    public PostResponse create(PostRequest request) {
        Post post = new Post(
                request.getTitle(),
                request.getContent()
        );

        Post savedPost = postRepository.save(post);

        return PostResponse.from(savedPost);
    }

    // 전체 조회
    @Transactional(readOnly = true)
    public List<PostResponse> findAll() {
        return postRepository.findAll()
                .stream()
                .map(PostResponse::from)
                .toList();
    }

    // 단건 조회
    @Transactional(readOnly = true)
    public PostResponse findById(Long id) {
        Post post = postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("게시글이 존재하지 않습니다."));

        return PostResponse.from(post);
    }

    // 수정
    public PostResponse update(Long id, PostRequest request) {
        Post post = postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("게시글이 존재하지 않습니다."));

        post.update(
                request.getTitle(),
                request.getContent()
        );

        return PostResponse.from(post);
    }

    // 삭제
    public void delete(Long id) {
        Post post = postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("게시글이 존재하지 않습니다."));

        postRepository.delete(post);
    }
}

 

요즘 보면 코드 방식이 바뀐 것 같다.

작년에 공부할 땐 트랜잭션을 따로 안해주었던 것 같은데, 결론적으로 위 코드는 방법론이다.

위 코드를 지피티로 만들었다면,

실제로 다양한 상황에서 사용하는 코드를 가져오면 다음과 같다.

 

package com.example.demo.service;

import com.example.demo.dto.ItemRequestDto;
import com.example.demo.dto.ItemResponseDto;
import com.example.demo.entity.Item;
import com.example.demo.repository.ItemRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

// ================================================================
//  스프링부트 CRUD 서비스 - 3가지 패턴 총정리
// ================================================================
//
//  [공통 Entity 구조 가정]
//
//  @Entity
//  @Getter
//  @NoArgsConstructor
//  public class Item {
//      @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
//      private Long id;
//      private String name;
//      private Integer price;
//      private String description;
//
//      @Builder
//      public Item(String name, Integer price, String description) { ... }
//
//      public void update(String name, Integer price, String description) {
//          this.name = name;
//          this.price = price;
//          this.description = description;
//      }
//  }
//
//  [DTO 구조 가정]
//
//  public class ItemRequestDto {
//      private String name;
//      private Integer price;
//      private String description;
//
//      public Item toEntity() {                          // Dto → Entity 변환
//          return Item.builder()
//              .name(this.name).price(this.price)
//              .description(this.description).build();
//      }
//  }
//
//  public class ItemResponseDto {
//      private Long id;
//      private String name;
//      private Integer price;
//      // description 등 불필요하거나 민감한 필드는 여기서 제외
//
//      public static ItemResponseDto from(Item item) {  // Entity → Dto 변환
//          return new ItemResponseDto(item.getId(), item.getName(), item.getPrice());
//      }
//  }
//
// ================================================================


// ================================================================
//  패턴 1. RequestDto + ResponseDto 완전 분리
// ================================================================
//
//  데이터 흐름:
//    Controller → RequestDto → Service → Entity → DB      (입력)
//    DB → Entity → ResponseDto → Controller               (출력)
//
//  언제 쓰나?
//    - 실무 중대형 프로젝트의 정석 패턴
//    - 응답에서 민감 필드 제거, 입력 유효성(@Valid) 분리가 필요할 때
//    - Controller ↔ Service 간 결합을 최소화하고 싶을 때
// ================================================================

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)  // 클래스 기본값: 읽기 전용 트랜잭션 (SELECT 성능 최적화)
class ItemServiceWithDto {

    private final ItemRepository itemRepository;

    // ────────────────────────────────────
    // CREATE
    // ────────────────────────────────────

    /**
     * 흐름: RequestDto → Entity 변환 → save() → DB INSERT → ResponseDto 반환
     */
    @Transactional  // 클래스의 readOnly=true를 오버라이드 → 쓰기 가능
    public ItemResponseDto createItem(ItemRequestDto requestDto) {

        // [1] RequestDto → Entity 변환
        //     방법 A: Dto 안의 toEntity() 메서드 (Dto가 변환 책임)
        Item item = requestDto.toEntity();

        //     방법 B: 서비스에서 직접 빌더 (서비스가 변환 책임)
        //     Item item = Item.builder()
        //             .name(requestDto.getName())
        //             .price(requestDto.getPrice())
        //             .description(requestDto.getDescription())
        //             .build();

        // [2] DB INSERT - save()는 저장된 Entity(id가 채워진 상태)를 반환
        Item savedItem = itemRepository.save(item);

        // [3] Entity → ResponseDto 변환 후 반환
        //     방법 A: 정적 팩토리 메서드 (ResponseDto.from)
        return ItemResponseDto.from(savedItem);

        //     방법 B: 생성자 직접 사용
        //     return new ItemResponseDto(savedItem.getId(), savedItem.getName(), savedItem.getPrice());
    }

    // ────────────────────────────────────
    // READ - 단건
    // ────────────────────────────────────

    /**
     * 흐름: id → DB SELECT → Entity → ResponseDto 반환
     */
    public ItemResponseDto getItem(Long id) {

        // findById는 Optional<Entity> 반환 → 없으면 예외
        Item item = itemRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("Item not found. id=" + id));

        return ItemResponseDto.from(item);
    }

    // ────────────────────────────────────
    // READ - 목록
    // ────────────────────────────────────

    /**
     * 흐름: DB 전체 SELECT → List<Entity> → stream으로 List<ResponseDto> 변환 후 반환
     */
    public List<ItemResponseDto> getAllItems() {

        return itemRepository.findAll()   // SELECT * FROM item
                .stream()
                .map(ItemResponseDto::from)           // 각 Entity → ResponseDto
                .collect(Collectors.toList());

        // Java 16+ : .toList() 사용 가능 (불변 리스트)
    }

    // ────────────────────────────────────
    // UPDATE
    // ────────────────────────────────────

    /**
     * 흐름: id + RequestDto → 기존 Entity 조회 → 필드 변경 → 자동 UPDATE → ResponseDto 반환
     *
     * 더티 체킹(Dirty Checking)이란?
     *   JPA가 영속성 컨텍스트 안의 Entity 변경을 감지해,
     *   @Transactional 커밋 시점에 자동으로 UPDATE 쿼리를 발행하는 기능.
     *   → 명시적 save() 없이도 수정이 DB에 반영됨.
     */
    @Transactional  // 더티 체킹이 동작하려면 쓰기 트랜잭션 필수
    public ItemResponseDto updateItem(Long id, ItemRequestDto requestDto) {

        // [1] 수정할 Entity 조회 (영속성 컨텍스트에 올림)
        Item item = itemRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("Item not found. id=" + id));

        // [2] Entity 필드 변경
        //     Entity 안에 update() 메서드를 두는 방식 (도메인 로직 캡슐화)
        item.update(requestDto.getName(), requestDto.getPrice(), requestDto.getDescription());

        // [3] save() 없이 트랜잭션 종료 시 자동 UPDATE 쿼리 발행 (더티 체킹)
        //     → 명시적으로 save()를 호출해도 동작은 동일하지만 불필요한 중복

        // [4] 변경된 Entity → ResponseDto 반환
        return ItemResponseDto.from(item);
    }

    // ────────────────────────────────────
    // DELETE
    // ────────────────────────────────────

    /**
     * 흐름: id → 존재 확인 → DB DELETE
     */
    @Transactional
    public void deleteItem(Long id) {

        // 존재하지 않으면 명시적 예외 발생
        Item item = itemRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("Item not found. id=" + id));

        // 방법 A: 조회한 Entity 객체로 삭제
        itemRepository.delete(item);

        // 방법 B: id로 바로 삭제 (존재 확인 생략 시)
        //         없는 id면 EmptyResultDataAccessException 발생
        // itemRepository.deleteById(id);
    }

    // ────────────────────────────────────
    // 참고: 조건 검색 (JPQL / 메서드 쿼리)
    // ────────────────────────────────────

    /**
     * Repository에 JPQL 정의 예시:
     *   @Query("SELECT i FROM Item i WHERE i.name LIKE %:name%")
     *   List<Item> findByNameContaining(@Param("name") String name);
     */
    public List<ItemResponseDto> searchByName(String name) {
        return itemRepository.findByNameContaining(name)
                .stream()
                .map(ItemResponseDto::from)
                .collect(Collectors.toList());
    }
}


// ================================================================
//  패턴 2. DTO 없이 Entity 직통
// ================================================================
//
//  데이터 흐름:
//    Controller → Entity → Service → DB      (입력)
//    DB → Entity → Controller                (출력)
//
//  언제 쓰나?
//    - 소규모 프로젝트, 내부 API, 빠른 프로토타이핑
//    - 응답 필드 제어가 불필요하고 구조를 단순하게 유지할 때
//
//  주의사항:
//    - 민감한 필드(password 등)가 그대로 응답에 노출될 수 있음
//    - @OneToMany 등 양방향 연관관계 있을 때 Jackson 순환 참조 발생 가능
//      → @JsonIgnore 또는 @JsonManagedReference / @JsonBackReference 로 해결
// ================================================================

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
class ItemServiceWithoutDto {

    private final ItemRepository itemRepository;

    // ────────────────────────────────────
    // CREATE
    // ────────────────────────────────────

    /**
     * 흐름: Entity → save() → DB INSERT → Entity 반환
     *
     * Controller에서 @RequestBody Item item 으로 받으면
     * Jackson이 JSON → Entity 자동 역직렬화.
     * id 필드는 클라이언트가 보내지 않아야 함 (DB가 자동 생성).
     */
    @Transactional
    public Item createItem(Item item) {

        // id가 null → 신규 INSERT
        // id가 있으면 → UPDATE(merge)로 동작
        return itemRepository.save(item);
    }

    // ────────────────────────────────────
    // READ - 단건
    // ────────────────────────────────────

    public Item getItem(Long id) {
        return itemRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("Item not found. id=" + id));
    }

    // ────────────────────────────────────
    // READ - 목록
    // ────────────────────────────────────

    public List<Item> getAllItems() {
        return itemRepository.findAll();  // Entity 리스트 그대로 반환
    }

    // ────────────────────────────────────
    // UPDATE - 더티 체킹 방식
    // ────────────────────────────────────

    /**
     * 흐름: 기존 Entity 조회 → 필드 덮어쓰기 → 트랜잭션 커밋 시 자동 UPDATE
     *
     * save() 없이 더티 체킹으로 처리.
     */
    @Transactional
    public Item updateItem(Long id, Item updatedItem) {

        Item item = itemRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("Item not found. id=" + id));

        // 영속 상태의 Entity 필드 변경 → 커밋 시 자동 UPDATE
        item.update(updatedItem.getName(), updatedItem.getPrice(), updatedItem.getDescription());

        return item;  // 수정된 Entity 반환 (save() 불필요)
    }

    // ────────────────────────────────────
    // UPDATE - 명시적 save() 방식
    // ────────────────────────────────────

    /**
     * 더티 체킹 대신 save()를 명시적으로 호출하는 방식.
     * 동작 결과는 동일하나, 코드 의도를 명확히 표현하고 싶을 때 사용.
     */
    @Transactional
    public Item updateItemExplicit(Long id, Item updatedItem) {

        Item item = itemRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("Item not found. id=" + id));

        item.update(updatedItem.getName(), updatedItem.getPrice(), updatedItem.getDescription());

        // 이미 영속 상태이므로 save()는 내부적으로 merge 처리
        return itemRepository.save(item);
    }

    // ────────────────────────────────────
    // DELETE
    // ────────────────────────────────────

    @Transactional
    public void deleteItem(Long id) {

        // existsById로 존재 확인 후 삭제
        if (!itemRepository.existsById(id)) {
            throw new EntityNotFoundException("Item not found. id=" + id);
        }

        itemRepository.deleteById(id);
    }
}


// ================================================================
//  패턴 3. 혼합 패턴 - Entity로 받고 ResponseDto로 내보내기
// ================================================================
//
//  데이터 흐름:
//    Controller → Entity → Service → DB      (입력)
//    DB → Entity → ResponseDto → Controller  (출력)
//
//  언제 쓰나?
//    - 입력 구조는 단순하지만, 응답에서 특정 필드를 제거/가공해야 할 때
//    - 예: 비밀번호는 Entity로 그대로 받고, 응답에서 비밀번호 필드만 제외
//    - 소규모~중규모에서 많이 쓰이는 현실적인 절충안
// ================================================================

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
class ItemServiceHybrid {

    private final ItemRepository itemRepository;

    // ────────────────────────────────────
    // CREATE
    // ────────────────────────────────────

    @Transactional
    public ItemResponseDto createItem(Item item) {
        Item savedItem = itemRepository.save(item);
        return ItemResponseDto.from(savedItem);  // Entity로 받고 → ResponseDto로 반환
    }

    // ────────────────────────────────────
    // READ - 단건
    // ────────────────────────────────────

    public ItemResponseDto getItem(Long id) {
        Item item = itemRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("Item not found. id=" + id));
        return ItemResponseDto.from(item);
    }

    // ────────────────────────────────────
    // READ - 목록
    // ────────────────────────────────────

    public List<ItemResponseDto> getAllItems() {
        return itemRepository.findAll()
                .stream()
                .map(ItemResponseDto::from)
                .collect(Collectors.toList());
    }

    // ────────────────────────────────────
    // UPDATE
    // ────────────────────────────────────

    @Transactional
    public ItemResponseDto updateItem(Long id, Item updatedItem) {
        Item item = itemRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("Item not found. id=" + id));

        item.update(updatedItem.getName(), updatedItem.getPrice(), updatedItem.getDescription());
        return ItemResponseDto.from(item);  // 더티 체킹 후 ResponseDto로 반환
    }

    // ────────────────────────────────────
    // DELETE
    // ────────────────────────────────────

    @Transactional
    public void deleteItem(Long id) {
        Item item = itemRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("Item not found. id=" + id));
        itemRepository.delete(item);
    }
}


// ================================================================
//  패턴 비교 요약
// ================================================================
//
//  패턴                  입력          출력           적합한 규모
//  ─────────────────────────────────────────────────────────────
//  1. 완전 분리          RequestDto   ResponseDto   중대형, 정석
//  2. Entity 직통        Entity       Entity        소형, 프로토타입
//  3. 혼합               Entity       ResponseDto   소중형, 절충안
//
//  핵심 개념 정리:
//
//  @Transactional(readOnly=true)
//    → 클래스 기본값. flush 비활성화로 SELECT 성능 향상.
//      스냅샷 저장을 생략하므로 메모리 절약.
//
//  @Transactional (쓰기)
//    → 메서드에 붙이면 readOnly=true를 오버라이드.
//      INSERT / UPDATE / DELETE가 있는 메서드에 필수.
//
//  더티 체킹 (Dirty Checking)
//    → @Transactional 안에서 조회한 Entity를 수정하면
//      커밋 시 JPA가 변경을 감지해 자동 UPDATE 쿼리 발행.
//      명시적 save() 없이도 수정이 DB에 반영됨.
//
//  repository.save()
//    → id == null  : 신규 INSERT (persist)
//    → id != null  : 기존 UPDATE (merge)
//      저장된 Entity를 반환하므로 id가 필요할 때 반환값 사용.
//
//  findById() vs existsById()
//    → findById  : Optional<Entity> 반환, Entity가 필요할 때 사용
//    → existsById: boolean 반환, 존재 여부만 확인할 때 사용 (SELECT 1 쿼리로 경량)
// ================================================================