항해99 3주차 WIL - IOC / DI / Bean

Ming-Gry·2022년 10월 9일
1

항해99 WIL

목록 보기
3/12

솔직히 이번 포스팅을 어느 정도 수준까지 할까 굉장히 고민이 많았다. 그러나 어차피 공부하고 포스팅해두면 내꺼가 된다는 생각으로 부족하지만 최선을 다해서 작성해봤다!

1) IOC / DI

1-1) IOC 란?

IOC 의 뜻

IOC 란 Inversion Of Control 의 약자로, '제어의 역전' 이라는 뜻이다. 쉽게 말해 프로그래머가 직접 제어(혹은 관리) 하던 것외부에서 제어하도록 만든다는 뜻을 갖고 있다.

IOC 를 왜 하는데?

다른 포스팅들을 보니 'IOC 의 장점 때문에 사용한다', 'DIP 원칙 (의존성 역전의 원칙 - Dependency Inversion Priniple) 때문에 사용한다' 라는 내용을 봤다. 그러나 IOC 의 장점과 DIP 가 주는 '가치' 가 무엇인지에 대해서는 명확한 것을 알 수 없었다.

결국 모든 포스팅의 내용을 종합해보니 '변경에 유연한 코드를 짜기 위해서 IOC 를 사용한다.' 라고 답을 내릴 수 있었고, 실제 코드로 보았을 때도 그런 것을 확인할 수 있었다.

IOC 의 장점 :

  • 객체 간 결합도를 낮춘다.
  • 유연한 코드 작성 가능
  • 가독성 증진
  • 코드 중복 방지
  • 유지 보수 용이

DIP(의존성 역전의 원칙, Dependency Inversion Principle) - 상위 레벨의 모듈은 절대 하위 레벨 모듈에 의존하지 않는다. 둘 다 추상화에 의존해야 한다.

어떤 제어를 역전시키는데?

객체 생명주기나 흐름을 역전시키는 것이다. 자세한 것은 아래의 코드로 나타내보겠다.

public class A {
	private B b;
    
    public A() {
    	this.b = new B();
    }
}

라는 코드가 있다고 해보자. 이 코드의 클래스 A 는 B 라는 객체를 필드로 갖고 있는데, 생성자에서 어떠한 매개변수도 받지 않기 때문에 B 객체를 직접 생성해 필드를 초기화하고 있다.

public class A {
	private B b;
    
    public A(B b) {
    	this.b = b;
    }
}

코드를 이렇게 바꾸면 클래스 A 는 B 객체를 매개변수로 받아 이를 필드로 초기화하고 있다. 이럼으로써 A 클래스는 B의 변화에 훨씬 유연하게 대처할 수 있게 되고, B 또한 상속, 구체화 등을 통해 다형성 있는 코드를 짤 수 있게 되었다.

갑자기 이러한 말이 생각난다. 김춘수 선생님은 알고보니 프로그래머 출신이었을지도 모른다!

"내가 그의 이름을 불러 주었을 때 그는 나에게로 와서 꽃이 되었다." - 김춘수, <꽃> -

1-2) DI 란?

DI 의 뜻

DI 란 Dependency Injection 의 약자로, '의존성 주입' 이라는 뜻이다. 이는 IOC 와 연관이 있긴 하지만, 명백히 IOC 와는 다른 개념이며, IOC 를 구현하는 하나의 디자인 패턴 중 하나이다. 사실 위에서 코드로 나타낸 것이 DI 를 아주 간단히 표현해본 것이라고 할 수 있다.

DI 예시

이를 간단한 예시로 조금 더 구체적으로 설명해보도록 하겠다.

혹시 이 게임을 알고 있는가...? 좋은 피자 위대한 피자라는 게임인데 진상 손님들의 개같고 다양한 요구 사항에 맞춰 피자를 만드는 인성 파탄용 타이쿤류 게임이다. 위의 손님이 말한 것처럼 페퍼로니 없는 페퍼로니 피자를 만든다 생각하고 코드를 짜보자.

public class PepperoniPizza{
	private OriginalDough originalDough;
    private ItalianPepperoni italianPepperoni;
    private MozzarellaCheese mozzarellaCheese;
    private KoreanBeef koreanbeef;

