Hyun's Wonderwall

[U-KATHON 해커톤] RealMuse 프로젝트 백엔드 개발 회고 & 이번에 배운 것들 본문

활동/프로젝트

[U-KATHON 해커톤] RealMuse 프로젝트 백엔드 개발 회고 & 이번에 배운 것들

Hyun_! 2025. 8. 7. 05:00

RealMuse - 뷰티 크리에이터의 진정성과 브랜드의 가치관이 만나는 곳

2025.07.28~2025.07.30 U-KATHON 해커톤 프로젝트 (최우수상)
팀 인원 : 기획 1, 디자인 1, 프론트 1, 백엔드 1
사용 기술 : Java Spring, MySQL, Redis, Docker, AWS

 
교내 창업학회 주최 해커톤에 백엔드 개발자로 참여하여, RealMuse 프로젝트의 백엔드 파트를 전담 개발했다. 팀별로 주제가 정해졌는데 우리 팀의 주제는 '크리에이터'였다. 기획을 맡은 두 분 중 한 분이 불참하게 되어 전 팀원이 함께 기획을 조율하며 진행했다.
 
RealMuse는 팔로워 수 3천~10만 명 규모의 마이크로 뷰티 크리에이터와, 한정된 마케팅 예산을 가진 소규모 뷰티 브랜드를 매칭해주는 플랫폼이다. 기회가 적은 크리에이터와 효과적인 홍보를 원하는 브랜드가 서로 상생할 수 있는 구조를 지향한다. 해커톤 이튿날 팀별 기획 멘토링 시간에 멘토님으로부터 "초기 단계의 두 분야가 동반 성장할 수 있다는 점이 의미 있다"는 피드백을 받았고, 매칭 에이전시로 확장할 경우 가입 과정이나 계약/결제 구조를 중심으로 수익 모델을 설정할 수 있다는 인사이트도 얻었다.

B2B 플랫폼을 처음 개발해 보았는데, 두 이해관계자(크리에이터와 브랜드)의 니즈를 기능으로 구체화하는 과정이 신선했다. 경쟁사 서비스를 분석하고, 서비스의 필수 기능과 확장 가능성을 고민하면서 유저-개발자-운영자 구조에 대한 이해도도 높일 수 있었다. 실제로 운영된다면 정말 많은 기능이 필요할 듯했지만 해커톤 일정이 짧은 관계로 기획자 및 프론트 개발자와 협의하여 MVP 시연을 위한 기능 우선순위를 설정하고 빠르게 작업에 착수했다. 
 
해커톤을 진행하는 동안 기술적으로 고민하고 배운 내용들을 회고해본다.


1. ERD 설계 - 인증은 따로, 도메인 분리는 확실히

*서비스 운영에 필요할 전체 테이블이 아닌 구현한 기능 범위의 테이블

테이블 설명:

더보기

🔹 auth_user: 인증 사용자 (공통 유저 계정 정보)
- 도메인 역할: 인증, 로그인, 권한 관리의 기준이 되는 기본 유저 테이블
- 주요 필드: email, password_hash, role (브랜드/크리에이터/ADMIN), phone_number
- 특징: creator, brand 모두 이 테이블을 통해 계정과 연동됨

🔹 creator: 크리에이터 프로필
- 도메인 역할: 크리에이터의 개인 프로필
- 주요 필드: nickname, image, real_name, birth, gender
- 특징: auth_user_id로 인증 유저와 연결됨

🔹 brand: 브랜드 프로필
- 도메인 역할: 플랫폼에 등록된 뷰티 브랜드 정보
- 주요 필드: name, image, keyword, description, like_count
- 특징: auth_user_id로 인증 유저와 연결됨

🔹 creator_analysis: 크리에이터 상세 분석 정보
- 도메인 역할: 매칭 정확도를 높이기 위한 세부 특성 정보
- 주요 필드: 관심 카테고리, 피부톤, 화장 스타일, SNS, 타겟 성별/연령, 콘텐츠 정보 등
- 특징: creator_id를 외래키로 사용하여 creator와 연결됨

