후기🔥/회고록

넘블 디프백-프로젝트 회고록

안주형 2022. 12. 5. 17:39

회고록

먼저 나는 초기에 참가하지 않았다. 여러 개인적인 사정으로 슬럼프에 빠져있었기 때문에 초기에 지원할 수 있었음에도 지원하지 않았고, 프로젝트 시작일 2주 정도 뒤에 넘블 측에서 SNS로 백엔드 개발자 한 명이 부족하다는 소식을 알게 되어 연락을 통해 내가 해당 프로젝트에 추가로 투입되었다.

물론 빈자리가 생겼다는 소식만으로 가볍게 지원한 것은 아니었고, 평소 Spring Boot를 활용한 Back-end와 DevOps에 대해서 이론 지식 학습과 개인적인 프로젝트만 진행했었기 때문에 처음부터 끝까지 협업을 통해 만드는 실전 프로젝트를 해보고 싶어 지원했다.

개발 스택

간단하게 프로젝트에 사용된 기술을 나열하면 다음과 같다.

  • Java 11, Spring Boot, JPA, QueryDSL
  • MySQL, Redis
  • AWS, Docker, CI/CD, Nginx(https redirect), Sonar Cloud
  • 외부 API(cool sms)

 

DB 테이블

개발 내역

먼저 내가 개발한 API와 인프라를 나열하면 다음과 같다. 총 19일 동안 개발을 진행했으며, 조금 더 일찍 들어왔다면 더 많은 것을 개발할 수 있었을 텐데 하는 아쉬움이 좀 크다.

Infra

  • 가비아 도메인 구매
  • Nginx + Cerbot/SSL을 통한 https 설정
  • Sonar Cloud 연동

Docs

  • Swagger API Docs 생성

USER

  • MY 프로필 정보 조회
  • MY 북마크 조회
  • MY 댓글 조회
  • MY 게시글 조회
  • MY 정보 수정
  • SMS 인증번호 전송

AUTH

  • SMS 인증번호 체크

CATEGORY

  • 총 카테고리 정보
  • 카테고리 게시글 정보

COMMENT

  • 댓글 등록
  • 댓글 삭제
  • 댓글 수정
  • 댓글 리스트 조회
  • 답글 등록

FEED

  • 게시글 등록
  • 게시글 수정
  • 게시글 삭제
  • 게시글 상세 조회
  • 게시글 리스트 조회
  • 게시글 북마크
  • 게시글 북마크 취소
  • 게시글 좋아요
  • 게시글 좋아요 취소

2주 정도 늦게 투입되었기에 같이 개발을 진행했던 팀원분께서 프로젝트 기초 컨벤션과, security 설정들은 전부 세팅을 해놓으신 상태여서 온전히 비즈니스 로직 개발에만 집중할 수 있었던 것 같다. 위의 모든 API 코드들을 회고하는 것은 너무 길고 의미가 없을 것 같아 이번에 개발하면서 크게 배우고 성장할 수 있었다고 생각하는 코드들만 회고를 해보도록 하겠다.

 

주요 코드 설명

1. S3 연동과 Util Code

먼저 S3연동이다. 유저의 프로필 이미지를 저장하거나 게시글을 등록할 때 이미지가 등록되어야 하는데 이를 저장하는 저장소로 AWS의 S3 클라우드 스토리지를 사용했다.

아래와 같이 S3에 사용되는 코드들을 Infrastructure와 property pacage에 두어 확장성 있게 개발하였다.

@RequiredArgsConstructor
@Component
public class S3Facade implements ImageUtil {

    private final S3Properties s3Properties;
    private final AmazonS3Client amazonS3Client;

    @Override
    public String uploadImage(MultipartFile image) {
        String fileName = s3Properties.getBucket() + "/" + UUID.randomUUID() + image.getOriginalFilename();

        try {
            amazonS3Client.putObject(new PutObjectRequest(s3Properties.getBucket(), fileName, image.getInputStream(), null)
                    .withCannedAcl(CannedAccessControlList.PublicRead));
        } catch (Exception e) {
            throw SaveImageFalseException.EXCEPTION;
        }

        return getFileUrl(fileName);
    }

