2023-09-04 추가: TO-BE v3까지 오면서 더 이상 “단순 아키텍처"라고 부르기 힘든 수준이 된거 같다…
서론
회사의 새로운 솔루션에 사용 될 프로젝트의 스캐폴드 작성을 해달라는 요청이 있었는데,
이 기회에 개선된 내부 아키텍처를 제안드릴 겸, Spring MVC와 Mybatis 기반의 단순 아키텍처를 고안해보았다.
사내의 개발자분들이 일을 하는 방식, 배경지식을 고려하여 작성하였으나 비슷한 고민을 하는 다른 분들께 도움이 될 수도 있을 것 같아 글로 남겨본다.
이해를 돕기위해 간단한 생성, 조회 기능을 하는 예제를 같이 같이 작성해보았다.
여기에서 사용하는 모든 예제 코드는 Github Repository에서 확인 가능하다.
AS-IS
구성도

사내에서 지금까지 진행된 프로젝트는 전형적인 Controller-Service-Mapper 구조를 가지고 있고,
계층별 통신에 사용되는 데이터는 Map<String, Object>를 이용하고 있었다.
예제 코드
회사 코드를 사용 할 수는 없어서, 회사에서 일반적으로 사용하는 패턴을 따라서 작성해 보았다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| @RestController
@RequiredArgsConstructor
public class BookController {
private final BookService bookService;
/**
* EXAM001
*/
@PostMapping("/createBook")
public Map<String, Object> createBook(@RequestBody Map<String, Object> data) {
return bookService.create(data);
}
...
}
|
Controller는 매핑된 경로로 들어온 요청에 맞는 service의 메서드를 호출하는 역할 만 한다.
뭐든 담을 수 있는 Map을 요청, 응답의 데이터를 담는 그릇으로 사용하다보니 자연스럽게 Controller의 역할이 줄어든 것으로 보인다.
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
| @Service
@Transactional
@RequiredArgsConstructor
public class BookService {
private final BookMapper mapper;
public Map<String, Object> create(Map<String, Object> data) {
// 필수 값 확인
if (data.get("title") == null ||
data.get("isbn") == null ||
data.get("publishedDate") == null
) {
Map<String, Object> response = new HashMap<>();
response.put("resultCode", "0001");
response.put("resultMessage", "필수 값이 누락되었습니다.");
return response;
}
// ISBN 중복 확인
Map<String, Object> book = mapper.readByIsbn(data.get("isbn").toString());
if (book != null) {
Map<String, Object> response = new HashMap<>();
response.put("resultCode", "0002");
response.put("resultMessage", "동일한 ISBN으로 등록 된 책이 있습니다.");
return response;
}
// 책 생성
mapper.create(data);
// 응답 결과 적용
data.put("bookId", Long.parseLong(data.get("bookId").toString()));
data.put("resultCode", "0000");
data.put("resultMessage", "정상 처리 되었습니다.");
return data;
}
...
}
|
서비스의 메서드에 모든 로직 (유효성 검사, 비즈니스 로직, 응답 데이터 생성 등)이 존재한다.
각 세부 로직은 로직이 시작되는 부분에 주석으로 기능을 간단히 나타낸다.
파라미터와 반환값 모두 Map을 사용하고, 로직도 한 군데서 처리되다보니 Map의 재사용이 자연스러운 환경이었던걸로 보인다.
무분별한 Map 사용은 깨진 유리창 이론의 깨진 유리창과 같은 역할을 한다.
구현을 빠르게 할 수 있는 만큼, 빠르게 레거시화 된다는 것이 내 생각이다.
TO-BE v1
구성도

