좌석 상태 관리 프로세스 결정

  1. reservation 테이블에서 좌석 선택부터 예매 완료까지 관리
    → 여러 좌석 선택 시 좌석별로 insert가 필요함
  2. cart 테이블에서 좌석 선택 관리 및 reservation 테이블에서 결제 단계부터 예매 완료까지 관리
    → 좌석 선택 후 다음 단계에서 reservation에 데이터 insert

⇒ cart는 프론트 영역이라 굳이 DB에 저장할 필요 없으니까 1번 방식으로 결정함


클라이언트는 어떻게 enum의 name 값을 알까?

서버가 PICKUP, DELIVERY 같은 enum name 값을 요구하면,
클라이언트는 그 문자열이 뭔지 알아야 함.

  1. 리액트면 클라이언트 쪽에도 클래스를 만들어서 상수 관리 가능 → 서버랑 클라이언트 상수 일치 필요
  2. 문서로 알아야 함 ← 가장 현실적인 방법

⇒ 그래서 문서에 enum 정보 명시하기로 함


결제 완료와 예매 완료는 같이 처리하도록 수정

원래 결제 완료 api랑 예매 완료 api 분리했는데,
결제 완료까지만 잘 되고 그 뒤에 오류 나면 데이터 원자성 문제 발생하거나 결제 취소 api 호출해야 해서 번거로움.

같은 이유로 좌석 선택 api(좌석 도메인)와 좌석 정보 저장 api(예매 도메인)도 따로 실행하면 데이터 불일치 문제 생김.

그래서 하나의 api에서 결제 완료와 예매 완료를 함께 처리하도록 수정함.


예매 상태변경 api 필요할까?

예매 단계는 좌석 선택 → 할인/배송비 적용 → 결제 → 완료
결제 취소 api는 이미 있음.

할인/배송비 적용 취소는 예매 취소하거나 할인/배송비 변경 api 호출하면 됨.
좌석 선택 취소도 예매 취소 api 쓰면 됨.

⇒ 별도의 상태 변경 api는 필요 없고,
같은 유저/공연/좌석으로 재예매 가능하게 하려면 hard delete 처리하면 됨.


예매 좌석 정보 저장 api에 400 에러 코드 관리 필요할까?

다른 api는 예매 id가 없으면 404 에러 처리함.
근데 예매 좌석 정보 저장 api는 테이블에 저장 전에 실행되고,
대용량 트래픽 상황에선 대기 후 예매 화면 진입해야 해서 선행 api가 필수임.

그래서 선행 api가 안됐으면 400 에러 처리해야 함.

⇒ 선행 api 수행 여부를 알 수 있게 예매 요청 api 성공 시 바로 예매 api에 저장하도록 설계함.

문제

QueryDSL + Projections 사용 시 fetch join 오류

QueryDSL을 사용하여 Todo 엔티티를 조회하면서 Todo.managers 컬렉션을 fetch join으로 함께 가져오고, 결과를 DTO로 매핑하기 위해 Projections.constructor()를 사용했다.

실행 시 아래와 같은 예외가 발생했다.

jakarta.servlet.ServletException: Request processing failed: java.lang.IllegalArgumentException: org.hibernate.query.SemanticException: Query specified join fetching, but the owner of the fetched association was not present in the select list [SqmListJoin(org.example.expert.domain.todo.entity.Todo(todo).managers(manager))]
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1022) ~[spring-webmvc-6.1.12.jar:6.1.12]
	at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903) ~[spring-webmvc-6.1.12.jar:6.1.12]
List<TodoSearchResponse> todos = queryFactory  
    .select(Projections.constructor(TodoSearchResponse.class,  
       todo.title,  
       manager.countDistinct(),  
       comment.countDistinct())) // manager, comment의 조인으로 인해 데이터 중복 발생 가능 -> countDistinct() 사용  
    .from(todo)  
    .leftJoin(todo.managers, manager)  
    .leftJoin(todo.comments, comment)  
    .where(  
       StringUtils.isBlank(title) ? null : todo.title.like(title),  
       StringUtils.isBlank(nickname) ? null : todo.user.nickname.like(nickname),  
       createdFrom == null ? null : todo.createdAt.after(createdFrom.atStartOfDay()),  
       createdTo == null ? null : todo.createdAt.before(createdTo.atTime(23, 59, 59))  
    )  
    .offset((long) (page - 1) * size)  
    .limit(size)  
    .orderBy(todo.createdAt.desc())  
    .groupBy(todo) // 특정 할 일에 대해 manager, comment 조회  
    .fetch();

 

원인 분석 및 해결

https://go-getter1kim.tistory.com/146

 

QueryDSL Projections (JPAExpressions을 사용해서 조인 문제 해결)

Projection없이 QueryDSL을 사용해서 데이터를 조회하는 경우에는 Todo 엔티티 자체를 select하는 QueryDSL 로직을 만들어엔티티에 포함된 다른 엔티티를 모두 fetch join 해야 한다.그리고 가져온 Todo에서 필

go-getter1kim.tistory.com

 


 

문제

error : Method 'insertUserData' annotated with '@BeforeAll' should be static

테스트 코드 중 insertUserData 메서드가 @BeforeAll 어노테이션과 함께 사용되려면 static 메서드로 선언해야 한다.

 

원인 분석

@BeforeAll은 해당 테스트 클래스를 실행하기 전에 한 번만 수행되도록 하는 어노테이션이다.

