창 정렬에 10초도 아까운 개발자를 위한 Hammerspoon 자동화 레시피

매일 반복되는 창 정렬 작업, Lua 스크립트 한 줄로 끝내기


개발하다 보면 터미널, 브라우저, 에디터, Slack을 매번 같은 위치에 배치하는 작업을 하루에도 수십 번 반복하게 돼요. macOS의 Mission Control이나 Split View로는 한계가 있고, 매번 드래그하는 시간도 아까워요. Hammerspoon은 이런 반복 작업을 Lua 스크립트로 자동화할 수 있는 macOS 전용 도구예요.


-- init.lua 파일에 작성
-- VS Code는 왼쪽 2/3, 터미널은 오른쪽 1/3 배치
local function devLayout()
  local vscode = hs.application.get("Code")
  local terminal = hs.application.get("Terminal")
  
  if vscode then
    -- 화면의 왼쪽 66%에 배치
    vscode:mainWindow():moveToUnit({x=0, y=0, w=0.66, h=1})
  end
  
  if terminal then
    -- 화면의 오른쪽 34%에 배치
    terminal:mainWindow():moveToUnit({x=0.66, y=0, w=0.34, h=1})
  end
end

-- cmd+alt+d 누르면 개발 레이아웃 즉시 적용
hs.hotkey.bind({"cmd", "alt"}, "d", devLayout)


노란색 그라데이션 배경에 검은색 망치가 그려진 Hammerspoon 앱 아이콘. macOS에서 Lua 스크립팅을 통한 시스템 자동화를 제공하는 오픈소스 도구의 공식 로고

화면을 격자로 나누어 정밀하게 창 배치하기


hs.grid 모듈을 사용하면 화면을 원하는 크기의 격자로 나눌 수 있어요. 픽셀 단위로 계산할 필요 없이 격자 좌표만 지정하면 돼요.


-- 화면을 12x4 격자로 분할 (가로 12칸, 세로 4칸)
hs.grid.setGrid('12x4')
hs.grid.setMargins(hs.geometry.size(5, 5))  -- 창 사이 여백 5픽셀

-- 브라우저를 상단 왼쪽 6x2 영역에 배치
hs.hotkey.bind({"cmd", "shift"}, "1", function()
  local chrome = hs.application.get("Google Chrome")
  if chrome then
    local win = chrome:focusedWindow()
    hs.grid.set(win, '0,0 6x2')  -- x,y 위치와 너비x높이
  end
end)

-- Slack을 하단 오른쪽 4x2 영역에 배치
hs.hotkey.bind({"cmd", "shift"}, "2", function()
  local slack = hs.application.get("Slack")
  if slack then
    local win = slack:focusedWindow()
    hs.grid.set(win, '8,2 4x2')  -- 오른쪽 하단 코너
  end
end)


격자 시스템의 장점은 다양한 화면 크기에서도 비율이 유지된다는 거예요. 외부 모니터를 연결하거나 맥북 화면으로 전환해도 레이아웃이 자동으로 조정돼요.


작업 컨텍스트별로 자동 전환되는 레이아웃 구성


코딩할 때, 문서 작업할 때, 화상회의할 때마다 필요한 창 배치가 달라요. 각 상황에 맞는 레이아웃을 미리 정의해두면 단축키 하나로 작업 환경을 전환할 수 있어요.


-- 레이아웃 정의를 테이블로 관리
local layouts = {
  -- 코딩 모드: 에디터 중심
  coding = {
    {"Code", nil, nil, {x=0, y=0, w=0.7, h=1}},
    {"Terminal", nil, nil, {x=0.7, y=0, w=0.3, h=0.5}},
    {"Safari", nil, nil, {x=0.7, y=0.5, w=0.3, h=0.5}}
  },
  
  -- 리서치 모드: 브라우저 중심
  research = {
    {"Safari", nil, nil, {x=0, y=0, w=0.5, h=1}},
    {"Notion", nil, nil, {x=0.5, y=0, w=0.5, h=0.7}},
    {"Notes", nil, nil, {x=0.5, y=0.7, w=0.5, h=0.3}}
  },
  
  -- 회의 모드: 화상회의 앱과 필기 앱
  meeting = {
    {"zoom.us", nil, nil, {x=0, y=0, w=0.75, h=1}},
    {"Notion", nil, nil, {x=0.75, y=0, w=0.25, h=1}}
  }
}

