[첫번째 프로젝트] 16. @Transactional

DAEILLIM·2024년 1월 25일
0

첫번째 프로젝트

목록 보기
17/17
post-thumbnail

1. 트랜잭션

트랜잭션(Transaction)은 데이터베이스와 같은 시스템에서 여러 연산을 논리적으로 하나의 단위로 묶어서 처리하는 개념입니다. 트랜잭션은 여러 단계의 작업이 모두 성공적으로 수행되어야만 완료되는 원자성(Atomicity), 특정 작업이 완료된 상태에서만 다음 작업이 수행되는 일관성(Consistency), 작업이 완료된 결과를 시스템에 영구적으로 반영하는 지속성(Durability), 여러 사용자 간에 독립적으로 수행되는 독립성(Isolation)의 네 가지 특성을 가지고 있습니다.

간단하게 말하면, 트랜잭션은 데이터베이스에서 여러 작업을 하나의 논리적인 작업 단위로 묶어서 처리하는 것으로, 이 단위 내에서 모든 작업이 성공하면 모든 변경이 적용되고, 하나라도 실패하면 이전 상태로 롤백되는 개념입니다.

스프링에서 @Transactional 어노테이션을 사용하면, 해당 어노테이션이 붙은 메서드 또는 클래스의 모든 메서드는 하나의 트랜잭션으로 묶이게 됩니다. 이는 다음과 같은 상황에서 유용합니다.

  1. 원자성 (Atomicity): 메서드 내에서 여러 데이터베이스 작업이 수행되고, 하나라도 실패하면 모든 작업이 롤백되어 이전 상태로 돌아가게 됩니다.
  2. 일관성 (Consistency): 트랜잭션이 커밋되기 전까지는 데이터베이스가 일관된 상태를 유지합니다.
  3. 지속성 (Durability): 트랜잭션이 성공적으로 완료되면 변경사항이 영구적으로 데이터베이스에 적용됩니다.
  4. 독립성 (Isolation): 여러 트랜잭션이 동시에 실행될 때, 각 트랜잭션은 다른 트랜잭션의 영향을 받지 않고 독립적으로 수행됩니다.

즉, @Transactional 어노테이션을 사용하면 스프링이 트랜잭션을 시작, 커밋 또는 롤백하는 등의 작업을 대신해주어 개발자가 트랜잭션을 명시적으로 관리하는 부담을 줄여줍니다. 트랜잭션 설정이 적용되면 메서드 수행 도중 예외가 발생하면 트랜잭션이 롤백되고, 예외가 발생하지 않으면 트랜잭션이 커밋되어 데이터베이스에 변경사항이 반영됩니다.

2. @Transactional

@Transactional은 스프링 프레임워크에서 제공하는 어노테이션 중 하나로, 트랜잭션 처리에 관련된 설정을 지원합니다. 이 어노테이션을 사용하면 메서드 또는 클래스에 트랜잭션을 적용할 수 있습니다.

2.1 메서드 레벨에서의 사용

@Transactional 어노테이션을 메서드에 적용하면 해당 메서드에서 수행되는 모든 작업이 하나의 트랜잭션으로 묶입니다. 메서드가 실행되면 트랜잭션이 시작되고, 메서드가 정상적으로 완료되면 트랜잭션이 커밋되고, 예외가 발생하면 롤백됩니다.

@Transactional
public void someTransactionalMethod() {
    // 트랜잭션 범위 내에서의 작업
}

@Transactional 어노테이션을 메서드 레벨에서 사용하는 경우, 해당 메서드 내의 모든 작업이 하나의 트랜잭션으로 처리됩니다. 이것은 주로 특정 메서드에서 수행되는 작업들이 원자적으로 처리되어야 하는 경우에 유용합니다. 메서드 레벨에서 @Transactional을 사용하는 방법에 대해 자세히 알아보겠습니다.

@Service
public class MyService {

    @Autowired
    private MyRepository myRepository;

    @Transactional
    public void myTransactionalMethod() {
        // 트랜잭션 범위 내에서의 작업
        myRepository.save(new MyEntity("data1"));
        myRepository.save(new MyEntity("data2"));
        // 예외가 발생하지 않으면 모든 작업이 커밋됨
    }
}

