프로젝트를 진행하며 입력값을 받을 때 프론트에서도 검증을 하고 보내주겠지만, 비지니스 로직을 보호하기 위해 서버단에서도 검증을 해주는게 안전하다고 배웠다. 그걸 할 수 있게 해주는게 @Valid라고 한다.

 

@Valid란

요청 데이터 유효성 검증을 위해 사용하는 어노테이션

DTO에 선언된 제약 조건을 기반으로 입력값을 검증해줌

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class UserRegisterRequest {
    @NotBlank
    private String username;

    private String name;

    @Size(min = 1, max = 12)
    @NotBlank
    private String password;

    @NotNull
    @Min(1)
    @Max(100)
    private Integer age;

    @Email
    private String email;

    @PhoneNumber
    //@Pattern(regexp = "^\\\\d{2,3}-\\\\d{3,4}-\\\\d{4}$", message = "휴대폰 번호 양식에 맞지 않습니다.")
    private String phoneNumber;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
    @FutureOrPresent
    private LocalDateTime registerAt;

    //해당 메서드가 true 여야 검증이 통과
    @AssertTrue(message = "name or username은 존재해야 합니다.")
    public boolean isNameCheck(){
        if(Objects.nonNull(this.name) && !this.name.isBlank()){
            return true;
        }
        if(Objects.nonNull(this.username) && !this.username.isBlank()){
            return true;
        }
        return false;
    }

}

해당 코드에서

@Blank, @NotNull, @Pattern 등 필드값에 제약 조건을 건다.

그 후 입력값을 받아오는 Controller에서 검증이 필요한 입력값 앞에 @Valid를 사용한다.

@PostMapping("")
    public Api<UserRegisterRequest> register(
            @Valid // 입력값 검증 들어감
            @RequestBody
            Api<UserRegisterRequest> userRegisterRequest
    ) {
        log.info("init : {}", userRegisterRequest);

        var body = userRegisterRequest.getData();
        Api<UserRegisterRequest> response = Api.<UserRegisterRequest>builder()
                .data(body)
                .resultCode(String.valueOf(HttpStatus.OK.value()))
                .resultMessage(HttpStatus.OK.name())
                .build();
        return response;
    }

자주 사용되는 검증 어노테이션 정리

  • @NotNull: 널이 아닌 값
  • @NotBlank: NotNull + 빈문자열, 공백문자열 아닌 값(String)
  • @NotEmpty: String 이외에도 Collection, Array 와 같이 빈 리스트가 아닌 값
  • @Size(min=, max=): 길이 제한
  • @Pattern(regexp=): 정규식 패턴 만족
  • @Email: 이메일 형식
  • @Min/Max(value=): 숫자 타입 값 검증
  • @AssertTrue/False: 해당 메서드가 True 혹은 False여야 됨, 검증로직을 만들 수 있음
  • @Past/Future: LocalDate의 시간이 현재보다 과거/미래 검증

여기서 이 어노테이션은 검증 트리거일 뿐

@Constraint(validatedBy = PhoneNumberValidator.class) //검증 클래스 추가
@Target({ElementType.FIELD})//어디에 적용할거냐
@Retention(RetentionPolicy.RUNTIME) //언제 실행시킬거냐
public @interface PhoneNumber {
    String message() default "핸드 번호 양식에 맞지 않습니다. ex) 000-0000-0000";
    String regexp() default "^\\\\d{2,3}-\\\\d{3,4}-\\\\d{4}$";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

이렇게 커스텀으로 만들 수도 있다.

어노테이션을 만들고, 그 어노테이션을 검증할 클래스를 만들면 해당 어노테이션이 붙은 필드를 검사하는 것이다.

public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {
    private String regexp;

    @Override //정규식 패턴 초기화
    public void initialize(PhoneNumber constraintAnnotation) {
        this.regexp = constraintAnnotation.regexp();
    }

    @Override //입력받은 값과 정규식이 일치하는지 검사하는 메서드
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return Pattern.matches(regexp, value);
    }
}

다른 어노테이션도 동일하게 동작한다.

 

마무리

오늘은 필드 검증을 위한 자주 사용하는 어노테이션을 알아봤고, 어떻게 커스텀 어노테이션을 만드는지도 알아봤다.

검증을 철저히해 잘못된 입력을 받아 로직을 돌다가 에러가 나는 걸 막고,

빠르게 검증으로 막을 수 있어 서버에 부담을 줄일 수 있는 좋은 방식인 것 같다.

기존의 PDF 테이블에는 User 정보가 없었다.

누가 올렸는지 저장을 안하니 List를 줄 수도 없었다.

Domain Pdf에 User 추가

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

User의 id가 Pdf 테이블에 user_id로 들어감

 

이렇게 되면 JWT에서 사용자 정보를 가져와 User 만들고 Pdf 저장 시 User 도 저장 가능해졌다.

다음과 같이 업로드 시 JWT 토큰을 입력해야한다.

 

PdfController

    @GetMapping("/pdf")
    public ApiResponse<List<PdfResponse>> getPdf() {
        User currentUser = SecurityUtil.getCurrentUser();  // 현재 로그인한 유저 가져오기
        List<PdfResponse> data = pdfService.getAllUploadedPdfs(currentUser);
        return ApiResponse.success(data, "해당 유저의 PDF 리스트 입니다.", HttpStatus.OK);
    }