    public PepperoniPizza(){
        this.originalDough = new OriginalDough();
        this.italianPepperoni = new ItalianPepperoni();
        this.mozzarellaCheese = new MozzarellaCheese();
        this.koreanBeef = new KoreanBeef();
    }

    public void deletePepperoni(){
        this.italianPepperoni = null;
    }

    public ItalianPepperoni getItalianPepperoni(){
        return italianPepperoni;
    }
    
    public static void main(String[] args) {
        PepperoniPizza pepperoniPizza = new PepperoniPizza();
        System.out.println(pepperoniPizza.getItalianPepperoni());

        pepperoniPizza.deletePepperoni();
        System.out.println(pepperoniPizza.getItalianPepperoni());
    }
}

전혀 DI 가 되지 않아 IOC 가 되지 않은 코드의 모습이다. 필드를 보니 페퍼로니 객체에는 오리지날 도우, 이탈리안 페퍼로니, 모짜렐라 치즈, 한우가 들어갔다는 것을 알 수 있다. 그러나 위의 손님과 같은 요구사항을 들어주기 위해 main 메소드에서 정상적인 페퍼로니 피자 객체를 만들어준 후 deletePepperoni() 메소드를 통해 이탈리안 페퍼로니를 null 로 만드는 것을 볼 수 있다.

물론 동작하는 데에는 크게 지장이 있진 않지만, 어딘가 다형성이 무너져 있으며, 불필요한 메소드가 들어가 있는 것으로 보인다.

이게 실제 세상이었다면 이탈리안 페퍼로니로 피자를 만들고 빼는 불필요한 시간과 금전적 손실을 입게 되었을 것이다.

public class Pizza {
    private Dough dough;
    private Pepperoni pepperoni;
    private Cheese cheese;
    private Meat meat;

    public Pizza(Dough dough, Pepperoni pepperoni, Cheese cheese, Meat meat){
        this.dough = dough;
        this.pepperoni = pepperoni;
        this.cheese = cheese;
        this.meat = meat;
    }

    public Pepperoni getPepperoni() {
        return pepperoni;
    }

    public Cheese getCheese() {
        return cheese;
    }

    public static void main(String[] args) {
        Pizza pizza = new Pizza(new OriginalDough(), null, null, new KoreanBeef());

        System.out.println(pizza.getPepperoni());
        System.out.println(pizza.getCheese());
    }
}

코드를 이렇게 바꾸면 생성자를 통해 도우, 페퍼로니, 치즈, 고기를 선택하고 넣을 수 있게 된다. 진상 손님이 페퍼로니와 치즈까지 빼달라고 하면 null 로 어떠한 값을 넣지 않아도 되며, Dough 를 extend / implement 받은 new OriginalDough() 객체와 Meat 를 extend / implement 받은 new KoreanBeef() 객체 마저 넣으며 아주 다양한 피자를 만들 수 있게 된다.

이렇듯 생성자에 객체를 넣어준 것으로 의존 관계를 역전시킴으로서 유연하게 대처할 수 있게 되었다. 이처럼 객체 외부인 생성자에서 의존성을 주입시켜준 것이라고 하여 의존성 주입이라 말하며, 이것이 DI 패턴 중 하나이다.

1-3) 생성자 주입을 사용해야하는 이유

다양한 DI 의 방법

DI 패턴이냐, Spring 의 DI 냐로 조금 나뉘는 것 같은데 어차피 필자도 Java, Spring 스택이기 때문에 Spring 을 기반으로 설명하도록 하겠다. ※ DI 패턴에는 생성자 주입, Setter 주입, 인터페이스 주입으로 나뉘는 것 같고, Spring 의 DI 는 필드 주입, Setter 주입, 생성자 주입으로 나뉘는 것 같다.

필드 주입

가장 간단하지만 가장 추천되지 않는 방법이다. 주입할 필드 위에 @Autowired 어노테이션을 붙이는 방법이다. @Autowired 어노테이션을 붙이면, 스프링이 자동으로 의존성을 주입해준다.

