[F-Lab 모각코 챌린지] 41일차 - 프로젝트 아키텍쳐

F-Lab 모각코 챌린지 41일차 - 프로젝트 아키텍쳐. 프로젝트 아키텍쳐를 고민한 결과로, 고민한 아키텍쳐가 적용된 api 한 본을 만들어보았습니다.

[F-Lab 모각코 챌린지] 41일차 - 프로젝트 아키텍쳐

프로젝트를 진행하며 아키텍쳐에 대한 고민을 했고, 오늘 그 고민의 결과를 코드로 작성 해 보고자 한다

각 패키지별 제약사항을 적어두었고 끝에 개인적인 생각을 포함하였다.

패키지 분류

아래의 패키지 분류는 이 내용에서 소개 할 코드들의 순서와 비슷하게 나열하였다.

  • 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

테스트 결과


직접 만들어보니 구조적으로 문제점들이 여럿 발견되었다

내일 한번 개선 해 볼 예정이다.

하지만...

구조를 고민하고 작성하며 든 생각이다

정말 위와 같은 구조를 만들어서 개발을 할 필요성이 있을까?

정말 확장성이 극한으로 필요한가?

정말로 위의 코드처럼 구조를 잡고 시작해야 하는가?

결과적으로, 오버엔지니어링이 아닌가?

하지만 구조에 대한 고민은 좋은 고민이기에 이 설계는 끝을 볼 예정이다

내일 마무리가 될 것으로 기대하며 글을 마친다.