본문 바로가기

Web Sever 개발과 CS 기초/스프링

스프링 컨테이너와 빈에 대한 이해

개요 목적

스프링은 다른 클래스의 객체를 사용할 때 직접 객체를 등록하는 방식이 아닌, 외부 컨테이너의 도움을 받아 객체를 주입 받고 사용한다.

이렇게 직접 객체를 생성하지 않고 컨테이너와 그 안에 등록된 객체=빈을 사용하면 얻는 장점이 무엇인지 그리고 동작 원리와 활용 방법은 무엇인지 알아본다

스프링 컨테이너와 빈의 필요성

스프링 프레임워크가 인기가 많아진 이유는 의존성 주입을 담당하는 컨테이너 관리기술 때문이다.

과거 자바에서 공식적으로 구현한 서버 개발 프레임 워크 EJB가 있었다. 이론적으로 좋은 기능들이 많이 포함되어 초기에는 인기가 많았지만, 코드가 너무 복잡하고 EJB에 종속된 코드로 자바의 객체 지향 스타일 장점을 전혀 활용하지 못했다.

그래서 자바의 객체 지향 코딩의 장점을 최대한 살려 개발할 수 있게 도와주는 스프링 프레임 워크가 탄생했다. 스프링의 DI 컨테이너가 그 역할을 해준다.

스프링의 핵심 기술 DI 컨테이너와 빈의 역할과 기능을 알아보기 전에 객체 지향 프로그래밍이 무엇인지 알아보자

객체 지향 프로그래밍이란

객체 지향 프로그래밍은 객체들간의 상호작용을 통해 로직을 구성하는 프로그래밍 방법이고 최대 장점은 다형성을 가지는 것이다.

다형성이란 역할을 인터페이스로 만들고 구현을 인터페이스를 구현한 클래스로 만든다. 해당 서비스를 이용할 때 클라이언트가 인터페이스 존재만 알게 되면 다형성의 장점을 누릴 수 있다.

예를 들어 DB 사용이라는 역할이 있고, 구현체로 Memory DB 구현체와 MySQL DB 구현체가 있다. 클라이언트가 DB 인터페이스 역할만 접촉하게 되면 Memory DB가 MySQL DB로 바뀌어도 클라이언트 코드는 변경 하나 없이 변화에 유연하게 변화할 수 있다. 이것이 객체 지향 코드 최대 장점 다형성이다.

<다형성의 장점을 최대로 올려주는 객체 지향 설계의 5원칙이 있다.(SOLID)>

  • SRP 단일 책임 원칙(single responsibility principle)

한 클래스는 하나의 책임을 가져야 한다.

하나의 클래스에 UI DB SERVICE 관련 코드가 합쳐져 있으면 유지 보수성이 현저히 떨어진다.

  • OCP: 개방-폐쇄 원칙 (Open/closed principle)

소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다. 구현에 변화가 있어도 클라이언트 코드는 변화가 전혀 없어야 한다.

아래 코드는 MemberRepository 인터페이스를 사용하는 클라이언트 Service가 구현에 변화가 있을 때 코드를 변경하는 개방 - 폐쇄 원칙을 어긴 예시이다.

Public class MemberService {
     private MemberRepository memberRepository = new MemoryMemberRepository();
}
//Memory Repository->JDBC Repository로 구현체 변경
//Repository 구현체를 사용하는 클라이언트 Service는 개방 폐쇄 원칙에 따라
//코드가 변경되면 안되는데 변경 되었다. 단순 다형성만으로는 부족함을 느낀다.
public class MemberService {
    //private MemberRepository memberRepository = new MemoryMemberRepository();
    private MemberRepository memberRepository = new JdbcMemberRepository();
}

이 문제를 해결하기 위해서는 객체를 생성하고, 연관 관계를 맺어주는 별도의 조립, 설정자가 필요하다.

public class AppConfig {

    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }
    //구현체가 바뀌어도 클라이언트인 Service는 전혀 코드 변경이 없다.
    //설정자 역할을 하는 AppConfig 클래스가 대신 둘 사이 의존성을 주입하기 때문이다.
    public MemberRepository memberRepository() {
        //return new MemoryMemberRepository();
        return new JdbcMemberRepository();
    }
}