🔹 brand_like: 브랜드 찜 기능
- 도메인 역할: 크리에이터가 관심 있는 브랜드를 찜함 (브랜드 북마크)
- 주요 필드: brand_id, creator_id

🔹 matching: 브랜드-크리에이터 매칭 내역
- 도메인 역할: 실제 매칭 요청/수락/거절 상태를 관리하는 핵심 테이블
- 주요 필드:
  - initiator: 누가 요청했는지
  - status: 상태 (요청/수락/거절 등)
  - match_score: 매칭 점수 (추천 기준)
  - proposal, reply: 제안 메시지 및 답변
  - 특징: brand_id, creator_id를 함께 FK로 가지고 있음

🔹 chat_room: 매칭 기반 1:1 채팅방
- 도메인 역할: 매칭 후 의사소통 공간
- 주요 필드: 마지막 메시지 내용, 시간, 연관된 matching_id
- 특징: 채팅방은 매칭 단위로 존재

🔹 chat_message: 채팅 메시지
- 도메인 역할: 채팅방 내 메시지 내역 저장
- 주요 필드: sender_id, content, type(enum), created_at
- 특징: sender_id는 auth_user의 id 사용

ERD를 설계하면서 가장 고민했던 것은 사용자 테이블 구조였다.

creator와 brand는 항상 서로 매칭되는 관계여야 하고, 할 수 있는 작업이 명확히 다르기 때문에 두 역할 테이블을 분리했다.
이때 DB 정규화를 위해 이메일, 비밀번호 해시, 역할, 전화번호 등 인증 관련 공통 정보는 auth_user 테이블에 저장하고, 역할에 따라 creator 또는 brand 테이블에 도메인 특화 데이터를 분리 저장하는 구조로 설계했다(1:1).

'역할 기반 접근 제어(RBAC, Role-Based Access Control, 사용자의 역할에 따라 접근 권한을 제어하는 방식)에 대해 깊게 고민해볼 수 있었다. auth_user를 통해 로그인과 인증 로직을 통합적으로 처리하고, 채팅 메시지 등 모든 사용자가 공통 사용할 수 있는 기능 개발 시 fk 연결이 용이했다. (특히, 추후에 알림 기능이 추가되는 것을 생각했다. / 인증 쪽을 짜다가 보니 ADMIN 때문에 나눴어야 하는 것이 확실했다.)
비록 조인 비용이 늘어나는 단점은 있지만, 확장성과 역할 분리 측면에서 얻는 이점이 훨씬 크다고 판단했다. 실제로 찾아보니 많은 B2B 구조 서비스들이 이와 유사한 방식을 채택하고 있었다. (->Spring 애플리케이션 단에서 쿼리 호출 확인해보고, 캐싱 등 개선 계획)
 
스프링 프로그래밍에서는 사용자 Role을 ADMIN, BRAND, CREATOR로 설정하고, JWT 토큰 발급 시 권한을 담고,
컨트롤러에서 @PreAuthorize("hasRole('BRAND')")와 같이 제약을 주어 해당 역할의 사용자만 접근할 수 있게 제약을 걸었다.


2. Docker - 도커 컴포즈 파일에서 각 컨테이너에 헬스 체크 설정

이번 Docker Compose 파일에서는 Spring Boot 애플리케이션과 Redis 컨테이너에 Health Check를 설정했다.
(각 컨테이너에 헬스 체크 테스트 명령어 작성->컨테이너가 완전히 실행된 이후에 서비스가 시작되도록 구성)

Spring Boot의 경우 Spring Boot Actuator 의존성을 추가해 /actuator/health 엔드포인트를 활용했다. Redis가 완전히 실행되고 응답 가능한 상태일 때 Spring 애플리케이션이 부팅되도록 해, 초기 연결 실패를 방지하고 운영 중에도 상태를 감시할 수 있도록 했다.

