스프링 부트와 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 타입으로 하드코딩된 상태이다. 하지만 연관관계를 통해서 Posts의 author필드는 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 타입으로 하드코딩된 상태이다. 하지만 연관관계를 통해서 Posts의 author필드는 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
복사
Related Posts
Search