해당 설정자 역할을 스프링은 컨테이너와 빈을 사용해서 더욱 범용성 있게 처리한다. 그 방법은 아래 방법에서 확인하자.

  • LSP: 리스코프 치환 원칙 (Liskov substitution principle)

단순히 컴파일 성공하는 것을 넘어서 다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야 한다. 인터페이스를 구현한 구현체를 믿고 사용하려면 이 원칙을 반드시 지켜야 한다.

예를 들어 자동차 인터페이스의 엑셀은 앞으로 가라는 기능이다, 구현체가 뒤로 가게 구현하면 LSP 위반한 것이다. 느리더라도 앞으로 가는 기능으로 구현체를 구현해야 한다.

  • ISP: 인터페이스 분리 원칙 (Interface segregation principle)

기능 별로 인터페이스를 잘 나누어야 한다. 자동차 기능 정비 기능 합쳐진 인터페이스 보다 자동차 인터페이스 정비 인터페이스로 나누어 명확성과 유지 보수성을 높여야 한다.

  • DIP: 의존관계 역전 원칙 (Dependency inversion principle)

구현 클래스에 의존하지 말고, 인터페이스에 의존해야 한다는 원칙이다.

위에서 나온 JDBC Repository로 변경되었을 때 클라이언트가 구현체 클래스를 직접 바꾸는 의존성이 생긴다. 의존 관계 역전 원칙이 위배된 예시이다.

public class MemberService {
     // private MemberRepository memberRepository = new MemoryMemberRepository();
     private MemberRepository memberRepository = new JdbcMemberRepository();
}

그래서 단순히 다형성 코드만으로는 OCP, DIP를 지킬 수 없다.

스프링 컨테이너로 OCP, DIP 문제 해결하기

AppConfig.class에서 하는 역할을 스프링 컨테이너로 빈 등록 방식을 통해 OCP, DIP 문제 해결해보자. 즉 스프링 프레임워크 목적은 컨테이너와 빈을 통해 다형성 장점을 최대로 활용한 코드를 짜는 것이다.

컨테이너 사용법을 알아보기 전에, AppConfig.class와 스프링 컨테이너의 공통 특징인 제어의 역전과 의존 관계 주입 의미를 알아보자.

  • 제어의 역전 IoC(Inversion of Control)

기존 프로그램은 클라이언트 구현 객체(MemberService)가 스스로 필요한 서버 구현 객체를 생성하고, 연결하고, 실행했다.

AppConfig.class가 나온 이후 클라이언트는 실행하는 역할만 담당한다.

이렇게 프로그램 제어의 흐름을 외부 AppConfig.class에서 결정하는 것이 제어의 역전이다.

  • 의존관계 주입 DI(Dependency Injection)

애플리케이션 실행 시점(런타임)에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달해서 클라이언트와 서버의 실제 의존관계가 연결 되는 것을 의존 관계 주입이라 한다.

의존관계 주입을 사용하면 클라이언트 코드를 변경하지 않고, 클라이언트가 호출하는 대상의 타입 인스턴스를 변경할 수 있다.

즉 AppConfig와 스프링 컨테이너처럼 객체를 생성하고 관리하면서 의존 관계를 연결해 주는 것을 IoC 컨테이너 또는 DI 컨테이너라 한다.

 

<스프링 컨테이너로 AppConfig 역할 대체하기>

//AppConfig에 설정을 구성한다는 뜻의 @Configuration 을 붙여준다.
@Configuration
public class AppConfig {
    
