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