JUnit은 테스트 클래스의 객체를 생성하기 전에 이 메서드를 실행하려고 한다.

하지만 일반 메서드는 객체가 생성되어야만 호출할 수 있으므로, static이어야 객체 없이 호출 가능하다.

 

하지만 해당 메서드를 static으로 변경하면 @Autowired로 Repository를 주입받을 수 없다.

static 메서드에 아직 생성되지 않은 객체가 접근할 수 없기 때문이다.

 

해결

@TestInstance(TestInstance.Lifecycle.PER_CLASS)

 

@TestInstance 어노테이션을 사용하면 @BeforeAll을 static으로 만들지 않아도 되도록 허용한다.

이 설정을 하면 JUnit이 테스트 클래스의 인스턴스를 한 번만 생성해서 사용하기 때문이다.

지금까지 자바 공부를 하면서 제네릭을 많이 사용해왔고, 대충 어떤 목적으로 사용하는지는 알고 있었다.

그런데 이번 프로젝트를 진행하면서 공통 응답 코드를 짜게 되었는데, 제네릭을 왜 이런 식으로 짜는걸까 의문이 드는 부분이 있었어서 이번 기회에 다시 정리해보고자 한다.

 

제네릭

만약 어떤 클래스가 제네릭 타입이라면, 객체를 선언하는 부분에서 <> 안에 특정 타입으로 명시해준다.

이렇게 클래스 외부에서 클래스 내부 데이터의 타입을 선언하는 방식이 제네릭이다.

class Fruit<T>{
  ...
}

Fruit<String> fruit = new Fruit<>();

 

 

타입 매개변수

메서드와 파라미터를 표기하는 것과 비슷한 형태로 클래스와 제네릭의 타입을 명시한다.

따라서 제네릭의 타입을 결정하는 <> 내부의 데이터 타입을 타입 매개변수라고 한다.

그리고 new ~의 구현부에서는 <>로 타입 매개변수를 한 번 더 작성하는 형태이지만, 데이터 타입을 중복으로 작성하는 것은 비효율적이기 때문에 뒷부분의 타입 매개변수는 생략이 가능하다.

methodA(String parameter);

Fruit<String> = new Fruit<>();

 

 

제네릭을 사용하는 이유

클래스 내부에서 필드나 파라미터로 Object 타입을 사용하면 굳이 제네릭을 사용하지 않고도 다양한 타입을 클래스에 적용시킬 수 있다.

하지만 그렇게 하면 객체를 생성할 때 어떤 타입을 객체를 생성했는지 개발자가 직접 확인하고, 해당 타입으로 반환값을 형변환 해줘야 한다.

아래 상황에서는 배열에 Apple 타입만 삽입했지만 개발자가 실수로 Banana 타입으로 형변환하여 데이터를 저장하려고 한다.

이 상황에서 자바는 컴파일 시점에 이 오류를 인식하지 못하기 때문에 런타임 오류가 발생하게 된다.

반면에 제네릭은 어떤 타입인지 명시해주기 때문에 자바가 컴파일 시점에 오류가 발생하는 것을 알릴 수 있고, 다운캐스팅해서 반환값을 저장하지 않아도 된다.

public static void main(String[] args) {
    Apple[] arr = {
            new Apple(),
            new Apple()
    };
    FruitBox box = new FruitBox(arr);

    Apple apple = (Apple) box.getFruit(0);
    Banana banana = (Banana) box.getFruit(1);
}
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨‍💻:티스토리]
public static void main(String[] args) {
    Apple[] arr = {
            new Apple(),
            new Apple()
    };
    FruitBox<Apple> box = new FruitBox<>(arr);

    Apple apple = (Apple) box.getFruit(0);
    Banana banana = (Banana) box.getFruit(1);
}
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨‍💻:티스토리]

 

 

제네릭을 사용할 때 주의할 점

static 메서드, static 필드에는 제네릭 타입이 올 수 없다.

제네릭 타입은 객체가 생성되는 순간에 어떤 타입이 오는지 명시해줘야 하는데, static의 경우 공유 자원이기 때문에 객체가 생성되기 전에 이미 데이터 타입이 정해져 있어야 하기 때문이다.

 

배열의 구현부에는 제네릭 타입을 사용할 수 없다.

Sample<Integer>[] arr = new Sample<Integer>[10];

 

타입 소거로 인해 런타임 시에 new Sample<Integer>[10] → new Sample[10] 으로 변경되면 런타임 시에는 어떤 타입이 들어오는지 명시되지 않아서 타입 안정성 문제가 발생할 수 있기 때문에 애초에 컴파일 오류를 발생시킨다.

더보기

배열과 제네릭의 핵심 차이

공변성이란

 

항목 배열 제네릭
타입 정보 유지 런타임까지 유지됨 컴파일 후 제거됨 (타입 소거)
타입 검사 시점 런타임 검사 가능 (ArrayStoreCheck) 컴파일 시점에만 검사
공변성 허용 (String[] → Object[]) 허용되지 않음 (무공변)
생성 가능 여부 new String[10] ✅ new T[10] ❌
타입 안전성 보장 런타임에 보장 컴파일 시점에 보장

 

배열은 왜 런타임에 타입을 유지하는가?

String[] strArray = new String[10];
Object[] objArray = strArray; // 업캐스팅 가능(공변성)

