Rails 8 + ViewComponent로 만든 SVG 기반 토너먼트 대진표에 인터랙션을 추가하면서 겪은 내용을 정리했다.
목표는 간단했다: 대진표에서 특정 선수를 클릭하면 그 선수가 출전하는 모든 경기 카드를 색상으로 강조하기.
배경: SVG로 렌더링된 대진표
이 프로젝트의 대진표는 HTML div 카드가 아닌 SVG로 렌더링된다. BracketTreeComponent (ViewComponent)가 각 경기 슬롯 좌표를 계산해 SVG <rect>, <text>, <circle> 등으로 출력한다.
<%# bracket_tree_component.html.erb %>
<svg width="<%= svg_width %>" height="<%= svg_height %>">
<% slots.each do |slot| %>
<% x = x_position(slot.round) %>
<% y = y_position(slot) %>
<g id="bracket_slot_<%= slot.id %>">
<rect x="<%= x %>" y="<%= y %>" width="216" height="88" rx="10" fill="#fff" />
<text x="<%= x + 46 %>" y="<%= y + 42 %>"><%= team_a_name %></text>
<text x="<%= x + 46 %>" y="<%= y + 70 %>"><%= team_b_name %></text>
</g>
<% end %>
</svg>
SVG는 HTML과 달리 hover:, ring- 같은 Tailwind 클래스가 직접 먹히지 않는다. 그래서 처음엔 어떻게 접근할지 고민이 됐다.
설계: 세 가지 레이어
Stimulus + SVG 조합에서 인터랙션은 세 개의 레이어로 분리하면 깔끔하다.
1. 데이터 레이어 — 참가자 ID 임베딩
각 경기 <g> 태그에 선수 ID를 data 속성으로 박아둔다.
# bracket_tree_component.rb
def team_participant_ids(slot, team_side)
return [] if slot.bye?
match = slot.match
return [] unless match
match.public_send("#{team_side}_players").filter_map(&:participant_id)
end
<% a_ids = team_participant_ids(slot, :team_a).join(",") %>
<% b_ids = team_participant_ids(slot, :team_b).join(",") %>
<g id="bracket_slot_<%= slot.id %>"
data-bracket-highlight-target="slot"
data-bracket-highlight-team-a-ids="<%= a_ids %>"
data-bracket-highlight-team-b-ids="<%= b_ids %>">
2. 시각 레이어 — 숨겨진 하이라이트 rect
각 팀 행 위치에 맞는 반투명 인디고 <rect>를 미리 그려두고 기본값은 display:none으로 숨긴다. 이게 클릭 시 켜지는 강조 밴드다.
SVG 렌더 순서(painter’s algorithm)에 따라 반드시 흰색 배경 rect 다음, 텍스트 content 이전에 삽입해야 텍스트 위를 가리지 않는다.
<%# 배경 rect 이후, 텍스트 이전 %>
<rect class="bracket-player-hl-a"
x="<%= x + 3 %>" y="<%= y + 24 %>"
width="<%= MATCH_WIDTH - 3 %>" height="25"
fill="rgba(99,102,241,0.12)"
style="display:none; pointer-events:none" />
<rect class="bracket-player-hl-b"
x="<%= x + 3 %>" y="<%= y + 49 %>"
width="<%= MATCH_WIDTH - 3 %>" height="32"
fill="rgba(99,102,241,0.12)"
style="display:none; pointer-events:none" />
3. 클릭 레이어 — 투명 rect 오버레이
텍스트와 아바타 위에 fill="transparent" rect를 올린다. 클릭 이벤트를 받는 전용 레이어다. SVG 렌더 순서상 그룹의 가장 마지막에 위치해야 모든 요소 위에 오버레이된다.
<%# 그룹 마지막에 %>
<% if a_ids.present? %>
<rect x="<%= x + 3 %>" y="<%= y + 24 %>"
width="<%= MATCH_WIDTH - 3 %>" height="25"
fill="transparent" style="cursor:pointer"
data-action="click->bracket-highlight#selectTeam"
data-bracket-highlight-ids-param="<%= a_ids %>" />
<% end %>
<% if b_ids.present? %>
<rect x="<%= x + 3 %>" y="<%= y + 49 %>"
width="<%= MATCH_WIDTH - 3 %>" height="32"
fill="transparent" style="cursor:pointer"
data-action="click->bracket-highlight#selectTeam"
data-bracket-highlight-ids-param="<%= b_ids %>" />
<% end %>
Stimulus 컨트롤러
컨트롤러 로직은 단순하다. 클릭된 ID 목록을 기억하고, 전체 슬롯을 순회하며 해당 ID를 포함하는 슬롯의 하이라이트 rect를 보여준다.
// bracket_highlight_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["slot"]
connect() {
this.selectedIds = null
}
selectTeam(event) {
event.stopPropagation()
const ids = (event.params.ids || "").split(",").filter(Boolean)
if (!ids.length) return
// 같은 선수 재클릭 → 해제
if (this.#sameSelection(ids)) {
this.selectedIds = null
} else {
this.selectedIds = ids
}
this.#applyHighlights()
}
#sameSelection(ids) {
if (!this.selectedIds) return false
const sort = (arr) => [...arr].sort().join(",")
return sort(ids) === sort(this.selectedIds)
}
#applyHighlights() {
this.slotTargets.forEach((slot) => {
const aIds = (slot.dataset.bracketHighlightTeamAIds || "").split(",").filter(Boolean)
const bIds = (slot.dataset.bracketHighlightTeamBIds || "").split(",").filter(Boolean)
const aMatch = this.selectedIds?.some((id) => aIds.includes(id)) ?? false
const bMatch = this.selectedIds?.some((id) => bIds.includes(id)) ?? false
slot.querySelector(".bracket-player-hl-a")?.style.setProperty("display", aMatch ? "" : "none")
slot.querySelector(".bracket-player-hl-b")?.style.setProperty("display", bMatch ? "" : "none")
})
}
}
배포 전 테스트 수정에서 겪은 것들
SVG 기능 작업 전에 bin/rails test를 돌렸더니 7개 실패가 나왔다. 타입이 각기 달랐다.
1. 리다이렉트 경로 불일치
로그인 후 root_path로 리다이렉트된다고 테스트가 가정했는데, 실제 컨트롤러는 로그인 여부에 따라 dashboard_path로 분기했다. 테스트 기대값을 실제 동작에 맞게 수정.
2. ViewComponent 테스트 데이터 타입
컴포넌트 템플릿이 player[:name]으로 접근하는데, 테스트는 ["이름1", "이름2"] 문자열 배열로 넘겼다. 해시 배열 [{ name: "이름1" }]으로 수정.
# 수정 전
players: ["이름1", "이름2"]
# 수정 후
players: [{ name: "이름1" }, { name: "이름2" }]
3. Settings 페이지 게스트 접근
before_action :authenticate_user_or_participant!가 게스트를 enter_path로 보내고 있었다. 설정 페이지는 게스트도 볼 수 있어야 하고(회원가입 안내 표시), 수정(PATCH)만 막으면 됐다.
class SettingsController < ApplicationController
skip_before_action :authenticate_user!, raise: false
before_action :require_user!, only: [:update]
end
4. 네이티브 앱 게스트 리다이렉트
Hotwire Native 앱에서 미로그인 상태로 접근하면 enter_path로 보내던 것을 new_session_path로 통일.
배운 점
- SVG 인터랙션은 레이어 순서가 전부다. 배경 → 하이라이트 밴드 → 콘텐츠 → 투명 클릭 오버레이 순서를 지켜야 한다.
- SVG 요소에도
data-*속성과 Stimulus가 그대로 동작한다. 특별한 설정 없이data-action,data-controller,data-*-target이 모두 작동한다. fill="transparent"는 클릭 이벤트를 받는다.fill="none"은 클릭 이벤트가 안 통과할 수 있으므로 주의.- 배포 전 테스트 전수 실행은 필수다. “구현했다"와 “테스트가 통과한다"는 다른 문제다.
💬 댓글
비밀번호를 기억해두면 나중에 내 댓글을 삭제할 수 있어요.