위의 예제에서 myTransactionalMethod 메서드는 @Transactional 어노테이션이 적용된 메서드입니다. 이 메서드 내에서 수행되는 모든 데이터베이스 작업은 하나의 트랜잭션으로 묶이게 됩니다.

여기서 중요한 점은 메서드가 끝날 때, 즉 메서드가 정상적으로 종료되면 스프링은 트랜잭션을 커밋하고, 메서드에서 예외가 발생하면 트랜잭션을 롤백합니다. 따라서, 위의 예제에서 myRepository.save(new MyEntity("data2"));에서 예외가 발생하면 data1은 저장되지만 data2는 저장되지 않습니다.

@Transactional 어노테이션을 메서드 레벨에서 사용할 때, 여러 옵션을 설정할 수 있습니다. 몇 가지 주요한 옵션은 다음과 같습니다:

  • readOnly: 트랜잭션이 읽기 전용인지 여부를 나타내며, 읽기 전용일 경우에는 커밋이 아닌 롤백으로 트랜잭션을 종료하여 성능을 최적화할 수 있습니다.

    @Transactional(readOnly = true)
    public void readOnlyMethod() {
        // 읽기 전용 트랜잭션 내에서의 작업
    }
  • propagation: 트랜잭션 전파 동작을 나타냅니다. 메서드가 이미 실행 중인 트랜잭션에 참여할지, 새로운 트랜잭션을 시작할지를 결정합니다.

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void requiresNewMethod() {
        // 새로운 트랜잭션 내에서의 작업
    }
  • isolation: 격리 수준을 나타내며, 여러 트랜잭션이 동시에 실행될 때 어떻게 격리되는지를 정의합니다.

    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void readCommittedMethod() {
        // READ_COMMITTED 격리 수준의 트랜잭션 내에서의 작업
    }
  • timeout: 트랜잭션의 제한 시간을 나타내며, 설정된 시간 내에 트랜잭션이 완료되지 않으면 롤백됩니다.

    @Transactional(timeout = 30)
    public void timeoutMethod() {
        // 트랜잭션 내에서의 작업
    }

이렇게 @Transactional 어노테이션을 메서드 레벨에서 사용하면 해당 메서드 내의 모든 작업이 트랜잭션 범위에 속하게 되어, 트랜잭션의 원자성을 보장할 수 있습니다.

2.2 클래스 레벨에서의 사용

@Transactional 어노테이션을 클래스에 적용하면 해당 클래스의 모든 메서드에 트랜잭션 설정이 적용됩니다. 클래스 레벨에서 설정한 트랜잭션 속성은 메서드 레벨에서의 설정보다 우선시됩니다.

@Transactional
public class SomeTransactionalService {
    public void method1() {
        // 트랜잭션 범위 내에서의 작업
    }

    public void method2() {
        // 트랜잭션 범위 내에서의 작업
    }
}

@Transactional 어노테이션을 클래스 레벨에서 사용하는 경우, 해당 클래스의 모든 메서드에 트랜잭션 설정이 적용됩니다. 이는 주로 클래스 내의 모든 메서드가 하나의 트랜잭션으로 묶여야 하는 경우에 유용합니다. 클래스 레벨에서 @Transactional을 사용하는 방법에 대해 자세히 설명하겠습니다.

예를 들어, 다음과 같이 클래스 레벨에서 @Transactional 어노테이션을 사용할 수 있습니다:

@Service
@Transactional
public class UserService {

    @Autowired
    private UserRepository userRepository;

    public User getUserById(Long userId) {
        return userRepository.findById(userId).orElse(null);
    }

    public void saveUser(User user) {
        userRepository.save(user);
    }

    public void updateUser(User user) {
        userRepository.save(user);
        // 다른 비즈니스 로직 수행
    }

    public void deleteUser(Long userId) {
        userRepository.deleteById(userId);
    }
}

위의 예제에서 @Transactional 어노테이션이 UserService 클래스에 적용되어 있습니다. 이렇게 하면 getUserById, saveUser, updateUser, deleteUser 메서드가 모두 UserService 클래스에 정의된 트랜잭션 설정을 따르게 됩니다.

