5-2. 회원 기능 구현 (Service)
이제, 핵심 비즈니스 로직에 해당하는 UserService를 구현해 보자.
필요한 기능과 고려해야 할 예외사항은 다음과 같이 예상했다.
UserService
먼저, UserService를 생성해 준다. UserService는 UserDAO가 제공하는 메서드를 호출해야 하기 때문에 UserDAO 에게 의존성을 가지고 있어야 한다.
@Autowired를 사용해 의존성을 추가해준다.
//...
@Autowired
UserDAO userDAO
//...
Model
이전에 자형에게 프로젝트 구조를 설명 들을 때 각 계층마다 모델을 따로 두는 것이 일반적이라고 설명을 들었다.
미처 그 이유까지 설명을 듣지는 못했으나, 그대로 구현을 해 보며 어떤 이점이 있는지를 찾아보고자 한다.
각 계층마다 모두 다른 모델을 사용할 것이기 때문에 각 계층마다 하위 계층에게 전달하기 위한 모델 변환 클래스를 함께 두어야 할 것이다.
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
public class ServiceUser {
private String userName;
private String id;
private String password;
}
idx 값은 비즈니스 로직 상에서는 관심이 없는 값이기 때문에 굳이 모델에 포함시키지 않았다.
UserMapper
이제 우리는 DAO가 사용하는 Repository 계층의 모델과 Service 계층에서 사용하는 모델로 두 개의 모델을 사용한다.
각 계층에서 하위 계층으로 데이터를 전달하기 위해서는 적절한 모델 변환이 필요하기 때문에 Service 계층에서 사용할 User 변환 클래스를 다음과 같이 만들어 주었다.
기존의 Repository 계층에서 사용하던 User 클래스는 DBUser로 변경하였다.
public class ServiceUserMapper {
public static DBUser getDBUser(ServiceUser user){
DBUser dbUser = new DBUser(user.getUserName(),
user.getId(), user.getPassword());
return dbUser;
}
public static ServiceUser getServiceUser(DBUser dbUser){
ServiceUser user = new ServiceUser(
dbUser.getUserName(),
dbUser.getId(), dbUser.getPassword());
return user;
}
}
DAO 수정 사항
UserService의 코드를 작성하기 위해 Repository 계층에서 다음과 같은 수정사항들이 있었다.
- find()
idx 값에 해당하는 유저가 존재하지 않을 경우를 위해 NoSuchUserException을 만들어 find() 메서드에 사용하였다.
//NoSuchUserException
package zangsu.selfmadeBlog.user.exception;
public class NoSuchUserException
extends Exception{
public NoSuchUserException(String s) {
super(s);
}
}
//UserDAO
package zangsu.selfmadeBlog.user.repository;
public DBUser find(long idx) throws NoSuchUserException {
DBUser findUser = em.find(DBUser.class, idx);
if(findUser == null)
throw new NoSuchUserException(idx +
" 유저가 존재하지 않습니다.");
return findUser;
}
이어서 UserDAO.find() 메서드를 사용하는 동 클래스의 delete() 메서드에도 throws NoSuchUserException을 명시해 주었다.
- modify()
상위 계층이 사용하는 데이터는 영속성 컨텍스트가 관리하는 데이터가 아닌, 상위 계층의 모델이다.
그렇기 때문에 수정 메서드를 별도로 작성해 주어야 영속성 컨텍스트에서 변화를 감지하여 데이터를 수정해 줄 수 있다.
//CantModifyFieldException
package zangsu.selfmadeBlog.user.exception;
public class CantModifyFieldException
extends Exception{
public CantModifyFieldException(String s) {
super(s);
}
}
//UserService
public void modify(long idx, DBUser user)
throws NoSuchUserException, CantModifyFieldException{
DBUser originalUser = this.find(idx);
if(!originalUser.getId().equals(user.getId()))
throw new CantModifyFieldException(
"유저의 ID 값은 변경될 수 없습니다.");
originalUser.setUserName(user.getUserName());
originalUser.setPassword(user.getPassword());
}
유저의 ID는 고유한 값으로 사용하기 위해 변경되지 않도록 코드를 작성하였다.
- deleteTest()
테스트 코드 역시 바뀌는 부분이 생긴다.
@Test
@Transactional
public void deleteTeset() throws Exception{
//given
userDAO.delete(existingId);
//when
assertThatThrownBy(() -> userDAO.find(existingId))
.isInstanceOf(NoSuchUserException.class);
}
null을 검사해 주던 로직에서 Exception 발생 검증 로직으로 바뀌었다.
UserService 로직 구현
@Service
public class UserService {
@Autowired
UserDAO userDAO;
public Long saveUser(ServiceUser user){
long userIdx =
userDAO.save(ServiceUserMapper.getDBUser(user));
return userIdx;
}
public ServiceUser findUser(long idx)
throws NoSuchUserIdxException{
DBUser dbUser = userDAO.find(idx);
return ServiceUserMapper.getServiceUser(dbUser);
}
public void modify(long idx, ServiceUser user){
userDAO.modify(idx,
ServiceUserMapper.getDBUser(user));
}
public void delete(long idx){
userDAO.delete(idx);
}
}
위 코드는 UserService의 기능을 간단하게 구현한 코드의 전문이다.
각 메서드는 사이드 이펙트를 최소화 하기 위해 CQS를 지키는 설계를 해 보았다.
CQS
CQS (Command Query Separation) 는 커맨드와 쿼리를 분리한다는 뜻인데, 이 때 커맨드는 내부의 상태를 변경시키는 메서드, 쿼리는 상태 (데이터)를 반환하는 메서드라고 한다.
즉, CQS를 지키기 위해선,
1. 데이터의 변경이 일어나는 메서드는 아무런 값도 반환하지 않아야 하며
2. 데이터를 반환하는 메서드에서는 아무런 내부 변경이 일어나지 않아야 한다.
다만, 이번 설계에서 저장 기능의 경우 idx 값이 새로 생성되기 떄문에 해당 값을 반환해 주어 활용도를 높이고자 하였다.
아래는 참고한 자료들이다.
https://www.inflearn.com/questions/27795/cqrs
https://velog.io/@yena1025/CQS-Command-Query-Separation
코드 테스트
@ExtendWith(SpringExtension.class)
@SpringBootTest
class UserServiceTest {
@Autowired
UserService userService;
ServiceUser existingUser =
new ServiceUser("eUserName", "eUserID", "eUserPW");
long existUserIdx;
@BeforeEach
public void init(){
existUserIdx = userService.saveUser(existingUser);
}
@Test
@Transactional
public void findTest() throws Exception{
//given
//when
ServiceUser findUser =
userService.findUser(existUserIdx);
//then
isSameUser(existingUser, findUser);
}
@Test
@Transactional
public void cantFindTest() throws Exception{
//given
//when
//then
assertThatThrownBy(() ->
userService.findUser(existUserIdx + 1))
.isInstanceOf(NoSuchUserException.class);
}
@Test
@Transactional
public void saveTest() throws Exception{
//given
ServiceUser user =
new ServiceUser("userName", "userID", "userPW");
Long savedIdx = userService.saveUser(user);
//when
ServiceUser findUser = userService.findUser(savedIdx);
//then
isSameUser(user, findUser);
}
@Test
@Transactional
public void modifyTest() throws Exception{
//given
existingUser.setUserName("modifyUserName");
existingUser.setPassword("newPassword");
//when
userService.modify(existUserIdx, existingUser);
ServiceUser findUser =
userService.findUser(existUserIdx);
//then
isSameUser(existingUser, findUser);
}
@Test
@Transactional
public void cantModifyIdTest() throws Exception{
//given
existingUser.setId("newId");
//when
assertThatThrownBy(() -> userService.modify(existUserIdx, existingUser))
.isInstanceOf(CantModifyFieldException.class);
//then
}
@Test
@Transactional
public void deleteTest() throws Exception{
//given
userService.delete(existUserIdx);
//when
assertThatThrownBy(() ->
userService.findUser(existUserIdx))
.isInstanceOf(NoSuchUserException.class);
//then
}
private void isSameUser(ServiceUser u1, ServiceUser u2) {
assertThat(u1.getId())
.isEqualTo(u2.getId());
assertThat(u1.getUserName())
.isEqualTo(u2.getUserName());
assertThat(u1.getPassword())
.isEqualTo(u2.getPassword());
}
}
위는 테스트 코드의 전문이다.
기능 고도화
이제 앞서 예상헀던 예외 상황들에 대한 처리를 해 주고, 문제가 없음을 테스트로 확인하자.
유저 생성 - 유일한 ID
우리는 유저의 ID를 고유한 값으로 만들어야 한다.
Model 수정
// DBUser
//...
@Column(name = "ID", unique = true)
private String id;
//...
@Column에 속성으로 unuque=true를 넣어 주었다.
UserDAO 수정
영속성 컨텍스트는 Unique 제약조건을 직접 확인하지 않고, DB에 해당 역할을 위임한다고 한다. (ChatGPT가 그랬음, 영한님한테 물어봤음)
그래서 각 데이터 저장마다 바로 DB에 데이터를 저장하기 위해 flush() 메서드를 함께 사용하였다.
public long save(DBUser user)
throws DataIntegrityViolationException {
em.persist(user);
//새로 추가된 em.flush()
em.flush();
return user.getIdx();
}
테스트로 확인
@Test
@Transactional
public void sameIdSaveTest(){
//given
DBUser user1 = new DBUser("User1", "sameId", "PW1");
DBUser user2 = new DBUser("User2", "sameId", "PW2");
//when
userDAO.save(user1);
assertThatThrownBy(() -> userDAO.save(user2))
.isInstanceOf(DataIntegrityViolationException.class);
//that
}
JPA는 중복되는 unique 값에 대해 DataIntegrityViolationException을 발생시킨다.
테스트 성공!
UserService 수정
//DuplicatedUserIdException
public class DuplicatedUserIdException extends Exception{
public DuplicatedUserIdException(Throwable cause) {
super(cause);
}
}
//UserService
public Long saveUser(ServiceUser user)
throws DuplicatedUserIdException {
long userIdx;
try{
userIdx =
userDAO.save(ServiceUserMapper.getDBUser(user));
} catch (DataIntegrityViolationException e){
throw new DuplicatedUserIdException(e);
}
return userIdx;
}
UserService에서는 중복 상황 예외에 대해 체크 예외를 새로 생성하여 상위 계층에서 예외 처리를 강제하였다.