PdfResponse

@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class PdfResponse {
    private Long id;
    private String indexPath;
    private Integer maxQuizCount;
    private LocalDateTime createdAt;

    public static PdfResponse from(Pdf pdf) {
        return PdfResponse.builder()
                .id(pdf.getId())
                .indexPath(pdf.getIndexPath())
                .maxQuizCount(pdf.getMaxQuizCount())
                .createdAt(pdf.getCreatedAt())
                .build();
    }
    public static List<PdfResponse> fromList(List<Pdf> pdfList) {
        // pdflist가 null 이면 pdfList.stream() NPE 발생
        if (pdfList == null || pdfList.isEmpty()) {
            return List.of();
        }
        return pdfList.stream()
                .map(PdfResponse::from)
                .toList();
    }
}

PdfServiceImp

    // 사용자를 받아 해당 사용자의 Pdf -> PdfResponse로 변환 후 반환
    @Override
    public List<PdfResponse> getAllUploadedPdfs(User user) {
        List<Pdf> pdfs = pdfRepository.findAllByUser(user);
        return PdfResponse.fromList(pdfs);
    }

 

이렇게 구현하면 본인의 Pdf를 받아올 수 있다.

 

여기서 만약 업로드한 Pdf 가 없다면 

{
  "success": true,
  "message": "해당 유저의 PDF 리스트 입니다.",
  "data": [],
  "statusCode": 200,
  "timestamp": "2025-08-04T23:38:05.662623"
}

 

다음과 같이 응답함

 

테스트 작성

1. Repository Test - findAllByUser(User user)

    @Test
    @DisplayName("존재하는 유저의 pdf 목록 조회")
    void findAllByUserTest() {
        // given
        User user = userRepository.save(UserFixture.testUser());
        pdfRepository.save(PdfFixture.withUser(user));
        pdfRepository.save(PdfFixture.withUser(user));

        // when
        List<Pdf> pdfs = pdfRepository.findAllByUser(user);

        // then
        assertThat(pdfs).hasSize(2);
    }

2. Service Test - getAllUploadedPdfs(User user)

    @Test
    @DisplayName("pdf 리스트 테스트")
    void pdfListTest(){
        //given
        User user = UserFixture.testUser();
        List<Pdf> mockPdfs = List.of(
                // repo에서 2개 가져왔다고 생각
                PdfFixture.withUser(user),
                PdfFixture.withUser(user)
        );

        // findAllByUser 호출되면 mockPdfs 반환
        when(pdfRepository.findAllByUser(user)).thenReturn(mockPdfs);

        // when
        List<PdfResponse> result = pdfService.getAllUploadedPdfs(user);

        //then
        assertThat(result).hasSize(2);
        assertThat(result.get(0).getIndexPath()).isEqualTo(mockPdfs.get(0).getIndexPath());
        //findAllByUser가 정확히 한 번 호출되었는지 검증
        verify(pdfRepository).findAllByUser(user);
    }

    @Test
    @DisplayName("pdf 리스트 테스트")
    void emptyPdfListTest(){
        // given
        User user = UserFixture.testUser();

        when(pdfRepository.findAllByUser(user)).thenReturn(List.of());

        // when
        List<PdfResponse> result = pdfService.getAllUploadedPdfs(user);

        // then
        assertThat(result).isEmpty();
        verify(pdfRepository).findAllByUser(user);
    }

마무리

이제 해당 PDF의 상세보기 API를 만들어야한다. 이건 간단하게 만들테니 쉬울거고,

이게 끝나면 본격적으로 Quiz를 AI서버에 요청하고, 응답을 파싱해 DB에 저장하고 나눠서(SSE) 프론트로 전달해줘야한다.

이 부분이 가장 어려울 거 같다.

프로젝트 진행하며 Pdf 정보를 응답으로 보내야 했다.

별도의 PdfResponse를 만들고 Builder 패턴으로 만들었다.

또한 Domain Pdf를 활용해 응답을 만들어야 했다.

그렇기에 다음과 같이 정적 팩토리 메서드를 활용했다.

public static PdfResponse from(Pdf pdf) {
    return PdfResponse.builder()
            .id(pdf.getId())
            .indexPath(pdf.getIndexPath())
            .maxQuizCount(pdf.getMaxQuizCount())
            .createdAt(pdf.getCreatedAt())
            .build();
}

 

이렇게 사용하면 좋다고 해서 사용하는데 왜 그런지 자세히 알아보자

 

먼저 정적 팩토리 메소드는 디자인 패턴이 아니다.

객체 생성 기법 중 하나이다.

생성자를 대체할 방법 중 하나

 

왜 만들어졌나

생성자는 제약이 많고, 유연성이 부족

  • 생성자는 클래스 명으로 메서드를 만들어야해 오버로딩 시 구분 어려움
  • 항상 새 객체 생성하기에 재사용 어려움
  • 하위 타입 반환 어려움

이러한 제약을 정적 팩토리 메소드는 해결할 수 있다.

여러 방식이 있어서 아래의 장점을 모두 가지지는 않는다.

