블로그

열씨미살자!

22.11.27 학습일기

Facts변경에 용이한 소프트웨어를 만들기 위해 고민해보았는데 oop 에서는 어떻게 하고 있는지 찾아보니 solid 원칙이라는 것을 알게 되었다. react 에서 어떻게 oop의 solid 원칙을 적용할 수 있을까를 고민하는 시간을 가졌다.우선 solid 가 무엇인지에 대해 찾아보았다.SRP(Single Responsibility Principle): 단일 책임 원칙하나의 객체는 반드시 하나의 동작만의 책임을 갖는다.OCP(Open Closed Principle): 개방 폐쇄 원칙객체의 확장은 열려 있고, 수정은 닫혀있어야 한다.LCP(Liskov Substition Principle): 리스코프 치환 원칙부모 객체를 호출하는 동작에서 자식 객체가 부모 객체를 완전히 대체할 수 있어야 한다.ISP(Interface Segregation Principle): 인터페이스 분리 원칙객체는 자신이 호출하지 않는 메소드에 의존하지 않아야 한다.DIP(Dependency Inversion Principle): 의존성 역전 원칙객체는 인터페이스에 의존해야한다.즉 의존 관계를 맺을 때 변화하기 쉬운 것(구현) 보다는, 변화하지 않는 것(인터페이스)에 의존해야한다.리액트에서 위의 원칙들을 어떻게 적용해볼 수 있을지 간단한 예제들을 생각해봤다. SRP데이터와 관련된 로직은 custom hook 으로, ui 와 관련된 로직은 컴포넌트로 분리한다.하나의 도메인에 묶일 수 있도록 한다. 별점블로그 리스트 관련 훅과 컴포넌트가 하나로 합쳐져 있어도 상관없을 것 같다.export function App() { const { rating, onChangeRating } = useRating(); const { blogs } = useBlogs(); return ( <main> <Rating rating={rating} onChangeRating={onChangeRating}/> <BlogCards blogs={blogs}/> </main> ) }OCP기존 코드를 변경하지 않고 수정을 할 수 있도록 한다.조건에 의해 컴포넌트의 내부 로직이 계속해서 변경된다면 위험할 수 있다.// before export function CommentEditor({ user }) { // user.role 이 추가되면 분기가 계속해서 생기게 된다. if(user.role === 'ADMIN') { return <textarea placeholder="운영자 계정입니다."></textarea> } if(user.role === 'B2B') { return <textarea placeholder="비지니스 유저는 댓글을 달 수 없습니다."></textarea> } return <textarea placeholder="댓글을 작성해주세요."></textarea> } // after export function CommentEditor({ placeholder }) { return <textarea placeholder={placeholder}></textarea> } export function AdminCommentEditor() { return <CommentEditor placeholder="운영자 계정입니다."> } export function B2BCommentEditor() { return <CommentEditor placeholder="비지니스 유저는 댓글을 달 수 없습니다."> } export function UserCommentEditor() { return <CommentEditor placeholder="댓글을 작성해주세요."> }LCP아직 예제가 잘 생각나지 않음.ISP컴포넌트 props 를 넘겨줄 때 책임을 지지 않는 영역의 데이터까지 넘기지 않는다.//before export function Career() { const { data: career } = useCareerQuery(); return <section> <Name career={career} /> <PhoneNumber career={career} /> <Email career={career} /> <Adreess career={career} /> </section> } // after export function Career() { const { data: career } = useCareerQuery(); return <section> <Name name={career.name} /> <PhoneNumber phoneNumber={career.phoneNumber} /> <Email email={career.email} /> <Adreess address={career.address} /> </section> }DIP세부 구현에 의존하지말고 인터페이스를 만들어서 의존하게 한다. 리액트에서는 props들의 인터페이스들을 활용할 수 있을 것 같다.example1 에서 onSumbit 이 Form 컴포넌트 내부에 있다면 다른 로직이 반영된 onSumbit 이 필요할 때 코드 변경이 매우 어렵다. 즉 Form 컴포넌트가 내부의 onSubmit이라는 세부구현에 의존하게 되면 변경이 어렵다는 것이다. 이것을 인터페이스에 의존하게 만들면 변경에 용이한 컴포넌트가 될 수 있다.example2 은 react-query 에서 제공하는 provider 예제이다. context-api를 활용하여 의존성 주입을 해주고 있는데 나중에 테스트코드를 작성할 때 client 인터페이스를 맞춘 Stub 클래스를 주입해주면 테스트할 때 용이할 수 있다. 나중에 중요 도메인 로직들을 외부로 밀어낼때 사용해보면 좋을 것 같다. // example 1 type Props = { onSumbit: (email, password) => void; // 해당 인터페이스를 맞춘 함수를 구현해서 내려주면 된다. buttons: ReactNode; // 해당 인터페이스를 맞춘 buttons 를 구현해서 내려주면 된다. } export function Form({ onSubmit }: Props) { const [email, onChangeEmail] = useInput(''); const [password, onChangePassword] = useInput(''); const handleSumbit = (event) => { onSumbit(email, password); } return <form onSumbit={handleSubmit}> <input type="email" value={email} onChange={onChangeEmail} /> <input type="password" value={password} onChange={onChangePassword} /> <div> {buttons} </div> </form> } export function LoginForm() { const handleLoginSumbit: Props['onSumbit'] = (email, password) => { //... } return <Form onSumbit={handleLoginSumbit} buttons={<button type="submit">로그인</button>}/> } export function SignUpForm() { const handleSignUpSumbit: Props['onSumbit'] = (email, password) => { //... } return <Form onSumbit={handleSignUpSumbit} buttons={<button type="submit">회원가입</button>}/> } // example2 type Props = { children: ReactNode; } export function AppProvider({ children }: Props) { // client 라는 props 의 인터페이스를 맞춰서 내려주기만 하면 된다. // 보통 react 에서 의존성 주입(Dependency Injection)을 사용할 때 context-api 를 활용한다. return <QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider> }Feelings항상 변경에 용이한 소프트웨어를 만들려면 어떻게 해야될지가 고민이다. 특히나 프론트엔드쪽은 변경이 너무 잦기 때문에 코드변경 또한 매우 잦다. 그래서 변경이 일어났을 때 유연하게 대처할 수 있는 방법이 없을까 고민하다가 oop 영역에서 사용하는 소프트웨어 설계원칙인 solid 에 관심을 가지게 되었다. 완벽하게 리액트 코드에 적용할순 없겠지만 단순히 설계 원칙이기에 최대한 적용할 수 있는 부분에는 적용해보면 좋지 않을까 싶다. 항상 코드를 짤 때 왜 이런 코드가 좀 더 나은 코드인지 생각하는 연습을 해보면 좋을 것 같다.그리고 요즘들어 경력이 늘어날수록 내가 지금 막 커리어를 시작한 신입 개발자들과 다른게 무엇인지 고민하게 되는 것 같다. 지금 하는일에만 너무 안주하고 있다보니 이러다간 영원히 제자리 걸음일 것 같다는 생각이 든다. 이러한 고민들과 숙련도가 쌓여 다음단계로 가기 위한 발판이 되었으면 좋겠다.Findings변경에 용이한 소프트웨어를 만들기 위해 어떤 것들을 신경쓰면 좋은지에 대한 기초지식을 쌓았다. 오늘 정리한 것들을 실제 코드에 적용시켜보면서 실제로 어떤 점들이 나아졌는지에 대해서도 정리해보면 좋을 것 같다. 