services:
  redis:
    container_name: redis
    image: redis:7.2-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    command: ["redis-server", "--requirepass", "your_redis_password"]  # 비밀번호 설정
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "your_redis_password!", "ping"]
      interval: 60s
      timeout: 5s
      retries: 3
    restart: unless-stopped
    networks:
      - my_network

  application:
    container_name: app
    image: namespace/image_name:latest # 도커 이미지
    ports:
      - "8080:8080"
    depends_on:
      redis:
        condition: service_healthy # Redis가 healthy 상태일 때만 실행
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 180s #자유
      timeout: 5s
      retries: 5
    restart: unless-stopped
    networks:
      - my_network

volumes: # 레디스 데이터를 도커 볼륨에 저장
  redis_data:

networks:
  my_network: # 네트워크 이름 예시
    driver: bridge

이전에는 Redis 데이터를 EC2의 특정 경로에(즉, EBS 볼륨에) 직접 저장했었는데, 이번에는 Docker 볼륨에 저장해보았다. 도커를 통해 방법인데 컨테이너 삭제와 관계없이 명시 삭제 전까지 유지되며, docker volume 명령어로 관리할 수 있어 관리가 용이했다.


3. 이미지 업로드 - S3 Presigned URL 발급으로 서버 부담 감소, 업로드 파일명 처리

이전 프로젝트에서는 클라이언트가 이미지 파일을 직접 서버로 전송하고, 서버가 이를 수신한 뒤 AWS SDK를 통해 S3에 업로드하는 구조를 사용했었다. 이 방식은 클라이언트 요청을 서버가 중간에서 모두 처리해야 하므로 서버의 리소스를 사용하게 된다. 따라서 이번 프로젝트에서는 S3 Presigned URL 방식을 도입해보았다.
 
Presigned URL은 마치 예약된 것과 같이 임시로 유효한 미리 서명된 업로드 URL이다.
서버는 Presigned URL 발급만 담당하면 되며 클라이언트가 해당 URL로 S3에 이미지를 직접 업로드(POST)를 한다.

예를 들어, 프로필 사진 변경 기능을 수행할 때 클라이언트는 Presigned URL을 발급받아 이미지 업로드를 먼저 완료한 후, 프로필 사진 변경 API 요청 body의 image 필드에 해당 URL을 전달해 DB에 저장하면 된다.
이를 통해 서버 부하가 감소하고, 대용량 이미지 업로드 시에도 응답 지연 없이 안정적으로 처리할 수 있다.
 
코드:

@Getter @Setter
@Component
@ConfigurationProperties(prefix = "cloud.aws")
public class AwsConfig {
    private Credentials credentials;
    private String region;
    private S3 s3;

    @Getter @Setter
    public static class Credentials {
        private String accessKey;
        private String secretKey;
    }
    @Getter @Setter
    public static class S3 {
        private String bucket;
    }
}
@Tag(name = "S3 Presigned URL 발급")
@RestController
@RequestMapping("/s3")
@RequiredArgsConstructor
public class S3PresignedUrlController {
    private final S3PresignedUrlService s3PresignedUrlService;

    @Operation(summary = "프로필 이미지 업로드를 위한 Presigned URL 발급")
    @PostMapping("/presigned-url")
    public ResponseEntity<Map<String, String>> getPresignedUrlForProfile(
            @RequestParam String fileName,
            @RequestParam String contentType
    ) {
        URL presignedUrl = s3PresignedUrlService.generatePresignedUrl(fileName, contentType, 10);
        return ResponseEntity.ok(Map.of("url", presignedUrl.toString()));
    }
}

 

