Repository · 전체 흐름 · DTO · MapStruct
데이터를 꺼내오고, 저장하고, 수정하고, 삭제하는 역할만 담당. JPA Repository부터 Querydsl까지 빠짐없이.
@Service public class UserService { public User 조회(int id) { // DB 코드가 Service에 직접! String sql = "SELECT * FROM users WHERE id=?"; // ... SQL 처리 코드 길게 ... } }
DB를 MySQL에서 다른 걸로 바꾸면 Service 코드도 전부 수정. 역할이 불명확하고 테스트 어려움.
@Service public class UserService { public User 조회(int id) { // DB 처리는 Repository에 위임 return userRepository.findById(id); } } // DB 처리 전부 여기서 public interface UserRepository extends JpaRepository<User, Integer> { }
@Repository public class UserRepository { } // @Component의 특수 버전 → 스프링 빈으로 등록 // 추가로 하는 일: DB 관련 예외를 스프링 표준 예외로 자동 변환 // (DB마다 예외 클래스가 달라서, 어떤 DB 쓰든 같은 예외로 처리 가능) // // Spring Data JPA (JpaRepository) 쓰면 @Repository 직접 안 붙여도 됨 // JpaRepository 상속하면 자동으로 처리됨
@Entity // 이 클래스가 DB 테이블과 연결된 클래스야 @Table(name = "users") // 연결할 테이블 이름 지정. 생략하면 클래스명이 테이블명 @Getter @Setter public class User { @Id // 이 필드가 PK(기본키)야 @GeneratedValue(strategy = GenerationType.IDENTITY) // PK 자동 생성 전략 // IDENTITY → DB가 알아서 1,2,3... 증가 (AUTO_INCREMENT) // SEQUENCE → 시퀀스 객체 사용 (Oracle) // AUTO → DB에 맞게 자동 선택 private int id; @Column(nullable = false) // NOT NULL @Column(unique = true) // UNIQUE @Column(length = 50) // VARCHAR(50) @Column(name = "user_name") // 컬럼명 직접 지정 private String name; @Transient // 이 필드는 DB 컬럼과 매핑 안 함 private String 임시데이터; }
// JpaRepository 상속만 하면 기본 CRUD 메서드 전부 자동 생성! public interface UserRepository extends JpaRepository<User, Integer> { // ↑타입 ↑PK타입 (id가 int니까 Integer) }
// 조회 userRepository.findById(1); // id로 조회 → Optional<User> userRepository.findAll(); // 전체 조회 → List<User> userRepository.count(); // 전체 개수 → long userRepository.existsById(1); // 존재 여부 → boolean // 저장 / 수정 userRepository.save(user); // id 없으면 INSERT, id 있으면 UPDATE userRepository.saveAll(userList); // 여러 개 한번에 저장 // 삭제 userRepository.deleteById(1); // id로 삭제 userRepository.delete(user); // 객체로 삭제 userRepository.deleteAll(); // 전체 삭제
Spring Data JPA의 가장 강력한 기능. 메서드 이름만 잘 지으면 SQL을 자동으로 만들어준다.
public interface UserRepository extends JpaRepository<User, Integer> { // SELECT * FROM users WHERE name = ? User findByName(String name); // SELECT * FROM users WHERE name = ? AND age = ? User findByNameAndAge(String name, int age); // SELECT * FROM users WHERE name = ? OR email = ? List<User> findByNameOrEmail(String name, String email); // SELECT * FROM users WHERE age >= ? List<User> findByAgeGreaterThanEqual(int age); // SELECT * FROM users WHERE name LIKE '%?%' List<User> findByNameContaining(String keyword); // SELECT * FROM users WHERE name LIKE '?%' List<User> findByNameStartingWith(String prefix); // SELECT COUNT(*) > 0 FROM users WHERE email = ? boolean existsByEmail(String email); // SELECT COUNT(*) FROM users WHERE age >= ? long countByAgeGreaterThanEqual(int age); // DELETE FROM users WHERE name = ? void deleteByName(String name); // SELECT * FROM users ORDER BY name ASC List<User> findAllByOrderByNameAsc(); // SELECT * FROM users WHERE age >= ? LIMIT 3 List<User> findTop3ByAgeGreaterThanEqual(int age); }
| 키워드 | SQL |
|---|---|
And, Or | AND, OR |
GreaterThan / GreaterThanEqual | > / >= |
LessThan / LessThanEqual | < / <= |
Between | BETWEEN |
Containing | LIKE '%?%' |
StartingWith / EndingWith | LIKE '?%' / LIKE '%?' |
IsNull / IsNotNull | IS NULL / IS NOT NULL |
OrderBy + 필드명 + Asc/Desc | ORDER BY |
Top/First + 숫자 | LIMIT |
public interface UserRepository extends JpaRepository<User, Integer> { // JPQL — 자바 객체 기준으로 쿼리 작성 @Query("SELECT u FROM User u WHERE u.age >= :age AND u.name LIKE %:keyword%") List<User> 복잡한조회(@Param("age") int age, @Param("keyword") String keyword); // 수정 쿼리는 @Modifying 필요 @Modifying @Query("UPDATE User u SET u.name = :name WHERE u.id = :id") int 이름수정(@Param("id") int id, @Param("name") String name); }
SELECT u FROM User u ← User는 클래스명@Query(value = "SELECT * FROM users WHERE age >= :age", nativeQuery = true) List<User> 네이티브조회(@Param("age") int age);
없는 사용자 조회 → user = null → user.getName() 호출 → NullPointerException!
이게 자바에서 제일 많이 나는 에러. Optional은 이걸 방지하려고 만들어진 것.
Optional<User> optional = userRepository.findById(1); // ① 값이 있는지 확인 optional.isPresent(); // true / false // ② 없으면 예외 던지기 (실무에서 제일 많이 씀) User user = optional.orElseThrow( () -> new UserNotFoundException("없는 사용자") ); // ③ 없으면 기본값 User user = optional.orElse(new User()); // ④ 없으면 null (비권장) User user = optional.orElse(null); // ⑤ 값이 있을 때만 실행 optional.ifPresent(u -> System.out.println(u.getName())); // 실무에서 제일 많이 쓰는 패턴: userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException("없는 사용자")); // → 있으면 꺼내주고, 없으면 예외 던져서 404 응답으로 처리
// Repository public interface UserRepository extends JpaRepository<User, Integer> { Page<User> findAll(Pageable pageable); Page<User> findByAgeGreaterThanEqual(int age, Pageable pageable); } // Service에서 사용 @Transactional(readOnly = true) public Page<User> 목록(int 페이지번호) { Pageable pageable = PageRequest.of( 페이지번호, // 0부터 시작 (0 = 첫 번째 페이지) 10, // 한 페이지에 몇 개 Sort.by("name").ascending() // 정렬 ); return userRepository.findAll(pageable); } // Page 객체에서 꺼낼 수 있는 것들 page.getContent(); // 실제 데이터 List page.getTotalElements(); // 전체 데이터 수 page.getTotalPages(); // 전체 페이지 수 page.getNumber(); // 현재 페이지 번호 page.isFirst(); // 첫 페이지인지 page.isLast(); // 마지막 페이지인지 page.hasNext(); // 다음 페이지 있는지
// User 클래스 (1쪽) — 유저 한 명이 주문 여러 개 @Entity public class User { @OneToMany(mappedBy = "user") // 1:N — mappedBy 있는 쪽 = 연관관계 주인 아님 private List<Order> orders = new ArrayList<>(); } // Order 클래스 (N쪽) — 주문 여러 개가 유저 한 명 @Entity public class Order { @ManyToOne // N:1 — mappedBy 없는 쪽 = 연관관계 주인 @JoinColumn(name = "user_id") // FK 컬럼명 지정 private User user; } // 연관관계 종류 // @OneToMany → 1:N (일대다) // @ManyToOne → N:1 (다대일) // @OneToOne → 1:1 (일대일) // @ManyToMany → N:N (다대다) — 중간 테이블 Entity 따로 만드는 게 권장
mappedBy 있는 쪽 → 연관관계 주인 아님. 읽기만 가능, FK 수정 불가.
mappedBy 없는 쪽 → 연관관계 주인. FK를 관리함.
FK가 있는 테이블 쪽 = 연관관계 주인 → Order 테이블에 user_id(FK)가 있으니까 Order가 주인.
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY) // 지연로딩 (기본값) @OneToMany(mappedBy = "user", fetch = FetchType.EAGER) // 즉시로딩 // 기본값: // @OneToMany → LAZY (지연로딩) // @ManyToOne → EAGER (즉시로딩) ← 이걸 LAZY로 바꾸는 게 권장
지연로딩을 쓸 때 트랜잭션 밖에서 orders에 접근하면 이 에러가 터짐.
영속성 컨텍스트(트랜잭션)가 끝난 뒤에 연관 데이터를 꺼내려 해서 발생.
→ 반드시 @Transactional 안에서만 연관 데이터에 접근해야 함.
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL) private List<Order> orders = new ArrayList<>(); // cascade = CascadeType.ALL // → User 저장하면 orders도 자동 저장 // → User 삭제하면 orders도 자동 삭제 // CascadeType 종류: // ALL → 전부 적용 // PERSIST → 저장할 때만 // REMOVE → 삭제할 때만 // MERGE → 수정할 때만
검색 조건이 있을 수도 없을 수도 있는 경우. 예: 이름으로 검색할 수도, 나이로 검색할 수도, 둘 다 할 수도, 아무것도 안 할 수도 있을 때.
@Query로는 이런 경우 처리가 매우 어려움 → Querydsl이 깔끔하게 해결.
public List<User> 동적검색(String name, Integer age) { QUser user = QUser.user; // Querydsl이 자동 생성하는 클래스 BooleanBuilder builder = new BooleanBuilder(); // 조건이 있을 때만 추가 if (name != null) builder.and(user.name.contains(name)); if (age != null) builder.and(user.age.goe(age)); // goe = GreaterOrEqual return queryFactory .selectFrom(user) .where(builder) .fetch(); } // name만 있으면 → WHERE name LIKE '%?%' // age만 있으면 → WHERE age >= ? // 둘 다 있으면 → WHERE name LIKE '%?%' AND age >= ? // 둘 다 없으면 → 전체 조회 // Querydsl은 JPA 기본을 완전히 익힌 다음에 배우는 게 순서상 맞음
public interface UserRepository extends JpaRepository<User, Integer> { // 이메일로 조회 Optional<User> findByEmail(String email); // 이메일 존재 여부 boolean existsByEmail(String email); // 나이 이상 + 페이징 Page<User> findByAgeGreaterThanEqual(int age, Pageable pageable); // 복잡한 조회는 @Query @Query("SELECT u FROM User u WHERE u.age BETWEEN :min AND :max") List<User> 나이범위조회(@Param("min") int min, @Param("max") int max); }
요청이 들어와서 응답이 나가기까지 전체 흐름을 하나의 코드로 연결해서 본다.
// Entity @Entity @Table(name="users") @Getter @Setter public class User { @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private int id; @Column(nullable=false) private String name; @Column(unique=true, nullable=false) private String email; @Column(nullable=false) private String password; } // Repository public interface UserRepository extends JpaRepository<User, Integer> { boolean existsByEmail(String email); Optional<User> findByEmail(String email); } // Service @Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; @Transactional public User 회원가입(User user) { if (userRepository.existsByEmail(user.getEmail())) throw new DuplicateEmailException("이미 있는 이메일"); return userRepository.save(user); } } // Controller @RestController @RequestMapping("/users") @RequiredArgsConstructor public class UserController { private final UserService userService; @PostMapping public ResponseEntity<User> 회원가입(@RequestBody User user) { User saved = userService.회원가입(user); return ResponseEntity.status(201).body(saved); } }
// 조회 (Service) @Transactional(readOnly = true) public User 조회(int id) { return userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException("없는 사용자")); } // 조회 (Controller) @GetMapping("/{id}") public ResponseEntity<User> 조회(@PathVariable int id) { return ResponseEntity.ok(userService.조회(id)); } // 수정 (Service) — 변경 감지로 자동 UPDATE, save() 필요 없음! @Transactional public User 수정(int id, User 수정데이터) { User user = userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException("없는 사용자")); user.setName(수정데이터.getName()); return user; // 트랜잭션 끝날 때 자동 UPDATE } // 수정 (Controller) @PutMapping("/{id}") public ResponseEntity<User> 수정(@PathVariable int id, @RequestBody User data) { return ResponseEntity.ok(userService.수정(id, data)); } // 삭제 (Service) @Transactional public void 삭제(int id) { if (!userRepository.existsById(id)) throw new UserNotFoundException("없는 사용자"); userRepository.deleteById(id); } // 삭제 (Controller) @DeleteMapping("/{id}") public ResponseEntity<Void> 삭제(@PathVariable int id) { userService.삭제(id); return ResponseEntity.noContent().build(); }
지금까지 Entity(User)를 직접 주고받았는데, 실무에서는 이렇게 하면 안 된다. DTO가 왜 필요한지부터 MapStruct까지.
// 요청용 DTO — 클라이언트가 보내는 데이터 @Getter public class UserCreateRequest { private String name; private String email; private String password; } // 응답용 DTO — password 제외! 클라이언트에 돌려주는 데이터 @Getter @AllArgsConstructor public class UserResponse { private int id; private String name; private String email; // password는 여기 없음! }
// Controller — DTO로 요청 받고, DTO로 응답 @RestController @RequestMapping("/users") @RequiredArgsConstructor public class UserController { private final UserService userService; @PostMapping public ResponseEntity<UserResponse> 회원가입( @RequestBody UserCreateRequest request) { return ResponseEntity.status(201) .body(userService.회원가입(request)); } @GetMapping("/{id}") public ResponseEntity<UserResponse> 조회(@PathVariable int id) { return ResponseEntity.ok(userService.조회(id)); } } // Service — DTO ↔ Entity 변환 담당 @Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; @Transactional public UserResponse 회원가입(UserCreateRequest request) { if (userRepository.existsByEmail(request.getEmail())) throw new DuplicateEmailException("이미 있는 이메일"); // DTO → Entity 변환 User user = new User(); user.setName(request.getName()); user.setEmail(request.getEmail()); user.setPassword(request.getPassword()); User saved = userRepository.save(user); // Entity → DTO 변환 return new UserResponse(saved.getId(), saved.getName(), saved.getEmail()); } @Transactional(readOnly = true) public UserResponse 조회(int id) { User user = userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException("없는 사용자")); return new UserResponse(user.getId(), user.getName(), user.getEmail()); } }
DTO ↔ Entity 변환을 매번 직접 하면 코드가 반복되고 길어짐. 필드가 많아지면 변환 코드만 수십 줄.
실무에서는 변환을 자동으로 해주는 라이브러리를 씀.
// Mapper 인터페이스 정의 @Mapper(componentModel = "spring") public interface UserMapper { UserResponse toResponse(User user); // Entity → Response DTO User toEntity(UserCreateRequest request); // Request DTO → Entity } // Service에서 사용 @Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; private final UserMapper userMapper; @Transactional public UserResponse 회원가입(UserCreateRequest request) { User user = userMapper.toEntity(request); // DTO → Entity 한 줄! User saved = userRepository.save(user); return userMapper.toResponse(saved); // Entity → DTO 한 줄! } }
| MapStruct | ModelMapper | |
|---|---|---|
| 변환 방식 | 컴파일 시점에 변환 코드 자동 생성 | 런타임에 리플렉션으로 변환 |
| 속도 | 빠름 | 상대적으로 느림 |
| 타입 안전성 | 컴파일 시점에 에러 잡음 | 런타임에 에러 발생 가능 |
| 실무 | 더 많이 씀 (권장) | 설정 간단, 소규모 프로젝트 |
Controller → DTO로 요청 받고, DTO로 응답. Entity 직접 노출 금지.
Service → DTO → Entity 변환 후 처리. Entity → DTO 변환 후 반환.
Repository → Entity로만 DB와 대화.
DTO → 요청용(Request)과 응답용(Response) 분리. Entity와 클라이언트 사이의 완충재.