14:00 ~ 15:00 Spring 심화 개인과제 해설
3일간의 예비군 이슈로 부재를 하여 과제를 손도 대지 못하였다. 주말동안 강의와 특강 세션에 집중을 하다보니 시간이 남아 도질 않았다. 주말에는 (1:1, 1:N, N:1, N:M) 연관관계, proxy, 지연로딩/즉시로딩, Bean 생명주기 등 강의를 집중적으로 들었고 따로 포스팅은 하지 않을 예정이다.
1️⃣ API 예외처리
✅ @ExceptionHandler
컨트롤러 클래스 내 특정 예외 처리하는 메소드를 정의하는 Annotation

- 특정 컨트롤러에서 발생한 예외를 처리하기 위한 메서드를 지정한다.
- 단일 컨트롤러 내 예외 처리
- 계층별로 알맞은 예외를 발생 ( throw new ) 시키기만 하면 됨.
@RestController
@RequiredArgsConstructor
public class ExceptionHandlerController {
private final ExceptionHandlerService exceptionHandlerService;
@RequestMapping("/v1/exception")
public void illegalArgumentException() {
throw new IllegalArgumentException("IllegalArgumentException 발생");
}
@RequestMapping("/v2/exception")
public void nullPointerException() {
throw new NullPointerException("NPE 발생");
}
@RequestMapping("/v3/exception")
public void serviceLayerException() {
exceptionHandlerService.throwNewIllegalArgumentException();
}
// IllegalArgumentException을 처리하는 메서드
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleIllegalArgumentException(IllegalArgumentException ex) {
Map<String, String> response = new HashMap<>();
response.put("error", ex.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(response);
}
}
@RestController
public class NonExceptionHandlerController {
@RequestMapping("/v4/exception")
public void illegalArgumentException() {
throw new IllegalArgumentException("IllegalArgumentException 발생");
}
}
서비스
@Service
public class ExceptionHandlerService {
public void throwNewIllegalArgumentException() {
throw new IllegalArgumentException("ServiceLayer Exception");
}
}
500에러가 최대한 뜨지 않게 하는것이 안전한 서버다.
💥 문제점
- Controller 코드에 Exception 처리를 위한 책임이 추가된다.(단일 책임 원칙 위반)
- 단일 컨트롤러 내의 예외만 처리가 가능하다.(컨트롤러 예외처리 중복코드)
- 코드 재사용, 유지보수성 부족
✅ @ControllerAdvice
@ControllerAdvice와 동일한 기능을 제공하지만 @RestController를 포함하고 있어 반환 값이 자동으로 JSON 형태로 변환되며 REST API에서 발생하는 예외를 처리할 때 사용

Application 전역에서 발생한 예외를 처리한다.
⚡ @RestControllerAdvice
- REST API의 예외를 처리
- 반환 값이 JSON 이다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleIllegalArgumentException(IllegalArgumentException ex) {
Map<String, String> response = new HashMap<>();
response.put("error", ex.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(response);
}
}
@ExceptionHandler 이 선언되지 않은 컨트롤러의 예외처리 가능
| Annotation | 용도 | 반환 형식 | 예시 처리 범위 |
| @ExceptionHandler | 특정 컨트롤러 내 발생한 예외 처리 | 뷰 이름, String | 단일 컨트롤러 내 |
| @ControllerAdvice | 애플리케이션 전체에서 발생한 예외 처리 | 뷰 이름, String | 모든 컨트롤러 전역에 적용 |
| @RestControllerAdvice | REST API에서 발생한 예외를 JSON 형식으로 처리 | ResponseEntity(JSON) | 모든 컨트롤러 전역에 적용 (RESTful 서비스) |
2️⃣ JPQL
✅ JPQL
JPA의 SQL 쿼리 지원
- ⭐ JPQL (Java Persistence Query Language)
- 객체지향 쿼리 언어
- Entity 객체를 대상으로 SQL Query를 작성하도록 도와줌.
- ⭐ QueryDSL
- Java 기반 ORM 쿼리 빌더 라이브러리
- 동적 쿼리를 지원함
- JPA Criteria
- JPQL와 유사한 쿼리를 코드로 생성할 수 있다.
- 복잡하고 실용성 ❌
- Native SQL
- JPA가 제공하는 SQL을 직접 사용하는 기능
- DB 종속적인 Query가 필요시 사용
JPQL 등장배경
- JPA 사용 ➡ 객체 중심적인 설계
- 조회 ( em.find() ) ➡ Entity 객체 그래프 탐색 ( tutor.getCompany() )
- 모든 DB 데이터를 객체로 변환해 조회하는 경우는 사용 ❌
- 특정 조건을 가진 데이터를 검색 ❌
- 실제로 필요한 데이터를 조회하기 위한 조건이 포함된 SQL이 필요
String jpql = "SELECT p FROM Product p WHERE p.price > :minPrice";
List<Product> products = em.createQuery(jpql, Product.class)
.setParameter("minPrice", 1000)
.getResultList();
- SELECT p FROM Product p ( SQL의 SELECT * FROM Product 와 대응 )
- 최소 가격 (minPrice) 이상의 Product 를 조회하는 SQL Query
특징
- 객체를 대상으로 검색하는 객체 지향 쿼리 ( 테이블 ❌ )
- SQL 추상화 ( DB종속 ❌ )
- 다양한 DB에서 사용 가능
- JPA 영속성 컨텍스트를 사용해 1차 캐시, 지연 로딩등의 기능을 활용할 수 있다.
- 타입 안정성
- 컴파일 시점 Entity와 필드의 타입을 확인해 오류를 줄인다.
| 특징 | JPQL | SQL |
| 대상 | Entity와 필드 | 테이블과 컬럼 |
| 표현 방식 | 객체 지향 | 관계형 |
| DB 독립성 | 높음 | 특정 DB에 종속 |
| 호출 방식 | EntityManager.createQuery() | 직접 DB와 연결 |
✅ 동적 쿼리
JPQL은 SQL 문법과 유사한 SELECT, WHERE, GROUP BY, HAVING, JOIN 등을 지원
String jpql = "SELECT p FROM Product p WHERE 1=1";
if (name != null) {
jpql += " AND p.name = :name";
}
if (price != null) {
jpql += " AND p.price = :price";
}
- 특정 조건에 따라 실행되는 SQL이 변함.
JPQL 동적 쿼리
- JPQL은 정적 쿼리에 최적화 되어있다.
- JPQL은 문자열 SQL을 직접 다루어야 하기때문에 문자열을 동적으로 조합해야 한다.
- 가독성과 유지보수가 어려워지고 다루기 까다롭다.
- 오타나 문법 오류가 발생하기 쉽고 컴파일 시점에 검증되지 못한다.
- 조건이 많아지면 많아질수록 JPQL을 사용하는것은 한계가 존재
✅ 기본 문법
JPQL 문법 규칙
- 테이블 이름이 아닌 Entity 이름을 사용한다.(클래스 이름이 Default)
- Entity와 필드는 대소문자를 구분한다.
- JPQL 키워드(SELECT, from, Where)는 대소문자를 구분하지 않는다.
- 별칭(alias)은 필수이고 as 는 생략이 가능
SELECT <별칭> FROM <엔티티 이름> [AS <별칭>] [WHERE 조건] [GROUP BY 속성] [HAVING 조건] [ORDER BY 속성]
select
SUM(p.price), // 상품 가격의 총합
AVG(p.price), // 평균 가격
MAX(p.price), // 최대 가격
MIN(p.price) // 최소 가격
COUNT(p), // 상품 수
from Product p
✅ 반환 타입 및 결과
JPQL에서 데이터베이스 쿼리를 실행하기 위해 Query와 TypedQuery 두 가지 타입을 제공하며 이들은 JPA의 쿼리 API로 작성된 쿼리를 실행하고 결과를 반환받는 데 사용
TypeQuery
- 반환 타입이 명확할 때 사용한다.
- 주로 Entity나 특정 타입의 결과를 반환할 때 사용
- 컴파일 시 타입 검사를 할 수 있다.
TypedQuery<Tutor> typeQuery1 = em.createQuery("select t from Tutor t", Tutor.class);
TypedQuery<String> typeQuery2 = em.createQuery("select t.name from Tutor t", String.class);
Query
- 반환 타입이 명확하지 않을 때 사용한다.
- 결과를 알 수 없거나 다양한 타입의 데이터를 반환할 때 사용
- 결과를 처리할 때 형 변환(casting)이 필요할 수 있다.
Query query = em.createQuery("select t.name, t.age from Tutor t");
| 특징 | Query | TypeQuery |
| 타입 안정성 | 낮음 (결과를 Object로 처리) | 높음 (지정한 타입으로 변환) |
| 사용 목적 | 결과 타입이 명확하지 않을때 | 결과 타입이 명확할때 |
| 형 변환 필요성 | 수동 형 변환 필요 ( Casting ) | 자동 형 변환 지원 |
| 결과 처리 방식 | Object, Object[] | 지정한 클래스 타입 |
결과 조회
- getResultList()
- 결과가 하나 이상일 때 사용한다.
- List 반환
- 결과가 없다면 빈 List 반환
List resultList = em.createQuery("select t from Tutor t").getResultList();
- getSingleResult()
- 결과가 딱 하나일 때 사용한다.
- 결과가 없거나 여러개라면 예외 발생
- NoResultException, NonUniqueResultException
Tutor singleResult = em.createQuery("select t from Tutor t where t.id = 1L", Tutor.class).getSingleResult();
✅ 파라미터 바인딩
파라미터 바인딩은 쿼리에 동적으로 값을 전달해 SQL 인젝션을 방지, 쿼리를 재사용할 수 있도록 돕는 기능
Tutor wonuk = em.createQuery("select t from Tutor t where t.name = :name", Tutor.class)
.setParameter("name", "wonuk")
.getSingleResult();
System.out.println("wonuk.getName() = " + wonuk.getName());
System.out.println("wonuk.getAge() = " + wonuk.getAge());
메소드 체이닝 사용 가능
✅ Embedded Type
JPA는 새로운 값 타입을 @Embedded , @Embeddable 을 사용하여 직접 정의해서 사용할 수 있고 이것을 임베디드 타입이라 한다. 직접 정의한 임베디드 타입도 int, String과 같은 값 타입
Embeded Type
@Entity
public class Tutor {
@Id
@GeneratedValue
private Long id;
private String name;
// Embedded 사용
@Embedded
private Period workPeriod;
}
// Embedded 정의
@Embeddable
public class Period {
@Temporal(TemporalType.DATE)
Date startDate;
@Temporal(TemporalType.Date)
Date endDate;
public boolean isWork (Date date) {
// startDate <= date <= endDate 확인
return (startDate == null || !date.before(startDate)) &&
(endDate == null || !date.after(endDate));
}
}
- 하나의 임베디드 타입으로 정의하여 여러 Entity에서 재사용 가능
- 응집도 증가 → 단일 책임 원칙 준수
- 독립적인 로직 작성 가능
✅ 프로젝션
Entity 특정 필드만 선택하여 조회하는 방식으로 필요한 데이터만 조회하여 성능을 최적화하고 네트워크 비용을 줄일 수 있다.
- 프로젝션
- SELECT 문에 조회할 대상(Entity, Embedded, Scala)을 지정할 수 있다.
- DISTINCT 중복 제거가 가능
SELECT t FROM Tutor t // Entity
SELECT t.company FROM Tutor t // Entity
SELECT t.period // Embedded
SELECT t.name, t.age FROM Tutor t // Scala
SELECT DISTINCT t.name, t.age FROM Tutor t // 중복 제거
- Entity 프로젝션
- Entity 프로젝션을 사용하면 영속성 컨텍스트가 관리한다.
- 연관된 Entity를 JOIN하여 조회할 수 있다
- Embedded 프로젝션
- 나머지 코드 주석 필요
- select period from Period p 는 불가능하다.
- select t from Tutor t where t.period.startDate < ? Embedded
- select t from Tutor t where t.startDate < ? 상속(@MappedSuperclass)
- 상속을 사용하는것이 편리하고 직관적
- Scala 프로젝션
- Scala 프로젝션은 DTO 형태로 반환 받을 수 있다.
묵시적 조인을 사용하면 실행되는 SQL Query를 예측하기 어렵다. 명시적 조인을 사용하자.
✅ Paging
DB의 쿼리 결과를 특정 범위로 제한하는 기능, 대량의 데이터 중 원하는 페이지의 데이터만 조회하기 위해 사용된다.
- JPQL Paging
- setFirstResult(int startPosition)
- 조회 시작 위치
- setMaxResult(int maxResult)
- 조회할 데이터 수
- setFirstResult(int startPosition)
List<Tutor> tutorList = em.createQuery("select t from Tutor t order by t.age desc", Tutor.class)
.setFirstResult(5)
.setMaxResults(10)
.getResultList();
for (Tutor tutor : tutorList) {
System.out.println("tutor = " + tutor.getId() + ", " + tutor.getName() + ", " + tutor.getAge());
}
✅ JOIN
JPQL의 JOIN은 대상을 필터링(ON) 하거나 연관관계가 없는 Entity와 JOIN이 가능
💡 Hibernate 5.1 버전 이상에서 연관관계가 없는 Entity와 JOIN이 가능하도록 지원.
JOIN ON
String query = "select t from Tutor t left join Subject s on t.name = s.name";
List<Tutor> tutorList = em.createQuery(query, Tutor.class).getResultList();
Tutor findTutor = tutorList.get(0);
System.out.println("findTutor.getAge() = " + findTutor.getAge());
- 연관관계가 없지만 on을 사용하여 JOIN이 가능
✅ CASE
JPQL에서 CASE 표현식은 조건에 따라 다른 값을 반환할 때 사용된다. SQL의 CASE 구문과 유사하며 주로 조건에 따라 계산된 값을 반환하거나 쿼리 결과를 조정하는 데 사용
💡 COALESCE : null이 아니면 반환, NULLIF : 같으면 null, 다르면 첫번째 값 반환
String query =
"select" +
" case when t.age < 10 then '어린이'" +
" when t.age >= 80 then '노약자'" +
" else '청년'" +
" end " +
"from Tutor t";
List<String> resultList = em.createQuery(query, String.class).getResultList();
for (String range : resultList) {
System.out.println("range = " + range);
}
문자열 SQL 띄어쓰기 유의
✅ 함수
- 문자열 함수
- CONCAT('a', 'b')
- a, b 문자열을 더한다.
- SUBSTRING('abcde', 2, 3)
- 두번째 글자부터 세글자 문자열을 자른다.
- bcd
- TRIM(' a ')
- 문자열 양쪽 공백제거
- LOWER('A'), UPPER('a')
- 각각 문자열을 소문자, 대문자로 변환한다.
- LENGTH('abcde')
- 문자열의 길이를 반환한다.
- CONCAT('a', 'b')
- 숫자 함수
- ABS(-100)
- 절대값을 반환한다.
- MOD(100, 2)
- 나머지를 계산한다.
- SIZE(t.company)
- 연관된 컬렉션의 크기를 반환
- ABS(-100)
Tutor tutor = new Tutor("wonuk", 100);
em.persist(tutor);
ArrayList<String> resultList = new ArrayList<>();
String concatQuery = "select concat(t.name, ' is good') from Tutor t";
List<String> concatResult = em.createQuery(concatQuery, String.class).getResultList();
resultList.add(concatResult.get(0));
String substringQuery = "select substring(t.name, 2, 3) from Tutor t";
List<String> substringResult = em.createQuery(substringQuery, String.class).getResultList();
resultList.add(substringResult.get(0));
Tutor tutor2 = new Tutor(" wonukgap ", 101);
em.persist(tutor2);
String trimQuery = "select trim(t.name) from Tutor t";
List<String> trimResult = em.createQuery(trimQuery, String.class).getResultList();
resultList.add(trimResult.get(1));
String upperQuery = "select upper(t.name) from Tutor t";
List<String> upperResult = em.createQuery(upperQuery, String.class).getResultList();
resultList.add(upperResult.get(0));
String lengthQuery = "select length(t.name) from Tutor t";
List<Integer> lengthResult = em.createQuery(lengthQuery, Integer.class).getResultList();
resultList.add(lengthResult.get(0).toString());
for (String result : resultList) {
System.out.println("result = " + result);
}
상황에 맞게 함수를 사용하면 됨.
3️⃣ Fetch Join
✅ N + 1 문제
JPA가 연관된 엔티티를 조회할 때 추가적인 쿼리를 반복적으로 실행하기 때문에 발생하는 문제
- 반복문 1
- tutor1~3 조회 및 1차 캐시에 저장
- tutor1 출력
- sparta 조회 및 1차 캐시에 저장
- sparta 출력
- 반복문 2
- 1차 캐시의 tutor2 출력
- etc 조회 및 1차 캐시에 저장
- etc 출력
- 반복문 3
- 1차 캐시의 tutor3 출력
- 1차 캐시의 sparta 출력
- 튜터(N), 회사(1)가 많아질수록 더 많은 N + 1 문제가 생긴다.
💡 N + 1 문제는 지연 로딩, 즉시 로딩 모두 발생한다.
✅ Entity fetch join
JPQL에서 성능 최적화를 위해 fetch join을 제공, 연관된 엔티티나 컬렉션을 SQL 한번에 조회 해주는 기능
fetch join은 SQL의 JOIN과는 다른 종류이고 객체 그래프를 SQL 한 번에 조회함. 실무에서 굉장히 많이 활용
- N:1 fetch join(Entity)
- Tutor N : 1 Comapny 연관관계


- 튜터 조회시 연관된 회사도 함께 조회
String query = "select t from Tutor t join fetch t.company";
- drop table 로 데이터 중복 제거
- 지연로딩을 설정해도 fetch join이 우선권을 가진다.
- fetch join 후 모든 Entity가 영속성 컨텍스트로 관리된다.
Proxy 객체가 아닌,진짜 Entity 객체를 조회
✅ Collection fetch join
💡 @OneToMany 의 기본 FetchType은 LAZY
String query = "select c from Company c join fetch c.tutorList";
List<Company> companyList = em.createQuery(query, Company.class).getResultList();
for (Company company : companyList) {
System.out.println("company.getName() = " + company.getName());
System.out.println("company.getTutorList().size() = " + company.getTutorList().size());
}
데이터 중복이 발생하지 않는다.
SQL Query의 조회 결과는 데이터가 중복된다.
- Hibernate 6.0 이상 부터는 DISTINCT가 자동으로 적용
주의점 ❗
- Collection 에 fetch join을 사용하면 페이징을 메모리에서 수행한다.
String query = "select c from Company c join fetch c.tutorList";
List<Company> companyList = em.createQuery(query, Company.class)
.setFirstResult(0)
.setMaxResults(1)
.getResultList();
- 전체를 조회하는 SQL이 실행된다.
- setFirstResult(), setMaxResult() 가 SQL에 반영되지 않는다.
- 모든 데이터를 조회하여 메모리에서 페이징을 처리한다.
- 필요없는 데이터까지 전체 로드한 후 필터링
✅ @BatchSize
JPA에서 N+1 문제를 해결하기 위해 사용되는 설정
지연 로딩시 한 번에 로드할 엔티티의 개수를 조정하여 여러 개의 엔티티를 효율적으로 조회할 수 있다.
@Entity
@Table(name = "company")
public class Company {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@BatchSize(size = 100)
@OneToMany(mappedBy = "company")
private List<Tutor> tutorList = new ArrayList<>();
public Company() {
}
public Company(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public List<Tutor> getTutorList() {
return tutorList;
}
}
- 한 번의 IN Query에 식별자(PK)를 조회된 개수만큼 넣어준다.
- 설정 파일의 hibernate.jdbc.batch_size 를 통해 Global 적용이 가능하다.
- xml, yml, properties 모두 가능
💡 Global 설정보다 필드에 직접 선언한 @BatchSize 의 우선순위가 더 높다.
✅ 정리
JPQL의 한계
- 동적 쿼리를 사용하기 어렵다.
- SQL을 문자열로 작성하여 사용하기 까다롭다.
- SQL의 모든 기능을 사용할 수 없다(Native Query 사용).
fetch join 정리
- SQL의 JOIN과 비슷하지만 연관된 엔티티나 컬렉션을 한 번의 쿼리로 함께 로드하는 기능.
- 지연 로딩 설정된 엔티티도 fetch join으로 사용할 수 있다.
- N + 1 문제를 해결할 수 있다.
- 데이터의 중복이 발생할 수 있다. (DISTINCT)
- 컬렉션 페이징 처리가 힘들다.
- @BatchSize 적용
- 조회 시 여러 테이블이 JOIN 된다면 일반 JOIN을 사용하면 된다.
- JPQL 또는 QueryDSL로 필요한 데이터만 조회하여 DTO로 반환한다.
4️⃣ Spring Data JPA와 JPQL
✅ JPQL 적용
- Spring Data JPA
- Query Methods 만으로는 조회하기 힘든 경우가 많다.
- N+1 문제가 발생한다.
- Spring Data JPA에도 JPQL을 적용할 수 있다.
- @Query Annotation을 사용한다.
- @Param 을 사용하여 파라미터 바인딩한다.
- yml, properties
- application.properties
spring.application.name=SpringJPQL
spring.datasource.url=jdbc:mysql://localhost:3306/example
spring.datasource.username=아이디
spring.datasource.password=비밀번호
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=create
spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
- key-value 형태로 구문이 간단하다.
- 동일 접두사의 키가 많은 경우 중복이 빈번 ( spring.datasource , spring.jpa 등등 )
- application.yml
spring:
application:
name: SpringJPQL
datasource:
url: jdbc:mysql://localhost:3306/example
username: 아이디
password: 비밀번호
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
show_sql: true
format_sql: true
use_sql_comments: true
dialect: org.hibernate.dialect.MySQLDialect
- 계층적 구조로 작성이 가능하여 가독성이 높다.
- 중복을 줄일 수 있다.
💡 properties, yml을 함께 사용할 수 있지만 properties의 우선순위가 높다. 하나의 방법을 선택해서 사용하면 된다.
✅ @PostConstruct 활용
- 프로필 설정하기
- application.yml
profiles:
active: dev
프로필 환경별로 다른 설정을 적용할 수 있다.
@Slf4j
@Component
@Profile("dev")
public class DataInitializer {
@Autowired
private TutorRepository tutorRepository;
@Autowired
private StudentRepository studentRepository;
@PostConstruct
public void init() {
// Tutor 데이터 초기화
Tutor tutor1 = new Tutor("tutor1");
Tutor tutor2 = new Tutor("tutor2");
Tutor tutor3 = new Tutor("tutor3");
tutorRepository.save(tutor1);
tutorRepository.save(tutor2);
tutorRepository.save(tutor3);
// Student 데이터 초기화
for (int i = 0; i < 30; i++) {
Student student = new Student("student" + i, 20 + i);
// 튜터를 순차적으로 할당
if (i % 3 == 0) {
student.setTutor(tutor1);
} else if (i % 3 == 1) {
student.setTutor(tutor2);
} else {
student.setTutor(tutor3);
}
studentRepository.save(student);
}
log.info("===== Test Data Initialized =====");
}
}
- @PostConstruct 로 Application 최초 실행 시에만 초기화 하도록 설정
- @Profile("dev") dev 프로필에서만 동작하도록 설정
- 실행 결과

'[내일배움캠프-Sparta] > Spring 6기' 카테고리의 다른 글
| TIL 42 - [ Spring 심화 프로젝트 ( Day 1 ) - 설계 ( 와이어프레임, ERD, API 명세서 ) ] (0) | 2025.04.22 |
|---|---|
| 테스트 코드 ( 이론 ) (0) | 2025.04.21 |
| TIL 40 - [ Spring 심화 ( HttpMethodConverter, Converter, Formatter ) ] (0) | 2025.04.15 |
| TIL 39 [ Spring 기초 프로젝트 ( D-day ) - 발표/마무리, 트러블슈팅 ] (0) | 2025.04.14 |
| TIL 38 [ Spring 기초 프로젝트 ( Day 5 ) - 마무리 작업 ( ppt, 대본, ERD/API명세서 리뉴얼) ] (0) | 2025.04.11 |