졸업작품 Dandi 개발일지 #1

공병주(Chris)·2023년 2월 16일
2
post-thumbnail

졸업작품 개발이 시작되었다. 앞으로 개발일지를 작성하려 한다.

서비스에 대해

서비스 Dandi라는 이름으로, 날씨에 따라 입은 옷을 기록하고 다른 사람들과 공유하는 서비스이다. 어떤 서비스를 기획할까 고민하다가, 우리가 필요한 서비스를 배포하자고 결정했다. 춥고 더우니 옷을 단디(Dandi) 챙겨 입으라는 뜻에서 서비스 이름이 도출되었다.

협업 방식

팀원들과 1주일 단위로 노션 칸반보드에 할 일을 정하고 일주일 단위로 달성률을 공유하기로 했다. 간단한 회고도 제안해보려 한다. 반려된다면, 혼자라도 회고를 진행하겠다.

TODO(2.1 ~ 2.15)

api

  • Apple Id로 로그인/회원가입 (Access Token만)
    • 회원가입
      • 랜덤 닉네임 부여
      • 푸시 알림 시간 초기화
      • 사용자 주 활동 위치 초기화
    • Feign
      • Timeout 설정
      • Feign Retyer 설정
  • 푸시 알림
    • 푸시 알림 정보(시간, 허용 여부) 반환
    • 푸시 알림 시간 변경
    • 푸시 알림 허용 여부 변경
  • 사용자
    • 사용자 정보(닉네임, 위치) 반환
    • 닉네임 변경
    • 주 활동 위치 정보 변경

Infra

  • Log4j2 설정
  • image를 위한 S3 구축

Docs

  • Swagger 설정

Git

  • Submodule

테스트 전략

AcceptanceTest : E2E, 통합 테스트

일단 인수테스트를 작성하기로 결정했다. 팀 메인 기획자와 인수테스트를 함께 보면서 서비스의 기능들이 의도대로 동작하는지 확인하기 위해서 필요하다고 생각했다. Controller부터 DB까지 테스트하는 E2E 테스트, 통합테스트가 필요하다고 생각했다. 따라서, 인수테스트를 E2E테스트, 통합테스트 용으로 가져가기로 결정했다.

ControllerTest : 채택 x

AcceptanceTest를 통해서 충분히 Controller가 검증이 된다고 생각한다. domain layer나 application layer에 비해 로직이 복잡하지 않기 때문에, ControllerTest를 따로 가져가도 테스트를 작성하는 시간 대비 검증 효율이 떨어진다고 생각했다. 따라서, 인수테스트를 통해 검증하는 것을 채택했다.

과거 프로젝트에서 RestDocs 생성을 위해 ControllerTest를 사용했다. Service를 Mocking해서 테스트를 작성했는데, AcceptanceTest에서 검증이 되고 Controller는 로직이 간단하기 때문에, 검증의 효율을 느끼지 못하고 RestDocs 생성을 위해 작성한다는 느낌을 크게 받았다.

ServiceTest : Mocking

과거에AcceptanceTest와 ServiceTest가 통합테스트로 이뤄졌는데, 두 테스트가 중복이라고 생각했다. 실제로 버그가 발생하면 인수테스트와 ServiceTest의 동일한 테스트에서 발생하고 버그도 한번에 잡을 수 있었다.

따라서, 이번엔 ServiceTest에서 Service가 가지는 의존 객체들을 Mocking 하는 방식을 채택했다. 실제로 Mocking을 함으로써 내가 느낀 장점은 ServiceTest를 위해 Mocking 해야할 것이 많아질 때, 프로덕션 코드의 설계에 대해 고민할 수 있게 해줬다.

Mocking을 너무 많이 해야한다. → 테스트 검증 대상이 가진 의존성이 과하지 않은지 의심

DomainTest : 단위테스트

getter, eq&hc를 제외한 모든 로직에 테스트를 진행한다.


Apple ID로 Login

Apple 로그인

서비스가 iOS로 배포될 예정이라서, Apple Login을 적용했다. 다른 OAuth들도 사용할 수 있지만, 정책상 iOS 앱에서 OAuth를 사용하려면 Apple Login을 필수적으로 추가해야한다.

