Blog

[Spring-LDW] 유저:게시글 1:N 양방향 연관관계 설정 및 테스트 코드 수정 중 직렬화/역직렬화 트러블 슈팅

Category
Author
citeFred
citeFred
Tags
PinOnMain
1 more property
스프링 부트와 AWS로 혼자 구현하는 웹 서비스  : 인텔리제이, JPA, JUnit 테스트, 그레이들, 소셜 로그인, AWS 인프라로 무중단 배포까지 이동욱 저
Table of Content

연관관계를 해야하는 이유?

프로젝트 기본 구조가 완성되었지만 User, Posts 테이블간의 연관관계가 설정되어있지 않다. 1:N, 1:1, N:N 등 연관 관계와 단방향, 양방향 연관 관계를 설정하는 것을 배워왔고 이 프로젝트에도 적용하고자 한다.
그럼 왜 연관관계가 필요할까?
요약하면 객체 지향 프로그래밍을 지향하는데 있어 자연스러운 객체간의 관계를 만들기 위해서다.
일반적으로 특정 유저가 게시글을 작성하게 될 것이며, 유저를 통해서 무슨 게시글을 작성했는지 알고 싶을 수도 있고, 게시글을 통해서 누가 작성한 게시글인지 알고 싶을 때도 있다. 이처럼 객체간의 자연스러운 연결을 위해서 연관 관계를 생각해야 하는 것이 기본이다. 이것은 곧 두 객체간의 일관성을 유지하는 목적이 크다.
연관관계를 맺으려는 이유는 다음처럼 정리 할 수 있다.
1. 데이터의 의미적인 표현:
연관관계를 맺으면 데이터베이스에서 두 엔티티 간의 관계가 명확히 표현된다. 이는 개발자나 유지보수를 담당하는 사람들이 데이터 구조를 이해하고 사용하기 쉽게 만들 수 있다.
4. 객체지향 프로그래밍에서의 모델링:
객체지향 프로그래밍에서는 엔티티 간의 관계를 모델링하는 것이 중요하다. 엔티티들 간의 자연스러운 관계를 유지하면 객체 간의 상호작용이나 로직 구현이 편리해진다.
2. 데이터 일관성 유지:
연관관계를 맺으면 데이터의 일관성을 유지하기 쉽다. 예를 들어, 사용자(User)와 게시물(Posts)이 서로 연관되어 있다면, 특정 사용자가 작성한 게시물을 식별하고 관리하기 용이해진다.
3. 쿼리 및 조인의 효율성:
데이터베이스에서 연관된 엔티티들은 조인을 사용하여 효율적으로 검색할 수 있다. 특정 사용자가 작성한 모든 게시물을 조회하거나, 특정 게시물의 작성자 정보를 얻는 등의 작업이 쉬워진다.
1:N(일대다) 관계를 선택하는 이유:
사용자(User)와 게시물(Posts) 사이의 1:N 관계를 설정하는 이유는 하나의 사용자가 여러 개의 게시물을 작성할 수 있기 때문이다. 이는 실제 세계의 상황을 반영하고, 데이터 관리와 쿼리 수행에 있어서 유용하다.
예를 들어, 블로그나 포럼 시스템에서 사용자가 여러 개의 게시물을 작성하는 경우가 흔하며, 이러한 상황에서 1:N 관계를 설정하면 효율적으로 데이터를 관리할 수 있다.
여기서 단방향vs양방향을 고민하는 것은 간단한 관계 질문으로 해결하곤했다.
유저를 조회 할 때 게시글과 관련된 내용을 조회 할 필요가 있는가? → yes=유저→게시글 관계 OK
게시글을 조회 할 때 유저와 관련된 내용을 조회 할 필요가 있는가? → yes=게시글→유저 관계 OK
하지만 상황에 따라서 게시글을 통해서 유저를 살펴볼 필요가 없을 수도 있다.
따라서 이 상황에 맞춰서 양방향, 단방향 연관 관계를 생성할 필요가 있다.

엔터티 수정

User.java