학습일기

wnsgh5049

22.11.23 TIL - 객체 지향 원리 적용 그리고 static

오늘은 휴가를 내고 집에서 <스프링 핵심원리 - 기본편> 강의를 들었다. 생각만큼 많이 듣지는 못했다. 연말에 휴가를 다 써야 하는데 12월 되면 바빠서 못 쓸 것 같아서 다른 사람들 휴가 쓸 때 같이 썼다 1.자바 코드로 회원, 주문, 할인 도메인과 인터페이스 예제를 작성하였다. 이 예제 코드도 좋은 코드지만 SOLID 원칙에 OCP과 DIP 원칙에 위배가 되었다.해결 방법은 각 Service 클래스에 인스턴스 변수를 생성자 인자로 넣어준다. 그리고 AppConfig 파일을 만들어서 그 안에 Service 구현체를 반환해주고 해당 Service를 사용하려는 지점에 AppConfig를 호출하면 AppConfig에서 필요한 인스턴스를 꺼내어 사용할 수 있다. 이렇게 하면 각 Service들은 각자의 역할에만 집중할 수 있으며 확장은 가능하지만 수정은 안되는 코드를 작성해줄 수 있다.어제 객체지향 원리에 5가지 원칙에 대해서 잘 이해가 안 되었지만 코드로 작성해보니 좀 더 이해할 수 있었다. 그리고 Spring을 사용하기 전에 그냥 자바 코드로 의존성 주입을 경험해볼 수 있었다. 2.그리고 static을 왜 사용하는지에 대해서도 알았다. 그 전에는 그냥 코드 따라 칠 때 있길래 생각없이 따라 작성했다. static은 프로그램이 실행될 때 클래스에서 딱 한번 생성된다. 인스턴스가 생성될 때 static 변수는 생성되지 않으며 다른 인스턴스들은 해당 클래스 내부에 있는 static변수를 공유한다.  우연히 다른 사람이 질문한 글을 보았는데 완전 유레카였다. 예전에는 왜 static을 이해하지 못했을까...static 참조: https://www.inflearn.com/questions/240845 내일이나 모래부터 Spring로 프로젝트 시작할 거 같은데 처음 해보는거라 너무 긴장된다... 잘 할 수 있을까...