    public String getFileUrl(String fileName) {
        return amazonS3Client.getUrl(s3Properties.getBucket(), fileName).toString();
    }
}

@Getter
@RequiredArgsConstructor
@ConstructorBinding
@ConfigurationProperties(prefix = "cloud.aws.s3")
public class S3Properties {

    private final String bucket;

}

여기서 유심히 봐야 할 부분은 바로 S3Properties.class인데 보통 다른 프로젝트들을 살펴보면 @Value를 통해서 불러오는 것을 볼 수 있다. 하지만 나는 @ConfigurationProperties 어노테이션을 통해서 설정 값들을 불러왔는데, 이유는 다음 블로그에 잘 정리되어 있다.
https://tecoble.techcourse.co.kr/post/2020-09-29-spring-properties-binding/

yml에 설정해둔 값을 서로 다른 클래스에서 @Value를 이용해서 산발적으로 사용한다고 생각해 보자. 설정값을 boolean으로 가져다 쓴다면 다행이지만 모든 개발자가 String으로 불러다 쓰는 실수하지 않을 거라는 확신을 할 수 없다. 지금은 단순히 true이지만 숫자가 연속된 형태의 값(ex.342462351)이라면 이 값을 숫자로 인지할지 문자열로 인지할지 모르게 된다. 결국 타입에 대한 안정성을 가지기가 힘들다는 것을 의미한다.

하지만 이럼에도 @Value와 @ConfigurationProperties 두 방식 모두 공통으로 불변이 아니라는 문제점이 있다.
심지어 @ConfigurationProperties은 개발자 입장에서 불필요한 setter가 공개되어 있어 중간에 값이 변경될 위험성이 크게 남아있다. 이 문제를 해결하기 위해 @ConstructorBinding를 사용하였다.

@ConstructorBinding 어노테이션을 이용하면 final 필드에 대해 값을 주입해준다. 그리고 중첩 클래스가 있다면 자동으로 중첩 클래스의 final 필드 또한 자동으로 값을 주입하는 대상이 된다. 따라서 @ConstructorBinding 어노테이션을 이용함으로써 불필요한 setter를 사용하지 않게 되면서 불변성을 유지할 수 있도록 하였다.

2. Offset 페이징과 Cursor(No-offset) 페이징 사용

지금 우리 프로젝트의 디자인을 살펴보면 페이징 처리가 필요한 부분이 매우 많다.한번 나열해 보면 인기 게시글 조회, 최신 글 조회, 카테고리 게시글 조회, 댓글 조회, 내가 쓴 게시글 조회, 내가 쓴 댓글 조회, 내가 북마크 한 게시글 조회 등 당장 생각나는 데로 나열만 했을 뿐인데도 이렇게 많다.

먼저 Cursor라 불리는 No-offset 페이징은 최신 글 조회, 카테고리 게시글 조회, 내가 쓴 게시글 조회에 사용했다. 이유는 다음과 같다. https://bbbicb.tistory.com/40

Cursor 페이징을 사용하면 Offset 페이징을 사용했을 때 발생하는 데이터 중복 문제가 일어나지 않고, 누락된 데이터 없이 우수한 실시간 데이터 처리가 가능하다. 그렇기에 가장 메인이 되는 최신 글 조회 같은 부분에는 빠르게 로딩하기 위해 Cursor 기반 페이징을 사용했다.

하지만 Cursor 기반은 Unique 값을 기반으로 값이 크거나 작은 것을 비교하는 방식으로 동작하기에 정렬 조건이 좋아요 순과 같이 이전과 이후가 복잡하고 두 개 이상의 정렬 기준이 주어지는 경우에는 사용하기 복잡한 감이 없지 않아 있다. 실제로 나는 게시글 부분을 개발할 때 인스타그램과 당근 마켓을 많이 참고했는데 두 서비스 모두 메인 뉴스피드에서는 최신 글만 조회할 수 있도록 되어있지 따로 정렬 조건을 주는 부분은 존재하지 않았다.