objArray[0] = "문자열";       // OK
objArray[1] = 123;            // 런타임 오류 (ArrayStoreException)

 

  • objArray의 타입은 Object[]지만, 실제 배열은 String[]
  • -> 자바는 초기에 배열을 객체처럼 사용할 수 있도록 설계했기 때문에 업캐스팅이 가능(공변성)
  • -> 따라서 컴파일 시점에는 오류 발생하지 않음
  • 하지만 123은 String이 아니므로 JVM이 런타임에 오류를 던짐
  • → 이 동작을 위해 JVM은 배열의 타입 정보를 런타임까지 유지함

 

제네릭은 왜 런타임에 타입 정보가 사라지는가?

제네릭의 경우는 컴파일 시에 이미 특정 타입인지 검사하는 과정을 거친 뒤에 실행하기 때문에, 제네릭의 타입 정보를 런타임까지 가지고 있는 것은 메모리 낭비다.

따라서 컴파일이 끝나면 타입 정보는 사라지고 일반 클래스처럼 취급된다.

 

 

 

반환 타입을 제네릭으로 선언

interface IAdd<T> {
    public T add(T x, T y);
}
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨‍💻:티스토리]

 

위와 같이 메서드명 앞에 T를 명시해주면 외부에서 선언하는 타입으로 메서드의 파라미터뿐만 아니라 반환타입도 결정된다.

하지만 제네릭 클래스 내부에 있는 add() 메서드는 <T>에서 설정된 타입을 받아와 사용할 뿐 제네릭 메서드는 아니다.

 

 

제네릭 메서드

class FruitBox<T> {
	
    // 클래스의 타입 파라미터를 받아와 사용하는 일반 메서드
    public T addBox(T x, T y) {
         ...
    }
    
    // 독립적으로 타입 할당 운영되는 제네릭 메서드
    public static <T> T addBoxStatic(T x, T y) {
         ...
    }
}
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨‍💻:티스토리]

 

제네릭 메서드는 메서드 선언부에 <T>가 선언된 메서드이다.

메서드에 선언된 <T>는 동적으로 타입을 받아와, 독립적으로 타입을 명시할 수 있는 메서드이다.

제네릭 메서드의 제네릭 타입 선언 위치는 메서드의 반환타입 바로 앞이다.

위 코드에서는 addBoxStatic의 반환타입이 T이기 때문에 그 앞에 <T>를 선언하여 제네릭 메서드임을 명시한다.

 

 

제네릭 메서드 호출

제네릭 메서드를 호출할 때는 제네릭 메서드를 선언할 때와 비슷한 형태로 메서드명 바로 왼쪽에 타입을 명시해준다.

FruitBox.<Integer>addBoxStatic(1, 2);
FruitBox.<String>addBoxStatic("안녕", "잘가");
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨‍💻:티스토리]

 

하지만 제네릭 메서드의 경우 파라미터를 보고 제네릭 타입에 들어갈 데이터 타입을 추정할 수 있기 때문에 제네릭 클래스를 선언할 때와는 다르게 타입 파라미터를 생략하고 호출할 수 있다.

FruitBox.addBoxStatic(1, 2); 
FruitBox.addBoxStatic("안녕", "잘가");
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨‍💻:티스토리]

 

만약 제네릭 메서드의 타입을 명시해주지 않고 호출한다면 기본적으로 제네릭 클래스의 제네릭 타입을 따른다.

하지만 <>로 직접 데이터 타입을 선언해주거나 제네릭 클래스의 타입과는 다른 타입의 값을 파라미터로 넘겨준다면 제네릭 클래스와는 독립적으로 제네릭 타입을 주입할 수 있다.

 

 

제네릭 와일드 카드

제네릭은 위에서 말했듯이 배열과는 다르게 형변환(공변)이 불가능하다.

따라서 제네릭 간의 형변환이 성립되려면 제네릭에서 제공하는 와일드 카드(?)를 사용해야 한다.

 

이렇게 선언하면 list는 Object 하위의 어떤 타입도 허용하고,

list2는 String 상위의 어떤 타입도 허용한다.

List<? extends Object> list = new ArrayList<String>();

List<? super String> list2 = new ArrayList<Object>();
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨‍💻:티스토리]

 

 

 

참고 링크

https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%A0%9C%EB%84%A4%EB%A6%ADGenerics-%EA%B0%9C%EB%85%90-%EB%AC%B8%EB%B2%95-%EC%A0%95%EB%B3%B5%ED%95%98%EA%B8%B0

0단계 - EC2 인스턴스에 jdk 설치

# SSH 또는 aws 콘솔로 원격 서버 접속해서 아래 명령어 실행
$ sudo apt-get update
$ sudo apt-get install openjdk-11-jdk

1단계 - 스프링부트의 .jar 파일 생성

1. gradle > bootJar 실행

2. 파일 디렉토리 build.libs에 snapshot.jar 파일 생성됨

3. 해당 파일 복사해서 어느 위치에 저장해두기

 

t2023-m0082@t2023-m0082ui-MacBookAir spring-plus % ./gradlew build
#Exception in thread "main" java.lang.RuntimeException: Wrapper properties file '/Users/t2023-m0082/Desktop/spring-plus/gradle/wrapper/gradle-wrapper.properties' does not exist.
#        at org.gradle.wrapper.GradleWrapperMain.main(SourceFile:74)
t2023-m0082@t2023-m0082ui-MacBookAir spring-plus % ./gradlew clean build
#Exception in thread "main" java.lang.RuntimeException: Wrapper properties file '/Users/t2023-m0082/Desktop/spring-plus/gradle/wrapper/gradle-wrapper.properties' does not exist.
#        at org.gradle.wrapper.GradleWrapperMain.main(SourceFile:74)
t2023-m0082@t2023-m0082ui-MacBookAir spring-plus % gradle wrapper
#zsh: command not found: gradle​

