Navigation

ARTIST PROP

아티스트가 이미지와 파일, 약력 같은 포트폴리오 자산을 관리하고 구독 플랜에 따라 저장 용량을 쓰는 플랫폼입니다. 개발자 6명이 헥사고날 구조(포트와 어댑터)의 Spring Boot API를 함께 만들었고 저는 이메일 인증, 파일 자산, 구독 결제와 관리자 결제내역 API를 설계하고 구현했습니다. 프론트엔드에서는 사용자 웹과 운영 백오피스가 함께 쓰는 공통 구조를 검토하고 방향을 제시했습니다.

핵심 기여

과제본인 기여확인한 동작
이메일 인증과 계정 복구RSA 암호화 링크로 구현한 뒤 서버 저장 검증으로 전환인증 종류별 번호와 토큰, 만료 시각을 서버에서 대조
파일 자산의 등록과 삭제저장, 경로 복사, 재저장 순서와 소유자 검사 연결파일 id가 들어간 저장 경로, NFC로 맞춘 파일명
외부 결제와 구독 상태결제 이력 저장과 구독 갱신 분리, 결제사별 예외 계층성공한 결제만 구독에 반영, 결제사와 단계별 결과 코드
관리자 결제내역Querydsl 동적 조건 검색목록과 전체 건수가 같은 조건을 사용
트랜잭션 경계와 검증읽기 전용 트랜잭션, 외부 연동을 모킹한 통합 테스트실제 유스케이스와 H2 DB 위에서 파일과 구독 흐름 실행

개요

항목내용
기간2024.05 ~ 2024.08
역할백엔드 API 설계와 구현, 프론트엔드 공통 구조 검토
협업과 인원개발자 6명
기술Java, Spring Boot, Spring Security, Spring Data JPA, Querydsl, AWS S3, AWS SES, PortOne, NaverPay, Spring Boot Test, H2
링크ARTIST PROP

맡은 범위

문제와 해결

코드는 저장소의 본인 커밋에서 발췌했습니다. 키와 설정값, 내부 주소는 넣지 않았고 긴 인자 목록은 줄바꿈만 조정했습니다.

이메일 링크의 상태를 링크에 둘지 서버에 둘지 정하기

회원가입 인증번호와 이메일 찾기, 비밀번호 재설정 메일을 AWS SES로 보내야 했습니다. 처음 구현한 86528ee에서 가입 인증번호는 SecureRandom으로 만든 6자리 번호를 DB에 저장해 대조했습니다. 비밀번호 재설정 링크에는 이메일과 만료 시각을 2048비트 RSA 개인키로 암호화한 값을 담았습니다. 링크가 만료 시각을 직접 들고 다니는 방식이었습니다.

86528ee: 이메일과 만료 시각을 암호화해 링크에 담는다java
public static String generateVerificationLink(String email, String privateKeyString) {
    long expiryTime = System.currentTimeMillis() + LINK_EXPIRY_DURATION;
    String payload = String.format("{\"email\":\"%s\",\"expiry\":%d}", email, expiryTime);

    try {
        return BASE_VERIFY_URL + encryptWithPrivateKey(payload, privateKeyString);
    } catch (Exception e) {
        throw new LinkGenerationException(e);
    }
}

나흘 뒤 ce8e1dd에서 이 방식을 서버 검증으로 바꿨습니다. 커밋에는 “RSA 방식에서 서버 검증 방식으로 변경”이라는 사실만 있고 바꾼 이유는 기록되어 있지 않습니다. 여기서는 두 구현을 나란히 놓고 코드에서 보이는 차이만 정리했습니다.

