유저 구현은 아래 글과 똑같은 과정으로 진행했다.
OAuth2 적용 (구글)
기본적인 Spring Security 내용과 OAuth2의 동작 방식에 대한 설명 글이다. OAuth에서 사용하는 클라이언트, 리소스 오너, auth 서버, 리소스 서버 용어에 대한 개념도 들어가있다. Spring Security + OAuth2 1. Spr
buchu-doodle.tistory.com
이제 프로젝트 기능의 핵심이라고 할 수 있는, 농장일기를 CRUD하는 기능을 만들어보겠다.
1. FarmLog Entity & DTO 작성
FarmLog.java :
package com.buchu.greenfarm.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.*;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.util.List;
@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@EntityListeners(AuditingEntityListener.class)
public class FarmLog extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long farmLogId;
@NotBlank(message = "내용이 공란이어선 안됩니다.")
@Size(max = 300, message = "농장 일기는 300자를 넘겨선 안됩니다!")
private String logContent;
@ManyToOne
@JoinColumn(name = "author")
private User author;
@OneToMany(mappedBy = "farmLog", cascade = CascadeType.ALL)
private List<Good> likers;
public int getLikeNum() {
return likers.size();
}
}
연관관계는 2가지이다.
1) 작성자 - 일기 관계 : @ManyToOne. 작성자 한 명이 여러 개의 일기를 가지고 있을 수 있다. "author"라는 필드 명으로 작성자의 pk를 저장해두었다.
2) 일기 - 좋아요 유저 관계 : 로그 하나에 여러 명의 사용자가 좋아요를 누를 수 있다. 여러 명의 사용자가 여러 개의 일기에 좋아요를 누를 수 있으므로 원래는 @ManyToMany 관계이지만, "Good"이라는 연관관계 전용 테이블을 하나 만들어 @OneToMany가 엮인 연관관계로 설정했다.
상세한 엔티티간 연관관계 매핑은 다음 글에서!
CreateFarmLog.java :
package com.buchu.greenfarm.dto.farmLog;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.*;
public class CreateFarmLog {
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class Request {
@NotBlank
@Size(max = 300, message = "농장 일기는 0자 이상 300자 이하여야 합니다!")
private String logContent;
}
}
dto 패키지에 저장해놓은 DTO. 일기를 새로 작성하는 데엔 사용자 정보와 일기 내용만 있으면 된다. 사용자 정보는 SecurityContext에서 따로 빼올 것이므로 Request DTO엔 실질적인 내용이 들어있는 logContent만을 담았다.
FarmLogDto.java :
package com.buchu.greenfarm.dto.farmLog;
import com.buchu.greenfarm.entity.FarmLog;
import lombok.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString
public class FarmLogDetailDto {
private Long farmLogId;
private String logContent;
private String authorId;
private String authorName;
private int likeNum;
private String createdAt;
public static FarmLogDetailDto fromEntity(FarmLog farmLog) {
return FarmLogDetailDto.builder()
.logContent(farmLog.getLogContent())
.authorId(farmLog.getAuthor().getUserId())
.authorName(farmLog.getAuthor().getName())
.likeNum(farmLog.getLikeNum())
.createdAt(createdAtString(farmLog.getCreatedAt()))
.farmLogId(farmLog.getFarmLogId())
.build();
}
private static String createdAtString(LocalDateTime createdAt) {
return DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm").format(createdAt);
}
}
홈 화면에서 보일 일기를 가져오기 위한 DTO 클래스인데 말이 DTO지 대부분의 정보를 다 가져오는 것 같다. 해당 일기 객체의 Id를 가져오는 이유는 일기 상세보기 경로로 접근하기 위함이고, 작성자 정보를 가져오는 이유 역시 일기를 노출할 때 작성자의 정보를 함께 노출하고 작성자 상세정보 경로로 이동하기 위함이다.
fromEntity() method를 통해 DB에서 불러온 실제 객체들을 DTO로 변환하여 return한다.
2. FarmLogRepository 작성
일기를 검색함에 있어 필요한 기능은 3가지이다.
- 현재 존재하는 모든 일기 불러오기
- 유저가 팔로잉하는 유저들의 일기만 불러오기
- 특정 유저가 작성한 일기만 불러오기
- 유저가 좋아요한 일기 불러오기
3번의 경우에 연관 테이블을 따로 이용할 것이므로 Repository에서 직접 쿼리를 지정할 필요는 없다.
1번을 위한 "모든 일기 검색", 2번과 3번을 위한 "특정 유저의 일기 검색"을 위한 derived query를 Repository에 지정해줘야 한다.
그리고 불러온 일기들은 모두 최신순으로 정렬한다.
FarmLogRepository.java :
package com.buchu.greenfarm.repository;
import com.buchu.greenfarm.entity.FarmLog;
import com.buchu.greenfarm.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface FarmLogRepository extends JpaRepository<FarmLog,Long> {
List<FarmLog> findAllByOrderByCreatedAtDesc();
List<FarmLog> findByAuthorOrderByCreatedAtDesc(User author);
List<FarmLog> findByAuthor(User author);
}
findByAuthor()은 유저가 팔로잉하는 유저의 일기 목록을 불러올 때, 팔로잉하는 유저들의 일기를 모두 합한 후 마지막에 정렬을 다시 한 번 진행할 것이라 굳이 처음부터 시간순으로 정렬할 필요는 없다 생각해 작성한 method이다.
3. FarmLogService 작성
일기 CRUD를 위한 로직이 담겨있는 서비스 빈이다. service 패키지 밑에 작성했다.
FarmLogService.java :
package com.buchu.greenfarm.service;
import ...
@Service
@RequiredArgsConstructor
public class FarmLogService {
private final FarmLogRepository farmLogRepository;
private final UserRepository userRepository;
private final HttpSession httpSession;
// CREATE
@Transactional
public Long getCreatedFarmLogId(CreateFarmLog.Request request) {
return farmLogRepository.save(
FarmLog.builder()
.logContent(request.getLogContent())
.author(getSessionUser())
.build())
.getFarmLogId();
}
// READ
@Transactional
public List<FarmLogDto> getAllFarmLogs() {
// 모든 일기들을 시간 순으로
return farmLogRepository
.findAllByOrderByCreatedAtDesc().stream()
.map(FarmLogDto::fromEntity)
.collect(Collectors.toList());
}
@Transactional
public FarmLogDto getFarmLogDetail(Long id) {
return FarmLogDto.fromEntity(getFarmLogById(id));
}
// DELETE
@Transactional
public void deleteFarmLog(final Long farmLogId) {
FarmLog beingDeletedFarmLog = getFarmLogById(farmLogId);
validateCurrentUser(beingDeletedFarmLog.getAuthor().getUserId());
farmLogRepository.delete(
beingDeletedFarmLog
);
}
// ETC
@Transactional
public FarmLog getFarmLogById(Long id) {
// Optional 처리
return farmLogRepository.findById(id)
.orElseThrow(() -> new GreenFarmException(
GreenFarmErrorCode.NO_FARM_LOG_ERROR));
}
@Transactional
private void validateCurrentUser(
final String userId) {
// 일기 작성자와 현재 로그인한 유저의 일치 확인
if (!getSessionUser().getUserId().equals(userId)) {
throw new GreenFarmException(
GreenFarmErrorCode.INVALID_REQUEST_USER);
}
}
@Transactional
private User getSessionUser() {
SessionUser currentUser = (SessionUser)
httpSession.getAttribute("user");
if (currentUser==null) {
throw new GreenFarmException(GreenFarmErrorCode.NEED_LOGIN);
}
String email = currentUser.getEmail();
return userRepository.findByEmail(email)
.orElseThrow(() -> new GreenFarmException(
GreenFarmErrorCode.NO_USER_ERROR));
}
}
CR(U)D를 위한 로직은 Repository에서 제공하는 기능 + @Transactional을 이용했다. 기본적인 사항 외 추가로 구현한 util method를 간단히 살펴보면,
- getFarmLogById() : Optional로 return되는 findById()를 처리하기 위한 내부 private method. 만약 찾고자하는 id의 농장 일기가 존재하지 않으면 NO_FARM_LOG_ERROR을 던진다.
- validateCurrentUser() : 일기를 삭제하기 위해선 일기 작성자와 현재 로그인한 유저가 같아야 한다. 이를 위해 현재 세션에 로그인한 유저의 id와 지우고자 하는 일기의 author id를 비교하여 일치하지 않으면 잘못된 유저의 요청이라는 예외를 던진다.
- getSessionUser() : 현재 셋녀에 존재하는 email정보로 DB의 유저 객체를 직접 가져오는 method. 새로운 일기를 생성하기 위해 author를 설정할 때 / 로그인을 확인할 때 등에 쓰였다.
정도가 있겠다. Spring Boot 프로젝트에서 exception handling은 나중에! 처리하도록 하겠다.
4. FarmLogController 작성
프로젝트에서 사용하는 View template은 thymeleaf이다.
FarmLogController.java :
package com.buchu.greenfarm.controller;
import ...;
@Controller
@RequestMapping("farm-log")
@RequiredArgsConstructor
@Slf4j
public class FarmLogController {
private final FarmLogService farmLogService;
private final HttpSession httpSession;
@GetMapping
public List<FarmLogDto> getAllLogs() {
// 모든 일기
return farmLogService.getAllFarmLogs();
}
@GetMapping("/{id}")
public String showFarmLog(
HttpServletRequest request,
@PathVariable Long id,
Model model) {
// 특정 일기
model.addAttribute("farmLog",
farmLogService.getFarmLogDetail(id));
return "showFarmLog.html";
}
@PostMapping
public String createFarmLog(
@Valid @ModelAttribute("createFarmLog")
final CreateFarmLog.Request request) {
// 일기 생성
return "redirect:/farm-log/" +
String.valueOf(
farmLogService.getCreatedFarmLogId(request));
}
@DeleteMapping(value = "/{id}")
public String deleteFarmLog(
@PathVariable("id") final Long farmLogId
) {
// 일기 삭제
log.info("deleting farm log of id: {}", farmLogId);
farmLogService.deleteFarmLog(farmLogId);
return "redirect:/";
}
}
Spring Boot에서 @Controller 어노테이션으로 컨트롤러 빈을 작성해보았다. 몇 가지 짚고가야할 개념을 정리했다.
- 컨트롤러 Object 자체에 @RequestMapping을 붙이면 컨트롤러 하위 method의 prefix path가 된다. 위의 예시에서 controller 클래스에 @RequestMapping("farm-log")가 붙었으므로 @GetMapping()이 붙은 findAll() method는 /farm-log path에, @GetMapping("/{id}")가 붙은 showFarmLog() method는 /farm-log/{id} path에 매칭되는 것이다.
- 파라미터로 들어온 값엔 final을 붙이는 것이 선호된다. 필수는 아니지만 클라이언트 단에서 들어온 정보는 원본을 유지하는 것이 로직상 안전하기 때문이다.
- Thymeleaf를 사용하는 프로젝트에서 handler method의 Model 객체를 통해 view단으로 넘길 객체 attribute를 설정할 수 있다. 추가로, handler method가 return하는 문자열은 resources/templates 아래 있는 html 파일의 이름이다. 해당 경로로 들어갔을 때 html 파일이 유저에게 렌더링된다.
5. Thymeleaf
html 시간이 아니기 때문에 일기와 관련된 기능이 존재하는 부분만 간단하게 따로 떼왔다. 부트스트랩5 기능을 이용했다.
1) Create
일단 일기를 작성하기 위한 form이다. Thymeleaf에서 form data를 주고받기 위해선 view단으로 넘기는 모델에 빈껍데기 객체를 보내주어야 한다. CreateFarmLog.Request 객체를 생성하여 form이 있는 경로에 보내주도록 하자.
model.addAttribute("createFarmLog", new CreateFarmLog.Request());
이제 일기를 작성하는 form html을 확인해보겠다.
<form th:action="@{/farm-log}" class="needs-validation" method="post" th:object="${createFarmLog}" novalidate>
<div class="modal-header">
<h5 class="modal-title fw-bold" id="writeLogTitle">📖 일기 작성</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="form-group">
<textarea class="form-control" id="textArea" rows="5" th:field="*{logContent}" maxlength="300" required>
</textarea>
<div class="invalid-feedback">
농장 일기가 공란이어선 안됩니다.
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
<button type="submit" class="btn btn-success">작성!</button>
</div>
</form>
form 태그의 th:object="${createFarmLog}"에서 방금 우리가 보내준 빈껍데기 객체가 사용되었음을 알 수 있다.
form에서 받는 데이터는 단 하나, logContent 필드이다. textarea 태그의 th:field="*{logContent}"에서 확인할 수 있다. 부트스트랩의 기본적인 validation 기능을 깔아놨기 때문에 아무것도 작성하지 않은 채 버튼을 제출 버튼을 누르면 invalid feedback이 뜬다.
2) Read
특정 일기를 확인하기 위한 view이다. 컨트롤러의 showFarmLog() 핸들러를 통해 Model로 넘어간 farmLog attribute를 view 단에서 이용하는 모습이다.
<div class="d-flex p-1">
<a class="me-auto text-decoration-none text-black"
th:text="${'@'+farmLog.authorId}"
th:href="@{'/'+${farmLog.authorId}}"></a>
<div th:text="${farmLog.createdAt+' 작성'}"></div>
</div>
위의 코드에선 a 태그의 href 값으로 authorId를 넣어 해당 태그를 클릭하면 특정 유저의 프로필 화면으로 넘어갈 수 있도록 했다.
<p class="card-text" th:text="${farmLog.logContent}"></p>
3) Delete
<form class="d-inline"
th:action="@{${'/farm-log/'+farmLog.farmLogId}}"
th:method="delete"
sec:authorize="isAuthenticated()">
<button th:if="${session.user.userId==farmLog.authorId}"
th:text="${'✕ 삭제하기'}"
class="btn btn-outline-danger btn-sm"></button>
</form>
# 참고 : HTTP Method
데이터의 CRUD를 위해 데이터 자체는 명사로써, 데이터를 조작하는 GET / POST / PUT(PATCH) / DELETE method를 동사로써 지향하는 것이 RESTful API이다. 그러나 순수 HTML form은 GET과 POST method만을 지원한다.
이와 관련한 내용은 아래 블로그 글이 설명해주고 있는데, 뭔가 장황한 내용이 있지만 크게 이래서 아니구나! 싶게 와닿는 내용은 없는 것 같다(...)
REST - HTML Form에서 GET/POST만 지원하는 이유
연재 목록 REST - 긴 여정의 시작 REST - HTML Form에서 GET/POST만 지원하는 이유 REST - 논문(요약) 훑어보기 REST - REST 좋아하시네 REST - Roy가 입을 열다 REST - 당신이 만든 건 REST가 아니지만 괜찮아 REST -
haah.kr
아무튼, spring web 프로젝트에서 form에 다른 rest 동사를 쓰고 싶으면 application.properties에 다음 코드를 추가해주어야 한다.
spring.mvc.hiddenmethod.filter.enabled=true
유저 구현은 아래 글과 똑같은 과정으로 진행했다.
OAuth2 적용 (구글)
기본적인 Spring Security 내용과 OAuth2의 동작 방식에 대한 설명 글이다. OAuth에서 사용하는 클라이언트, 리소스 오너, auth 서버, 리소스 서버 용어에 대한 개념도 들어가있다. Spring Security + OAuth2 1. Spr
buchu-doodle.tistory.com
이제 프로젝트 기능의 핵심이라고 할 수 있는, 농장일기를 CRUD하는 기능을 만들어보겠다.
1. FarmLog Entity & DTO 작성
FarmLog.java :
package com.buchu.greenfarm.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.*;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.util.List;
@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@EntityListeners(AuditingEntityListener.class)
public class FarmLog extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long farmLogId;
@NotBlank(message = "내용이 공란이어선 안됩니다.")
@Size(max = 300, message = "농장 일기는 300자를 넘겨선 안됩니다!")
private String logContent;
@ManyToOne
@JoinColumn(name = "author")
private User author;
@OneToMany(mappedBy = "farmLog", cascade = CascadeType.ALL)
private List<Good> likers;
public int getLikeNum() {
return likers.size();
}
}
연관관계는 2가지이다.
1) 작성자 - 일기 관계 : @ManyToOne. 작성자 한 명이 여러 개의 일기를 가지고 있을 수 있다. "author"라는 필드 명으로 작성자의 pk를 저장해두었다.
2) 일기 - 좋아요 유저 관계 : 로그 하나에 여러 명의 사용자가 좋아요를 누를 수 있다. 여러 명의 사용자가 여러 개의 일기에 좋아요를 누를 수 있으므로 원래는 @ManyToMany 관계이지만, "Good"이라는 연관관계 전용 테이블을 하나 만들어 @OneToMany가 엮인 연관관계로 설정했다.
상세한 엔티티간 연관관계 매핑은 다음 글에서!
CreateFarmLog.java :
package com.buchu.greenfarm.dto.farmLog;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.*;
public class CreateFarmLog {
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class Request {
@NotBlank
@Size(max = 300, message = "농장 일기는 0자 이상 300자 이하여야 합니다!")
private String logContent;
}
}
dto 패키지에 저장해놓은 DTO. 일기를 새로 작성하는 데엔 사용자 정보와 일기 내용만 있으면 된다. 사용자 정보는 SecurityContext에서 따로 빼올 것이므로 Request DTO엔 실질적인 내용이 들어있는 logContent만을 담았다.
FarmLogDto.java :
package com.buchu.greenfarm.dto.farmLog;
import com.buchu.greenfarm.entity.FarmLog;
import lombok.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString
public class FarmLogDetailDto {
private Long farmLogId;
private String logContent;
private String authorId;
private String authorName;
private int likeNum;
private String createdAt;
public static FarmLogDetailDto fromEntity(FarmLog farmLog) {
return FarmLogDetailDto.builder()
.logContent(farmLog.getLogContent())
.authorId(farmLog.getAuthor().getUserId())
.authorName(farmLog.getAuthor().getName())
.likeNum(farmLog.getLikeNum())
.createdAt(createdAtString(farmLog.getCreatedAt()))
.farmLogId(farmLog.getFarmLogId())
.build();
}
private static String createdAtString(LocalDateTime createdAt) {
return DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm").format(createdAt);
}
}
홈 화면에서 보일 일기를 가져오기 위한 DTO 클래스인데 말이 DTO지 대부분의 정보를 다 가져오는 것 같다. 해당 일기 객체의 Id를 가져오는 이유는 일기 상세보기 경로로 접근하기 위함이고, 작성자 정보를 가져오는 이유 역시 일기를 노출할 때 작성자의 정보를 함께 노출하고 작성자 상세정보 경로로 이동하기 위함이다.
fromEntity() method를 통해 DB에서 불러온 실제 객체들을 DTO로 변환하여 return한다.
2. FarmLogRepository 작성
일기를 검색함에 있어 필요한 기능은 3가지이다.
- 현재 존재하는 모든 일기 불러오기
- 유저가 팔로잉하는 유저들의 일기만 불러오기
- 특정 유저가 작성한 일기만 불러오기
- 유저가 좋아요한 일기 불러오기
3번의 경우에 연관 테이블을 따로 이용할 것이므로 Repository에서 직접 쿼리를 지정할 필요는 없다.
1번을 위한 "모든 일기 검색", 2번과 3번을 위한 "특정 유저의 일기 검색"을 위한 derived query를 Repository에 지정해줘야 한다.
그리고 불러온 일기들은 모두 최신순으로 정렬한다.
FarmLogRepository.java :
package com.buchu.greenfarm.repository;
import com.buchu.greenfarm.entity.FarmLog;
import com.buchu.greenfarm.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface FarmLogRepository extends JpaRepository<FarmLog,Long> {
List<FarmLog> findAllByOrderByCreatedAtDesc();
List<FarmLog> findByAuthorOrderByCreatedAtDesc(User author);
List<FarmLog> findByAuthor(User author);
}
findByAuthor()은 유저가 팔로잉하는 유저의 일기 목록을 불러올 때, 팔로잉하는 유저들의 일기를 모두 합한 후 마지막에 정렬을 다시 한 번 진행할 것이라 굳이 처음부터 시간순으로 정렬할 필요는 없다 생각해 작성한 method이다.
3. FarmLogService 작성
일기 CRUD를 위한 로직이 담겨있는 서비스 빈이다. service 패키지 밑에 작성했다.
FarmLogService.java :
package com.buchu.greenfarm.service;
import ...
@Service
@RequiredArgsConstructor
public class FarmLogService {
private final FarmLogRepository farmLogRepository;
private final UserRepository userRepository;
private final HttpSession httpSession;
// CREATE
@Transactional
public Long getCreatedFarmLogId(CreateFarmLog.Request request) {
return farmLogRepository.save(
FarmLog.builder()
.logContent(request.getLogContent())
.author(getSessionUser())
.build())
.getFarmLogId();
}
// READ
@Transactional
public List<FarmLogDto> getAllFarmLogs() {
// 모든 일기들을 시간 순으로
return farmLogRepository
.findAllByOrderByCreatedAtDesc().stream()
.map(FarmLogDto::fromEntity)
.collect(Collectors.toList());
}
@Transactional
public FarmLogDto getFarmLogDetail(Long id) {
return FarmLogDto.fromEntity(getFarmLogById(id));
}
// DELETE
@Transactional
public void deleteFarmLog(final Long farmLogId) {
FarmLog beingDeletedFarmLog = getFarmLogById(farmLogId);
validateCurrentUser(beingDeletedFarmLog.getAuthor().getUserId());
farmLogRepository.delete(
beingDeletedFarmLog
);
}
// ETC
@Transactional
public FarmLog getFarmLogById(Long id) {
// Optional 처리
return farmLogRepository.findById(id)
.orElseThrow(() -> new GreenFarmException(
GreenFarmErrorCode.NO_FARM_LOG_ERROR));
}
@Transactional
private void validateCurrentUser(
final String userId) {
// 일기 작성자와 현재 로그인한 유저의 일치 확인
if (!getSessionUser().getUserId().equals(userId)) {
throw new GreenFarmException(
GreenFarmErrorCode.INVALID_REQUEST_USER);
}
}
@Transactional
private User getSessionUser() {
SessionUser currentUser = (SessionUser)
httpSession.getAttribute("user");
if (currentUser==null) {
throw new GreenFarmException(GreenFarmErrorCode.NEED_LOGIN);
}
String email = currentUser.getEmail();
return userRepository.findByEmail(email)
.orElseThrow(() -> new GreenFarmException(
GreenFarmErrorCode.NO_USER_ERROR));
}
}
CR(U)D를 위한 로직은 Repository에서 제공하는 기능 + @Transactional을 이용했다. 기본적인 사항 외 추가로 구현한 util method를 간단히 살펴보면,
- getFarmLogById() : Optional로 return되는 findById()를 처리하기 위한 내부 private method. 만약 찾고자하는 id의 농장 일기가 존재하지 않으면 NO_FARM_LOG_ERROR을 던진다.
- validateCurrentUser() : 일기를 삭제하기 위해선 일기 작성자와 현재 로그인한 유저가 같아야 한다. 이를 위해 현재 세션에 로그인한 유저의 id와 지우고자 하는 일기의 author id를 비교하여 일치하지 않으면 잘못된 유저의 요청이라는 예외를 던진다.
- getSessionUser() : 현재 셋녀에 존재하는 email정보로 DB의 유저 객체를 직접 가져오는 method. 새로운 일기를 생성하기 위해 author를 설정할 때 / 로그인을 확인할 때 등에 쓰였다.
정도가 있겠다. Spring Boot 프로젝트에서 exception handling은 나중에! 처리하도록 하겠다.
4. FarmLogController 작성
프로젝트에서 사용하는 View template은 thymeleaf이다.
FarmLogController.java :
package com.buchu.greenfarm.controller;
import ...;
@Controller
@RequestMapping("farm-log")
@RequiredArgsConstructor
@Slf4j
public class FarmLogController {
private final FarmLogService farmLogService;
private final HttpSession httpSession;
@GetMapping
public List<FarmLogDto> getAllLogs() {
// 모든 일기
return farmLogService.getAllFarmLogs();
}
@GetMapping("/{id}")
public String showFarmLog(
HttpServletRequest request,
@PathVariable Long id,
Model model) {
// 특정 일기
model.addAttribute("farmLog",
farmLogService.getFarmLogDetail(id));
return "showFarmLog.html";
}
@PostMapping
public String createFarmLog(
@Valid @ModelAttribute("createFarmLog")
final CreateFarmLog.Request request) {
// 일기 생성
return "redirect:/farm-log/" +
String.valueOf(
farmLogService.getCreatedFarmLogId(request));
}
@DeleteMapping(value = "/{id}")
public String deleteFarmLog(
@PathVariable("id") final Long farmLogId
) {
// 일기 삭제
log.info("deleting farm log of id: {}", farmLogId);
farmLogService.deleteFarmLog(farmLogId);
return "redirect:/";
}
}
Spring Boot에서 @Controller 어노테이션으로 컨트롤러 빈을 작성해보았다. 몇 가지 짚고가야할 개념을 정리했다.
- 컨트롤러 Object 자체에 @RequestMapping을 붙이면 컨트롤러 하위 method의 prefix path가 된다. 위의 예시에서 controller 클래스에 @RequestMapping("farm-log")가 붙었으므로 @GetMapping()이 붙은 findAll() method는 /farm-log path에, @GetMapping("/{id}")가 붙은 showFarmLog() method는 /farm-log/{id} path에 매칭되는 것이다.
- 파라미터로 들어온 값엔 final을 붙이는 것이 선호된다. 필수는 아니지만 클라이언트 단에서 들어온 정보는 원본을 유지하는 것이 로직상 안전하기 때문이다.
- Thymeleaf를 사용하는 프로젝트에서 handler method의 Model 객체를 통해 view단으로 넘길 객체 attribute를 설정할 수 있다. 추가로, handler method가 return하는 문자열은 resources/templates 아래 있는 html 파일의 이름이다. 해당 경로로 들어갔을 때 html 파일이 유저에게 렌더링된다.
5. Thymeleaf
html 시간이 아니기 때문에 일기와 관련된 기능이 존재하는 부분만 간단하게 따로 떼왔다. 부트스트랩5 기능을 이용했다.
1) Create
일단 일기를 작성하기 위한 form이다. Thymeleaf에서 form data를 주고받기 위해선 view단으로 넘기는 모델에 빈껍데기 객체를 보내주어야 한다. CreateFarmLog.Request 객체를 생성하여 form이 있는 경로에 보내주도록 하자.
model.addAttribute("createFarmLog", new CreateFarmLog.Request());
이제 일기를 작성하는 form html을 확인해보겠다.
<form th:action="@{/farm-log}" class="needs-validation" method="post" th:object="${createFarmLog}" novalidate>
<div class="modal-header">
<h5 class="modal-title fw-bold" id="writeLogTitle">📖 일기 작성</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="form-group">
<textarea class="form-control" id="textArea" rows="5" th:field="*{logContent}" maxlength="300" required>
</textarea>
<div class="invalid-feedback">
농장 일기가 공란이어선 안됩니다.
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
<button type="submit" class="btn btn-success">작성!</button>
</div>
</form>
form 태그의 th:object="${createFarmLog}"에서 방금 우리가 보내준 빈껍데기 객체가 사용되었음을 알 수 있다.
form에서 받는 데이터는 단 하나, logContent 필드이다. textarea 태그의 th:field="*{logContent}"에서 확인할 수 있다. 부트스트랩의 기본적인 validation 기능을 깔아놨기 때문에 아무것도 작성하지 않은 채 버튼을 제출 버튼을 누르면 invalid feedback이 뜬다.
2) Read
특정 일기를 확인하기 위한 view이다. 컨트롤러의 showFarmLog() 핸들러를 통해 Model로 넘어간 farmLog attribute를 view 단에서 이용하는 모습이다.
<div class="d-flex p-1">
<a class="me-auto text-decoration-none text-black"
th:text="${'@'+farmLog.authorId}"
th:href="@{'/'+${farmLog.authorId}}"></a>
<div th:text="${farmLog.createdAt+' 작성'}"></div>
</div>
위의 코드에선 a 태그의 href 값으로 authorId를 넣어 해당 태그를 클릭하면 특정 유저의 프로필 화면으로 넘어갈 수 있도록 했다.
<p class="card-text" th:text="${farmLog.logContent}"></p>
3) Delete
<form class="d-inline"
th:action="@{${'/farm-log/'+farmLog.farmLogId}}"
th:method="delete"
sec:authorize="isAuthenticated()">
<button th:if="${session.user.userId==farmLog.authorId}"
th:text="${'✕ 삭제하기'}"
class="btn btn-outline-danger btn-sm"></button>
</form>
# 참고 : HTTP Method
데이터의 CRUD를 위해 데이터 자체는 명사로써, 데이터를 조작하는 GET / POST / PUT(PATCH) / DELETE method를 동사로써 지향하는 것이 RESTful API이다. 그러나 순수 HTML form은 GET과 POST method만을 지원한다.
이와 관련한 내용은 아래 블로그 글이 설명해주고 있는데, 뭔가 장황한 내용이 있지만 크게 이래서 아니구나! 싶게 와닿는 내용은 없는 것 같다(...)
REST - HTML Form에서 GET/POST만 지원하는 이유
연재 목록 REST - 긴 여정의 시작 REST - HTML Form에서 GET/POST만 지원하는 이유 REST - 논문(요약) 훑어보기 REST - REST 좋아하시네 REST - Roy가 입을 열다 REST - 당신이 만든 건 REST가 아니지만 괜찮아 REST -
haah.kr
아무튼, spring web 프로젝트에서 form에 다른 rest 동사를 쓰고 싶으면 application.properties에 다음 코드를 추가해주어야 한다.
spring.mvc.hiddenmethod.filter.enabled=true