졸업작품 Dandi 개발일지 #2

공병주(Chris)·2023년 3월 8일
0


1주일은 베트남을 다녀와서 개발을 못해서 졸업작품 Dandi 개발일지 #1를 작성한지 3주가 지나서 개발일지 #2를 완성한다.

2주 간의 개발 일지이다.

api 및 기능

  • 닉네임 중복 확인 기능
  • Token
    • Refresh Token 구현
    • Access, Refresh Token 기한 설정
    • Access, Refresh Token 전달 위치 조정
  • Auth Interceptor 구현

Infra

  • S3 환경 구축
  • CI(Githuc Action)

Test

  • 인수테스트 픽스쳐 분리 및 가독성 증진
  • Docker, Localstack을 통한 S3 관련 테스트

Architecture

  • Layered Architecture Hexagonal Architecture로 이전

api 및 기능

이번 스프린트의 주 목표는 S3와 아키텍처 전환이었다. 거기에 더해 대학교 개강까지해서 API를 많이 구현하지 못했다. 참 핑계같다.

CI

Github Action으로 CI를 진행했다. 과거에 Jenkins의 MultiBranch Pipeline으로 구축했을 때보다 훨씬 간편하게 구축할 수 있었다. CI 스크립트는 아래와 같다.

name: Java CI with Gradle

on:
  push:
    branches: [ "**" ]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Check Out
        uses: actions/checkout@v3
        with:
          token: ${{ secrets.YML_CONFIG_CREDENTIAL }}
          submodules: true
      - name: Set up JDK 11
        uses: actions/setup-java@v3
        with:
          java-version: '11'
          distribution: 'temurin'

      - name: Setup MySQL
        uses: mirromutth/mysql-action@v1.1
        with:
          host port: 3306
          container port: 3306
          character set server: utf8mb4
          collation server: utf8mb4_general_ci
          mysql version: 8.0.31
          mysql root password: dandi123
          mysql database: test
          mysql user: root
          mysql password: dandi123

      - name: Build with Gradle
        uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1
        with:
          arguments: build

S3 구현과 Docker, Localstack을 통한 S3 관련 테스트

S3를 통해 이미지 기능을 구현하고 Docker, Localstack으로 관련 테스트를 진행했다.

S3는 Spring의 Transactional로 rollback 시킬 수 없기 때문에 어떻게 이를 해결해야 할지에 대해 고민해보았다.

Test, Local 환경에서 실제 S3에 접근하는 것은 비효율적이고 위험할 수 있다. 따라서, Localstack을 통한 방식으로 Test, Local 환경의 S3를 대체했다. 자세한 내용은 해당 글에 정리해두었다.

S3를 적용하는데는 소요한 시간보다 Local, Test 환경에서 어떻게 S3 로직이 포함된 테스트 환경 구축에 더 많은 시간을 사용했다.

프로덕션보다 테스트 코드에 더 많은 시간을 쏟다니. 주객이 전도되었다고 생각할 수 있다. 하지만, 테스트 코드는 프로덕션을 검증하는 서포터라고 생각할 수 있지만, 그 이상의 것들을 한다고 생각한다. 테스트를 함으로써, 의존성에 대한 고민을 할 수 있는 것이 이유 중 하나이다.

테스트를 중요시하는 개발자인 것이 꽤 뿌듯(?)하다.

인수테스트 픽스쳐 분리 및 가독성 증진

인수테스트는 사용자 시나리오대로 진행되어서 배포 가능성에 대한 근거이기 때문에 상당히 중요하다. 또한, 인수테스트들을 보면서 어플리케이션의 기능들을 파악할 수 있기 때문에 중요하다.

하지만, 인수테스트는 RestAssured의 http 통신 코드가 있기에 상당히 복잡하다. 따라서, 가독성 증진이 중요하다.

최대한 픽스쳐를 분리해서 한눈에 파악할 수 있는 테스트 코드가 되도록 노력하고 있다. 하지만, 아직 많이 부족해보인다.

현재 내 인수테스트는 아래와 같다. 픽스쳐로 분리해도 여전히 복잡하다는 생각이 든다.

과거에, 한글메서드 도입을 고려했는데 그때 당시에는 가독성이 충분해보였다. 근데 다시 코드들을 보니 그렇게 잘 읽히는 것 같지는 않다. 한글 변수, 메서드 도입에 대한 고민을 해봐야겠다.

