목표
1. JPA-Hibernate로 이뤄져있던 도메인 엔티티 구성을 변경한다.
2. JpaRepository를 MongoRepository와 MongoTemplate으로 변경한다.
3. 테스트 코드를 수정하고 확인한다.
순서대로 바꾸다보니, 참조하는 코드끼리 문제가 생길 수 있음을 고려하여 경우에 따라 새로 클래스를 만들고 기존의 클래스를 변경하도록 할 것이다.
id의 타입을 ObjectId
로 변경하고, hibernate의 어노테이션을 지워준다.
@Getter
@MappedSuperclass
public class MgBaseEntity {
@Id
@Setter
protected ObjectId id;
protected LocalDateTime createdAt = LocalDateTime.now();
protected LocalDateTime updatedAt = LocalDateTime.now();
}
아직 @Entity
어노테이션이 남아있는 이유는 BoardEntity
에서 @ManyToOne
으로 이 엔티티를 참조하고 있기때문이다. BoardEntity를 MongoDB에 맞춰 완전히 변경하게 되면 이 어노테이션도 제거할 것이다.
@Getter
@NoArgsConstructor
@Document(collection = "users")
@Entity
public class UserEntity extends MgBaseEntity {
private String accountId;
private String password;
@Setter
@Enumerated(EnumType.STRING)
private UserRole role;
@Builder
public UserEntity(String accountId, String password, UserRole role) {
this.accountId = accountId;
this.password = password;
this.role = role;
}
}
MongoRepository가 Enum 타입을 지원한다고 하기는 하는데, persistence
에 포함된 @Enumerated
어노테이션으로 작동되는지 확인해보기위해 남겨놓았다.
users
라는 컬렉션에 저장되도록 하였다.
@Getter
@NoArgsConstructor
@Document(collection = "boards")
public class MgBoardEntity extends MgBaseEntity {
private UserEntity writer;
@Setter
private String contents;
private String title;
@Builder
public MgBoardEntity(UserEntity writer, String contents, String title){
this.writer = writer;
this.contents = contents;
this.title = title;
}
public MgBoardEntity update(MgBoardEntity newBoard){
this.writer = newBoard.getWriter();
this.contents = newBoard.getContents();
this.title = newBoard.getTitle();
return this;
}
}
이전에 JpaRepository를 상속받던 것을 MongoRepository를 상속받는 것으로 변경해준다.
제네릭 타입 중 두번째 인수인 id의 타입은 Long에서 ObjectId로 변경한다.
@Repository
public interface UserRepository extends MongoRepository<UserEntity, ObjectId> {
UserEntity findByAccountId(String accountId);
List<UserEntity> findAllByAccountIdLike(String keyword);
}
간단한 insert 테스트를 수행해보자
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/applicationContext.xml"})
@Transactional
public class UserMongoRepositoryTest {
@Autowired
private UserRepository sut;
private UserEntity user;
@BeforeEach
private void init(){
user = UserEntity.builder().role(UserRole.NORMAL).accountId(getRandomString()).password("pass").build();
}
@Test
public void saveTest(){
sut.save(user);
UserEntity findUser = sut.findById(user.getId()).get();
assertThat(findUser.getAccountId(), equalTo(user.getAccountId()));
}
private String getRandomString() {
String id = "";
for (int i = 0; i < 10; i++) {
double dValue = Math.random();
if (i % 2 == 0) {
id += (char) ((dValue * 26) + 65); // 대문자
continue;
}
id += (char) ((dValue * 26) + 97); // 소문자
}
return id;
}
}
Enum 타입도 name 형식으로 잘 들어갔다.
문제는 LocalDateTime.now()
로 생성한 시간이 UTC 표준시로 들어가는 것이다. MongoDB 공식문서에서는 9시간을 더하는 것으로 이를 해결해라고 하고 있지만.. 🤔 컴퓨터 로컬 시간에 맞춰서 사용자에게 시간을 보여주는 것이 맞기때문에 서버에서는 UTC로 저장하고, 프론트에서 로컬 시간대로 변경하도록 하겠다.
테스트 코드가 잘 돌아가는지 확인하자.
필자는 커스텀 Exception을 추가하기 전의 테스트 코드로 작성되어 있길래 아래와 같이 코드를 변경해주었다.
만약 해당 테스트 클래스에 @Rollback(value = false)
이 포함되어있으면 Exception을 의도한 테스트에서 아래와 같은 오류가 발생하니 주의하자.
❗️org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/applicationContext.xml", "file:src/main/webapp/WEB-INF/dispatcher-servlet.xml"})
@Transactional
@WebAppConfiguration
public class UserApiControllerTest {
@Autowired
private WebApplicationContext webApplicationContext;
@Autowired
private UserRepository userRepository;
private MockMvc mvc;
private ObjectMapper objectMapper;
@BeforeEach
public void initMvc() {
mvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
objectMapper = new ObjectMapper();
}
private String randomId(){
String id = "";
for(int i = 0; i < 10; i++) {
double dValue = Math.random();
if(i%2 == 0) {
id += (char) ((dValue * 26) + 65); // 대문자
continue;
}
id += (char)((dValue * 26) + 97); // 소문자
}
return id;
}
@Test
@DisplayName("동일한 아이디를 가진 유저가 없으면 가입에 성공한다.")
public void joinTest1() throws Exception {
UserForm userForm = UserForm.builder().accountId(randomId()).password("password").build();
mvc.perform(post("/api/users")
.content(objectMapper.writeValueAsString(userForm))
.contentType(MediaType.APPLICATION_JSON_VALUE))
.andExpect(status().isOk());
}
@Test
@DisplayName("동일한 아이디를 가진 유저가 있으면 가입에 실패한다.")
public void joinTest2() throws Exception {
UserEntity userEntity = userRepository.findAll().get(0);
UserForm userForm = UserForm.builder().accountId(userEntity.getAccountId()).password("password").build();
mvc.perform(post("/api/users")
.content(objectMapper.writeValueAsString(userForm))
.contentType(MediaType.APPLICATION_JSON_VALUE))
.andExpect(result -> assertEquals(result.getResolvedException().getClass().getCanonicalName(), FreeBoardException.class.getCanonicalName()))
.andExpect(result -> assertEquals(result.getResolvedException().getMessage(), UserExceptionType.DUPLICATED_USER.getErrorMessage()))
.andExpect(status().isOk());
}
@Test
@DisplayName("비밀번호를 올바르게 입력하면 로그인되고, 세션에 저장된다.")
public void loginTest1() throws Exception {
UserEntity userEntity = userRepository.findAll().get(0);
UserForm userForm = UserForm.builder().accountId(userEntity.getAccountId()).password(userEntity.getPassword()).build();
mvc.perform(post("/api/users?type=LOGIN")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsString(userForm)))
.andExpect(request().sessionAttribute("USER", notNullValue()))
.andExpect(status().isOk());
}
@Test
@DisplayName("비밀번호를 올바르게 입력하지 않으면 로그인 거부된다.")
public void loginTest2() throws Exception {
UserEntity userEntity = userRepository.findAll().get(0);
UserForm userForm = UserForm.builder().accountId(userEntity.getAccountId()).password(userEntity.getPassword()+"wrongPass").build();
mvc.perform(post("/api/users?type=LOGIN")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsString(userForm)))
.andExpect(request().sessionAttribute("USER", nullValue()))
.andExpect(result -> assertEquals(result.getResolvedException().getClass().getCanonicalName(), FreeBoardException.class.getCanonicalName()))
.andExpect(result -> assertEquals(result.getResolvedException().getMessage(), UserExceptionType.WRONG_PASSWORD.getErrorMessage()))
.andExpect(status().isOk());
}
}
User관련 로직들이 문제없이 돌아가는 것을 확인했으면, MgBoardEntity를 BoardEntity로 변경한 뒤 MgBoardEntity는 제거한다.
@Getter
@NoArgsConstructor
@Document(collection = "boards")
public class BoardEntity extends MgBaseEntity {
private UserEntity writer;
@Setter
private String contents;
private String title;
@Builder
public BoardEntity(UserEntity writer, String contents, String title){
this.writer = writer;
this.contents = contents;
this.title = title;
}
public BoardEntity update(BoardEntity newBoard){
this.writer = newBoard.getWriter();
this.contents = newBoard.getContents();
this.title = newBoard.getTitle();
return this;
}
}
id가 Long
타입에서 ObjectId
타입으로 변경되면서 여러 코드가 수정이 필요할 것이다. 테스트 코드를 돌려서 문법 오류가 나는 곳들은 고쳐준다.
문법을 다 고치고 나면 아래와 같은 예외가 발생할 것이다.
❗️Caused by: org.springframework.data.mapping.PropertyReferenceException: No property findAll found for type BoardEntity!
findAll
에서 받고있는 Specification 클래스 타입을 MongoRepository는 지원하지 않기 때문에 적절한 findAll
을 찾을 수 없다고 하는 것이다.
(추가적으로 findAllByWriterId
도 동작하지 않는데, Board가 가지고 있는 멤버변수는 writerId가 아니라 writer
이며 인수로 UserEntity
를 전달해줘야 짝이 맞다.)
1️⃣우선, findAllByWriterId(ObjectId writerId)
는 findAllByWriter(UserEntity userEntity)
로 변경한다.
2️⃣ findAll 메소드는 삭제하고, 대신 MongoOperation
을 사용하는 클래스를 만들것이다.
@Repository
public interface BoardRepository extends MongoRepository<BoardEntity, ObjectId> {
List<BoardEntity> findAllByWriter(UserEntity userEntity);
Page<BoardEntity> findAllByWriterIn(List<UserEntity> userEntityList, Pageable pageable);
}
BoardRepository
와 같은 댑스에 BoardOperator
클래스를 만들어준다.
이 클래스는 MongoOperator
를 사용한 로직 처리를 담당할 것이며 복잡한 조건문이나 동적쿼리 생성, 업데이트 처리에 사용할 것이다.
@Repository
public class BoardOperator {
private MongoOperations operations;
@Autowired
public BoardOperator(MongoTemplate template) {
Assert.notNull(template, "MongoTemplate must not be null!");
this.operations = template;
}
public Page<BoardEntity> findAllByLike(SearchType searchType, String keyword, Pageable pageable) {
Query query = getQuery(searchType, keyword, pageable);
List<BoardEntity> boards = operations.find(query, BoardEntity.class);
Page<BoardEntity> boardPage = PageableExecutionUtils.getPage(boards, pageable, () -> operations.count(Query.of(query).limit(-1).skip(-1), BoardEntity.class));
return boardPage;
}
private Query getQuery(SearchType searchType, String keyword, Pageable pageable) {
Query query = new Query();
if (searchType.equals(SearchType.ALL)) {
query.addCriteria(new Criteria().orOperator(Criteria.where("contents").regex(keyword), Criteria.where("title").regex(keyword)));
} else if (searchType.equals(SearchType.CONTENTS)) {
query.addCriteria(new Criteria().where("contents").regex(keyword));
} else if (searchType.equals(SearchType.TITLE)) {
query.addCriteria(new Criteria().where("title").regex(keyword));
} else if (searchType.equals(SearchType.WRITER)) {
query.addCriteria(new Criteria().where("writer.accountId").regex(keyword));
}
return query.with(pageable);
}
}
findAllByLike
는 검색 키워드와 검색 기준에 따라서 다른 쿼리를 수행하는 동적 쿼리 수행 메소드이다. getQuery
메소드에서 검색 기준과 페이징 처리(sort, limit, offset 등 ..)를 위한 쿼리를 만들어 반환하면 find
메소드로 적절한 데이터를 가져온다.
Page
컬렉션으로 만들기위해 PageableExecutionUtils.getPage
를 사용했는데, 첫번째인자는 컨텐츠, 두번째 인자는 페이지 정보(pageSize, number 등 사용), 세번째 인자는 같은 조건으로 검색했을 때 "전체" 데이터 개수이다.
이 세가지 인자를 이용하여 Page와 관련된 정보를 조합해준다. (+컨텐츠까지 저장해서 Page 컬렉션으로 반환한다.)
여기까지 바꿨으면 Service 클래스, ApiController 클래스, 테스트 클래스 등에서 구문 오류가 많이 발생할 것이다. 변경된 메소드를 적용하는 것으로 바꿔주도록 하자. 🙋🏻 (테스트 코드는 꼭 적절하게 변경해서 문제가 생기는 구간이 없는가 확인하자 ⭐️)
BoardOperator 클래스에 업데이트 메소드를 추가할 것이다. save 메소드를 이용하여 동일한 id의 데이터를 모두 덮어쓰는 (id가 없는 경우 새로운 데이터로 insert) MongoRepository와 달리, MongoOperations를 사용하면 변경되는 필드에 대해서만 갱신된다.
따라서 어떤 필드를 변경하는지는 상관없이 동적으로 쿼리를 생성할 수 있는 로직을 작성할 것이다.
public UpdateResult update(ObjectId id, BoardEntity updatedEntity) {
Query query = new Query(Criteria.where("_id").is(id));
Update update = getUpdate(updatedEntity);
return operations.updateFirst(query, update, BoardEntity.class);
}
getUpdate
부분이 핵심 코드이다. 변경되는 필드 종류에 구애받지 않고 사용가능하도록 reflection을 사용하였다.
private Update getUpdate(BoardEntity updatedEntity) {
try {
List getterMethodNames = getBoardEntityGetterMethodName();
Update update = new Update().currentDate("updatedAt");
for (Method method : BoardEntity.class.getDeclaredMethods()) {
if (getterMethodNames.contains(method.getName())) {
Object obj = method.invoke(updatedEntity);
Optional.ofNullable(obj).ifPresent(none -> update.set(getField(method), obj));
}
}
return update;
} catch (IllegalAccessException | InvocationTargetException e) {
e.getStackTrace();
throw new RuntimeException();
}
}
private List getBoardEntityGetterMethodName() {
Field[] fields = BoardEntity.class.getDeclaredFields();
return Arrays.asList(fields).stream().map(field -> "get" + field.getName().substring(0, 1).toUpperCase() + field.getName().substring(1)).collect(Collectors.toList());
}
private String getField(Method method) {
String Field = method.getName().substring(3);
return Field.substring(0, 1).toLowerCase() + Field.substring(1);
}
getBoardEntityGetterMethodName
에서 Getter "메소드 이름"을 만들어낸다.getUpdate
의 for 루프를 돌며 Getter "메소드"를 가려낸다.method.invoke(..)
를 이용하여 updatedEntity가 가지고 있는 변수 값을 가져온다. (obj)null
이 아니라면 그 값으로 갱신한다고 판단한다.getField
에서 getter 메소드 "이름"을 조작하여 갱신될 필드의 "이름"을 추출해낸다. (e.g. getSchoolBusNumber
-> SchoolBusNumber -> s + hoolBusNumber -> shoolBusNumber = field명으로 사용)update 메소드가 잘 작동하는지 테스트 코드를 돌려보자.
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/applicationContext.xml"})
@Transactional
class BoardOperatorTest {
@Autowired
private BoardOperator sut;
@Autowired
private BoardRepository boardRepository;
@Autowired
private UserRepository userRepository;
@Test
public void updateTest() {
BoardEntity boardEntity = getTestData();
boardRepository.save(boardEntity);
String updatedContents = getRandomString();
BoardEntity updatedEntity = BoardEntity.builder().contents(updatedContents).build();
sut.update(boardEntity.getId(), updatedEntity);
BoardEntity selectedEntity = boardRepository.findById(boardEntity.getId()).get();
assertEquals(selectedEntity.getTitle(),boardEntity.getTitle());
assertEquals(selectedEntity.getWriter().getId().toString(), boardEntity.getWriter().getId().toString());
assertEquals(selectedEntity.getContents(), updatedContents);
}
private BoardEntity getTestData() {
UserEntity userEntity = userRepository.findAll().get(0);
return BoardEntity.builder().title("title").contents("contents").writer(userEntity).build();
}
private String getRandomString() {
String id = "";
for (int i = 0; i < 10; i++) {
double dValue = Math.random();
if (i % 2 == 0) {
id += (char) ((dValue * 26) + 65); // 대문자
continue;
}
id += (char) ((dValue * 26) + 97); // 소문자
}
return id;
}
}
update 메소드를 위와같이 변경해준다.
BoardService(=sut
)를 테스트하는 클래스에 update 메소드의 테스트 코드를 추가하였다.
@Test
public void update() {
String contents = "컨텐츠는 변화없음";
String updatedTitle = "제목 업데이트";
BoardEntity boardEntity = BoardEntity.builder().contents(contents).writer(writer.get(3)).title("title").build();
boardRepository.save(boardEntity);
UserForm userForm = UserForm.builder().accountId(writer.get(3).getAccountId()).password(writer.get(3).getPassword()).build();
BoardForm updatedForm = BoardForm.builder().title(updatedTitle).build();
sut.update(updatedForm, userForm, boardEntity.getId());
BoardEntity selectedEntity = boardRepository.findById(boardEntity.getId()).get();
assertThat(selectedEntity.getTitle(), equalTo(updatedTitle));
assertThat(selectedEntity.getContents(), equalTo(contents));
}
프론트 코드를 보면 (수정된 값인지 아닌지에 상관없이) 무조건 Form 데이터를 가져오게 돼있어 BoardForm의 변수는 null 값을 가질 수가 없다.
예를 들어 제목이 "Hello"이고, 내용이 "World"인 글의 내용만 "Java"로 변경하였다한들 폼을 생성할 때는 이전값과 똑같은 제목인 "Hello"도 포함되어 넘어온단 소리다.
이 부분은 추후에 프론트에서 리팩토링 해주도록 하겠다.
전체 코드는 github에서 확인 할 수 있습니다.