Posts 학원 #81일차: 비즈니스 로직 분리: 서비스 객체 도입
Post
Cancel

학원 #81일차: 비즈니스 로직 분리: 서비스 객체 도입

image

논리적으로 어떤 객체에게 insert라는 일을 시키고, delete이라는 일을 시킬 지를 정하는 것을 논리적으로 전개를 해야 한다. 이것을 묶어서 로직이라고 한다. 업무에 관련된 논리적인 지시를 Business Logic이라고 한다. 화면에 관련된 논리적인 지시는 Presentation Logic이라고 한다. DB에 어떻게 데이터를 넣고 뺄 것인지는 Persistance Logic이라고 한다.

image

현재 구조에서 문제점은 한 객체가 너무 많은 일을 한다는 것이다. 예를 들어 ProjectDeleteCommand 같은 경우 사용자 입력과 화면 출력(UI)과 프로젝트 삭제 업무와 트랜잭션 처리(비즈니스 로직)를 동시에 수행한다.

image

이를 분리시켜야 한다. 어떻게 분리시켜야 할까? ProjectService 객체를 생성하여 이 객체에서 비즈니스 로직(프로젝트 업무 수행과 트랜잭션 처리)을 수행하도록 만들고, Command에서는 UI(사용자 입력, 화면 출력 처리)를 하도록 기능을 분리시키는 것이 좋다. 분리를 하는 것의 장점은 각각의 코드를 다른 프로젝트에서 재사용할 수 있고, 다른 것으로 쉽게 교체할 수 있다는 점이다. 단점은 클래스가 늘어나는 것이다.

image

이번 수업 때는 커맨드 객체에서 비즈니스 로직을 분리하여 서비스 객체에 옮기는 작업을 할 것이다.

image

인터페이스가 없다면 File 입출력을 담당하는 ProjectDaoinsert() 메서드를 호출하여 사용하기 위해 생성자에 ProjectDao 객체를 받아야 한다. 이때 데이터를 파일이 아니라 DBMS에 저장한다고 하자. 그러면 특정 DBMS와 소통하는 ProjectDao을 사용하기 위해 해당 DBMS에 해당하는 DAO를 생성자로 받아야 한다. 이처럼 기존 코드를 계속 수정해야 하는 문제점이 있다. 즉, 교체가 용이하지 않다.

image

특정 계층에서 다른 객체를 사용할 때 특정 객체에 종속이 되지 않도록 하기 위해서 인터페이스로 소통을 한다. 이제 ProjectAddCommand는 특정 객체에 종속되지 않고 그냥 이 객체들의 인터페이스를 사용하면 된다.

실무에서 프로젝트 삭제를 어떻게 구현할까? 실제로는 삭제할 때 데이터를 삭제하는 것이 아니라, 프로젝트 테이블에 활성 비활성 상태를 기록하는 컬럼을 추가한다. 즉, 프로젝트를 삭제해도 비활성 상태로 변경될 뿐이다.

image

실습

1단계: 프로젝트 삭제 Command 객체에서 비즈니스 로직을 분리한다.

커맨드 객체에서 비즈니스 로직을 분리하여 서비스 객체에 옮긴다.

ProjectService 인터페이스 생성

  • 서비스 객체의 메서드 명은 보통 업무 관련 용어를 사용한다.
  • DAO 객체의 메서드 명은 데이터 관련 용어를 사용한다.
1
2
3
public interface ProjectService {
  public int delete() throws Exception;
}

보통 구현체는 인터페이스 이름 앞에 접두사를 붙여 이름을 붙인다. 기본으로 사용하는 클래스는 DefaultProjectService라고 이름붙인다.

relations-command-service-dao

비즈니스 로직을 처리하기 위해서 하나의 커맨드가 여러 개의 서비스를 사용할 수 있고, 하나의 서비스를 여러 개의 DAO를 사용할 수 있다. 그리고 하나의 DAO는 여러 테이블의 owner가 될 수도 있고, viewer가 될 수도 있다. 하나의 커맨드가 하나의 서비스를 사용하거나, 하나의 서비스가 하나의 DAO를 쓸 필요는 없다. 초심자들이 가장 많이 하는 실수가 꼭 한 커맨드에서 한 서비스를 써야 하거나 한 서비스가 하나의 DAO를 써야 하는 거라고 잘못 아는 것이다.