@Controller
public class MemberController {

	@Autowired
	private MemberService memberService;
}

필드 주입이 추천되지 않는 이유는 이것이 갖는 치명적인 단점 때문이다. 필드 주입을 하게 되면 외부 접근이 불가능하다. 해당 필드를 초기화하는 생성자도, 필드에 값을 넣어주는 setter 도 없기 때문에 필드에 값을 주입해줄 방법이 없다.

Setter 주입

주입받는 객체가 변경될 가능성이 있는 경우에 사용한다고 한다. 그러나 실제로 변경이 필요한 경우는 극히 드물다. 그리고 Setter 주입은 주입되지 않은 의존성을 호출할 경우 NPE (Nullpoint Exception) 이 발생할 수 있기 때문에 역시나 추천되는 방법은 아니다.

@Controller
public class MemberController {

	private MemberService memberService;
    
    @Autowired
    public void setMemberService(MemberService memberService){
    	this.memberService = memberService;
    }
}

생성자 주입

Spring 에서 공식적으로 추천하는 방법이고, 어떤 블로그 글을 봐도 하나 같이 입을 모아 추천하는 방법이 생성자 주입이다. 물론 나도 위의 코드에서 생성자 주입을 사용했다.

@Controller
public class MemberController {

	private final MemberService memberService;
    
    @Autowired
    public MemberController(MemberService memberService){
    	this.memberService = memberService;
    }
}

왜 생성자 주입을 사용해야 하는지 아래의 포스팅에서 자세히 설명하도록 하겠다.

생성자 주입을 사용해야하는 이유

  • 객체의 불변성 확보
  • 테스트 코드 작성 용이
  • final 키워드 작성 및 Lombok 과의 결합
  • 스프링에 비침투적인 코드 작성
  • 순환 참조 에러 방지

생성자 주입을 사용할 경우, 의존성 주입이 최초 빈 생성 시 1회만 호출됨을 보장할 수 있다. (애초에 생성자는 1회만 호출되기 때문) 그렇기 때문에 변경의 가능성을 배제하고 불변성이 보장된다.

생성자 주입을 사용하면 컴파일 시점에 객체를 주입받아 테스트 코드를 작성할 수 있으며, 주입하는 객체가 누락된 경우 컴파일 시점에 오류를 발견할 수 있다.

또 필드 객체에 final 키워드를 써서 런타임 시 불변성을 보장할 뿐 아니라 Lombok 과 결합해 코드를 간결하게 짤 수 있다.

@Controller
@RequiredArgsConstructor //final 이 붙은 필드를 매개변수로 하는 생성자를 자동으로 생성해준다.
public class MemberController {

	private final MemberService memberService;
    
    //우리 눈에는 보이지 않지만, 코드 상 생략되었을 뿐 생성자가 있는 것과 같다!
}

사실 스프링을 사용하면서 스프링에 비침투적인 코드를 짤 수 있을까 싶긴 해서 이게 장점인지는 모르겠지만...! 어쨌든 우리가 사용하는 프레임워크는 언제든 바뀔 수 있기 때문에 장점이라고(?) 하는 것 같다.

import org.springframework.beans.factory.annotation.Autowired;
// 스프링 의존성이 import 되어 스프링에 의존도가 높아진다!

@Controller
public class MemberController {

	private final MemberService memberService;
    
    @Autowired
    public MemberController(MemberService memberService){
    	this.memberService = memberService;
    }
}

마지막으론 프로그래머의 실수로 순환참조가 이뤄졌을 때 컴파일 시점에 오류를 띄워준다는 것이 장점이라고 한다!

이 다섯 가지 이유 중에서 가장 그럴듯한 이유는 객체의 불변성 확보와 테스트 코드 작성 용이, Lombok 과 결합 이 세 가지인 것 같다.

특히 필드 주입 혹은 Setter 주입은 객체 생성시점에 스프링 빈이 없는 경우 null 인 상태로 존재하게 되는데, 생성자 주입 방식과 final 키워드를 사용해 무조건 의존 관계가 주입되도록 강제함으로써 NPE 를 방지할 수 있다.

