1. AOP란?
AOP는 Aspect Oriented Programming의 약자로 관점 지향 프로그래밍이라고 불린다. 관점 지향은 쉽게 말해 어떤 로직을 기준으로 핵심적인 관점, 부가적인 관점으로 나누어서 보고 그 관점을 기준으로 각각 모듈화하겠다는 것이다. 여기서 모듈화란 어떤 공통된 로직이나 기능을 하나의 단위로 묶는 것을 말한다.
예로들어 핵심적인 관점은 결국 우리가 적용하고자 하는 핵심 비즈니스 로직이 된다. 또한 부가적인 관점은 핵심 로직을 실행하기 위해서 행해지는 데이터베이스 연결, 로깅, 파일 입출력 등을 예로 들 수 있다.
AOP에서 각 관점을 기준으로 로직을 모듈화한다는 것은 코드들을 부분적으로 나누어서 모듈화하겠다는 의미다. 이때, 소스 코드상에서 다른 부분에 계속 반복해서 쓰는 코드들을 발견할 수 있는 데 이것을 흩어진 관심사 (Crosscutting Concerns)라 부른다.
위와 같이 흩어진 관심사를 Aspect로 모듈화하고 핵심적인 비즈니스 로직에서 분리하여 재사용하겠다는 것이 AOP의 취지다.
AOP는 OOP를 보완해준다. OOP는 객체를 재사용함으로 코드의 중복을 많이 줄일 수 있었지만, 그럼에도 객체마다 반복되는 코드를 없앨수는 없었고 이를 관점지향으로 해결해주기 때문이다.
2. AOP 주요 개념
- Aspect : 위에서 설명한 흩어진 관심사를 모듈화 한 것. 주로 부가기능을 모듈화함.
- Target : Aspect를 적용하는 곳 (클래스, 메서드 .. )
- Advice : 실질적으로 어떤 일을 해야할 지에 대한 것, 실질적인 부가기능을 담은 구현체
- JointPoint : Advice가 적용될 위치, 끼어들 수 있는 지점. 메서드 진입 지점, 생성자 호출 시점, 필드에서 값을 꺼내올 때 등 다양한 시점에 적용가능
- PointCut : JointPoint의 상세한 스펙을 정의한 것. 'A란 메서드의 진입 시점에 호출할 것'과 같이 더욱 구체적으로 Advice가 실행될 지점을 정할 수 있음
- Weaving - Advice를 핵심 로직 코드에 끼워 넣는 것을 weaving 이라고 한다.
3. AOP 적용방법
3-1. 스프링 AOP와 AspectJ의 차이
AOP를 구현하는 구현체는 자바에서 구현한 AspectJ 라이브러리와 Spring AOP가 있다.
차이점을 알아보기 전에 결론부터 말하자면 AspectJ가 더 많은 기능을 가지고 있고 Spring AOP는 제한적이다.
이 둘을 비교해보겠다.
3-1-1. Weaving - 끼워넣기
Weaving시점 | Spring AOP | AspectJ |
컴파일 타임 | 불가능 | 가능 |
로드 타임 | 불가능 | 가능 |
런타임 | 가능 | 불가능 |
만약 Class A에 Perf라는 메서드가 있고, Hello라는 Aspect가 있고, Class A의 Perf메서드가 실행 되기 전에 항상 Hello를 출력해야한다고 가정한다.
1) 컴파일 타임
자바 파일을 클래스파일로 만들 때, 바이트 코드들을 조작하여, 조작된 바이트 코드들을 생성
즉, A.java 파일이 A.class로 변환될 때, A.class 파일에 Hello를 출력하는 메서드가 포함되어 있어야한다.
2) 로드 타임
A.java는 순수하게 A.class로 컴파일 되었지만, A.class를 로딩하는 시점에 Hello를 출력하는 메서드를 끼워넣는 방법이다.
즉, A의 바이트코드는 변함이 없지만, 로딩하는 JVM 메모리 상에서는 Perf라는 메서드 전에 Hello를 출력하는 메서드가 같이 포함된 상태로 로딩이 되는 것이다.
3) 런타임
A라는 Bean(Class A를 뜻함)을 만들 때(spring 어플리케이션에서 Bean을 만드는 시점은 런타임이다.), A라는 타입의 프록시 Bean을 만든다.
이 프록시 Bean이 실제 A가 가지고 있는 Perf라는 메서드를 호출하기 직전에 Hello를 출력하는 일을 하고, 그 다음에 A를 호출한다.
3-1-2. Joint point 가능여부
Joint Point | Spring AOP | AspectJ |
메서드 호출 | X | O |
메서드 실행 | O | O |
생성자 호출 | X | O |
생성자 실행 | X | O |
Static 초기화 실행 | X | O |
객체 초기화 | X | O |
필드 참조 | X | O |
필드 값 변경 | X | O |
핸들러 실행 | X | O |
Advice 실행 | X | O |
3-1-3. 성능 비교
컴파일 타임이나 로드타임에 weaving 할 수 있는 AspectJ가 성능이 훨씬 좋다.
Spring AOP같은 경우 런타임시에 빈들을 읽어서 AOP가 적용 되 있을 경우 해당 객체를 Proxy들을 생성하고 이를 기반으로 AOP를 구현했기 때문에 성능상으로는 런타임시에 어떠한 과부하도 주지 않는 AspectJ가 더 성능이 좋다.
3-1-4. 결론
Spring AOP와 AspectJ는 다른 목표를 가지고 있다.
Spring AOP는 프로그래머가 직면하는 가장 일반적인 문제를 해결하기 위해 Spring IoC에서 간단한 AOP 구현을 제공하는 것을 목표로합니다. 완전한 AOP 솔루션 이 아니라 Spring 컨테이너가 관리하는 Bean에만 적용하는 것이 목표입니다.
반면 AspectJ는 완전한 AOP 솔루션을 제공하는 것을 목표로합니다. 더 강력하지만 Spring AOP보다 훨씬 더 복잡하다. AspectJ가 모든 객체에 적용될 수 있다는 점도 주목할 가치가 있다.
즉, 런타임시에 쉽고 간단하게 Spring Bean들을 대상으로 AOP를 적용하고 싶다면 Spring AOP를 사용하고 만약 성능이 굉장히 중요하거나 예외적인 상황이라면 AspectJ를 활용하는 것이 좋다.
4. 스프링 AOP 특징
- 프록시 패턴 기반의 AOP 구현체, 프록시 객체를 쓰는 이유는 접근 제어 및 부가기능을 추가하기 위해서임
- 스프링 빈에만 AOP를 적용 가능
4-1. 프록시 패턴이란?
- 컴퓨터 프로그래밍에서 소프트웨어 디자인 패턴의 하나
- 다른 무언가와 이어지는 인터페이스 역할을 하는 클래스(프록시)를 생성.
- 어떤 객체에 대한 접근을 제어하는 용도로 대리인이나 대변인에 해당하는 객체를 제공하는 패턴
- 프록시와 리얼 서브젝트가 공유하는 인터페이스(Subject)가 존재하고, 클라이언트는 프록시를 사용합니다.
- 클라이언트는 프록시를 거쳐 리얼 서브젝트를 사용하기 때문에 프록시는 리얼 서브젝트에 대한 접근 관리, 부가기능 제공, 리턴값 변경 등을 수행할 수 있습니다.
- 리얼 서브젝트는 자신이 해야 할 일(핵심 기능)만 수행하면서 프록시를 사용해 부가기능(로깅, 트랜잭션 등)을 제공하고자 할 때 이와 같은 프록시 패턴을 주로 사용합니다.
로그인을 하는 것을 예로 프록시패턴을 이해해보자.
위 상황에서 login() 메소드에는 로그인 기능을 나타내는 핵심 기능과 로깅, 트랜잭션 등과 같은 부가 기능을 모두 수행하게 됩니다.
하나의 메소드에서 여러 가지 기능을 수행하다보니 응집도(한 모듈내 기능들의 관련성)도 낮아지게 되고 테스트 코드를 작성하더라도 핵심 기능 뿐만 아니라 부가 기능도 테스트를 해야 하기 때문에 좋지 않은 코드가 될 가능성이 높아지게 됩니다.
핵심 기능과 더불어 부가 기능도 포함된다면 여러 패키지에 의존하고 있을 확률이 높기 때문에 의존성또한 높아지게 되고,
이러한 의존성이 높은 코드는 리팩토링을 하기가 어렵고, 변화에 민첩하게 대응하기가 어려울 수 있습니다.
또한 객체지향 설계원칙(SOLID) 중 하나인 단일책임원칙(Single Responsibility Principle)에도 어긋나게 됩니다.
따라서 위와 같은 상황을 프록시 패턴을 통해 다음과 같이 개선할 수 있습니다.
위의 구조에서 클라이언트의 입장에서는 로그인 기능을 사용하는 것과 아무런 차이점이 존재하지 않습니다.
클라이언트가 로그인 기능을 수행하면 프록시 패턴에 의해 부가 기능이 수행되고, 핵심 기능이 수행되기 때문에 결과는 동일합니다.
4-2. 스프링 AOP 하는법
스프링 AOP를 사용하기 위해서는 다음과 같은 의존성을 추가해야 한다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
다음에는 아래와 같이 @Aspect 어노테이션을 붙여 이 클래스가 Aspect를 나타내는 클래스라는 것을 명시하고 @Component를 붙여 스프링 빈으로 등록한다. 이 클래스가 하는 역할이 프록시의 역할이라 생각하면 된다.
@Component
@Aspect
public class PerfAspect {
@Around("execution(* com.saelobi..*.EventService.*(..))")
public Object logPerf(ProceedingJoinPoint pjp) throws Throwable{
long begin = System.currentTimeMillis();
Object retVal = pjp.proceed(); // 타겟 메서드를 호출
System.out.println(System.currentTimeMillis() - begin);
return retVal;
}
}
@Around 어노테이션은 타겟 메서드를 감싸서 특정 Advice를 실행한다는 의미이다. 위 코드의 Advice는 타겟 메서드가 실행된 시간을 측정하기 위한 로직을 구현하였다. 추가적으로 execution(* com.saelobi..*.EventService.*(..))가 의미하는 바는 com.saelobi 아래의 패키지 경로의 EventService 객체의 모든 메서드에 이 Aspect를 적용하겠다는 의미다.
public interface EventService {
void createEvent();
void publishEvent();
void deleteEvent();
}
@Component
public class SimpleEventService implements EventService {
@Override
public void createEvent() {
try {
Thread.sleep(1000);
} catch(InterruptedException e) {
e.printStackTrace();
}
System.out.println("Created an event");
}
@Override
public void publishEvent() {
try {
Thread.sleep(1000);
} catch (InterruptedException e){
e.printStackTrace();;
}
System.out.println("Published an event");
}
public void deleteEvent() {
System.out.println("Delete an event");
}
}
@Service
public class AppRunner implements ApplicationRunner {
@Autowired
EventService eventService;
@Override
public void run(ApplicationArguments args) throws Exception {
eventService.createEvent();
eventService.publishEvent();
eventService.deleteEvent();
}
}
Created an event
1003
Published an event
1000
Delete an event
0
또한 경로지정 방식말고 특정 어노테이션이 붙은 포인트에 해당 Aspect를 실행할 수 있는 기능도 제공한다.
@Component
@Aspect
public class PerfAspect {
@Around("@annotation(PerLogging)")
public Object logPerf(ProceedingJoinPoint pjp) throws Throwable{
long begin = System.currentTimeMillis();
Object retVal = pjp.proceed(); // 타겟 메서드 호출
System.out.println(System.currentTimeMillis() - begin);
return retVal;
}
}
@Target(ElementType.METHOD) //메서드를 타겟으로
@Retention(RetentionPolicy.CLASS) //어노테이션 정보를 .Class파일까지 유지하겠다.
public @interface PerLogging {
}
@Component
public class SimpleEventService implements EventService {
@PerLogging
@Override
public void createEvent() {
try {
Thread.sleep(1000);
} catch(InterruptedException e) {
e.printStackTrace();
}
System.out.println("Created an event");
}
@Override
public void publishEvent() {
try {
Thread.sleep(1000);
} catch (InterruptedException e){
e.printStackTrace();;
}
System.out.println("Published an event");
}
@PerLogging
@Override
public void deleteEvent() {
System.out.println("Delete an event");
}
}
Created an event
1003
Published an event
Delete an event
0
위 출력 결과에서 @PerLogging 어노테이션이 붙은 메서드만 Aspect가 적용된 것을 볼 수 있다.
마찬가지로 스프링 빈의 모든 메서드에 적용할 수 있는 기능도 제공한다.
@Component
@Aspect
public class PerfAspect {
@Around("bean(simpleEventService)")
public Object logPerf(ProceedingJoinPoint pjp) throws Throwable{
long begin = System.currentTimeMillis();
Object retVal = pjp.proceed(); // 타겟 메서드 호출
System.out.println(System.currentTimeMillis() - begin);
return retVal;
}
}
@Component
public class SimpleEventService implements EventService {
@Override
public void createEvent() {
try {
Thread.sleep(1000);
} catch(InterruptedException e) {
e.printStackTrace();
}
System.out.println("Created an event");
}
@Override
public void publishEvent() {
try {
Thread.sleep(1000);
} catch (InterruptedException e){
e.printStackTrace();;
}
System.out.println("Published an event");
}
@Override
public void deleteEvent() {
System.out.println("Delete an event");
}
}
@Service
public class AppRunner implements ApplicationRunner {
@Autowired
EventService eventService;
@Override
public void run(ApplicationArguments args) throws Exception {
eventService.createEvent();
eventService.publishEvent();
eventService.deleteEvent();
} }
Created an event
1002
Published an event
1001
Delete an event
0
위 출력결과로 SimpleEventService의 모든 메서드에 해당 Aspect가 추가된 것을 알 수 있다.
이 밖에도 @Around 외에 타겟 메서드의 Aspect 실행 시점을 지정할 수 있는 어노테이션이 있다.
- @Before (이전) : 어드바이스 타겟 메소드가 호출되기 전에 어드바이스 기능을 수행
- @After (이후) : 타겟 메소드의 결과에 관계없이(즉 성공, 예외 관계없이) 타겟 메소드가 완료 되면 어드바이스 기능을 수행
- @AfterReturning (정상적 반환 이후)타겟 메소드가 성공적으로 결과값을 반환 후에 어드바이스 기능을 수행
- @AfterThrowing (예외 발생 이후) : 타겟 메소드가 수행 중 예외를 던지게 되면 어드바이스 기능을 수행
- @Around (메소드 실행 전후) : 어드바이스가 타겟 메소드를 감싸서 타겟 메소드 호출전과 후에 어드바이스 기능을 수행
4-4. Spring AOP의 작동원리
Spring AOP는 일반적으로 두 가지 방식이 있다.
- JDK Dynamic Proxy
- CGLIB
Spring은 AOP 프록시를 생성하는 과정에서 자체 검증 로직을 통해 타겟의 인터페이스 유무를 판단한다. 이때 타겟이 하나 이상의 인터페이스를 구현하고 있는 클래스라면 JDK Dynamic Proxy를 사용하고, 그렇지 않으면 CGLIB의 방식으로 AOP 프록시를 생성해 준다.
4-4-1. JDK Dynamic Proxy
JDK Dynamic Proxy는 Java의 리플렉션 패키지에 존재하는 Proxy라는 클래스를 통해 생성된 프록시 객체를 의미한다. 리플렉션의 Proxy 클래스가 동적으로 프록시 객체를 생성해 주므로 JDK Dynamic Proxy라는 이름이 붙여졌다.
프록시 객체 생성 과정
Object proxy = Proxy.newProxyInstance(ClassLoader // 클래스로더
, Class<?>[] // 타겟의 인터페이스
, InvocationHandler // 타겟의 정보가 포함된 Handler
);
위 코드와 같이 단순히 리플렉션 Proxy 클래스의 newProxyInstance() 메소드를 사용하면 된다. 그리고 전달 받은 파라미터를 가지고 다음과 같이 프록시 객체를 생성한다.
- 타겟의 인터페이스에 대해 자체적인 검증 로직을 거치고, ProxyFactory에 의해 타겟의 인터페이스를 상속한 프록시 객체를 생성한다.
- 프록시 객체에 InvocationHandler를 포함하여 하나의 객체로 변환한다.
위 과정에서 가장 핵심적인 부분은 인터페이스를 기준으로 프록시 객체를 생성한다는 것이다. 따라서 구현체는 인터페이스를 상속 받아야 하고, @Autowired를 통해 생성된 프록시 빈을 사용하기 위해서는 반드시 인터페이스의 타입으로 지정해야 한다.
내부 검증 로직
프록시 패턴은 접근 제어 목적 및 사용자의 요청이 기존의 타겟을 그대로 바라볼 수 있도록 타겟에 대한 위임 코드를 프록시 객체에 작성하기 위해 사용된다. 이러한 위임 코드는 InvocationHandler에 작성해야 한다.
사용자의 요청에 의해 Proxy 메소드가 호출되면, 내부적으로 invoke에 대한 내부 검증 로직이 일어난다.
public Object invoke(Object proxy, Method proxyMethod, Object[] args) throws Throwable {
Method targetMethod = null;
// 주입된 타겟 객체에 대한 검증 코드
if (!cachedMethodMap.containsKey(proxyMethod)) {
targetMethod = target.getClass().getMethod(proxyMethod.getName(), proxyMethod.getParameterTypes());
cachedMethodMap.put(proxyMethod, targetMethod);
} else {
targetMethod = cachedMethodMap.get(proxyMethod);
}
// 타겟의 메소드 실행
Ojbect retVal = targetMethod.invoke(target, args);
return retVal;
}
JDK Dynamic Proxy는 인터페이스에 대한 Proxy만 생성하기 때문에, 개발자가 타겟에 대한 정보를 잘 못 주입할 경우를 대비하여 내부적으로 타겟 객체에 관한 검증 코드를 형성하고 있다.
장단점
- 장점
- 개발자가 직접 프록시 객체를 만들 필요가 없다.
- 단점
- 프록시하려는 클래스는 반드시 인터페이스의 구현체여야한다.
- 리플렉션을 활용하므로 성능이 떨어진다.
4-4-2. CGLIB
CGLIB는 Code Generator Libray의 약자로, 클래스의 바이트 코드를 조작하여 프록시 객체를 생성해 주는 라이브러리다. CGLIB를 사용하면 인터페이스가 아닌 타겟 클래스에 대해서도 프록시 객체를 만들어 줄 수 있고, 이 과정에서 Enhancer라는 클래스를 활용한다. 스프링 부트는 CGLIB가 default다.
프록시 객체 생성 과정
먼저 의존성을 추가해 주자. 아래는 gradle의 예시인데, maven을 사용해도 좋다.
implementation group: 'cglib', name: 'cglib', version: '3.2.4'
그리고 아래와 같이 Enhancer를 사용하여 프록시 객체를 생성할 수 있다. (자세한 예제는 하단 참조)
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(MemberService.class); // 타겟 클래스
enhancer.setCallback(MethodInterceptor); // Handler
Object proxy = enhancer.create(); // Proxy 생성
CGLIB는 타겟의 클래스를 상속 받아서 위 그림처럼 프록시를 생성해 준다. 이 과정에서 CGLIB는 타겟 클래스에 포함된 모든 메소드를 재정의하고, 타겟 클래스에 대한 바이트 코드를 조작하여 프록시를 생성한다. 따라서 CGLIB를 적용할 클래스는 final 메소드가 들어있거나, final 클래스면 안 된다. 더불어, private 접근자로 된 메소드도 상속이 불가하므로 적용되지 않는다.
장단점
- 장점
- 인터페이스 없이 단순 클래스만으로도 프록시 객체를 동적으로 생성해 줄 수 있다.
- 리플렉션이 아닌 바이트 조작을 사용하며, 타겟에 대한 정보를 알고 있기 때문에 JDK Dynamic Proxy에 비해 성능이 좋다.
- 단점
- 의존성을 추가해야 한다. (Spring 3.2 이후 버전의 경우 Spring Core 패키지에 포함되어 있음)
- default 생성자가 필요하다. (현재는 objenesis 라이브러리를 통해 해결)
- 타겟의 생성자가 두 번 호출된다. (현재는 objenesis 라이브러리를 통해 해결)
4-4-3. JDK Dynamic Proxy와 CGLIB의 성능 차이
CGIB는 타겟에 대한 정보를 직접적으로 제공 받고, 타겟 클래스에 대한 바이트 코드를 조작하여 프록시를 생성하므로 리플렉션을 사용하는 JDK Dynamic Proxy에 비해 성능이 좋다.
또한 CGLIB는 메소드가 처음 호출되었을 때 동적으로 타겟 클래스의 바이트 코드를 조작하고, 이후 호출 시엔 조작된 바이트 코드를 재사용한다.
5. Transaction이란?
쿼리는 데이터 베이스에게 특정 데이터를 보여달라는 클라이언트의 요청이다.
트랜잭션은 이러한 여러 쿼리들을 하나의 작업으로 묶는 것이다. 트랜잭션이 필요한 이유는 은행을 예시로 들면 쉽다. 은행에서 A의 통장에서 돈을 출금해서 B의 통장에 입금해준다고 가정해보자. 이런 상황에서 A의 통장에서는 돈이 출금되었지만 B의 통장에 입금해주는 쿼리가 실행되기 전에 오류로 서버가 다운되었다고 생각해보자. 이와 같은 상황을 방지하기 위해서 쿼리들을 하나의 트랜잭션으로 묶고 이 트랜잭션이 전부 실행되어 DB에 트랜잭션 결과가 반영되거나 아니면 일부 쿼리 실행 결과를 취소하고 아예 실행되지 전의 DB 상태로 돌아가는 것이다(이를 커밋 또는 롤백이라 한다).
5-1. 트랜잭션 특징
A: 원자성 -> 트랜잭션은 DB에 모두 반영되거나 전혀 반영되지 않아야 한다. (중간상태가 DB에 반영되서는 안된다)
C: 일관성 -> 트랜잭션 처리 결과는 항상 같은 결과이어야 한다.
I: 독립성 -> 둘 이상의 트랜잭션이 동시 실행중일 때, 한 트랜잭션이 다른 트랜잭션의 작업에 끼어들 수 없다.
D: 지속성 -> 트랜잭션이 성공적으로 완료되었다면 그 결과는 영원히 지속되어야한다.
5-2. @Transactional 동작 원리
@Transactional은 스프링에서 제공하는 트랜잭션 처리 중 하나이다. 애노테이션으로 트랜잭션 처리를 지원한다. 선언적 트랜잭션이라고도 부른다.
@Transactional을 메소드 또는 클래스에 명시하면, AOP를 통해 타겟이 상속하고 있는 인터페이스 또는 타겟을 상속한 프록시 객체가 생성된다. 이때 프록시 객체의 메소드를 호출하면 타겟 메소드 전 후로 트랜잭션 처리를 수행한다.
- Caller에서 AOP 프록시를 탄다. 이때 타겟을 호출하지는 않고, 프록시를 호출한다.
- AOP 프록시는 트랜잭션 Advisor(트랜잭션 advice, 포인트컷을 총칭 -> 간단한 Aspect라 생각)를 호출한다. 이 과정에서 커밋이 되거나 롤백이 된다.
- Custom Advisor가 있다면, 트랜잭션 Advisor 실행 전후로 동작한다.
- Custom Advisor는 타겟 메소드를 호출하여, 비즈니스 로직을 호출한다.
- 후에 순서대로 리턴된다.
6. 참고
https://www.youtube.com/watch?v=e9PC0sroCzc
https://engkimbs.tistory.com/746 [새로비:티스토리]
https://www.youtube.com/watch?v=Hm0w_9ngDpM
https://devlog-wjdrbs96.tistory.com/398
https://hengbokhan.tistory.com/133
https://hwannny.tistory.com/98
https://velog.io/@max9106/Spring-AOP%EB%9E%80-93k5zjsm95
https://velog.io/@probsno/AOP%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%84%EB%9D%BC%EB%B3%B4%EC%9E%90
https://kils-log-of-develop.tistory.com/638
https://zzang9ha.tistory.com/378
https://uuukpyo.tistory.com/10
https://velog.io/@max9106/Spring-%ED%94%84%EB%A1%9D%EC%8B%9C-AOP-xwk5zy57ee
'웹 > 스프링' 카테고리의 다른 글
IOC란? IOC 컨테이너란? DI란? DI 방법 (0) | 2022.06.02 |
---|---|
Spring MVC란? (0) | 2022.05.31 |
RedisTemplate 사용해서 Redis 이용하기 (0) | 2022.04.18 |
Spring Boot에서 통합 테스트코드 구현하기(JUnit5 + Mockito) (0) | 2022.04.17 |
SQL이란? MVC란? (0) | 2022.02.06 |