Spring 입문 (JPA CRUD 실습)

KimGwangmin·2026년 9월 7일

@Transactional

  • 트랜잭션: 논리적으로 하나의 작업 단위(원자성)
    • 예: A가 B에게 10000원을 송금하는 작업이라면
      1. A의 계좌에서 10000원 차감
      2. B의 계좌에 10000원 추가
      3. 장부에 송금 내역 기록
  • 비즈니스 로직이므로 Service 계층에서 처리
  • @Transactional을 붙여주면 해당 메서드는 트랜잭션으로 관리된다
  • @Transactional 속성
    • readOnly: 읽기 전용(CUD 불가), 성능 최적화
    • propagation: 트랜잭션 전파 규칙 정의 (REQUIRED, REQUIRES_NEW 등)
    • isolation: 트랜잭션 격리 수준 설정 (동시성 문제 제어)
    • rollbackFor: 특정 예외 상황에 강제 롤백 지정

입문 주차에선 readOnly 속성만 알아본다.

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;
    /**
     * 회원가입 (새 데이터 추가 - readOnly = false)
     */
    @Transactional
    public Long signUp(String email) {
        // 이메일 중복 검사 등의 로직이 포함될 수 있음
        Member member = new Member(email);
        memberRepository.save(member);
        return member.getId();
    }

    /**
     * 회원 조회 (데이터 변경이 없는 읽기 작업)
     * readOnly = true 속성으로 성능을 최적화합니다.
     */
    @Transactional(readOnly = true)
    public Member findMemberByEmail(String email) {
        return memberRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("해당 이메일의 유저가 없습니다."));
    }
}

JpaRepository CRUD

  • 쿼리 메서드 사용: SQL 쿼리를 작성하지 않아도 DB에 CRUD 작업 가능
    • Create: save(entity)
    • Read: findById, findAll
    • Update: 트랜잭션 안에서 엔티티 필드 변경 -> 커밋 시 자동 UPDATE (Dirty Checking)
    • Delete: delete(entity), deleteById, deleteAll

실습

프로젝트 세팅은 이 글의 실습 탭 참조

1. 3 Layer Archietecture 기반 패키지 구성

2. Entity 클래스(User) 생성

  • id를 제외한 필드만 초기화하는 생성자 필요 (@AllArgsConstructor 사용하면 안됨!)
    • id@GeneratedValue(strategy = GenerationType.IDENTITY)에 따라 자동 생성되기 때문에, 임의로 값을 설정해선 안됨
  • 추후 Service 레이어에서 Update 작업을 구현하기 위해 update 메서드 구현 필요
@Getter
@Entity
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(length = 50, nullable = false)
    private String name;
    @Column(unique = true, nullable = false)
    private String email;
    private String address;

    public User(String name, String email, String address) {
        this.name = name;
        this.email = email;
        this.address = address;
    }
    
    public void update(String name, String email, String address) { // 추후 Update 작업에 필요
        this.name = name;
        this.email = email;
        this.address = address;
    }
}

3. Repository 인터페이스(UserRepository) 생성

public interface UserRepository extends JpaRepository<User, Long> {
    // 내부를 비워도 JpaRepository가 갖고 있는 save(), findById(), ... 등의 메서드 사용 가능
    // 커스텀 쿼리 메서드 정의 가능
    // 예: Optional<User> findByEmail(String email);
}

4. Service 클래스(UserService) 생성

  • 서비스 레이어
  • 레포지토리 레이어인 UserRepository를 필드로 가지는 것이 자연스러운 설계
@Service
@RequiredArgsConstructor // final 필드인 userRepository를 초기화하는 생성자 필요
public class UserService {
    private final UserRepository userRepository;
}

5. Controller 클래스(UserController) 생성

  • 컨트롤러 레이어
  • 서비스 레이어인 UserService를 필드로 가지는 것이 자연스러운 설계
