주노 님의 블로그

20240817 주말에도 나와버린 나 TIL 본문

TIL

20240817 주말에도 나와버린 나 TIL

juno0432 2024. 8. 17. 23:52

오늘 해야할 일 ✔️ 🔺 ❌

✔️ 2주차 강의 모두 듣기

🔺복습하기

주말에 미리 공부를 안했으면 월요일에 더 쫓겼을지도 몰라

이거 완전 러키비키잔앙?

 

더보기
  • entity의 상태
    엔티티의 상태는 네가지로 분류된다
    비영속, 영속, 준영속, 삭제

  • 비영속 > 영속 상태
    Memo memo = new Memo(); // 비영속 상태
                memo.setId(1L);
                memo.setUsername("Robbie");
                memo.setContents("비영속과 영속 상태");
    
                em.persist(memo);
    
                et.commit();

    현재 persist를 호출하기 전인 객체생성단계는 비영속상태로, 영속성 컨텍스트가 관리하지 않는다.

    persist를 호출할때 위 객체는 비로소 영속상태가 된다

     
    persist를 호출하기 전에는 nonEnhancedEntityXref가 null 인것을 볼 수 있다.

    persist를 호출하게되면 영속상태이고
    (MANAGED)상태가 된다. 이 상태가 영속상태이다.

  • 준영속 상태
    영속성 컨텍스트에 저장되어 관리되다가 분리된 상태를 의미한다
    detach, clear, close

  • detach(entity)
    특정 엔티티만 준영속 상태로 변환
    Memo memo = em.find(Memo.class, 1);
    System.out.println("memo.getId() = " + memo.getId());
    System.out.println("memo.getUsername() = " + memo.getUsername());
    System.out.println("memo.getContents() = " + memo.getContents());
    
    // em.contains(entity) : Entity 객체가 현재 영속성 컨텍스트에 저장되어 관리되는 상태인지 확인하는 메서드
    System.out.println("em.contains(memo) = " + em.contains(memo));
    
    System.out.println("detach() 호출");
    em.detach(memo);
    System.out.println("em.contains(memo) = " + em.contains(memo));
    
    System.out.println("memo Entity 객체 수정 시도");
    memo.setUsername("Update");
    memo.setContents("memo Entity Update");
    
    System.out.println("트랜잭션 commit 전");
    et.commit();
    System.out.println("트랜잭션 commit 후");


    DETACH 호출 전까지는 현재 영속상태인것을 볼 수 있다.

    DETACH호출하는순간 영속상태에서 빠진것을 볼 수 있다.
    memo객체는 현재 준영속 상태이다.

    디버그를 하지 않아도, Continas를 호출을 하면 영속상태인지 아닌지 알 수 있다
    memo.getContents() = 영속성 컨텍스트와 트랜잭션 이해하기
    em.contains(memo) = true
    detach() 호출
    em.contains(memo) = false
    memo Entity 객체 수정 시도
    트랜잭션 commit 전
    트랜잭션 commit 후

    또한 commit과정 중에서도 아무 일도 일어나지않는다

    영속 상태일때만 dirtyChecking가 일어나며, 변경사항이 추적된다.

  • clear
    영속성 컨텍스트를 초기화한다

    현재 클리어 메서드를 통과하지 않았을때는 영속성컨텍스트(1차 캐시)에 두개의 정보가 있다.


    클리어 메서드를 통과하는 순간 비워지게된다.

  • close
    영속성 컨텍스트를 종료한다.
    관리하던 entity는 준영속 상태로 바뀌게된다.

                Memo memo1 = em.find(Memo.class, 1);
                Memo memo2 = em.find(Memo.class, 2);
    
                // em.contains(entity) : Entity 객체가 현재 영속성 컨텍스트에 저장되어 관리되는 상태인지 확인하는 메서드
                System.out.println("em.contains(memo1) = " + em.contains(memo1));
                System.out.println("em.contains(memo2) = " + em.contains(memo2));
    
                System.out.println("close() 호출");
                em.close();

    위 코드를 보게되면 memo1과 memo2는 find를 사용하여 조회하고 있다.

    em.close를 호출한다.
    위 결과를 보면
    select문 한번 (memo1)
    select문 두번 (memo2)
    가 호출것을 볼수있고
    memo1과 memo2는 영속상태를 나타내는 true가 있다.

    그리고 close메서드를 호출하는순간  에러가뜨게되는데
    EntityManager이 closed라고 나와있다.

    즉... close는 아예 영속성컨텍스트를 탈출하는것이다.

    clear() : 초기화 후 다시 사용할 수 있음
    close() : 초기화 후 다시 사용할 수 없음.

  • 준영속 > 영속
    mege가 있다

  • merge(entity)
    전달받은 entity를 사용하여 새로운 영속 상태의 entity를 반환한다.

    1. entity가 영속성 컨텍스트에 없다면
    db에서 select > 조회한 내용을 영속성 컨텍스트에 저장 > 전달받은 entity를 병합함 > update sql 수행
     Memo memo = em.find(Memo.class, 3);
                System.out.println("memo.getId() = " + memo.getId());
                System.out.println("memo.getUsername() = " + memo.getUsername());
                System.out.println("memo.getContents() = " + memo.getContents());
    
                System.out.println("em.contains(memo) = " + em.contains(memo));
    
                System.out.println("detach() 호출");
                em.detach(memo); // 준영속 상태로 전환
                System.out.println("em.contains(memo) = " + em.contains(memo));
    
                System.out.println("준영속 memo 값 수정");
                memo.setContents("merge() 수정");
    
                System.out.println("\n merge() 호출");
                Memo mergedMemo = em.merge(memo);
                System.out.println("mergedMemo.getContents() = " + mergedMemo.getContents());
    
                System.out.println("em.contains(memo) = " + em.contains(memo));
                System.out.println("em.contains(mergedMemo) = " + em.contains(mergedMemo));
    
                System.out.println("트랜잭션 commit 전");
                et.commit();
                System.out.println("트랜잭션 commit 후");
     
    위 코드를 살펴보자
    기존에 db에 등록되어있는 3번 id를 조회한다.
    그리고 memo를 준영속 상태로 변환시킨다.

    준영속 상태의 값을 수정한후 merge를 하고

    mergedmemo를 호출한다.

    바뀌었을까?

    예! 바뀐것을 볼수있다.

    또한 준영속 상태의 memo는 merge후에도 준영속상태인것을 알 수 있고.
    이미 있는 데이터이기 떄문에 update sql이 생성된것을 볼 수 있다.

    2. DB에도 없다면
    새로운 entity를 영속성 컨텍스트에 저장함 >  insert sql을 수행함

     Memo memo = new Memo();
                memo.setId(3L);
                memo.setUsername("merge()");
                memo.setContents("merge() 저장");
    
                System.out.println("merge() 호출");
                Memo mergedMemo = em.merge(memo);
    
                System.out.println("em.contains(memo) = " + em.contains(memo));
                System.out.println("em.contains(mergedMemo) = " + em.contains(mergedMemo));
    
                System.out.println("트랜잭션 commit 전");
                et.commit();
                System.out.println("트랜잭션 commit 후");

    id3번이 db에 없다고 가정한다면

    일단 merge 수행시, insert 문이 날라간것을 볼 수 있다.

    em.contains(memo) = false
    em.contains(mergedMemo) = true

    출력 결과를 보면
    merge하기전의 생성된 객체는 영속상태가 아니다
    merge를 한 객체는 영속상태다 왜 그런것일까?

    현재 merge전의 객체는 persist를 하지 않아 영속 상태는 아닌것이다.
    그냥 객체를 만들었을뿐.

    따라서 merge메서드는 비영속, 준영속 모두 파라미터로 받을 수 있으며
    새로운 객체가 영속상태로 되는것이다.