    //각 메서드에 @Bean 을 붙여준다. 이렇게 하면 스프링 컨테이너에 스프링 빈으로 등록한다.
    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }
    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(
            memberRepository(),
            discountPolicy());
    }
    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();
    }
}
public class MemberApp {
    public static void main(String[] args) {
        //AppConfig appConfig = new AppConfig();
        //MemberService memberService = appConfig.memberService();

        //어노테이션을 붙인 AppConfig로 컨테이너(=applicationContext)를 생성
        ApplicationContext applicationContext = new
            AnnotationConfigApplicationContext(AppConfig.class);
        
        //의존관계주입이 완료된 객체를 컨테이너에서 받아와서 사용한다.
        //역시 service코드는 Repository 구현체가 변경되어도 코드 변경이 전혀 없다.
        MemberService memberService =
            applicationContext.getBean("memberService", MemberService.class);

        Member member = new Member(1L, "memberA", Grade.VIP);
        memberService.join(member);
    }
}

스프링 컨테이너 자세히 알아보기

컨테이너 객체 파악하기

ApplicationContext applicationContext =new
AnnotationConfigApplicationContext(AppConfig.class);

ApplicationContext를 스프링 컨테이너라 한다. ApplicationContext 는 인터페이스이고 AnnotationConfigApplicationContext는 어노테이션 기반으로 컨테이너를 만들어주는 구현체이다.

  • 어노테이션 외에도 xml 파일로 빈을 관리하는 컨테이너로 만들 수 있다.(GenericXmlApplicationContext)
  • 스프링 컨테이너를 부를 때 BeanFactory , ApplicationContext 구분한다.ApplicationContext는 BeanFactory 기능을 모두 상속받고 빈을 관리하는 기능 외에 다양한 부가 기능이 추가된 컨테이너로 해당 컨테이너를 주로 사용한다.
  • 추가된 기능으로는 환경변수(로컬, 개발, 운영등을 구분해서 처리), 메시지소스를 활용한 국제화 기능, 편리한 리소스 조회(파일, 클래스패스, 외부 등에서 리소스를 편리하게 조회)이 있다.
  • BeanFactory 스프링 컨테이너의 최상위 인터페이스다.

컨테이너 생성 과정 알아보기

스프링 컨테이너를 생성할 때는 구성 정보를 지정해주어야 한다. AppConfig.class가 구성 정보 역할을 한다.

구성 정보로 들어온 정보로 컨테이너 빈을 등록하고 의존 관계를 설정하게 된다.

빈 이름은 기본적으로 메서드 이름을 사용한다.

빈 이름을 직접 부여할 수 도 있다. @Bean(name="memberService2")

빈 이름은 항상 다른 이름을 부여해야 한다. 같은 이름을 부여하면, 다른 빈이 무시되거나, 기존 빈을 덮어버리거나 설정에 따라 오류가 발생한다. (NoUniqueBeanDefinitionException)

설정 정보를 참고해서 의존 관계를 주입한다.

싱글톤 컨테이너

스프링 컨테이너는 등록된 빈을 싱글톤으로 관리하는 특징이 있다.

장점으로는 웹 서버에 다양한 클라이언트 요청이 와도 이미 만들어둔 1개의 객체를 제공하면 되기 때문에 객체 생산 자원을 절약할 수 있다.

그러나 싱글톤 패턴으로 객체를 생성 관리하려면 구현하는 코드 자체가 많아지고 테스트하기 어려움 등 다양한 문제점이 존재하는데, 스프링 컨테이너는 이런 문제점을 해결하면 인스턴스를 싱글톤으로 관리한다.

@Configuration으로 싱글톤 설정하기

@Configuration
public class AppConfig {
    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(repository());
    }
    @Bean
    public OrderService orderService() {
            return new OrderServiceImpl(repository());
    }
    //자바 코드로만 봐서는 repository()를 두번 작성하여
    //new MemoryRepository를 두번 호출 두개의 인스턴스만 작성한 것 같은데
    //결론적으로 하나의 인스턴스를 반환한다
    //그 이유는 @Configuration으로 AppConfig도 스프링 빈으로 등록되어서 
    //동작이 추가되었 때문이다.
    @Bean
    public Repository repository() {
        return new MemoryRepository();
    }
}

자바 코드로만 보면 MemoryRepotiory인스턴스가 new로 두 번 호출되어 두 개의 인스턴스가 생성된 것 같다.

