Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 67 additions & 65 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,80 +25,82 @@ jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Checkout
uses: actions/checkout@v6

- name: Set up JDK 21
uses: actions/setup-java@v5
with:
java-version: '21'
distribution: 'temurin'
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
java-version: '21'
distribution: 'temurin'

- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6

- name: Grant execute permission to gradlew
run: chmod +x gradlew
- name: Grant execute permission to gradlew
run: chmod +x gradlew

- name: Build & Test
run: ./gradlew build --no-daemon
- name: Build & Test
run: ./gradlew build --no-daemon

- name: Upload test report (on failure)
if: failure()
uses: actions/upload-artifact@v5
with:
name: test-report
path: build/reports/tests/
retention-days: 7
- name: Upload test report (on failure)
if: failure()
uses: actions/upload-artifact@v5
with:
name: test-report
path: build/reports/tests/
retention-days: 7

push-to-ecr:
needs: build-and-test
if: |
github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}

- name: Login to Amazon ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build, tag, and push image to ECR
env:
REGISTRY: ${{ steps.ecr-login.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker buildx build --platform linux/amd64 \
--build-arg GITHUB_USER=${{ secrets.GH_USER }} \
--secret id=github_token,env=GITHUB_TOKEN \
-t $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
-t $REGISTRY/$ECR_REPOSITORY:latest \
--push \
.

- name: Show pushed image
run: |
echo "✅ Pushed: $ECR_REPOSITORY:${{ github.sha }}"
echo "✅ Pushed: $ECR_REPOSITORY:latest"

- name: Download task definition
run: |
aws ecs describe-task-definition --task-definition program-service \
--query taskDefinition > task-definition.json

- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: task-definition.json
service: program-service
cluster: first-ticket-cluster
wait-for-service-stability: false
- uses: actions/checkout@v6

- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}

- name: Login to Amazon ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build, tag, and push image to ECR
env:
REGISTRY: ${{ steps.ecr-login.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
GH_USER: ${{ secrets.GH_USER }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
docker buildx build --platform linux/amd64 \
--build-arg GITHUB_USER="$GH_USER" \
--secret id=github_token,src=<(echo "$GH_TOKEN") \
-t $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
-t $REGISTRY/$ECR_REPOSITORY:latest \
--push \
.

- name: Show pushed image
run: |
echo "✅ Pushed: $ECR_REPOSITORY:${{ github.sha }}"
echo "✅ Pushed: $ECR_REPOSITORY:latest"

- name: Download task definition
run: |
aws ecs describe-task-definition --task-definition program-service \
--query taskDefinition > task-definition.json

- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: task-definition.json
service: program-service
cluster: first-ticket-cluster
wait-for-service-stability: false
10 changes: 3 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,15 @@ COPY gradle gradle
COPY build.gradle settings.gradle ./
RUN chmod +x gradlew

# 2. 의존성 미리 다운로드
ARG GITHUB_USER
RUN --mount=type=secret,id=github_token \
GITHUB_TOKEN="$(cat /run/secrets/github_token)" \
GITHUB_USER=$GITHUB_USER \
./gradlew dependencies --no-daemon

# 3. 소스 코드 복사 및 실행 가능한 JAR 빌드
COPY src src

RUN --mount=type=secret,id=github_token \
GITHUB_TOKEN="$(cat /run/secrets/github_token)" \
GITHUB_USER=$GITHUB_USER \
./gradlew clean bootJar --no-daemon -x test
./gradlew clean bootJar --no-daemon -x test -x asciidoctor


# 4. Spring Boot 3의 계층화 기능을 활용해 레이어 추출
RUN java -Djarmode=layertools -jar build/libs/*.jar extract
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ public record CreateScheduleCommand(
LocalDateTime eventStartAt,
LocalDateTime eventEndAt,
LocalDateTime saleStartAt,
LocalDateTime saleEndAt,
int totalCapacity
LocalDateTime saleEndAt
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ public record UpdateScheduleCommand(
LocalDateTime eventEndAt,
LocalDateTime saleStartAt,
LocalDateTime saleEndAt,
UUID venueId,
Integer totalCapacity
UUID venueId
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -257,13 +257,6 @@ public ProgramResult createSchedule(UUID requesterId, CreateScheduleCommand comm
VenueValidationData venueValidation =
venueProvider.validateVenue(command.venueId(), program.getType());

// 스케줄 totalCapacity가 venue 내 해당 타입 구역 전체 수용량을 초과하는지 검증
// ex) SEATED 프로그램인데 venue에 SEATED 구역 수용량 합계가 500석이면
// totalCapacity는 500을 넘을 수 없다
if (command.totalCapacity() > venueValidation.totalCapacity()) {
throw new ProgramException(ProgramErrorCode.TOTAL_CAPACITY_EXCEEDS_VENUE_LIMIT);
}

// 2. 공연장 시간 겹침 검증
// 비관적 락으로 동시 요청 간 TOCTOU 방지 (V-04)
// DB 레벨 exclusion constraint(tsrange)와 이중 방어
Expand All @@ -275,7 +268,7 @@ public ProgramResult createSchedule(UUID requesterId, CreateScheduleCommand comm

// 3. 스케줄 생성
program.addSchedule(command.venueId(), command.eventStartAt(), command.eventEndAt(), command.saleStartAt(),
command.saleEndAt(), command.totalCapacity(), LocalDateTime.now());
command.saleEndAt(), venueValidation.totalCapacity(), LocalDateTime.now());
programRepository.save(program);

return ProgramResult.from(program);
Expand All @@ -294,14 +287,14 @@ public ProgramResult updateSchedule(UUID requesterId, UpdateScheduleCommand comm

Schedule schedule = findScheduleInProgram(program, command.scheduleId());

Integer newTotalCapacity = null;

// venueId가 변경되는 경우에만 존재 여부 확인
if (command.venueId() != null) {
VenueValidationData venueValidation =
venueProvider.validateVenue(command.venueId(), program.getType());

if (command.totalCapacity() > venueValidation.totalCapacity()) {
throw new ProgramException(ProgramErrorCode.TOTAL_CAPACITY_EXCEEDS_VENUE_LIMIT);
}
newTotalCapacity = venueValidation.totalCapacity();

// 변경된 venueId 기준으로 시간 겹침 검증
// 자기 자신은 제외하고 검증
Expand All @@ -321,10 +314,12 @@ public ProgramResult updateSchedule(UUID requesterId, UpdateScheduleCommand comm
}
}

// schedule.update() 단일 호출
// totalCapacity: venueId 변경 시 → venue 계산값, 미변경 시 → null (기존 값 유지)
schedule.update(
command.eventStartAt(), command.eventEndAt(),
command.saleStartAt(), command.saleEndAt(),
command.venueId(), command.totalCapacity(),
command.venueId(), newTotalCapacity,
LocalDateTime.now()
);

Expand Down Expand Up @@ -566,9 +561,6 @@ private void validateCreateScheduleCommand(CreateScheduleCommand command) {
if (command.saleStartAt() == null || command.saleEndAt() == null) {
throw new ProgramException(ProgramErrorCode.INVALID_SALE_PERIOD);
}
if (command.totalCapacity() <= 0) {
throw new ProgramException(ProgramErrorCode.INVALID_CAPACITY);
}
}

/**
Expand All @@ -580,10 +572,6 @@ private void validateUpdateScheduleCommand(UpdateScheduleCommand command) {
if (command.scheduleId() == null) {
throw new ProgramException(ProgramErrorCode.SCHEDULE_NOT_FOUND);
}
// 도메인의 Schedule.update()와 일관성 유지 — 0도 차단
if (command.totalCapacity() != null && command.totalCapacity() <= 0) {
throw new ProgramException(ProgramErrorCode.INVALID_CAPACITY);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

/**
* 스케줄 등록 요청 DTO.
Expand All @@ -33,18 +32,14 @@ public record CreateScheduleRequest(

@NotNull(message = "판매 종료 일시는 필수입니다")
@FutureOrPresent(message = "판매 종료 일시는 현재 시각 이후여야 합니다")
LocalDateTime saleEndAt,

@Positive(message = "수용 인원은 1명 이상이어야 합니다")
int totalCapacity
LocalDateTime saleEndAt

) {
public CreateScheduleCommand toCommand(UUID programId) {
return new CreateScheduleCommand(
programId, venueId,
eventStartAt, eventEndAt,
saleStartAt, saleEndAt,
totalCapacity
saleStartAt, saleEndAt
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@

import com.firstticket.programservice.application.dto.command.UpdateScheduleCommand;

import jakarta.validation.constraints.Positive;

/**
* 스케줄 수정 요청 DTO.
* null이면 기존 값 유지 (부분 업데이트).
Expand All @@ -17,16 +15,14 @@ public record UpdateScheduleRequest(
LocalDateTime eventEndAt,
LocalDateTime saleStartAt,
LocalDateTime saleEndAt,
UUID venueId,
@Positive(message = "수용 인원은 1명 이상이어야 합니다")
Integer totalCapacity // ← primitive int → Integer
UUID venueId
) {
public UpdateScheduleCommand toCommand(UUID programId, UUID scheduleId) {
return new UpdateScheduleCommand(
programId, scheduleId,
eventStartAt, eventEndAt,
saleStartAt, saleEndAt,
venueId, totalCapacity
venueId
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -462,8 +462,7 @@ private CreateScheduleCommand validCommand() {
return new CreateScheduleCommand(
PROGRAM_ID, VENUE_ID,
FUTURE, FUTURE.plusHours(2),
FUTURE.minusDays(30), FUTURE.minusDays(1),
500
FUTURE.minusDays(30), FUTURE.minusDays(1)
);
}

Expand All @@ -484,21 +483,6 @@ void success() {
assertThat(result.schedules()).hasSize(1);
}

@Test
@DisplayName("totalCapacity가 공연장 수용량 초과 시 TOTAL_CAPACITY_EXCEEDS_VENUE_LIMIT 예외 — 도메인 규칙 위반")
void fail_capacityExceedsVenueLimit() {
Program program = draftProgram();
given(programRepository.findByIdWithSchedules(PROGRAM_ID))
.willReturn(Optional.of(program));
given(venueProvider.validateVenue(VENUE_ID, ProgramType.SEATED))
.willReturn(venueValidation(499)); // command.totalCapacity=500 > 499

assertThatThrownBy(() -> programCommandService.createSchedule(OWNER_ID, validCommand()))
.isInstanceOf(ProgramException.class)
.extracting(e -> ((ProgramException)e).getErrorCode())
.isEqualTo(ProgramErrorCode.TOTAL_CAPACITY_EXCEEDS_VENUE_LIMIT);
}

@Test
@DisplayName("공연장 시간 겹침 시 VENUE_TIME_CONFLICT 예외 — 도메인 규칙 위반 (V-04)")
void fail_venueTimeConflict() {
Expand Down
Loading