RSA 구현에서는 개인키를 애플리케이션 설정으로 주입하고 키쌍 파일과 암복호화 유틸리티, 그 테스트를 함께 관리해야 했습니다. 재설정 요청이 유효한지는 이메일만 저장하는 별도 테이블로 확인했기 때문에 만료 시각은 링크 안에, 요청 여부는 DB에 나뉘어 있었습니다. 서버 검증으로 바꾼 뒤에는 재설정 링크에 UUID 토큰만 담고 이메일, 코드, 만료 시각을 한 행에 저장했습니다. 키 설정과 키쌍 파일, RSA 유틸리티와 그 테스트를 지웠고 별도 요청 테이블은 더 이상 쓰지 않게 되어 이후 e85e500에서 남은 도메인과 어댑터를 정리했습니다. 대신 요청마다 행을 쓰고 사용 뒤 지우는 저장 비용과 정리 책임이 서버로 넘어왔습니다.

flowchart LR
    subgraph RSA["86528ee: RSA 링크"]
        R1["이메일과 만료 시각"] --> R2["개인키로 암호화"]
        R2 --> R3["링크의 data 값"]
        R4["재설정 요청 테이블: 이메일"]
    end
    subgraph Server["ce8e1dd 이후: 서버 검증"]
        S1["6자리 번호 또는 UUID 토큰"] --> S2["이메일, 코드, 만료, 종류를 한 행에 저장"]
        S2 --> S3["메일에는 코드만 전달"]
        S3 --> S4["제출된 코드와 만료를 서버에서 대조"]
    end

전환 뒤에도 두 번 더 고쳤습니다. ce8e1dd에서는 재설정 링크 검증만 만료 시각을 확인하고 가입 인증번호 확인은 번호 일치만 봤습니다. 또 링크 검증이 코드 값만으로 행을 찾았는데 6자리 번호는 사용자 사이에서 겹칠 수 있는 값이었습니다. 2be4cd6에서 인증 종류(VerifyType)를 모델에 넣고 이메일과 종류로 행을 찾도록 바꿨습니다. 검증 메서드도 하나로 합쳐 번호 일치와 만료를 함께 확인했고 가입 인증번호는 3분, 재설정 토큰은 1시간으로 만료를 나눴습니다.

구현 코드: 인증 종류별 코드와 만료를 한 모델에서 검증
2be4cd6: 가입 인증번호와 재설정 토큰java
public static EmailVerification create(String email) {
    return EmailVerification.builder()
            .email(new Email(email))
            .code(String.format("%06d", new SecureRandom().nextInt(1000000)))
            .expiredDateTime(LocalDateTime.now().plusMinutes(3))
            .verifyType(VerifyType.EMAIL)
            .build();
}

public static EmailVerification resetRequestCreate(String email) {
    return EmailVerification.builder()
            .email(new Email(email))
            .code(UUID.randomUUID().toString())
            .expiredDateTime(LocalDateTime.now().plusHours(1))
            .verifyType(VerifyType.PASSWORD)
            .build();
}

public void verify(String inputCode) {
    if (!this.code.equals(inputCode)) throw new InvalidVerifyCodeException();
    if (LocalDateTime.now().isAfter(this.expiredDateTime)) throw new ExpiredRequestException();
}

verify의 두 조건은 원문에서 블록으로 나뉘어 있어 한 줄씩으로 줄였습니다.

인증 행은 이메일을 키로 저장하므로 같은 주소로 다시 요청하면 이전 번호가 새 번호로 바뀝니다. 가입은 번호를 확인한 뒤 인증 행을 지우고 회원을 등록합니다. 인증 메일과 비밀번호 API의 인증 제외 경로는 기존 필터 체인 설정에 제가 추가했습니다. 이후 비밀번호 변경은 동료가 휴대폰 본인인증 확인을 거치도록 바꿨고 현재 코드의 메일 인증은 가입 절차에서 쓰입니다. 이 전환을 보안 강화나 성능 개선의 근거로 쓰지는 않았습니다.

파일 id가 정해진 뒤에 저장소 경로를 만들기