공식 문서만을 보고 개발하려고 노력하고 있다. 하지만, Apple Login은 공식 문서나 꽤나 불친절..해서 힘들었다. 결국 다른 개발자들의 블로그들도 참고하면서 개발했다.

로그인 과정에서 Apple 서버에 요청해서 Apple의 Public Key를 가져왔어야 했는데,

FeignClient

을 통해 응답받았다. 팀원들과 회의를 통해 사용자가 로그인 버튼을 클릭했을 때, 응답/실패 응답이 오는 시간을 정하고 이에 따라 Retryer 까지 구현하는 과정이 재밌었다. 또한, Feign의

ConnectionTimeout과 ReadTimeout은 어떤 값이 적절할까?

을 고민하는 과정에서 네트워크 개념에 대한 공부가 필요했고 새로운 지식을 알게 되어 재밌었다.


Logging 환경 구축

log4j2 AsyncLogger와 함께 하는 Logging 환경 구축

을 했다. 우테코 프로젝트에서 Logging을 직접 설정하지 않았고 Logback을 통한 설정을 공유받았었다. 따라서, 이번에 완전 제로베이스부터 로깅 프레임워크들을 비교하고 Log4j2를 적용했다. Log4j2에서도 Async Logger를 적용해서 로깅 성능을 최대한 끌어올리려했다.

아직 해결하지 못한 문제가 있는데, Logback에는 AsyncAppender를 사용하면 BlockingQueue를 사용하고 이에 따라, Queue가 일정 수준 이상 찬다면, 어떤 레벨의 로깅까지만 Queue에 담을지 설정할 수 있는데, Log4j2에도 이와 비슷한 설정이 있는지 찾아보다가 찾지 못했다.


랜덤닉네임 생성 : unique에 대해 if~exists와 try catch

회원가입 시에 랜덤 닉네임을 생성하는 기능이 있었다. 사용자가 어떤 추가적인 입력 없이 빠르게 회원가입 절차를 마치게 하기 위해서다.

따라서, 아래와 같이 코드를 작성했다.

private Member saveNewMember(String oAuthMemberId) {
	  String randomNickname = nicknameGenerator.generate();
	  while (memberRepository.existsMemberByNicknameValue(randomNickname)) {
	      randomNickname = nicknameGenerator.generate();
	  }
	  return memberRepository.save(new Member(oAuthMemberId, randomNickname));
}

NicknameGenerator에서 nickname을 생성해주고 그 값을 exists로 체크한 후, save를 실행한다. 이 로직은 사실 동시성이 완전히 해결된 코드가 아니었다. 그 이유는 동시에 exists 쿼리를 통해 unique 체크를 한후 while 문을 빠져나와서 insert를 하면 결국 unique 제약을 위반해서 예외가 발생하기 때문이다.

private Member saveNewMember(String oAuthMemberId) {
    try {
        String generateNickname = nicknameGenerator.generate();
        return memberRepository.save(new Member(oAuthMemberId, generateNickname));
    } catch (DataIntegrityViolationException exception) {
        return saveNewMember(oAuthMemberId);
    }
}

따라서, 코드를 위처럼 변경해봤다. 동시성을 완벽하게 제어하려면 exists를 통한 체크 후에도 예외 catch를 해줘야하기 때문에 그냥 try ~ catch로 가자고 생각했다.

try ~ catch

그런데, 사실 try ~ catch를 그렇게 좋아하지 않는다. 위의 코드는 oAuthMemberId와 generateNickname을 포함한 Member를 저장하는데 어디서 예외가 발생하는지 추측하기가 쉽지 않다. 둘 중에 어떤 컬럼이 unique지? 아니면 DB에 다른 제약 조건이 걸려있는 건가? 라고 생각할 수 있을 것 같다.

if ~ exists