#은 에러메시지

.jar 파일 생성 안되는 경우
1. 위 명령어 실행해보기 (주석처럼 에러나면 2번부터 수행)
2. file > project structure > artifacts > + > web application exploded > from module > expert.main > ok
3. build > build artifacts > 선택 > build

 


2단계 - export (필수 아님)

1. 터미널에서 .jar 파일 저장해둔 경로로 이동

2. application.properties에 설정해둔 환경변수 export

3. .jar 파일 export (=> 로컬에서 서버 실행됨. 포스트맨이나 브라우저로 api 요청되는지 체크해보기)

export secretKey=#secretKey 입력
export username=#username 입력
export password=#password 입력
#RDB 연결시
export url=#jdbc:mysql://localhost:3306/spring_plus
java -jar expert-0.0.1-SNAPSHOT.jar

# 터미널 종료해도 백그라운드에서 서버 실행
nohup java -jar api-0.0.1-SNAPSHOT.jar

nohup 레퍼런스 참고


3단계 - FTP 사용해서 EC2와 로컬 연동

FTP 다운로드

FTP (File Transfer Protocol)는 파일 전송을 위한 표준 네트워크 프로토콜로, 한 컴퓨터에서 다른 컴퓨터로 파일을 이동하는 데 사용

터미널 명령어로 로컬 파일을 EC2로 옮기는 과정을 FTP로 대체

 

1. 사이트 관리자에서 해당하는 값 입력 후 연결

2. .jar 파일 위치로 이동하여 원격 파일 디렉토리에 드래그 (=> 원격 서버에 파일 전송 완료)

사이트 관리자 버튼 클릭

 

 


4단계 - SSH 또는 aws 콘솔로 원격 서버(EC2 인스턴스)에서 실행

export secretKey=#secretKey 입력
export username=#username 입력
export password=#password 입력
#RDB 연결시
export url=#jdbc:mysql://localhost:3306/spring_plus

java -jar expert-0.0.1-SNAPSHOT.jar
# 또는
nohup java -jar expert-0.0.1-SNAPSHOT.jar

참고

https://bcp0109.tistory.com/356

'도구 & 환경 설정 > AWS' 카테고리의 다른 글

AWS 유저 생성, MFA 설정  (0) 2025.05.12

문제 상황

// entity

public class Log {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@CreatedDate
	@Column(updatable = false)
	@Temporal(TemporalType.TIMESTAMP)
	private LocalDateTime createdAt;

	@CreatedBy
	@Column(updatable = false)
	private Long userId;

	public Log(Long userId){
		this.userId = userId;
	}
}

// LogService
public void logSaveManager(Long userId) {
		Log log = new Log(userId);
		logRepository.save(log);
	}

 

@CreatedBy를 사용하여 JPA가 api를 요청한 유저의 정보를 자동으로 DB에 저장하도록 설계했지만, 막상 서비스 코드에서는 userId를 직접 넣어주고 있었던 것을 발견하고, 파라미터없이 new Log를 수행하도록 변경했다.

 

이렇게 코드를 수정하고 포스트맨으로 테스트하는 과정에서 회원가입 api를 요청했을 때 token이 없다는 에러 메시지 응답하는 문제가 발생했다.

 


원인 분석

회원가입 api는 화이트리스트기 때문에 토큰 검증을 하지 않았고, 코드를 수정하기 전까지는 잘 동작했었기 때문에

필터에 sout 코드를 넣어서 url이 어떤 값이 들어오는지를 확인했다.

실행해보니 "/auth/signup"이 들어오는 게 아니라 "/error"가 들어오는 것을 확인했다.

내부 로직을 수행하면서 에러가 발생했고, 자동으로 리디렉션을 수행했던 것이다.

 

이 사실을 알고 다시 에러 메시지를 확인하니까 토큰이 없다는 에러와 함께 아래 에러가 발생하고 있었음을 발견했다.

java.lang.NumberFormatException: For input string: "anonymousUser"

 

위 에러는 PersistConfig의 코드가 아직 유저 정보가 없는 회원가입 상황에서도 context에서 유저 정보를 뽑으려고 시도해서

스프링 시큐리티에서 인증되지 않은 익명 사용자를 뜻하는 "anonymousUser"가 return에 담기게 되었고, 이 문자열을 Long.parseLong()으로 숫자 변환하려고 하다보니 NumberFormatException이 발생한 것이었다.

public class PersistenceConfig {
	@Bean
	public AuditorAware<Long> auditorProvider() {
		return () -> Optional.of(Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()));
	}
}

 


해결 방법

public class PersistenceConfig implements AuditorAware<Long> {
	@Override
	public Optional<Long> getCurrentAuditor() {
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

		if (authentication == null || !authentication.isAuthenticated() || authentication.getPrincipal().equals("anonymousUser")) {
			return Optional.empty(); // 유저 정보 없음
		}

		CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();

		return Optional.of(Long.parseLong(principal.getUsername()));
	}
}

 

authentication이 비어있거나 "anonymoususer" 라는 문자열을 가지는 경우에는 Optional을 반환하도록 로직을 수정하니 회원가입이 제대로 동작하는 것을 볼 수 있었다.

 

 

IAM (Identity and Access Management) - 유저 관리

- Users, Groups, Policies
- MFA

 