방식마다 아래의 장점 중 일부를 가지게 된다.

  • 이름 부여
  • 객체 재사용 가능
  • 하위 타입 반환 가능
  • 생성자보다 유연한 오버로딩
  • 불필요한 부분 가리는 캡슐화

이름 부여

생성자는 모두 같은 이름을 사용해야해 구분이 힘들었다.
하지만 정팩메는 메서드 이름으로 의미를 알 수 있다.

또한 대표적인 네이밍 패턴으로 구분하기 편하다.

메서드 이름 의미 예시
of(...) 값 기반 간단한 생성 User.of(name, age)
from(...) 다른 객체나 타입 → 변환 UserDto.from(User)
valueOf(...) 문자열 등에서 객체 변환 Enum.valueOf("ACTIVE")
getInstance() 싱글턴, 캐시 객체 반환 Logger.getInstance()
newInstance() 매번 새로운 객체 생성 UUID.newInstance()
create() / build() 생성 과정 복잡할 때 Order.create(...)

 

그 전에 봤던 건 getInstance()로 싱글턴 패턴 구현할 때 사용했었다.

내가 이번에 사용한 메서드 이름은 from이다. 이건 다른 객체을 변환해 별도의 객체로 만드는 작업이다.

이러한 방식은 생성자 오버로드를 별도로 할 필요 없이 사용할 수 있고, 빌더를 내부로 숨겨 캡슐화 할 수 있었다.

이 접근은 생성자 오버로드를 줄이고, 내부적으로 builder를 사용하되 외부에 노출하지 않음으로써
객체 생성 로직을 캡슐화하고 코드의 명확성과 일관성을 높일 수 있었다.

 

이러한 정팩메도 단점은 존재한다.

단점

  • 제대로된 정팩메를 위해 생성자를 가려야 한다(private, protected) -> 상속에서 힘듦
  • new 키워드처럼 직관적이지 않음
  • 문서 없으면 숨겨져 있어서 사용 힘듦

상속이 힘든 부분은 생성자도 같이 사용해 어느 정도 완화할 수는 있다.

목적에 맞게 사용하면 되기 때문

 

마무리

오늘은 프로젝트를 진행하며 정팩메라는 것을 알게 되었고, 왜 사용하는지, 언제 사용해야 되는지 알아봤다.

일단 이번 상황에서는 객체를 변환하는데 사용되었지만, 다른 경우로 사용될 때도 그때 그때 정리하면서 완성해 나가야겠다.

시스템 설계 면접 스터디도 마지막 주차가 되었다.

오늘은 구글 드라이브를 설계해보는 상황으로 진행한다.

구글 드라이브와 같이 드롭박스, 원드라이브, iCloud 와 같은 클라우드 저장소 서비스에 대한 설계이다.

문제 이해 및 설계 범위 확정

기능적 요구사항

  • 기능: 파일 업로드/다운로드, 파일 동기화, 알림
  • 파일 암호화 제공
  • 제한은 10GB
  • DAU: 천만명

비기능적 요구사항

  • 안정성
  • 빠른 동기화 속도
  • 네트워크 대역폭
  • 규모 확장성
  • 높은 가용성

개략적 추정치

  • 가입자 5천만명, DAU 1천만명
  • 모든 사용자에게 10GB 제공 → 500페타바이트 필요
  • 평균 500KB, 하루에 2개
  • 읽기 쓰기 비율은 1대1
  • 업로드 API QPS = 약 240
  • 최대 QPS = 약 480

개략적 설계안 제시 및 동의 구하기

필요한 구성요소

  • 웹 서버
  • 메타데이터 데이터베이스
  • 파일 저장소

웹 서버

3개의 API를 제공해야한다.

업로드, 다운로드, 히스토리

  • 앞단의 로드밸런서를 두어 대처

메타데이터 DB

파일이 아닌 해당 파일의 정보를 저장한다.

속도 측면을 위해 캐시를 앞 단에 둘 수 있다.

파일 저장소

파일 저장소의 경우는 확장성과 안정성을 위해 클라우드 서비스의 S3와 같은 저장소를 사용한다.

여기서 지능형으로 사용을 안하는 데이터는 글랜시어와 같은 아카이브 저장소로 보내는 등 티어를 나눌 수 있다.

동기화 문제

이러한 경우 여러 클라이언트가 하나의 파일에 대한 수정을 하려할 수 있다.

그럴 때 발생하는게 이 문제이다.

동시에 작업을 해도 서버가 받아들이는 순서로 나중 것은 충돌이 발생했다고 알려줘야한다.

이 때 이름이 사본을 만들어서 작업한 것이 사라지지 않게 하는것도 중요하다.

계략적 설계안

  • 블록 저장소 서버
    • 하나의 파일을 여러 개로 나눠 저장 → 네트워크 대역폭 적게 차지
  • 클라우드 저장소
  • 아카이빙 저장소
  • 로드밸런서
  • API 서버
  • 메타데이터 DB
  • 메타데이터 캐시
  • 알림 서비스: 클라이언트에게 파일이 추가, 수정, 삭제 되었다는 정보를 pub/sub 방식으로 전달
  • 오프라인 사용자 백업 큐: 파일이 달라진 부분을 수정하기 위해 큐에 담아두고, 클라이언트 접속 시 적용

상세 설계

블록 저장소 서버

큰 파일을 한 번에 건들면 네트워크 대역폭을 많이 먹게 된다.