class PushNotificationAcceptanceTest extends AcceptanceTest {

    @DisplayName("회원의 푸시 알림 시간 변경 요청에 성공하면 204를 반환한다.")
    @Test
    void updatePushNotificationTime_NoContent() {
        String token = getToken();
        LocalTime initialPushNotificationTime = getPushNotificationTime(token);
        PushNotificationTimeUpdateRequest pushNotificationTimeUpdateRequest =
                new PushNotificationTimeUpdateRequest(LocalTime.of(20, 10));

        ExtractableResponse<Response> response = httpPatchWithAuthorization(
                PUSH_NOTIFICATION_TIME_REQUEST_URI, pushNotificationTimeUpdateRequest, token);

        LocalTime pushNotificationTimeAfterUpdate = getPushNotificationTime(token);
        assertAll(
                () -> assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()),
                () -> assertThat(initialPushNotificationTime).isNotEqualTo(pushNotificationTimeAfterUpdate)
        );
    }

    @DisplayName("10분 단위가 아닌 푸시 알림 시간 변경 요청에 대해 400을 반환한다.")
    @Test
    void updatePushNotificationTime_BadRequest() {
        String token = getToken();
        PushNotificationTimeUpdateRequest invalidPushNotificationTimeUpdateRequest =
                new PushNotificationTimeUpdateRequest(LocalTime.of(20, 11));

        ExtractableResponse<Response> response = httpPatchWithAuthorization(
                PUSH_NOTIFICATION_TIME_REQUEST_URI, invalidPushNotificationTimeUpdateRequest, token);

        assertAll(
                () -> assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()),
                () -> assertThat(extractExceptionMessage(response)).isNotNull()
        );
    }

Refresh Token 구현 및 유효기간 설정

Refresh Token을 구현하고 토큰 유효기간을 설정했다.

Bean Validation 통용적으로 사용해보기

Bean Validation을 사용하면서 불편함을 겪었고 이를 해결해보았다.

Hexagonal Architecture로의 전환

새로운 프로젝트를 할 때 중요한 점은 이전 프로젝트을 개선하거나 혹은 이전 프로젝트와의 차별점을 두어야 한다는 점이다.

따라서, Layered Architecture에서 최근 관심이 생긴 Hexagonal Architecture로 전환했다.

좋은 책을 통해 Hexagonal Architecture에 대해 쉽게 이해할 수 있었다. 전환을 하면서 이전의 Layered Architecture와의 차이점에 대해서 생각해보려했다. 글에서는 Hexagonal Architecture에 대해 자세히 정리했다기 보다, Hexagonal Architecture로의 전환에서 느낀 점을 많이 정리해두었다.

전환 과정이 생각보다 시간이 많이 오래걸렸다. 책을 읽으면서 해야했고 구조를 뜯어고치는 작업이었기 때문에 대공사였다. 하지만, 전환할거면 빨리 전환하는 것이 좋다고 생각한다. 어플리케이션의 덩치가 더 커지면 그만큼 작업하는데 더 오래 걸렸을 것이다. 또한, 처음부터 Hexagonal Architecture를 도입하는 것이 아니라, 개발이 진행되는 와중에 아키텍처를 전환하는 것이 더 값진 경험 같다.

개선 필요한 사항

Controller, Service의 책임 분리에 대한 필요성

@Service
public class AuthService implements AuthUseCase {

    private final MemberSignupManager memberSignupManager;
    private final OAuthClientPort oAuthClientPort;
    private final MemberPersistencePort memberPersistencePort;
    private final RefreshTokenPersistencePort refreshTokenPersistencePort;
    private final AccessTokenManagerPort accessTokenManagerPort;
    private final RefreshTokenManagerPort refreshTokenManagerPort;

    public AuthService(MemberSignupManager memberSignupManager, OAuthClientPort oAuthClientPort,
                       MemberPersistencePort memberPersistencePort,
                       RefreshTokenPersistencePort refreshTokenPersistencePort,
                       RefreshTokenManagerPort refreshTokenManagerPort, AccessTokenManagerPort accessTokenManagerPort) {
        this.memberSignupManager = memberSignupManager;
        this.oAuthClientPort = oAuthClientPort;
        this.memberPersistencePort = memberPersistencePort;
        this.refreshTokenPersistencePort = refreshTokenPersistencePort;
        this.refreshTokenManagerPort = refreshTokenManagerPort;
        this.accessTokenManagerPort = accessTokenManagerPort;
    }