회원가입시 만들어진 계정(Root Account)는 사용/공유되지 않아야 한다.

Root Account는 AWS에서 할 수 있는 작업이 많아서 해당 계정을 해킹당하면 위험이 크다.

IAM으로 User와 Group을 만들어서 사용한다.

 

IAM은 AWS 리소스에 대한 액세스를 안전하게 제어할 수 있는 웹 서비스이다.

만들어진 Group, User들은 Policy를 통해서 권한을 조정한다.

 


유저 등록 방법

Step 1. 유저 정보 입력

Provide user access ~ : 체크박스 체크 안하면 api를 통한 접근만 가능 / 체크하면 web을 통한 접근 가능

Users must create a new password ~ : 체크박스 체크하면 자동 생성된 비밀번호를 새로운 비밀번호로 변경 필수

 

Step 2. 그룹 설정

커스텀 : Create group > Create policy > 원하는 권한 선택

자동 설정 : Create group > 기본 제공되는 그룹 선택(ex. AdministratorAccess)

// 커스텀 시 JSON 타입으로 설정하는 방법
{
  "Version": "policy-version",
  "Statement": [
    {
      "Effect": "allow-or-deny",
      "Action": ["action-name"],
      "Resource": ["resource-arn"],
      "Condition": {
        "condition-operator": {
          "condition-key": "condition-value"
        }
      }
    }
  ]
}

- Version: 정책의 버전을 나타냅니다. 현재는 "2012-10-17" 에서 고정되었습니다.
- Statement: 정책의 규칙을 나타냅니다. 배열 형태로 여러 개의 규칙을 작성할 수 있습니다.
- Effect: 규칙의 적용 여부를 나타냅니다. "allow" 또는 "deny" 중 하나를 선택합니다.
- Action: 규칙이 적용되는 작업의 종류를 나타냅니다. 예를 들어 "s3:ListBucket"과 같은 형식으로 작성합니다.
- Resource: 규칙이 적용되는 리소스의 ARN (Amazon Resource Name)을 나타냅니다. 예를 들어 "arn:aws:s3:::my-bucket/*"과 같은 형식으로 작성합니다.
- Condition: 규칙이 적용되는 조건을 나타냅니다. 필수는 아니며, 필요한 경우 추가할 수 있습니다. 다양한 조건 연산자를 사용할 수 있습니다. 예를 들어 "IpAddress" 조건 연산자를 사용하면 특정 IP 주소에서만 작업을 수행할 수 있도록 제한할 수 있습니다.

 

Step 3~4. 확인 및 생성

Email sign-in instructions : 팀원에게 로그인 url과 username 전송

Download .csv file : password 저장

 

로그인

alias 설정 시 URL이 랜덤 숫자가 아니라 https://{alias}.signin...으로 설정됨

유저 생성 시 발급된 URL을 브라우저 주소창에 입력 -> username, password 입력해서 로그인

 


MFA (Multi-factor authentication)

- 로그인 시 비밀번호 외의 요소로 추가 인증하는 방법

- ex. Virtual Authenticator (모바일 앱으로 인증번호 입력)

- Root user의 MFA 설정 필수 (보안 강화)

 

Step 1. 인증 디바이스 선택

Add MFA > device 선택

 

Step 2. 디바이스 설정

Show QR > 모바일 앱에서 QR 인식 > 앱의 코드 2개 입력

이제부터 로그인 시 MFA까지 인증해야 로그인 성공

CI (Continuous Integration, 지속적인 통합)

  • 개발자가 코드를 자주 공유 저장소에 통합하는 과정
  • CI 시스템은 코드가 푸시되면 자동으로 빌드와 테스트를 실행해 코드의 품질을 검증
    • git push -> CI 서버가 빌드 및 테스트 실행 -> 성공 여부 피드백
    • CI 서버 : Github Actions, GitLab CI/CD, Jenkins, Travis CI 등
  • 주요 목적
    • 빠른 버그 발견
    • 기능 충돌 방지
    • 안정적인 통합

CD (Continuous Delivery / Continuous Deployment, 지속적인 제공 / 배포)

1. Continuous Delivery (지속적인 제공)

  • 테스트를 통과한 코드를 자동으로 배포 직전 상태까지 올려둔다.
  • 실제 배포는 사람이 수동으로 승인
  • 주요 목적
    • 배포 준비 자동화
    • 배포 리스크 최소화

2. Continuous Deployment (지속적인 배포)

  • 테스트를 통과한 코드를 자동으로 운영 환경까지 배포
  • 개발자가 배포를 일일이 하지 않아도 돼서 피드백 속도 향상
  • 배포 자동화 도구 : Docker, Kubernetes, ArgoCD, Helm 등

CI/CD 도입 효과

  • 버그 조기 발견 및 빠른 수정
  • 배포 프로세스 자동화 -> 개발 생산성 향상
  • 코드 품질 향상 및 릴리즈 속도 증가
  • 릴리즈 리스크 최소화

CI/CD 파이프라인 구성 예시

1. 개발자 작업

  • 로컬에서 기능 개발 후 GitHub 저장소에 push 혹은 PR 생성

2. CI 단계 (지속적인 통합) - GitHub Actions에서 자동 수행

  • 코드 변경 시 자동으로 빌드, 테스트 실행
  • 실패하면 알림, 성공 시 다음 단계로 이동
// .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: JDK 설정
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'
      - name: Gradle 캐시 설정
        uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
      - name: 빌드 및 테스트
        run: ./gradlew clean build