반면, exists로 처리하는 코드는 비즈니스 로직을 더 잘 나타낸다는 생각한다. 또한, exists 쿼리에 대한 성능을 고민해봤다. unique 검사를 하는 컬럼에 index를 걸어둘 것이기 때문에 exists 쿼리가 그렇게 부담스럽지 않을 것이라고 생각했다. read를 하면서 lock을 거는 것도 아니다.

그리고, 가장 중요한 점은 try ~ catch를 사용할 정도로 예외가 발생할 일이 크게 없다고 판단했다. 생성될 수 있는 닉네임의 경우가 1400여개다. 또한, 타켓 사용자인 옷 좋아하는 20대들은 랜덤 생성해준 닉네임을 계속 사용할 것이 아니라 본인이 원하는 방식으로 닉네임을 변경할 것이라고 생각했다.

따라서, if ~ exists를 사용했다.


도메인 이벤트

이번에 DDD 개념을 프로젝트에 도입하려고 한다. MSA 환경이 아니기 때문에 애그리거트 간의 참조를 잘 끊는 것이 중요하다고 생각한다. 또한, 의존성을 분리하기 위해서 이벤트를 적용했다. 이전 프로젝트에서는 Service에서 ApplicationEventPublisher를 통해 이벤트를 발행했다.

하지만, Service에 이벤트 발행 로직이 있었기 때문에 도메인 로직이 Domain 레이어에 집중되지 못한다는 생각이 들었다. 따라서, AbstractAggregateRoot 상속을 통해 이벤트 발행 로직도 Domain에서 구현했다.

새로운 회원이 생성되는 이벤트를 발행했어야 했다.

@Entity
public class Member extends AbstractAggregateRoot<Member> {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    // ...

    private Member(Long id, String oAuthId, Nickname nickname, Location location) {
        this.id = id;
        this.oAuthId = oAuthId;
        this.nickname = nickname;
        this.location = location;
        registerEvent(new NewMemberCreatedEvent(id));
    }

위처럼, 생성자에서 이벤트를 완벽하게 발행할 수 없다. Member의 PK 생성 전략이 Identity기 때문에 영속화되지 않은 시점에는 id가 null이었기 때문이다. 따라서, 아래와 같이 @PostPersist 시점에 이벤트를 발행하도록 했다.

@Entity
public class Member extends AbstractAggregateRoot<Member> {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    // ...

    private Member(Long id, String oAuthId, Nickname nickname, Location location) {
        this.id = id;
        this.oAuthId = oAuthId;
        this.nickname = nickname;
        this.location = location;
    }