@RestController
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;
}

6. UserService 클래스에 CRUD 작업 구현

3 Layer Architecture에 의해 Controller는 User 엔티티를 직접 참조하면 안된다!

  • Entity는 DB의 테이블과 1:1로 매핑되는 객체이므로, 상위 계층인 Controller에서는 직접 참조하지 않고, Service 레이어와 Repository 레이어에서만 접근할 수 있다.
  • Controller와 Service 레이어 간에는 DTO를 통해 데이터를 주고받아야 한다.
  • Service는 요청 DTO를 가지고 Entity를 만들어 Repository에 DB CRUD 연산을 요청한 뒤, 결과를 다시 응답 DTO로 만들어 반환한다.

1. Create

DTO 정의

CreateUserRequest

  • id 필드가 없다. id는 요청에 필요한 정보가 아니라, DB에서 자동 생성되는 정보이다.
  • 생성자가 없고, final 키워드가 없다.
    • 라이브러리 내부적으로 기본 생성자를 사용해 빈 객체를 먼저 생성한 뒤 값을 주입하기 때문에, 상수로 만드는 final 키워드를 사용하면 안된다.
@Getter
public class CreateUserRequest {
    public String name;
    public String email;
    public String address;
}

CreateUserResponse

  • 필드가 final로 설정된다.
    • Service 계층에서 응답한 데이터가 임의로 변경되어선 안되기 때문이다.
    • 생성자가 당연히 필요하다.
@Getter
@RequiredArgsConstructor
public class CreateUserResponse {
    private final Long id;
    private final String name;
    private final String email;
    private final String address;
}
  • 다음과 같은 생성자를 추가하는 것도 고려해볼 수 있겠다.
  • 매번 필드 각각을 입력하는 번거로움을 줄일 수 있다.
  • 여전히 엔티티는 Controller에게 노출되지 않으니 구조적인 문제도 없다.
  • 단, 반대로 엔티티가 Requset DTO를 받아 초기화하는 형태의 생성자는 권장되지 않는다.
    • DTO는 화면 기획이나 클라이언트 요청 스펙에 따라 수시로 변경될 수 있다.
    • 안쪽 계층이 바깥쪽 계층에 의존하게 되면 시스템 결합도가 급격히 높아진다.
@Getter
public class CreateUserResponse {
    private final Long id;
    private final String name;
    private final String email;
    private final String address;

    // Entity를 직접 전달받아 필드 매핑
    public CreateUserResponse(User user) {
        this.id = user.getId();
        this.name = user.getName();
        this.email = user.getEmail();
        this.address = user.getAddress();
    }
}
  • 혹은, 정적 팩토리 메서드를 도입해볼 수도 있겠다.
    • Gemini가 말하길 실무에선 이런 패턴을 주로 사용한다고 한다.
      • 처음에는 생성자인 줄 알고 헷갈렸는데, from이 예약어 같은 게 아닌 단순 메서드 이름이고 GetUserResponse는 단순히 메서드 리턴 타입이다. (Unity 개발에선 메서드를 PascalCase로 쓰는 것이 일반적이다보니 이런 부분에서 가끔 혼동이 온다.)
    • 본 실습에서도 이 패턴으로 DTO를 작성하겠다.
@Getter
@RequiredArgsConstructor
public class GetUserResponse {
    private final Long id;
    private final String name;
    private final String email;
    private final String address;

    public static GetUserResponse from(User user) {
        return new GetUserResponse(
            user.getId(),
            user.getName(),
            user.getEmail(),
            user.getAddress()
        );
    }
}
@Transactional
public CreateUserResponse save (CreateUserRequest request) {
    User user = new User(
            request.getName(),
            request.getEmail(),
            request.getAddress()
    );
    User savedUser = userRepository.save(user);
    return CreateUserResponse.from(savedUser);
}

2. Read