테스트 코드 작성이 용이하다는 것은 워낙에 TDD 가 중요하고 실무에서 많이 쓰기 때문이다. 물론 많이 써보진 못했지만... 그리고 Lombok 의 ArgsConstructor 는 정말 편하다. 생각보다 개발을 하면서 의존성을 많이 주입해 줘야 할 때도 있고, 필드를 바꿔줘야 할 때도 많은데, 그때마다 생성자를 바꿔야 하는 게 얼마나 피곤한 일인지 모른다.

2) Bean

2-1) Bean 이란?

Bean 의 뜻

Bean 이란 Spring IOC Container 에서 관리되는 객체를 말한다. 우리가 특정 객체를 Bean 으로 등록함으로써 Spring IOC Container 가 Bean 의 생명주기 관리, 관계설정 등의 제어 작업을 총괄한다.

@Controller //Controller Bean 등록 Annotation
@RequiredArgsConstructor //생성자 주입으로 DI
public class MemberController {

	private final MemberService memberService;
}

@Service //Service Bean 등록 Annotation
@RequiredArgsConstructor 
public class MemberService {

	private final MemberRepository memberRepository;
}

위의 코드의 어노테이션을 달아주는 것만으로도 MemberController 와 MemberService 의 Bean 등록과 DI 가 끝났다. 아주 간단하지 않은가? Bean 등록 방법은 아래에서 다시 알아보도록 하자.

Bean 과 싱글톤

Bean은 싱글톤으로 관리된다. 스프링에 여러 번 빈을 요청하더라도 매번 동일한 객체를 돌려준다는 뜻이다. 많은 양의 트래픽에 대처하기 위해서는 싱글톤으로 관리하는 것이 더 좋기 때문이다.

예를 들어 요청 1번에 4개의 객체가 생성되고 1초에 100 번 요청이 온다고 하면 1초에 400개의 새로운 객체가 생성되어야 한다는 뜻이 된다. 그렇기 때문에 빈을 싱글톤으로 관리하여 1개의 요청이 왔을 때 여러 스레드가 빈을 공유해 처리하도록 한다.

2-2) Bean 등록하는 방법

Bean 을 등록하는 방법은 수동으로 등록하는 @Bean, @Configuration 을 사용하는 방법과 Component Scan 으로 자동으로 등록하는 @Component 를 사용하는 방법이 있다.

@Bean, @Configuration

@Configuration
public class QueryDslConfiguration {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory(){
        return new JPAQueryFactory(entityManager);
    }
}

항해 실전 프로젝트를 하며 실제로 사용했던 예시를 가져와봤다. 설정 클래스에 @Configuration 어노테이션을 달아주고 메소드에 Bean 을 달아주어 수동으로 Bean 을 등록한 모습이다.

스프링 컨테이너는 @Configuration 이 붙어있는 클래스를 Bean 으로 등록해두고 해당 클래스를 파싱하여 @Bean 이 있는 메소드를 찾아서 빈을 생성해준다.

수동으로 Bean 을 등록해줘야 하는 상황은 아래와 같다. 고 한다. 위의 예에서 Bean 을 수동으로 등록해준 이유는 Spring 외부 프레임워크인 QueryDSL 을 사용하기 위함이다.

  • 개발자가 직접 제어가 불가능한 라이브러리를 활용할 때
  • 어플리케이션 전범위적으로 사용되는 클래스를 등록할 때
  • 다형성을 활용하여 여러 구현체를 등록해주어야 할 때

이 방법을 쓸 때는 @Configuration 안에 @Bean 을 사용해야 싱글톤이 보장된다고 하는데, 그 이유는 아래의 포스팅에서 살펴보도록 하자.

@Configuration 안에 @Bean을 사용해야 하는 이유 : https://mangkyu.tistory.com/234

@Component

스프링에서는 Component Scan 을 사용해 @Component 어노테이션이 있는 클래스들을 찾아 자동으로 Bean 등록을 해준다. @Controller, @Service, @Repository, @Configuration 등이 그 예시이며, 어노테이션을 잘 살펴보면 아래의 코드와 같이 @Component 어노테이션이 숨어있다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component // @Configuration 안에 @Component이 숨어 있다.
public @interface Configuration {

}

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component // @Controller 안에 @Component이 숨어 있다.
public @interface Controller {

}