-- 레이아웃 적용 함수
local function applyLayout(layoutName)
  local layout = layouts[layoutName]
  if layout then
    for _, appLayout in ipairs(layout) do
      local appName = appLayout[1]
      local app = hs.application.get(appName)
      
      if app then
        local win = app:mainWindow()
        if win then
          win:moveToUnit(appLayout[4])
        end
      end
    end
    -- 알림 표시
    hs.alert.show(layoutName .. " 레이아웃 적용됨", 1)
  end
end

-- 각 레이아웃별 단축키 설정
hs.hotkey.bind({"cmd", "alt"}, "c", function() applyLayout("coding") end)
hs.hotkey.bind({"cmd", "alt"}, "r", function() applyLayout("research") end)
hs.hotkey.bind({"cmd", "alt"}, "m", function() applyLayout("meeting") end)


특정 앱이 실행될 때 자동으로 창 배치하기


앱이 실행될 때마다 자동으로 정해진 위치로 이동시킬 수도 있어요. Spotify는 항상 오른쪽 하단, Finder는 중앙에 배치하는 식으로 규칙을 만들 수 있어요.


-- 앱별 기본 위치 설정
local appPositions = {
  ["Spotify"] = {x=0.6, y=0.5, w=0.4, h=0.5},
  ["Finder"] = {x=0.2, y=0.2, w=0.6, h=0.6},
  ["Calendar"] = {x=0.7, y=0, w=0.3, h=0.5},
  ["Messages"] = {x=0.7, y=0.5, w=0.3, h=0.5}
}

-- 앱 실행 감지 워처 설정
appWatcher = hs.application.watcher.new(function(appName, eventType, appObject)
  -- 앱이 실행되거나 활성화될 때
  if eventType == hs.application.watcher.launched or 
     eventType == hs.application.watcher.activated then
    
    local position = appPositions[appName]
    if position then
      -- 0.5초 후에 창 배치 (앱이 완전히 로드되기를 기다림)
      hs.timer.doAfter(0.5, function()
        local app = hs.application.get(appName)
        if app then
          local win = app:mainWindow()
          if win then
            win:moveToUnit(position)
          end
        end
      end)
    end
  end
end)

-- 워처 시작
appWatcher:start()


모니터 연결 상태에 따라 다른 레이아웃 적용


외부 모니터를 연결했을 때와 맥북 단독으로 사용할 때 창 배치를 다르게 설정할 수 있어요. 화면 변화를 감지해서 자동으로 레이아웃을 전환해요.


-- 모니터별 레이아웃 정의
local screenLayouts = {
  -- 맥북 화면만 있을 때
  laptop = function()
    local screen = hs.screen.mainScreen()
    hs.window.focusedWindow():moveToScreen(screen)
    applyLayout("coding")  -- 위에서 정의한 레이아웃 사용
  end,
  
  -- 외부 모니터 연결 시
  external = function()
    local screens = hs.screen.allScreens()
    if #screens > 1 then
      -- 메인 앱은 외부 모니터로
      local external = screens[2]  -- 보통 외부 모니터가 두 번째
      
      local vscode = hs.application.get("Code")
      if vscode then
        vscode:mainWindow():moveToScreen(external)
        vscode:mainWindow():maximize()
      end
      
      -- 보조 앱은 맥북 화면으로
      local laptop = screens[1]
      local slack = hs.application.get("Slack")
      if slack then
        slack:mainWindow():moveToScreen(laptop)
        slack:mainWindow():moveToUnit({x=0, y=0, w=0.5, h=1})
      end
    end
  end
}

-- 화면 변화 감지
screenWatcher = hs.screen.watcher.new(function()
  local screens = hs.screen.allScreens()
  if #screens == 1 then
    screenLayouts.laptop()
  else
    screenLayouts.external()
  end
end)

screenWatcher:start()


창 크기를 단계적으로 조절하는 스마트 리사이징


같은 단축키를 여러 번 누르면 창 크기가 단계적으로 변하도록 설정할 수 있어요. 예를 들어 cmd+alt+right를 누를 때마다 창이 50% → 66% → 75% 크기로 늘어나요.


-- 창 크기 사이클 정의
local sizeCycles = {
  right = {
    {x=0.5, y=0, w=0.5, h=1},    -- 오른쪽 절반
    {x=0.33, y=0, w=0.67, h=1},   -- 오른쪽 2/3
    {x=0.25, y=0, w=0.75, h=1}    -- 오른쪽 3/4
  },
  left = {
    {x=0, y=0, w=0.5, h=1},       -- 왼쪽 절반
    {x=0, y=0, w=0.67, h=1},      -- 왼쪽 2/3
    {x=0, y=0, w=0.75, h=1}       -- 왼쪽 3/4
  }
}