웹 개발학습일기#스프링핵심원리_기본편

22.12.31 학습일기

4. Numeric_1var myScore: UInt = 50myScore = 50var myScore1: UInt8 = 200200이라는 값의 2진수 값을 알고 싶을 경우!!String(myScore1, radix: 2(2진수여서), uppercase: false(true/ false 아무거나 ok))String(myScore1, radix: 2, uppercase: false) uppercase : true // 16진수 표현하고자 할 때 대문자로 표현uppercase : false // 16진수 표현하고자 할 때 소문자로 표현 Int8.max // 127Int8.min // -128UInt8.max // 255UInt8.min // 0// bit (binary, 2진수) 0 -> 1 -> 10// 3bit 000 -> 111// 4bit 0000 -> 1100// 8bit 00000000 -> 111111115. Numeric_2var myScore = 1_000_000 // 숫자 중간에 언더바 사용 가능, 시각적으로 보기 편함var myBit = 00010001 // 이대로 쓰면 10001만 인식 00010001라는 2진수로 인식하게 하려면?var myBit = 0b00010001 // 앞에 0b를 쓴다! 17이 저장됨var myBit = 0b0001_0001_1111 // 이때도 사용 가능 그럼 8진수는? -> 0o (0-7까지의 숫자만 사용 가능)var myBit = 0o10 // 8그럼 16진수는? -> 0xvar myBit = 0xf // 15var myBit = 0x10 // 16// reminder %var anyNumber = 9if (anyNumber % 2 == 0) {// 짝수} else {// 홀수}var number1 = 0.7var number2 = 0.2var sum = number1 + number2 // 0.89999999가 나옴, 0.9X WHY?0.7, 0.2도 2진수로 바뀌어서 계산되기 때문에 0.1101001010 + 0.110100010012진수 소수 계산 검색하여 알아보기0.9가 나오려면 어떻게 계산해?--> Decimal // 일반적으로 우리가 사용하는 0부터 9까지의 숫자 체계대로 계산하여 나타나게 해줌따라서 Decimal(sum)을 하면 0.9가 나옴6. Array_1Swift는 Array, Set, Dictionary 세 가지 Collection Type이 있음Collection Type이란, 데이터의 묶음, 데이터의 집합 느낌// Int Arrayvar numbers1 = [4, 5, 10, 23, 0, 4, 3, 2, 1]var numbers2: [Int] = [4, 5, 10, 23, 0, 4, 3, 2, 1]var numbers3: Array<Int> = [4, 5, 10, 23, 0, 4, 3, 2, 1]// 1 ~ 5var numbers4 = Array<Int>(1...5)var numbers5 = [Int](1..5)// 1, 1, 1, 1, 1var numbers6 = Array(repeating: 1, count: 5)// String Arrayvar strings = ["lee", "kim"]// Any Arrayvar anyArray: [Any] = [0, "lee"] // 타입이 여러 개 있을 때 Any타입 사용. but, swift 성격과 맞지는 않음, 안 쓸 수 있으면 안 쓰는 게 좋음// empty Array - 타입을 꼭 명시해야 함. 네 가지 모두 같은 의미var emptyArray1: [String] = [] // String 타입의 빈 배열 만들어짐var emptyArray2: [String] = Array() // 인스턴스화 한 것 -> 그래야 메모리에 올라가 사용 가능var emptyArray3 = Array<String>()var empryArray4 = [String]()7. Array_2// addvar numbers10 = [4, 5, 10, 23, 0, 4, 3, 2, 1]var numbers11 = [100, 200, 300]numbers10.insert(contentsOf: numbers11, at: 1) // 중간에 삽입, [4, 100, 200, 300, 5, 10, 23, 0, 4, 3, 2, 1]numbers10.append(contentsOf: numbers11) // 끝에 삽입, [4, 100, 200, 300, 5, 10, 23, 0, 4, 3, 2, 1, 100, 200, 300]numbers10.append(100) // [4, 100, 200, 300, 5, 10, 23, 0, 4, 3, 2, 1, 100, 200, 300, 100]numbers10.insert(500, at: 0) // [500, 4, 100, 200, 300, 5, 10, 23, 0, 4, 3, 2, 1, 100, 200, 300, 100]numbers10.insert(1000, at: 5) // [500, 4, 100, 200, 300, 1000, 5, 10, 23, 0, 4, 3, 2, 1, 100, 200, 300, 100]// removevar numbers20 = [4, 5, 10, 23, 0, 4, 3, 2, 1]let removeNumber = numbers20.remove(at: 4) // 삭제 된 값이 리턴되어 removeNumber에 저장numbers20.removeFirst()numbers20.removeLast()numbers20.removeFirst(2) // 앞에 두 개 삭제numbers20.removeLast(2) // 뒤에 두 개 삭제// replacevar numbers30 = [4, 5, 10, 23, 0, 4, 3, 2, 1] // readnumbers30.last // 마지막 인덱스의 값 읽어옴let lastIndex = numbers30.count - 1numbers30[lastIndex]8. Set_1Set은 순서도 없고, 인덱스도 없음, 중복 허용 안됨Array는 순서대로 접근 할 때 사용 // Set typevar number1: Set = [1,2,3,4,5] // {4, 3, 1, 5, 2}var number2: Set<Int> = [1,2,3,4,5] // {2, 5, 4, 3, 1}var number3 = Set([1,2,3,4,5]) // {1, 4, 2, 3, 5} // empty Setvar numbers4 = Set<Int>()var numbers5 : Set<Int> = [] // addnumbers1.insert(100) // (inserted true, memberAfterInsert 100)numbers1.insert(100) // (inserted false, memberAfterInsert 100) true와 false 값만 얻고 싶다면?numbers1.insert(100).inserted // truenumbers1.insert(100).inserted // false, 중복 허용되지 않기 때문에 // 덮어쓰기, update는 무조건 넣는 것,numbers1.update(with: 100) // 기존에 그 값이 있는지 없는지 값으로 알려줌, 100을 넣으려고 했는데 기존에 100이 있었다는 것을 보여줌numbers1.update(with: 1000) // 기존 값은 없다라는 것이 nil로 표시됨, nil은 아무것도 없다는 의미, 이 값은 numbers1에 들어감 // removenumbers1.remove(50) // 50이란 값이 numbers1에 없으므로 nil로 표시numbers1.remove(100) // 100numbers1 // {3, 1000, 1, 2, 5, 4} 내부적으로는 Set도 순서를 가지고 있다!!numbers1.firstIndex(of: 1000) // 1000의 인덱스 값 가져오기let setIndex = numbers1.firstIndex(of: 1000) // Set<Int>.index // numbers에 1000이 있으므로 1000의 index값이 setIndex에 저장numbers1.remove(at: setIndex!) // !(느낌표) 꼭 써야함, 이유는 나중에 설명9. Set_2let numbers1: Set = [1,2,3,4,5]let numbers2: Set = [4,5,6,7,8]// 합집합numbers1.union(numbers2) // {4,1,3,5,7,8,2}// 교집합numbers.intersection(numbers2) // {4,5}// 대칭차집합 (합집합-교집합)numbers1.symmetricDifference(numbers2) // {1,3,2,7,8,6}// 여집합numbers1.subtracting(numbers2) // {1,3,2} // numbers1에서 numbers2와 겹치는 원소 제외하고 나열numbers2.subtracting(numbers1) // {7,9,8} // numbers2에서 numbers1과 겹치는 원소 제외하고 나열// 한 쪽에 모두 포함 여부let numbers3: Set = [1,2,3,4,5]let numbers4: Set = [1,5]numbers3.isSubset(of: numbers4) // numbers3의 원소들이 numbers4에 포함되어 있는가? --> falsenumbers4.isSubset(of: numbers3) // numbers4의 원소들이 numbers3에 포함되어 있는가? --> truenumbers3.isSuperset(of: numbers4) // numbers4의 원소들이 numbers3에 포함되어 있는가? --> truenumbers4.isSuperset(of: numbers3) // numbers3의 원소들이 numbers4에 포함되어 있는가? --> false// 한 쪽에 모두 불포함 여부let numbers5: Set = [1,2,3,4,5]let numbers6: Set = [6,7]numbers5.isDisjoint(with: numbers6) // numbers5와 numbers6는 중복되어 있는 값이 하나도 없는가? --> truelet numbers5: Set = [1,2,3,4,5]let numbers6: Set = [6,7,5]numbers5.isDisjoint(with: numbers6) // numbers5와 numbers6는 중복되어 있는 값이 하나도 없는가? --> false10. Counted Set// NSCountedSet : 타입의 한 종류, 중복되서 누적되는 카운팅 개수를 셀 때 사용let colors: NSCountedSet = ["red", "orange", "blue"]colors.count(for: "red") // 1colors.add("red") // {"red", "orange", "blue"}colors.count(for: "red") // 2colors.add("red") // {"red", "orange", "blue"}colors.count(for: "red") // 311. Dictionarykey - valueSet과 같이 순서가 없음, key 중복 허락x, value는 중복 가능// 생성// emptylet names1 = Dictionary<String, String>() // [:]let names2 = [String : String]() // [:]let names3: [String : String] = [:] // [:]var names4 = ["cityA" : "Seoul" , "cityB" : "La"]names4.count // 2names1.isEmpty / true// 직접 입력 추가names4["cityC"] = "tokyo"names4["cityD"] = "baijing"names4.updateValue("Busan", forKey: "cityE")names3["cityE"] = "incheon"names3["cityF"] = "SF"// Dictionary + Dictionary, cityE가 중복되므로 return 값에 따라 한쪽 cityE는 지워짐let mergingDic = names3.merging(names4) { leftValue, rightValue in return letfValue}// leftValue, rightValue는 개발자가 임의로 지은 이름. 보통은 current, new으로 씀, leftValue는 names3를, rightValue는 names4를 의미// return letfValue하면 names3에 있는 cityE : incheon이 남음// return rightValue하면 names4에 있는 cityE : Busan이 남음// 삭제names3 // ["cityE" = "incheon"], ["cityF" = "SF"]names3["cityF"] = nillnames3.removeValue(forKey: "cityF")// 두 개 같은 의미names3 // ["cityE" = "incheon"]// 업데이트names3["cityE"] = "hawaii"12. TupleTuple은 ()안에 들어 있음, 삭제, 추가할 수 없음var someTuple1 = (100, "kim", false)someTuple을 타입 추론하면 someTuple: (Int, String, Bool)==> 첫 번째 값은 Int, 두 번째 값은 String, 세 번째 값은 Bool 타입의 값이 들어가야 함// 생성var someTuple2 = (score : 100, userName : "kim", isShowName : false)var someTuple3: (String, Int) = ("lee", 50)// 조회someTuple.0 // 100someTuple2.score // 100let (score, name, isShowName) = someTuple1 // let 대신 var도 사용 가능score // 100name // "kim"isShowName // false// 업데이트someTuple2.score = 50 // 50someTuple2 // (score : 50, userName : "kim", isShowName : false) 

