번역 안내
이 글은 원본 영상을 @ysm0622 님의 추천으로 알게된 후, 발표자 Kent C. Dodds 님의 허락을 받고 번역하였습니다.
AHA Programming
오늘은 당신이 이미 알고 있는 DRY 프로그래밍 원칙, WET 프로그래밍 원칙은 제쳐두고 AHA 프로그래밍에 대해서 얘기해보려고 합니다.
주)
DRY: Don't Repeat Yourself
WET: Write Everything Twice
AHA: Avoid Hasty Abastraction
우리는 추상화(Abstractions)가 어떻게 시작되고 어떻게 끝나는지 라이프사이클을 살펴보고, 추상화를 신중하게 하는 것이 왜 중요한지에 대해 생각해볼 것입니다. 만들어진 예시이지만 그래도 충분히 공감하실 수 있을 거라 생각됩니다. 또, Sandi Metz의 블로그에 소개된 실제 사례도 함께 보겠습니다.
슬라이드 화면 없이 코드를 보여주면서 진행할 텐데요, 수동적으로 봐서는 이해가 안 될 테니 트위터를 할지 제 이야기에 집중할지 지금 골라주세요. 저는 괜찮습니다.
'Quokka'라는 VS Code 익스텐션을 사용할 건데 console.log값이 무엇인지 에디터 화면에 파란색 글씨로 바로 보여주는 익스텐션입니다. (주: 이 글에서는 // 주석으로 대체합니다.)
1
자 그럼 당신의 애플리케이션에 'phil' 이라는 객체가 있다고 가정해봅시다. name, username 라는 키를 갖고 있네요. 이 객체는 애플리케이션에서 UI에 이름을 표시하는 navDisplayName, profileDisplayName, cardDIsplayName 총 3군데에서 사용되고 있습니다.
const phil = {
name: { honorific: 'Dr.', first: 'Philp', last: 'Radriquez' },
username: 'philpr',
};
// navigation.js
// ...
const navDisplayName = `${phil.name.first} ${phil.name.lest}`;
console.log(navDisplayName); // Philp undefined
// profile.js
// ...
const profileDisplayName = `${phil.name.first} ${phil.name.lest}`;
console.log(profileDisplayName); // Philp undefined
// card.js
// ...
const cardDisplayName = `${phil.name.first} ${phil.name.lest}`;
console.log(cardDisplayName); // Philp undefined
이미 눈치챘을 수도 있겠지만 이 코드에는 버그가 있습니다. (주: phil.name.lest -> last) 필립이 로그인하고 본인의 이름이 'Philp undefined' 로 표시된 걸 본다면 매우 당황스러울 텐데요. 우리는 이 문제를 해결하면서 추상화, 추상함수(abstraction)를 시작합니다.
재직 중 어떤 페이지에 버그가 생겼을 때의 일입니다.
QA 테스터: "켄트야, 버그 고쳤다 하지 않았어?"
켄트: "응 고쳤는데?"
QA 테스터: "여기 아직 깨지는데?"
켄트: "오 쒯- 내가 복붙 했던 거 남아있나 보다. 거기도 수정해야겠다"
이 과정은 진정 고통이죠. 따라서 우리는 추상화 작업을 거쳐 동일한 버그를 여러 군데에서 고치지 않아도 되게끔 할 겁니다. 세 군데에서 3번 동일한 작업을 하는 대신에 한 군데만 수정하면 나머지가 자동으로 수정되도록 해봅시다. getDisplayName() 이라는 함수를 만들어 user를 인자로 받고 user.name을 이용해 리턴하도록 일반화시킵니다. 그리고 세 군데의 리터럴 템플릿을 getDisplayName(phil) 로 대체합니다.
function getDisplayName(user) {
return `${user.name.first} ${user.name.lest}`;
}
// navigation.js
// ...
const navDisplayName = getDisplayName(phil) console.log(navDisplayName); // Philp Radriquez
// profile.js
// ...
const profileDisplayName = getDisplayName(phil) console.log(profileDisplayName); // Philp Radriquez
// card.js
// ...
const cardDisplayName = getDisplayName(phil) console.log(cardDisplayName); // Philp Radriquez
이제는 getDisplayName(user) 함수 단 한 군데에서만 lest만 last로 바꿔주면 됩니다. 다른 어떤 곳도 수정하지 않아도 되니까 만족스럽습니다. 뭔가 또 수정해야 되더라도 여기 한군데서만 수정해도 되니까 멋지죠.
2
실제로 수정해야할 상황은 자주 발생합니다. profile 페이지에 'Dr. Philip Rodriguez'처럼 직위(honorific)도 같이 보여주기로 결정되었다고 해봅시다. 프로덕트 매니저가 와서 프로필 페이지에 honorific도 띄워달라고 당신에게 이야기하겠죠.
???: 프로필 페이지에 내가 박사인 게 나오면 좋겠어. 내 가방끈이 얼마나 긴지 보여주고 싶단 말이야.
코드로 돌아와 당신은 이전에 작성해둔 추상함수 getDisplayName() 발견합니다. 추상함수를 활용하려는 자연스러운 경향에 따라 getDisplayName()에서 이 유스케이스가 지원되는지부터 먼저 체크를 하겠죠? honorifc을 추가하는 기능이 없다는 것을 확인한 당신에게는 두 가지 선택지가 있습니다. profileDisplayName에서 getDisplayName()이라는 추상함수를 제거하는 방법과, 이미 존재하는 추상함수에 유스케이스를 더하는 방법이 있죠.
우리는 보통 존재하는 추상함수를 개선해서 새 유스케이스를 지원하도록 만드는 쪽으로 마음이 기웁니다. 지금 추가해두면 다른 사람도 추후에 활용할 수 있어서, 또는 추상함수가 주는 편리함으로부터 벗어나고 싶지 않아서 때문일 수도 있겠습니다. 이건 실제로도 흔한 일이죠. 단 한 가지 기능을 추가하기 위해 getDisplayName()을 지우기보다 getDisplayName()을 조금 개선하는게 훨씬 쉬워 보이거든요.
우리도 그 방법을 따라보겠습니다. honorific 표시 여부를 option 인자로 받되, 모두가 option을 전달할 필요는 없게 default 값을 {}로 설정하겠습니다.
function getDisplayName(user, options = {}) { ... }
음 options을 그냥 options이라고 하고 싶지 않네요. 구조 분해 할당(destructuring)을 해서 includeHonorific을 받겠습니다. 그리고 다른 유스케이스에 영향이 없도록 false를 기본값으로 설정해두겠습니다. 원래 리턴하던 리터럴 템플릿을 displayName 변수에 받아두고, if문으로 includeHonorific이 true일 경우에 honorific을 앞에 붙여주도록 합니다. 그리고 displayName을 리턴해줍니다.
function getDisplayName(user, { includeHonorific = false } = {}) {
let displayName = `${user.name.first} ${user.name.last}`;
if (includeHonorific) {
displayName = `${user.name.honorific} ${displayName}`;
}
return displayName;
}
// navigation.js
// ...
const navDisplayName = getDisplayName(phil);
console.log(navDisplayName); // Philp Radriquez
// profile.js
// ...
const profileDisplayName = getDisplayName(phil, { includeHonorific: true });
console.log(profileDisplayName); // Dr. Philp Radriquez
// card.js
// ...
const cardDisplayName = getDisplayName(phil);
console.log(cardDisplayName); // Philp Radriquez
이제 profileDisplayName에서 includeHonorific을 true로 설정해주면 끝입니다. 아주아주 만족스럽죠. 커밋을 올리고 리뷰를 받으면 사람들은 함수를 재사용했다고 추상함수가 주는 파워를 보라며 멋지다고 해주겠죠. 유닛테스트 몇 개를 마무리하면 상황은 일단락됩니다.
그 후 누군가 또 찾아와 카드에 사용자 아이디(username)를 괄호에 넣어 표시해야 한다고 합니다. 뭐 복잡하지 않겠네요. 다시 추상함수 getDisplayName()로 돌아가서 필요한 기능이 없는 걸 확인합니다. 그리고 추상함수를 우리의 코드에서 제거하기보다는 이 기능을 추상함수에 더해주겠죠.
원래 있던 기능을 망가뜨리고 싶지 않기 때문에 가능한 한 아주 살짝만 건드리도록 하겠습니다. includeUserName이라는 또 다른 옵션을 추가해주고 역시 기본값을 false로 설정해줍니다. if문으로 includeUserName이 true일 경우에 username을 괄호 안에 넣어주도록 합니다.
function getDisplayName(user, { includeHonorific = false, includeUserName = false } = {}) {
let displayName = `${user.name.first} ${user.name.last}`;
if (includeHonorific) {
displayName = `${user.name.honorific} ${displayName}`;
}
if (includeUserName) {
displayName = `${displayName} (${user.username})`;
}
return displayName;
}
// navigation.js
// ...
const navDisplayName = getDisplayName(phil);
console.log(navDisplayName); // Philp Radriquez
// profile.js
// ...
const profileDisplayName = getDisplayName(phil, { includeHonorific: true });
console.log(profileDisplayName); // Dr. Philp Radriquez
// card.js
// ...
const cardDisplayName = getDisplayName(phil, { includeUserName: true });
console.log(cardDisplayName); // Philp Radriquez (philpr)
이제 cardDisplayName에서 includeUserName을 true로 설정해주면 완벽하죠. 요청사항을 정확히 구현했고 추상함수에 녹여냈습니다.
함수가 제대로 동작하는지 테스트를 추가하면서, 추상함수가 필요하지 않은 옵션 조합도 지원하고 있다는 걸 깨닫게 됩니다. 우리가 만든 추상함수는 includeHonorific과 includeUsername 둘 다 true인 유스케이스도 지원하고 있습니다. 우리는 존재하는 이 기능이 망가지는 것을 방지하고 후에 누군가 이 유스케이스를 무리 없이 사용할 수도 있도록 테스트 코드를 추가해둡니다. 복잡한 일도 아닙니다. 순수한 함수를 테스트하는 건 아주 간단하니 바로 테스트를 추가해 놓겠죠.
3
그 후, 또 다른 기능 추가 요청이 들어옵니다. 내비게이션에서 이름을 맨 앞 글자(이니셜)만 표시해 달라고 하네요. 추상함수 getDisplayName()으로 돌아가서 지원되지 않는 케이스임을 확인합니다. 테스트도 잘 통과하고 있는 지금의 추상함수를 굳이 지우고 싶지 않습니다. 당연히 원래 있는 기능을 망가뜨리고 싶지도 않습니다. firstInitial 옵션을 추가하고 기본값을 false로 해줍니다. user.name.first 을 first 변수에 받아두고, if문으로 firstInitial이 true일 경우에는 이름으로 첫 글자랑 점을 넣어주도록 합니다.
function getDisplayName(
user,
{
includeHonorific = false,
includeUserName = false,
firstInitial = false,
} = {}
) {
let first = user.name.first;
if (firstInitial) {
first = `${first.slice(0, 1)}.`;
}
let displayName = `${first} ${user.name.last}`;
if (includeHonorific) {
displayName = `${user.name.honorific} ${displayName}`;
}
if (includeUserName) {
displayName = `${displayName} (${user.username})`;
}
return displayName;
}
// navigation.js
// ...
const navDisplayName = getDisplayName(phil, { firstInitial: true });
console.log(navDisplayName); // P. Radriquez
// profile.js
// ...
const profileDisplayName = getDisplayName(phil, { includeHonorific: true });
console.log(profileDisplayName); // Dr. Philp Radriquez
// card.js
// ...
const cardDisplayName = getDisplayName(phil, { includeUserName: true });
console.log(cardDisplayName); // Philp Radriquez (philpr)
이제 cardDisplayName에서 includeUserName을 true로 해줍니다. 이제 우리는 이 모든 유스케이스를 지원하는 추상함수를 갖게 되었습니다. 테스트 몇 개를 더 추가해주겠죠.
여기서 짚고 넘어가고 싶은 2가지가 있는데요.
지금 우리는 추상함수의 3가지 유스케이스가 있고 코드 전체를 통틀어 보면 더 많을 수 있겠죠. 첫 번째는, 이 3가지 케이스는 서로 비슷해 보이지 않는다는 점입니다. 공통점이 없죠. 성과 이름을 보여준다는 정도를 공통점이라고 할 수도 있겠으나 각각은 뚜렷한 차이가 있습니다. 이런 일은 추상함수에서 흔하게 발생합니다. 추상함수는 시간이 지남에 따라 초기 유스케이스를 넘어 진화하기 마련이니까요. 꼭 나쁜 건 아닙니다. 하지만 모든 유스케이스는 서로 상당히 달라졌습니다. 우리가 만든 추상함수는 많은 유스케이스를 지원해주고 있는데 실은 서로 전혀 상관없는 유스케이스들을 지원하고 있는 상황입니다.
이 상황의 또 다른 문제는 실제로 사용하지도 않을 유스케이스에 대해 테스트를 작성한다는 점입니다. 테스트를 작성하는 것 자체는 뭐 그렇게 끔찍한 일은 아닐 수 있습니다. 문제는 리팩토링할 때입니다. 테스트 코드에는 추상함수가 지원하는 모든 기능이 담겨있죠. 리팩토링한 코드는 (사용하지 않는 옵션조합까지) 모든 테스트를 모두 통과해야 할 것입니다. 하지만 그 옵션조합을 신경 쓰는 것은, 테스트뿐입니다. 테스트를 위해 존재하는 코드가 돼버린 것이죠. 아주 쓸모없습니다. 테스트는 본디 우리가 필요로 하는 유스케이스가 지속적으로 잘 지원되고 있는지 확인하기 위함입니다. 한데 지금은 이 코드가 작동하지 않아도 사용자는 아무 상관이 없고 테스트만이 신경 쓰겠죠. 테스트를 삭제해도 신경 쓰는 '사람'은 아무도 없습니다.
생각 없이 추상함수에 기능을 추가하면 곤란한 상황에 빠지게 됩니다.
여기서 끝이 아닙니다. 이 상황에서 프로필 페이지에서 더 이상 honorific 표시를 원하지 않는다는 요청이 들어오면 어떻게 해야 할까요?
// navigation.js
// ...
const navDisplayName = getDisplayName(phil, { firstInitial: true });
console.log(navDisplayName); // P. Radriquez
// profile.js
// ...
const profileDisplayName = getDisplayName(phil);
console.log(profileDisplayName); // Philp Radriquez
// card.js
// ...
const cardDisplayName = getDisplayName(phil, { onlyUsername: true });
console.log(cardDisplayName); // Philp Radriquez (philpr)
어렵지 않아 보이죠. profileDisplayName()에서 넣어준 includeHonorific 옵션만 지우면 되겠네요. 커밋하고 푸시하고 머지됐습니다. 옵션을 지우는 것만으로 완수해서 만족스럽습니다.
이런 일은 비일비재 합니다. 우리는 추상함수에서 honorific option과 honorific에 대해 한정적으로 쓰였던 코드까지 제거할 생각은 안 하죠. 생각은 했더라도 다음의 이유로 제거하길 원하지 않을 수 있습니다. 우선 코드를 이대로 유지하는데 드는 비용은 꽤 작죠. 작게 '느껴'집니다. 반면에 이 코드를 삭제해서 무언가 망가뜨릴 수도 있다는 '리스크'는 크게 느껴집니다. 이렇게 리스크 대비 비용을 해서 우리는 그냥 코드를 유지할 것을 선택합니다. 그냥 내버려 두고 아무것도 망가뜨리지 말자 라는 결론이 나오는 것입니다. (CSS에서도 특히 많이 발생하는 일이죠.) 원래 있던 걸 고치는 대신 차라리 새로 만들어서 사용하고, 절대 원래 있던 걸 삭제하지 않습니다. 이게 진짜 사용되고 있는 건지 아닌지 확인하는 건 너무 어려운 일이기 때문이죠.
또 우리 머릿속에는 항상 '언제 또 honorific을 쓸 일이 생기지 않을까?'하는 생각에 코드를 남기기도 합니다. 이 생각도 문제가 있어요. 우리에게는 git이 있습니다. 언제나 돌아가서 코드가 어땠는지 볼 수 있어요.
코드를 남겨두는 것은 비용이 드는게 맞습니다. 리팩토링 과정에서도 계속 코드를 유지해야 하기 때문입니다. 이 코드는 지금 코드 자신과 테스트 만을 위해 존재합니다. 그러니까 지워도 아무도 문제를 겪지 않습니다.
이번에는 카드에서 사용자 아이디(username)만을 표시해달라고 요청이 왔네요. 이 작업도 어렵지 않죠. 추상함수를 제거하고 phil.username 이라고 보내도 해결이 됩니다. 하지만 이미 만들어놓은 getDisplayName()이 있기 때문에 우리는 이 추상함수를 활용하는 방향으로 이끌립니다. includeUserName 대신에 onlyUserName 옵션을 추가하면 되겠네요.
// navigation.js
// ...
const navDisplayName = getDisplayName(phil, { firstInitial: true });
console.log(navDisplayName); // P. Radriquez
// profile.js
// ...
const profileDisplayName = getDisplayName(phil);
console.log(profileDisplayName); // Philp Radriquez
// card.js
// ...
const cardDisplayName = getDisplayName(phil, { onlyUserName: true });
console.log(cardDisplayName); // philpr
function getDisplayName(
user,
{
includeHonorific = false,
includeUserName = false,
firstInitial = false,
onlyUserName = false,
} = {}
) {
let first = user.name.first;
if (firstInitial) {
first = `${first.slice(0, 1)}.`;
}
let displayName = `${first} ${user.name.last}`;
if (includeHonorific) {
displayName = `${user.name.honorific} ${displayName}`;
}
if (includeUserName) {
displayName = `${displayName} (${user.username})`;
}
if (onlyUserName) {
displayName = user.username;
}
return displayName;
}
이제 우리는 이 새 기능을 지원합니다. 또 우리는 코드 자기 자신과 테스트 외에는 아~무도 신경 쓰지 않는 두 가지 옵션(includeHonorific, includeUsername)도 가지고 있습니다. 우리가 필요로 하는 것보다 더 복잡한 추상함수를 갖게 되었습니다. 리팩토링을 할 수야 있겠지만, 이 쓸데없는 유스케이스의 테스트들도 모두 통과시켜야 하는 상황이죠. 점점 엉망이 되고 있는 겁니다.
이 예시는 문자열이는 간단한 함수였지만, 복잡한 리액트 앵귤러 컴포넌트의 경우나, 당신이 지금 작성하고 있는 무언가를 생각해보세요. 당신이 만들어온 추상함수들을 떠올려 보세요. 돌아보면 다루기 끔찍한 추상함수에 빠져있을 수 있습니다.
4
그렇다면 이 문제를 피하는 방법은 무엇일까요? 이미 이 문제에 빠져버렸다면 어떻게 헤어 나올 수 있을까요? 이게 바로 Sandi Metz가 그녀의 블로그에서 잘못된 추상화(wrong abstraction)에 대해 한 이야기입니다. 이 글을 꼭 읽어보라고 조언하고 싶습니다.
그녀는 가장 빠른 방법은 뒤로 돌아가는 것이라고 말합니다.
1. 모든 호출자(Caller)에 추상화했던 코드를 넣어서 인라인으로 다시 '중복'되게 만들어라.
2. 각 호출자의 매개변수로 실행하는 특정 인라인 코드의 하위 집합을 결정하라.
3. 해당 호출자에게 필요 없는 부분을 지워라.
우리 경우에는 getDisplayName()을 3번 복붙하면 되겠습니다. navDisplayName(), profileDisplayName(), cardDisplayName() 에 각각 붙여 넣어주고 각각에 필요 없는 부분을 찾아 지워보겠습니다.
console.log를 아래 하나 더 써서 현재의 navDisplayNamer과 같은 결과를 만들 건데요. 우리의 함수가 매우 간단하기 때문에 함수를 따로 만들지 않고 필요한 부분만 가져오겠습니다.
const navDisplayName = getDisplayName(phil, { firstInitial: true });
console.log(navDisplayName); // P. Radriquez
console.log(`${first.slice(0, 1)}. ${user.name.last}`); // first, user are not defined
const navDisplayName = getDisplayName(phil, { firstInitial: true });
console.log(navDisplayName); // P. Radriquez
console.log(`${phil.name.first.slice(0, 1)}. ${phil.name.last}`); // P. Radriquez
이제 navigation.js는 더 이상 getDisplayName() 추상함수를 사용하지 하지 않습니다. getDisplayName()코드에서 사용하지 않는 부분을 삭제할 수도 있지만 우선 추상함수가 사용되는 나머지 부분에도 복사해서 중복을 만들어주는 걸 계속해봅시다.
const navDisplayName = `${phil.name.first.slice(0, 1)}. ${phil.name.last}`;
console.log(navDisplayName); // P. Radriquez
const profileDisplayName = `${phil.name.first} ${phil.name.last}`;
console.log(profileDisplayName); // Philip Radriquez
const cardDisplayName = phil.username;
console.log(cardDisplayName); // philpr
이제 우리는 이전과 완전히 동일한 결과를 갖게 되었습니다. 그리고 아래의 장황한 추상함수는 우리의 코드에서 완전히 삭제할 수 있습니다.
function getDisplayName(
user,
{
includeHonorific = false,
includeUserName = false,
firstInitial = false,
onlyUserName = false,
} = {}
) {
let first = user.name.first;
if (firstInitial) {
first = `${first.slice(0, 1)}.`;
}
let displayName = `${first} ${user.name.last}`;
if (includeHonorific) {
displayName = `${user.name.honorific} ${displayName}`;
}
if (includeUserName) {
displayName = `${displayName} (${user.username})`;
}
if (onlyUserName) {
displayName = user.username;
}
return displayName;
}
당신은 이 과정을 거치면서 지금과 같이 한 줄로 끝날 수도 또는 아주 약간씩만 다른 왕 큰 컴포넌트를 네 군데 복사 붙여넣기 했을 수도 있겠습니다. 4개 복사본을 보면서 그들의 공통점이 보일 수 있습니다. 드롭다운이 되는 거, 안되는 거 이렇게 두 개로 나누어질 수도 있겠죠. 하지만 우리는 어떤 경우라도 이 들을 별개의 것으로 유지할 것입니다.
5
우리는 추상화의 라이프사이클을 겪었고 이제 당신은 추상함수들의 공통점과 차이점을 알아볼 수 있을 것입니다. 기존의 코드에도 잘 동작하는 추상함수를 더 잘 만들 수 있게 되었습니다. 또, 나쁜 추상화와 싸우며 얻은 흉터 덕분에 추상화에 대해서 더 신중하게 접근하게 되었습니다.
핵심사항을 정리하며 마무리하겠습니다.
DRY(Don't Repeat Yourself)가 꼭 나쁜 것은 아닙니다. 스스로 반복하지 않는 것은 비즈니스 로직의 버그나 오타를 한 군데서 없앨 수 있게 해 주기 때문에 이론적으로 좋은 아이디어입니다. 중복을 피하는걸 정말 도와줄 수 있죠. 중복이 본질적으로 나쁜 것은 아니지만 문제가 될 수는 있습니다. 버그를 사방팔방으로 전파하는 일이 될 수도 있기 때문이죠.
또 우리는 미래에 대해 알 수 없기 때문에 우리가 실제로 최적화해야 할 것은 변경사항입니다. Sandi Mintz는 '중복은 잘못된 추상화보다 비용이 낮다. 그러므로 잘못된 추상화보다는 중복을 선호하자.'라고 말했습니다.
그것은 마치 여러 버그를 사방에 전파하는 것과 같을 수 있습니다. 우리는 미래를 알 수 없으므로 실제로 최적화해야 할 것은 변경뿐입니다. 저는 이 말에 진심으로 동의합니다. 중복을 만들어놓고 그 중복된 코드 안에 공통된 부분들이 당신을 향해 추상화해달라고 비명을 지르기까지 기다린다면, 추상화는 더욱 분명해질 것이고 당신은 그 시점에 필요한 유스케이스들을 대상으로 올바른 추상함수를 작성할 수 있을 것입니다. 여기저기 추상화할 부분이 발견되더라도 복사 붙여넣기하고 중복되도록 내버려 두세요. 그러면 처음 추상화하고 싶었을 때 생각했던 것만큼 비슷하지 않기 때문에 별개의 것으로 내버려 두길 잘했다는 생각이 들 수 있습니다. 또는 정말로 공통점이 많다면 추상함수를 만들기로 결정할 수도 있겠죠. 만약 많은 브랜치와 코드를 공유하는 부분이 있다면 여기에 더 조건을 추가하고 싶은 충동을 이겨내고 대신 리팩토링부터 먼저 할 것을 추천합니다. 해당 추상화의 사용자에 대해 가능한 모든 것을 둘러보고 익히는 방법으로 추상화의 전체적인 그림을 이해할 수 있을 것입니다. 그러면 그 추상화를 더 관리하기 쉽게 여러 조각으로 쪼갤 수도 있을 것입니다.
감사합니다.
'General' 카테고리의 다른 글
[테코톡 요약] 우아한테크코스 3기 레벨1 테코톡 모아보기 (3) | 2021.02.18 |
---|---|
Git - 자주 사용하는 명령어 모음 (0) | 2021.02.12 |
엑셀 자동화 - 구글 스프레드시트 매크로로 데일리 플래너 만들기 (83) | 2021.01.28 |
VS Code 필수 초기 설정 - 기본설정부터 익스텐션까지 (0) | 2021.01.26 |
맥북 자동화 - 키보드 마에스트로(Keyboard Maestro) 시작하기 (0) | 2021.01.25 |