서론
어제는 제어의 역전 (Inversion Of Control), 의존성 주입, 스프링 컨테이너와 스프링 빈에서 공부를 하였다. 오늘은 싱글톤 패턴과 스프링 컨테이너의 관계, 스프링 빈의 생명주기 콜백, 빈 스코프에 대해서 공부를 하였고 알고리즘 스터디를 통해 트리 자료구조를 공부하였으며 개념과 알고리즘의 대한 풀이를 발표하는 시간을 가졌다.
싱글톤 패턴
싱글톤 패턴이란 디자인 패턴 중 하나로 특정 클래스의 인스턴스가 하나만 생성되도록 보장하는 디자인 패턴이다. 싱글톤 패턴을 적용하면 하나의 클래스에 대해 전역적으로 접근 가능한 인스턴스를 하나만 생성하고 유지할 수 있다.
싱글톤 패턴을 구현하는 방법은 여러 가지가 있지만, 이른 초기화(Eager Initialization) 방법을 사용해 구현해보았다. 이 방법에서는 static 키워드를 사용해 클래스의 인스턴스를 전역 변수로 선언하고, private 생성자를 사용해 외부에서 객체를 생성하지 못하도록 막아둔다. 이 방식에서는 프로그램이 시작될 때 싱글톤 인스턴스가 즉시 생성되며, 외부에서 이 싱글톤 클래스의 인스턴스를 사용할 때에는 `getInstance` 메서드를 호출하여 이미 생성된 인스턴스를 반환받을 수 있다.
public class Singleton {
// 1. 싱글톤 인스턴스를 static으로 선언
private static Singleton instance;
// 2. 생성자를 private으로 선언하여 외부에서 인스턴스화하지 못하도록 막음
private Singleton() {}
// 3. 인스턴스를 반환하는 public 메서드
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
싱글톤 패턴을 사용하여 얻을 수 있는 장점은 다음과 같다.
- 하나의 인스턴스만 존재하므로 메모리 절약이 가능하고, 인스턴스 생성 비용이 높은 경우에도 유용하다.
- 전역 인스턴스를 제공하므로, 애플리케이션 내에서 일관된 객체 상태를 유지할 수 있다.
싱글톤 패턴을 사용하여 얻을 수 있는 단점은 다음과 같다.
- 싱글톤 인스턴스가 계속해서 메모리에 남아 있을 수 있으므로 메모리 관리 측면에서 불리할 수 있다.
- 전역 상태를 공유하기 때문에 객체 간 의존성이 강해질 수 있으며, 테스트가 어려울 수 있다.
스프링 빈과 싱글톤
싱글톤을 공부한 이유는, 스프링 컨테이너가 스프링 빈 객체를 관리할 때 기본적으로 싱글톤 방식으로 관리하기 때문이다. 아래 소스 코드는 스프링 컨테이너를 사용하지 않고, AppConfig라는 객체를 생성하여 의존 관계를 설정하는 클래스를 작성하였다.
public class AppConfig {
public MemberRepository memberRepository() {
System.out.println("객체 생성됨");
return new MemoryMemberRepository();
}
}
AppConfig appConfig = new AppConfig();
MemberRepository repository1 = appConfig.memberRepository();
MemberRepository repository2 = appConfig.memberRepository();
이 코드를 실행해보면 `AppConfig` 객체를 통해 `memberRepository()` 메서드를 두 번 호출할 때마다 새로운 MemberRepository 인스턴스가 생성되는것을 확인할 수 있다. 이런 방식으로 웹 애플리케이션을 만들게 되면 클라이언트의 요청마다 매번 새로운 객체가 생성되고 소멸된다. 즉, 메모리 낭비가 발생한다. 또한, 동일한 역할을 하는 객체임에도 불구하고 서로 다른 인스턴스가 생성되면 객체 간의 상태가 달라져, 예상치 못한 동작이 발생할 수 있다.
이제 스프링 컨테이너를 사용해보았다. `AppConfig` 클래스에 `@Configuration` 어노테이션을 사용하여 설정 정보 클래스라고 명시해주었고 `@Bean` 어노테이션을 통해 관리할 객체를 생성해주었다.
@Configuration
public class AppConfig {
@Bean
public MemberRepository memberRepository() {
System.out.println("memberRepository 객체 생성됨");
return new MemoryMemberRepository();
}
}
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
MemberRepository bean1 = ac.getBean("memberRepository", MemberRepository.class);
MemberRepository bean2 = ac.getBean("memberRepository", MemberRepository.class);
이 소스 코드를 실행해보면 코드 내부에서 ac.getBean을 통해 스프링 빈을 두 번 조회했는데 딱 한 번 객체가 생성되는것을 확인할 수 있다. 어떻게 스프링은 스프링 빈을 싱글톤으로 관리할 수 있는걸까?
🖥 @Configuration
스프링은 `@Configuration` 어노테이션이 붙은 클래스를 CGLIB 라이브러리를 사용하여 프록시 클래스로 변환한다. 즉, `AppConfig` 클래스는 AppConfig@CGLIB 와 같은 프록시 객체로 변환되고, 스프링 컨테이너에 등록된다. 실제로 위 코드를 실행하여 클래스의 정보를 출력해보면 아래와 같이 출력되는것을 확인해볼 수 있다.
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig bean = ac.getBean(AppConfig.class);
System.out.println(bean.getClass());
🛠 동작 방식
- CGLIB 프록시 생성
- @Configuration이 붙은 클래스는 CGLIB 라이브러리를 사용하여 프록시 클래스로 변환된다. 즉, AppConfig 클래스는 AppConfig@CGLIB와 같은 프록시 객체로 변환되고, 스프링 컨테이너에 등록된다.
- @Configuration이 붙은 클래스는 CGLIB 라이브러리를 사용하여 프록시 클래스로 변환된다. 즉, AppConfig 클래스는 AppConfig@CGLIB와 같은 프록시 객체로 변환되고, 스프링 컨테이너에 등록된다.
- 메서드 오버라이딩
CGLIB은 원래 클래스(AppConfig)의 메서드를 오버라이드하여, 빈 생성 로직을 수정한다. @Bean이 붙은 메서드를 호출할 때, CGLIB이 프록시를 통해 먼저 스프링 컨테이너에서 해당 빈이 이미 생성되어 있는지 확인한다. - 싱글톤 보장 로직
- 만약 스프링 컨테이너에 해당 빈이 이미 존재하면, 그 빈을 반환한다.
- 그렇지 않으면 메서드를 실행하여 빈을 생성하고, 그 결과를 스프링 컨테이너에 등록한 뒤 반환한다.
@Bean
public MemberRepository memberRepository() {
if (스프링 컨테이너에 MemberRepository 빈이 이미 등록되어 있으면) {
// 이미 생성된 빈을 반환
return 스프링 컨테이너에서 찾아서 반환;
} else {
// 새로운 빈을 생성하고, 스프링 컨테이너에 등록 후 반환
MemberRepository repository = 새로운 MemoryMemberRepository();
스프링 컨테이너에 등록(repository);
return repository;
}
}
이 방식 덕분에 @Bean이 붙은 메서드가 여러 번 호출되더라도, 스프링 컨테이너는 동일한 빈을 반환하여 싱글톤이 보장된다.
💬 스프링부트가 자동으로 생성하는 스프링 컨테이너는?
그러면 스프링부트가 자동으로 생성해주는 스프링 컨테이너도 CGLIB 라이브러리를 사용하는지 궁금하였다. 다음과 같이 메인 애플리케이션에서 `@SpringBootApplication` 어노테이션을 따라 들어가보았다. 그 내부에는 `@SpringBootConfiguration` 어노테이션을 확인할 수 있었고 이것도 따라 들어가게 되면 내부에 `@Configuration` 어노테이션이 붙어 있는것을 확인할 수 있다.
따라서, 스프링 부트가 자동으로 생성하는 스프링 컨테이너 역시 CGLIB 라이브러리를 사용하여 빈의 싱글톤을 보장한다는것을 알수 있었다.!
📗 @SpringBootApplication
이제 @SpringBootApplication의 역할이 궁금해졌따. @SpringBootApplication 어노테이션은 스프링 부트 애플리케이션의 가장 중요한 역할을 하며 이 어노테이션은 스프링 부트 애플리케이션의 시작점을 정의하고 여러 스프릉 부트 관련설정을 자동으로 구성한다. @SpringBootApplication은 세 가지 중요한 어노테이션을 조합한 것으로 역활과 내부 구성은 다음과 같다.
- @SpringBootConfiguration
- @SpringBootConfiguration은 스프링 부트 애플리케이션의 설정 클래스 역할을 한다.
- 이 어노테이션은 내부적으로 @Configuration을 포함하고 있어, 해당 클래스를 스프링 컨테이너에서 설정 클래스로 인식한다. 즉, 스프링 애플리케이션의 설정을 담당하고, @Bean 메서드를 통해 빈을 등록할 수 있다.
- @EnableAutoConfiguration
- 자동 설정을 활성화하는 어노테이션이다.
- 스프링 부트는 이 어노테이션을 통해 애플리케이션의 클래스패스, 설정 파일, 의존성 등을 분석하여 필요한 스프링 빈을 자동으로 구성한다.
- 예를 들어, 데이터베이스 의존성이 있으면 자동으로 DataSource를 설정하고, 웹 애플리케이션일 경우 자동으로 톰캣 같은 내장 서버를 설정해준다.
- @ComponentScan
- 컴포넌트 스캔을 활성화하여, 지정된 패키지 내의 @Component, @Service, @Repository, @Controller 어노테이션이 붙은 클래스들을 스프링 빈으로 자동 등록한다.
- 기본적으로 @SpringBootApplication이 붙은 클래스의 패키지와 하위 패키지를 스캔하여 빈으로 등록한다.
스프링 빈 생명주기 콜백
스프링에서 빈 생명주기 콜백은 스프링 컨테이너가 빈을 생성, 초기화, 소멸하는 과정에서 특정 메서드를 호출하여 빈의 상태를 관리하거나 추가적인 작업을 할 수 있도록 하는 메서드를 말한다.
- 초기화 콜백 : 빈이 생성되고 빈의 의존관계 주입이 끝나면 호출된다.
- 소멸전 콜백 : 빈이 소멸되기 전에 호출한다.
- 스프링 컨테이너 생성 -> 스프링 빈 생성 -> 의존관계 주입 -> 초기화 콜백 -> 사용 -> 소멸전 콜백 -> 스프링 종료
빈 생명주기 콜백을 구현하는 방법은 여러가지 방법이 있지만 @PostConstruct와 @PreDestroy을 주로 사용한다고 한다. 먼저 @PostConstruct는 빈이 초기화 될때 호출되고 . @PreDestroy는 빈이 소멸될 때 호출된다.
@PostConstruct
public void init() {}
@PreDestroy
public void close() {}
스프링 빈 스코프 종류
스프링에서 빈 스코프는 스프링 컨테이너가 빈 객체를 생성하고 관리하는 범위를 말한다. 스프링은 다양한 스코프를 지원한다.
- singleton : 기본 스코프, 스프링 컨테이터 시작과 종료까지 유지되는 스코프
- prototype : 스프링 컨테이너는 빈 생성 의존관계 주입까지만 관여하고 더 이상 관여하지 않는 스코프
- request : 웹 요청이 들어오고 나갈때까지 유지되는 스코프
- session : 웹 세션이 생성되고 종료될때까지 유지되는 스코프
- application : 웹 서블릿 컨텍스트와 같은 범위로 유지되는 스코프
- request : HTTP 요청이 들어오고 나갈때 까지 유지되는 스코프, 각각 요청마다 별도의 인스턴스 생성
- session : HTTP Session과 동일한 생명주기를 가지는 스코프
- application : 서블릿 컨텍스트와 동일한 생명주기를 가지는 스코프
- websocket : 웹 소켓과 동일한 생명주기를 가지는 스코프
스프링 빈 스코프 실습
✔ singleton scope
먼저 스프링 빈 스코프 중 기본값인 singleton 스코프를 실습해보려 한다. 기본적으로 스프링의 빈 스코프는 싱글톤으로 지정되어 있으며 따로 설정하지 않아도 되지만 여기서는 @Scope 어노테이션을 사용해 이를 명시적으로 설정했다. 또한 사용자의 요청을 구분하기 위해 uuid 변수를 생성하고, 빈이 생성 및 초기화될 때 @PostConstruct 메서드를 사용하여 초기화 작업을 수행했다.
@Component
@Scope(value = "singleton")
public class SingletonScope {
String uuid;
public void login(String requestURI) {
System.out.println("[ " + uuid + " ]" + " " + "[ " + requestURI + " ]");
}
public SingletonScope() {
System.out.println("SingletonScope 객체 생성됨");
}
@PostConstruct
public void init() {
System.out.println("SingletonScope 초기화");
uuid = UUID.randomUUID().toString();
}
@PreDestroy
public void destroy() {
System.out.println("SingletonScope 소멸");
}
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/scope")
public class ScopeController {
private final SingletonScope singletonScope;
@GetMapping("/singleton")
public String singletonScope(HttpServletRequest request) {
singletonScope.login(request.getRequestURI());
return "OK";
}
}
실행 결과를 보면, 애플리케이션 실행 시 객체가 생성되어 스프링 빈으로 등록되는 것을 확인할 수 있다. 또한, 객체가 생성된 후 초기화 콜백 메서드가 호출되는 것도 확인할 수 있다. 클라이언트가 /scope/singleton 경로로 여러 번 요청을 보내도 동일한 UUID가 출력되는 것을 통해, 빈이 싱글톤으로 동작하며 모든 요청에서 동일한 인스턴스를 재사용하고 있음을 확인할 수 있다. 따라서, 싱글톤 스코프에서는 빈이 한 번만 생성되며 모든 요청에서 동일한 인스턴스가 사용된다는 사실을 실습을 통해 알 수 있었다.
❌ prototype scope - 실패
이제 프로토타입 스코프를 가지는 스프링 빈을 실습해보려 한다. PrototypeScope 클래스에 @Scope(value = "prototype")을 사용하여 해당 빈이 프로토타입 스코프임을 명시하였다. 프로토타입 스코프는 요청마다 새로운 인스턴스를 생성하므로, count 변수를 선언하여 각 요청 시 값을 증가시키고 출력하도록 하였다. 매번 새로운 객체가 생성되기 때문에, 각 요청마다 count 값이 1로 고정되어 출력될 것으로 기대하고 코드를 작성하였다.
@Component
@Scope(value = "prototype")
public class PrototypeScope {
String uuid;
int count;
public void login(String requestURI) {
count++;
System.out.println("[ " + uuid + " ]" + " " + "[ " + requestURI + " ] " + "[ " + count + " ]");
}
public PrototypeScope() {
System.out.println("PrototypeScope 객체 생성됨");
}
@PostConstruct
public void init() {
System.out.println("PrototypeScope 초기화");
uuid = UUID.randomUUID().toString();
}
@PreDestroy
public void destroy() {
System.out.println("PrototypeScope 소멸");
}
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/scope")
public class ScopeController {
private final PrototypeScope prototypeScope;
@GetMapping("/prototype")
public String prototypeScope(HttpServletRequest request) {
prototypeScope.login(request.getRequestURI());
return "OK";
}
}
스프링 애플리케이션을 실행하면, 스프링 컨테이너가 해당 객체를 스프링 빈으로 등록하고 초기화해주는 것까지 확인할 수 있었다. 그러나 기대했던 것과 달리, 매 요청 시마다 count 값이 계속 증가하는 것을 확인할 수 있었다. 이는 해당 빈이 프로토타입 스코프로 동작하지 않고, 싱글톤 스코프로 적용된 것임을 의미한다. 왜 이런 문제가 발생하는것일까?
✏ prototype scope - 실패 이유
문제는 바로 컨트롤러에 있었다. ScopeController 내부를 살펴보면, @RestController 어노테이션을 사용하고 있다. 이 어노테이션은 내부에 @Component를 포함하고 있어, 별도로 스코프를 설정하지 않으면 기본적으로 싱글톤으로 적용된다. 따라서 스프링 컨테이너가 이 컨트롤러를 스프링 빈으로 등록할 때 의존관계를 설정하며, 이 과정에서 PrototypeScope 빈이 등록되고 주입된다. 이후 클라이언트가 요청할 때마다 싱글톤 스코프인 ScopeController가 호출되므로, 동일한 인스턴스를 사용하게 되어 PrototypeScope 또한 매번 같은 인스턴스를 재사용하게 되는 것이였다.
✔ prototype scope - 성공
이 문제를 해결하려면 DI(Dependency Injection)가 아닌 DL(Dependency Lookup) 방식을 사용해야한다. DL이란 의존관계를 주입받는 것이아니라 직접 필요한 의존관계를 찾는것을 말한다.
좀더 자세하게 설명하면 객체가 필요한 의존성을 직접 조회하여 가져오는 방식을 말한다. 이것은 의존관계를 외부로부터 주입받는 DI 와는 다른 패턴으로, DI는 스프링 컨테이너가 필요한 의존성을 자동으로 주입해주는 반면, DL은 객체가 필요할 때마다 스스로 의존성을 조회하거나 탐색하는 방식이다.
✏ DL vs DI
- Dependency Injection (DI):
- 의존성 주입 방식으로, 객체의 의존성을 외부에서 주입받는다.
- 스프링 컨테이너가 관리하는 빈들은 애플리케이션이 실행될 때 한 번에 생성되고 주입된다..
- 의존성을 주입받는 대상은 객체의 생성 시점에 의존성이 주입되며, 주로 싱글톤 스코프에서 사용된다.
- Dependency Lookup (DL):
- 객체가 필요할 때 직접 의존성을 조회하는 방식이다.
- 스프링 컨테이너가 아닌 객체가 필요한 의존성을 스스로 찾아서 가져온다.
- 주로 프로토타입 스코프나 의존성이 동적으로 변경될 수 있는 경우에 사용된다.
스프링에서는 DL(Dependency Lookup)을 지원하는 여러 가지 방법이 있다. 그중 하나는 ObjectProvider로, 스프링에서 제공하는 DL 방식이다. ObjectProvider를 사용하면 필요할 때마다 새로운 빈을 조회할 수 있다. 또 다른 방법으로는 ApplicationContext를 직접 사용하여 스프링 컨테이너에서 필요한 빈을 조회하는 방식도 있다. 마지막으로, JSR-330에서 제공하는 javax.inject.Provider 인터페이스를 사용하여 DL을 구현할 수도 있다. 여기서는 ObjectProvider를 사용하여 문제를 해결하였다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/scope")
public class ScopeController {
private final ObjectProvider<PrototypeScope> prototypeScopeProvider;
@GetMapping("/prototype")
public String prototypeScope(HttpServletRequest request) {
PrototypeScope prototypeScope = prototypeScopeProvider.getObject();
prototypeScope.login(request.getRequestURI());
return "OK";
}
}
이제 웹 애플리케이션을 다시 실행해보면, 클라이언트의 요청마다 새로운 객체가 생성되고 매번 다른 인스턴스가 반환되는 것을 확인할 수 있다. 이는 DL(Dependency Lookup) 방식 덕분에 매 요청 시마다 새로운 프로토타입 빈을 생성할 수 있었기 때문이다. 즉, DL은 필요한 시점에 스프링 컨테이너로부터 빈을 직접 조회하여 매번 새로운 인스턴스를 사용할 수 있게 해준다는것을 알 수 있었다. 또한 종료시에 소멸 메서드가 호출되지 않는데 이것은 스프링 컨테이너가 생성과 초기화만 관여하고 초기화 이후에는 관리하지 않는다는것도 확인할 수 있었다.
❌ request scope - 실패
다음은 웹 스코프인 request 스코프를 사용하였다. reqeust 스코프는 HTTP 요청이 들어오고 나갈때 까지 유지되는 스코프, 각각 요청마다 별도의 인스턴스 생성한다. 다음과 같은 코드로 request 스코프를 사용해보았다.
@Component
@Scope("request")
public class RequestScope {
String uuid;
int count;
public void login(String requestURI) {
count++;
System.out.println("[ " + uuid + " ]" + " " + "[ " + requestURI + " ] " + "[ " + count + " ]");
}
public RequestScope() {
System.out.println("RequestScope 객체 생성됨");
}
@PostConstruct
public void init() {
System.out.println("RequestScope 초기화");
uuid = UUID.randomUUID().toString();
}
@PreDestroy
public void destroy() {
System.out.println("RequestScope 소멸");
}
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/scope")
public class ScopeController {
private final RequestScope requestScope;
@GetMapping("/request")
public String requestScope(HttpServletRequest request) {
requestScope.login(request.getRequestURI());
return "OK";
}
}
이제 웹 서버를 실행하면 즉시 예외가 발생한다. 그 이유는 request 스코프가 웹 스코프이기 때문에, 사용자의 HTTP 요청이 있을 때에만 인스턴스가 생성될 수 있기 때문이다. 그러면 웹 서버를 띄울때는 사용자의 요청이 없는데 이 문제를 어떻게 해결해야될까?
✔ request scope - 성공
이 문제를 해결하기 위해서는 RequestScope 빈이 HTTP 요청이 있을 때만 생성되도록 처리해야 한다. 스프링에서는 request 스코프 빈을 싱글톤 스코프 빈에 바로 주입할 수 없기 때문에, 이전에 다루었던 ObjectProvider 또는 Proxy를 사용하여 지연 로딩방식으로 빈을 주입받아야 한다. ObjectProvider 방식을 사용하여 문제를 해결해보았다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/scope")
public class ScopeController {
private final ObjectProvider<RequestScope> requestScopeObjectProvider;
@GetMapping("/request")
public String requestScope(HttpServletRequest request) {
RequestScope requestScope = requestScopeObjectProvider.getObject();
requestScope.login(request.getRequestURI());
return "OK";
}
이제 실행하면, 실제 클라이언트의 요청이 있을 때 DL(Dependency Lookup)을 통해 의존성을 동적으로 조회하여 RequestScope 빈이 생성되는 것을 확인할 수 있다. 이를 통해 매 요청마다 새로운 인스턴스가 생성되고, HTTP 요청에 맞게 별도의 빈이 사용되는 방식이 구현된다.
이제 프록시 모드를 사용하여 이 문제를 해결해보자.
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestScope {
@RestController
@RequiredArgsConstructor
@RequestMapping("/scope")
public class ScopeController {
private final RequestScope requestScope;
@GetMapping("/request")
public String requestScope(HttpServletRequest request) {
requestScope.login(request.getRequestURI());
return "OK";
}
}
🌞 프록시란?
프록시(Proxy)는 실제 객체에 대한 대리자 역할을 하는 객체이다. 프록시 객체는 실제 객체에 대한 접근을 제어하거나, 그 앞에서 추가적인 기능을 수행할 수 있다. @Scope 어노테이션에서 proxyMode = ScopedProxyMode.TARGET_CLASS를 설정하면, 스프링은 CGLIB 라이브러리를 사용하여 클래스 기반의 프록시를 생성한다. 이때 스프링 컨테이너는 가짜 프록시 객체를 등록하고, 실제 요청이 들어오면 실제 빈을 호출하여 동작을 처리한다. Provider를 사용하든, 프록시를 사용하든 핵심 아이디어는 진짜 객체의 조회를 꼭 필요한 시점까지 지연처리 한다는 점이다. 즉, 가짜 객체로 처리하다가 실제 실행 시점에 진짜 빈을 호출하는 방식이다.
결론
이번 학습을 통해 싱글톤 패턴과 스프링 컨테이너가 기본적으로 스프링 빈을 싱글톤으로 관리한다는 개념을 명확히 이해할 수 있었다. 또한, 스프링 빈의 생명주기 콜백과 스코프를 실습하며 각각의 빈이 어떻게 동작하는지를 경험했고, Dependency Lookup(DL)과 Proxy를 활용하여 빈을 지연 로딩하는 방법을 배울 수 있었다.
'TIL' 카테고리의 다른 글
[TIL - 2024-09-17] JDBC, DriverManager, HikariCp, DataSource (0) | 2024.09.16 |
---|---|
[TIL - 2024-09-12] 스프링 테스트, 단위 테스트, 통합 테스트 (0) | 2024.09.12 |
[TIL - 2024-09-10] 제어의 역전, 의존성 주입, 스프링 컨테이너 (0) | 2024.09.10 |
[TIL - 2024-09-10] 좋은 객체 지향 설계의 5가지 원칙 SOLID (1) | 2024.09.10 |
[TIL - 2024-09-09] REST, RESTful API, 실습 (3) | 2024.09.09 |