그랬기 때문에 아래의 코드에서 MemberService 와 MemberController 를 Bean 으로 등록하고, DI 를 사용할 수 있었다.

@Controller //Controller Bean 등록 Annotation
@RequiredArgsConstructor //생성자 주입으로 DI
public class MemberController {

	private final MemberService memberService;
}

@Service //Service Bean 등록 Annotation
@RequiredArgsConstructor 
public class MemberService {

	private final MemberRepository memberRepository;
}

위에서 살펴본 것처럼 수동으로 Bean 을 등록해줘야 할 때를 제외하곤 스프링에선 자동 빈 등록 방식을 권장한다.

@Autowired 는 뭐였더라?

@AutowiredDI 를 시켜주는 어노테이션이므로 Bean 을 등록시켜주는 어노테이션은 아니다. 그래서 필드 주입, Setter 주입, 생성자 주입 등에 사용하는 어노테이션이다. 다만, 생성자가 하나일 경우 @Autowired 를 생략할 수 있기 때문에 쓰지 않는 경우도 많다.

추가적으로 Bean 으로 등록되지 않은 객체에 @Autowired 로 DI 시키려는 경우 에러가 발생할 수 있다. 또한 같은 타입의 Bean 이 여러 개일 경우 @Qualifier 를 사용해 빈을 찾도록 하거나 @Primary 를 사용해 Bean 의 우선순위를 부여할 수 있는 기능도 있지만 여기서는 다루지 않도록 하겠다. 궁금한 독자를 위해 아래의 포스팅을 남기도록 하겠다.

@Autowired 빈 탐색 전략과 @Qualifier와 @Primary : https://mangkyu.tistory.com/148
@Autowired 란 무엇인가? : https://devlog-wjdrbs96.tistory.com/166

2-3) Bean 의 생명주기

일반적으로 IOC Container 에서 Spring 이 자체적으로 Bean 을 관리하며 생명주기(생성 및 소멸) 도 관리한다. Bean 의 생명주기는 아래와 같다.

스프링 컨테이너 생성 → 스프링 빈 생성 → 의존관계 주입 → 초기화 콜백 → 사용 → 소멸 전 콜백 → 스프링 종료

필요하지 않은 경우도 있겠으나, 예를 들어 애플리케이션이 시작될 때, 미리 DB 서버를 연결시켜두거나 네트워크 소켓처럼 애플리케이션 시작 시점에 미리 연결하고, 애플리케이션이 종료될 때 안전하게 종료해줘야 할 때 콜백 메소드를 사용해 Bean 의 초기화 및 소멸 시점을 알려줘야 한다.

Spring 은 의존관계 주입 후에 초기화 콜백을 사용하는데, 이때 아래의 방법을 이용하면 초기화 시점을 알려줄 수 있다. 또한 스프링 컨테이너가 종료되기 직전에 소멸 콜백을 주어 안전하게 종료 작업을 진행할 수 있다.

이처럼 Bean 초기화 및 소멸 시점의 생명주기를 관리하는 방법에는 세 가지 방법이 있지만, 여기서는 가장 추천되는 방법인 @PostConstruct 와 @PreDestroy 에 대해서만 알아보고자 한다. 다른 방법에 대한 내용은 아래의 포스팅을 참고하도록 하자.

SPRING - 빈 생명 주기 : https://imspear.tistory.com/170

@Service
@RequiredArgsConstructor
public class S3UploadService {

    private final AmazonS3 s3Client;

    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;

    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    @Value("${cloud.aws.region.static}")
    private String region;

    @PostConstruct
    public AmazonS3Client amazonS3Client() {
        BasicAWSCredentials awsCredential = new BasicAWSCredentials(accessKey, secretKey);
        return (AmazonS3Client) AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCredential))
                .build();
    }

