개발 가이드 백엔드 JPA / 쿼리 작성 규칙
최종 수정:

JPA / 쿼리 작성 규칙

JPA Entity 설계, 쿼리 작성 규칙, N+1 방지 (참고: MyBatis는 mybatis-query.md 참조)

JPA / 쿼리 작성 규칙

Entity 작성 규칙

@Entity
@Table(
    name = "news_items",
    indexes = {
        @Index(name = "idx_news_items_published_at", columnList = "published_at"),
        @Index(name = "idx_news_items_category", columnList = "category")
    }
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class NewsItem extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 500)
    private String title;

    @Column(nullable = false, length = 2000)
    private String url;

    @Enumerated(EnumType.STRING)          // ORDINAL 금지
    @Column(nullable = false, length = 50)
    private Category category;

    @ManyToOne(fetch = FetchType.LAZY)    // 연관관계: LAZY 기본
    @JoinColumn(name = "user_id")
    private User user;

    @Column
    private LocalDateTime deletedAt;      // Soft Delete
}

Entity 작성 체크리스트

  • [ ] @Column(nullable, length) 명시
  • [ ] Enum → @Enumerated(EnumType.STRING) (ORDINAL 금지)
  • [ ] 연관관계 → fetch = FetchType.LAZY
  • [ ] BaseEntity 상속 (created_at, updated_at 자동 관리)
  • [ ] @NoArgsConstructor(access = AccessLevel.PROTECTED)
  • [ ] Soft Delete 컬럼: deletedAt

BaseEntity

실제 구현 (com.scraping.agent.global.common.BaseEntity):

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
@SuperBuilder
@NoArgsConstructor
public abstract class BaseEntity {

    @CreatedDate
    @Column(name = "frst_regist_dt", updatable = false)
    private LocalDateTime frstRegistDt;

    @LastModifiedDate
    @Column(name = "last_updt_dt")
    private LocalDateTime lastUpdtDt;
}

모든 Entity는 BaseEntity를 상속하여 frst_regist_dt, last_updt_dt 컬럼을 자동 관리한다.


JPA Repository 쿼리

public interface NewsItemRepository extends JpaRepository<NewsItem, Long> {

    // Spring Data 메서드명 쿼리
    Optional<NewsItem> findByUrl(String url);
    List<NewsItem> findByCategory(Category category);

    // JPQL — 파라미터 바인딩 필수
    @Query("SELECT n FROM NewsItem n WHERE n.category = :category AND n.deletedAt IS NULL")
    List<NewsItem> findActiveByCategory(@Param("category") Category category);

    // Soft Delete 조건 필수
    @Query("SELECT n FROM NewsItem n WHERE n.deletedAt IS NULL ORDER BY n.createdAt DESC")
    Page<NewsItem> findAllActive(Pageable pageable);

    // 잘못된 예 — 문자열 연결 (SQL Injection 위험)
    // @Query("SELECT n FROM NewsItem n WHERE n.title = '" + title + "'")
}

SQL Injection 방지

// 올바른 예 — :param 또는 ?1 바인딩
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmail(@Param("email") String email);

// Native Query도 파라미터 바인딩 필수
@Query(value = "SELECT * FROM news_items WHERE category = :category", nativeQuery = true)
List<NewsItem> findNativeByCategory(@Param("category") String category);

N+1 문제 방지

// 문제: N+1 발생
List<Post> posts = postRepository.findAll();
posts.forEach(p -> p.getComments().size()); // 각 Post마다 추가 쿼리

// 해결 1: Fetch Join
@Query("SELECT p FROM Post p JOIN FETCH p.comments WHERE p.deletedAt IS NULL")
List<Post> findAllWithComments();

// 해결 2: @BatchSize
@BatchSize(size = 100)
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments;

// 해결 3: EntityGraph
@EntityGraph(attributePaths = {"comments"})
List<Post> findByCategory(Category category);

페이징

// Service
public Page<BoardDetailRes> getBoards(int page, int size) {
    Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
    return boardRepository.findAllActive(pageable)
            .map(BoardDetailRes::from);
}

// Controller
@GetMapping
public String list(@RequestParam(defaultValue = "0") int page, Model model) {
    model.addAttribute("boards", boardService.getBoards(page, 10));
    return "board/list";
}

MyBatis 사용법

XML 매퍼

<!-- resources/mapper/news/NewsMapper.xml -->
<mapper namespace="com.scraping.agent.domain.news.repository.NewsMapper">

    <!-- #{} 사용 (PreparedStatement — SQL Injection 방지) -->
    <select id="findByTitle" resultType="NewsItem">
        SELECT * FROM news_items
        WHERE title LIKE CONCAT('%', #{keyword}, '%')
          AND deleted_at IS NULL
        ORDER BY created_at DESC
    </select>

    <!-- ${} 사용 시 화이트리스트 검증 필수 -->
    <select id="findAllSorted" resultType="NewsItem">
        SELECT * FROM news_items
        WHERE deleted_at IS NULL
        ORDER BY ${sortColumn}    <!-- sortColumn은 화이트리스트로 검증 후 전달 -->
    </select>
</mapper>

인터페이스

@Mapper
public interface NewsMapper {
    List<NewsItem> findByTitle(@Param("keyword") String keyword);
    List<NewsItem> findAllSorted(@Param("sortColumn") String sortColumn);
}

인덱스 설계 기준

인덱스 추가 필수 대상:

  • WHERE 절에 자주 등장하는 컬럼
  • ORDER BY / GROUP BY 컬럼
  • 외래키(FK) 컬럼
  • 카디널리티가 높은 컬럼
@Table(
    name = "collected_items",
    indexes = {
        @Index(name = "idx_collected_items_url",        columnList = "url"),
        @Index(name = "idx_collected_items_category",   columnList = "category"),
        @Index(name = "idx_collected_items_created_at", columnList = "created_at")
    }
)

인덱스명: idx_{테이블명}_{컬럼명}


ISMS-P DB 보안 체크리스트

  • [ ] 개인정보 컬럼 AES-256 암호화
  • [ ] SQL 파라미터 바인딩 (#{}, :param)
  • [ ] ${} 사용 시 화이트리스트 검증
  • [ ] N+1 쿼리 없음
  • [ ] 대용량 조회 시 페이징 적용
  • [ ] Soft Delete 적용 (deleted_at)
  • [ ] 운영 환경 ddl-auto: validate
AI 문서 검색

현재 페이지 내용을 기반으로 질문하세요.