따라서 정렬 조건이 주어지는 부분은 어쩔 수 없이 Offset Paging을 사용하였다. 물론 Offset 페이징이 나쁘다는 것은 아니다. 일반 웹 페이지의 게시글과 같이 페이지 번호를 통해 데이터를 불러들이는 방법 같은 경우 당연히 써야 한다.여러 서비스 로직에서 페이징이 쓰이기 때문에 이 로직 또한 나는 Utils Package에 다음과 같이 두어서 전역으로 사용하였다.

public class PagingSupportUtil {

    private static final int DEFAULT_PAGE_SIZE = 10;
    private static final long DEFAULT_CURSOR_ID = Long.MAX_VALUE;

    public static <T> Slice<T> fetchSliceByCursor(JPAQuery<T> query, Pageable pageable) {
        int pageSize = pageable.getPageSize();

        List<T> content = query
                .limit(pageable.getPageSize() + 1)
                .fetch();

        return new SliceImpl<>(content, pageable, isHasNext(pageSize, content));
    }

    public static <T> Slice<T> fetchSliceByOffset(JPAQuery<T> query, Pageable pageable) {
        int pageSize = pageable.getPageSize();

        List<T> content = query
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1)
                .fetch();

        return new SliceImpl<>(content, pageable, isHasNext(pageSize, content));
    }

    private static <T> boolean isHasNext(int pageSize, List<T> content) {
        boolean hasNext = false;
        if (pageSize < content.size()) {
            hasNext = true;
            content.remove(pageSize);
        }
        return hasNext;
    }

    public static OrderSpecifier<?> getSortedColumn(Order order, Path<?> parent, String fieldName) {
        Path<Object> fieldPath = Expressions.path(Object.class, parent, fieldName);
        return new OrderSpecifier(order, fieldPath);
    }

    public static Long applyCursorId(Long cursorId) {
        return cursorId == 0 ? DEFAULT_CURSOR_ID : cursorId;
    }

    public static Pageable applyPageSize() {
        return PageRequest.of(0, DEFAULT_PAGE_SIZE);
    }

3. Redis를 통한 조회수 엔티티

조회 수 같은 경우는 기존 MySQL DB 말고 Redis에 따로 다음과 같은 관계로 만들어 사용하였다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@RedisHash
public class FeedViewCount {

    @Id
    private Long feedId;

    private Long viewCount;

    @TimeToLive
    private Long expiredAt;

    @Builder
    public FeedViewCount(Long feedId) {
        this.feedId = feedId;
        this.viewCount = 0L;
        this.expiredAt = -1L; // 만료 기간 없음
    }

    public void addViewCount(){
        this.viewCount++;
    }
}

조회 수 같은 경우 매번 사용자가 게시글 상세페이지를 조회할 때마다 더티 체킹을 통해 업데이트가 일어나야 하는데 이러한 업데이트가 매번 RDBMS에서 일어난다면 서버에 부하가 많이 가고 속도 면에서 유리하지 않을 것이라 생각했기 때문이다. 또한 Redis는 모든 명령어가 단일 스레드로 동작하기에 여러 명의 클라이언트 요청을 동시성 처리가 가능하다.

또한 게시글 조회는 트랜잭션을 ReadOnly로 설정할 수 있음에도 불구하고 RDBMS에서 조회수를 관리한다면 조회수를 업데이트하는 로직이 추가되어야 하기 때문에 ReadOnly를 걸 수 없다. 하지만 Redis는 트랜잭션의 영향을 받지 않기 때문에 ReadOnly로 설정할 수 있다는 장점도 있다.

4. 휴대폰 본인인증 검증 로직 서버에서 처리