고안해본 구조의 핵심은 public method에서의 Map사용을 지양하는 것과 endpoint 별로 package를 나누는 것이다.
인터페이스 정의서를 작성 후 구현에 들어가는데, 인터페이스 정의서는 엑셀로 작성 및 관리되고, 1개의 endpoint 당 1개의 시트를 사용하여 작성된다.
각 endpoint는 고유한 ID (ex. EXAM001)을 가지고 있다.
그래서 각 endpoint의 ID를 기준으로 Controller를 나누고, Controller의 중첩 클래스로서 요청, 응답의 형태를 정의하여 사용하는 것으로 인터페이스 정의서와 매칭하여 관리하기 용이하도록 구성하였다.
예제 코드
Mapper와 Entity는 Mybatis Generator로 생성된 것을 사용하였다.
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
46
47
48
49
50
51
52
53
| @RestController
@RequiredArgsConstructor
public class EXAM001Controller {
private final EXAM001Service service;
@PostMapping("/createBook")
public Response createBook(@RequestBody Request request) {
request.validate();
DataManager dataManager = new DataManager(request);
service.createBook(dataManager);
return dataManager.buildResponse();
}
@Data
static class Request {
private String title;
private String isbn;
private LocalDate publishedDate;
public void validate() {
if (title == null || isbn == null || publishedDate == null) {
throw new RequiredValueException();
}
}
}
@Getter
static class Response extends BaseApiResponse {
private Long bookId;
private String title;
private String isbn;
private LocalDate publishedDate;
public static Response of(
Long bookId,
String title,
String isbn,
LocalDate publishedDate
) {
Response response = new Response();
response.bookId = bookId;
response.title = title;
response.isbn = isbn;
response.publishedDate = publishedDate;
response.markOk();
return response;
}
}
}
|
Controller에 endpoint 1개만 정의하였고, endpoint에서 사용되는 요청 데이터와 응답 데이터의 형식을 중첩클래스로 작성하였다.
덕분에 인터페이스 정의서와 코드를 모니터 양 옆에 띄어놓고 확인하기가 수월해졌다.
또한 요청 자체에 대한 유효성 검사는 요청 클래스에서 하도록 함으로서 응집도를 높였다.
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
| @Service
@Transactional
@RequiredArgsConstructor
class EXAM001Service {
private final BookMapper bookMapper;
public void createBook(DataManager dataManager) {
checkIsbnDuplication(dataManager);
Book book = dataManager.bookForInsert();
bookMapper.insert(book);
dataManager.setBook(book);
}
private void checkIsbnDuplication(DataManager dataManager) {
BookExample example = new BookExample();
example.createCriteria().andIsbnEqualTo(dataManager.getIsbn());
List<Book> books = bookMapper.selectByExample(example);
if (!books.isEmpty()) {
throw new Exceptions.ISBNDuplicationException();
}
}
}
|
Service는 비즈니스 로직에 집중 할 수 있게 구성하였다.
ISBN 중복 검사부분은 Mybatis Generator로 생성한 Mapper를 사용해서 가독성은 줄어들었다.
하지만 형 안전성을 얻었고, private 메서드를 이용하면 충분히 유지보수성을 유지 할 수 있을 것이라 생각한다.
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
| @RequiredArgsConstructor
class DataManager {
private final EXAM001Controller.Request request;
private Book book;
public String getIsbn() {
return request.getIsbn();
}
public Book bookForInsert() {
Book book = new Book();
book.setTitle(request.getTitle());
book.setIsbn(request.getIsbn());
book.setPublishedDate(request.getPublishedDate());
return book;
}
public void setBook(Book book) {
this.book = book;
}
public EXAM001Controller.Response buildResponse() {
return EXAM001Controller.Response.of(
book.getBookId(),
book.getTitle(),
book.getIsbn(),
book.getPublishedDate()
);
}
}
|
타입을 적극적으로 도입하다보면 타입 전환 및 생성 코드가 필요하게 되는데,
이러한 코드는 Controller와 Service 어디에도 어울리지 않는다.
그래서 이러한 일을 전담하는 DataManager라는 클래스를 도입하였다.
개인적으로는 Service계층을 위한 전용 타입이 있어야 하고, 의존성 방향에 맞춰서 의존하는 쪽 계층에서 타입 전환 및 생성 책임을 가져야한다고 생각하지만,
인터페이스와 DB설계가 먼저 다 완료되고, 마지막으로 서비스 계층의 설계 및 구현이 되는 조직의 특성상 이러한 형태가 좀 더 실리적이라고 생각한다.
TO-BE v2
회사에서 v1을 도입하여 작업한 결과, DataManager가 가독성을 저하시키는 요인으로 작용 할 여지가 크게 보임에 따라 아키텍처를 수정하였다.
구성도