이번에도 항해 실전 프로젝트를 하며 실제로 사용했던 예시를 가져와봤다. AWS S3 에 이미지를 업로드 시키는 서비스를 만들기 위한 클래스이다. AmzonS3 객체를 필드로 두고, 해당 객체에 AccessKey, SecretKey, Region 등을 넣어 S3UploadService 가 사용되기 전에 초기화되도록 설정하였다. 실제로 위의 방법은 AWS 측에서도 권장하는 방법이다. 추가로 NoSQL 인 CassandraDB 에 DB 연결 후 테스트 데이터를 삽입하는 방법도 아래에 있으니 참고하면 좋을 듯 하다.

AWS 기반 Spring Boot 애플리케이션 개발 시작하기 : https://aws.amazon.com/ko/blogs/korea/getting-started-with-spring-boot-on-aws/
Connecting to a NoSQL Database with Spring Boot : https://www.baeldung.com/spring-boot-nosql-database

@TestConfiguration
public class TestRedisConfiguration {

    private RedisServer redisServer;

    public TestRedisConfiguration(RedisProperties redisProperties) {
        this.redisServer = new RedisServer(redisProperties.getRedisPort());
    }

    @PostConstruct
    public void postConstruct() {
        redisServer.start();
    }

    @PreDestroy
    public void preDestroy() {
        redisServer.stop();
    }
}

위의 코드는 RedisServer 를 테스트하기 위한 코드인데, 실제 Redis 서버를 중지하지 않고 테스트를 하기 위해 짜놓은 코드이다. 이런 식으로 @PostConstruct 나 @Predestroy 를 사용할 수 있다.

Embedded Redis Server with Spring Boot Test : https://www.baeldung.com/spring-embedded-redis

@PostConstuct 나 @PreDestroy 는 아래의 장점을 갖고 있지만 외부 라이브러리를 적용하지 못한다. 따라서, 그럴 때는 @Bean 의 initMethod 와 destroyMethod 옵션을 사용해보자.

@PostConstruct 와 @PreDestroy 의 장점 :

  • 어노테이션 하나로 관리 가능
  • 컴포넌트 스캔에 적용이 용이함

외부 라이브러리 사용 시 @Bean 사용법 :

@Configuration
static class LifeCycleConfig{
	@Bean(initMethod = "init", destroyMethod = "close")
	public NetworkClient networkClient(){
    	NetworkClient networkClient = new NetworkClient();
		networkClient.setUrl("<http://hello-spring.dev>");
		return networkClient;
    }
}

참고 자료 : https://isoomni.tistory.com/entry/TISPRING-IOC-DI-%EC%A0%95%EC%9D%98-%EC%9E%A5%EC%A0%90
https://velog.io/@gillog/Spring-DIDependency-Injection
https://velog.io/@ohzzi/Spring-DIIoC-IoC-DI-%EA%B7%B8%EA%B2%8C-%EB%AD%94%EB%8D%B0
https://velog.io/@ohzzi/Spring-DIIoC-%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%98-%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%A3%BC%EC%9E%85
https://velog.io/@ohzzi/Spring-DIIoC-%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%98-%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%A3%BC%EC%9E%85-2-DIIoC-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88%EC%99%80-%EC%9A%B0%EC%84%A0%EC%88%9C%EC%9C%84
https://biggwang.github.io/2019/08/31/Spring/IoC,%20DI%EB%9E%80%20%EB%AC%B4%EC%97%87%EC%9D%BC%EA%B9%8C/
https://catsbi.oopy.io/c7f85a3d-fe55-40b5-85af-7ec0b99b27c3#c7f85a3d-fe55-40b5-85af-7ec0b99b27c3
https://mangkyu.tistory.com/150?category=761302
https://mangkyu.tistory.com/125
https://mangkyu.tistory.com/151
https://mangkyu.tistory.com/75
https://mangkyu.tistory.com/148
https://www.youtube.com/watch?v=8lp_nHicYd4&list=WL&index=67
https://devlog-wjdrbs96.tistory.com/166
https://devlog-wjdrbs96.tistory.com/321
https://imspear.tistory.com/170

profile
항상 진심이지만 뭔가 안풀리는 개발 (주의! - 코린이가 배우고 이해한 내용을 끄적이는 공간이므로 실제 개념과 일부 다를 수 있음!)

0개의 댓글