DTO 정의
현재 구현에서 `GET` '요청'에는 DTO가 필요하지 않으니 작성하지 않아도 된다.

GetUserResponse

@Getter
@RequiredArgsConstructor
public class GetUserResponse {
    private final Long id;
    private final String name;
    private final String email;
    private final String address;

    public static GetUserResponse from(User user) {
        return new GetUserResponse(
                user.getId(),
                user.getName(),
                user.getEmail(),
                user.getAddress()
        );
    }
}
// 전체 조회
@Transactional  
public List<GetUserResponse> getAll() {  
    List<User> userList = userRepository.findAll();  
    List<GetUserResponse> dtoList = new ArrayList<>();  
  
    for (User user : userList) {  
        dtoList.add(GetUserResponse.from(user));  
    }  
    return dtoList;  
}  

// 단 건 조회
@Transactional  
public GetUserResponse getOne(Long userId) {  
    User user = userRepository.findById(userId).orElseThrow(  
            () -> new IllegalStateException("User not found.")  
    );  
    return GetUserResponse.from(user);  
}

3. Update

DTO 정의

UpdateUserRequest

@Getter
public class UpdateUserRequest {
    private String name;
    private String email;
    private String address;
}

UpdateUserResponse

@Getter
@RequiredArgsConstructor
public class UpdateUserResponse {
  private final Long id;
  private final String name;
  private final String email;
  private final String address;
  
  public static UpdateUserResponse from(User user) {
      return new UpdateUserResponse(
              user.getId(),
              user.getName(),
              user.getEmail(),
              user.getAddress()
      );
  }
}
@Transactional  
public UpdateUserResponse update(Long userId, UpdateUserRequest request) {  
    User user = userRepository.findById(userId).orElseThrow(  
            () -> new IllegalStateException("User not found.")   
    );  
    user.update(  
            request.getName(),  
            request.getEmail(),  
            request.getAddress()  
    );  
    return UpdateUserResponse.from(user);  
}

4. Delete

  • Delete는 userId를 받아 삭제하는 메서드로, DTO가 필요하지 않다.
@Transactional  
public void delete(Long userId) {  
    if (!userRepository.existsById(userId)) {  
        throw new IllegalStateException("User not found.");  
    }  
    userRepository.deleteById(userId);  
}

전체 Service 코드

펼치기
package org.example.jpapractice.service;

import lombok.RequiredArgsConstructor;
import org.example.jpapractice.dto.*;
import org.example.jpapractice.entity.User;
import org.example.jpapractice.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;

    @Transactional
    public CreateUserResponse save (CreateUserRequest request) {
        User user = new User(
                request.getName(),
                request.getEmail(),
                request.getAddress()
        );
        User savedUser = userRepository.save(user);
        return CreateUserResponse.from(savedUser);
    }

    @Transactional
    public List<GetUserResponse> getAll() {
        List<User> userList = userRepository.findAll();
        List<GetUserResponse> dtoList = new ArrayList<>();

        for (User user : userList) {
            dtoList.add(GetUserResponse.from(user));
        }
        return dtoList;
    }

    @Transactional
    public GetUserResponse getOne(Long userId) {
        User user = userRepository.findById(userId).orElseThrow(
                () -> new IllegalStateException("User not found.")
        );
        return GetUserResponse.from(user);
    }

    @Transactional
    public UpdateUserResponse update(Long userId, UpdateUserRequest request) {
        User user = userRepository.findById(userId).orElseThrow(
                () -> new IllegalStateException("User not found.")
        );
        user.update(
                request.getName(),
                request.getEmail(),
                request.getAddress()
        );
        return UpdateUserResponse.from(user);
    }

    @Transactional
    public void delete(Long userId) {
        if (!userRepository.existsById(userId)) {
            throw new IllegalStateException("User not found.");
        }
        userRepository.deleteById(userId);
    }
}