3. CD 단계 (지속적인 배포)

방식 1. EC2에 직접 SSH 접속 → 배포 스크립트 실행

  • EC2에 접속하여 컨테이너 재시작 또는 JAR 파일 재실행
// .github/workflows/cd.yml
name: CD

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: SSH로 EC2에 접속해 배포
        uses: appleboy/ssh-action@v0.1.6
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ec2-user
          key: ${{ secrets.EC2_PRIVATE_KEY }}
          script: |
            cd /home/ec2-user/app
            git pull origin main
            ./deploy.sh

 

방식 2. Docker + GitHub Actions

  • GitHub Actions에서 Docker 이미지 빌드 → DockerHub에 푸시
  • EC2에서는 DockerHub의 이미지를 pull 후 컨테이너 재시작

 

목적 도구
코드 저장 및 트리거 GitHub
빌드 및 테스트 GitHub Actions (CI)
배포 자동화 GitHub Actions + SSH or Docker
운영 서버 AWS EC2
컨테이너화 (선택) Docker
환경 변수 관리 GitHub Secrets, .env 파일

'도구 & 환경 설정 > Docker' 카테고리의 다른 글

Docker  (0) 2025.05.09

Docker

Docker는 컨테이너 기반 가상화 플랫폼으로, 애플리케이션과 그 실행 환경을 격리된 단위(컨테이너)로 패키징하고 배포할 수 있도록 지원

  • 단순히 컨테이너 기술만 제공하는 것이 아니라, 이미지 생성, 네트워크, 볼륨, 배포 관리 등 컨테이너 생명 주기 전반을 관리하는 도구
  • Docker는 컨테이너 기술을 실용화한 오픈소스 플랫폼이며, Docker가 컨테이너 기술 그 자체는 아니다.

 

가상화

등장 배경

  • 하나의 서버에 단일 애플리케이션만 띄우면 자원 낭비
  • 여러 애플리케이션을 동시에 구동하면 환경 충돌
  • 이를 해결하기 위해, 하나의 물리 서버에서 여러 애플리케이션이 격리된 환경에서 실행될 수 있도록 하는 기술이 가상화

 

가상화의 종류

https://medium.com/@jyson88/container%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C-ef0673798f23

1. 서버 가상화 (하이퍼바이저 기반)

  • 물리 서버 위에 하이퍼바이저(Hypervisor)를 설치해, 가상 머신(VM)을 생성
  • 각 VM은 독립적인 운영체제와 커널을 포함하고, 완전히 격리된 환경을 제공
  • VM마다 리소스가 많이 필요하고, 실행 속도가 느릴 수 있다.

2. 컨테이너 기반 가상화

  • 운영체제를 공유하고, 애플리케이션 실행에 필요한 부분만 격리
  • 도커 엔진 같은 컨테이너 런타임이 컨테이너 실행을 관리
  • VM에 비해 경량, 빠른 시작, 리소스 효율성 우수

 

컨테이너

https://www.youtube.com/watch?v=IiNI6XAYtrs

 

  • 이미지를 기반으로 생성된 실행 단위
  • 애플리케이션과 그 실행 환경을 함께 패키징하고, 독립된 프로세스로 실행된다.
  • 커널은 호스트와 공유하지만, 네트워크, 파일시스템, PID 등은 격리

 

이미지

https://sunrise-min.tistory.com/entry/Docker-Container%EC%99%80-Image%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80

  • 컨테이너 실행을 위한 불변의 스냅샷 파일
  • 실행 파일, 라이브러리, 설정 등을 포함
  • 계층 구조로 구성되며, 수정 시 새로운 레이어를 추가한다(Union File System)
  • 하나의 이미지에서 여러 컨테이너 생성 가능

 

도커 엔진

  • 컨테이너 생명 주기를 관리하는 핵심 런타임
  • Docker Client ↔ Docker Daemon 구조
  • 사용자가 명령어 입력 → Docker Daemon이 실행 → 결과 반환

 

클러스터

  • 여러 서버를 하나의 논리적 시스템처럼 묶어 관리하는 구조
  • 고가용성, 수평 확장, 로드 밸런싱, 자동 복구 등을 가능하게 한다
  • 클러스터에서 발생하는 문제:
    • 새로운 노드나 컨테이너가 추가될 때 서비스 발견(Discovery) 필요
    • 어떤 노드에 어떤 컨테이너를 배포할지 스케줄링 결정 필요

 

서비스

  • 같은 이미지로부터 생성된 여러 개의 컨테이너 집합
  • Swarm에서 Service는 스케일 단위이며, 각 인스턴스를 Task라고 함
  • Task는 클러스터 내에서 적절한 노드에 자동 할당됨

 

Docker Swarm

  • Docker에서 제공하는 경량 클러스터링 및 오케스트레이션 도구
  • 여러 노드를 하나의 클러스터(Swarm)로 구성 가능

구성

  • Manager 노드: 클러스터 상태 관리, 작업 스케줄링, 노드 제어
  • Worker 노드: 실제 컨테이너(Task) 실행

주요 기능

  • 서비스 단위 배포 (docker service)
  • 로드 밸런싱 (기본 지원)
  • 롤링 업데이트
  • 다중 Manager 구성으로 장애 허용(Failover)

실제 운영에서는 더 강력한 오케스트레이터인 쿠버네티스가 사용되며, 스웜은 비교적 단순한 클러스터링이 필요할 때 선택된다.