파일은 먼저 S3 임시 버킷에 올라가고 등록 API는 그 임시 주소와 이름, 크기를 받습니다. 최종 저장 경로에는 회원 id와 파일 id가 들어가야 했는데 파일 id는 DB에 저장한 뒤에야 생깁니다. 그래서 등록 유스케이스를 구독 상태 확인, 파일 정보 저장, 파일 id를 넣은 경로로 객체 복사, 바뀐 주소를 담아 다시 저장하는 순서로 묶었습니다. 경로 복사 자체는 동료가 만든 S3 컴포넌트를 호출했습니다.

이 순서에서 첫 버전에 버그가 있었습니다. 두 번째 저장에서 id가 붙은 객체 대신 처음 만든 객체를 넘겨 같은 파일이 두 행으로 생겼고 3d80d44에서 저장 결과 객체를 다시 저장하도록 고쳤습니다. 이후 267ac34에서 여러 파일을 한 요청으로 받도록 확장하면서 저장과 복사, 재저장을 목록 단위로 바꿨습니다.

flowchart LR
    Request["등록 요청: 임시 주소, 이름, 크기"] --> Policy["구독 상태 확인"]
    Policy --> Save["파일 정보 저장, id 발급"]
    Save --> Copy["회원 id와 파일 id 경로로 S3 복사"]
    Copy --> Resave["새 주소로 다시 저장"]
    Resave --> Usage["회원 사용량 반영"]
구현 코드: 다중 업로드의 저장, 복사, 재저장
267ac34: registerFilesjava
if (!memberPersistenceAdapter.verifySubscriptionStatus(member.getId())) {
    throw new InvalidSubscriptionException();
}

Member register = memberPersistenceAdapter.getMember(member.getId());

List<File> files = fileDetails.stream()
        .map(detail -> File.register(register, detail.getFileUrl(), detail.getFileName(),
                detail.getFileType(), detail.getFileSize(), content))
        .toList();

List<File> savedFiles = filePersistenceAdapter.saveAll(files);

savedFiles.forEach(file -> {
    FileInfo info = file.getFileInfo();
    String s3Url = awsS3Adapter.copyObject(info.getFileAddress(), UploadPathType.FILE,
            info.getFileName(), register.getId(), file.getId());
    file.modifyFileInfo(new FileInfo(new URL(s3Url), info.getFileName(), info.getFileType(), info.getFileSize()));
    register.updateFileUsage(info.getFileSize());
});

memberPersistenceAdapter.save(register);
return filePersistenceAdapter.saveAll(savedFiles).stream().map(File::getId).toList();

반복문 안의 file.getFileInfo() 호출을 지역 변수로 묶고 람다 변수 이름만 줄였습니다.

삭제는 요청 회원을 다시 조회해 파일 도메인의 소유자 검사에 넘기고(7e31e8f) 원격 객체 삭제를 연결했습니다(350ff83). 소유자 검사 메서드는 동료가 만든 것이고 저는 실제 요청 회원을 불러 그 검사를 호출하도록 유스케이스를 고쳤습니다.

파일명은 등록 경계에서 정리했습니다. 같은 한글 이름이라도 운영체제에 따라 자모가 분리된 NFD로 들어올 수 있어 f4664c3에서 파일 정보를 만들 때 이름을 NFC로 정규화했습니다. 화면마다 이름을 보정하지 않고 저장되는 값의 기준을 한곳에 뒀습니다.

f4664c3: 파일 정보를 만들 때 이름을 NFC로java
.fileInfo(new FileInfo(new URL(fileUrl), Normalizer.normalize(fileName, Normalizer.Form.NFC), fileType, fileSize))

사용량은 처음에 제가 등록 때 파일 크기를 더하고(21075d5) 삭제 때 빼는(2826193) 방식으로 회원 모델에 반영했습니다. 이후 동료가 회원의 삭제되지 않은 파일 크기 합계로 사용량을 다시 계산하는 쿼리로 바꿨고 현재 코드는 그 재계산을 호출합니다. DB 트랜잭션이 S3 복사와 삭제까지 되돌리지는 않으므로 이 흐름을 원격 저장소와 DB의 원자적 처리로 설명하지 않았습니다.

결제 시도와 구독 반영을 나누기

