Загружаем каталог…
Загружаем каталог…
1부에서 키, 관계, 정규화, 인덱스, 트랜잭션 같은 개념을 정리했다. 이번 글에서는 그 개념을 스프링 부트와 JPA 코드로 옮긴다. 예시는 1부와 같다. 유저, 식사, 배변, 복용 기록이다. JPA는 자바 클래스와 DB 테이블을 연결해주는 기술이다. 클래스 하나가 테이블 하나, 객체 하나가 행 하나, 필드 하나가 컬럼 하나가 된다. 1. 엔티티 — 테이블을 클래스로 @Entity @Getter @Table(name = "users") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, unique = true, length = 20) private String username; @Column(nullable = false) private String password; @Enumerated(EnumType.STRING) private IbdType ibdType; } 애너테이션 개념 하는 일 @Entity 테이블 이 클래스를 테이블로 관리한다 @Table(name) 테이블 이름 user 는 예약어라 users 로 지정한다 @Id PK 기본키 @GeneratedValue(IDENTITY) 대리키 DB가 번호를 자동으로 올린다 @Column(nullable = false) NOT NULL 빈 값 금지 @Column(unique = true) UNIQUE 중복 금지 @Enumerated(STRING) 데이터 타입 선택지를 문자열로 저장한다 @Enumerated 의 기본값은 순서 번호(ORDINAL)이기 때문에 반드시 String으로 바꿔야 한다. ORDINAL로 저장하면 선택지 중간에 값을 추가했을 때 기존 데이터의 의미가 밀린다. 2. 복합 유니크 1부에서 다루었던 "(유저, 음식 이름) 조합의 중복 금지" 이다. @Entity @Table(name = "food", uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "name"})) public class Food extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @Column(nullable = false, length = 50) private String name; } columnNames 에는 DB 컬럼 이름 을 쓴다. 자바 필드 이름이 아니다. 필드는 user 지만 컬럼은 user_id 다. 카멜케이스 필드( providerUserId )도 DB에서는 provider_user_id 가 된다. 틀리면 서버가 켜질 때 컬럼을 못 찾는다. 3. 1:N — @ManyToOne "FK는 N쪽에 둔다." 배변 기록(N)이 유저(1)을 가리킨다. @Entity @Table(name = "bowel_record") public class BowelRecord extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @Column(nullable = false) private LocalDateTime bowelTime; } @ManyToOne : 여러 배변 기록(Many)이 한 유저(One)에 속한다. @JoinColumn(name = "user_id") : FK 컬럼 이름이다. nullable = false : 주인 없는 기록은 없다. fetch = FetchType.LAZY : @ManyToOne에는 항상 LAZY를 붙인다. 기본값이 EAGER라서 직접 바꿔야 한다. 양방향은 필요할 때에만 유저 쪽에서도 기록 목록을 가질 수 있다. // User 안에 @OneToMany(mappedBy = "user") private List<BowelRecord> bowelRecords = new ArrayList<>(); 이걸 양방향 관계라고 한다. 하지만 처음엔 단방향(@ManyToOne)으로 시작 한다. 유저의 기록이 필요하면 레포지토리에서 user_id 로 조회하면 된다. 양방향은 양쪽을 맞춰줘야 하는 관리 부담이 생긴다. 양방향을 쓸 때 알아야 할 개념이 연관관계의 주인 이다. DB에는 FK가 배변 기록 테이블에만 있다. 그래서 FK를 실제로 바꾸는 것은 배변 기록 쪽( @ManyToOne )이다. mappedBy 는 "나는 주인이 아니고, 상대의 user 필드에 매핑된다"는 뜻이다. 유저의 리스트에만 기록을 추가하면 DB에는 반영되지 않는다. 4. N:M — 연결 엔티티로 직접 만든다. JPA에는 @ManyToMany 가 있다. 연결 테이블을 자동으로 만들어준다. 하지만 연결 테이블을 엔티티로 직접 만드는 것이 좋다. @ManyToMany 의 자동 연결 테이블에는 두 FK만 있다. 나중에 "먹은 양"같은 컬럼을 추가할 수 없다. 직접 만든 엔티티는 컬럼을 자유롭게 추가할 수 있다. @Entity @Table(name = "meal_record") public class MealRecord extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @Column(nullable = false) private LocalDateTime eatenAt; } @Entity @Table(name = "meal_food", uniqueConstraints = @UniqueConstraint(columnNames = {"meal_id", "food_id"})) public class MealFood { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "meal_id", nullable = false) private MealRecord meal; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "food_id", nullable = false) private Food food; } MealFood가 FK 두 개를 다 가진다. MealRecord와 Food에는 상대를 가리키는 필드가 없다. (meal_id, food_id) 복합 유니크로 한 식사에 같은 음식이 두 번 연결되는 걸 막는다. 결과적으로 Food 1:N MealFood N:1 MealRecord다. 1부에서 언급한 "두 개의 1:N"이 이것이다. 저장 흐름 사용자가 ["떡볶이", "우유"] 를 입력했다고 하자. @Transactional public void createMeal(Long userId, MealCreateRequest request) { User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); MealRecord meal = mealRecordRepository.save( MealRecord.create(user, request.eatenAt())); request.foods().stream() .map(String::strip) // 앞뒤 공백 제거 .distinct() // 같은 음식 두 번 입력 방지 .forEach(name -> { Food food = foodRepository.findByUserIdAndName(userId, name) .orElseGet(() -> foodRepository.save(Food.create(user, name))); mealFoodRepository.save(MealFood.create(meal, food)); }); } 음식이 이미 있으면 그걸 쓰고, 없으면 만든다. 저장 전에 공백을 정리해서 "떡볶이" 와 "떡볶이 " 가 다른 음식이 되는 것을 방지한다. 이 전체가 한 트랜잭션이기 때문에 중간에 실패하면 식사도 저장되지 않는다. 5. 날짜와 생성 시각 private LocalDate eatDate; // 날짜만 (DATE) private LocalDateTime bowelTime; // 날짜 + 시각 (TIMESTAMP) 생성, 수정 시각은 모든 엔티티에 필요하기 때문에 공통 부모로 뺀다. @Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public class BaseTimeEntity { @CreatedDate private LocalDateTime createdAt; @LastModifiedDate private LocalDateTime updatedAt; } 메인 클래스에 @EnableJpaAuditing 이 있어야 작동한다. createdAt (기록한 시각)과 eatenAt (먹은 시각)은 다르다. 어제 먹은 것을 오늘 기록할 수 있기 때문이다. 분석은 eatenAt 기준으로 한다. 6. 레포지토리 — 조회 쿼리 메서드 이름으로 쿼리 만들기 public interface BowelRecordRepository extends JpaRepository<BowelRecord, Long> { List<BowelRecord> findByUserIdOrderByBowelTimeDesc(Long userId); long countByUserIdAndBowelTimeBetween(Long userId, LocalDateTime start, LocalDateTime end); } 이름 규칙 뜻 findBy... 조회 existsBy... 있는지만 확인 (boolean) countBy... 개수 UserId 필드 user 의 id . 연관 객체의 필드도 이어서 쓸 수 있다 Between 범위 OrderBy...Desc 정렬 StartingWith 앞부분이 일치 (LIKE '떡%') In 목록 안에 있는 것 전부 ( IN (1, 2, 3) ) countBy 가 1부에서 언급한 "저장하지 않고 계산"하는 부분이다. 오늘 배변 횟수를 컬럼에 저장하지 않고, 오늘 범위의 기록을 센다. 단건 조회는 Optional Optional<Food> findByUserIdAndName(Long userId, String name); 결과가 없을 수 있다. Optional로 받으면 호출하는 쪽이 "없으면 어떻게 할지"를 반드시 정하게 된다. 이름으로 안되면 @Query "내가 먹은 음식 중 '떡'으로 시작하는 것을, 많이 먹은 순으로"는 이름 규칙으로 만들기 어렵다. 이런 경우 JPQL을 직접 쓴다. JPQL은 테이블이 아니라 엔티티와 필드 이름을 쓰는 SQL이다. @Query(""" select f.name from Food f left join MealFood mf on mf.food = f where f.user.id = :userId and f.name like concat(:keyword, '%') group by f.id, f.name order by count(mf) desc """) List<String> searchByPrefix(@Param("userId") Long userId, @Param("keyword") String keyword, Pageable pageable); Pageable 로 개수를 제한한다. 호출할 때 PageRequest.of(0,10) 을 넘기면 상위 10개만 온다. 7. 지연 로딩과 N+1 문제 지연 로딩 (LAZY) 배변 기록을 조회하면 연결된 유저는 어떻게 될까. EAGER : 기록을 가져올 때 유저도 항상 같이 가져온다. Lazy : 기록만 가져온다. 유저는 record.getUser().getNickname() 처럼 실제로 쓰는 순간 가져온다. 필요 없는 데이터를 미리 가져오지 않도록 LAZY를 기본으로 쓴다. N+1 문제 LAZY에도 함정이 있다. 1번 식사의 음식 이름들을 보여준다고 하자. List<MealFood> mealFoods = mealFoodRepository.findByMealId(1L); // 쿼리 1번 for (MealFood mf : mealFoods) { mf.getFood().getName(); // 음식마다 쿼리가 한 번씩 더 } 연결 행 목록을 가져오는 쿼리가 1번 나간다. 그다음 음식 N개의 이름을 꺼낼 때마다 쿼리가 N번 더 나간다. 총 N+1이다. 식사 목록 화면이면 이게 식사 수만큼 곱해진다. 해결은 처음부터 같이 가져오는 것 이다. // 방법 1: fetch join @Query("select mf from MealFood mf join fetch mf.food where mf.meal.id = :mealId") List<MealFood> findWithFoodByMealId(@Param("mealId") Long mealId); // 방법 2: @EntityGraph @EntityGraph(attributePaths = "food") List<MealFood> findByMealId(Long mealId); 둘 다 음식까지 한 번의 쿼리로 가져온다. 개발 중에는 show-sql: true 로 실제 나가는 쿼리를 확인하는 습관을 들이는 것이 좋다. 같은 모양의 쿼리가 줄줄이 찍히면 N+1이다. 목록일 때 — IN 조회와 groupingBy 식사 하나는 위 방법으로 해결된다. 그런데 화면은 보통 여러 식사 를 한 번에 보여준다. 이렇게 짜기 쉽다. List<MealRecord> meals = mealRecordRepository.findMyMeals(userId, start, end); // 쿼리 1번 for (MealRecord meal : meals) { mealFoodRepository.findByMealId(meal.getId()); // 식사마다 1번씩 } @EntityGraph 를 붙여서 음식 이름의 N+1은 없앴다. 하지만 식사마다 MealFood 조회가 한 번씩 나간다. 식사가 30개면 쿼리가 31번이다. 반복문 안에 레포지토리 호출이 있으면 N+1이다. 해결은 반복문 밖에서 한 번에 가져오는 것이다. 순서는 다섯 단계다. public List<MealResponse> getMeals(Long userId, LocalDate from, LocalDate to) { LocalDateTime start = from.atStartOfDay(); LocalDateTime end = to.plusDays(1).atStartOfDay(); // 1 기간 안의 식사 조회 (쿼리 1번) List<MealRecord> meals = mealRecordRepository.findMyMeals(userId, start, end); if (meals.isEmpty()) { return List.of(); } // 2 식사 id만 뽑기 List<Long> mealIds = meals.stream() .map(MealRecord::getId) .toList(); // 3 그 식사들의 MealFood를 한 번에 (쿼리 1번) List<MealFood> mealFoods = mealFoodRepository.findAllByMealIdIn(mealIds); // 4 식사 id별로 음식 이름 묶기 Map<Long, List<String>> foodsByMeal = mealFoods.stream() .collect(Collectors.groupingBy( mf -> mf.getMeal().getId(), Collectors.mapping(mf -> mf.getFood().getName(), Collectors.toList()) )); // 5 식사마다 응답 만들기 (DB 조회 없음) return meals.stream() .map(meal -> MealResponse.of(meal, foodsByMeal.getOrDefault(meal.getId(), List.of()))) .toList(); } // MealFoodRepository @EntityGraph(attributePaths = "food") List<MealFood> findAllByMealIdIn(List<Long> mealIds); 식사가 몇 개든 쿼리는 2번이다. 3 IN 조회 메서드 이름 끝의 In 은 "이 목록 안에 있는 것 전부"디. SQL로는 WHERE meal_id IN (1, 2, 3) 이 된다. 식사마다 따로 묻는 대신 한 번에 묻는다. 결과는 MealFood가 한 줄로 쭉 나열된 목록이다. 이걸 식사별로 나눠야 한다. mealFoods = [ (식사1, 떡볶이), (식사1, 우유), (식사2, 김밥) ] Collectors.groupingBy(기준, 모으는 방법) 은 목록을 기준값별로 묶어 Map을 만든다. 기준 mf -> mf.getMeal().getId() : 식사 id가 같은 것끼리 한 그룹이 된다. 모으는 방법 mapping(mf -> mf.getFood().getName(), toList()) : 그룹 안의 MealFood를 음식 이름으로 바꿔 리스트로 모은다. foodsByMeal = { 1 → ["떡볶이", "우유"], 2 → ["김밥"] } 모으는 방법을 생략하면 {1 -> [MealFood, MealFood], ... } 처럼 객체가 그대로 담긴다. 이름만 필요하니 mapping 으로 바꾼다. mf.getMeal().getId() 는 LAZY여도 추가 쿼리가 나가지 않는다. 연관 객체의 id는 FK 값이라 이미 갖고 있기 때문이다. 8. 트랜잭션과 변경 감지 @Transactional @Service @Transactional(readOnly = true) public class MealService { @Transactional public void createMeal(...) { ... } public List<MealResponse> getMeals(...) { ... } } 클래스 기본은 읽기 전용이다. 실수로 데이터를 바꾸는 것을 막는다. 저장, 수정, 삭제 메서드만 @Transactional 을 따로 붙인다. 메서드 안에서 예외(RuntimeException)가 나면 전체가 롤백된다. 수정할 때 save()를 부르지 않는다. @Transactional public void updateBowel(Long id, BowelUpdateRequest request) { BowelRecord record = bowelRecordRepository.findById(id).orElseThrow(...); record.update(request.painLevel(), request.isBleed()); // save() 호출 없음 } 트랜잭션 안에서 조회한 엔티티는 JPA가 관리한다(영속 상태). 메서드가 끝날 때 JPA가 처음 조회했을 때와 지금을 비교하고, 바뀐게 있으면 UPDATE를 알아서 보낸다. 이걸 변경 감지(더티 체킹) 라고 한다. 읽기 전용 트랜잭션에서는 이 비교를 하지 않는다. 그래서 조회 메서드가 빨라진다. 9. 인덱스 @Entity @Table(name = "bowel_record", indexes = @Index(name = "idx_bowel_user_time", columnList = "user_id, bowel_time")) public class BowelRecord { ... } "유저별, 기간별 조회"를 자주 한다면 (user_id, bowel_time) 복
То, что RADAR обнаружил и классифицировал для этой возможности. Это опубликованный источником текст, а не подтверждение, что предложение ещё действует.
개발자가 알아야 할 데이터베이스(2부) — 스프링 부트 편. 1부에서 키, 관계, 정규화, 인덱스, 트랜잭션 같은 개념을 정리했다. 이번 글에서는 그 개념을 스프링 부트와 JPA 코드로 옮긴다. 예시는 1부와 같다. 유저, 식사, 배변, 복용 기록이다. JPA는 자바 클래스와 DB 테이블을 연결해주는 기술이다. 클래스 하나가 테이블 하나, 객체 하나가 행 하나, 필드 하나가 컬럼 하나가 된다. 1. 엔티티 — 테이블을 클래스로 @Entity @Getter @Table(name = "users") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends BaseTimeEntity { @Id…
Открыть источник