package com.citefred.ldwspring.domain.user; import com.citefred.ldwspring.domain.BaseTimeEntity; import com.citefred.ldwspring.domain.posts.Posts; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import javax.persistence.*; import java.util.ArrayList; import java.util.List; @Getter @NoArgsConstructor @Entity public class User extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String name; @Column(nullable = false) private String email; @Column private String picture; @Enumerated(EnumType.STRING) @Column(nullable = false) private Role role; // User와 Posts 간의 1:N 관계 설정 @OneToMany(mappedBy = "author", cascade = CascadeType.ALL) private List<Posts> posts = new ArrayList<>(); @Builder public User(String name, String email, String picture, Role role) { this.name = name; this.email = email; this.picture = picture; this.role = role; } public User update(String name, String picture) { this.name = name; this.picture = picture; return this; } public String getRoleKey() { return this.role.getKey(); } }
Java
복사

@OneToMany

1:N(일대다) 관계에서 1부분에 해당하는 엔터티에 작성한다.(User 는 여러N개의 Posts를 작성 한다라는 기준)
mappedBy = "author"는 Posts의 어떤 필드에 이 User가 맵핑될지 지정하는 것이다.(햇갈릴 수 있음)
Posts는 여러개 또는 여러개를 받을 준비를 해야하기 때문에 배열 List 타입이 된다.

Posts.java

package com.citefred.ldwspring.domain.posts; import com.citefred.ldwspring.domain.BaseTimeEntity; import com.citefred.ldwspring.domain.user.User; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import javax.persistence.*; @Getter @NoArgsConstructor @Entity public class Posts extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(length = 500, nullable = false) private String title; @Column(columnDefinition = "TEXT", nullable = false) private String content; // Posts와 User 간의 N:1 관계 설정 @ManyToOne @JoinColumn(name = "user_id") private User author; @Builder public Posts(String title, String content, User author){ this.title = title; this.content = content; this.author = author; } public void update(String title, String content){ this.title = title; this.content = content; } }
Java
복사

@ManyToOne

1:N(일대다) 관계에서 N부분에 해당하는 엔터티에 작성한다.(User 는 여러N개의 Posts를 작성 한다라는 기준)
@JoinColumn(name = "user_id")
이 N에 해당되는 필드가 1의 어떤 필드에 소속될지 지정하는 것이다. User의 PK인 ID와 연결되도록 할 것임을 말한다.

테스트 코드 수정

User 관련 PostsApiControllerTest 수정