결제 API를 호출했다는 사실과 구독이 유효해졌다는 사실은 다릅니다. 실패한 결제도 운영자가 볼 이력에는 남아야 하고 회원의 구독 상태는 결과에 따라 달라져야 했습니다. 결제 결과는 먼저 이력으로 저장하고 회원의 구독은 성공했을 때만 갱신했습니다.

flowchart LR
    Apply["구독 신청"] --> Key["요청 빌링키, 없으면 저장된 빌링키"]
    Key --> Pay["PortOne 빌링키 결제"]
    Pay --> Record["결제 결과와 금액, 결제 식별자 저장"]
    Record --> Result{"성공 여부"}
    Result -->|성공| Subscribe["회원 구독 갱신"]
    Result -->|실패| Fail["구독 변경 없이 반환"]
MemberUseCaseImpl.applySubscription: 이력 저장 뒤 구독 갱신java
paymentPersistenceAdapter.save(payment);

if (paymentResult.success()) {
    member.applySubscription(subscriptionPlan, billingCycle, paymentMethod, paymentBillingKey);
    memberPersistenceAdapter.save(member);
    return member.getSubscription();
} else {
    return null;
}

요청에 빌링키가 없으면 회원 구독에 저장된 빌링키로 결제하도록 했습니다. 구독 정책이 바뀌던 날에는 재신청 API를 걷어내고(081b39d) 빌링키 확인 API는 다시 살리면서 이 분기를 넣었습니다(66ca815).

다만 결제 식별자는 PAY와 현재 시각을 이어 만들고 PortOne 호출이 DB 트랜잭션 안에서 일어나므로 같은 요청의 재시도를 막는 멱등성은 주장할 수 없습니다. 실패 시 null을 돌려주는 계약도 호출하는 쪽이 분기해야 하는 구조였습니다. 구축 이후 결제 운영과 장애 대응은 맡지 않았습니다.

결제사와 단계별로 예외를 나누기

PortOne과 NaverPay는 응답 형식과 실패 단계가 달랐습니다. 공통 예외 처리기는 동료가 만든 PropException의 결과 코드를 응답으로 바꿔 주는 구조였습니다. 저는 이 기반을 그대로 쓰면서 결제사마다 기반 예외를 하나씩 두고 그 아래에 승인, 결제 처리, 취소, 빌링키 없음 같은 단계별 예외를 만들었습니다. 결과 코드는 1700번대에 결제사와 단계별로 배정했고 기반 예외에는 결제사 응답 본문을 담을 수 있게 했습니다. bec3ad1에서는 어댑터 안에 섞여 있던 결제 예외와 응답 DTO를 exception, dto 패키지로 나누고 PortOne 응답 클래스를 추가했습니다.

flowchart TD
    Prop["PropException: 공통 결과 코드"] --> PortOne["PortOneApiException: 응답 본문 보관"]
    Prop --> Naver["NaverPayApiException: 응답 본문 보관"]
    PortOne --> P1["BillingKeyNotFoundException"]
    PortOne --> P2["PortOnePaymentProcessException"]
    PortOne --> P3["PortOnePaymentCancelException"]
    Naver --> N1["NaverPaySubscriptionApprovalException"]
    Naver --> N2["NaverPayPaymentProcessException"]
    Naver --> N3["NaverPayPaymentCancelException"]

NaverPay 어댑터는 성공 코드가 아닌 응답, 4xx 응답, 호출 실패를 각각 잡아 같은 단계 예외에 원인 본문을 붙였습니다.

NaverPayAdapterImpl: 실패 원인을 단계 예외에 담는다java
    log.error("네이버페이 결제 처리 실패. 응답: {}", reserveResponse.getBody());
    throw new NaverPayPaymentProcessException(reserveResponse.getBody());
} catch (HttpClientErrorException e) {
    log.error("네이버페이 API 호출 중 클라이언트 에러 발생. 상태 코드: {}, 응답 본문: {}",
            e.getStatusCode(), e.getResponseBodyAsString());
    throw new NaverPayPaymentProcessException(Map.of("error", e.getResponseBodyAsString()));
} catch (RestClientException e) {
    log.error("네이버페이 API 호출 중 에러 발생", e);
    throw new NaverPayPaymentProcessException(Map.of("error", e.getMessage()));
}