이를 최적화 하기 위해 블록 단위로 저장해

수정된 일부 블록만 수정한다. → 델타 동기화 전략

블록 단위로 압축해 관리한다.

이렇게 하기 위해 새로운 파일이 들어오면 먼저 블록 저장소 서버에서 해당 파일을 일정 크기로 분할하고 ID를 부여한다. 나중에 이 블록들을 순서대로 묶어 사용하면 된다.

높은 일관성 요구사항

이 시스템은 강한 일관성을 제공해야한다.

즉 누가 언제 열어도 모두 똑같은 걸 봐야한다.

메모리 캐시는 일반적으로 결과적 일관성을 보장한다.

일관성이 깨질 수 있지만, 언젠가 제대로 보여준다는 것이다.

우리가 원하는 건 강한 일관성이니 다음을 만족해야한다.

  • 캐시에 보관된 사본과 DB에 원본이 일치해야한다
  • DB에 원본에 변경이 발생하면 캐시는 무효화 되어야 한다.

이걸 RDBMS에 저장한다면 ACID원칙으로 강한 일관성 보장이 쉽지만,

NoSQL의 경우 기본적으로 지원 안해서 동기화 로직 안에 프로그램해 넣어야 한다.

그렇기에 메타데이터 디비는 RDBMS 사용됨

업로드 절차

이제 가장 중요한 업로드/다운로드 설계를 보겠다.

사용자가 파일을 올리면 어떤 일이 벌어지는지 살펴보면, 먼저 블록 저장소 서버에서 분할 후 저장소에 저장된다.

메타데이터 저장과 파일 저장이 병렬적으로 수행된다.

  • 파일 메타데이터 추가
    • 클라이언트 1이 새 파일의 메타데이터를 추가하기 위한 요청 전송
    • 새 파일의 메타데이터를 데이터베이스에 저장하고 업로드 상태를 대기중으로 변경
    • 새 파일이 추가되었음을 알림 서비스에 통지
    • 알림 서비스는 관련된 클라이언트에게 파일이 업로드되고 있음을 알림
  • 파일을 클라우드 저장소에 업로드
    • 클라이언트 1이 파일을 블록 저장소 서버에 업로드
    • 블록 단위로 나눠 압축 + 암호화 진행 후 클라우드 저장소로 전송
    • 완료 시 콜백으로 API 서버로 전송
    • 업로드 상태를 완료로 변경
    • 알림 서비스에 파일 업로드 끝이라고 통지
    • 알림 서비스 관련 클라이언트는 끝났음 알림을 받음

다운로드 절차

새로 추가되거나 편집되면 자동으로 시작된다.

그렇게 하기 위해서는 변경을 감지해야한다.

  • 클라이언트 접속 중일 때는 알림 발생으로 새로운 버전 가져오기
  • 접속 중이 아니면 변경이 되었다는 데이터가 캐시에 보관되고(큐) 접속 시 해당 변경 가져옴

이렇게 변경을 알았다면 클라이언트는 먼저 API를 보내 메타데이터를 받아와야한다.

그 후 새롭게 변경된 블록을 가져와 최신화 해야한다.

  • 알림 서비스가 파일이 변경되었다고 알림
  • 새로운 메타데이터 요청
  • 해당 데이터로 블록 다운로드 요청
  • 클라우드에서 새로운 블록 다운
  • 파일 재구성

알림 서비스

파일의 일관성을 유지하기 위해, 클라이언트는 로컬에서 파일이 수정되었음을 감지하는 순간 다른 클라이언트에 그 사실을 알려서 충돌 가능성을 줄여야 한다. 알림 서비스는 그 목적으로 이용된다.

이를 구현하기 위해

  • 롱 폴링
  • 웹 소켓
  • SSE

방식이 있는데 알림은 일방향 통신만 되면 되기에 웹소켓은 불필요하다.

저장소 공간 절약

클라우드 서비스를 사용하기에 용량은 곧 돈이다.

최대한 절약해야한다.

  • 중복 제거: 동일한 블록을 계정단위로 제거하는 것 → 해시 값 비교로 간단하게 구현
  • 지능적 백업 전략
    • 버전 개수의 한도를 두기
    • 중요 버전만 보관
  • 자주 쓰지 않는 건 아카이빙 저장소에 넣기

장애 처리

이런 여러 컴포는트가 들어가는 서비스는 장애 처리에 있어서 각 컴포넌트들 모두를 신경써야 한다.

  • 로드밸런서 장애
    • 부 로드밸런서를 두고 헬스체크를 주기적으로
  • 블록 저장소 서버 장애
    • 다른 저장소 서버가 미완료 파일 대신 처리 로직
  • 클라우드 저장소 장애
    • 지역 다중화
  • API 서버 장애
    • 무상태성으로 설계해 다른 서버로 로드밸런싱
  • 메타데이터 캐시 장애
    • 캐시 서버 다중화
  • 메타데이터 DB 장애
    • 마스터 슬래이브 구조
  • 알림 서비스 장애
    • 서버 다중화 → 롱 폴링 재연결은 시간이 많이 듬
  • 오프라인 사용자 백업 큐 장애
    • 큐 다중화

마무리