클래스 레벨에서 @Transactional을 사용할 때 주의할 점:

  1. 메서드 레벨의 설정 우선 순위: 클래스 레벨과 메서드 레벨에 모두 @Transactional 어노테이션이 존재할 경우, 메서드 레벨의 설정이 우선 순위를 가집니다. 메서드에 별도의 설정이 없는 경우에는 클래스 레벨의 설정이 적용됩니다.

    @Service
    @Transactional
    public class UserService {
    
        @Transactional(readOnly = true)
        public User getUserById(Long userId) {
            return userRepository.findById(userId).orElse(null);
        }
    }

    위의 예제에서 getUserById 메서드는 클래스 레벨의 @Transactional 설정을 따르지 않고, 메서드 레벨에서 명시한 readOnly = true 설정을 따르게 됩니다.

  2. 트랜잭션 속성 일괄 적용: 클래스 레벨에서 @Transactional을 사용하면 해당 클래스 내의 모든 메서드에 동일한 트랜잭션 속성이 적용됩니다. 따라서, 특정 메서드에 대해 다르게 설정하려면 메서드 레벨에서 추가적인 @Transactional 어노테이션을 사용해야 합니다.

    @Service
    @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
    public class UserService {
    
        @Transactional(timeout = 30)
        public User getUserById(Long userId) {
            return userRepository.findById(userId).orElse(null);
        }
    }

    위의 예제에서 getUserById 메서드는 클래스 레벨의 트랜잭션 속성과는 다르게 timeout = 30 설정을 따르게 됩니다.

클래스 레벨에서 @Transactional 어노테이션을 사용하면 클래스 내의 모든 메서드가 하나의 트랜잭션으로 묶이게 되어 트랜잭션의 일관성과 효율성을 유지할 수 있습니다.

2.3 @Transactional 속성

@Transactional 어노테이션은 다양한 속성을 제공하여 트랜잭션의 동작을 조절할 수 있습니다. 이러한 속성들은 트랜잭션의 격리 수준, 전파 동작, 읽기 전용 여부, 타임아웃 등을 설정하는 데 사용됩니다. 아래에서 주요한 @Transactional 속성들을 자세히 설명하겠습니다:

  1. readOnly

    • 트랜잭션을 읽기 전용으로 만들지 여부를 나타냅니다.
    • 기본값은 false이며, 읽기 전용일 경우 true로 설정하면 성능 최적화를 위해 트랜잭션이 커밋되지 않고 종료됩니다.
    @Transactional(readOnly = true)
    public void readOnlyMethod() {
        // 읽기 전용 트랜잭션 내에서의 작업
    }
  2. isolation

    • 격리 수준을 설정합니다.
    • 여러 트랜잭션이 동시에 실행될 때 트랜잭션 간의 격리 수준을 지정합니다.
    • 기본값은 Isolation.DEFAULT로서 데이터베이스 기본 격리 수준을 따릅니다.
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void readCommittedMethod() {
        // READ_COMMITTED 격리 수준의 트랜잭션 내에서의 작업
    }

    Isolation 열거형에는 여러 격리 수준이 정의되어 있습니다. 예를 들어 Isolation.READ_COMMITTED, Isolation.REPEATABLE_READ, Isolation.SERIALIZABLE 등이 있습니다.

  3. propagation

    • 트랜잭션 전파 동작을 설정합니다.
    • 다른 트랜잭션 내에서 메서드가 호출될 때 해당 메서드가 참여할지, 새로운 트랜잭션을 시작할지를 결정합니다.
    @Transactional(propagation = Propagation.REQUIRED)
    public void requiredMethod() {
        // 현재 트랜잭션 내에서의 작업
    }

    Propagation 열거형에는 다양한 전파 옵션이 정의되어 있습니다. 예를 들어 Propagation.REQUIRED, Propagation.REQUIRES_NEW, Propagation.NESTED 등이 있습니다.

  4. timeout

    • 트랜잭션의 타임아웃을 설정합니다.
    • 지정된 시간 내에 트랜잭션이 완료되지 않으면 롤백됩니다.
    @Transactional(timeout = 30)
    public void timeoutMethod() {
        // 트랜잭션 내에서의 작업
    }

    timeout은 초 단위로 설정하며, 기본값은 -1로서 타임아웃이 없음을 나타냅니다.

  5. rollbackFornoRollbackFor

    • 롤백을 수행할 예외 클래스를 지정할 수 있습니다.
    • rollbackFor는 롤백을 수행할 예외 클래스를 나타내고, noRollbackFor는 롤백을 수행하지 않을 예외 클래스를 나타냅니다.
    @Transactional(rollbackFor = { CustomException.class, AnotherException.class },
                   noRollbackFor = { AllowedException.class })
    public void customRollbackMethod() {
        // 트랜잭션 내에서의 작업
    }

    위의 예제에서는 CustomExceptionAnotherException이 발생하면 롤백을 수행하고, AllowedException이 발생하면 롤백을 수행하지 않습니다.

  6. readOnlytimeout을 동시에 사용하는 예제:

    @Transactional(readOnly = true, timeout = 30)
    public void readOnlyWithTimeoutMethod() {
        // 읽기 전용 트랜잭션 내에서의 작업
    }

    위의 예제에서는 읽기 전용 트랜잭션을 사용하며, 30초 동안 트랜잭션이 완료되지 않으면 롤백됩니다.