다시 보면 두 어댑터의 실패 계약이 달랐습니다. PortOne 어댑터는 정상 응답에 결제 정보가 없으면 실패 결과를 반환하고 호출 예외만 PortOneApiException으로 감쌌습니다. NaverPay 어댑터는 실패를 모두 예외로 던졌습니다. 최종 구독 신청 유스케이스는 PortOne 빌링키 결제만 호출하고 NaverPay 어댑터를 부르는 곳은 남아 있지 않습니다. 또 빌링키 없음 예외는 어댑터 패키지에 두고 유스케이스가 직접 던지고 있어 바깥 계층에 기대지 않는다는 헥사고날 구조의 의존 방향을 거슬렀습니다. PortOne과 NaverPay 연동을 결제 엔진 구현으로 표현하지 않았습니다.

관리자 결제내역을 조건 조합으로 검색하기

운영자는 결제내역을 이메일 검색어, 기간, 서비스 플랜, 구독 주기, 결제 결과로 좁혀 봐야 했습니다. 다섯 조건은 모두 선택값이고 구독 주기와 결제 결과는 여러 값을 함께 고를 수 있었습니다. 조건 조합마다 저장소 메서드를 만들 수 없어 Querydsl의 BooleanBuilder에 값이 있는 조건만 더했습니다.

요청은 헥사고날 구조를 따라 컨트롤러, 결제 유스케이스, 영속성 어댑터, 저장소 순으로 내려갑니다. 저장소는 같은 조건 객체로 목록과 전체 건수를 함께 조회했습니다. 기간은 20240827-20240830 형식으로 받아 시작일 0시부터 종료일 마지막 시각까지로 바꿨고 구분자로 나눈 값이 두 개가 아니면 경고 로그만 남기고 기간 조건을 뺐습니다.

구현 코드: 값이 있는 조건만 더하는 검색
PaymentRepositoryImpl.getAdminPaymentsjava
BooleanBuilder builder = new BooleanBuilder();

if (StringUtils.hasText(searchWord)) {
    builder.and(paymentEntity.payerEmail.containsIgnoreCase(searchWord));
}
if (StringUtils.hasText(period)) {
    String[] dates = period.split("-");
    if (dates.length == 2) {
        LocalDateTime startDate = LocalDate.parse(dates[0], DateTimeFormatter.BASIC_ISO_DATE).atStartOfDay();
        LocalDateTime endDate = LocalDate.parse(dates[1], DateTimeFormatter.BASIC_ISO_DATE).atTime(LocalTime.MAX);
        builder.and(paymentEntity.createdDateTime.between(startDate, endDate));
    }
}
if (StringUtils.hasText(subscriptionPlan)) {
    builder.and(paymentEntity.subscriptionPlan.eq(subscriptionPlan));
}
if (billingCycle != null && !billingCycle.isEmpty()) {
    builder.and(paymentEntity.billingCycle.in(billingCycle));
}
if (paymentResults != null && !paymentResults.isEmpty()) {
    builder.and(paymentEntity.paymentResult.in(paymentResults));
}

조건별 로그 출력과 목록, 건수 조회 부분은 생략했습니다.

한계는 두 가지였습니다. 결제 결과는 “결제 성공” 같은 화면 문구로 저장했고 필터도 그 문구로 걸었기 때문에 문구를 바꾸면 조회 조건도 함께 바뀌는 구조였습니다. 사용자용 결제내역 조회에서는 dd87579에서 목록에 삭제 제외 조건을 더했지만 전체 건수 쿼리에는 같은 조건을 넣지 않아 두 값이 어긋날 수 있었습니다. 관리자 검색처럼 조건 객체 하나를 목록과 건수가 공유하게 했어야 했습니다.