// 매니저 노드 설정
docker swarm init

// 워커 노드 설정 -> 클러스터 생성됨
docker swarm join

 

Docker Compose

  • 다중 컨테이너 애플리케이션을 정의하고 실행하기 위한 도구
  • docker-compose.yml에 여러 서비스 정의
  • 주로 개발/테스트 환경에서 사용
  • 단일 노드(Localhost)에서만 동작하며, 클러스터 기능은 없음

주요 기능

  • 서비스, 볼륨, 네트워크 구성 정의
  • docker-compose up/down/build 등의 명령어로 간단하게 실행 가능
항목 Docker Compose Docker Swarm
목적 로컬 다중 컨테이너 실행 분산 클러스터 기반 서비스 운영
실행 범위 단일 머신 다중 노드 (클러스터)
구성 파일 docker-compose.yml docker-compose.yml or stack.yml
실행 명령어 docker-compose up docker stack deploy
오케스트레이션 없음 있음 (스케줄링, 복구, 업데이트 등)
사용 대상 개발/테스트 환경 운영 환경도 가능
로드 밸런싱 없음 기본 제공
고가용성 지원하지 않음 지원 (Manager 다중화)
복잡성 낮음 다소 있음

 


Docker 명령어

// 1. 이미지 다운로드
docker pull ubuntu:22.04
docker images 				// 로컬 이미지 확인

// 2. docker create로 컨테이너 수동 생성 (실행 X)
docker create -ti --name ubuntu2204test ubuntu:22.04
docker ps -a

// 3. 생성한 컨테이너 시작 및 접속
docker start ubuntu2204test
docker attach ubuntu2204test

// 4. docker run은 create + start + attach의 축약 (컨테이너 생성+실행+셀 진입)
docker run -ti --name=ubuntu2204test2 ubuntu:22.04 /bin/bash

// 5. 실행 중인 컨테이너 확인
// 1) Docker 컨테이너 목록
docker ps

// 2) Docker가 사용하는 HyperKit 또는 qemu 프로세스 확인
ps aux | grep -i docker

// 3) 실행 중인 컨테이너의 PID 확인 (Docker 내부 PID 기준)
docker inspect --format '{{.State.Pid}}' ubuntu2204test3

참고 자료

https://sunrise-min.tistory.com/entry/Docker-Container%EC%99%80-Image%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80

 

https://www.youtube.com/watch?v=IiNI6XAYtrs

 

 

'도구 & 환경 설정 > Docker' 카테고리의 다른 글

CI/CD  (0) 2025.05.09

Projection없이 QueryDSL을 사용해서 데이터를 조회하는 경우에는 Todo 엔티티 자체를 select하는 QueryDSL 로직을 만들어

엔티티에 포함된 다른 엔티티를 모두 fetch join 해야 한다.

그리고 가져온 Todo에서 필요한 필드만 뽑아 Dto에 담는 .stream().map() 과정이 필요하다.

 

다른 엔티티를 join하지 않는 상황에서도 Projections를 사용하지 않고 select문에 2개 이상의 필드를 넣었다면, 반환 형태가 tuple이 되기 때문에 Model 객체를 직접적으로 비즈니스 로직에 사용하게 된다.

튜플을 Dto에 담아서 다른 레이어로 전달하면 문제는 없지만 첫 번째 상황과 마찬가지로 Dto에 담는 과정이 필요하다.

 

불필요한 과정을 없애기 위해서 처음 Todo 데이터를 조회할 때부터 필요한 필드만 조회하도록 하는 Projections를 적용했다.

즉, Proejections는 Dto로 데이터를 매핑할 때 사용한다.

 


 

Projections의 사용 방식은 매핑 방법에 따라 3가지로 나뉜다.

1. 생성자 기반 매핑

필드명이 달라도 상관없지만, 순서가 틀리면 런타임 오류가 발생하거나 의도와 다르게 값이 매핑될 수 있다.

// DTO
public class UserDto {
    private String name;
    private int age;