게시글 등록 및 수정 API 테스트 코드에는 게시글 작성자인 author 필드가 기존 String 타입으로 하드코딩된 상태이다. 하지만 연관관계를 통해서 Postsauthor필드는 User 타입의 author가 필요한 것으로 변경되었다.
따라서 게시글 작성 및 수정 테스트 과정에서 임시 모의 유저를 생성하여 작성자인 author로 user의 정보를 넘겨주어야 한다.
@ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class PostsApiControllerTest { ... @Autowired private UserRepository userRepository; @Test @WithMockUser(roles = "USER") public void Posts_등록된다() throws Exception{ //given ... User user = userRepository.save(User.builder() .name("Test User") .email("test@example.com") .role(Role.USER) .build()); PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder() .title(title) .content(content) .author(user) .build(); ... }
Java
복사
하지만 이 부분을 변경하면서 RequestDto 들도 아직 author 필드가 String 타입으로 지정되어 있었기 때문에 관련된 부분에 대해 전체적인 리팩토링이 진행되었다.
게시글 등록 및 수정 API 테스트 코드에는 게시글 작성자인 author 필드가 기존 String 타입으로 하드코딩된 상태이다. 하지만 연관관계를 통해서 Postsauthor필드는 User 타입의 author가 필요한 것으로 변경되었다.
@Getter @NoArgsConstructor public class PostsSaveRequestDto { ... private User author; @Builder public PostsSaveRequestDto(String title, String content, User author){ ... this.author = author; }
Java
복사

역직렬화(Deserialize) 오류?

테스트 코드 실행 중 하나만 비정상적인 테스트를 보여주었다.
오류 내용은 Java 8 date/time type java.time.LocalDateTime not supported by default... 와 같은 내용이었으며 ObjectMapper 함수를 써서 LocalDataTime을 역직렬화 하지 못해서 발생하는 문제임을 알게 되었다. 간단히 말하면 날짜/시간 타입을 JSON으로 변환하는 Jackson 모듈이 클래스패스에 없어서 발생하는 것이라 한다.

build.gradle

build.gradle에 다음과 같이 역직렬화를 해결 할 수 있는 jackson 의존성을 추가해주었다.
dependencies { ... // Jackson - Deserialize Troubleshoot implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' implementation 'com.fasterxml.jackson.core:jackson-databind' ... }
Java
복사

BaseTimeEntity.java

이후 TimeStamp같은 기능을 하고있는 클래스에 @JsonSerialize, @JsonDeserialize 어노테이션을 추가해준다.
package com.citefred.ldwspring.domain; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import lombok.Getter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import javax.persistence.EntityListeners; import javax.persistence.MappedSuperclass; import java.time.LocalDateTime; @Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseTimeEntity { @JsonSerialize(using = LocalDateTimeSerializer.class) @JsonDeserialize(using = LocalDateTimeDeserializer.class) @CreatedDate private LocalDateTime createdDate; @JsonSerialize(using = LocalDateTimeSerializer.class) @JsonDeserialize(using = LocalDateTimeDeserializer.class) @LastModifiedDate private LocalDateTime modifiedDate; }
Java
복사

전체 테스트 코드

다시 테스트코드가 정상적으로 작동하는 것을 확인 할 수 있다.
@ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class PostsApiControllerTest { @LocalServerPort private int port; @Autowired private TestRestTemplate restTemplate; @Autowired private PostsRepository postsRepository; @Autowired private UserRepository userRepository; @Autowired private WebApplicationContext context; private MockMvc mvc; @BeforeEach public void setup() { mvc = MockMvcBuilders .webAppContextSetup(context) .apply(springSecurity()) .build(); } @AfterEach public void tearDown() throws Exception{ postsRepository.deleteAll(); } @Test @WithMockUser(roles = "USER") public void Posts_등록된다() throws Exception{ //given String title = "title"; String content = "content"; User user = userRepository.save(User.builder() .name("Test User") .email("test@example.com") .role(Role.USER) .build()); PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder() .title(title) .content(content) .author(user) .build(); String url = "http://localhost:" +port+"/api/v1/posts"; //when mvc.perform(post(url) .contentType(MediaType.APPLICATION_JSON_UTF8) .content(new ObjectMapper().writeValueAsString(requestDto))) .andExpect(status().isOk()); //then List<Posts> postsLists =postsRepository.findAll(); assertThat(postsLists.get(0).getTitle()).isEqualTo(title); assertThat(postsLists.get(0).getContent()).isEqualTo(content); } @Test @WithMockUser(roles = "USER") public void Posts_수정된다() throws Exception{ //given User user = userRepository.save(User.builder() .name("Test User") .email("test@example.com") .role(Role.USER) .build()); Posts savedPosts = postsRepository.save(Posts.builder() .title("title") .content("content") .author(user) .build()); Long updateId = savedPosts.getId(); String expectedTitle = "title2"; String expectedContent = "content2"; PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder() .title(expectedTitle) .content(expectedContent) .build(); String url = "http://localhost:"+port+"/api/v1/posts/"+updateId; //when mvc.perform(put(url) .contentType(MediaType.APPLICATION_JSON_UTF8) .content(new ObjectMapper().writeValueAsString(requestDto))) .andExpect(status().isOk()); //then List<Posts> postsLists =postsRepository.findAll(); assertThat(postsLists.get(0).getTitle()).isEqualTo(expectedTitle); assertThat(postsLists.get(0).getContent()).isEqualTo(expectedContent); } }
Java
복사
Search
 | Main Page | Category |  Tags | About Me | Contact | Portfolio