조회 유스케이스에 읽기 전용 경계를 두기

파일 유스케이스는 처음에 jakarta.transaction.Transactional을 쓰고 있었습니다. 이 어노테이션에는 읽기 전용 속성이 없어서 조회 메서드도 쓰기 트랜잭션과 같은 조건으로 열렸습니다. fa51638에서 Spring의 @Transactional로 바꾸고 파일 검색을 readOnly = true로 지정했습니다. Spring과 JPA 조합에서 읽기 전용 트랜잭션은 끝날 때 변경 감지에 따른 flush를 하지 않으므로 조회 중 엔티티 값이 바뀌어도 UPDATE가 나가지 않습니다.

fa51638: 조회는 읽기 전용으로diff
-import jakarta.transaction.Transactional;
+import org.springframework.transaction.annotation.Transactional;

 @Override
-@Transactional
+@Transactional(readOnly = true)
 public Page<File> searchFiles(Member member, String searchWord, Pageable pageable) {

같은 기준으로 제가 만든 약력과 프로필 조회, 구독 조회, 빌링키 확인, 인증번호 확인을 읽기 전용으로 두고 상태를 바꾸는 가입, 결제, 파일 등록은 쓰기 트랜잭션으로 남겼습니다. 인증번호 확인 단계는 대조만 하고 인증 행 삭제는 가입 트랜잭션에서 합니다. 저장소 전체의 읽기 전용 지정은 동료들이 더 많이 했고 제 범위는 제가 만든 유스케이스입니다. 이 변경의 성능 효과는 측정하지 않았습니다.

외부 연동을 모킹하고 실제 유스케이스를 통과시키기

파일과 구독 흐름은 S3, 압축 컴포넌트, PortOne에 기대고 있어 단위 테스트만으로는 유스케이스 사이의 연결을 확인하기 어려웠습니다. 반대로 실제 결제와 객체 복사를 테스트에서 일으킬 수는 없었습니다. 그래서 @SpringBootTest로 실제 유스케이스와 H2 DB를 띄우고 S3, 압축, 결제 빈을 @MockBean으로 바꿨습니다.

파일 테스트는 실제 인증번호 발급과 확인으로 회원을 만들고 PortOne 결제를 성공으로 고정해 구독을 적용한 뒤 시작합니다. 그다음 단일 업로드, 다중 업로드, 검색, 삭제, 다운로드 순서로 같은 회원의 파일을 다뤘습니다. 구독 테스트는 구독 생성과 조회, 취소에 더해 잘못된 플랜, 없는 회원, 이미 취소된 구독의 예외를 확인했습니다. 파일 테스트 176줄 중 169줄, 구독 테스트 136줄 중 93줄이 제 작성분입니다.

FileControllerTest: 외부 연동만 모킹한다java
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class FileControllerTest {

    @MockBean
    AWSS3Component awsS3Component;

    @MockBean
    CompressionComponent compressionComponent;

    @MockBean
    PortOneAdapter portOneAdapter;

    @Autowired
    FileUseCase fileUseCase;
구독 적용 전 PortOne 결제를 성공으로 고정java
when(portOneAdapter.processPayment(anyString(), anyString(), anyInt(), anyString(), anyString(), anyString(), anyString()))
        .thenReturn(new PaymentResult(true, "testPaymentId", "결제 성공", "pgTxId", "2023-01-01T00:00:00Z"));

memberUseCase.applySubscription(memberId, SubscriptionPlan.PLAN_100GB, BillingCycle.MONTHLY, PaymentMethod.CARD, "123");

이 테스트는 순서를 고정한 시나리오 테스트라서 앞 테스트가 실패하면 뒤 테스트도 영향을 받습니다. 메일 발송은 모킹 대상에서 빠져 있어 회원 준비 단계가 메일 설정에 기댔고 모킹한 S3와 결제 응답은 실제 외부 서비스의 실패를 재현하지 않았습니다.

결과와 근거

배운 것