강의 - SpringBoot JPA

더보기
  • 스프링 부트에서 JPA를 사용해보자.

     먼저, build.gradle에 JPA를 위한 의존성을 추가해보자:

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'


    이 의존성을 추가하면, 스프링 부트는 JPA 관련 설정과 라이브러리를 자동으로 관리해준다.

    그 다음, application.properties 파일에 아래 설정을 추가하자:
    spring.jpa.hibernate.ddl-auto=update
    spring.jpa.properties.hibernate.show_sql=true
    spring.jpa.properties.hibernate.format_sql=true
    spring.jpa.properties.hibernate.use_sql_comments=true


    설정 설명
    DDL 설정:
    create: 기존 테이블을 삭제하고 다시 생성한다.
    create-drop: create와 동일하지만, 애플리케이션 종료 시 테이블을 삭제한다.
    update: 변경된 부분만 반영한다.
    validate: 엔티티와 테이블이 올바르게 매핑되었는지 확인한다.
    none: 아무 작업도 하지 않는다.
    SQL 출력 설정:

    show_sql: Hibernate가 실행하는 SQL 문을 콘솔에 출력한다.
    format_sql: SQL 문을 보기 좋게 포맷하여 출력한다.
    use_sql_comments: SQL 문에 주석을 포함하여 출력한다.
    스프링 부트는 application.properties에 설정을 명시하면 자동으로 이를 반영해준다. 과거에는 META-INF 디렉토리에 별도의 설정 파일을 두어야 했지만, 스프링 부트에서는 이 과정이 간소화되었다.

    또한, 스프링 부트는 @PersistenceContext 어노테이션을 사용하여 EntityManager를 자동으로 주입해준다. 이를 통해 개발자는 엔티티 매니저를 직접 관리하지 않아도 된다.

    스프링에서는 트랜잭션을 쉽게 관리할 수 있도록 @Transactional 어노테이션을 제공한다. 이 어노테이션을 통해 트랜잭션을 시작하고, 오류 발생 시 자동으로 롤백할 수 있다.

  • entity 변경
    package com.sparta.springprepare.entity;
    
    import com.sparta.springprepare.dto.MemoRequestDto;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    import lombok.Setter;
    
    @Getter
    @Setter
    @NoArgsConstructor
    
    public class Memo {
        private Long id;
        private String username;
        private String contents;
    
        public Memo(MemoRequestDto requestDto)
        {
            this.username = requestDto.getUsername();
            this.contents = requestDto.getContents();
        }
    
        public void update(MemoRequestDto requestDto)
        {
            this.contents = requestDto.getContents();
            this.username = requestDto.getUsername();
        }
    }



    기존의 jdbcTemplate를 사용하던 코드에서 아래와 같이 jpa로 변경해본다.
    package com.sparta.memo.entity;
    
    import com.sparta.memo.dto.MemoRequestDto;
    import jakarta.persistence.*;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    import lombok.Setter;
    
    @Entity // JPA가 관리할 수 있는 Entity 클래스 지정
    @Getter
    @Setter
    @Table(name = "memo") // 매핑할 테이블의 이름을 지정
    @NoArgsConstructor
    public class Memo {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        @Column(name = "username", nullable = false)
        private String username;
        @Column(name = "contents", nullable = false, length = 500)
        private String contents;
    
        public Memo(MemoRequestDto requestDto) {
            this.username = requestDto.getUsername();
            this.contents = requestDto.getContents();
        }
    
        public void update(MemoRequestDto requestDto) {
            this.username = requestDto.getUsername();
            this.contents = requestDto.getContents();
        }
    }


  •  테스트.
    @SpringBootTest
    public class TransactionTest
    {
        @PersistenceContext
        private EntityManager em;
    
        @Test
        @Transactional
        @Rollback(value = false) // 테스트 코드에서 @Transactional 를 사용하면 테스트가 완료된 후 롤백하기 때문에 false 옵션 추가
        @DisplayName("메모 생성 성공")
        void test1() {
            Memo memo = new Memo();
            memo.setUsername("Robbert");
            memo.setContents("@Transactional 테스트 중!");
    
            em.persist(memo);  // 영속성 컨텍스트에 메모 Entity 객체를 저장합니다.
        }
    }
     

    @PersistenceContext를 통해 스프링부트가 엔티티 매니저를 만들어준다.
    @transactional을 통해 자동으로 transaction을 만들어준다
    @rollback 어노테이션의 value=false를 통해 테스트코드를 실행하고 끝날시 다시 롤백해주는 것을 방지할수있다.

    @Test
        @DisplayName("메모 생성 실패")
        void test2() {
            Memo memo = new Memo();
            memo.setUsername("Robbie");
            memo.setContents("@Transactional 테스트 중!");
    
            em.persist(memo);  // 영속성 컨텍스트에 메모 Entity 객체를 저장합니다.
        }

     만약 @transactional을 걸지않았다면??

    오류가뜬다.!

  • 영속성 컨텍스트와 생명주기
    spring에서는 어떻게 service, repository 전체에 transaction을 유지할수 있을까?
    spring은 트랜잭션 전파기능을 가지고 있다. service에서 repository를 호출할때, 트랜잭션도 같이 전파한다.

  • 트랜잭션 전파
    repository에 아래 메서드를 정의해보자
    @Transactional
        public Memo createMemo(EntityManager em) {
            Memo memo = em.find(Memo.class, 1);
            memo.setUsername("Robbie");
            memo.setContents("@Transactional 전파 테스트 중!");
    
            System.out.println("createMemo 메서드 종료");
            return memo;
        }

    아래 테스트코드를 작성하자
    Test
        @Transactional
        @Rollback(value = false)
        @DisplayName("트랜잭션 전파 테스트")
        void test3() {
            memoRepository.createMemo(em);
            System.out.println("테스트 test3 메서드 종료");
        }

    위 코드를 보면, 먼저 test3에서 memorepository의 createMemo를 출력한다
    일단 test3를 부모메서드, create를 자식메서드라고하자.

    위 결과를 보면 자식 메서드의 find로 인해 select쿼리가 날아간것을 볼 수 있다.
    근데 createMemo를 수행한다음, update 쿼리가 날아갈 줄 알았지만
    부모 메서드 종료후 날아가는것을 볼 수 있다.

    왜일까?

    Transactional에는 propagation의 값이 required로 되어있다.
    위 옵션은, 부모 메서드에서 transactional이 존재를 한다면 자식 메서드의 transactional은 부모 메서드에 합류하게된다 라는뜻이다.


    부모 메서드의 transactional을 주석 처리해보자.

    자식 메서드가 종료되면서 update가 날라갔는것을 볼 수 있다.

  • Spring Data Jpa
    spring data jpa는 jpa를 쉽게 사용할 수 있게 만들어놓은 모듈이다
    jpa를 추상화시킨 repository 인터페이스를 제공해준다

    스프링이 실행 될때 jpaRepository 인터페이스를 상속받은 인터페이스가 스캔이 되면, 자동으로 bean으로 등록해준다

  • repository 수정
    package com.sparta.springprepare.repository;
    
    import com.sparta.springprepare.dto.MemoRequestDto;
    import com.sparta.springprepare.dto.MemoResponseDto;
    import com.sparta.springprepare.entity.Memo;
    import jakarta.persistence.EntityManager;
    import org.springframework.jdbc.core.JdbcTemplate;
    import org.springframework.jdbc.core.RowMapper;
    import org.springframework.jdbc.support.GeneratedKeyHolder;
    import org.springframework.jdbc.support.KeyHolder;
    import org.springframework.stereotype.Component;
    import org.springframework.transaction.annotation.Transactional;
    
    import java.sql.PreparedStatement;
    import java.sql.ResultSet;
    import java.sql.SQLException;
    import java.sql.Statement;
    import java.util.List;
    
    @Component
    public class MemoRepository
    {
    
        private final JdbcTemplate jdbcTemplate;
    
        public MemoRepository(JdbcTemplate jdbcTemplate)
        {
            this.jdbcTemplate = jdbcTemplate;
        }
    
        public Memo save(Memo memo)
        {
            KeyHolder keyHolder = new GeneratedKeyHolder(); // 기본 키를 반환받기 위한 객체
    
            String sql = "INSERT INTO memo (username, contents) VALUES (?, ?)";
            jdbcTemplate.update( con -> {
                        PreparedStatement preparedStatement = con.prepareStatement(sql,
                                Statement.RETURN_GENERATED_KEYS);
    
                        preparedStatement.setString(1, memo.getUsername());
                        preparedStatement.setString(2, memo.getContents());
                        return preparedStatement;
                    },
                    keyHolder);
    
            // DB Insert 후 받아온 기본키 확인
            Long id = keyHolder.getKey().longValue();
            memo.setId(id);
    
            return memo;
        }
    
        public List<MemoResponseDto> findAll()
        {
            // DB 조회
            String sql = "SELECT * FROM memo";
    
            return jdbcTemplate.query(sql, new RowMapper<MemoResponseDto>() {
                @Override
                public MemoResponseDto mapRow(ResultSet rs, int rowNum) throws SQLException {
                    // SQL 의 결과로 받아온 Memo 데이터들을 MemoResponseDto 타입으로 변환해줄 메서드
                    Long id = rs.getLong("id");
                    String username = rs.getString("username");
                    String contents = rs.getString("contents");
                    return new MemoResponseDto(id, username, contents);
                }
            });
        }
    
        public void update(Long id, MemoRequestDto requestDto)
        {
            // memo 내용 수정
            String sql = "UPDATE memo SET username = ?, contents = ? WHERE id = ?";
            jdbcTemplate.update(sql, requestDto.getUsername(), requestDto.getContents(), id);
        }
    
        public Memo findById(Long id) {
            // DB 조회
            String sql = "SELECT * FROM memo WHERE id = ?";
    
            return jdbcTemplate.query(sql, resultSet -> {
                if(resultSet.next()) {
                    Memo memo = new Memo();
                    memo.setUsername(resultSet.getString("username"));
                    memo.setContents(resultSet.getString("contents"));
                    return memo;
                } else {
                    return null;
                }
            }, id);
        }
    
        public void delete(Long id)
        {
            String sql = "DELETE FROM memo WHERE id = ?";
            jdbcTemplate.update(sql,id);
        }
    
        @Transactional
        public Memo createMemo(EntityManager em) {
            Memo memo = em.find(Memo.class, 1);
            memo.setUsername("Robbie");
            memo.setContents("@Transactional 전파 테스트 중!");
    
            System.out.println("createMemo 메서드 종료");
            return memo;
        }
    }

    기존에 jdbc로 만들어진 코드를 지우고.

    public interface MemoRepository extends JpaRepository<Memo, Long> 
    {
        
    }

    JpaRepository를 상속받은 인터페이스로 만들어준다
    • 어노테이션을 적용하지않아도 자동으로 빈으로 등록되는데
      spring data jpa에 의해 자동으로 bean등록이 되었다.
      simplejparepository를 보면
      우리가 그동안 날것으로 배운 jpa의 기능이 모두 들어가있다.

      그래서 코드를 넣지않아도 웬만한 기능은 수행할 수 있다.

      repository를 수정하였으니 service를 수정해보자
      package com.sparta.springprepare.service;
      
      import com.sparta.springprepare.dto.MemoRequestDto;
      import com.sparta.springprepare.dto.MemoResponseDto;
      import com.sparta.springprepare.entity.Memo;
      import com.sparta.springprepare.repository.MemoRepository;
      import org.springframework.stereotype.Component;
      
      import java.util.List;
      
      @Component
      public class MemoService
      {
          private final MemoRepository memoRepository;
      
          public MemoService(MemoRepository memoRepository)
          {
              this.memoRepository = memoRepository;
          }
      
          public MemoResponseDto createMemo(MemoRequestDto requestDto)
          {
              // RequestDto -> Entity
              Memo memo = new Memo(requestDto);
      
              // DB 저장
              Memo saveMemo = memoRepository.save(memo);
      
              // Entity -> ResponseDto
              MemoResponseDto memoResponseDto = new MemoResponseDto(memo);
      
              return memoResponseDto;
          }
      
          public List<MemoResponseDto> getMemos()
          {
              return memoRepository.findAll();
          }
      
          public Long updateMemo(Long id, MemoRequestDto requestDto)
          {
              Memo memo = memoRepository.findById(id);
      
              // 해당 메모가 DB에 존재하는지 확인
              if(memo != null) {
                  memoRepository.update(id, requestDto);
                  return id;
              } else {
                  throw new IllegalArgumentException("선택한 메모는 존재하지 않습니다.");
              }
          }
      
          public Long deleteMemo(Long id)
          {
              // 해당 메모가 DB에 존재하는지 확인
              Memo memo = memoRepository.findById(id);
              if(memo != null) {
                  // memo 삭제
                  memoRepository.delete(id);
                  return id;
              } else {
                  throw new IllegalArgumentException("선택한 메모는 존재하지 않습니다.");
              }
          }
      }


      public List<MemoResponseDto> getMemos()
          {
              return memoRepository.findAll().stream().map(MemoResponseDto::new).toList();
          }

      getmemos는 위와같이 변경한다.

      private Memo findMemo(Long id) {
          return memoRepository.findById(id).orElseThrow(() ->
                  new IllegalArgumentException("Memo not found")
          );
      }

      find memo 메서드가 많이 쓰이는것을 알 수 있다 그걸 빼면 위와같이 뺄 수 있고.

      public Long updateMemo(Long id, MemoRequestDto requestDto) {
          Memo memo = findMemo(id);
      
          // 해당 메모가 DB에 존재하는지 확인
          memo.update(requestDto);
          return id;
      }
       
      jpa에서는 update가 없는것을 배웠다
      더티체킹을 이용한 수정방법을 적용해야한다.
      따라서, 

      Memo entity에서 만든 메서드를 이용해 수정을 진행한다.

      @Transactional
      public Long updateMemo(Long id, MemoRequestDto requestDto) {
          Memo memo = findMemo(id);
      
          // 해당 메모가 DB에 존재하는지 확인
          memo.update(requestDto);
          return id;
      }

      update에 transaction이 존재해야 변경감지를 할 수 있다.


    • Jpa Auditing 적용하기
      저장된 시간 수정된 시간을 적용하고싶다. 어떻게 해야할까?
      이렇게 하면될까?
      코드변경 > 로직변경까지 해야한다..

      @Getter
      @MappedSuperclass
      @EntityListeners(AuditingEntityListener.class)
      public abstract class Timestamped {
      
          @CreatedDate
          @Column(updatable = false)
          @Temporal(TemporalType.TIMESTAMP)
          private LocalDateTime createdAt;
      
          @LastModifiedDate
          @Column
          @Temporal(TemporalType.TIMESTAMP)
          private LocalDateTime modifiedAt;
      }
       
      @MapedSuperclass : Entity클래스가 해당 추상클래스를 상속할경우 멤버변수를 컬럼으로 가질 수 있다
      @EntityListeners(AuditingEntityListener.class) : 해당 클래스에 auditing 기능을 제공한다 (자동으로 시간을 등록한다 )
      @CreatedDate : 객체가 생성된 시간을 저장한다
      해당 컬럼의 updatable = false로 하면 변경이되지않는다
      @LastModifiedDate : 조회한 값의 데이터를 변경할때 변경된 시간을 저장한다
      @Temporal : date나 calendar과 같은 날짜를 매핑할때 쓴다.

      @EnableJpaAuditing
      @SpringBootApplication
      public class SpringPrepareApplication {
      
          public static void main(String[] args) {
              SpringApplication.run(SpringPrepareApplication.class, args);
          }
      
      }
       그리고 springbootApplication 어노테이션이 적용된 메인에서 EnableJpaAuditing 어노테이션을 적용한다.

      public class Memo extends Timestamped{

      메모는 timestamped를 상속받는다
      메모는 timestamped의 필드도 적용되었다.

      @Getter
      public class MemoResponseDto {
          private Long id;
          private String username;
          private String contents;
          private LocalDateTime createdAt;
          private LocalDateTime modifiedAt;
      
          public MemoResponseDto(Memo memo)
          {
              this.id = memo.getId();
              this.username = memo.getUsername();
              this.contents = memo.getContents();
              this.createdAt = memo.getCreatedAt();
              this.modifiedAt = memo.getModifiedAt();
          }
      }

      response에 반환을 해줘야할것이다
      auditing을 적용하기 전에는 null이었지만, 현재는 날짜가 적용되는것을 볼 수 있다.

    • Query Mehthods
      spring data jpa에서 메서드 이름으로 sql을 생성할 수 있는 기능이다.


      쿼리문을 자동 완성 해주는데 simpleJpaRepository이미 정의된 내용들이다.

      자동완성을 통해 만들게 되었다.
      따로 코드는 작성하지 않아도 된다, simpleJpaRepository가 메서드 이름을 분석해 만들어주는것이다!


      원래는 이렇게 오름차순으로 정렬된 것을 볼 수 있다.

      public List<MemoResponseDto> getMemos() {
              return memoRepository.findAllByOrderByModifiedAtDesc().stream().map(MemoResponseDto::new).toList();
          }
       
      findAllByOrderByModifiedAtDesc메서드를 적용해주면


      이렇게 바뀐다.





 

'TIL' 카테고리의 다른 글

20240821 본캠프 28일차 TIL  (0) 2024.08.21
20240819 본캠프 26일차 TIL  (0) 2024.08.19
20240816 내배캠 25일차 TIL  (0) 2024.08.17
20240815 본캠프 24일차 TIL  (0) 2024.08.15
20240814 본캠프 23일차 TIL  (0) 2024.08.14