한편, 같은 레벨의 클래스를 참조하면 안 된다. 그러면 같은 레벨의 객체끼리 종속 관계가 생겨서 유지보수가 어렵다.

DefaultProjectService

ProjectDeleteCommand에서 비즈니스 로직을 처리하는 코드를 가져온다. 여기서는 UI 관련 코드가 하나도 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class DefaultProjectService implements ProjectService{
  TaskDao taskDao;
  ProjectDao projectDao;
  // 트랜잭션을 다루기 위해 사용할 객체
  SqlSessionFactoryProxy factoryProxy;

  public DefaultProjectService(TaskDao taskDao, ProjectDao projectDao, SqlSessionFactoryProxy factoryProxy) {
    this.taskDao = taskDao;
    this.projectDao = projectDao;
    this.factoryProxy = factoryProxy;
  }

  @Override
  public int delete(int no) throws Exception {
    try {
      factoryProxy.startTransaction();
      // 프로젝트에 소속된 모든 작업 삭제하기
      taskDao.deleteByProjectNo(no);


      int count  = projectDao.delete(no);
      factoryProxy.commit();
      return count;

    } catch (Exception e) {
      factoryProxy.rollback();
      throw e; // 서비스 객체에서 발생한 예외는 호출자에게 전달한다.
    } finally {
      factoryProxy.endTransaction();
    }
  }
}

ProjectDeleteCommand 클래스 변경

이제 이때까지 Command에서 생성자로 받았던 ProjectDao, TaskDao, SqlSessionFactoryProxy는 필요가 없어졌다. 서비스 객체에서 이 객체를 사용해서 모든 비즈니스 로직을 처리해주기 때문이다. 단 예외가 떴을 때 사용자에게 출력하는 일은 프레젠테이션 레이어인 Command에서 하도록 해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class ProjectDeleteCommand implements Command {
  ProjectService projectService;

  public ProjectDeleteCommand(ProjectService projectService) {
    this.projectService = projectService;
  }

  @Override
  public void execute(Map<String,Object> context) {
    System.out.println("[프로젝트 삭제]");
    int no = Prompt.inputInt("번호? ");

    String response = Prompt.inputString("정말 삭제하시겠습니까?(y/N) ");
    if (!response.equalsIgnoreCase("y")) {
      System.out.println("프로젝트 삭제를 취소하였습니다.");
      return;
    }
    
    try {
      if (projectService.delete(no) == 0) {
        System.out.println("해당 번호의 프로젝트가 존재하지 않습니다.");
        return;
      }
      System.out.println("프로젝트를 삭제하였습니다.");
    } catch (Exception e) {
      System.out.println("프로젝트 삭제 중 오류 발생!");
      e.printStackTrace();
    }
  }

서비스 객체에서 발생한 예외는 호출자(Command)에게 전달한다. 그럼 호출자는 이를 catch해서 오류 발생 이유를 e.printStackTrace() 메서드를 통해 출력한다.

1
2
3
ProjectService projectService = new DefaultProjectService(taskDao, projectDao, sqlSessionFactory);

commandMap.put("/project/delete", new ProjectDeleteCommand(projectService));

2단계: 프로젝트 삭제 DAO 객체에서 업무 코드를 분리한다.

ProjectService-move-business-logic

프로젝트를 지울 지 말지 아니면 그냥 비활성화로 처리할 지는 비즈니스 로직이 결정한다. 즉 어느 테이블의 데이터를 삭제할 것인지에 대한 결정은 비즈니스 로직에 따른다. 기존 코드에서는 ProjectDeleteCommand 뿐만 아니라 ProjectDaoImpl 또한 비즈니스 로직을 가지고 있다. 이를 추출하여 DefaultProjectService로 옮길 것이다.

ProjectDaoImpl 클래스 변경

기존 delete()

1
2
3
4
5
6
7
8
9
public int delete(int no) throws Exception {
  try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
    // 프로젝트에 소속된 모든 멤버를 삭제한다.
    sqlSession.delete("ProjectDao.deleteMembers", no);
    // => 프로젝트를 삭제한다.
    int count = sqlSession.delete("ProjectDao.delete", no);
    return count;
  }
}
  • delete() 메서드에서 비즈니스 로직을 추출하여 DefaultProjectService로 옮긴다.
  • 프로젝트 멤버를 삭제하는 코드를 별도의 메서드deleteMembers()로 옮긴다.