구글 드라이브를 설계하면서

  • 파일 나눠 저장
  • 업로드/다운로드 절차
  • 알림 서비스 적용

등을 알아봤다.

지금까지 15장의 다양한 시스템을 알아봤는데

가장 중요한건 다중화 같다.

하나의 시스템의 SPOF을 두지 않기위해서는 다중화 말고는 답이 없다는 걸 알게 되었다.

또한 컴포넌트 간의 결합도가 낮은게 좋다는 점 그렇기에 각 사이에 메시지 큐를 두어 비동기로 처리 가능하게 두는 것도 중요한 점이었다.

트랜스코딩 DAG 모델의 장점은 무엇이고, 왜 유튜브 수준에서는 DAG가 필요할까요?

트랜스코딩 작업은 단순히 한 번의 인코딩 작업이 아니라, 워터마크 삽입, 썸네일 추출, DRM 적용, 다양한 해상도 인코딩 등 서로 다른 종속성을 가진 작업들의 집합입니다. DAG(Directed Acyclic Graph) 모델은 이러한 작업 흐름을 유연하게 정의하고 병렬/비병렬 처리를 명확하게 분리해줄 수 있습니다.

트랜스코딩 서버의 장애가 발생하면 어떤 영향이 발생하고, 어떻게 복구 설계할 수 있나요?

서버가 중단되면 해당 작업이 중단되거나 지연됩니다. 이를 방지하기 위해,

  • GOP를 나눠 작은 단위로 처리하며
  • 임시 저장소에 저장해 놓고 오류 시 재사용할 수 있게 설계했습니다.
  • 서버 장애 시에는 DAG 스캐줄러가 다른 작업 서버에 작업 배정

업로드 URL을 미리 사인(pre-signed URL) 방식으로 발급하는 이유는? 어떤 보안 이슈가 있을 수 있나요?

Presigned URL은 서버가 인증된 사용자를 대신해 일정 시간 동안 유효한 업로드 권한을 위임한 URL입니다. 이를 사용하면:

  • 클라이언트가 직접 S3나 Blob 스토리지에 업로드 가능 (서버 부하 감소)
  • API 서버는 파일 경로와 메타데이터만 관리하면 됨

보안 이슈로는

  • URL이 노출되면 제3자 업로드 가능
  • URL에 제한 조건(만료시간, content-type, 파일 크기 등) 필수
  • HTTPS 미사용 시 URL 탈취 가능

비디오 조회/재생 요청 처리에서 Redis 메타데이터 캐시는 어떤 방식으로 invalidate 혹은 갱신하나요?

Write-through 방식을 통해 메타데이터 변경 시 DB와 캐시 동시 갱신하거나 TTL 방식을 추가해서 주기적 갱신을 합니다. 또한 트랜스코딩 완료 핸들러가 DB와 캐시에 함께 업데이트합니다.

AES 암호화와 DRM(Digital Rights Management)은 어떤 차이가 있고, 유튜브에서는 각각 어떤 상황에서 사용될까요?

AES는 대칭키 방식의 일반적인 데이터 암호화 방식으로 전송, 저장 중 비디오를 보호합니다.

DRM은 클라이언트에서 복호화/재생 권한을 제어하는 시스템입니다.

저작권 보호가 필요한 콘텐츠의 경우 DRM을 통해 보호합니다.

트랜스코딩 중단 시 자동 재시도 로직 설계는 어떻게 하나요? 실패 감지는 어디서?

각 작업은 작업 큐에 등록되고 작업관리자가 작업 상태를 주기적으로 확인합니다.

정해진 시간 안에 응답 없으면 Timeout 으로 실패처리 하고

Retry 큐에 등록되며 GOP 단위로 재처리 됩니다.

이렇게 하나의 영상을 나눠 병렬 처리하여 오류 대처를 합니다.

이번 스터디 주제는 유튜브 설계이다.

저번 세미 프로젝트로 넷플릭스와 같은 스트리밍 서비스 설계를 했었다.

유튜브 설계는 거기다 사용자가 업로드 할 수 있게 한 서비스 같다.

문제 이해 및 설계 범위 확정

  • 업로드 및 스트리밍
  • 앱, 웹, 스마트 TV 지원
  • DAU 500만명, 평균 소비 시간 30분
  • 해상도는 대부분 지원
  • 암호화 필수
  • 파일크기 1GB 최대
  • 클라우드 서비스 쓰면 좋음

이러한 상황에서 설계 시 중요한 점은

  • 빠른 비디오 업로드
  • 원활한 비디오 재생
  • 재생 품질 선택 가능
  • 낮은 인프라 비용
  • 높은 가용성과 규모 확장성, 안정성

개략적 규모 추정

  • DAU: 500만명
  • 평균 1명당 5개 비디오 시청
  • 평균 10% 사용자가 하루에 1개의 비디오 업로드
  • 평균 영상 크기 300MB
  • 500만 * 1/10 * 300MB = 150 TB
  • 하루 CDN 비용은 아마존 미국 리전 기준 스트리밍 비용
    • 500만 * 5 * 0.02 * 0.3GB = $150

개략적 설계안

여기서 Blob 스토리지(Binary Large Object), CDN은 클라우드 서비스를 이용할 것이다.

크게 두 영역을 설계하면 된다.

  • 비디오 업로드 절차
  • 비디오 스트리밍 절차