DataManager를 제거하고, Service계층을 위한 전용 타입인 Command와 Result를 추가하였다.
예제 코드
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
46
47
48
49
50
| @RestController
@RequiredArgsConstructor
public class EXAM001Controller {
private final EXAM001Service service;
@PostMapping("/createBook")
public Response createBook(@RequestBody Request request) {
request.validate();
EXAM001Service.Result result = service.createBook(request.toCommand());
return Response.from(result);
}
@Data
static class Request {
private String title;
private String isbn;
private LocalDate publishedDate;
public void validate() {
if (title == null || isbn == null || publishedDate == null) {
throw new RequiredValueException();
}
}
public EXAM001Service.Command toCommand() {
return EXAM001Service.Command.of(title, isbn, publishedDate);
}
}
@Getter
static class Response extends BaseApiResponse {
private Long bookId;
private String title;
private String isbn;
private LocalDate publishedDate;
public static Response from(EXAM001Service.Result result) {
Response response = new Response();
response.bookId = result.getBookId();
response.title = result.getTitle();
response.isbn = result.getIsbn();
response.publishedDate = result.getPublishedDate();
response.markOk();
return response;
}
}
}
|
Request는 Command 객체를 생성하는 책임이 추가되었고, Response는 Result로 부터 스스로를 생성 할 책임이 추가되었다.
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
| @Service
@Transactional
@RequiredArgsConstructor
class EXAM001Service {
private final BookMapper bookMapper;
public Result createBook(Command command) {
checkIsbnDuplication(command);
Book book = command.bookForInsert();
bookMapper.insert(book);
return Result.from(book);
}
private void checkIsbnDuplication(Command command) {
BookExample example = new BookExample();
example.createCriteria().andIsbnEqualTo(command.getIsbn());
List<Book> books = bookMapper.selectByExample(example);
if (!books.isEmpty()) {
throw new Exceptions.ISBNDuplicationException();
}
}
@Getter
static class Command {
private String title;
private String isbn;
private LocalDate publishedDate;
public static Command of(
String title,
String isbn,
LocalDate publishedDate
) {
Command command = new Command();
command.title = title;
command.isbn = isbn;
command.publishedDate = publishedDate;
return command;
}
public Book bookForInsert() {
Book book = new Book();
book.setTitle(title);
book.setIsbn(isbn);
book.setPublishedDate(publishedDate);
return book;
}
}
@Getter
static class Result {
private Long bookId;
private String title;
private String isbn;
private LocalDate publishedDate;
public static Result from(Book book) {
Result result = new Result();
result.bookId = book.getBookId();
result.title = book.getTitle();
result.isbn = book.getIsbn();
result.publishedDate = book.getPublishedDate();
return result;
}
}
}
|
서비스 메서드의 파라미터는 Command, 반환 값은 Result로 변경되었다. 이로인해 서비스 메서드에 어떤 데이터가 필요하고, 실행 결과 어떤 데이터가 반환되는지 확인하기 용이해졌다.
Command는 Mapper에서 사용 할 객체를 생성하는 책임이 추가되었고, Result는 Entity로부터 스스로를 생성 할 책임이 추가되었다.
Controller의 Request와 Service의 Command, Controller의 Response와 Service의 Result가 중복으로 보일 수 있으나 내 생각에는 우발적 중복이라 생각한다. (해당 내용은 클린 아키텍처 “16장.독립성#중복” 부분을 참고)
TO-BE v3
회사에서 v2를 도입하여 작업해보니, 특정 도메인 로직이 여러 인터페이스의 서비스에 분산되서 나타나기 시작하였다. 도메인 로직의 유지보수성 향상을 목적으로 아키텍처를 수정하였다.
구성도

도메인 로직을 관리하기위한 Usecase가 추가 되었다. Usecase 용 Command와 Result 객체는 표현력이 더 필요 할 때에 한해서 사용하고, 보통은 Entity를 사용하도록 하였다.
예제 코드
Controller의 코드는 TO-BE v2와 동일하여 생략하였다.
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
| @Service
@Transactional
@RequiredArgsConstructor
class EXAM001Service {
private final BookUsecase bookUsecase;
public Result createBook(Command command) {
if (bookUsecase.findByIsbn(command.getIsbn()).isPresent()) {
throw new Exceptions.ISBNDuplicationException();
}
Book book = bookUsecase.createBook(
CreateBookCommand.of(
command.getTitle(),
command.getIsbn(),
command.getPublishedDate()
)
);
return Result.from(book);
}
@Getter
static class Command {
private String title;
private String isbn;
private LocalDate publishedDate;
public static Command of(
String title,
String isbn,
LocalDate publishedDate
) {
Command command = new Command();
command.title = title;
command.isbn = isbn;
command.publishedDate = publishedDate;
return command;
}
}
@Getter
static class Result {
private Long bookId;
private String title;
private String isbn;
private LocalDate publishedDate;
public static Result from(Book book) {
Result result = new Result();
result.bookId = book.getBookId();
result.title = book.getTitle();
result.isbn = book.getIsbn();
result.publishedDate = book.getPublishedDate();
return result;
}
}
}
|
기존의 BookMapper를 직접호출하던 것과 달리, BookUsecase에 위임하는 방식으로 변경하였다.
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
| @Service
@Transactional
@RequiredArgsConstructor
public class BookUsecase {
private final BookMapper bookMapper;
public Book createBook(CreateBookCommand command) {
Book book = new Book()
.withTitle(command.getTitle())
.withIsbn(command.getIsbn())
.withPublishedDate(command.getPublishedDate());
bookMapper.insert(book);
return book;
}
...
public Optional<Book> findByIsbn(String isbn) {
BookExample example = new BookExample();
example.createCriteria().andIsbnEqualTo(isbn);
List<Book> books = bookMapper.selectByExample(example);
if (books.isEmpty()) {
return Optional.empty();
} else {
return Optional.of(books.get(0));
}
}
}
|
Book 도메인에 대한 로직을 BookUsecase에 모음으로서 유지보수성이 향상되었다.
Book 객체를 생성 할 때의 .withXXX()
는 Mybatis Generator의 FluentBuilderMethodsPlugin 플러그인을 적용하여 추가된 구문이다.