    @Override
    @Transactional
    public LoginResponse getToken(LoginCommand loginCommand) {
        String oAuthMemberId = oAuthClientPort.getOAuthMemberId(loginCommand.getIdToken());
        Optional<Member> member = memberPersistencePort.findByOAuthId(oAuthMemberId);
        if (member.isPresent()) {
            String accessToken = accessTokenManagerPort.generateToken(String.valueOf(member.get().getId()));
            String refreshToken = generateRefreshToken(member.get().getId());
            return LoginResponse.existingUser(accessToken, refreshToken);
        }
        return getNewMemberLoginResponse(oAuthMemberId);
    }

    private LoginResponse getNewMemberLoginResponse(String oAuthMemberId) {
        Long newMemberId = memberSignupManager.signup(oAuthMemberId);
        String token = accessTokenManagerPort.generateToken(String.valueOf(newMemberId));
        String refreshToken = generateRefreshToken(newMemberId);
        return LoginResponse.newUser(token, refreshToken);
    }

    private String generateRefreshToken(Long memberId) {
        RefreshToken refreshToken = refreshTokenManagerPort.generateToken(memberId);
        refreshTokenPersistencePort.save(refreshToken);
        return refreshToken.getValue();
    }

    @Override
    @Transactional
    public TokenResponse refresh(Long memberId, String refreshTokenValue) {
        RefreshToken refreshToken = refreshTokenPersistencePort
                .findRefreshTokenByMemberIdAndValue(memberId, refreshTokenValue)
                .orElseThrow(UnauthorizedException::refreshTokenNotFound);
        validateExpiration(refreshToken);
        RefreshToken updatedRefreshToken = refreshTokenManagerPort.generateToken(memberId);
        refreshTokenPersistencePort.update(refreshToken.getId(), updatedRefreshToken);
        String accessToken = accessTokenManagerPort.generateToken(String.valueOf(memberId));
        return new TokenResponse(accessToken, updatedRefreshToken.getValue());
    }

    private void validateExpiration(RefreshToken refreshTokenInfo) {
        if (refreshTokenInfo.isExpired()) {
            throw UnauthorizedException.expiredRefreshToken();
        }
    }

    @Override
    @Transactional
    public void logout(Long memberId) {
        refreshTokenPersistencePort.deleteByMemberId(memberId);
    }
}

현재 내 Auth 관련 UseCase이다. 엄청나게 많은 의존성을 가지고 있다. 그렇다고 불필요한 의존들인지에 대해서 고민해보았는데 모두 다 필요한 의존이라는 생각을 했다. 그렇다면 뭐가 문제였을까?

헥사고날 아키텍처 책을 읽으면서 해답을 알 수 있었다. 바로 UseCase가 너무 범용적이라는 것이다.

지금은 DDD의 어그리거트 단위로 Controller와 UseCase도 구현해두었다.

이렇게 되니 하나의 객체가 너무 많은 의존성을 가지고 있다. 관련 기능이 생기면 해당 UseCase와 Controller 들은 더 길어질 것이다. 또한, 하나의 객체가 가진 책임이 많다보니 Test 코드도 상당히 길어진다.

따라서, **어그리거트 단위로 Service와 Controller를 구현할 것이 아니라, 더 작은 단위로 책임을 나눠야 한다**는 생각이 들었다.

생각해보면 과거 우테코 프로젝트에서도 댓글에 대한 Service가 상당히 길었고 Test 코드도 테스트 케이스가 많아서 상당히 길었다.

이걸 왜 이제야 깨달았을까. 다음 스프린트 작업으로 가져가야겠다.

마치며

대학교가 개강을 했다. 우테코 후 학교로 돌아간다는 결심을 했을 때는 학교 공부를 거의 안하고 내가 하고 싶은 개발 공부만 하려고 했다. 그런데, 내 성격상 그게 될지 잘 모르겠다. 그냥 나를 지속가능한 선에서 갈아넣어야겠다.

이제 졸업 작품 중간 전시가 2달도 남지 않았다. API 개발에 속도를 조금 붙혀야겠다.

0개의 댓글