비디오 업로드 절차

필요 컴포넌트

  • 사용자
  • 로드밸런서
  • API서버: 스트리밍 이외 요청 처리
  • 메타데이터 DB: 샤딩과 다중화
  • 메타데이터 캐시
  • 원본 저장소
  • 트랜스코딩 서버: 다양한 해상도로 변환
  • 트랜스코딩 비디오 저장소
  • 트랜스코딩 완료 큐: 변환 완료 이벤트 저장
  • 트랜스코딩 완료 핸들러: 변환 완료 상태 DB, 캐시에 갱신
  • CDN: 트랜스코딩 된 영상 캐시역할
[1] 사용자
     │
     ▼
[2] 로드밸런서
     │
     ▼
[3] API 서버
     │
     ├─▶ [4] 메타데이터 DB         ← 사용자, 제목, 태그 등 저장
     │
     ├─▶ [5] 메타데이터 캐시       ← 업로드 완료 후 빠른 조회용 (ex. Redis)
     │
     └─▶ [6] 원본 저장소 (S3/Blob) ← 업로드된 비디오 원본 저장
     │
     ▼
[7] 트랜스코딩 서버
     ▲        │
     │        └─▶ [8] 트랜스코딩 비디오 저장소 ← 여러 해상도로 변환 후 저장 (e.g. 1080p, 720p)
     │
     ▼
[9] 트랜스코딩 완료 큐 (e.g. SQS, Kafka)
     │
     ▼
[10] 트랜스코딩 완료 핸들러
      │
      ├─▶ [4] 메타데이터 DB 업데이트
      │
      └─▶ [5] 메타데이터 캐시 업데이트
      │
      ▼
[11] CDN에 등록 (e.g. CloudFront, Akamai)

비디오 스트리밍 절차

사용자가 비디오를 클릭한 순간 빠르게 비디오를 출력해줘야한다.

여기서 스트리밍이란 영상 전부가 아닌 영상 데이터를 스트림으로 지속적으로 받는 것이다.

이걸 위한 프로토콜들이 있다.

  • MPEG-DASH
  • HLS
  • 마이크로소프트 스무드 스트리밍
  • 어도비 HTTP 동적 스트리밍

비디오는 CDN에서 바로 스트리밍된다. 지역적으로 가까운 CDN 에지 서버가 담당할 것이다.

상세 설계

비디오 트랜스코딩

모두가 다 다른 포맷으로 저장한다. 이걸 통일 다른 단말에도 재생하기 위해서 호환되는 비트레이트와 포맷으로 저장해야 한다.

즉 다양한 화질로 인코딩이 필요하고, 이걸 사용자가 선택, 혹은 사용자의 환경에 맞춰 자동 설정해줘야 한다.

인코딩 포맷은 크게 두가지로 이뤄져있다.

  • 컨테이너: 비디오 파일, 오디오, 메타데이터를 담는 바구니(.avi, .mov, .mp4)
  • 코덱: 비디오 압축 및 압축 해제 알고리즘(H.264, VP9, HEVC)

유향 비순환 그래프(DAG) 모델

트랜스코딩은 컴퓨팅 자원을 많이 소모할 뿐만 아니라 시간도 많이 소요되는 작업이다.

그리고 다양한 사용자의 요구사항도 충족시켜야한다.

  • 워터마크 넣기
  • 썸네일 추출
  • 검사
  • 인코딩

이렇게 다양한 비디오 프로세싱 파이프라인을 지원하면서, 병렬적으로 처리할 수 있게하려면 사용자에게 작업을 손수 정의할 수 있도록 해야 한다.

원본에서 비디오, 오디오, 메타데이터를 추출하고

각 형식에 맞는 정의된 작업들을 수행하고 병합하면 된다.

비디오 트랜스코딩 아키텍처

5가지 컴포넌트로 이뤄져있다.

  • 전처리기
    • 비디오 분할: 비디오 스트림을 GOP(Group of Pictures)라는 단위로 쪼갠다.
    • DAG 생성: 클라이언트 프로그래머가 작성한 설정파일에 따라 DAG를 만든다.
    • 데이터 캐시: 안정성을 위해 GOP들과 메타데이터를 임시 저장소에 보관한다. 실패 시 다시 꺼내서 시도할 수 있다.
  • DAG 스케줄러
    • 만들어진 DAG를 몇 단계로 나눠 작업관리자의 작업 큐에 넣는다.
    • 1단계: 비디오 오디오 메타데이터 추출
    • 2단계: 인코딩과 같은 작업들
  • 자원 관리자
    • 자원 배분을 위해 세 개의 큐와 작업 스케줄러로 구성됨
    • 작업 큐: 실행해야할 작업
    • 작업 서버 큐: 작업 서버의 가용 상태 정보가 보관된 큐
    • 실행 큐: 현재 실행 중인 작업 및 작업 서버 정보가 보관된 큐
    • 동작
      1. 작업 큐에서 작업 꺼내기
      2. 적절한 작업 서버 고르기
      3. 작업 서버에 지시
      4. 해당 정보 실행 큐에 넣기
      5. 완료 시 실행 큐에서 제거
  • 작업 실행 서버
    • 각 작업에 맞는 그룹으로 서버를 만들어 놓는다.
  • 임시 저장소
    • 메타데이터나 GOP를 캐시해두고, 작업 실패 시나 작업 시 이걸 참조해서 작업