@Service
@RequiredArgsConstructor
public class S3PresignedUrlService {
    private final AwsConfig awsConfig;
    private final AuthUserService authUserService;
    private static final List<String> FORBIDDEN_CHAR = List.of("/", "\\", "..", "\"", ":", "*", "?", "<", ">", "|");
    private static final List<String> ALLOWED_IMAGE_EXTENSIONS = List.of(".jpg", ".jpeg", ".png", ".webp");

    // fileName 유효성 검증
    private void validateImageFileName(String fileName) {
        if (fileName == null || fileName.isBlank() || FORBIDDEN_CHAR.stream().anyMatch(fileName::contains)) {
            throw new CustomException(ErrorCode.INVALID_FILE_FORMAT);
        }
        String lower = fileName.toLowerCase();
        if (ALLOWED_IMAGE_EXTENSIONS.stream().noneMatch(lower::endsWith)) {
            throw new CustomException(ErrorCode.INVALID_FILE_FORMAT);
        }
    }

    public URL generatePresignedUrl(String fileName, String contentType, long expirationMinutes) {
        validateImageFileName(fileName);
        String ext = fileName.substring(fileName.lastIndexOf(".")).toLowerCase();
        String uniqueFileName = UUID.randomUUID() + ext;
        String key = String.format("profile/%d/%s",
                authUserService.getCurrentAuthUser().getAuthUserId(),
                uniqueFileName
        );
        // S3Presigner 객체 생성
        S3Presigner presigner = S3Presigner.builder()
                .region(Region.of(awsConfig.getRegion()))
                .credentialsProvider(
                        StaticCredentialsProvider.create(
                                AwsBasicCredentials.create(
                                        awsConfig.getCredentials().getAccessKey(),
                                        awsConfig.getCredentials().getSecretKey()
                                )
                        )
                )
                .build();
        // PutObjectRequest 세팅
        PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(awsConfig.getS3().getBucket())
                .key(key)
                .contentType(contentType)
                .build();
        PresignedPutObjectRequest presignedRequest = presigner.presignPutObject(builder -> builder
                .putObjectRequest(putObjectRequest)
                .signatureDuration(Duration.ofMinutes(expirationMinutes))
        );
        presigner.close();
        return presignedRequest.url();
    }
}

 
String.format 부분이 파일명 형식을 지정한 부분인데, 확장성을 고려해 S3 Prefix로 파티셔닝을 사용했다. 추후 파일 관리와 검색을 용이하게 하고자 했다. 또한 파일명을 통해 공격이 이루어질 수 있을 것 같아, 업로드되는 파일명의 안정성을 검사하는 validateImageFileName 메소드를 만들어 사용했다.


4. 커서 기반 페이지네이션 & QueryDSL

무한 스크롤 UI를 지원하기 위해 Cursor-based Pagination을 도입했다.

클라이언트에서 전달받은 lastMatchingId를 커서로 삼아, 해당 ID 이후의 데이터를 size + 1개만큼 조회하도록 구현했다. 결과가 size + 1개일 경우, 마지막 항목은 제거하고 hasNext 여부를 판단해 응답에 포함했다. 클라이언트는 리스트의 마지막 ID를 다음 요청의 커서로 사용해 자연스럽게 다음 페이지를 요청한다.
 
커서 기반 방식은 중간 데이터의 삽입/삭제에도 정렬 순서가 깨지지 않아 안정성이 높고, UX 측면에서도 부드러운 스크롤 경험을 제공하는 장점이 있다. 다만 커서로 사용하는 필드는 반드시 정렬 가능하면서도 고유한 값이어야 함에 유의해야 한다.
 
'인기순' 정렬 기준이 되는 필드가 연관된 다른 엔티티에 있어서, 단순 JPA만으로는 조인 조건이나 커서 비교가 어려워 QueryDSL을 사용하게 되었다. QueryDSL을 활용하면 복합 조건, 연관 필드 기반 정렬에서 일관된 방식으로 처리 가능한 구조를 설계할 수 있다. QueryDSL은 아직 익숙하지 않아서 더 공부해야 할 것 같다.