하지만 @Configuration으로 등록된 AppConfig 클래스가 스프링 빈으로 등록되어 추가 동작을 수행해 싱글톤 인스턴스로 반환한다.

AppConfig 스프링 빈을 조회해서 클래스 정보를 출력해보면 class.hello.core.AppConfig$$EnhancerBySpringCGLIB$$bd479d70 라는 새로운 클래스가 빈으로 등록된 것을 확인할 수 있다.

이것은 내가 만든 클래스가 아니라 프링이 CGLIB라는 바이트코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 조작된 클래스를 스프링 빈으로 등록한 것이다.

스프링이 만든 조작된 클래스는 @Bean이 붙은 메서드마다 이미 스프링 빈이 존재하면 존재하는 빈을 반환하고, 스프링 빈이 없으면 생성해서 스프링 빈으로 등록하고 반환하는 코드가 동적으로 만들어진다.

덕분에 사용자의 코드 작성 없이 싱글톤이 보장된다.

그래서 @Bean만 사용해도 스프링 빈으로 등록되지만, 싱글톤을 보장하지 않는다는 주의점이 있다. 스프링 설정 정보는 항상 @Configuration 을 사용하자.

싱글톤 사용 시 주의 사항

객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에 싱글톤 객체는 상태를 유지(stateful)하게 설계하면 안된다.

특정 클라이언트에게 의존하는 필드 대신에 자바에서 공유되지 않는, 지역변수, 파라미터, ThreadLocal를 사용해서 데이터 저장을 해야 한다.

컴포넌트 스캔

지금까지 @Configration 설정 파일을 만들고 그 안에 @Bean을 일일이 작성하여 스프링 컨테이너에 빈을 등록하는 방법을 알아보았다.

단순히 어노테이션을 부착하여 컨테이너와 빈 등록 의존성 주입을 하는 편리한 방법을 알아보자.

@ComponentScan @Component @Autowired

//컴포넌트들을 스캔해서 빈으로 등록해주는 설정 정보 등록 @ComponentScan
@Configuration
@ComponentScan
public class AutoAppConfig {
}

//빈으로 등록하고 싶은 class에 @Component 어노테이션 작성
@Component
public class MemoryMemberRepository implements MemberRepository {}
@Component
public class RateDiscountPolicy implements DiscountPolicy {}

@ComponentScan 은 @Component 가 붙은 모든 클래스를 스프링 빈으로 등록한다.

이때 스프링 빈의 기본 이름은 클래스명을 사용하되 맨 앞 글자만 소문자를 사용한다.

  • 이름 기본 전략: MemberServiceImpl 클래스 → memberServiceImpl로 등록
  • 빈 이름 직접 지정: 만약 스프링 빈의 이름을 직접 지정하고 싶으면 @Component("memberService2") 이런식으로 이름을 부여하면 된다.
//@Autowired 는 의존관계를 자동으로 주입하기
@Component
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;
    @Autowired
    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy
        discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

생성자에 @Autowired 를 지정하면, 스프링 컨테이너가 자동으로 해당 스프링 빈을 찾아서 주입한다.

의존성을 주입하는 @Autowired 원리와 사용법은 아래 목차에서 알아본다.

@ComponentScan 탐색 위치, 빈 등록 대상, 다양한 필터

<탐색 위치>

@ComponentScan(
	 basePackages = "hello.core",
}

basPackages 옵션으로 탐색할 패키지 시작 위치를 정한다. 해당 패키지와 하위 패키지 모두 탐색한다. basePackages = {"hello.core", "hello.service"} 이렇게 여러 시작 위치를 지정할 수도 있다.

만약 지정하지 않으면 @ComponentScan 이 붙은 설정 정보 클래스의 패키지가 시작 위치가 된다.

<빈 등록 대상>

컴포넌트 스캔은 @Component 뿐만 아니라 다음과 내용도 추가로 대상에 포함한다.

@Component : 컴포넌트 스캔에서 사용
@Controlller : 스프링 MVC 컨트롤러에서 사용
@Service : 스프링 비즈니스 로직에서 사용
@Repository : 스프링 데이터 접근 계층에서 사용
@Configuration : 스프링 설정 정보에서 사용