모바일 앱 개발학습일기swiftiOS

wnsgh5049

22.11.28.(월) ~ 11.29.(화) TIL

22.11.28.(월) 월요일은 휴가 내고 어디 갔다가 저녁 먹고 <실전! 스프링 부트와 JPA 활용2> 강의를 들었다. RequestBody를 Entity로 받는 것이 아니라 DTO로 받는 것에 대한 내용이었다. 사실 스프링 부트 공부하면서 블로그를 찾아볼 때 DTO, DAO를 사용했는데 그걸 이해 못하고 넘어갔다. entity로 인자를 받거나 응답을 주는 것은 entity에 화면을 뿌리기 위한 로직이 들어가 있다. entity에 프레젠테이션 쪽 로직이 추가된 것이다. 또한 API의 스펙과 기능들이 entity에 들어왔다. 이렇게 되면 entity의 필드명이 바뀌면 API 스펙이 바뀌어 버린다. 또한 응답필드를 확장할 때 유연성도 떨어진다. 그래서 필요한 필드만 요청받고 응답받는 DTO를 사용하는 것이 좋다. 22.11.29.(화)화요일은 업무시간에 삽질한 내용이다. 시니어 개발자 분이 전달해준 기본 세팅이 된 프로젝트 소스코드를 받아서 코드를 볼려고 하였다. STS를 사용했는데 작동이 안되었다. Spring Boot App config에 Main Type이 정의가 안 되어 있었다. 그리고 그 외 여러 설정들을 해주니 실행이 되었다. 이것 저것 삽질하다가 하루가 다 갔다. 역시 세팅하는게 제일 어려운 것 같다... 기본 세팅되어 있는거 실행하는 것도 어렵다... 삽질하는데 전달해줄 때 설정이 뭔가 빠진 것 같다라는 생각이 들은 것에 반성하며...