나는 coolsms라는 외부 API를 통해서 서버에서 생성한 랜덤 문자를 클라이언트가 요청한 휴대폰 문자로 보내게 구현하였다. 그리고 초기에는 클라이언트에게 생성한 문자를 보내주고 클라이언트 측에서 유저가 받은 문자와 입력한 문자가 동일한지 검증하도록 구성하였다. 하지만 같이 개발을 진행하는 팀원 분께서 이 검증 로직 같은 경우는 서버에서 검증하는 게 좋을 것 같다는 의견을 내주셨고 나는 다음과 같은 과정으로 처리하도록 개발하였다.

  1. /users/verification-codes를 통한 클라이언트의 요청
  2. 서버에서 외부 API를 통해 휴대폰으로 랜덤 문자 전송 및 Redis에 사용자의 휴대폰 번호와 랜덤 문자 저장(만료 시간은 3분), 이때 랜덤 문자는 crypto encrpto를 통해 암호화한 뒤 저장
  3. /users/verification-codes를 통한 클라이언트의 문자 검증을 위해 랜덤 문자와 휴대폰 번호 전송
  4. 서버에서 먼저 사용자의 휴대폰 번호가 Redis에 존재하는지 검증, 없다면 3분이 지났기에 인증 실패
  5. 휴대폰 번호가 Redis에 존재한다면 Redis에 저장된 문자를 복호화 한 뒤 문자가 일치한 지 검증

5. @PrePersist를 통한 Insert Null 값 처리

    @NotNull
    @ColumnDefault("0")
    private Long likeCount;

    @NotNull
    @ColumnDefault("0")
    private Long bookmarkCount;

    @NotNull
    @ColumnDefault("0")
    private Long commentCount;

위 코드는 우리 프로젝트의 게시글 엔티티 중 일부분이다. 직관적으로 보면 알 수 있듯이 게시글의 좋아요, 북마크, 댓글 수를 기록하는 컬럼인데 @NotNull로 설정하고 ColumnDefault를 0으로 설정해 놓은 것을 볼 수 있다.

feedRepository.save(Feed.builder()
                .content(request.getContent())
                .user(user)
                .build());

하지만 게시글을 등록하는 서비스 로직을 위와 같이 작성할 경우에는 오류가 발생한다.나는 default가 있고 not null이어야 하는 컬럼에 @ColumnDefault를 쓰면, insert나 create 시에 null로 되었을 때 그 값이 어노테이션에 설정해 둔 default 값으로 바뀌는 줄 알았다. 그리고 그 상태로 DB에 save 하는 줄 알았다. 그런데 그게 아니라, application.yml에서 jpa.hibernate.ddl-auto : create-drop로 설정하고 서버를 구동시켰을 때 생성된 DDL을 보면 그때 default가 적용되어 있는 걸 확인할 수 있다. 즉, 저 어노테이션은 auto-ddl을 사용할 때에 적용되는 것이다.

이를 해결하는 방법으로는 세 가지 방법이 있다.

  1. 직접 위 코드에서 count값을 0으로 지정해준다.
  2. @DynamicInsert, @DynamicUpdate를 사용한다.
  3. @PrePersist, @PreUpdate를 사용한다.

나는 이 중에서 세 번째 방법을 사용하였다.

    @PrePersist
    public void prePersist() {
        this.likeCount = this.likeCount == null ? 0 : this.likeCount;
        this.bookmarkCount = this.bookmarkCount == null ? 0 : this.bookmarkCount;
        this.commentCount = this.commentCount == null ? 0 : this.commentCount;
    }

persist 되기 직전 호출되는 어노테이션으로써 위와 같이 Entity에 추가하면 된다. 두 번째 보다 세 번째 방법을 사용 한 이유는 코드를 보았을 때 조금이나마 더 손쉽게 파악할 수 있을 것 같아서이다.

6. Https 적용

프론트와 API를 연동하기 시작하면서 프론트측에서 API 요청 시 다음과 같은 에러가 나기 시작했다.

