스프링 부트 CKEditor
스프링 부트 CKEditor 13 - 세부 게시글 조회 코드 작성
https://youtu.be/Zy6sdwpwD_A?si=Q9-wgiLUizxQV-sN
스프링 부트 CKEditor 13 - 세부 게시글 조회 코드 작성
Spring Boot + CKEditor 5 : 게시글 상세 조회 구현하기
이번 글에서는 CKEditor로 작성해 DB에 저장한 게시글을 상세 페이지에서 조회하는 기능을 구현한다.
핵심은 한 줄이다.
목록에서 게시글 ID를 URL로 전달하고, 해당 ID로 DB에서 게시글 하나만 조회한다
1. 전체 흐름 이해
게시글 상세 조회 흐름은 다음과 같다.
목록 페이지 → 제목 클릭 → /contents/{id} 요청
→ Controller
→ Service
→ Repository
→ DB 조회
→ View 출력
목록 조회와 거의 비슷하지만, 차이가 있다.
- 목록 조회: 모든 게시글 조회
- 상세 조회: 특정 ID의 게시글 하나만 조회
2. 목록 페이지에서 상세 링크 만들기
먼저 게시글 목록에서 제목을 클릭하면 상세 페이지로 이동하도록 링크를 만든다.
<div th:each="content : ${contentList}">
<a th:href="@{/contents/{id}(id=${content.id})}">
<span th:text="${content.title}"></span>
</a>
<br>
</div>
예를 들어 게시글 ID가 1이면 다음 URL로 이동한다.
/contents/1
ID가 2이면 다음과 같다.
/contents/2
즉, 게시글마다 고유한 상세 URL을 갖게 된다.
3. Controller에서 PathVariable 받기
URL에 포함된 ID 값을 받기 위해 @PathVariable을 사용한다.
@Controller
public class ContentController {
private final ContentsService contentsService;
public ContentController(ContentsService contentsService) {
this.contentsService = contentsService;
}
@GetMapping("/contents/{id}")
public String detail(@PathVariable Long id, Model model) {
ContentEntity content = contentsService.selectOneContent(id);
model.addAttribute("content", content);
return "content";
}
}
핵심은 이 부분이다.
@PathVariable Long id
/contents/1로 요청이 들어오면 id = 1이 된다.
4. Service에서 게시글 하나 조회하기
Service 계층에서는 전달받은 ID로 Repository에 조회를 요청한다.
@Service
public class ContentsService {
private final ContentRepository contentRepository;
public ContentsService(ContentRepository contentRepository) {
this.contentRepository = contentRepository;
}
public ContentEntity selectOneContent(Long id) {
return contentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("게시글을 찾을 수 없습니다."));
}
}
findById()는 Spring Data JPA에서 기본 제공하는 메소드다.
5. Repository 코드
Repository는 이전에 만든 구조 그대로 사용하면 된다.
public interface ContentRepository extends JpaRepository<ContentEntity, Long> {
}
직접 findById를 선언하지 않아도 된다. JpaRepository가 이미 제공하기 때문이다.
6. 상세 View에서 데이터 출력하기
이제 content.html에서 전달받은 데이터를 출력한다.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>게시글 상세</title>
</head>
<body>
<h1 th:text="${content.title}"></h1>
<div th:utext="${content.content}"></div>
<a href="/list">목록으로</a>
</body>
</html>
여기서 중요한 부분은 이것이다.
<div th:utext="${content.content}"></div>
CKEditor로 작성된 내용은 일반 텍스트가 아니라 HTML 문자열이다.
예를 들면 다음처럼 저장된다.
<p>안녕하세요</p>
<strong>강조된 문장</strong>
그래서 th:text를 사용하면 HTML 태그가 문자 그대로 보일 수 있다.
<p>안녕하세요</p>
반면 th:utext를 사용하면 HTML이 실제 태그로 렌더링된다.
7. Mustache를 사용하는 경우
Mustache에서는 HTML 이스케이프를 해제하기 위해 중괄호 세 개를 사용한다.
<h1></h1>
<div>
}
</div>
- `` : HTML 태그를 문자로 출력
}: HTML 태그를 실제 HTML로 렌더링
8. 자주 발생하는 실수
1) URL 경로 불일치
목록 링크는 /contents/{id}인데 컨트롤러가 /content/{id}이면 동작하지 않는다.
@GetMapping("/contents/{id}")
링크와 컨트롤러 경로를 반드시 맞춰야 한다.
2) id 타입 불일치
Entity의 ID가 Long이면 Controller와 Repository도 Long을 사용하는 것이 좋다.
@PathVariable Long id
3) 없는 게시글 조회
없는 ID로 요청하면 Optional.empty()가 반환될 수 있다. 그래서 orElseThrow() 또는 별도 예외 처리가 필요하다.
4) CKEditor HTML 출력 문제
CKEditor 내용이 HTML 태그 그대로 보인다면 th:text 대신 th:utext를 사용해야 한다.
9. 실무 관점에서 중요한 포인트
상세 조회 기능은 단순해 보이지만 중요한 설계 포인트가 있다.
첫째, Entity를 그대로 View에 넘기는 방식은 학습용으로는 괜찮지만, 실무에서는 DTO로 변환하는 것이 좋다.
Entity → ResponseDTO → View
둘째, CKEditor HTML을 그대로 렌더링하면 XSS 위험이 있다. 사용자가 입력한 HTML을 화면에 출력하기 전에 허용할 태그만 남기는 sanitize 처리가 필요하다.
셋째, 상세 조회는 추후 수정, 삭제 기능의 기준이 된다. 따라서 URL 설계와 ID 조회 구조를 명확히 잡아두는 것이 중요하다.
정리
이번 글의 핵심은 다음과 같다.
상세 조회는 URL의 ID를 받아 DB에서 게시글 하나를 조회한 뒤 View로 전달하는 흐름이다
핵심 요약:
- 목록에서
/contents/{id}링크 생성 - Controller에서
@PathVariable로 ID 수신 - Service에서
findById()로 단건 조회 - Model에 담아 View로 전달
- CKEditor HTML은
th:utext또는 Mustache의}로 출력