<다양한 필터 옵션>

includeFilters : 컴포넌트 스캔 대상을 추가로 지정한다.
excludeFilters : 컴포넌트 스캔에서 제외할 대상을 지정한다

@ComponentScan 중복 등록 충돌

컴포넌트 스캔에서 같은 빈 이름을 등록하면 에러가 발생한다.

  • 자동 빈 등록 vs 자동 빈 등록

컴포넌트 스캔에 의해 자동으로 스프링 빈이 등록되는데, 그 이름이 같은 경우 스프링은 오류를 발생시킨다. ConflictingBeanDefinitionException 예외 발생

  • 수동 빈 등록 vs 자동 빈 등록

최신 스프링 부트에서는 수동 빈 등록과 자동 빈 등록이 충돌이 나면 오류가 발생하도록 기본 값이 세팅되었다. (과거에는 수동 빈이 우선권을 가져 자동 빈을 오버라이딩 해버렸다.)

의존 관계 자동 주입 @Autowired 이해

스프링 다양한 의존관계 주입 방법

1. 수정자 주입(Setter 주입, Setter Injection)

@Service
public class UserService {

    private ServerRepository serverRepository ;

    @Autowired
    public void setServerRepository(ServerRepository serverRepository ) {
        this.serverRepository = serverRepository ;
    }
}

수정자 주입은 Setter를 사용하여, DI를 설정한다. 의존성이 선택적으로 필요한 경우에 사용한다. (실제로 변경되는 경우는 극히 드물다.)

2. 필드 주입(Filed Injection)

@Service
public class UserService {

    @Autowired
    private ServerRepository serverRepository ;
}

필드에서 바로 의존 관계를 주입하는 것이다. 간단 하지만 외부에서 접근이 불가능하는 단점 때문에, 거의 사용되지 않는다. 어플리케이션 기능과 무관한 간단한 테스트 코드에서만 이용을 한다.

3. 생성자 주입(Constructor Injection)

@Service
public class UserService {

    private ServerRepository serverRepository ;

    @Autowired
    public UserService(ServerRepository serverRepository) {
        this.serverRepository= serverRepository;
    }
}

Spring Bean으로 등록된 UserService와 ServerRepository를 생성자를 통해 등록하는 방법이다.

생성자를 작성하고 @Autowired 어노테이션을 붙이면 스프링 컨테이너에서 DI를 설정한다.

<생정자 주입을 써야 하는 이유>

  • 불변성 보장

대부분 의존 관계 주입은 한번 일어나면 애플리케이션 종료 시점까지 의존 관계를 변경할 일이 없다. 생성자 주입은 객체 생성할 때 딱 1번 호출되므로 불변한 설계가 가능하다.

  • 누락 확인이 쉽다

생성자 주입을 설정하면 어플리케이션 실행 시점이 아닌 컴파일 시점에서 의존성이 누락된 것을 확인할 수 있다.

  • final 키워드 사용 가능

생성자 주입을 사용하면 필드에 final 키워드를 사용할 수 있다. 그래서 생성자에서 혹시라도 값이 설정되지 않는 오류를 컴파일 시점에 막아준다.

  • Lombok과 결합 가능

롬복 라이브러리가 제공하는 @RequiredArgsConstructor 기능을 사용하면 final이 붙은 필드를 모아서 생성자를 자동으로 만들어준다.

@Component
@RequiredArgsConstructor
public class UserService {
    private ServerRepository serverRepository ;
}

@Autowired 옵션 처리

주입할 스프링 빈이 없어도 동작해야 할 때가 있다.

그런데 @Autowired 만 사용하면 required 옵션의 기본값이 true 로 되어 있어서 자동 주입 대상이 없으면 오류가 발생한다.

자동 주입 대상을 옵션으로 처리하는 방법은 다음과 같다.

  • @Autowired(required=false) : 자동 주입할 대상이 없으면 수정자 메서드 자체가 호출 안된다.
  • org.springframework.lang.@Nullable : 자동 주입할 대상이 없으면 null이 입력된다.
  • Optional<> : 자동 주입할 대상이 없으면 Optional.empty 가 입력된다
