diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df36a58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +*# +*.iml +*.ipr +*.iws +*.jar +*.sw? +*~ +.#* +.*.md.html +.DS_Store +.attach_pid* +.classpath +.factorypath +.gradle +.metadata +.project +.recommenders +.settings +.springBeans +.vscode +/code +MANIFEST.MF +_site/ +activemq-data +bin +build +!/**/src/**/bin +!/**/src/**/build +build.log +dependency-reduced-pom.xml +dump.rdb +interpolated*.xml +lib/ +manifest.yml +out +overridedb.* +target +.flattened-pom.xml +secrets.yml +.gradletasknamecache +.sts4-cache + +.idea diff --git a/README.md b/README.md index 4162bb5..7c850dd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,854 @@ # spring-tutorial-24th CEOS 백엔드 24기 스프링 튜토리얼 + +# 1️⃣ spring이 지원하는 기술들(IoC/DI, AOP, PSA 등)에 대해 +___ +## 1. IoC와 DI +커피를 만드는 예시 코드를 통해 IoC와 DI에 대해 알아보겠습니다. +``` java +public class CoffeeMachine { + private Grinder grinder = new ManualGrinder(); // 객체를 직접 생성 + + public void makeCoffee() { + grinder.grind(); + System.out.println("커피 완성"); + } +} +``` +위의 코드에서는 `CoffeeMachine`이 `ManualGrinder`를 직접 만들고 있습니다. 여기서 Grinder를 전동 그라인더로 바꾸려면 어떻게 해야 될까요? +``` java +private Grinder grinder = new ElectricGrinder(); // 이 줄을 수정해야 함 +``` +위와 같이 `CoffeeMachine` 코드 자체를 수정해야 됩니다. +그라인더 종류가 늘어나고, 이런 관계가 많아질수록 고쳐야되는 코드가 많아지게 됩니다. + +### 제어권 넘기기 +위의 코드와는 다르게 밖에서 만들어서 넣어주는 방식으로 코드를 수정해보겠습니다. +```java +public class CoffeeMachine { + private Grinder grinder; + + public CoffeeMachine(Grinder grinder) { // 밖에서 받음 + this.grinder = grinder; + } + + public void makeCoffee() { + grinder.grind(); + System.out.println("커피 완성"); + } +} +``` +위의 코드에서는 `CoffeeMachine`은 어떤 그라인더가 올지도 모르고 알 필요도 없습니다. +그저 `Grinder` 타입이기만 하면 됩니다. 처음의 코드에서는 `CoffeeMachine` 객체가 자신이 사용할 객체를 직접 만드는 방식이었고 두 번째 코드에서는 누가 만들어서 주는 것을 쓰는 방식입니다. + +> 즉 객체를 만들고 연결하는 제어권이 밖으로 넘어간 겁니다. 이것이 바로 IoC(Inversion of Control, 제어의 역전)입니다. + +### 밖에서 제어권을 가진 것이 바로 '스프링' +수정된 두 번째 코드에서도 결국 외부에서 `new CoffeeMachine(new ElectricGrinder())`를 호출해야 합니다. 이 역할을 수행하는 것이 바로 스프링 컨테이너입니다. +``` java +@Component +public class CoffeeMachine { + private final Grinder grinder; + + public CoffeeMachine(Grinder grinder) { + this.grinder = grinder; + } +} +``` +`@Component`를 보고 스프링이 알아서 객체를 만들고, 생성자 파라미터를 보고 알아서 `Grinder` 구현체를 찾아 넣어줍니다. +> 이렇게 필요한 객체를 외부에서 넣어주는 것을 DI(Dependency Injection), 의존성 주입이라고 합니다. IoC를 구현하는 방법에는 DI 외에도 서비스 로케이터, 템플릿 메서드 패턴 등이 있습니다. +> 공통점은 "내가 호출하는"게 아니라 "호출당하는" 구조라는 점이고, 이것을 할리우드 원칙이라고 부릅니다. +### 의존성을 주입하는 3가지 방법 +``` java +// ① 생성자 주입 -> 제일 권장되는 방식 +@Service +public class TestService { + private final TestRepository repository; + + public TestService(TestRepository repository) { + this.repository = repository; + } +} + +// ② 필드 주입 +@Service +public class TestService { + @Autowired + private TestRepository repository; +} + +// ③ setter 주입 +@Service +public class TestService { + private TestRepository repository; + + @Autowired + public void setRepository(TestRepository repository) { + this.repository = repository; + } +} +``` +DI에는 크게 `생성자 주입`, `필드 주입`, `setter 주입`이 있는데 첫 번째 `생성자 주입`이 가장 권장되는 방식입니다. +`생성자 주입`이 권장되는 이유는 크게 3가지로 아래와 같습니다. +1. `final` 사용 가능: 생성 후 바뀌지 않음이 보장됩니다. 필드 주입은 `final`이 불가능합니다. (객체가 만들어진 다음에 값을 넣기 때문) +2. 순환 참조 조기 발견: A <-> B가 서로 필요하면 어플리케이션이 뜨지 않습니다. +3. 테스트 편의: 스프링 없이 new TestService(MockRepository)로 만들 수 있습니다. + + +`@RequiredArgsConstructor` 어노테이션을 사용하여 생성자 주입을 할 수 있습니다. 이 어노테이션은 `final` 필드를 받는 생성자를 롬복이 대신 만들어주는 것이고 아래과 같은 코드에서는 생성자가 하나뿐이라 +`@Autowired`가 생략된 상태이다. +```java +@Service +@RequiredArgsConstructor +public class TestService { + private final TestRepository repository; // ← 이걸 받는 생성자가 자동 생성됨 +} +``` +## 2. AOP +### 문제 상황 +메서드가 실행될 때 로그를 남기고 싶은 상황을 생각해겠습니다. +```java +@Service +public class TestService { + + public List findAllTests() { + System.out.println("메서드 시작"); // 부가 기능 + + List result = repository.findAll(); // 진짜 하고 싶은 일 + + System.out.println("메서드 종료"); // 부가 기능 + return result; + } +} +``` +진짜 로직은 가운데 한 줄이지만 위 아래로 로그 코드가 붙은 상황입니다. 메서드가 하나면 괜찮은데 많아지면 로그와 관련된 코드를 수정할 때 모든 메서드를 수정해야 되는 문제가 있습니다. +### 횡단 관심사 +여러 곳에 흩어져서 중복되는 부가 기능을 횡단 관심사라고 합니다. +로깅이나 트랜잭션 같은 기능은 모든 계층을 가로질러 나타납니다. 그래서 횡단이라고 부르고 전형적인 예로는 로깅, 트랜잭션, 권한 검사, 캐싱, 성능 측정 등이 있습니다. + +### AOP가 하는 일 +**이 부가 기능을 별도 클래스로 빼내고, 어디에 적용할지만 지정**합니다. +```java +@Aspect +@Component +public class LogAspect { + + @Around("execution(* com.ceos24.springboot.service..*(..))") + public Object log(ProceedingJoinPoint joinPoint) throws Throwable { + System.out.println("메서드 시작"); + + Object result = joinPoint.proceed(); // 원래 메서드 실행 + + System.out.println("메서드 종료"); + return result; + } +} +``` +이제 `TestService`는 아래와 같이 됩니다. +```java +@Service +public class TestService { + + public List findAllTests() { + return repository.findAll(); // 로그 코드가 없음 + } +} +``` +로그 코드는 어디에도 없지만 실행하면 로그가 찍히게 됩니다. 그리고 로그 형식을 바꾸로 싶으면 `LogAspect` 한 곳만 고치면 됩니다. +핵심은 **관심사를 분리하는 것으로 비즈니스 로직과 부가 기능을 따로 관리**합니다. +### 어떻게 가능한 걸까 - 프록시 +`TestService` 코드에는 로그가 없는데 어떻게 로그가 찍힐 수 있을까요? +스프링이 `TestService`를 감싼 가짜 객체를 만들어서, 진짜 대신 빈으로 등록하기 때문입니다. **이 가짜 객체를 프록시**라고 합니다. +컨드롤러는 `TestService`를 주입받았다고 생각하지만, 실제로 손에 쥔 건 프록시입니다. 프록시가 부가 기능을 실행하고 진짜 객체에게 일을 넘기는 구조입니다. +```java +@RestController +@RequiredArgsConstructor +public class TestController { + private final TestService testService; + + @GetMapping("/check") + public String check() { + return testService.getClass().getName(); + } +} +``` +AOP가 적용돼 있으면 `TestService`가 아니라 `TestService$$SpringCGLIB$$0` 같은 이름이 나옵니다. 이 이름은 프록시 클래스 이름입니다. +### 프록시 때문에 생기는 함정 +```java +@Service +public class TestService { + + public void outer() { + inner(); // ← 여기가 문제 + } + + @Transactional + public void inner() { + ... + } +} +``` +`outer()`를 호출하면 `inner()`의 `@Transactional`이 동작하지 않습니다. 컨트롤러가 `outer()`를 부르면 프록시를 거칩니다. 하지만 +`outer()` 안에서 `inner()`를 부르는 건 `this.inner()`입니다. 이미 진짜 객체 안에 들어와 있으니 프록시를 다시 지나가지 않습니다. + +이걸 **자기 호출(self-invocation)문제**라고 하고, 실무에서 자주 발생합니다. 트랜잭션이 왜 안 걸리지 하고 한참 헤매게 되는 원인 1순위라고 볼 수 있습니다. +해결책은 클래스를 분리하거나, 자기 자신을 주입받거나(`@Lazy` 필요), `AopContext`를 쓰는 방법이 있는데 보통은 클래스를 나누는 것이 정석입니다. +## 3. PSA +PSA는 Portable service Abstraction의 약자로 세 가지 중 제일 추상적이라 감이 잘 안 오는 개념입니다. +**이식 가능한 서비스 추상화**라는 뜻으로 코드로 먼저 보겠습니다. +```java +@GetMapping +public List findAllTests() { + return testService.findAllTests(); +} +``` +위 코드는 톰캣에서 돌아갑니다. 그러나 Jetty나 Undertow로 바꿔도 한 글자도 고치지 않아도 됩니다. `build.gradle`에서 의존성만 교체하면 되기 때문입니다. +서블릿 컨테이너마다 내부 API가 다른데도 상관없습니다. + +즉 PSA는 특정 기술에 종속되지 않도록 일관된 추상화 계층을 제공하는 것입니다. 다른 예로 트랜잭션 관련 코드를 살펴보겠습니다. +```java +@Transactional +public void save(Test test) { + repository.save(test); +} +``` +위 코드는 JPA를 쓰든 JDBC를 쓰든 MyBatis를 쓰든 똑같습니다. 스프링이 `PlatformTransactionManager`라는 인터페이스로 추상화해두고, +실제 구현체 (`JpaTransactionManager`,`DataSourceTransactionManager` 등)를 상황에 맞게 갈아끼우기 때문입니다. +```java +public interface PlatformTransactionManager { + TransactionStatus getTransaction(TransactionDefinition definition); + void commit(TransactionStatus status); + void rollback(TransactionStatus status); +} +``` + +## 셋의 관계 +> PSA → 인터페이스로 추상화해두고 +> +> IoC/DI → 어떤 구현체를 쓸지는 컨테이너가 결정해서 주입하고 +> +> AOP → 그 적용은 프록시로 이루어진다 + + +# 2️⃣ Spring Bean과 Bean 라이프사이클/Scope에 대해 +___ +## 1. Spring Bean이란? +**스프링 빈은 스프링 컨테이너가 생성하고 관리하는 객체**입니다. +`new`로 만든 객체는 그냥 객체고, 컨테이너가 관리하면 빈입니다. 이 둘의 차이는 컨테이너가 관리하느냐 뿐입니다. +```java +TestService a = new TestService(repo); // 그냥 객체 + +@Service +public class TestService { ... } // 빈 +``` +왜 굳이 컨테이너에게 맡길까요? 의존성을 자동으로 주입하려면 주입할 객체가 컨테이너에 있어야 하고, AOP 프록시를 씌우려면 객체 생성을 컨테이너가 해야 합니다. +빈은 IoC/DI/AOP가 성립하기 위한 전제입니다. + +## 빈 등록 방법 +### 방법1: 어노테이션 + 컴포넌트 스캔 +```java +@Component // 가장 기본 +@Controller // 웹 요청 처리 +@Service // 비즈니스 로직 +@Repository // 데이터 접근 +``` +`@Controller`, `@Service`, `@Repository`는 사실 `@Component`를 포함하고 있습니다. +네 개의 어노테이션이 모두 컴포넌트 스캔에 잡히는데 그럼 왜 나눠놨을까요?
+첫째, 읽는 사람이 역할을 바로 알 수 있게 하기 위함입니다. `@Service`가 붙어 있으면 비주니스 로직임을 바로 알 수 있습니다. +둘째, 추가 기능이 붙습니다. `@Repository`는 DB 예외를 스프링의 공통 예외(`DataAccessException`)로 변환해줍니다. JPA 예외든 JDBC 예외든 같은 타입으로 잡을 수 있게 됩니다. +이것도 PSA의 사례라고 볼 수 있습니다. +### 방법2: `@Configuration` + `@Bean` +```java +@Configuration +public class AppConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} +``` +메서드 반환값이 빈으로 등록되고, 빈 이름은 메서드 이름(`passwordEncoder`)가 됩니다. 외부 라이브러리 클래스는 소스를 수정할 수 없으므로, 내가 만든 `@Configuration` 클래스 안에 메서드를 만들고 `@Bean`을 붙여 반환값을 등록합니다. + +## 어노테이션 무엇인가 +어노테이션 자체는 아무 기능이 없습니다. +```java +public @interface MyAnnotation { + String value() default ""; +} +``` +`@interface`로 선언하는 특별한 인터페이스일 뿐이고 컴파일하면 `Annotation` 인터페이스를 상속한 타입이 됩니다. +주석에 붙이는 메타데이터이지, 그 자체로는 아무 동작을 하지 않습니다. +동작은 읽는 쪽이 만듭니다. 스프링이 리플렉션으로 클래스를 훑다가 `@Service`가 붙어 있는 걸 발견하면 "이건 빈으로 등록해야지"하고 처리합니다. + +## 컴포넌트 스캔 +`@SpringBootApplication`을 자세히 뜯어보면 아래와 같습니다. +```java +@SpringBootConfiguration +@EnableAutoConfiguration +@ComponentScan // ← 이것 +public @interface SpringBootApplication { ... } +``` +`@ComponentScan`은 자기가 있는 패키지부터 하위 전체를 스캔하여 `@Component` 계열이 붙은 클래스를 찾아 빈으로 등록합니다. +스캔 과정을 대략 정리하면 아래와 같습니다. +1. 기준 패키지 아래 `.class` 파일을 전부 찾습니다. +2. 각 파일의 메타데이터를 읽어 `@Component` 계열인지 확인합니다. +3. 맞으면 빈 정의(BeanDefinition)로 등록합니다. +4. 등록된 정의를 바탕으로 실제 객체 생성 및 의존성 주입을 수행합니다. + +## 하나의 인터페이스, 여러 구현체 +빈 이름은 기본적으로 클래스명의 첫 글자를 소문자로 바꾼 것입니다.
+`TestService` -> `testService`
+같은 이름이 두 개면 어플리케이션이 뜨지 않습니다. 그리고 타입이 같은 빈이 여러 개여도 문제가 됩니다. + +```java +public interface PaymentService { void pay(); } + +@Service +public class CardPayment implements PaymentService { ... } + +@Service +public class KakaoPayment implements PaymentService { ... } +``` +위 코드에서 `PaymentService`의 의존성을 주입하려고 하면 스프링이 둘 중 뭘 넣을지 몰라 `NoUniqueBeanDefinitionException`이 발생합니다. +### 해결 1: `@Primary` - 기본값 지정 +```java +@Service +@Primary +public class CardPayment implements PaymentService { ... } +``` +### 해결 2: `@Quallifier` - 이름으로 지정 +```java +@Service +@Qualifier("kakao") +public class KakaoPayment implements PaymentService { ... } + +// 주입할 때 +public OrderService(@Qualifier("kakao") PaymentService paymentService) { ... } +``` +### 해결 3: 전부 주입 받기 - 구현체가 여럿인 게 자연스러운 경우 +```java +@Service +public class PaymentProcessor { + private final List services; // 모든 구현체가 들어옴 +} +``` +`@Primary`와 `@Qualifier`가 같이 있으면 `@Qualifier`가 더 우선으로 작용합니다. + +## 빈 라이프사이클 +빈 라이프사이클은 기본적으로 아래와 같습니다. +``` markdown +컨테이너 생성 + ↓ +빈 정의 읽기 (컴포넌트 스캔 / @Bean) + ↓ +객체 생성 (생성자 호출 — 생성자 주입은 여기서 발생) + ↓ +의존관계 주입 (필드 주입 / 수정자 주입) + ↓ +초기화 콜백 ← @PostConstruct + ↓ + 사용 + ↓ +소멸 전 콜백 ← @PreDestroy + ↓ +컨테이너 종료 +``` +생성자는 객체를 만드는 시점이기에 아직 의존성이 다 안 들어와 있을 수도 있어서 생성과 초기화를 분리합니다. +### 초기화/소멸 콜백을 지정하는 세 가지 방법 +방법 1: **인터페이스 구현 +스프링이 제공하는 인터페이스를 구현하는 방식**입니다. +```java +@Component +public class DbConnector implements InitializingBean, DisposableBean { + + @Override + public void afterPropertiesSet() { // 초기화 + System.out.println("연결 시작"); + } + + @Override + public void destroy() { // 소멸 + System.out.println("연결 종료"); + } +} +``` +가장 오래된 방식이고 지금은 거의 사용하지 않습니다.
+2가지 단점이 있는데 첫째, `InitializingBean`과 `DisposableBean`이 스프링 인터페이스라 내 코드가 스프링에 묶입니다. +나중에 다른 프레임워크로 옮기려면 클래스를 고쳐야 됩니다. 둘째, 메서드 이름을 `afterPropertiesSet`, `destroy`로 고정해야 합니다.
+ +방법 2: **`@Bean` 속성으로 지정** +```java +@Configuration +public class AppConfig { + + @Bean(initMethod = "init", destroyMethod = "close") + public DbConnector dbConnector() { + return new DbConnector(); + } +} +``` +```java +public class DbConnector { // 어노테이션이 하나도 없음 + + public void init() { + System.out.println("연결 시작"); + } + + public void close() { + System.out.println("연결 종료"); + } +} +``` +`DbConnector` 클래스는 스프링 관련 코드가 한 줄도 없는 평범한 자바 클래스입니다. 어떤 메서드를 언제 부를지는 설정파일(`AppConfig`) 쪽에서 정합니다.
+그래서 **이 방식은 외부 라이브러리 클래스에 사용**합니다. 다른 사람의 코드에는 `@PostConstruct`를 붙일 수 없으니, 밖에서 "이 메서드를 초기화 시점에 불러줘"라고 지정하는 겁니다.
+ +방법 3: **어노테이션** -> 권장하는 방식 +```java +@Component +public class DbConnector { + + @PostConstruct + public void init() { + System.out.println("연결 시작"); + } + + @PreDestroy + public void close() { + System.out.println("연결 종료"); + } +} +``` +메서드에 붙이기만 하면 끝이고, 이름도 자유롭게 지을 수 있습니다.
+또한 `@PostConstruct`와 `@PreDestroy`는 스프링이 만든 것이 아니고 자바 표준 스펙인 `jakarta.annotation`에 속합니다. +표준이라서 좋은 점은 아래 3가지 정도가 있습니다. +1. 스프링에 종속되지 않습니다. 이 클래스를 다른 DI 컨테이너로 옮겨도 그대로 동작합니다. 표준을 지원하는 컨테이너라면 어디서든 `@PostConstruct`를 인식하기 때문입니다. +2. 코드가 간결합니다. 인터페이스를 구현할 필요도, 설정 클래스를 따로 만들 필요도 없습니다. +3. 메서드 이름이 자유롭습니다. `init`,`setup`,`connect` 등 어떠한 것을 사용하든 상관이 없습니다. + +## 빈 스코프 +**빈 스코프는 빈이 얼마나 오래 사는지를 정하는 것**입니다. + +| 스코프 | 범위 | +|---|---| +| **singleton** | 컨테이너당 하나. 기본값 | +| **prototype** | 요청할 때마다 새로 생성. 생성 이후에는 관리하지 않음 | +| **request** | HTTP 요청 하나당 하나. 요청이 끝나면 소멸 | +| **session** | HTTP 세션 하나당 하나 | +| **application** | 서블릿 컨텍스트(ServletContext)당 하나 | + +```java +@Component +@Scope("prototype") +public class MyBean { ... } +``` +빈은 기본이 싱글톤: 웹 앱은 동시에 수많은 요청을 받습니다. 요청마다 객체를 새로 만들면 메모리와 GC의 부담이 커져 하나만 만들어 공유합니다.
+그래서 따라오는 제약: **싱글톤 빈은 상태를 가지면 안 됩니다**. +```java +@Service +public class BadService { + private int count = 0; // ← 위험 + + public void add() { + count++; // 여러 요청이 동시에 건드림 + } +} +``` +모든 요청이 같은 객체를 쓰기 때문에 이 필드는 공유됩니다. 그에 따라 동시성 문제가 생깁니다. +그래서 스프링 빈은 보통 필드에 `final` 의존성만 두고 상태를 갖지 않습니다.
+prototype의 함정: 스프링은 prototype 빈을 만들어서 넘겨준 뒤로는 관리하지 않습니다. 그래서 `@PreDestroy`가 호출되지 않습니다. +정리가 필요하면 직접 해야 합니다.
+```java +@Component +@Scope("prototype") +public class PrototypeBean { ... } + +@Service +public class SingletonService { + private final PrototypeBean bean; // 주입은 딱 한 번 +} +``` +위 코드처럼 싱글톤 빈이 protype 빈을 주입받으면, protype인데도 하나만 만들어져서 계속 재사용됩니다. 주입은 싱글톤이 생성될 때 딱 한 번 일어나기 때문입니다.
+이 문제를 해결하기 위한 방법은 크게 2가지가 있습니다.
+주입받은 걸 계속 쓰는 것이 아니라, 필요할 때마다 컨테이너에 새로 요청하는 방식입니다. +### ObjectProvider +```java +@Service +@RequiredArgsConstructor +public class SingletonService { + + private final ObjectProvider provider; + + public void doSomething() { + PrototypeBean bean = provider.getObject(); // 호출할 때마다 새 인스턴스 + } +} +``` +`ObjectProvider`는 빈 자체가 아니라 빈을 꺼내주는 창구를 주입받는 겁니다. `getObject()`를 부를 때마다 컨테이너에 새로 요청하니, prototype이면 매번 새 객체가 나옵니다. +### @Lookup +```java +@Service +public abstract class SingletonService { + + @Lookup + protected abstract PrototypeBean getBean(); // 스프링이 구현해줌 + + public void doSomething() { + PrototypeBean bean = getBean(); // 호출할 때마다 새 인스턴스 + } +} +``` +추상 메서드만 선언해두면 스프링이 "컨테이너에서 꺼내오는" 구현을 만들어 넣습니다. 클래스를 +추상으로 만들어야 해서 요즘은 `ObjectProvider`를 더 많이 사용합니다. + +# 3️⃣ Spring MVC 심층 분석 +___ +## Servlet +웹 서버가 하는 일을 아주 단순하게 보면 아래와 같습니다. +> 클라이언트 ──(요청)──→ 서버 ──(응답)──→ 클라이언트 + +여기서 요청과 응답은 사실 텍스트 덩어리입니다. +```markdown +GET /tests HTTP/1.1 +Host: localhost:8080 +Accept: application/json +``` +위 요청을 처리한다고 생각해봅시다. 소켓을 열고, 연결을 기다리고, 바이트를 읽고, 첫 줄을 +파싱해서 메서드와 경로를 꺼내고, 헤더를 하나씩 잘라 맵에 담고, 바디를 읽는 등의 매우 많은 일련의 과정을 거친 후에야 +비즈니스 로직을 시작할 수 있습니다. 응답도 마찬가지로 상태줄과 헤더를 직접 조립해야 합니다.
+즉 요청 하나 처리하는 데 진짜 로직은 한 줄이고 나머지가 전부 진짜 로직과는 상관없는 일입니다. +### Servlet이 하는 일 +위의 진짜 로직과 상관없는 일을 대신 해주는 것이 바로 Servlet(서블릿)입니다. +```java +public class TestServlet extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) { + String name = request.getParameter("name"); // 파싱 다 돼서 들어옴 + + response.setContentType("application/json"); + response.getWriter().write("{\"name\": \"" + name + "\"}"); + } +} +``` +위 코드에는 HTTP 텍스트를 파싱하는 코드는 어디에도 없습니다. 서블릿은 HTTP 요청/응답의 저수준 처리를 대신 해주고, 개발자는 비즈니스 로직에만 집중하게 해주는 자바 표준 기술입니다.
+이때 서블릿은 인터페이스 스펙으로 `jakarta.servlet` 패키지에 정의돼 있고 서블릿 객체는 요청마다 새로 만들지 않고 하나를 공유한다. + +## WAS와 톰캣 +### 웹 서버와 WAS +웹 서버는 정적 파일(HTML,CSS,이미지 등)을 그대로 돌려줍니다. Nginx, Apache가 대표적입니다.
+> GET /logo.png → 파일 찾아서 그대로 전송 + +WAS(Web Application Server)는 프로그램을 실행해서 결과를 만들어 돌려줍니다. +> GET /tests → DB 조회 → JSON 생성 → 전송 + +WAS는 웹 서버 기능을 포함하여 WAS만으로도 서비스가 가능합니다. + +### 톰캣 +톰캣은 서블릿 스펙을 구현한 WAS입니다. 정확히는 서블릿 컨테이너라고 부릅니다.
+컨테이너라는 이름이 붙은 이유는 앞에서 본 스프링 컨테이너처럼 서블릿 객체를 대신 만들고 생명주기를 관리하기 때문입니다. +```markdown +톰캣이 하는 일 +├── 소켓 열고 연결 대기 +├── HTTP 텍스트 파싱 → HttpServletRequest 객체 생성 +├── 요청 URL에 맞는 서블릿 찾기 +├── 그 서블릿의 doGet/doPost 호출 ← 여기서 내 코드 실행 +├── HttpServletResponse 내용을 HTTP 텍스트로 변환 +└── 전송하고 연결 종료 +``` +여기서도 IoC를 확인할 수 있습니다. 내가 톰캣을 호출하는 것이 아니라, 톰캣이 내 서블릿을 호출합니다. +서블릿 객체를 만드는 것도 톰캣이 하여 앞서 본 '프레임워크가 내 코드를 호출한다'는 구조가 그대로 반복됩니다.
+톰캣은 요청마다 스레드를 하나씩 배정합니다. 톰캣은 스레드 풀을 미리 만들어두고, 요청이 현재 비어 있는 스레드에 맡깁니다. 스레드를 매번 +생성/소멸하면 비싸기 때문입니다.
+ +### 내장 톰캣 +`build.gradle`을 톰캣에 따로 넣은 적이 없는데 어플리케이션을 실행하면 아래와 같은 로그가 찍힙니다. +>Tomcat started on port 8080 (http) + +`spring-boot-starter-web` 안에 톰캣이 포함돼 있기 때문입니다. 예전에는 WAS를 서버에 따로 설치하고 war 파일을 배포했는데, 스프링 부트는 +톰캣을 라이브러리로 품고 jar 하나로 실행합니다. 이것을 **내장 톰캣**이라고 합니다. + +## MVC 패턴 +서블릿 코드를 하나 보겠습니다. +```java +protected void doGet(HttpServletRequest request, HttpServletResponse response) { + // 1. 파라미터 꺼내기 + Long id = Long.parseLong(request.getParameter("id")); + + // 2. DB 조회 + Test test = testRepository.findById(id); + + // 3. HTML 만들기 + response.getWriter().write(""); + response.getWriter().write("