이러한 @Transactional 어노테이션의 속성들을 사용하여 트랜잭션의 동작을 조절하면, 특정 메서드나 클래스에서 필요에 맞게 트랜잭션을 설정할 수 있습니다.

@Transactional 속성에 대한 간단한 정리는 다음과 같습니다.

속성타입설명
valueString사용할 트랜잭션 관리자
propagationenum: Propagation선택적 전파 설정
isolationenum: Isolation선택적 격리 수준
readOnlyboolean읽기/쓰기 vs 읽기 전용 트랜잭션
timeoutint (초)트랜잭션 타임 아웃
rollbackForThrowable 로부터 얻을 수 있는 Class 객체 배열롤백이 수행되어야 하는,
선택적인 예외 클래스의 배열
rollbackForClassNameThrowable 로부터 얻을 수 있는 클래스 이름 배열롤백이 수행되어야 하는,
선택적인 예외 클래스 이름의 배열
noRollbackForThrowable 로부터 얻을 수 있는 Class 객체 배열롤백이 수행되지 않아야 하는,
선택적인 예외 클래스의 배열
noRollbackForClassNameThrowable 로부터 얻을 수 있는 클래스 이름 배열롤백이 수행되지 않아야 하는,
선택적인 예외 클래스 이름의 배열

2.4 @Transactional 예제1

간단한 예제를 통해 @Transactional 어노테이션을 사용하는 방법을 설명하겠습니다. 이 예제에서는 간단한 서비스와 리포지토리 클래스를 사용하여 데이터베이스 트랜잭션을 다루겠습니다.

  1. 도메인 클래스 정의:

    // Article.java
    @Entity
    public class Article {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String title;
    
        // getters and setters
    }
    // ArticleRepository.java
    public interface ArticleRepository extends JpaRepository<Article, Long> {
    }
  2. 서비스 클래스 정의:

    // ArticleService.java
    @Service
    public class ArticleService {
    
        @Autowired
        private ArticleRepository articleRepository;
    
        @Transactional
        public void saveArticleWithTransaction(Article article1, Article article2) {
            articleRepository.save(article1);
            // 예외를 발생시킬 수 있는 일부 비즈니스 로직
            throw new RuntimeException("Simulating an error");
            
    		// 예외가 발생하면 두 저장이 모두 롤백됩니다.
            // 예외가 발생하지 않으면 두 저장이 모두 커밋됩니다.	
            articleRepository.save(article2);
        }
    }
  3. 테스트 클래스 작성:

    // ArticleServiceTest.java
    @SpringBootTest
    @Transactional
    public class ArticleServiceTest {
    
        @Autowired
        private ArticleService articleService;
    
        @Autowired
        private ArticleRepository articleRepository;
    
        @Test
        public void testSaveArticleWithTransaction() {
            Article article1 = new Article();
            article1.setTitle("Article 1");
    
            Article article2 = new Article();
            article2.setTitle("Article 2");
    
            try {
                articleService.saveArticleWithTransaction(article1, article2);
            } catch (RuntimeException e) {
                // 예상되는 예외, 아무것도 하지 않음
            }
    
    		// 데이터베이스에 article1과 article2가 모두 저장되지 않았는지 확인합니다.
            List<Article> articlesInDatabase = articleRepository.findAll();
            assertThat(articlesInDatabase).isEmpty();
        }
    }