//호출 안됨
@Autowired(required = false)
public void setNoBean1(Member member) {
    System.out.println("setNoBean1 = " + member);
}
//

//null 호출
@Autowired
public void setNoBean2(@Nullable Member member) {
    System.out.println("setNoBean2 = " + member);
}
//setNoBean2 = null

//Optional.empty 호출
@Autowired(required = false)
public void setNoBean3(Optional<Member> member) {
    System.out.println("setNoBean3 = " + member);
}
//setNoBean3 = Optional.empty

조회할 빈이 2개 이상일 때 해결 방법 - @Autowired 필드 명, @Qualifier, @Primary

@Autowired
private DiscountPolicy discountPolicy

DiscoutPolicy를 의존성 주입할 때 DiscountPolicy를 구현한 빈이 FixDiscountPolicy , RateDiscountPolicy 두 개 등록되어 있다면 문제가 발생한다.

@AutoWired를 통해서 의존성 주입을 하게 되면 ac.getBean(DiscountPolicy.class)와 비슷한 형식으로 타입 조회를 하기 때문에 NoUniqueBeanDefinitionException 오류가 발생한다.

조회할 빈이 2개 이상일 때 해결 방법으로는

  • @Autowired 필드 명 매칭
//등록할 빈을 필드명을 통해서 정확히 명시한다.
@Autowired
private DiscountPolicy rateDiscountPolicy
  • @Qualifier 사용
//빈 등록시 @Qualifier를 붙여 주고 주입시에 @Qualifier를 붙여주고 등록한 이름을 적어준다.
@Component
@Qualifier("mainDiscountPolicy")
public class RateDiscountPolicy implements DiscountPolicy {}

@Autowired
public OrderServiceImpl(@Qualifier("mainDiscountPolicy") DiscountPolicy discountPolicy) {
    this.discountPolicy = discountPolicy;
}
  • @Primary 사용
//타입이 같은 스프링 빈중에 우선권을 가지도록 설정할 수 있다. 
@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy {}
@Component
public class FixDiscountPolicy implements DiscountPolicy {}

@Primary와 @Qualifier 동시에 설정했을 때, 상세한 동작을 하는@Qualifier 가 우선권이 있다.

Bean 의 관리 방식 Bean Scope

Spring 컨테이너가 Bean을 관리하는 범위를 나타내는 것이, Bean Scope이다.

Scope의 종류에는

1.Singleton

Spring은 기본적으로 Bean을 Singleton으로 생성한다. 빈이 생성되고 없어질 때까지 컨테이너에서 싱글톤으로 관리된다. Spring 컨테이너 시작과 종료까지 1개의 객체로 유지된다.

2. Prototype

Spring 컨테이너는 prototype Bean의 생성과 의존관계까지만 관여하고 더 이상 관리하지 않는다.

객체에 대한 요청이 오면 항상 새로운 인스턴스를 반환하고 이후에 관리 하지 않는다.

클라이언트가 직접 객체를 관리해야 한다.

@Scope(”prototype”) 어노테이션으로 설정 할 수 있다.

쓰기를 많이 하는 객체의 경우, 동기화 비용이 많이 드는 경우, 비 Singleton으로 생성하는 것이 좋다.

→현재는 거의 사용하지 않는다. 대신 객체를 관리해주는 기능을 사용하기 위해 스프링을 쓰는데, Prototype은 그런 잠점이 사라진다. 그래서, 매번 새로 써야하는 객체를 사용할 때는 싱글톤을 유지하고 쓰레드 로컬로 지정하여 사용한다.

3. Web Scope

request: 각각의 요청이 들어오고 나갈때가지 유지되는 스코프

session: 세션이 생성되고 종료될 때 까지 유지되는 스코프

application: 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프


Reference

https://mangkyu.tistory.com/125

https://www.inflearn.com/course/스프링-입문-스프링부트

https://roadofdevelopment.tistory.com/66