웹 개발학습일기스프링부트

wnsgh5049

22.11.25.(금)-스프링 컨테이너, 싱글톤, @Component, @Autowired

스프링 컨테이너(ApplicationContext)를 생성하고 @Bean Annotation을 메서드에 등록하면 컨테이너에서 @Bean을 스캔하여 스프링 빈을 등록한다.@CompoenetScan은 @Component가 붙은 모든 클래스를 스프링 빈으로 등록한다.@Autowired를 지정하면 스프링 컨테이너가 자동으로 해당 스프링 빈을 찾아 주입한다.(getBean(MemberRepository.class)와 동일) 싱글톤 패턴은 클래스의 인스턴스가 1개만 생성되는 것을 보장하는 디자인 패턴이다. 하지만 싱글톤 패턴을 사용하면 문제점이 여러가지가 발생한다. 다 이해하는건 어렵지만 결론적으로 유연성이 떨어진다고 한다.그래서 스프링 컨테이너는 인스턴스를 한 개만 생성(싱글톤)하여 관리한다.내부 로직은 자세히 알 수 없지만 스프링이 CGLIB라는 바이트코드 조작 라이브러리를 사용하여 싱글톤을 보장해준다고 한다. 바이트코드를 조작하는 CGLIB 기술을 사용해서 싱글톤을 보장해주려면 클래스에 @Configuration을 붙여주면 된다.그래고 의존관계를 주입할 때 생성자 주입, 수정자(setter) 주입, 필드 주입, 일반 메서드 주입이 있는데 4가지 중에 대부분 생성자 주입을 사용한다고 하고 종종 수정자 주입을 한다고 한다.  생성자 주입을 하면 한 번만 호출 되는 것을 보장된다. 그리고 불변, 필수 의존관계에 사용된다.내가 블로그에 찾아본 예제에는 필드에 @Autowired를 사용했는데 이 방법은 좋은 방법이 아니었다...  사실 @Autowired나 @Component도 왜 사용하는지 몰랐다. 하지만 순수 자바 코드로 AppConfig를 만들어 사용하는 클래스를 메서드로 사용하여 main 함수에 AppConfig 인스턴스를 생성해주면 되었다. 하지만 스프링 컨테이너를 사용하고 나면 싱글톤을 보장해주고 Annotation으로 OCP, DIP 원칙을 지키면서 코딩을 할 수 있게 해준다. - 이제 스프링 부트로 개발을 시작할 날이 얼마 안 남았다ㄴ 이번에 블랙프라이데이 이벤트라서 김영한님의 강의 실전 JPA1, 2 강의를 구매하였다.ㄴ 요즘 개인적으로 재정긴축해서 아껴쓰고 있긴하지만... 과감하게 강의를 질러야될 만큼 속도감있게 올바른 방향으로 공부를 해야되었다. 현재 위기기만 기회로 만들기 위해서 계속 노력이 필요하다. 강의가 비싸지만 영한님 버프 받고 생존의 굴레에서 벗어나자 

웹 개발학습일기스프링핵심원리_기본편

채널톡 아이콘