[F-Lab 모각코 챌린지] 41일차 - 프로젝트 아키텍쳐
F-Lab 모각코 챌린지 41일차 - 프로젝트 아키텍쳐. 프로젝트 아키텍쳐를 고민한 결과로, 고민한 아키텍쳐가 적용된 api 한 본을 만들어보았습니다.
![[F-Lab 모각코 챌린지] 41일차 - 프로젝트 아키텍쳐](/content/images/size/w1200/2023/07/f_lab_mogacko-2.png)
프로젝트를 진행하며 아키텍쳐에 대한 고민을 했고, 오늘 그 고민의 결과를 코드로 작성 해 보고자 한다
각 패키지별 제약사항을 적어두었고 끝에 개인적인 생각을 포함하였다.
패키지 분류
아래의 패키지 분류는 이 내용에서 소개 할 코드들의 순서와 비슷하게 나열하였다.
- database: 데이터를 저장하기 위한 store 를 구현하는 패키지
- store: 데이터 저장하는 공간을 구현하는 패키지
- persistence: 데이터를 다루기 위한 기능을 명시하는 코드의 집합
- repository: 데이터를 다루기 위한 store 를 호출하는 코드 집합
- table: 테이블의 형태를 모아놓는 패키지
- usecase: 데이터를 다루는 형태를 명시하는 패키지
- business: 비즈니스 클래스
- domain: 도메인 클래스. 가장 핵심적인 비즈니스를 명시하는 곳이다.
- service: 서비스 클래스. 도메인의 Usecase 를 명시하는 곳이다.
- presentation: 사용자에게 보여주는 화면을 구현하는 패키지
- controller: api 를 명시하는 패키지
- dto: 사용자의 요청이나 응답을 명시하는 패키지
- request: 사용자의 요청을 명시
- response: 사용자의 응답을 명시
- application: 웹 애플리케이션을 위한 클래스가 위치하는 곳
- config: 설정 클래스들
- exception: 예외 클래스들
- util: 유틸성 클래스들
Code
코드는 데이터 저장 공간 부터 Controller 까지 가는 형태로 기록되며 데이터베이스는 메모리에 저장하는 형태이다
store 구현체부터 시작한다
InmemoryDataStore
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
@RequiredArgsConstructor
public class InmemoryDataStore implements DataStore {
private final Map<String, Map<Long, Object>> store;
private final Map<String, Long> sequenceStore;
@Override
public Long save(String tableName, Object value) {
Map<Long, Object> table = store.get(tableName);
if (table == null) {
store.put(tableName, new ConcurrentHashMap<>());
table = store.get(tableName);
}
Long currentSeq = sequenceStore.get(tableName);
if (currentSeq == null) {
sequenceStore.put(tableName, 1L);
currentSeq = sequenceStore.get(tableName);
}
table.put(currentSeq, value);
store.put(tableName, table);
sequenceStore.put(tableName, currentSeq+1);
return currentSeq;
}
@Override
public Long update(String tableName, Long key, Object value) {
Map<Long, Object> table = store.getOrDefault(tableName, new ConcurrentHashMap<>());
table.put(key, value);
return key;
}
@Override
public Object findById(String tableName, Long key) {
Map<Long, Object> table = store.get(tableName);
if (table == null) {
throw new RuntimeException("table is null");
}
return table.getOrDefault(key, null); // TODO Default 값은?
}
}
RDBMS 와 비슷하게 동작시키는것을 목표로 구현하였다.
각 테이블에 존재하는 데이터는 시퀀스 형태의 키와 함께 저장되며 성능은 크게 신경쓰지 않았다. 어차피 데이터를 저장하고 불러오는 역할만 수행하는 터라, 구현의 초점이 성능이 아니였다.
위 클래스에서는 DataStore 를 구현하는 모습을 볼 수 있는데, 이는 presentation.repository 와 소통하기 위해서이다.
DataStore
public interface DataStore {
Long save(String tableName, Object value);
Long update(String tableName, Long key, Object value);
Object findById(String tableName, Long id);
}
바로 다음 Repository 를 살펴보자
Repository
import com.picketing.www.databases.inmemory.DataStore;
import com.picketing.www.persistence.table.UserPersist;
import com.picketing.www.persistence.usecase.UserCreate;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
@Repository
@RequiredArgsConstructor
public class UserRepository {
private DataStore dataStore;
public Long save(UserCreate userCreate) {
return dataStore.save("user", UserPersist.mapping(userCreate));
}
}
저장을 위한 기능을 명시하고 있는 것을 볼 수 있다.
보면 UserPersist 라는 객체가 존재하는데, 이 객체는 User 데이터가 저장되는 테이블을 명시한다.
나중에 JPA 나 MyBatis 가 들어와도 문제가 없게 설계 하는 것을 목표로 잡았기 때문에 위와 같이 구현하였다.
사실 UserRepository 도 Interface 로 명시하고, 그 구현체로 두는 편이 DIP 를 충족하기 때문에 더 좋겠지만, 예시를 위한 코드라고 생각해주시길 바라고 있다.
table
Repository 에서 사용하는 테이블을 명시하는 dto 들이다
아래는 예시로 User 에 대한 테이블 명시를 위해 작성 한 코드이다
import com.picketing.www.persistence.usecase.UserCreate;
import java.time.LocalDateTime;
public record UserPersist(
String email,
String password,
String name,
String phoneNumber,
LocalDateTime createdAt,
LocalDateTime modifiedAt
) {
public static UserPersist mapping(UserCreate userCreate) {
return new UserPersist(
userCreate.email(),
userCreate.password(),
userCreate.name(),
userCreate.phoneNumber(),
userCreate.createdAt(),
userCreate.modifiedAt()
);
}
}
usecase
테이블 자체를 명시하는 것 과 usecase 를 명시하는 dto 가 따로 존재하는데, 이유는 테이블의 업데이트나, 저장을 위해 사용하는 각각의 dto 가 다를 수 있기 때문이다
import java.time.LocalDateTime;
public record UserCreate(
String email,
String password,
String name,
String phoneNumber,
LocalDateTime createdAt,
LocalDateTime modifiedAt
) { }
business
import com.picketing.www.application.exception.BadRequestException;
import com.picketing.www.persistence.usecase.UserCreate;
import com.picketing.www.presentation.dto.request.UserSignUpRequest;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
public class User {
private final String email;
private final String password;
private final String name;
private final String phoneNumber;
private final LocalDateTime createdAt;
private final LocalDateTime modifiedAt;
private static final String EMAIL_VALID_REGEX = "^[\\w\\.-]+@[a-zA-Z\\d-]+(\\.[a-zA-Z\\d-]+)*\\.[a-zA-Z]{2,}$";
private static final String PASSWORD_VALID_REGEX = "^(?=.*[!@#$%^&*()-=_+[\\]{},.<>/?;:|`~])(?=.*\\d)(?=.*[a-zA-Z]).{8,}$";
public User(UserSignUpRequest userSignUpRequest) {
if (!StringUtils.hasLength(userSignUpRequest.email())
&& !userSignUpRequest.email().matches(EMAIL_VALID_REGEX)) {
throw new BadRequestException("Email 형식이 맞지 않습니다.");
}
if (!StringUtils.hasLength(userSignUpRequest.password())
&& !userSignUpRequest.password().matches(PASSWORD_VALID_REGEX)) {
throw new BadRequestException("비밀번호 형식이 맞지 않습니다");
}
this.email = userSignUpRequest.email();
this.password = userSignUpRequest.password();
this.name = null;
this.phoneNumber = null;
this.createdAt = LocalDateTime.now();
this.modifiedAt = LocalDateTime.now();
}
public UserCreate persistence() {
return new UserCreate(
this.email,
this.password,
this.name,
this.phoneNumber,
this.createdAt,
this.modifiedAt
);
}
}
객체지향을 생각하며 만든 코드이다.
Lombok 등의 라이브러리를 의존하지 않는 것을 목표로 하지만 지금 보니 Spring framework 를 의존하였다.
해당 기능은 util 로 따로 작성하여 제거해야 한다
service
다음은 user 도메인의 usecase 를 명시하는 service 이다
service 는 각각의 use case 가 추가될 때 마다 추가되는데, 결과적으로 아래와 같이 구현된다.
@Service
@Transactional
@RequiredArgsConstructor
public class UserCreateServiceImpl implements UserCreateService{
private final UserRepository userRepository;
public Long create(UserSignUpRequest userSignUpRequest) {
User user = new User(userSignUpRequest);
return userRepository.save(user.persistence());
}
}
UserCreateService 는 아래와 같은 인터페이스에서 합쳐지게 된다.
public interface UserService extends UserCreateService, UserFindService{
}
그럼 아래와 같이 구현체가 나오게 되는데, 구현하면서 조금 이상한 부분이 존재한다고 생각했다. 그것은 위 service 내부에서 도메인이 아닌 command 객체를 다룬다는 점인데, 해당 부분을 좀 더 잘게 나누어 처리 해야 할 필요성을 느꼈다
@Component
@Transactional
@RequiredArgsConstructor
public class UserServiceImpl implements UserService{
private final UserCreateService userCreateService;
private final UserFindService userFindService;
@Override
public Long create(UserSignUpRequest userSignUpRequest) {
return userCreateService.create(userSignUpRequest);
}
@Override
public UserDetailResponse get(Long userId) {
return userFindService.get(userId);
}
}
service 는 spring 과 domain 의 연결이다
controller 는 이 서비스를 통해 user 에게 기능을 명세한다
Controller
import com.picketing.www.business.service.UserService;
import com.picketing.www.presentation.dto.request.UserSignUpRequest;
import com.picketing.www.presentation.dto.response.UserDetailResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping
public Long create(@RequestBody UserSignUpRequest userSignUpRequest) {
return userService.create(userSignUpRequest);
}
@GetMapping("/{userId}")
public UserDetailResponse get(@PathVariable Long userId) {
return userService.get(userId);
}
}
사용자에게 보여지는 영역.
이로서 사용자는 api 를 통해 기능을 이용할 수 있다
Test
테스트 결과
![](https://blog.pollra.com/content/images/2023/07/-----2023-07-08----2.58.14.png)
![](https://blog.pollra.com/content/images/2023/07/-----2023-07-08----2.58.31.png)
직접 만들어보니 구조적으로 문제점들이 여럿 발견되었다
내일 한번 개선 해 볼 예정이다.
하지만...
구조를 고민하고 작성하며 든 생각이다
정말 위와 같은 구조를 만들어서 개발을 할 필요성이 있을까?
정말 확장성이 극한으로 필요한가?
정말로 위의 코드처럼 구조를 잡고 시작해야 하는가?
결과적으로, 오버엔지니어링이 아닌가?
하지만 구조에 대한 고민은 좋은 고민이기에 이 설계는 끝을 볼 예정이다
내일 마무리가 될 것으로 기대하며 글을 마친다.