우선 인터페이스에 ` deleteMembers(int projectNo)`를 추가한다.

1
int deleteMembers(int projectNo) throws Exception;

그리고 구현체에 deleteMembers() 메서드를 추가한다.

1
2
3
4
5
6
@Override
public int deleteMembers(int projectNo) throws Exception {
  try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
    return sqlSession.delete("ProjectDao.deleteMembers", projectNo);
  }
}

이제 delete 메서드에서 멤버를 삭제하는 코드를 제거한다.

1
2
3
4
5
6
  @Override
  public int delete(int no) throws Exception {
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      return sqlSession.delete("ProjectDao.delete", no);
    }
  }

DAO는 그 전까지 두 개의 일을 했는데, 이제 단순하게 하나의 일만 한다.

DefaultProjectService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public int delete(int no) throws Exception {
  try {
    factoryProxy.startTransaction();
    taskDao.deleteByProjectNo(no);
    projectDao.deleteMembers(no);
    int count  = projectDao.delete(no);
    factoryProxy.commit();
    return count;

  } catch (Exception e) {
    factoryProxy.rollback();
    throw e; // 서비스 객체에서 발생한 예외는 호출자에게 전달한다.
  } finally {
    factoryProxy.endTransaction();
  }
}

이제 각각의 Service, Command, DAO는 하나의 일만 하기 때문에 높은 응집력을 갖는다. 어떤 작업을 할 것인지는 Service의 영역이다.

3단계: 프로젝트 등록 커맨드에서 비즈니스 로직을 분리한다.

ProjectDao 인터페이스 변경

1
int insertMembers(Project project) throws Exception;

ProjectDaoImpl 클래스 변경

1
2
3
4
5
6
@Override
public int insertMembers(Project project) throws Exception {
  try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
    return sqlSession.insert("ProjectDao.insertMembers", project);
  }
}

DefaultProjectService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public int add(Project project) throws Exception {
  try {
    factoryProxy.startTransaction();
    projectDao.insert(project);      
    int count  = projectDao.insertMembers(project);
    factoryProxy.commit();
    return count;
  } catch (Exception e) {
    factoryProxy.rollback();
    throw e; // 서비스 객체에서 발생한 예외는 호출자에게 전달한다.
  } finally {
    factoryProxy.endTransaction();
  }
}

한편 ProjectAddCommand에서 memberDao.findByName()코드도 있었다. 그러나 Command 객체에서 dao 객체를 직접적으로 사용하면 안된다. 따라서 MemberService를 만들어야 한다.

MemberService, DefaultMemberService: list(String name) 메서드 추가

1
2
3
4
@Override
public List<Member> list(String name) throws Exception {
return memberDao.findByName(name);
}

MemberDao, MemberDaoImpl: findByName()의 리턴 값을 List 객체로 변경한다. 이름은 중복될 수 있기 때문이다.

1
2
3
4
5
6
7
@Override
public List<Member> findByName(String name) throws Exception {
  try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
    List<Member> members = sqlSession.selectList("MemberDao.findByName", name);
    return members;
  }
}

ProjectAddCommand

커맨드가 두 개의 서비스를 사용하도록 필드와 생성자를 변경한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class ProjectAddCommand implements Command {

  ProjectService projectService;
  MemberService memberService;

  public ProjectAddCommand(ProjectService projectService, MemberService memberService) {
    this.projectService = projectService;
    this.memberService = memberService;
  }

  @Override
  public void execute(Map<String,Object> context) {
    System.out.println("[프로젝트 등록]");

    try {
      //사용자 입력 코드
      //..
      
      // 프로젝트에 참여할 회원 정보를 담는다.
      List<Member> members = new ArrayList<>();
      while (true) {
        String name = Prompt.inputString("팀원?(완료: 빈 문자열) ");
        if (name.length() == 0) {
          break;
        } else {
          List<Member> list = memberService.list(name);
          if (list.size() == 0) {
            System.out.println("등록된 회원이 아닙니다.");
            continue;
          }
          // 이것은 나중에 웹으로 바뀔 때 기능을 변경할 것
          // 현재는 그냥 리스트의 맨 첫번째 요소를 팀원으로 등록한다.
          members.add(list.get(0));
        }
      }
      // 사용자로부터 입력 받은 멤버 정보를 프로젝트에 저장한다.
      project.setMembers(members);
      projectService.add(project);
      System.out.println("프로젝트가 등록되었습니다!");
    } catch (Exception e) {
      System.out.println("프로젝트 등록 중 오류 발생!");
      e.printStackTrace();
    }
  }
}