    public UserDto(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

// QueryDSL
List<UserDto> result = queryFactory
    .select(Projections.constructor(UserDto.class, user.name, user.age))
    .from(user)
    .fetch();

 

2. 필드 접근 매핑

순서는 상관없지만 필드명이 sql 쿼리 결과의 컬럼명과 일치해야 하고, Dto에 기본 생성자가 필수이다.

만약 필드명이 다르다면 ExpressionUtils.as()로 alias를 지정해줘야 한다.

// DTO
public class UserDto {
    public String name;
    public int age;
}

// QueryDSL
List<UserDto> result = queryFactory
    .select(Projections.fields(UserDto.class, user.name, user.age))
    .from(user)
    .fetch();
    
// alias 필요한 경우 select문
.select(Projections.fields(UserDto.class,
    user.username.as("name"),
    user.userAge.as("age")
))

 

3. setter 기반 매핑

Dto에 기본 생성자와 setter가 필수이다.

// DTO
public class UserDto {
    private String name;
    private int age;

    public void setName(String name) { this.name = name; }
    public void setAge(int age) { this.age = age; }
}

// QueryDSL
List<UserDto> result = queryFactory
    .select(Projections.bean(UserDto.class, user.name, user.age))
    .from(user)
    .fetch();

 

위 3가지 방식 중 가장 번거롭지 않은 방식인 생성자 기반 매핑 방식을 적용했다.

아래 이미지와 같이 Projections는 데이터를 담을 Dto를 먼저 파라미터에 넣어주고, 그 뒤에 각 필드를 작성한다.

 

manager, comment를 한 번에 todo에 left join하게 되면 성능에 매우 안좋기 때문에 따로 조인하도록 먼저 todo와 manager에 대해서만 쿼리를 작성했다.

하지만 아래와 같은 오류가 발생했다.

두 엔티티를 조인할 때 select문에서 todo의 title이 아닌 todo 자체가 필요해서 발생하는 오류같았다.

하지만 todo가 아니라 todo의 title만 Dto에 담아야 하는 상황이었기 때문에 이 오류를 해결하려면 todo를 가져오는 쿼리, manager와 comment에서 todo의 id와 일치하는 데이터를 가져오는 쿼리를 각각 수행한 뒤 페이징을 적용시키는 로직을 작성해야해서 너무 복잡해졌다.

List<TodoSearchResponse> todos = queryFactory  
    .select(Projections.constructor(TodoSearchResponse.class,  
       todo.title,  
       manager.count()))
    .from(todo)  
    .leftJoin(todo.managers, manager) // left join 하나만 사용  
    .where(  
       StringUtils.isBlank(title) ? null : todo.title.like(title),  
       StringUtils.isBlank(nickname) ? null : todo.user.nickname.like(nickname),  
       createdFrom == null ? null : todo.createdAt.after(createdFrom.atStartOfDay()),  
       createdTo == null ? null : todo.createdAt.before(createdTo.atTime(23, 59, 59))  
    )  
    .orderBy(todo.createdAt.desc())  
    .groupBy(todo) // 특정 할 일에 대해 manager 조회  
    .fetch();
jakarta.servlet.ServletException: Request processing failed: java.lang.IllegalArgumentException: org.hibernate.query.SemanticException: 
Query specified join fetching, but the owner of the fetched association was not present in the select list [SqmListJoin(org.example.expert.domain.todo.entity.Todo(todo).managers(manager))]

 

 


 

이러한 문제를 해결하기 위해 JPAExpression을 사용했다.

JPAExpression은 서브 쿼리를 사용할 때 필요한 클래스다.

manager, comment 엔티티에서 서브쿼리로 데이터를 뽑아와서, 그 데이터를 Projections에 적용시켜 최종적으로는 하나의 QueryDSL 로직에서 모든 작업을 수행할 수 있었다.

List<TodoSearchResponse> responseDto = queryFactory
    .select(Projections.constructor(
        TodoSearchResponse.class,
        todo.title,
        JPAExpressions
            .select(manager.count())
            .from(manager)
            .where(manager.todo.id.eq(todo.id)),
        JPAExpressions
            .select(comment.count())
            .from(comment)
            .where(comment.todo.id.eq(todo.id))
    ))
    .from(todo)
    .where(
        StringUtils.isBlank(title) ? null : todo.title.like("%" + title + "%"),
        StringUtils.isBlank(nickname) ? null : todo.user.nickname.like("%" + nickname + "%"),
        createdFrom == null ? null : todo.createdAt.after(createdFrom.atStartOfDay()),
        createdTo == null ? null : todo.createdAt.before(createdTo.atTime(23, 59, 59))
    )
    .offset((long) (page - 1) * size)
    .limit(size)
    .orderBy(todo.createdAt.desc())
    .fetch();

 

 

@CreatedDate, @LastModifiedDate의 경우는 JPA가 생성일시, 수정일시 정보를 알 수 있기 때문에 

아래의 어노테이션만 달아주면 정상적으로 생성일시, 수정일시 정보가 DB에 저장됐다.

// 해당 엔티티에 적용
@EntityListeners(AuditingEntityListener.class)

// Auditing을 활성화하기 위해 PersistenceConfig에 적용
@EnableJpaAuditing

 

하지만 @CreatedBy, @LastModifiedBy는 누가 해당 엔티티를 생성, 수정했는지(엔티티를 생성, 수정하는 api를 호출했는지) JPA가 알 수 없기 때문에 추가 작업이 필요하다.

기존의 날짜 자동 생성을 위해 @EnableJpaAuditing을 적용해둔 클래스에 AuditorAware 클래스를 활용해서 유저 정보를 꺼내는 로직을 작성해야 한다.

 

// 방법 1
@Configuration
@EnableJpaAuditing
public class PersistenceConfig {
	@Bean
	public AuditorAware<Long> auditorProvider() {
		return () -> Optional.of(Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()));
	}
}
// 방법 2
@Configuration
@EnableJpaAuditing
public class PersistenceConfig implements AuditorAware<Long> {
	@Override
	public Optional<Long> getCurrentAuditor() {
		return Optional.of(Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()));
	}
}

 

첫 번째의 경우는 Config에서 직접 메서드를 빈으로 등록해서 실행시키는 방법으로, 

AuditorAware를 반환타입으로 선언해서 람다식으로 AuditorAware의 구현체 형태를 리턴하는 방식이다.

AuditorAware의 추상 메서드가 하나밖에 없기 때문에 람다식으로 간단히 표현이 가능하다.

 

두 번째의 경우는 AuditorAware를 implements로 구현하는 클래스로 생성하는 것이다.

이 경우에는 스프링이 자동으로 해당 메서드를 빈으로 등록해주기 때문에 @Bean 어노테이션 없이 AuditorAware의 추상 메서드를 오버라이딩 해주면 된다.

 

AuditorAware는 optional로 감싸진 데이터를 반환하도록 되어있기 때문에, Optional 클래스의 of 메서드를 사용해서 return 해야 한다.