1주일은 베트남을 다녀와서 개발을 못해서 졸업작품 Dandi 개발일지 #1를 작성한지 3주가 지나서 개발일지 #2를 완성한다.
2주 간의 개발 일지이다.
이번 스프린트의 주 목표는 S3와 아키텍처 전환이었다. 거기에 더해 대학교 개강까지해서 API를 많이 구현하지 못했다. 참 핑계같다.
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는 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을 구현하고 토큰 유효기간을 설정했다.
Bean Validation을 사용하면서 불편함을 겪었고 이를 해결해보았다.
새로운 프로젝트를 할 때 중요한 점은 이전 프로젝트을 개선하거나 혹은 이전 프로젝트와의 차별점
을 두어야 한다는 점이다.
따라서, Layered Architecture에서 최근 관심이 생긴 Hexagonal Architecture로 전환했다.
좋은 책을 통해 Hexagonal Architecture에 대해 쉽게 이해할 수 있었다. 전환을 하면서 이전의 Layered Architecture와의 차이점에 대해서 생각해보려했다. 글에서는 Hexagonal Architecture에 대해 자세히 정리했다기 보다, Hexagonal Architecture로의 전환에서 느낀 점
을 많이 정리해두었다.
전환 과정이 생각보다 시간이 많이 오래걸렸다. 책을 읽으면서 해야했고 구조를 뜯어고치는 작업이었기 때문에 대공사였다. 하지만, 전환할거면 빨리 전환하는 것이 좋다고 생각한다. 어플리케이션의 덩치가 더 커지면 그만큼 작업하는데 더 오래 걸렸을 것이다. 또한, 처음부터 Hexagonal Architecture를 도입하는 것이 아니라, 개발이 진행되는 와중에 아키텍처를 전환하는 것이 더 값진 경험
같다.
@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 개발에 속도를 조금 붙혀야겠다.