지난 "Jelog 만들기 #8" 에 이어서, 이번에는 게시글 관리에 필요한 API 를 명세해보자.
우선, 게시글 작성, 삭제, 조회 등의 기능이 필요할 것 같다.
게시글을 추가하는 publish
API
POST
, /api/publish
, { 'publisher' : String, 'title' : String, 'content' : String }
으로 명세한다.
게시자의 아이디로 모든 게시글을 불러오는 findByPublisher
API
GET
, /api/articles/{publisher}
, publisher
는 @PathVariable
로 받아온다.
게시자의 아이디와, 게시글의 아이디를 통해 특정 하나의 게시글을 불러오는 findById
API
GET
, /api/articles/{publisher}/{id}
, publisher
와 id
는 @PathVariable
이다.
이번 API 역시 역순으로 코드를 작성하자.
// Article.java
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Entity
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", updatable = false)
private Long id;
@Column(name = "publisher", nullable = false)
private String publisher;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", nullable = false, length = 500000)
private String content;
@CreatedDate
@Column(name = "created_at", nullable = false)
private LocalDateTime createAt;
@LastModifiedDate
@Column(name = "update_at", nullable = false)
private LocalDateTime updatedAt;
@Builder
public Article(String publisher, String title, String content){
this.publisher = publisher;
this.title = title;
this.content = content;
}
}
DB
에 저장될 Article
, Entity
이다.
PK
는 id
로 설정하고, 고유번호가 자도 생성 되도록 어노테이션을 붙여준다.
// ArticleRepository.java
public interface ArticleRepository extends JpaRepository<Article, Long> {
Optional<List<Article>> findByPublisher(String publisher);
Optional<Article> findByPublisherAndId(String publisher, Long id);
}
Article
저장소를 repository
를 interface
로 생성하고, JpaRepository
를 상속받아 Spring Data JPA 를 사용한다.
내가 필요로하는 findByPublisher
와, findByPublisherAndId
메소드를 작성한다.
// AddArticleRequestDto.java
@Getter
@Setter
public class AddArticleRequestDto {
private String publisher;
private String title;
private String content;
}
새로운 게시글 작성시 들어올 요청에 대한 Dto
이다.
// ArticleService.java
@RequiredArgsConstructor
@Service
public class ArticleService {
private final ArticleRepository articleRepository;
public Long save(AddArticleRequestDto requestDto){
return articleRepository.save(
Article.builder()
.publisher(requestDto.getPublisher())
.title(requestDto.getTitle())
.content(requestDto.getContent())
.build()
).getId();
}
public List<Article> findByPublisher(String publisher){
return articleRepository.findByPublisher(publisher).get();
}
public Article findByPublisherAndId(String publisher, Long id) {
return articleRepository.findByPublisherAndId(publisher, id).orElse(null);
}
public List<Article> findAll(){
return articleRepository.findAll();
}
}
ArticleRepository
와 직접 동작할 서비스인 ArticleService
를 생성한다.
필요한 어노테이션을 달아주어 Bean
으로 정상 등록 될 수 있도록 한다.
save
메소드를 통해서, requestDto
로 입력받은 게시자, 제목, 내용 정보를 Article
빌더를 통해서 새로운 게시글을 저장한다.
findByPublisher
메소드를 통해서, publisher
를 입력받아 해당 게시자가 작성한 모든 글을 리스트 형태로 반환한다.
findByPublisherAndId
메소드는 publisher
와 id
를 입력받아 특정 게시글 하나를 반환한다.
// ArticleApiController.java
@RequiredArgsConstructor
@RestController
@RequestMapping("/api")
public class ArticleApiController {
private final ArticleService articleService;
@PostMapping("/publish")
public ResponseEntity<String> publish(@RequestBody AddArticleRequestDto requestDto){
articleService.save(requestDto);
return ResponseEntity.ok(articleService.findByPublisher(requestDto.getPublisher()).toString());
}
@GetMapping("/articles/{publisher}")
public ResponseEntity<List<Article>> findByPublisher(@PathVariable String publisher){
System.out.println("article Result ::: "+articleService.findByPublisher(publisher));
return ResponseEntity.ok(articleService.findByPublisher(publisher));
}
@GetMapping("/articles/{publisher}/{id}")
public ResponseEntity<Article> findById(@PathVariable String publisher, @PathVariable Long id) {
Article article = articleService.findByPublisherAndId(publisher, id);
System.out.println("article = " + article.toString());
if (article == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(article);
}
@GetMapping("/articles")
public ResponseEntity<List<Article>> findAll(){
return ResponseEntity.ok(articleService.findAll());
}
}
프론트에서 호출할 API
를 명세한다.
정상적으로 게시글이 등록되는 모습.
test
가 publisher
인 모든 게시물을 리스트로 정상적으로 반환한다.
test
가 작성한 1
번째 게시물을 Article
객체로 반환하는 모습.
기존의 게시글을 저장하는 방법은, 내용 영역에 있는 <div>
태그의 outerHTML
을 저장하는 방식으로 구현했었다.
그 이유는, 게시글에는 텍스트 뿐만 아니라, 이미지도 당연히 포함될 수 있기 때문이었고, 그렇기에 <textArea>
를 사용하지 않고 <div>
태그를 사용한 것이다.
그러나, 이러한 방식에는 치명적인 문제가 존재했다.
저장될 <img>
태그의 소스로 로컬 이미지 경로로 설정되는 것은 물론이고, <div>
에 바로 표현해주기 위해서 이미지 파일을 base64
형태로 인코딩하여 저장했다.
이렇게 저장하게 되면, Dto
로 넘겨줄 content
의 텍스트길이가 지나치게 길어지게 된다.
메모리도 낭비되고, 애초에 인코딩 된 텍스트는 오버플로우로 저장되지도 않았다.
며칠동안, 검색과 GPT의 도움을 받아서 다행히 해결했다.
방법은 스프링 서버에 먼저 이미지를 업로드하고, 해당 이미지를 게시글을 보여줄때 다운로드 받는 방식으로 해결했다.
const uploadImage = async () => {
const fileInput = document.querySelector(".imageSelector");
const file = fileInput.files[0];
const formData = new FormData();
formData.append("file", file);
try {
const response = await axios.post("/api/uploadImage", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
const imageURL = response.data;
insertImageToEditor(imageURL);
console.log(response);
return imageURL;
} catch (err) {
console.error("Error uploadTets : ", err);
}
};
이미지를 선택하면, 해당 이미지를 FormData
형식으로 생성하여, uploadImage
API로 POST
요청을 날린다.
정상적으로 업로드가 완료되면 해당 이미지에 접근할 수 있는 URL을 insertImageToEditor
로 넘겨준다.
const insertImageToEditor = async (imageURL) => {
const imgTag = document.createElement("img");
imgTag.src = imageURL;
contentTextareaRef.current.appendChild(imgTag);
};
넘겨받은 URL 을 imgTag
의 소스로 설정해주면, 정상적으로 contentTextareaRef
인 <div>
영역에 이미지를 삽입할 수 있다.
// FileUploadController.java
@PostMapping("/api/uploadImage")
public ResponseEntity<String> uploadImage(@RequestParam("file") MultipartFile file) {
try {
File directory = new File(UPLOAD_DIR);
if (!directory.exists()) {
directory.mkdirs(); // creates directory including any necessary but nonexistent parent directories
}
System.out.println("UPLOAD DIRECTORY: " + directory.getAbsolutePath()); // 절대 경로 출력
String fileName = file.getOriginalFilename();
String filePath = UPLOAD_DIR + fileName;
File dest = new File(filePath);
file.transferTo(dest);
return ResponseEntity.ok().body(ACCESS_PATH+fileName);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to upload file: " + e.getMessage());
}
}
}
이미지를 업로드할 때 POST
요청을 날릴 uploadImage
API 이다.
MultipartFile
형태로 파일을 입력받고, UPLOAD_DIR
에 이미지를 저장한다.
저장된 이미지에 접근 가능한 URL을 반환한다.
@RestController
public class FileDownloadController {
private final String UPLOAD_DIR = "uploads/";
@GetMapping("api/images/{filename:.+}")
public ResponseEntity<Resource> downloadFile(@PathVariable String filename){
try {
Path file = Paths.get(UPLOAD_DIR).resolve(filename).normalize();
Resource resource = new UrlResource(file.toUri());
String mimeType = "image/png"; // 기본값으로 png 설정
if (filename.endsWith(".jpg") || filename.endsWith(".jpeg")) {
mimeType = "image/jpeg";
} else if (filename.endsWith(".gif")) {
mimeType = "image/gif";
}
return ResponseEntity.ok().contentType(MediaType.parseMediaType(mimeType))
.body(resource);
} catch (Exception e) {
return ResponseEntity.notFound().build();
}
}
}
브라우저에서 이미지 파일로 인식하기 위해서 MIME
타입을 설정해주고, 이미지를 소스로 설정하여, 해당 URL 을 사용하여 리액트에서 이미지를 표시할 수 있도록 설정한다.
...짠! 🎉
드디어 해결했다.
이제 서버에 이미지가 저장되고, 해당 이미지를 다시 다운받는 형태로 동작한다.
계속해서 프로젝트를 진행하고 있지만..
진짜 어려운 것 같다.
프론트를 개발하려면 백엔드를 알아야하니.. 모든 웹 개발자들은 풀스택 개발자가 될 수 밖에 없는 운명인가.. 🧐