시스템 최적화

  • 속도 최적화
    • 비디오 병렬 업로드
      • GOP로 분할
      • 업로드 센터를 사용자 근거리로 지정
    • 모든 절차를 병렬화: 분할된 영상 단위로 처리할 수 있게 또한 메시지 큐를 통한 결합도 낮추기
  • 안정성 최적화
    • 미리 사인된 업로드 URL
    • 비디오 보호
      • 디저털 저작권 관리
      • AES 암호하
      • 워터마크
  • 비용 최적화
    • 인기 비디오만 CDN 나머지는 비디오 서버를 통해
    • 인기 없는 건 인코딩 안하고 있다가 요청 시 인코딩(짧은 경우)
    • 특정 지역에만 인기 있는 비디오와 아닌걸 다르게 처리
    • CDN 직접 구축

오류 처리

  • 회복 가능 오류: 트랜스코딩 중 GOP 하나가 실패 시 임시 저장소에서 꺼내서 다시 하는 것 등
  • 회복 불가능 오류: 사용자가 이상한 파일 올렸을 경우 등

최대한 재시도 해보고 안되면 적절하게 안내를 해줘야 한다.

마무리

스트리밍 같은 경우는 CDN을 통해 쉽게 구현했다.

반면 업로드 부분은 트랜스코딩이라는 중요한 작업 때문에 DAG와 같은 모델을 사용해 작업해야했다.

오류가 생긴다면 트랜스코딩 부분이 가장 많을 것 같다.

추가 논의 사항

  • 무상태성 API 서버 확장
  • DB 계층 샤딩과 다중화 방안
  • 라이브 스트리밍 추가 논의
  • 비디오 삭제 로직

테스트 단계에서는 http://quzgn.site를 포함한 OAuth2 인증 흐름이 가능했지만,
운영 환경 전환을 위해 HTTPS만을 사용하는 구조로 변경하고자 했습니다.
이를 위해 모든 OAuth2 관련 URI가 반드시 HTTPS 상에서 동작해야만 하는 구조로 전환이 필요했습니다.

목표

 

  • HTTP 요청은 모두 HTTPS로 리디렉션
  • Spring Boot에서 baseUrl 추정 제거, 직접 https://quzgn.site로 고정
  • OAuth2 인증/인가 흐름에서 Google이 요구하는 redirect-uri 형식과 완전 일치

 

OAuth2 과정

사용자 → 브라우저에서 로그인 버튼 클릭
        ↓
<https://quzgn.site/oauth2/authorization/google> (OAuth2 인증 요청 시작)
        ↓
[Nginx] SSL 종료 & 리버스 프록시
    - https 요청 수신
    - 내부로  로 전달
    - X-Forwarded-Proto: https 헤더 포함
        ↓
[Spring Boot] OAuth2 로그인 흐름 시작
    - X-Forwarded-Proto를 보고 baseUrl 계산 → redirect-uri 생성
    - 생성된 redirect-uri: <https://quzgn.site/login/oauth2/code/google>
        ↓
Google OAuth2 서버로 리디렉션
    - redirect-uri 포함해서 구글 로그인 페이지로 이동
        ↓
사용자 로그인 완료 후 Google이 redirect-uri로 인가 코드 전달
    - → <https://quzgn.site/login/oauth2/code/google?code=xxx>
        ↓
[Nginx] again SSL 종료 후  로 프록시
        ↓
[Spring Boot] 인가 코드 수신 후 AccessToken 요청
    - AccessToken + 사용자 정보 받아와서 로그인 처리

 

과정

 

1. Nginx HTTP -> HTTPS 리디렉션 설정

server {
    listen 80;
    server_name quzgn.site www.quizgen.site;
    return 301 https://$host$request_uri;
}

http 로 오는 요청을 모두 https로 리다이렉션하게 설정

 

2. Nginx 443 블록(SSL + 프록시 + 헤더 전달)

