해당 게시물은 김영한 강사님의 스프링 핵심원리 고급편 을 학습 후 정리한 내용이다.
부가 기능 적용하기
보통 디버깅(에러 혹은 비즈니스 흐름 분석)을 위해 로깅을 적용하는 것은 당연하다.
아래와 같은 요구 사항을 구현한다고 가정해보자.
- 모든 PUBLIC 메서드의 호출과 응답 정보를 로그로 출력
- 애플리케이션의 비즈니스 흐름을 변경하면 안됨
- 로그를 남긴다고 해서 비즈니스 로직의 동작에 영향을 주면 안됨
- 메서드 호출에 걸린 시간을 기록함
- 정상 흐름과 예외 흐름을 구분함
- 예외 발생시 예외 정보가 남아야 함
- 메서드 호출의 깊이 표현
- HTTP 요청을 구분
- HTTP 요청 단위로 특정 ID를 남겨서 어떤 HTTP 요청에서 시작된 것인지 명확하게 구분이 가능해야 함
- 트랜잭션 ID (DB 트랜잭션X), 여기서는 하나의 HTTP 요청이 시작해서 끝날 때 까지를 하나의 트랜잭션이라 함
다음과 같은 문제점이 생긴다.
- 그의 깊이를 나타내기 위해 다른 서비스 호출로 로그 상태 객체를 넘겨줘야 한다.
- 해결 방법 : 빈 주입
- 빈 주입 문제점은 로그 상태 객체의 공유
- 공유 자원 경합 문제(동시성 문제)가 생긴다.
- 쓰레드 로컬을 활용한다.
동시성 문제 : 쓰레드 로컬(Thread Local)
A가 userA를 쓰고 2초 뒤 자기가 쓴것을 조회하길 기대하는데,
2초 사이에 userB가 써버리면, 둘 다 userB만 보게 된다.
아무도 기대하지 않던 결과이다.
만약 같은 지갑에서 만원을 동시에 사용하는게 가능하다면, 은행은 큰 손해를 보게 될 것이다.
이를 해결하기 위해, 쓰레드 별로 각각의 저장소를 할당한다.
즉, 해당 객체는 해당 쓰레드만을 위한 저장 장소를 갖고 있다.
따라서 같은 객체에서 조회해도 쓰레드 별로 다른 결과를 볼 수 있다.
아래는 추적을 위한 ID 역할을 하는 TraceId 클래스다.
public class TraceId {
private String id;
private int level;
public TraceId() {
this.id = createId();
this.level = 0;
}
private TraceId(String id, int level) {
this.id = id;
this.level = level;
}
private String createId() {
return UUID.randomUUID().toString().substring(0, 8);
}
public TraceId createNextId() {
return new TraceId(id, level + 1);
}
public TraceId createPreviousId() {
return new TraceId(id, level - 1);
}
public boolean isFirstLevel() {
return level == 0;
}
public String getId() {
return id;
}
public int getLevel() {
return level;
}
}
추적을 위한 ID와 레벨을 가지고 있는 TraceStatus 클래스다.
public class TraceStatus {
private TraceId traceId;
private Long startTimeMs;
private String message;
public TraceStatus(TraceId traceId, Long startTimeMs, String message) {
this.traceId = traceId;
this.startTimeMs = startTimeMs;
this.message = message;
}
public Long getStartTimeMs() {
return startTimeMs;
}
public String getMessage() {
return message;
}
public TraceId getTraceId() {
return traceId;
}
}
ThreadLocal 사용법
- 값 저장: ThreadLocal.set(xxx)
- 값 조회: ThreadLocal.get()
- 값 제거: ThreadLocal.remove()
public interface LogTrace {
TraceStatus begin(String message);
void end(TraceStatus status);
void exception(TraceStatus status, Exception e);
}
@Slf4j
public class ThreadLocalLogTrace implements LogTrace {
private static final String START_PREFIX = "-->";
private static final String COMPLETE_PREFIX = "<--";
private static final String EX_PREFIX = "<X-";
private ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>();
@Override
public TraceStatus begin(String message) {
syncTraceId();
TraceId traceId = traceIdHolder.get();
Long startTimeMs = System.currentTimeMillis();
log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX,
traceId.getLevel()), message);
return new TraceStatus(traceId, startTimeMs, message);
}
@Override
public void end(TraceStatus status) {
complete(status, null);
}
@Override
public void exception(TraceStatus status, Exception e) {
complete(status, e);
}
private void complete(TraceStatus status, Exception e) {
Long stopTimeMs = System.currentTimeMillis();
long resultTimeMs = stopTimeMs - status.getStartTimeMs();
TraceId traceId = status.getTraceId();
if (e == null) {
log.info("[{}] {}{} time={}ms", traceId.getId(),
addSpace(COMPLETE_PREFIX, traceId.getLevel()), status.getMessage(),
resultTimeMs);
} else {
log.info("[{}] {}{} time={}ms ex={}", traceId.getId(),
addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(), resultTimeMs,
e.toString());
}
releaseTraceId();
}
private void syncTraceId() {
TraceId traceId = traceIdHolder.get();
if (traceId == null) {
traceIdHolder.set(new TraceId());
} else {
traceIdHolder.set(traceId.createNextId());
}
}
private void releaseTraceId() {
TraceId traceId = traceIdHolder.get();
if (traceId.isFirstLevel()) {
traceIdHolder.remove();// destroy
} else {
traceIdHolder.set(traceId.createPreviousId());
}
}
private static String addSpace(String prefix, int level) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < level; i++) {
sb.append((i == level - 1) ? "|" + prefix : "| ");
}
return sb.toString();
}
}
다음과 같이 실행해본다.
public class ThreadLocalLogTraceTest {
ThreadLocalLogTrace trace = new ThreadLocalLogTrace();
@Test
void begin_end_level2() {
TraceStatus status1 = trace.begin("hello1");
TraceStatus status2 = trace.begin("hello2");
trace.end(status2);
trace.end(status1);
}
@Test
void begin_exception_level2() {
TraceStatus status1 = trace.begin("hello1");
TraceStatus status2 = trace.begin("hello2");
trace.exception(status2, new IllegalStateException());
trace.exception(status1, new IllegalStateException());
}
}
주의사항 :
해당 쓰레드가 쓰레드 로컬을 모두 사용하고 나면 ThreadLocal.remove() 를 호출해서 쓰레드 로컬에 저장된 값을 제거해주어야 한다.
쓰레드 로컬은 쓰레드 별 저장소를 만들기 때문에, set한 값을 다른 사람이 볼 수 있기 떄문이다.
이게 다 메모리 위에 한번 올라간 뒤 앱 생명주기 동안 계속 살아있는 싱글턴이기 떄문임
경과
현재 로그찍는게 비즈니스 로직을 침범함
- 핵심 기능
- 객체 고유 기능(비즈니스 로직)
- 단독 사용
- 부가 기능
- 핵심 기능 보조
- 로그 추적 로직
- 트랜잭션 기능
- 단독 미사용
좋은 설계는 변하는 것과 변하지 않는 것을 분리하는 것이다.
핵심 기능은 변한다.
부가 기능이 변하지 않는다.
이를 분리하여 모듈화해야 한다.
탬플릿 메서드 패턴(Template Method Pattern)
템플릿 메서드 패턴은 이렇게 다형성을 사용해서 변하는 부분과 변하지 않는 부분을 분리하는 방법이다.
부모 클래스를 오버라이딩 해서 구현한다.
탬플릿 메서드 패턴은 아래와 같은 추상 클래스를 자식이 상속하여 구현한다.
@Slf4j
public class SubClassLogic2 extends AbstractTemplate {
@Override
protected void call() {
log.info("비즈니스 로직2 실행");
}
}
@Slf4j
public abstract class AbstractTemplate {
public void execute() {
long startTime = System.currentTimeMillis(); // 비즈니스 로직 실행
call(); // 상속
// 비즈니스 로직 종료
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime={}", resultTime);
}
protected abstract void call();
}
이를 이전 비즈니스 로직에 적용하면 다음과 같다.
1. 먼저 제네릭 추상 탬플릿 클래스를 구현한다.
public abstract class AbstractTemplate<T> {
private final LogTrace trace;
public AbstractTemplate(LogTrace trace) {
this.trace = trace;
}
public T execute(String message) {
TraceStatus status = null;
try {
status = trace.begin(message); // 로직 호출
T result = call();
trace.end(status);
return result;
} catch (Exception e) {
trace.exception(status, e);
throw e;
}
}
protected abstract T call();
}
- AbstractTemplate 은 템플릿 메서드 패턴에서 부모 클래스이고, 템플릿 역할을 한다.
- <T> 제네릭을 사용했다. 반환 타입을 정의한다.
- 객체를 생성할 때 내부에서 사용할 LogTrace trace 를 전달 받는다.
- 로그에 출력할 message 를 외부에서 파라미터로 전달받는다.
- 템플릿 코드 중간에 call() 메서드를 통해서 변하는 부분을 처리한다.
- abstract T call() 은 변하는 부분을 처리하는 메서드이다. 이 부분은 상속으로 구현해야 한다.
2. 서비스, 레포지토리, 컨트롤러에서 해당 클래스를 상속하여 구현한다.
별도의 익명클래스를 만들어 별도의 클래스를 만들지 않고도 사용할 수 있다.
@RestController
@RequiredArgsConstructor
public class OrderControllerV4 {
private final OrderServiceV4 orderService;
private final LogTrace trace;
@GetMapping("/v4/request")
public String request(String itemId) {
AbstractTemplate<String> template = new AbstractTemplate<>(trace) {
@Override
protected String call() {
orderService.orderItem(itemId);
return "ok";
}
};
return template.execute("OrderController.request()");
}
}
@Service
@RequiredArgsConstructor
public class OrderServiceV4 {
private final OrderRepositoryV4 orderRepository;
private final LogTrace trace;
public void orderItem(String itemId) {
AbstractTemplate<Void> template = new AbstractTemplate<>(trace) {
@Override
protected Void call() {
orderRepository.save(itemId);
return null;
}
};
template.execute("OrderService.orderItem()");
}
}
@Repository
@RequiredArgsConstructor
public class OrderRepositoryV4 {
private final LogTrace trace;
public void save(String itemId) {
AbstractTemplate<Void> template = new AbstractTemplate<>(trace) {
@Override
protected Void call() {
// 저장 로직
if (itemId.equals("ex")) {
throw new IllegalStateException("예외 발생!");
}
sleep(1000);
return null;
}
};
template.execute("OrderRepository.save()");
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
AbstractTemplate<Void>
제네릭에서 반환 타입이 필요한데, 반환할 내용이 없으면 Void 타입을 사용하고 null 을 반환한다.
제네릭은 기본 타입인 void,int 등을 선언할 수 없다.
> 컨트롤러를 호출하면 로그가 찍힐 것이다.
다시 한 번, 좋은 설계란 변경이 일어날 때 자연스럽게 드러난다.
지금까지 로그를 남기는 부분을 모아서 하나로 모듈화하고, 비즈니스 로직 부분을 분리했다.
로그를 남기는 로직은 AbstractTemplate만 변경하면 된다.
소스코드 몇줄을 줄인 것이 전부가 아니다.
로그를 남기는 부분에 단일 책임 원칙(SRP)을 지킨 것이다.
변경 지점을 하나로 모아서 변경에 쉽게 대처할 수 있는 구조를 만든 것이다.
GOF의 탬플릿 메서드 정의
템플릿 메서드 디자인 패턴의 목적은 다음과 같습니다
작업에서 알고리즘의 골격을 정의하고 일부 단계를 하위 클래스로 연기합니다.
템플릿 메서드를 사용하면 하위 클래스가 알고리즘의 구조를 변경하지 않고도 알고리즘의 특정 단계를 재정의할 수 있습니다.
부모는 알고리즘 골격(변하지 않는 로직)만 정의한다.
변경되는 부분은 오직 자식만이 담당한다.
이렇게 하면 자식 클래스가 알고리즘의 전체 구조를 변경하지 않고, 특정 부분만 재정의할 수 있다.
결국 상속과 오버라이딩을 통한 다형성으로 문제를 해결하는 것이다.
하지만 탬플릿 메서드 패턴은 상속의 단점을 그대로 갖고간다.
- 부모의 기능을 쓰지 않음에도 부모 상속;부모에 대한 강력한 의존
- 부모의 변경이 자식에 영향을 줌
- 상속 구조에 의한 복잡성(별도 클래스와 익명 내부 클래스)
템플릿 메서드 패턴과 비슷한 역할을 하면서 상속의 단점을 제거할 수 있는 디자인 패턴이 바로 전략 패턴 (Strategy Pattern)이다.
전략 패턴(Strategy Pattern)
탬플릿 메서드 패턴은
- 변하지 않는 템플릿을 부모 클래스에 둔다.
- 변하는 부분을 자식 클래스에 둔다.
- 상속을 사용해서 문제를 해결한다.
전략 패턴은
- 변하지 않는 템플릿을 Context 클래스에 둔다.
- 변하는 부분을 Strategy 라는 인터페이스를 만들고 해당 인터페이스를 구현하록 한다.
- 상속이 아니라 위임으로 문제를 해결한다.
전략 패턴에서 Context 는 변하지 않는 템플릿 역할을 하고, Strategy 는 변하는 알고리즘 역할을 한다.
GOF 디자인 패턴에서 정의한 전략 패턴의 의도는 다음과 같다.
- 알고리즘 제품군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만들자.
- 전략을 사용하면 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다.
@Slf4j
public class StrategyLogic1 implements Strategy {
@Override
public void call() {
log.info("비즈니스 로직1 실행");
}
}
@Slf4j
public class ContextV1 {
private Strategy strategy;
public ContextV1(Strategy strategy) {
this.strategy = strategy;
}
public void execute() {
long startTime = System.currentTimeMillis(); // 비즈니스 로직 실행
strategy.call(); // 위임
// 비즈니스 로직 종료
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime={}", resultTime);
}
}
전략 패턴의 핵심은 Context 는 Strategy 인터페이스에만 의존한다는 점이다.
덕분에 Strategy 의 구현체를 변경하거나 새로 만들어도 Context 코드에는 영향을 주지 않는다.
어디서 많이 본 코드 같지 않은가?
바로 스프링에서 의존관계 주입에서 사용하는 방식이 바로 전략 패턴이다.
익명 내부 클래스를 자바8부터 제공하는 람다로 변경할 수 있다. (익명 클래스의 일종)
람다로 변경하려면 인터페이스에 메서드가 1개만 있으면 되는데,
Strategy 인터페이스는 메서드가 1개만 있으므로 람다로 사용할 수 있다.
/**
* 전략 패턴, 람다
*/
@Test
void strategyV4() {
ContextV1 context1 = new ContextV1(() -> log.info("비즈니스 로직1 실행")); context1.execute();
ContextV1 context2 = new ContextV1(() -> log.info("비즈니스 로직2 실행"));
context2.execute();
}
선 조립, 후 실행
지금까지 전략 패턴 방식은
Context 와 Strategy 를 실행 전에 원하는 모양으로 조립해두고, 그 다음에 Context 를 실행하는
선 조립, 후 실행 방식에서 매우 유용하다.
즉, 관계가 한번 맺어지면, 영원히 갈 경우에 좋다.
스프링으로 애플리케이션을 개발할 때 애플리케이션 로딩 시점에 의존관계 주입을 통해
필요한 의존관계를 모두 맺어두고 난 다음에 실제 요청을 처리하는 것 과 같은 원리이다.
이 방식의 단점은 런타임에 전략을 변경하기 번거롭다는 것이다.
setter를 사용하는 것은 이전의 쓰레드 로컬이 해결한 싱글턴 동시성 문제를 가져온다.
차라리 컨텍스트를 새로 만들어 strategy를 주입하는 것이 나을 수 있다.
다른 방법은, 쓰레드 간에 메모리 영역을 공유하지 않는 스택 메모리 영역을 사용하는 것이다.
-> 즉 컨텍스트의 파라미터로 주입받는 것이다.
/**
* 전략을 파라미터로 전달 받는 방식
*/
@Slf4j
public class ContextV2 {
public void execute(Strategy strategy) {
long startTime = System.currentTimeMillis(); // 비즈니스 로직 실행
strategy.call(); // 위임
// 비즈니스 로직 종료
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime={}", resultTime);
}
}
전략을 필드로 소유하지 않는 대신, 항상 파라미터로 전달받는다.
하나의 Context 만 생성한 뒤
하나의 Context 에 실행 시점에 여러 전략을 인수로 전달해서 유연하게 실행할 수 있다.
@Slf4j
public class ContextV2Test {
/**
*전략 패턴 적용
*/
@Test
void strategyV1() {
ContextV2 context = new ContextV2();
context.execute(new StrategyLogic1());
context.execute(new StrategyLogic2());
} }
실행할 때 마다 전략을 유연하게 변경할 수 있지만,
단점 역시 실행할 때 마다 전략을 계속 지정해주어야 한다는 것이다.
템플릿과 선 조립, 후 실행
지금 우리가 해결하고 싶은 문제는 변하는 부분과 변하지 않는 부분을 분리하는 것이다.
변하지 않는 부분을 템플릿이라고 정의하며,
그 템플릿 안에서 변하는 부분에 약간 다른 코드 조각을 넘겨서 실행하는 것이 목적이다.
지금 우리가 원하는 것은 애플리케이션 의존 관계를 설정하는 것 처럼 선 조립, 후 실행이 아니다.
단순히 코드를 실행할 때 변하지 않는 템플릿이 있고, 그 템플릿 안에서 원하는 부분만 살짝 다른 코드를 실행하고 싶을 뿐이다.
전략은 변할 수 있다는 것이 핵심이기 때문이다.
따라서 전략 패턴은 파라미터 형태로 구현하는 것이 좋다.
탬플릿 콜백 패턴(Template Callback Pattern)
위와 같이 전략을 파라미터(콜백)으로 받는 형태의 전략 패턴을 탬플릿 콜백 패턴이라 한다.
즉 탬플릿 콜백 패턴은 전략 패턴의 일종이다.
템플릿 콜백 패턴은 GOF 패턴은 아니고, 스프링 내부에서 이런 방식을 자주 사용하기 때문에, 스프링 안에서만 이렇게 부른다
전략 패턴에서 템플릿과 콜백 부분이 강조된 패턴이라 생각하면 된다.
스프링에서는 JdbcTemplate , RestTemplate , TransactionTemplate , RedisTemplate 처럼
다양한 템플릿 콜백 패턴이 사용된다.
스프링에서 이름에 XxxTemplate 가 있다면 템플릿 콜백 패턴으로 만들어져 있다 생각하면 된다.
프로그래밍에서 콜백(callback) 또는 콜애프터 함수(call-after function)는
다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 말한다.
콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있다.
쉽게 이야기해서 callback 은 코드가 호출( call )은 되는데 지금 당장이 아니라,
코드를 넘겨준 곳에서 다음에(back) 실행된다는 뜻이다.
자바 언어에서 콜백
자바 언어에서 실행 가능한 코드를 인수로 넘기려면 객체가 필요하다.
- 자바8부터는 람다를 사용할 수 있다.
- 자바 8 이전에는 보통 하나의 메소드를 가진 인터페이스를 구현하고, 주로 익명 내부 클래스를 사용했다.
최근에는 주로 람다를 사용한다.
탬플릿 콜백 패턴 구현법은 다음과 같다.
먼저 인터페이스를 정의한다.
public interface Callback {
void call();
}
탬플릿은 해당 인터페이스를 구현한 콜백을 파라미터로 받는다.
@Slf4j
public class TimeLogTemplate {
public void execute(Callback callback) {
long startTime = System.currentTimeMillis(); // 비즈니스 로직 실행
callback.call(); // 위임
// 비즈니스 로직 종료
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime={}", resultTime);
}
}
다음과 같이 테스트 할 수 있다.
@Slf4j
public class TemplateCallbackTest {
/**
* 템플릿 콜백 패턴-익명 내부 클래스
*/
@Test
void callbackV1() {
TimeLogTemplate template = new TimeLogTemplate();
template.execute(new Callback() {
@Override
public void call() {
log.info("비즈니스 로직1 실행");
}
});
template.execute(new Callback() {
@Override
public void call() {
log.info("비즈니스 로직2 실행");
}
});
}
}
이전 로깅 로직 탬플릿 메서드 패턴으로 구현하기
먼저 제네릭 탬플릿을 구현한다.
public class TraceTemplate {
private final LogTrace trace;
public TraceTemplate(LogTrace trace) {
this.trace = trace;
}
public <T> T execute(String message, TraceCallback<T> callback) {
TraceStatus status = null;
try {
status = trace.begin(message); // 로직 호출
T result = callback.call();
trace.end(status);
return result;
} catch (Exception e) {
trace.exception(status, e);
throw e;
}
}
}
해당 탬플릿을 익명 클래스(람다)를 이용해 레포지토리에서 구현한다.
@Repository
public class OrderRepositoryV5 {
private final TraceTemplate template;
public OrderRepositoryV5(LogTrace trace) {
this.template = new TraceTemplate(trace);
}
public void save(String itemId) {
template.execute("OrderRepository.save()", () -> {
// 저장 로직
if (itemId.equals("ex")) {
throw new IllegalStateException("예외 발생!");
}
sleep(1000);
return null;
});
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
해당 탬플릿을 익명 클래스(람다)를 이용해 서비스에서 구현한다.
@Service
public class OrderServiceV5 {
private final OrderRepositoryV5 orderRepository;
private final TraceTemplate template;
public OrderServiceV5(OrderRepositoryV5 orderRepository, LogTrace trace) {
this.orderRepository = orderRepository;
this.template = new TraceTemplate(trace);
}
public void orderItem(String itemId) {
template.execute("OrderService.orderItem()", () -> {
orderRepository.save(itemId);
return null;
});
}
}
해당 탬플릿을 익명 클래스(람다)를 이용해 컨트롤러에서 구현한다.
@RestController
public class OrderControllerV5 {
private final OrderServiceV5 orderService;
private final TraceTemplate template;
public OrderControllerV5(OrderServiceV5 orderService, LogTrace trace) {
this.orderService = orderService;
this.template = new TraceTemplate(trace);
}
@GetMapping("/v5/request")
public String request(String itemId) {
return template.execute("OrderController.request()", new TraceCallback<>() {
@Override
public String call() {
orderService.orderItem(itemId);
return "ok";
}
});
}
}
별도의 클래스를 만들어서 전달해도 되지만,
콜백을 사용할 경우 익명 내부 클래스나 람다를 사용하는 것이 편리하다.
물론 여러곳에서 함께 사용되는 경우 재사용을 위해 콜백을 별도의 클래스로 만들어도 된다.
정리 : 지금까지 다룬 내용
- 변하는 코드와 변하지 않는 코드를 분리 -> 탬플릿과 전략
- 더 적은 코드로 변하지 않는 로직 공통 적용
- 탬플릿 메서드 패턴, 전략 패턴, 탬플릿 콜백 패턴
- 람다
지금까지 방법의 한계점
로그 추적기를 적용하기 위해서 비즈니스 로직을 포함한 원본 코드를 수정해야 한다는 점이다.
클래스가 수백개이면 수백개를 더 힘들게 수정하는가 조금 덜 힘들게 수정하는가의 차이가 있을 뿐,
본질적으로 코드를 다 수정해야 하는 것은 마찬가지이다.
수 많은 개발자가 이 문제에 대해서 집요하게 고민해왔고, 여러가지 방향으로 해결책을 만들어왔다.
이를 위해 Proxy라는 기술을 사용한다.
'BackEnd' 카테고리의 다른 글
[Java, Spring] 리플렉션을 활용한 프록시 동적 생성 (0) | 2023.06.19 |
---|---|
[Java, Spring] 프록시, 프록시 패턴, 데코레이터 패턴 (0) | 2023.06.18 |
설계를 깔끔하게 발전시키기 (0) | 2022.03.22 |
Persistence (0) | 2022.03.21 |
Serialization (0) | 2022.03.21 |