    @PostPersist
    private void registerNewMemberCreatedEvent() {
        registerEvent(new NewMemberCreatedEvent(id));
    }
}

Spring의 AbstractAggregateRoot

도메인에 Spring 의존성을 추가한 것이 처음이다. 처음에는 도메인에 Spring 의존성이 들어오는 것이 맘에 들지 않아서 다른 방식으로 도메인 이벤트를 구현할 수 있는 방법을 찾아보았다.

최범균님의 도메인 주도 개발 시작하기라는 책에서도 static한 Events 객체를 통해서 도메인 이벤트를 발행한다. Events 객체도 spring을 의존성하기 때문에 AbstractAggregateRoot와 마찬가지로 Spring을 의존한다고 생각한다.

물론 도메인이 가지는 spring의 의존을 빼는 것이 좋다. 하지만, 과연 Spring이 제거될 일이 있을까? 없다는 생각이 들었다. 그렇다면, Spring이 제거되지 않는다는 가정하에 AbstractAggregateRoot를 사용해서 도메인 레이어에서 이벤트를 발행한다면 도메인 로직을 더 응집도 있게 가져갈 수 있다고 생각한다.

@PostPersist

PostPersist도 마찬가지라고 생각한다. 생성자에서 이벤트를 발행할 수 없기 때문에 @PostPersist를 통해 이벤트를 발행했다. @PostPersist를 사용하지 않으면 Service에서 이벤트를 발행해야한다.

JPA의 @Id, @GeneratedValue와 같은 값을 위한 어노테이션들이 붙은 필드들은 JDBC로 바뀌어도 어노테이션을 떼기만 하면 유지할 수가 있다고 생각한다. 하지만, @PostPersist는 조금 다르다고 생각한다. 단순 값 할당, 객체 생성 방식이 아닌 도메인 로직이 JPA에 의존적이라는 회의감이 들었다.

하지만, JPA 의존성이 걷어내지 않을 것이라는 가정과 @PostPersist를 사용을 통해 높은 도메인 로직의 응집도를 가져갈 수 있기 때문에 @PostPersist 사용을 선택했다.


Swagger

Swagger(feat. Springdoc OpenAPI)

api 문서화를 위해 Restdocs와 비교한 후 Swagger를 사용했다. Swagger를 처음 써보긴했는데, Springdoc-openapi의 공식문서가 워낙 잘되어있어서 속성 값들을 잘 알 수 있었다. 사용방법도 꽤 간단했다.

과거에 Swagger가 아닌 Restdocs를 사용했던 이유가 Controller에 문서화코드가 침범하는 것이었다. 이것을 추상화로 풀어보았다. interface를 정의해서 interface에는 문서화 코드를 작성하고 이를 구현하는 Controller에는 실제 api 통신을 위한 코드를 작성했다.


Submodule

저번 우테코 프로젝트에서는 보안적으로 위험한 값들 때문에 yml 파일을 서버에서 관리했다. yml에 수정이 있을 때 마다 서버에 직접 들어가서 vim으로 수정했는데, 매우 불편했다. 심지어 로드밸런싱도 되있는 상황이었어서 두 개의 서버에 yml을 직접 관리해줬어야 했다.

따라서, 이번엔 Submodule로 yml을 관리하기로 했다.


회고

목표 달성

image를 위한 S3 구축을 제외하고는 나머지 일들을 다 해냈다. OAuth가 처음인데다가, Apple Login이 다른 OAuth에 비해 복잡하고 공식문서가 잘 되어있지도 않고 레퍼런스도 많지 않아서 시간이 많이 걸렸다.

또한, Log4j2를 통한 Logging 구축도 시간이 꽤 걸렸던 것 같다. 추후에 로깅 설정을 건드리고 싶지 않았기 때문에 처음부터 잘 구축해두기 위해서 공식 문서를 자세히 읽어보면서 구축했기 때문이라 생각한다.

나머지 API들과 기능들은 잘 구축했다.

아키텍처

최근에 2022 우아콘-개발자가 아키텍처에 집착하는이유를 보면서 클린 아키텍처라는 것에 관심이 생겼다. 과거의 프로젝트에 비해 더 디벨롭하고 싶은 사항들이 있는데, 아키텍처가 그 중 하나이다.

현재는 레이어드 아키텍처로 구성되어 있는데, 헥사고날 아키텍처를 공부해서 아키텍처 변경을 해봐야겠다.
개발이 많이 된 상태에서 변경을 하려면 힘드니까 빠른 시일 내에 아키텍처를 변경해야겠다.

내일 베트남으로 여행을 가는데, 오가는 비행기에서 헥사고날 아키텍처 책을 읽어야겠다.

API 개발 속도

클라이언트 개발자가 API를 빨리 개발해달라는 요청을 해서 일단 알겠다고 해뒀다. 그래서 API를 빨리 뽑아두고 추후에 개선을 시키는 방향으로 가야하나..? 라는 생각이 잠시 들었다.

API를 빨리 찍어내야한다는 생각을 하면서 개발하니까, 깊은 고민을 하기가 힘들었다. API를 잘 뽑는 것도 중요하지만, 더 많고 깊은 고민들로 내 철학이 담긴 코드를 작성하는 개발자가 되고 싶다.

사실 API 개발을 엄청 빨리 하지 않아도, 졸업작품 전시 이전까지는 100% 완성할 수 있을 것이라고 확신한다. 또한, 클라이언트와 개발을 병렬적으로 진행해도 내가 1-2주 정도의 양만 먼저 개발해두면 충분히 클라이언트 개발자도 개발을 진행할 수 있을 것이라고 생각한다.

따라서, 개발을 하면서 더 많은 고민들을 해보려합니다.

profile
self-motivation

0개의 댓글