4단계: 프로젝트 목록 조회 커맨드에서 비즈니스 로직을 분리한다.

ProjectListCommand

이전

1
List<Project> list = projectDao.findAll();

이후

1
List<Project> list = projectService.list();

DefaultProjectService

이 메서드를 보면 서비스 객체가 할 일이 없다. 그냥 DAO 객체의 메서드를 호출한 다음에 그 리턴 값을 그대로 리턴해주는 일을 한다. 그럼 왜 이런 메서드를 만들어야 할까? 프로그래밍의 일관성을 위해서다. 커맨드 객체가 상황에 따라 Service 객체를 쓰거나 DAO 객체를 써야 한다면, 프로그래밍의 일관성의 없어서 유지보수하기가 어렵다. 그래서 이런 메서드를 만드는 것이다. 서비스 객체의 메서드에서 특별히 할 일이 없다 하더라도 커맨드 객체가 일관성 있게 작업을 수행할 수 있도록 중간에서 DAO 객체의 메서드를 호출해주는 것이다.

1
2
3
  public List<Project> list() throws Exception {
    return projectDao.findAll();
  }

5단계: 프로젝트 검색 커맨드에서 비즈니스 로직을 분리한다.

메서드를 쪼개고 합치는 것을 무서워하지 말자! 상황에 따라 쪼갰다가 합칠 수도 있다. 유지보수에 좋다고 판단되는 것을 하자.

ProjectService, DefaultProjectService: list() 메서드에 검색어를 받는 파라미터 추가

1
2
3
public List<Project> list(String keyword) throws Exception {
  return projectDao.findAll(keyword);
}

ProjectDao, ProjectDaoImpl:

  • findAll() 메서드에 검색어를 받는 파라미터 추가
  • findByKeyword() 메서드 삭제: findAll() 메서드와 합친다.
1
2
3
4
5
6
@Override
public List<Project> findAll(String keyword) throws Exception {
  try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
    return sqlSession.selectList("ProjectDao.findAll", keyword);
  }
}

ProjectMapper.xml

  • findAll 변경
  • findByKeyword을 삭제: findAll에 합친다.