server {
    listen 443 ssl http2;
    server_name quzgn.site www.quzgn.site;

    # SSL 인증서
    ssl_certificate ;
    ssl_certificate_key ;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
		
		#다른 로케이션...
    
    # OAuth2 인증 관련 요청
    location ~* ^/(oauth2|login/oauth2)/.* {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # React/Vue 프론트엔드 라우팅 대응
    location / {
        root /var/www/html;
        index index.html index.htm;
        try_files $uri $uri/ /index.html;
    }
}

이렇게 443 요청이 오면 해당 uri 보고 location에 맞는 곳으로 프록시를 해준다.

즉 https → http 내부 프록시 url로

우리는 nginx 와 백엔드 서버가 같이 있기 때문에 localhost:8080으로 프록시 해준다.

이때 기존의 헤더를 넘겨줘야하는데 그부분이 proxy_set_header로 넣어준다.

  • Host: 원래 클라이언트가 요청한 도메인
  • X-Real_IP: 클라이언트의 실제 IP 주소
  • X-Forwarded-For: 프록시 체인을 포함한 클라이언트 IP
  • X-Forwarded-Proto: 원래 요청이 HTTP였는지 HTTPS였는지

이렇게 헤더에 넣어서 Spring으로 보내는 것

여기서 http로 올 텐데 그걸 nginx가 헤더를 넣은 것들이 있다.

다음 처럼 yaml파일에 추가해서 Spring이 넘어오는 헤더를 믿고 사용하도록 설정해야한다.

3. Spring Boot application.yaml 설정

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id:
            client-secret:
            redirect-uri: "https://quzgn.site/login/oauth2/code/{registrationId}"

 

승인된 리디렉션 URI

다음과 같은 uri를 구글 클라우드에서 허용해줘야한다.

https://quzgn.site/login/oauth2/code/google

그렇지 않는다면 400 redirect mismatch 에러가 되면서 액세스가 거부된다.

사용자가 구글로그인을 하고, 제공할 정보에 동의를 하면 우리가 설정한 저 rediect-uri로 구글이 리다이렉트 해준다.

Spring boot는 저렇게 넘어오는 코드를 내부적으로 엑세스 토큰으로 교환 후 사용자 정보를 가져오는 것이다.

 

이 부분에서 문제가 발생했는데 바로 baseUrl 관련된 문제이다.

기존에는 {baseUrl} 이렇게 외부에서 받아 url을 만들었는데 https로 바꾸고 나니 ngnix에서 제대로 전달해주지 못하는 것이었다.

처음 http를 승인된 리디렉션 uri에 넣어서 했을 때는 됐지만 https만 허용하니 400에러가 났던 것이다.

생각해 보니 Spring에서 http로 계속 리다이렉션을 해주고 있던 것이었다.

그래서 baseUrl을 빼고 https://quzgn.site 넣어서 https로 리다이렉션 받도록 했다.

 

이 부분이 조금 미흡하긴 하지만 추후에 baseUrl을 사용해서 받을 수 있게 고쳐봐야 겠다.

 

 

 

 

 

 

오늘 구로에 있는 어느 회사에 면접을 보고 왔다.

1. 기업분석

2. 자기소개 작성

3. 지원동기 작성

4. 입사포부

5. 커리어 플랜

이 정도 준비하고 갔다.

면접을 보기 전에 GPT에게 해당 기업에 궁금한 점을 쭉 적고

리서치 기능으로 뽑으면 10장 정도되는 리포트가 나오는데 그걸 읽으면

기업분석은 한 방에 되고 지원동기 작성은 쉽게 할 수 있었다.

처음으로 그룹면접을 진행했는데
모르는 분들과 나 포함 4명이 면접을 봤고, 3명의 면접관님이 진행했다.

 

간단 자기소개, 앉은 자리 순으로 이력서 기반 질문으로 면접 진행했고, 각자 10~20분 정도 씩 한 것 같다.

그 후 정보처리기사 문제 같은 필기 시험을 본다. 30분 정도? 먼저 다 풀면 가는 느낌

그리고 면접비 1만원도 챙겨주신다.

 

 

이번 면접에서 얻은 점

  • 그룹면접 첫 경험
  • 이력서 기반 질문 중 DB 쪽 더 철저하게 준비
  • 관련 문서화 한 것들 정리하기

질문 리스트

자기소개

내 질문 차례

  • 오는데 얼마나 걸렸나
  • 왜 교육을 두 번 들었나
  • 백엔드 인프라 둘 중 어디가 맞는거 같나
  • 보통 인프라는 제공되어서 다룰일이 크게 없는데 괜찮은가
  • IaaS PaaS 차이가 뭔가

내 프로젝트 집중 검증

  • 기억나는 가장 복잡한 쿼리는 뭔가 -> 크게 복잡한 쿼리가 없었다고 했고, 테이블 조인 하는거
  • finterst 테이블 몇 개 였나 -> 20개 정도로 대답했다.
  • 왜 erd 같은게 없나요 readme에
  • 리눅스 마스터 2급 있던데,  복사할 때 디렉터리 하위도 같이 복사 명령어 뭔지 아나요
  • 온프레미스에서 뭘 했나
  • 면접 몇 번 봤나, 다른데 붙는다면 어쩔 것인가

다른 분 한테 한 질문

  • 카프카 왜 썼냐(기술 선택에 고민을 했나)
  • 이력서에 있는 내용(계속해서 고민하는게 개발 철학이다.) -> 프로젝트가 끝난 후 추가적으로 어떤 고민을 했나
  • 도커 다뤘다는데 아는 명령어 하나

 

공통질문

이전 버전 자바나 신 기술 적용은 힘들 수 있는데 괜찮냐

 

필기시험

  • Http/s 차이
  • JavaScript, if문에서 선언된 변수 외부에서 출력 시 
  • XSS SQL Injection 뭔지
  • Outer join sql문 작성
  • 기본형 참조형 비교(int, String)출력
  • 마지막 이중 for문 별찍기 출력 적기 -> 제대로 못 품
  • display:none , 뭐 있던데 기억이 안남 이게 아마 프론트 쪽 내용 같은데 모르겠어서 못적음

 

마지막 궁금한 점 질문

  • 디비 어떤 거 주로 사용하요 -> 오라클, Cubrid
  • 야구 동호회 관련 질문 -> 동호회는 입사 후 몇 개월 이후부터 가능하다고 하고, 야구 동호회는 잠깐 정비 중인 듯 하다.

+ Recent posts