" + test.getName() + "

"); + response.getWriter().write(""); +} +``` +한 메서드 안에 **요청 처리**, **비즈니스 로직**, **화면 생성**이 모두 섞여 있습니다.
+화면 디자인만 바꾸고 싶어도 자바 코드를 고쳐야 하고, DB 로직을 바꾸려 해도 같은 파일을 건드립니다. 화면 +담당자와 로직 담당자가 같은 파일을 수정하게 되는 문제가 발생합니다. + +### MVC 패턴 +요청 처리, 비즈니스 로직, 화면 생성을 세 역할로 나눕니다. +```markdown +요청 ──→ Controller ──→ (비즈니스 로직) + │ + ├─ 결과를 Model에 담음 + │ + └─→ View ──→ 응답(HTML) +``` +관심사를 분리하였습니다. 화면을 바꾸면 View만, 로직을 바꾸면 Controller와 그 아래만 건드립니다.
+**MVC는 스프링 것이 아니라 일반적인 소프트웨어 디자인 패턴**입니다. 데스크톱 앱에서 먼저 쓰였고 웹으로 넘어왔습니다. + +### MVC 패턴과 Spring MVC의 차이 +**MVC는 패턴(설계 아이디어)이고, Spring MVC는 그 패턴을 스프링이 서블릿 위에서 구현한 웹 프레임워크**입니다.
+Spring MVC가 MVC 패턴에 더한 것들: +- Front Controller 패턴: 모든 요청을 하나의 입구(`DispatcherSevlet`)로 받음 +- 어노테이션 기반 매핑: `@GetMapping` 하나로 URL 연결 +- 자동 변환: 파라미터를 객체로, 반환값을 JSON으로 +- IoC 컨테이너 통합: 컨트롤러가 빈이라 DI, AOP가 그대로 적용됨 + ++ 요즘 REST API에서는 View가 없습니다. +```java +@RestController +public class TestController { + @GetMapping("/tests") + public List findAllTests() { + return testService.findAllTests(); // JSON으로 바로 나감 + } +} +``` +`@RestController` = `@Controller` + `@ResponseBody`인데, `@ResponseBody`는 **View를 거치지 말고 반환값을 그대로 응답 본문에 쓰라**는 뜻입니다. +그래서 사실상 Model-View 부분이 생략된 형태로 쓰이고 있습니다. + +## DispatcherServlet +서블릿만으로 웹앱을 만들면 URL마다 서블릿을 만들어야 합니다. +```markdown +/tests → TestServlet +/users → UserServlet +/orders → OrderServlet +``` +위 코드 상으로는 각 서블릿마다 공통 처리(인코딩 설정,로그인 확인,로깅 등)를 반복해야 합니다. + +### Front Controller 패턴 +입구를 하나로 만듭니다. +```markdown + ┌─→ TestController +모든 요청 ──→ [ 하나의 서블릿 ] ─┼─→ UserController + └─→ OrderController +``` +공통처리는 입구에서 한 번만 하고, 각 컨트롤러는 자기 일만 합니다. 이 입구 역할을 하는 것이 +`DispatcherServlet`입니다.
+여기서 중요한 건 `DispatcherServlet`도 서블릿입니다. `HttpServlet`을 상속받은 클래스입니다. +톰캣 입장에서는 그냥 서블릿 하나가 등록된 것뿐이고, 그 서블릿이 내부적으로 복잡한 일을 할 뿐입니다. + +### 전체 흐름 +```markdown +① 클라이언트 요청 + ↓ + [ DispatcherServlet ] + ↓ +② HandlerMapping — 이 URL을 처리할 컨트롤러 찾기 + ↓ +③ HandlerAdapter — 찾은 핸들러를 실행할 어댑터 선택 + ↓ +④ 핸들러(컨트롤러) 실행 ← 내가 짠 코드 + ↓ +⑤ ModelAndView 반환 + ↓ +⑥ ViewResolver — 뷰 이름으로 실제 View 찾기 + ↓ +⑦ View 렌더링 + ↓ +⑧ 응답 +``` +`@RestController`를 쓰면 ⑥,⑦이 없습니다. 대신 `HttpMessageConverter`가 반환 객체를 JSON으로 변환해서 바로 응답 본문에 씁니다. + +### doDispatch 코드 확인 +```java +protected void doDispatch(HttpServletRequest request, HttpServletResponse response) { + + // ② 핸들러 찾기 + mappedHandler = getHandler(processedRequest); + if (mappedHandler == null) { + noHandlerFound(processedRequest, response); // → 404 + return; + } + + // ③ 어댑터 찾기 + HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler()); + + // 인터셉터 preHandle + if (!mappedHandler.applyPreHandle(processedRequest, response)) { + return; + } + + // ④ 실제 컨트롤러 실행 + mv = ha.handle(processedRequest, response, mappedHandler.getHandler()); + + // 인터셉터 postHandle + mappedHandler.applyPostHandle(processedRequest, response, mv); + + // ⑥⑦ 뷰 처리 + processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException); +} +``` +- `getHandler()`: 등록된 `HandlerMapping`들을 순회하며 처리 가능한 걸 찾습니다. 여기서 +못 찾으면 `noHandlerFound`로 가고 404가 뜨게 됩니다.
+-> CEOS Spring tutorial 과제를 진행하면서 api 호출을 할 때 엔드포인트가 /tests인 것을 /test로 잘못 쳐 +Whitelabel이 떴던 게 생각났고 그 원리에 대해 이번 기회로 알 수 있게 된 거 같습니다. + +### 어댑터는 왜 필요한가? +`doDispatch`를 읽으며 왜 핸들러를 바로 호출하지 않고 어댑터를 거치지?라는 의문이 들었습니다.
+찾아본 결과 컨트롤러를 만드는 방식이 여러 가지이기 때문입니다. +```java +@Controller // 어노테이션 방식 (요즘) +public class TestController { } + +public class OldController implements Controller { // 인터페이스 방식 (구버전) + public ModelAndView handleRequest(...) { } +} +``` +호출 방법이 서로 다른데, `DispatcherServlet`이 이걸 다 알면 새 방식이 생길 때마다 고쳐야 합니다. 그래서 +중간에 어댑터를 두고 `handle()`이라는 공통 인터페이스로만 호출합니다.
+이것이 어댑터 패턴이고, 앞에서 본 PSA와 같은 발상입니다. 인터페이스로 추상화 해두고 구현체를 갈아끼우는 것입니다. +## 전체 그림 +```markdown +클라이언트 +↓ HTTP 요청 +[ 톰캣 (WAS / 서블릿 컨테이너) ] +↓ HttpServletRequest 생성, 스레드 배정 +[ DispatcherServlet ] ← Front Controller, 서블릿의 일종 +↓ HandlerMapping으로 컨트롤러 탐색 +↓ HandlerAdapter로 실행 +[ Controller ] ← 스프링 빈 (DI, AOP 적용됨) +↓ +[ Service ] → [ Repository ] → DB +↓ +반환값 → HttpMessageConverter → JSON +↓ +[ 톰캣 ] → HTTP 응답 텍스트로 변환 +↓ +클라이언트 +``` +# 5️⃣ (선택) CGV DB 모델링 +___ +## 설계 판단 과정 + +### 용어 분리: 지점 vs 상영관 + +요구사항의 "영화관"이 두 의미로 쓰이고 있었습니다. "모든 영화관에 특별관과 +일반관이 존재해요"의 영화관은 CGV 홍대점 같은 **지점**이고, 그 안의 +특별관/일반관이 실제로 영화를 트는 **상영관**입니다. 한 테이블로 묶으면 +이후 관계가 전부 꼬이므로 `branch`와 `theater`로 분리했습니다. + +특별관/일반관을 각각 엔티티로 두는 것도 고려했으나 두 테이블의 속성이 +동일했고, 관 종류가 늘어날 때마다 테이블을 추가해야 합니다. 종류만 다른 +같은 성격의 것이므로 하나의 테이블 + 종류 구분으로 표현했습니다. + +### 좌석 설계 + +요구사항의 두 단서가 설계를 결정했습니다. + +- "종류가 같다면 좌석은 동일해요" → 배치는 상영관이 아니라 **종류**에 붙습니다 +- "직사각형, 중간에 비어있는 곳 없음" → **행·열 두 숫자로 배치가 결정됩니다** + +따라서 좌석 테이블을 만들지 않고 `theater_type`에 `row_count`, `col_count` +두 컬럼만 두었습니다. 지점 30개 × 상영관 8개여도 배치는 종류 수만큼만 +저장됩니다. + +**트레이드오프**: 좌석 데이터가 없으므로 예매 시 좌석을 FK로 참조할 수 +없습니다. 10×15 상영관에 `row_num = 20`으로 예매해도 DB가 막지 못하며 범위 +검증은 애플리케이션 책임입니다. 좌석별 속성(커플석 등)이 생기면 구조 변경이 +필요합니다. 요구사항에 좌석별 차이가 없고 직사각형이 보장되므로 감수할 +만하다고 판단했습니다. + +이 선택의 결과로 "예매 가능한 좌석"은 DB에 존재하지 않습니다. 조회 시 +`theater_type`의 크기와 `reservation_seat`의 예매된 좌석 목록을 받아 +애플리케이션에서 나머지를 계산합니다. + +### 예매/구매를 두 테이블로 분리 + +한 번의 예매에 여러 좌석이 선택됩니다. 단일 테이블에 좌석마다 한 행씩 +저장하면 이 행들이 한 건의 예매라는 것을 표현할 수 없습니다. 회원과 회차로 +묶으려 해도 같은 사람이 같은 회차를 두 번 나눠 예매한 경우를 구분하지 +못해 취소 처리가 불가능합니다. + +- `reservation` — 예매 행위 (누가, 언제, 어느 회차, 상태) +- `reservation_seat` — 선택한 좌석들 (행, 열, 가격) + +취소는 부모의 `status`만 변경하면 됩니다. 매점 구매도 같은 상황이므로 +`purchase` / `purchase_product`에 동일 구조를 적용했습니다. + +### 가격을 시점별로 복사 저장 + +정가는 `screening.price`, `product.price`에 두되, 결제 시점 금액은 +`reservation_seat.paid_price`, `purchase_product.unit_price`에 복사합니다. + +원본 가격을 UPDATE하면 과거 결제 기록의 금액까지 바뀌기 때문입니다. 작년에 +12,000원에 예매한 기록이 가격 인상 후 15,000원으로 보이면 사실과 달라집니다. +정규화 관점의 중복이지만 **과거 사실은 변하지 않아야 한다**는 원칙을 +우선했습니다. + +반대로 회차의 영화 제목 등은 "지금의 사실"을 가리키므로 복사하지 않고 +FK로 참조합니다. **지금의 사실은 참조하고, 그때의 사실은 복사합니다.** + +### 찜을 두 테이블로 분리 + +하나로 합치려면 `(대상_타입, 대상_id)` 형태가 되는데, 이 방식 은 `대상_id`에 FK를 걸 수 없습니다. DB가 영화 +테이블을 봐야 할지 지점 테이블을 봐야 할지 모르기 때문에, 존재하지 않는 +영화 999번을 찜해도 막지 못합니다. + +테이블이 늘더라도 참조 무결성을 확보하는 쪽을 택했습니다. PK를 +`(member_id, 대상_id)` 복합키로 두어 중복 찜도 자동으로 방지됩니다. + +### 매점 테이블 제거 + +`매점`을 엔티티로 두었으나 컬럼을 채우려 하니 `id`와 `branch_id` 외에 +담을 것이 없었습니다. 지점당 매점이 하나이고 고유 속성이 없으므로 지점 +테이블과 실질적으로 동일합니다. 재고가 지점을 직접 참조하도록 했습니다. + +한편 요구사항의 두 문장이 서로 다른 것을 말합니다. "메뉴는 같아요"는 공통, +"재고는 따로 관리해요"는 개별입니다. 따라서 상품 정보는 `product`에 전 지점 +공통으로, 재고는 `stock(branch_id, product_id, quantity)`에 지점별로 +두었습니다. 좌석에서 "공통 배치는 종류에, 개별 정보는 상영관에" 나눈 것과 +같은 구조입니다. + +### 가격을 회차에 배치 + +요구사항에 가격 언급은 없었으나, 조조·심야 할인이나 특별관 추가 요금처럼 +가격을 결정하는 요인은 영화가 아니라 상영 조건입니다. 영화에 두면 "같은 +영화는 언제 어디서 봐도 같은 가격"이 되어 확장 시 구조 변경이 필요합니다. + +### 중복 예매 방지 + +`reservation_seat`에 `(screening_id, row_num, col_num)` 유니크 제약을 +걸었습니다. 이를 위해 부모에 있는 `screening_id`를 자식에도 중복 +저장했습니다. 비정규화지만 애플리케이션 로직만으로는 동시 요청을 완전히 +막을 수 없습니다. + +이 제약은 취소와 충돌합니다. 취소된 좌석 행이 남아 있으면 다른 사람이 같은 +자리를 예매할 때 제약에 걸립니다. **취소 시 `reservation_seat` 행을 삭제하는 +방식**을 택했고, 그 결과 어느 좌석을 취소했는지에 대한 이력은 남지 않습니다. +요구사항에 취소 이력 조회가 없고, DB 차원의 무결성 보장이 애플리케이션 +로직보다 확실하다고 판단했습니다. + +### 나이를 생년월일로 저장 + +영화 관람 등급이 있어 나이 정보가 필요합니다. 나이를 정수로 저장하면 매년 +값이 틀려지고 전 회원을 갱신해야 하므로, 변하지 않는 사실인 `birth_date`를 +저장하고 나이는 조회 시 계산합니다. + +--- + +## 한계 + +- **좌석 범위 검증**: 상영관 크기를 벗어난 좌석 예매를 DB가 막지 못합니다. +- **취소 이력**: 취소 시 좌석 행을 삭제하므로 어느 좌석이 취소되었는지 + 추적할 수 없습니다. 필요하다면 별도 로그 테이블이 있어야 합니다. +- **상영 시간 겹침**: `screening`에 종료 시각이 없어 같은 상영관의 회차가 + 겹치는지 검증하려면 `movie.running_time`을 조인해 계산해야 합니다. +- **나이 제한 검증**: `movie.age_rating`과 생년월일 비교는 애플리케이션 + 책임입니다. + +![img_1.png](img_1.png) diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..2863495 --- /dev/null +++ b/build.gradle @@ -0,0 +1,36 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.1.1' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.ceos24' +version = '0.0.1-SNAPSHOT' +description = 'Demo project for Spring Boot' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.mysql:mysql-connector-j' + + testImplementation('org.springframework.boot:spring-boot-starter-test') +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ad7845b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/img_1.png b/img_1.png new file mode 100644 index 0000000..8565e5c Binary files /dev/null and b/img_1.png differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..19b5204 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'spring-boot' diff --git a/src/main/java/com/ceos24/spring_boot/Application.java b/src/main/java/com/ceos24/spring_boot/Application.java new file mode 100644 index 0000000..f95a7cb --- /dev/null +++ b/src/main/java/com/ceos24/spring_boot/Application.java @@ -0,0 +1,34 @@ +package com.ceos24.spring_boot; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; + +import java.util.Arrays; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + @Bean + public CommandLineRunner commandLineRunner(ApplicationContext ctx) { + return args -> { + + System.out.println("Let's inspect the beans provided by Spring Boot:"); + + // Spring Boot 에서 제공되는 Bean 확인 + String[] beanNames = ctx.getBeanDefinitionNames(); + Arrays.sort(beanNames); + for (String beanName : beanNames) { + System.out.println(beanName); + } + + }; + } + +} diff --git a/src/main/java/com/ceos24/spring_boot/HelloController.java b/src/main/java/com/ceos24/spring_boot/HelloController.java new file mode 100644 index 0000000..f6c5a5d --- /dev/null +++ b/src/main/java/com/ceos24/spring_boot/HelloController.java @@ -0,0 +1,13 @@ +package com.ceos24.spring_boot; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HelloController { + + @GetMapping("/") + public String hello() { + return "Hello, Spring Boot!"; + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos24/spring_boot/Test.java b/src/main/java/com/ceos24/spring_boot/Test.java new file mode 100644 index 0000000..12a85b1 --- /dev/null +++ b/src/main/java/com/ceos24/spring_boot/Test.java @@ -0,0 +1,14 @@ +package com.ceos24.spring_boot; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.Data; + +@Data +@Entity +public class Test { + + @Id + private Long id; + private String name; +} diff --git a/src/main/java/com/ceos24/spring_boot/TestController.java b/src/main/java/com/ceos24/spring_boot/TestController.java new file mode 100644 index 0000000..e8cbc6c --- /dev/null +++ b/src/main/java/com/ceos24/spring_boot/TestController.java @@ -0,0 +1,21 @@ +package com.ceos24.spring_boot; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/tests") +public class TestController { + + private final TestService testService; + + @GetMapping + public List findAllTests() { + return testService.findAllTests(); + } +} diff --git a/src/main/java/com/ceos24/spring_boot/TestRepository.java b/src/main/java/com/ceos24/spring_boot/TestRepository.java new file mode 100644 index 0000000..91a43ee --- /dev/null +++ b/src/main/java/com/ceos24/spring_boot/TestRepository.java @@ -0,0 +1,5 @@ +package com.ceos24.spring_boot; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TestRepository extends JpaRepository {} diff --git a/src/main/java/com/ceos24/spring_boot/TestService.java b/src/main/java/com/ceos24/spring_boot/TestService.java new file mode 100644 index 0000000..69d7a1c --- /dev/null +++ b/src/main/java/com/ceos24/spring_boot/TestService.java @@ -0,0 +1,20 @@ +package com.ceos24.spring_boot; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TestService { + + private final TestRepository testRepository; + + /* Read All*/ + @Transactional(readOnly = true) + public List findAllTests() { + return testRepository.findAll(); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..f80146f --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,21 @@ +spring: + application: + name: spring-boot + + datasource: + url: jdbc:mysql://localhost:3306/${database}?allowPublicKeyRetrieval=true&useSSL=false&characterEncoding=UTF-8 + username: root + password: ${password} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + format_sql: true + +logging: + level: + org.hibernate.SQL: debug diff --git a/src/test/java/com/ceos24/spring_boot/ApplicationTests.java b/src/test/java/com/ceos24/spring_boot/ApplicationTests.java new file mode 100644 index 0000000..26bf847 --- /dev/null +++ b/src/test/java/com/ceos24/spring_boot/ApplicationTests.java @@ -0,0 +1,13 @@ +package com.ceos24.spring_boot; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/ceos24/spring_boot/HelloControllerTest.java b/src/test/java/com/ceos24/spring_boot/HelloControllerTest.java new file mode 100644 index 0000000..c77cc6d --- /dev/null +++ b/src/test/java/com/ceos24/spring_boot/HelloControllerTest.java @@ -0,0 +1,30 @@ +package com.ceos24.spring_boot; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +public class HelloControllerTest { + + @Autowired + private MockMvc mvc; + + @DisplayName("HelloController의 hello() 메서드 테스트") + @Test + public void getHello() throws Exception{ + mvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(result -> { + String response = result.getResponse().getContentAsString(); + assert response.equals("Hello, Spring Boot!"); + }); + } +}