-- 현재 창 상태 저장
local windowStates = {}

-- 사이클 함수
local function cycleWindowSize(direction)
  local win = hs.window.focusedWindow()
  if not win then return end
  
  local id = win:id()
  local cycle = sizeCycles[direction]
  
  -- 현재 상태 확인, 없으면 0으로 시작
  if not windowStates[id] then
    windowStates[id] = {}
  end
  
  if not windowStates[id][direction] then
    windowStates[id][direction] = 0
  end
  
  -- 다음 크기로 이동
  windowStates[id][direction] = (windowStates[id][direction] % #cycle) + 1
  local newSize = cycle[windowStates[id][direction]]
  
  win:moveToUnit(newSize)
end

-- 단축키 설정
hs.hotkey.bind({"cmd", "alt"}, "right", function() 
  cycleWindowSize("right") 
end)

hs.hotkey.bind({"cmd", "alt"}, "left", function() 
  cycleWindowSize("left") 
end)


창 포커스를 앱 이름으로 빠르게 전환


마우스를 사용하지 않고 키보드만으로 원하는 앱으로 포커스를 이동할 수 있어요. cmd+ctrl+s를 누르면 Slack으로, cmd+ctrl+c를 누르면 Chrome으로 바로 전환돼요.


-- 앱 전환 단축키 매핑
local appHotkeys = {
  s = "Slack",
  c = "Google Chrome", 
  v = "Code",
  t = "Terminal",
  n = "Notion",
  f = "Finder",
  m = "Messages"
}

-- 단축키 등록
for key, appName in pairs(appHotkeys) do
  hs.hotkey.bind({"cmd", "ctrl"}, key, function()
    local app = hs.application.get(appName)
    
    if app then
      app:activate()  -- 앱 활성화
      
      -- 앱이 숨겨져 있으면 보이게 함
      if app:isHidden() then
        app:unhide()
      end
      
      -- 최소화된 창이 있으면 복원
      local win = app:mainWindow()
      if win and win:isMinimized() then
        win:unminimize()
      end
    else
      -- 앱이 실행되지 않았으면 실행
      hs.application.launchOrFocus(appName)
    end
  end)
end


현재 창 배치를 스냅샷으로 저장하고 복원하기


작업 중인 창 배치를 그대로 저장했다가 나중에 똑같이 복원할 수 있어요. 프로젝트별로 창 배치를 저장해두면 컨텍스트 스위칭이 훨씬 빨라져요.


-- 창 스냅샷 저장소
local snapshots = {}

-- 현재 창 배치 저장
local function saveSnapshot(name)
  snapshots[name] = {}
  
  -- 모든 보이는 창 정보 저장
  local windows = hs.window.visibleWindows()
  for _, win in ipairs(windows) do
    local app = win:application()
    if app then
      local frame = win:frame()
      table.insert(snapshots[name], {
        app = app:name(),
        title = win:title(),
        frame = frame
      })
    end
  end
  
  -- 파일로 저장 (재시작 후에도 유지)
  hs.settings.set("snapshots", snapshots)
  hs.alert.show("'" .. name .. "' 스냅샷 저장됨", 2)
end

-- 스냅샷 복원
local function restoreSnapshot(name)
  local snapshot = snapshots[name]
  if not snapshot then
    -- 저장된 설정에서 불러오기
    snapshots = hs.settings.get("snapshots") or {}
    snapshot = snapshots[name]
  end
  
  if snapshot then
    for _, winInfo in ipairs(snapshot) do
      local app = hs.application.get(winInfo.app)
      if app then
        -- 타이틀이 일치하는 창 찾기
        for _, win in ipairs(app:allWindows()) do
          if win:title() == winInfo.title then
            win:setFrame(winInfo.frame)
            break
          end
        end
      end
    end
    hs.alert.show("'" .. name .. "' 스냅샷 복원됨", 2)
  end
end

-- 단축키로 스냅샷 관리
hs.hotkey.bind({"cmd", "shift", "ctrl"}, "s", function()
  saveSnapshot("project1")
end)

hs.hotkey.bind({"cmd", "shift", "ctrl"}, "r", function()
  restoreSnapshot("project1")
end)


Hammerspoon의 진짜 강점은 이 모든 기능을 자유롭게 조합하고 확장할 수 있다는 거예요. WiFi 연결, 시간대, 배터리 상태 등 다양한 조건에 따라 창 배치를 자동화할 수 있어요. 한 번 설정해두면 창 정렬에 쓰던 시간을 완전히 없앨 수 있어요.


macOS에서 Shell Script로 폴더별 작업 일지 자동 생성하는 방법