7. Controller 클래스 CRUD 요청 메서드 작성

  • Controller의 API에서는 리턴값을 응답 DTO 그대로 사용하지 않고 ResponseEntity로 감싸준다.
  • ResponseEntity는 HTTP 응답의 상태 코드, 헤더, 본문을 직접 제어하고 관리할 수 있도록 도와주는 클래스이다.
  • 본 실습에서는 상태 코드를 올바르게 매핑해주는 데에 사용한다.

1. Create

@PostMapping("/users") // RESTful API
public ResponseEntity<CreateUserResponse> create(@RequestBody CreateUserRequest request) {
      CreateUserResponse result = userService.save(request);
      return ResponseEntity.status(HttpStatus.CREATED).body(result);
  }

2. Read

// 전체 조회
@GetMapping("/users")
public ResponseEntity<List<GetUserResponse>> getAll() {
	List<GetUserResponse> result = userService.getAll();
	return ResponseEntity.status(HttpStatus.OK).body(result);
}

// 단 건 조회
@GetMapping("/users/{userId}")
public ResponseEntity<GetUserResponse> getOne(@PathVariable Long userId) {
	GetUserResponse result = userService.getOne(userId);
	return ResponseEntity.status(HttpStatus.OK).body(result);
}

3. Update

@PutMapping("/users/{userId}")  
public ResponseEntity<UpdateUserResponse> update(  
      @PathVariable Long userId, @RequestBody UpdateUserRequest request) {  
  UpdateUserResponse result = userService.update(userId, request);  
  return ResponseEntity.status(HttpStatus.OK).body(result);  
}

4. Delete

@DeleteMapping("/users/{userId}")
public ResponseEntity<Void> delete(@PathVariable Long userId) {
  userService.delete(userId);
  return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}

전체 Controller 코드

펼치기
package org.example.jpapractice.controller;

import lombok.RequiredArgsConstructor;
import org.example.jpapractice.dto.*;
import org.example.jpapractice.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;

    @PostMapping("/users")
    public ResponseEntity<CreateUserResponse> create(@RequestBody CreateUserRequest request) {
        CreateUserResponse result = userService.save(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(result);
    }

    @GetMapping("/users")
    public ResponseEntity<List<GetUserResponse>> getAll() {
        List<GetUserResponse> result = userService.getAll();
        return ResponseEntity.status(HttpStatus.OK).body(result);
    }

    @GetMapping("/users/{userId}")
    public ResponseEntity<GetUserResponse> getOne(@PathVariable Long userId) {
        GetUserResponse result = userService.getOne(userId);
        return ResponseEntity.status(HttpStatus.OK).body(result);
    }

    @PutMapping("/users/{userId}")
    public ResponseEntity<UpdateUserResponse> update(
            @PathVariable Long userId, @RequestBody UpdateUserRequest request) {
        UpdateUserResponse result = userService.update(userId, request);
        return ResponseEntity.status(HttpStatus.OK).body(result);
    }

    @DeleteMapping("/users/{userId}")
    public ResponseEntity<Void> delete(@PathVariable Long userId) {
        userService.delete(userId);
        return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
    }
}

8. 결과 테스트

펼치기

1. Create

  • POST 요청 2번
  • DB 테이블 결과

2. Read

  • 전체 조회

  • 단 건 조회

3. Update

  • DB 테이블 결과

4. Delete

  • DB 테이블 결과

5. User not found 요청

모든 요청에 대해 올바르게 동작하였다.
상태 코드 역시 ResponseEntity에서 설정한 대로 잘 응답하는 것을 확인하였다.

본 실습에서는 바텀업 방식으로 개발하였다. (Entity -> Service -> Controller)
IntelliJ에서는 탑다운 방식으로 보다 빠르게 작업할 수 있다. (Controller 먼저 구현, Service에 아직 정의되지 않은 메서드를 IDE에서 생성하도록 요청하면 기본 틀을 잡아줌)

0개의 댓글