1
2
3
4
5
6
7
8
9
10
	<select id="findAll" resultMap="ProjectMap" parameterType="string">
		<include refid="sql1"/>
		<if test="keyword != null">
		where
			p.title like concat('%', #{keyword}, '%')
			or m.name like concat('%', #{keyword}, '%')
			or m2.name like concat('%', #{keyword}, '%')
		</if>
		order by p.no desc
	</select>

ProjectListCommand 클래스 변경

list(String) 메서드 호출 코드 변경: 검색을 하지 않는다면 파라미터로 null을 넘긴다.

1
List<Project> list = projectService.list(null);

ProjectSearchCommand 클래스 변경

1
2
String keyword =Prompt.inputString("검색어?");
List<Project> list = projectService.list(keyword);

AppInitListener

1
commandMap.put("/project/search", new ProjectSearchCommand(projectService));

6단계: 프로젝트 상세 검색 커맨드에서 비즈니스 로직을 분리한다.

ProjectService, DefaultProjectService

  • list(Map<String,Object> keywords) 메서드 추가(오버로딩): 검색 항목과 검색어를 입력 받는 파라미터를 추가
1
2
3
  public List<Project> list(Map<String, Object> keywords) throws Exception {
    return projectDao.findByDetailKeyword(keywords);
  }

낭비적이라고 보여도 항상 커맨드가 서비스를 경유하도록 프로그래밍의 일관성을 유지한다.

오버로딩(Overloading): 메서드의 파라미터 개수나 파라미터 타입이 다르더라도, 같은 기능을 수행한다면 같은 이름을 부여함으로써 프로그램의 일관성을 도와주는 문법

오버라이딩(Overriding): 상속받은 메서드가 서브클래스에 맞지 않을 경우 서브클래스의 역할에 맞춰서 재정의하는 문법

그런데 위와 같이 오버로딩할 경우 list(null)을 할 경우 list(Map)인지, list(String)인지 모호하기 때문에 컴파일러가 컴파일을 안 해준다. 따라서 다음과 같이 null에 형변환을 해줌으로써 컴파일을 가능하게 만든다.

ProjectListCommand

1
List<Project> list = projectService.list((String)null);

ProjectDetailSearchCommand

1
2
3
4
5
6
7
8
9
10
11
12
public class ProjectDetailSearchCommand implements Command {
  ProjectService projectService;

  public ProjectDetailSearchCommand(ProjectService projectService) {
    this.projectService =projectService;
  }
  
  @Override
  public void execute(Map<String,Object> context) {
				//..
      List<Project> list = projectService.list(keywords);
      //..

AppInitListener

1
commandMap.put("/project/detailSearch", new ProjectDetailSearchCommand(projectService));

7단계: 프로젝트 상세 조회 커맨드에서 비즈니스 로직을 분리한다.

ProjectService, DefaultProjectService: get(int no) 메서드 추가

1
2
3
public Project get(int no) throws Exception {
  return projectDao.findByNo(no);
}

TaskService, DefaultTaskService: listByProject(int no)

1
2
3
4
5
public List<Task> listByProject(int projectNo) throws Exception {
  HashMap<String,Object> map = new HashMap<>();
  map.put("projectNo", projectNo);
  return taskDao.findAll(map);
}

ProjectDetailCommand: ProjectService.get(int) 메서드를 사용하여 프로젝트를 조회하고, TaskService.listByProject(int) 메서드를 사용하여 작업 목록을 조회한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ProjectDetailCommand implements Command {
  ProjectService projectService;
  TaskService taskService;

  public ProjectDetailCommand(ProjectService projectService, TaskService taskService) {
    this.projectService = projectService;
    this.taskService = taskService;
  }

  @Override
  public void execute(Map<String,Object> context) {
    System.out.println("[프로젝트 상세보기]");
    int no = Prompt.inputInt("번호? ");

    try {
      Project project = projectService.get(no);
      //..
      Map<String, Object> map = new HashMap<>();
      map.put("projectNo", project.getNo());
      List<Task> tasks = taskService.listByProject(no);
      //..

8단계: 프로젝트 변경 커맨드에서 비즈니스 로직을 분리한다.

ProjectService, DefaultProjectService: update(project) 추가 및 구현

1
2
3
public int update(Project project) throws Exception {
  return projectDao.update(project);
}

ProjectMapper.xml update SQL 문을 동적 SQL로 변경하였다.

1
2
3
4
5
6
7
8
9
10
<update id="update" parameterType="project">
  update pms_project 
  <set>
    <if test="title != null">title = #{title}, </if>
    <if test="content != null">content = #{content}, </if>
    <if test="startDate != null">sdt = #{startDate}, </if>
    <if test="endDate != null">edt = #{endDate} </if>
  </set>
  where no = #{no}
</update>

ProjectUpdateCommand

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
String value = Prompt.inputString(String.format("프로젝트명(%s)? ", project.getTitle()));
if (value.length() > 0) {
  project.setTitle(value);
}

value = Prompt.inputString(String.format("내용(%s)? ", project.getContent()));
if (value.length() > 0) {
  project.setContent(value);
}

value = Prompt.inputString(String.format("시작일(%s)? ", project.getStartDate()));
if (value.length() > 0) {
  project.setStartDate(Date.valueOf(value));
}

value = Prompt.inputString(String.format("종료일(%s)? ", project.getEndDate()));
if (value.length() > 0) {
  project.setEndDate(Date.valueOf(value));
}
This post is licensed under CC BY 4.0 by the author.

코어 자바스크립트 #5 자료구조와 자료형: 위크맵과 위크셋

코어 자바스크립트 #5 자료구조와 자료형: Object.keys, values, entries

Loading comments from Disqus ...