위의 예제에서 ArticleService 클래스의 saveArticleWithTransaction 메서드에 @Transactional 어노테이션이 적용되어 있습니다. 이 메서드는 두 개의 Article 객체를 받아서 데이터베이스에 저장하고, 그 중 하나의 저장 과정에서 의도적으로 예외를 발생시켜 롤백을 시도하고 있습니다.

테스트 클래스에서는 @Transactional 어노테이션을 통해 해당 테스트 메서드가 트랜잭션 내에서 실행되도록 설정하고 있습니다. 그리고 articleService.saveArticleWithTransaction 메서드를 호출할 때 예외가 발생하므로, 트랜잭션 내의 모든 작업이 롤백되어 데이터베이스에는 아무런 변경이 없는지를 검증하고 있습니다.

이런식으로 @Transactional 어노테이션을 사용하면 트랜잭션의 범위에서 메서드의 실행이 관리되며, 예외가 발생하면 롤백이 이루어지고 예외가 발생하지 않으면 커밋이 이루어지게 됩니다.

3. 선언적 트랜잭션

선언적 트랜잭션(Declarative Transaction Management)은 트랜잭션의 관리를 코드 대신 설정으로 처리하는 방법을 의미합니다. 이는 주로 스프링과 같은 프레임워크에서 지원되는 기능 중 하나입니다.

선언적 트랜잭션은 개발자가 코드 내에서 명시적으로 트랜잭션을 시작, 커밋 또는 롤백을 작성하지 않고도 트랜잭션을 관리할 수 있게 해줍니다. 대신에 설정 파일이나 어노테이션을 통해 트랜잭션의 속성을 지정하고, 프레임워크가 이를 인식하여 자동으로 트랜잭션을 처리합니다.

주로 선언적 트랜잭션은 다음과 같은 방법으로 사용됩니다:

  1. XML 기반 설정:

    • 스프링에서는 tx:adviceaop:config를 사용하여 XML 기반으로 선언적 트랜잭션을 설정할 수 있습니다.
    <beans xmlns="http://www.springframework.org/schema/beans"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xmlns:tx="http://www.springframework.org/schema/tx"
           xmlns:aop="http://www.springframework.org/schema/aop"
           xsi:schemaLocation="
               http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
               http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd
               http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd">
    
        <!-- 데이터베이스 설정 등 -->
    
        <tx:advice id="txAdvice" transaction-manager="transactionManager">
            <tx:attributes>
                <tx:method name="*" propagation="REQUIRED" />
            </tx:attributes>
        </tx:advice>
    
        <aop:config>
            <aop:advisor advice-ref="txAdvice" pointcut="execution(* com.example.service.*.*(..))"/>
        </aop:config>
    </beans>
  2. 어노테이션 기반 설정:

    • 스프링에서는 @Transactional 어노테이션을 통해 선언적 트랜잭션을 설정할 수 있습니다.
    @Service
    @Transactional
    public class MyService {
        // 메서드들
    }

특별한 경우가 아니라면 어노테이션 기반 설정을 사용합니다.

따라서, 아래와 같은 특징이 있다

  • 클래스, 메소드에 @Transactional이 선언되면 해당 클래스에 트랜잭션이 적용된 프록시 객체 생성
  • 프록시 객체는 @Transactional이 포함된 메서드가 호출될 경우, 트랜잭션을 시작하고 Commit or Rollback을 수행
  • CheckedException or 예외가 없을 때는 Commit
  • UncheckedException이 발생하면 Rollback