메시지만 읽어도 알 수 있는데 현재 프론트는 https로 배포가 되어있고, 서버는 http로 배포가 되어있다 보니 https에서 http로 요청을 보내는 과정에서 오류가 발생하였다. http에서 https로의 요청은 되는데 반대로의 요청은 되지 않는 모양이었다.

이를 해결하는 방법으로는 프론트에서 http로 바꾸거나 서버에서 https로 바꾸는 방법이 있는데 현재 프론트 같은 경우 https://souso.netlify.app/ 이와 같이 netlify.app를 통해 배포가 되어있다 보니 이를 http를 바꿀 수는 없고, 따로 프론트에서 AWS를 구축 후 재 배포하기에는 무리가 있어 보였다. 따라서 서버에서 https를 적용하기로 하였다. 이전에 내가 개인적인 학습을 진행했었을 때 Nginx + Cerbot/SSL을 통한 https 적용을 해본 경험이 있어서 내가 담당했다.

Nginx 설정과 SSL 인증서를 받기 위해 EC2에 접근해야 했는데 나는 지금까지 그냥 mac iterm을 통해서 pem을 불러와 접속했었다. 하지만 팀원분께서 Terminus라는 것을 알려주셨고, 손쉽게 SSH Plaform을 이용 가능한 도구가 있다는 사실을 알게 되었다. Terminus는 계정을 초대할 시 pem을 따로 보내주지 않더라도 손쉽게 접속 버튼 하나로 접근할 수 있고, 여러 개의 SSH를 손쉽게 관리할 수 있다.

7. 일괄 삭제를 위한 OneToMany 연결

현재 최상단에 프로젝터의 DB 테이블을 보았을 때, 게시글의 경우는 좋아요, 북마크, 이미지, 카테고리 등과 같이 서로 FK로 연결되어 제약조건이 걸려 있기 때문에 서비스 로직에서 게시글을 삭제하기 위해서는 순차적으로 연관된 모든 엔티티를 제거해야 한다. 하지만 이 같은 경우 계속해서 제공할 테이블이 늘어날수록 서비스 로직이 더러워지고 확장성 측면에서도 좋지 않기 때문에 게시글을 삭제했을 때 연관된 모든 엔티티들을 자동으로 삭제하기 위해 CascadeType.REMOVE를 게시글 엔티티에서 지정해 주었다. 

8. 지속적인 코드 관리와 개선을 위한 SonarCloud  연동

코드 품질을 지속적으로 관리 및 분석을 통해 향상할 수 있도록 휴먼 적인 방법 외에 어떤 방법이 있을지 고민하다가 Sonar Cloud라는 도구가 있다는 사실을 알게 되었고 이를 도입했다.

 

위 이미지를 보면 알 수 있는데, Build(CI) 시에 Build 및 SonarCloud의 Code Analysis가 시작되며 코드를 분석하게 된다.

결과는 위와 같이 sonarcloud 대시보드에서 확인이 가능한데, 여러 가지 측면에서 A 이상의 품질을 받았을 때만 깃허브 커밋 상태가 체크표시로 변환되도록 설정했다. 위 이미지에서 Maintainability 즉 유지성이 8개 문제가 있다고 나오는데 클릭을 통해 어느 코드들이 문제가 있는지 확인할 수 있다.

보이는 바와 같이 현재 불필요한 import문들이 import 되어 있다는 사실을 SonarCloud를 통해 확인이 가능하며, 이를 통해 코드 품질을 향상할 수 있었다.

 

결론

그 외에도 많은 코드들을 작성하며 느끼거나 배운 점이 많지만 글이 너무 길어지고 의미 없을 것 같아 여기까지만 작성하려고 한다.이번 프로젝트를 진행하면서 JPA, QueryDSL, Java Stream, Redis 등과 같이 이론 지식만 가지고 있었던 지식들을 실전에 접목해 봄으로써 실제로 많이 성장한 것 같다. 그리고 어떻게 패키지를 분리하고 관리해야 깔끔하고 확장성 있게 관리가 가능한지도 알 수 있었다.

 

결과

전체 팀 중에 2등 했다.