
데이터를 “표”로만 보다 보면, 사람과 사람, 계정과 계정처럼 관계가 본질인 문제에서 한계가 옵니다.
테이블 두 개와 외래키로도 구현은 가능하지만, “A가 B를 팔로우하고, B가 C를 팔로우할 때 A와 C 사이에 어떤 경로가 있는지”를 여러 단계로 따라가려면 JOIN이 늘어나고, 홉이 많아질수록 쿼리와 성능이 무거워집니다.
이런 맥락에서 그래프 데이터베이스는 “노드”와 “관계”를 일급 시민으로 두고, 관계 탐색에 특화되어 있습니다.
Neo4j를 Spring Boot에 붙여 SNS 계정·팔로우 네트워크를 다루는 흐름을 서술하고, 같은 도메인을 RDB 구현한 Account Network 프로젝트와 엮어서 정리하였습니다.
그래프 DB는 노드 와 관계 로 구성됩니다.
노드는 “무언가 하나”를 나타내고 예를들어 사람, 계정, 장소 , 관계는 “두 노드 사이의 연결”을 의미합니다.
노드에는 종류과 속성가 있고, 관계에는 관계 종류과 방향이 있습니다.
예를 들어 “궁금하면 500원”과 “leeky1004”가 친구라면, Cypher로는 다음과 같이 작성하실 수 있습니다.
CREATE (p:Person {name: 'curious? 500 won', age: 10});
CREATE (q:Person {name: 'leeky1004', age: 12});
MATCH (a:Person {name: 'curious? 500 won'}), (b:Person {name: 'xxxjjhhh'})
CREATE (a)-[:FRIENDS]->(b);
RDB로도 PERSON 테이블과 FRIENDS 테이블을 두고 구현할 수 있습니다.
다만 “2홉, 3홉 떨어진 친구”를 찾거나, “이 사람에서 저 사람까지의 모든 경로”를 보려면 JOIN이 반복되고, 인덱스를 잘 설계해도 그래프 형태의 탐색에는 그래프 DB가 더 자연스럽습니다. Neo4j는 이런 그래프 모델을 네이티브로 지원하며, 트랜잭션도 지원해서 Spring과 함께 사용하실 때 무리 없이 통합할 수 있습니다.
스타/연예인 연관 관계, 지식 그래프(Knowledge Graph), RAG용 그래프, 완제품–모듈–부품 같은 계층·연결 구조, 행동 패턴 기반 이상 탐지 등에 활용됩니다.
여기서는 “계정” 노드와 “팔로우” 관계만으로 SNS 계정 네트워크를 만든다고 가정합니다.
Spring Boot 3.x 기준으로 spring-boot-starter-data-neo4j를 넣고, application.properties에 URI·사용자명·비밀번호만 넣으면 기본 연결이 됩니다.
연결 주소는 bolt://호스트:7687 형태로 두시는 것이 좋습니다.
필요하면 Neo4jConfig에서 Driver, Neo4jClient, Neo4jTransactionManager 빈을 직접 정의해 두시면, 나중에 Cypher를 직접 실행하거나 트랜잭션 경계를 명확히 하실 때 유리합니다.
Neo4j 브라우저는 http://호스트:7474로 접속하시면, Cypher를 직접 실행해 보시거나 결과를 시각화하실 수 있습니다.
로그인 실패 시 일정 시간 락이 걸릴 수 있으니, 비밀번호는 한 번에 맞춰 두시는 편이 좋습니다.
Account Network 프로젝트는 Neo4j 대신 Oracle을 사용하므로, 설정은 다음과 같이 RDB용으로 되어 있습니다.
# account-network/backend/src/main/resources/application.yml
spring:
datasource:
url: jdbc:oracle:thin:@//host:1521/service
username: user
password: password
driver-class-name: oracle.jdbc.OracleDriver
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
format_sql: true
default_schema: YOUR_SCHEMA
database-platform: org.hibernate.dialect.OracleDialect
server:
port: 8080
servlet:
context-path: /api
Neo4j를 사용하실 경우에는 spring.neo4j.uri=bolt://localhost:7687, spring.neo4j.authentication.username, spring.neo4j.authentication.password 등을 설정하시면 됩니다.
Neo4j의 “계정”을 하나의 노드로 두고, “A가 B를 팔로우”를 관계 하나로 두면, 스프링에서는 @Node, @Relationship으로 매핑하실 수 있습니다.
Account Network에서는 그래프 DB 대신 RDB를 사용하므로, 도메인 모델은 저장소에 의존하지 않는 순수한 형태로 두고, JPA 엔티티로 노드·관계를 표현합니다.
도메인에는 “계정”과 “그래프”만 둡니다.
// account-network/backend/.../domain/Account.java
public final class Account {
private final Long id;
private final String username;
private final Set<String> following;
private final Set<String> followers;
private Account(Long id, String username, Set<String> following, Set<String> followers) {
this.id = id;
this.username = Objects.requireNonNull(username);
this.following = following == null ? Set.of() : Set.copyOf(following);
this.followers = followers == null ? Set.of() : Set.copyOf(followers);
}
public static Account of(Long id, String username, Set<String> following, Set<String> followers) {
return new Account(id, username, following, followers);
}
public static Account create(String username) {
return new Account(null, username, Set.of(), Set.of());
}
public Long getId() { return id; }
public String getUsername() { return username; }
public Set<String> getFollowing() { return Collections.unmodifiableSet(following); }
public Set<String> getFollowers() { return Collections.unmodifiableSet(followers); }
}
// account-network/backend/.../domain/Graph.java
public final class Graph {
private final List<NodeView> nodes;
private final List<EdgeView> edges;
private Graph(List<NodeView> nodes, List<EdgeView> edges) {
this.nodes = List.copyOf(Objects.requireNonNull(nodes));
this.edges = List.copyOf(Objects.requireNonNull(edges));
}
public static Graph of(List<NodeView> nodes, List<EdgeView> edges) {
return new Graph(nodes, edges);
}
public List<NodeView> getNodes() { return nodes; }
public List<EdgeView> getEdges() { return edges; }
public record NodeView(Long id, String username) {}
public record EdgeView(Long startId, Long endId) {}
}
Neo4j에서는 @Node("Account"), @Relationship(type = "FOLLOWS", direction = OUTGOING)으로 매핑합니다.
RDB 버전인 Account Network에서는 ACCOUNT 테이블과 FOLLOW 조인 테이블로 같은 구조를 표현합니다.
// account-network/backend/.../infrastructure/persistence/AccountJpa.java
@Entity
@Table(name = "ACCOUNT")
public class AccountJpa {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@ManyToMany
@JoinTable(
name = "FOLLOW",
joinColumns = @JoinColumn(name = "FROM_ACCOUNT_ID"),
inverseJoinColumns = @JoinColumn(name = "TO_ACCOUNT_ID")
)
private Set<AccountJpa> following = new HashSet<>();
@ManyToMany(mappedBy = "following")
private Set<AccountJpa> followers = new HashSet<>();
// getter/setter 생략
}
계정 생성은 DTO에서 username만 받아 도메인 Account를 만들어 저장하시면 됩니다. 관계 생성은 start, end username으로 두 노드를 조회한 뒤, start 쪽의 following에 end를 넣고 저장하시면 됩니다.
Account Network의 REST API와 DTO·컨트롤러 예시는 다음과 같습니다.
// account-network/backend/.../interfaces/dto/AccountRequestDto.java
public record AccountRequestDto(@NotBlank String username) {}
// account-network/backend/.../interfaces/dto/AccountResponseDto.java
public record AccountResponseDto(String username, Set<String> following, Set<String> followers) {}
// account-network/backend/.../interfaces/dto/RelationRequestDto.java
public record RelationRequestDto(@NotBlank String start, @NotBlank String end) {}
{ "username": "alice" } → 계정 생성 { "start": "alice", "end": "bob" } → alice가 bob을 팔로우// account-network/backend/.../interfaces/web/NodeController.java
@RestController
@RequestMapping("/node")
public class NodeController {
private final CreateAccountUseCase createAccountUseCase;
private final GetAccountUseCase getAccountUseCase;
@PostMapping
public ResponseEntity<Void> createNode(@RequestBody @Valid AccountRequestDto dto) {
createAccountUseCase.execute(dto.username());
return ResponseEntity.status(HttpStatus.CREATED).build();
}
@GetMapping("/{username}")
public ResponseEntity<AccountResponseDto> getNode(@PathVariable String username) {
return getAccountUseCase.execute(username)
.map(this::toResponse)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
private AccountResponseDto toResponse(Account account) {
return new AccountResponseDto(
account.getUsername(),
account.getFollowing(),
account.getFollowers()
);
}
}
// account-network/backend/.../interfaces/web/RelationController.java
@RestController
@RequestMapping("/relationship")
public class RelationController {
private final CreateFollowUseCase createFollowUseCase;
@PostMapping
public ResponseEntity<Void> createRelationship(@RequestBody @Valid RelationRequestDto dto) {
createFollowUseCase.execute(dto.start(), dto.end());
return ResponseEntity.ok().build();
}
}
Neo4j를 사용하실 때 GET /node/{username} 응답에서 following/followers가 빈 배열로만 나오는 경우는, @Query와 OPTIONAL MATCH만으로는 양방향 참조·순환 참조·collect 매핑 이슈가 생길 수 있기 때문입니다.
실무에서는 Neo4jClient로 Cypher를 실행하고, Record를 DTO로 직접 매핑하는 방식이 안정적입니다. Account Network처럼 RDB + 유스케이스에서 도메인을 반환하고 DTO로 변환하시면, 이 문제를 피하실 수 있습니다.
“모든 계정 노드”와 “모든 팔로우 관계”를 한 번에 가져와서 화면에 그리려면, 서비스에서 노드와 FOLLOWS 관계를 한 번에 로드하는 쿼리를 사용하고, 그 결과를 노드 리스트와 간선 리스트로 가공하시면 됩니다.
노드는 id, username, 간선은 start, end만 있으면 됩니다.
// account-network/backend/.../interfaces/dto/GraphResponseDto.java
public record GraphResponseDto(List<NodeDto> nodes, List<EdgeDto> edges) {
public record NodeDto(Long id, String username) {}
public record EdgeDto(Long start, Long end) {}
}
// account-network/backend/.../interfaces/web/GraphController.java
@RestController
@RequestMapping("/graph")
public class GraphController {
private final GetGraphUseCase getGraphUseCase;
@GetMapping
public ResponseEntity<GraphResponseDto> getGraph() {
Graph graph = getGraphUseCase.execute();
List<GraphResponseDto.NodeDto> nodes = graph.getNodes().stream()
.map(n -> new GraphResponseDto.NodeDto(n.id(), n.username()))
.collect(Collectors.toList());
List<GraphResponseDto.EdgeDto> edges = graph.getEdges().stream()
.map(e -> new GraphResponseDto.EdgeDto(e.startId(), e.endId()))
.collect(Collectors.toList());
return ResponseEntity.ok(new GraphResponseDto(nodes, edges));
}
}
// account-network/backend/.../infrastructure/persistence/GraphQueryAdapter.java
@Component
public class GraphQueryAdapter implements GraphQueryPort {
private final AccountJpaRepository jpaRepository;
@Override
@Transactional(readOnly = true)
public Graph loadGraph() {
List<AccountJpa> all = jpaRepository.findAllWithFollowing();
List<Graph.NodeView> nodes = all.stream()
.map(a -> new Graph.NodeView(a.getId(), a.getUsername()))
.collect(Collectors.toList());
List<Graph.EdgeView> edges = new ArrayList<>();
for (AccountJpa a : all) {
for (AccountJpa f : a.getFollowing()) {
edges.add(new Graph.EdgeView(a.getId(), f.getId()));
}
}
return Graph.of(nodes, edges);
}
}
Spring Boot에서 머스태치 같은 뷰 템플릿을 사용하실 때는 컨트롤러가 nodes와 edges를 Model에 담아 뷰에 넘기고, 뷰에서는 vis-network로 네트워크 그래프를 그리실 수 있습니다.
Account Network는 React + TypeScript + Vite + vis-network로 같은 방식을 사용합니다.
// account-network/frontend/src/pages/GraphPage.tsx
import { useEffect, useRef, useState } from 'react';
import { DataSet, Network } from 'vis-network';
import { getGraph } from '@/api/client';
import type { GraphResponse } from '@/types/api';
export default function GraphPage() {
const containerRef = useRef<HTMLDivElement>(null);
const [data, setData] = useState<GraphResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getGraph()
.then(setData)
.catch((e) => setError(e instanceof Error ? e.message : 'Failed'));
}, []);
useEffect(() => {
if (!data?.nodes?.length || !containerRef.current) return;
const nodes = new DataSet(data.nodes.map((n) => ({ id: n.id, label: n.username })));
const edges = new DataSet(
data.edges.map((e) => ({ from: e.start, to: e.end, arrows: 'to' }))
);
const net = new Network(containerRef.current, { nodes, edges }, { physics: { stabilization: true } });
return () => net.destroy();
}, [data]);
if (error) return <p style={{ padding: '1rem', color: 'crimson' }}>{error}</p>;
if (!data) return <p style={{ padding: '1rem' }}>Loading…</p>;
return (
<div style={{ padding: '1rem' }}>
<div ref={containerRef} style={{ width: '100%', height: '600px' }} />
</div>
);
}
arrows: 'to'로 방향을 주시면 “누가 누구를 팔로우하는지”가 한눈에 들어옵니다.
Neo4j 버전과 동일한 형태의 API(GET /api/graph → nodes, edges)를 사용하시면 같은 방식으로 시각화하실 수 있습니다.
Neo4j 문서 시리즈에서 다룬 “계정 노드 + 팔로우 관계 + 그래프 시각화”라는 도메인을, 그래프 DB 대신 Oracle + Spring Boot + React로 구현한 것이 Account Network 프로젝트입니다.
저장소는 RDB라서 ACCOUNT 테이블과 FOLLOW 테이블(From/To 외래키)로 노드·간선을 표현하고, 조회 시 JOIN으로 “전체 노드·간선 목록”을 만들어 동일한 형태의 API(GET /api/graph → nodes, edges)를 제공합니다.
프론트는 React + TypeScript + Vite + vis-network로, Neo4j 버전과 같은 방식으로 그래프를 그립니다.
도메인에는 Account, Graph만 두고, 유스케이스가 포트를 통해 어댑터를 사용합니다.
인프라 쪽에서 JPA·Oracle로 구현하고, 웹 인터페이스에서 REST와 DTO로만 통신합니다. 그래서 “계정 생성”, “팔로우 생성”, “단일 계정 조회”, “전체 그래프 조회” 같은 동작은 Neo4j 버전과 1:1로 대응되며, 차이는 “그래프 DB에서 관계를 탐색하는가” vs “RDB에서 JOIN으로 관계를 조회하는가”뿐입니다.
사용 방법은 다음과 같습니다.
POST /api/node Body { "username": "alice" }POST /api/relationship Body { "start": "alice", "end": "bob" }GET /api/graph → nodes, edges를 받아 vis-network로 시각화Oracle에 더미 데이터를 넣을 때는 아래 스크립트를 참고하시면 됩니다.
-- account-network/backend/scripts/oracle-dummy-data.sql
DELETE FROM FOLLOW;
DELETE FROM ACCOUNT;
INSERT INTO ACCOUNT (username) VALUES ('alice');
INSERT INTO ACCOUNT (username) VALUES ('bob');
INSERT INTO ACCOUNT (username) VALUES ('charlie');
INSERT INTO ACCOUNT (username) VALUES ('dev_curious_then_500_won');
INSERT INTO ACCOUNT (username) VALUES ('xxxjjhhh');
INSERT INTO ACCOUNT (username) VALUES ('eve');
INSERT INTO ACCOUNT (username) VALUES ('frank');
INSERT INTO FOLLOW (FROM_ACCOUNT_ID, TO_ACCOUNT_ID)
SELECT a.id, b.id FROM ACCOUNT a, ACCOUNT b WHERE a.username = 'alice' AND b.username = 'bob';
INSERT INTO FOLLOW (FROM_ACCOUNT_ID, TO_ACCOUNT_ID)
SELECT a.id, b.id FROM ACCOUNT a, ACCOUNT b WHERE a.username = 'alice' AND b.username = 'charlie';
-- ... (이하 start/end 조합으로 INSERT 반복)
COMMIT;
Oracle에 위와 같이 더미 데이터를 넣어 두시면, Graph 페이지에서 Neo4j 버전과 유사한 노드·화살표 그림을 보실 수 있습니다.
@Node, @Relationship으로 노드·관계를 매핑하고, 단일 노드 조회 시 following/followers를 안정적으로 채우려면 Neo4jClient + Cypher + Record → DTO 직접 매핑이 실용적입니다.10년 차쯤 되니 새로운 기술을 보면 "무조건 써보자!" 보다는 "그래서 언제, 왜 써야 하는가?"를 먼저 고민하게 됩니다.
사실 팔로우 관계의 1~2뎁스 정도는 우리가 익숙한 RDB의 JOIN과 적절한 캐싱 로직만으로도 실무에서 충분히 커버가 가능합니다.
하지만 서비스가 성장하여 "친구의 친구가 좋아하는 콘텐츠 추천"이나 "다단계의 악성 유저 봇 네트워크 탐지"처럼 N-Hop 이상의 깊은 탐색이 비즈니스의 핵심이 되는 순간, RDB의 쿼리 플랜은 기하급수적으로 무거워집니다.
이때 노드와 관계를 일급 시민으로 취급하는 Neo4j 같은 그래프 DB는 그야말로 빛을 발합니다.
이번 프로젝트에서 눈여겨볼 포인트는 단연 '포트 앤 어댑터'의 적용입니다.
도메인과 인프라를 완벽히 격리해 두었기 때문에, 초반에는 팀에게 익숙한 RDB로 빠르게 가치를 증명하고, 이후 관계 탐색의 복잡도가 높아졌을 때 비즈니스 로직의 수정 없이 저장소만 그래프 DB로 갈아끼우는 우아한 전환이 가능해집니다.
결론적으로, 은탄환은 없습니다. 하지만 '관계' 자체가 데이터의 본질이 되는 도메인을 다루고 있다면, 기술 부채를 줄이고 확장성을 확보하기 위해 아키텍처 무기함에 '그래프 DB'라는 옵션을 꼭 하나쯤 넣어두면 좋겠다 생각이 들었습니다.