3.1 동작 원리

  1. 트랜잭션 시작:
    • @Transactional 어노테이션이 적용된 메서드가 호출되면, 스프링은 새로운 트랜잭션을 시작합니다.
  2. 메서드 수행:
    • 메서드 내에서 정의된 작업들이 수행됩니다. 이 동안 데이터베이스 조작 등이 이루어질 수 있습니다.
  3. 커밋 또는 롤백:
    • 메서드 수행이 정상적으로 완료되면 스프링은 트랜잭션을 커밋합니다. 이때, 트랜잭션 내의 모든 데이터베이스 조작이 영구적으로 적용됩니다.
    • 만약 메서드 수행 중에 예외가 발생하면, 스프링은 트랜잭션을 롤백하고, 트랜잭션 내에서의 모든 데이터베이스 조작이 취소됩니다.
  4. 트랜잭션 속성 고려:
    • @Transactional 어노테이션에 지정된 속성들에 따라 트랜잭션의 동작이 결정됩니다. 속성에는 readOnly, isolation, propagation, timeout 등이 있으며, 이들은 트랜잭션의 특성을 정의합니다.
  5. 트랜잭션 종료:
    • 트랜잭션은 메서드의 완료 시점에 종료됩니다. 이때 커밋이나 롤백이 이루어지며, 데이터베이스에 영향이 반영됩니다.

트랜잭션은 Spring AOP를 통해 구현되어있습니다. 더 정확하게 말하면, 어노테이션 기반 AOP를 통해 구현되어 있습니다.
(import문을 보면 알 수 있습니다.)

import org.springframework.transaction.annotation.Transactional;

따라서, 아래와 같은 특징이 있습니다.

  • 클래스, 메소드에 @Transactional이 선언되면 해당 클래스에 트랜잭션이 적용된 프록시 객체 생성
  • 프록시 객체는 @Transactional이 포함된 메서드가 호출될 경우, 트랜잭션을 시작하고 Commit or Rollback을 수행
  • CheckedException or 예외가 없을 때는 Commit
  • UncheckedException이 발생하면 Rollback

스프링은 AOP(Aspect-Oriented Programming)을 활용하여 @Transactional 어노테이션을 프록시로 감싸는 방식으로 동작합니다. 이를 통해 @Transactional 어노테이션이 적용된 메서드 호출 시 트랜잭션 관리를 추가할 수 있습니다. 스프링이 제공하는 TransactionInterceptor 등의 AOP 기능을 통해 이러한 동작이 구현됩니다.

중요한 점은 @Transactional 어노테이션이 적용된 클래스나 메서드는 스프링의 트랜잭션 관리자에 의해 관리되며, 트랜잭션 속성을 명시하면 해당 속성에 따라 트랜잭션이 동작한다는 것입니다.

3.2 우선순위

@Transactional은 우선순위를 가지고 있습니다.

클래스 메서드에 선언된 트랜잭션의 우선순위가 가장 높고, 인터페이스에 선언된 트랜잭션의 우선순위가 가장 낮습니다.

클래스 메소드 -> 클래스 -> 인터페이스 메소드 -> 인터페이스

따라서 공통적인 트랜잭션 규칙은 클래스에, 특별한 규칙은 메서드에 선언하는 식으로 구성할 수 있습니다.

또한, 인터페이스 보다는 클래스에 적용하는 것을 권고합니다.

  • 인터페이스나 인터페이스의 메서드에 적용할 수 있습니다.
  • 하지만, 인터페이스 기반 프록시에서만 유효한 트랜잭션 설정이 됩니다.
  • 자바 어노테이션은 인터페이스로부터 상속되지 않기 때문에 클래스 기반 프록시 or AspectJ 기반에서 트랜잭션 설정을 인식 할 수 없습니다.

트랜잭션의 모드

@Transactional은 Proxy Mode와 AspectJ Mode가 있는데 Proxy Mode가 Default로 설정되어 있습니다.

Proxy Mode는 다음과 같은 경우 동작하지 않습니다.

  • 반드시 public 메서드에 적용되어야한다.
    • Protected, Private Method에서는 선언되어도 에러가 발생하지는 않지만, 동작하지도 않는다.
    • Non-Public 메서드에 적용하고 싶으면 AspectJ Mode를 고려해야한다.
  • @Transactional이 적용되지 않은 Public Method에서 @Transactional이 적용된 Public Method를 호출할 경우, 트랜잭션이 동작하지 않습니다.
profile
필기하고, 타이핑하고, 말하면서 읽고, 코딩하고, 눈으로 읽고 오감으로 공부하기

0개의 댓글